Skip to content

RFC: Email sync performance — heartbeat loop state reloads, SyncCache writes, export batching #88

Description

@TDannhauer

Summary

An analysis of the email sync path (Horde_ActiveSync + Horde_Core_ActiveSync_Driver) identified several structural performance bottlenecks. This issue documents the findings and a proposed, robustness-first optimization plan before implementation starts. Review would be appreciated, especially of the database-related changes — pinging @ralflang.

Observations (production deployment, Dovecot backend, device with ~80 pinged mail collections)

During every PING/hanging-SYNC poll iteration (default 15 s wait interval), per device:

  1. Collections::pollForChanges() calls initCollectionState()State_Sql::loadState() for every collection, each doing: collection lock (tables() + SELECT ... FOR UPDATE), _gc() (including device-wide SELECT DISTINCT sync_key over the map/mailmap tables), and unserialize of the full folder UID blob from sync_data.
  2. Imap_Adapter::ping() issues one STATUS with STATUS_FORCE_REFRESH per folder — ~80 IMAP round trips per iteration.
  3. The SyncCache blob (all collections + folder map) is fully unserialized 2–3x and fully rewritten per save; State_Sql::saveSyncCache() ignores its $dirty parameter (the Mongo backend honors it) and logs the entire serialized blob at meta level.
  4. During actual message export, Exporter_Sync fetches one message per Driver::getMessage() call — a structure/envelope FETCH plus a separate body FETCH per message, so a 100-message window costs ~200 sequential IMAP round trips.

With two concurrent heartbeat requests (PING + hanging SYNC) this doubles. Net effect: the server spends most of its time re-proving "no changes".

Proposed changes (execution order = ascending risk)

  1. Batched folder status: once per poll iteration, prefetch fresh status for all pinged mail folders via a single Horde_Imap_Client_Base::status(array, ...) call, which uses one LIST-STATUS (RFC 5819) round trip where advertised; ping() consumes the prefetched entry. STATUS_FORCE_REFRESH semantics are kept — data is fresh from the server each iteration, so changes from other clients are still detected. Non-LIST-STATUS servers keep current per-mailbox behavior.
  2. SyncCache dirty writes (DB review focus): implement the $dirty contract in State_Sql::saveSyncCache() (field-level merge, skip write when clean), mirroring the Mongo backend. Replace the SELECT count(*) + UPDATE/INSERT with the portable UPDATE-first pattern (INSERT only when 0 rows affected), since Horde_Db (MySQL/PostgreSQL/SQLite/Oracle) has no portable upsert. Stop logging the full blob.
  3. Poll-loop state reuse (DB review focus): skip initCollectionState() when the refreshed cache shows an unchanged synckey; add an explicit read-only ping-path state load without collection lock, without FOR UPDATE, and without _gc() (locks/GC remain on the mutating SYNC path). Safety invariant: any anomaly (cache validation failure, stale request, load error) forces a full reload — worst case degrades to current behavior.
  4. Export batching: prefetch bounded message batches (10–25 UIDs) via the existing multi-UID Imap_Adapter::getMessages() instead of one fetch per change, with per-message error isolation and single-UID fallback. WBXML streaming, windowing, MOREAVAILABLE, and heartbeat behavior unchanged.

Deferred (higher structural risk, smaller returns)

  • Watermark-only PING saves / shrinking the sync_data UID blob (serialization format change).
  • Composite indexes for horde_activesync_state, horde_activesync_mailmap, horde_activesync_cache (schema migration).
  • Structure+body FETCH merge, dirty-only device saves, maillog Message-ID search batching.

Design principles

  • Never trade change detection for speed; every optimization degrades to current behavior on any doubt.
  • Explicit fallbacks per item; capability-gated paths keep current code as fallback.
  • One item per commit with tests, verified against the package PHPUnit suite and live device logs before the next lands.

Review questions (mainly DB)

  1. Is the UPDATE-first (INSERT on 0 affected rows) pattern acceptable for saveSyncCache() across the supported Horde_Db backends, or is there a preferred Horde idiom?
  2. Any objection to a lock-free/GC-free read-only state load for the PING poll path? The current lock acquisition on a read-only peek serializes against parallel SYNCs; dropping it should only ever delay detection by one iteration.
  3. Was the device-wide (not folder-scoped) SELECT DISTINCT sync_key in _gc() intentional, or would folder-scoping it be safe?
  4. Would field-level $dirty handling in the SQL SyncCache backend be accepted upstream, given Mongo already implements it?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions