feat(activesync): cut steady-state polling and export cost (#88) - #91
Merged
Conversation
This was referenced Jul 24, 2026
🔍 CI ResultsOverall: ❌ 12/12 lanes failed TL;DR: ❌ Quality issues: PHPStan: 243 unique errors in 11 lanes; PHP-CS-Fixer: 29 files. Summary by PHP Version
Quality Metrics
❌ Failed Lanesphp8.0-dev
php8.0-stable
php8.1-dev
php8.1-stable
php8.2-dev
php8.2-stable
php8.3-dev
php8.3-stable
php8.4-dev
php8.4-stable
php8.5-dev
php8.5-stable
CI powered by horde-components • View full results |
ralflang
approved these changes
Jul 24, 2026
Reduce the per-iteration cost of the PING/heartbeat loop and of message export while preserving foreign-client change detection: - Batch the IMAP STATUS checks for all pinged email folders into one round trip per poll iteration (LIST-STATUS where available) via the new optional Driver_Base::prefetchFolderStatus(); prefetched entries are consumed once by Imap_Adapter::ping() and fall back per mailbox. - Add a read-only state load path (loadState(..., ['readonly' => true])) used by the poll loop: no collection lock, no garbage collection, no SELECT ... FOR UPDATE. State_Sql memoizes decoded state rows per folder and synckey, so iterations with an unchanged synckey need no SQL at all. - Honor $dirty in State_Sql::saveSyncCache(): merge only dirty properties into the stored cache (per-collection granularity), UPDATE-first then INSERT, skip clean saves, and stop logging full cache blobs. Fix a missing dirty mark for bodypartprefs in SyncCache. - Prefetch upcoming email exports in batches of 10 via the new optional Driver_Base::getMessagesBulk() in Connector_Exporter_Sync; ids missing from a batch fall back to single getMessage() calls with unchanged error semantics. Every optimization is an optional hint with an explicit fallback to the previous per-item behaviour. Design document: doc/sync-performance.md. Companion change: horde/core implements prefetchFolderStatus() and getMessagesBulk() in Horde_Core_ActiveSync_Driver. Refs #88
Process tracking (review status, soak phases) does not belong in the long-term roadmap file; without it there are no open points left for the performance work. The entry lands in the tree together with the implementation, so "recently completed" is accurate on merge.
TDannhauer
force-pushed
the
feat/sync-performance
branch
from
July 24, 2026 10:01
f2dcf5d to
e9c153c
Compare
ralflang
added a commit
that referenced
this pull request
Jul 24, 2026
Release version 3.2.0 Merge pull request #91 from horde/feat/sync-performance docs(activesync): move #88 entry to recently completed in todo.md feat(activesync): cut steady-state polling and export cost (#88) feat(sync): evict ghost items via deferred synthetic deletions refactor(activesync): centralize client quirks in Device::hasQuirk() feat(activesync): persist folder UID map across FolderSync resets (#87) feat(sync): idempotent email import so retries do not duplicate IMAP (#85) (#86)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
STATUS round trip per poll iteration (instead of one per folder) and
lock-free, memoized read-only state loads (zero SQL on unchanged
synckeys).
overwrite, skip when clean), matching the Mongo backend semantics.
Driver_Base::getMessagesBulk()(10 per batch, per-id fallback).Implements the plan from #88. Companion PR: horde/Core#212.
Motivation
See #88: a device pinging a few dozen mail folders generated hundreds of
SQL statements and IMAP round trips per minute with zero changes to
report. Robustness first: every optimization is an optional hint with an
explicit per-item fallback, and foreign-client change detection is
unchanged (fresh STATUS data every iteration, prefetch consumed once).
Changes
Collections::pollForChanges(): gathers the backend folder ids of allpinged email collections and calls the new no-op-by-default
Driver_Base::prefetchFolderStatus()once per iteration; poll loadspass a new
readonlyflag throughinitCollectionState().Imap_Adapter::prefetchStatus(): single multi-mailbox STATUS(LIST-STATUS where the server supports it), consume-once in
ping(),per-mailbox fallback on errors or missing/incomplete entries.
getMessages()gains auid_keysoption.State_Base::loadState(): optionalreadonlyflag — no collectionlock, no garbage collection.
State_SqlreplacesSELECT … FOR UPDATEwith a plainSELECTand memoizes decoded state rows perfolder and synckey; any mutating load, state save, or synckey change
invalidates the memo. Column metadata from
Horde_Dbis cached pertable.
State_Sql::saveSyncCache(): honors$dirty(per-collection merge,removals included), reads the stored blob in the query that previously
only counted rows, UPDATE-first then INSERT, skips clean saves, and
logs dirty property names instead of full cache blobs.
SyncCachegets a missing dirty mark for
bodypartprefs.Connector_Exporter_Sync: prefetches up to 10 upcoming CHANGE/DRAFTitems through
getMessagesBulk()(initial-sync bare-uid listsincluded); ids missing from a batch fall back to single
getMessage()calls with unchanged error semantics (
NotFound→ drop and continue,TemporaryFailure→ abort, other errors → keep batch insync_pending).doc/sync-performance.md(problem, invariants,fallbacks, observability, deferred work); cross-references added to
architecture.md,integration.md,todo.md, andREADME.md.Expected exceptions in new catch blocks:
Horde_Imap_Client_Exception(status prefetch),
Horde_ActiveSync_Exception(folder-uid resolution inthe prefetch gather loop, bulk fetch in the exporter).
Test plan
ImapAdapterTest(prefetch consumed by ping,consume-once, per-mailbox fallback on error),
StateTest/Sql/ReadonlyLoadStateTest(lock-free load, memo hitwithout SQL, invalidation on mutating load and synckey change),
StateTest/Sql/SyncCacheSaveTest(dirty merge preserves concurrentwriters, per-collection removal, whole-property replace,
skip-when-clean),
Connector/ExporterPrefetchTest(bulk consumption,per-id fallback, no-bulk-support equivalence, initial-sync uid lists).
unrelated to this change).
SYNC/PING behaviour unchanged, no errors in device logs.
SYNC on the same folders).