Adds MailSyncConcurrencyTest — JVM interleaving tests for the two sync↔maintenance pairs that MailMaintenanceGate deliberately does not serialize (issue #53). MailMaintenanceGate locks only backfill↔prune (already asserted by MailMaintenanceGateTest); sync↔backfill and sync↔prune are ungated because foreground sync uses its own syncMutex to stay UI-responsive. Their safety rested entirely on a disjoint-by-UID argument with no test exercising it.
A real MailSyncer and a real MailBackfiller/MailPruner are wired to one shared in-memory message store (a thread-safe fake reproducing each MessageDao query's exact predicate). Mirroring MailMaintenanceGateTest, CompletableDeferred gates park one actor mid-critical-section — inside its IMAP fetch, or (for the IMAP-less pruner) inside its retention read — while the other's whole critical section runs. Every interleaving is driven to the boundary (window edge / count floor) where an overlap would surface.
Interleavings covered
sync↔backfill, delete-vs-mid-write — a backfill has written the page just below the window and is parked mid-fetch of the next (older) page; a full foreground sync runs. Seeded so the cache is the window alone (lowestSyncedUid == minWindowUid == 21, the tightest edge). Asserts sync's windowed deleteSyncedInWindowNotIn spares every below-window backfilled row.
sync↔backfill, stale-boundary ordering — a backfill reads its boundary and parks in the fetch; a full sync of the window completes first; the backfill's page still lands strictly below the window, and sync leaves the already-backfilled history intact.
sync↔prune, prune-while-sync-parked — a count-retention prune (floor = 10) runs to completion while sync is parked in its fetch. Asserts prune deletes exactly the below-floor rows (UIDs 1..20) and sync neither resurrects a pruned row nor deletes a kept one — the window (21..30) is exactly the pruner's kept set.
sync↔prune, sync-while-prune-parked — a full sync runs while a prune is parked inside its critical section (before it touches the message table). Asserts sync touches only its window and the resumed prune deletes exactly the below-floor rows.
Result
The invariant holds. All four tests pass — under every adversarial ordering, sync writes/reconciles only uid >= minWindowUid, backfill only writes strictly below it, and count-prune only deletes below the floor sync's recentWindowFor caps the window to. No lost, duplicated, or wrongly-deleted rows. No production code changed — this is defence-in-depth / regression protection, and the test class KDoc documents which functions maintain the disjoint-window invariant.
Fast CI gate green locally (JDK 21): testDebugUnitTest, lintDebug, ktlintCheck, detekt, compileDebugAndroidTestKotlin.
## What
Adds `MailSyncConcurrencyTest` — JVM interleaving tests for the two sync↔maintenance pairs that `MailMaintenanceGate` deliberately does **not** serialize (issue #53). `MailMaintenanceGate` locks only **backfill↔prune** (already asserted by `MailMaintenanceGateTest`); **sync↔backfill** and **sync↔prune** are ungated because foreground sync uses its own `syncMutex` to stay UI-responsive. Their safety rested entirely on a *disjoint-by-UID* argument with no test exercising it.
A real `MailSyncer` and a real `MailBackfiller`/`MailPruner` are wired to **one shared in-memory message store** (a thread-safe fake reproducing each `MessageDao` query's exact predicate). Mirroring `MailMaintenanceGateTest`, `CompletableDeferred` gates park one actor mid-critical-section — inside its IMAP fetch, or (for the IMAP-less pruner) inside its retention read — while the other's whole critical section runs. Every interleaving is driven to the **boundary** (window edge / count floor) where an overlap would surface.
## Interleavings covered
1. **sync↔backfill, delete-vs-mid-write** — a backfill has written the page *just* below the window and is parked mid-fetch of the next (older) page; a full foreground sync runs. Seeded so the cache is the window alone (`lowestSyncedUid == minWindowUid == 21`, the tightest edge). Asserts sync's windowed `deleteSyncedInWindowNotIn` spares every below-window backfilled row.
2. **sync↔backfill, stale-boundary ordering** — a backfill reads its boundary and parks in the fetch; a full sync of the window completes first; the backfill's page still lands strictly below the window, and sync leaves the already-backfilled history intact.
3. **sync↔prune, prune-while-sync-parked** — a count-retention prune (floor = 10) runs to completion while sync is parked in its fetch. Asserts prune deletes exactly the below-floor rows (UIDs 1..20) and sync neither resurrects a pruned row nor deletes a kept one — the window (21..30) is exactly the pruner's kept set.
4. **sync↔prune, sync-while-prune-parked** — a full sync runs while a prune is parked inside its critical section (before it touches the message table). Asserts sync touches only its window and the resumed prune deletes exactly the below-floor rows.
## Result
**The invariant holds.** All four tests pass — under every adversarial ordering, sync writes/reconciles only `uid >= minWindowUid`, backfill only writes strictly below it, and count-prune only deletes below the floor sync's `recentWindowFor` caps the window to. No lost, duplicated, or wrongly-deleted rows. **No production code changed** — this is defence-in-depth / regression protection, and the test class KDoc documents which functions maintain the disjoint-window invariant.
Fast CI gate green locally (JDK 21): `testDebugUnitTest`, `lintDebug`, `ktlintCheck`, `detekt`, `compileDebugAndroidTestKotlin`.
Closes #53
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Adds
MailSyncConcurrencyTest— JVM interleaving tests for the two sync↔maintenance pairs thatMailMaintenanceGatedeliberately does not serialize (issue #53).MailMaintenanceGatelocks only backfill↔prune (already asserted byMailMaintenanceGateTest); sync↔backfill and sync↔prune are ungated because foreground sync uses its ownsyncMutexto stay UI-responsive. Their safety rested entirely on a disjoint-by-UID argument with no test exercising it.A real
MailSyncerand a realMailBackfiller/MailPrunerare wired to one shared in-memory message store (a thread-safe fake reproducing eachMessageDaoquery's exact predicate). MirroringMailMaintenanceGateTest,CompletableDeferredgates park one actor mid-critical-section — inside its IMAP fetch, or (for the IMAP-less pruner) inside its retention read — while the other's whole critical section runs. Every interleaving is driven to the boundary (window edge / count floor) where an overlap would surface.Interleavings covered
lowestSyncedUid == minWindowUid == 21, the tightest edge). Asserts sync's windoweddeleteSyncedInWindowNotInspares every below-window backfilled row.Result
The invariant holds. All four tests pass — under every adversarial ordering, sync writes/reconciles only
uid >= minWindowUid, backfill only writes strictly below it, and count-prune only deletes below the floor sync'srecentWindowForcaps the window to. No lost, duplicated, or wrongly-deleted rows. No production code changed — this is defence-in-depth / regression protection, and the test class KDoc documents which functions maintain the disjoint-window invariant.Fast CI gate green locally (JDK 21):
testDebugUnitTest,lintDebug,ktlintCheck,detekt,compileDebugAndroidTestKotlin.Closes #53
🤖 Generated with Claude Code