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:
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.
Imap_Adapter::ping() issues one STATUS with STATUS_FORCE_REFRESH per folder — ~80 IMAP round trips per iteration.
- 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.
- 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)
- 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.
- 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.
- 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.
- 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)
- 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?
- 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.
- Was the device-wide (not folder-scoped)
SELECT DISTINCT sync_key in _gc() intentional, or would folder-scoping it be safe?
- Would field-level
$dirty handling in the SQL SyncCache backend be accepted upstream, given Mongo already implements it?
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:
Collections::pollForChanges()callsinitCollectionState()→State_Sql::loadState()for every collection, each doing: collection lock (tables()+SELECT ... FOR UPDATE),_gc()(including device-wideSELECT DISTINCT sync_keyover the map/mailmap tables), and unserialize of the full folder UID blob fromsync_data.Imap_Adapter::ping()issues oneSTATUSwithSTATUS_FORCE_REFRESHper folder — ~80 IMAP round trips per iteration.State_Sql::saveSyncCache()ignores its$dirtyparameter (the Mongo backend honors it) and logs the entire serialized blob at meta level.Exporter_Syncfetches one message perDriver::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)
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_REFRESHsemantics 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.$dirtycontract inState_Sql::saveSyncCache()(field-level merge, skip write when clean), mirroring the Mongo backend. Replace theSELECT count(*)+ UPDATE/INSERT with the portable UPDATE-first pattern (INSERT only when 0 rows affected), sinceHorde_Db(MySQL/PostgreSQL/SQLite/Oracle) has no portable upsert. Stop logging the full blob.initCollectionState()when the refreshed cache shows an unchanged synckey; add an explicit read-only ping-path state load without collection lock, withoutFOR 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.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)
sync_dataUID blob (serialization format change).horde_activesync_state,horde_activesync_mailmap,horde_activesync_cache(schema migration).Design principles
Review questions (mainly DB)
saveSyncCache()across the supportedHorde_Dbbackends, or is there a preferred Horde idiom?SELECT DISTINCT sync_keyin_gc()intentional, or would folder-scoping it be safe?$dirtyhandling in the SQL SyncCache backend be accepted upstream, given Mongo already implements it?