test(sync): interleaving tests for the ungated sync↔backfill and sync↔prune pairs #191

Merged
JMR-dev merged 2 commits from test-53-sync-concurrency into main 2026-07-03 02:14:46 +00:00
JMR-dev commented 2026-07-03 02:00:51 +00:00 (Migrated from github.com)

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

## 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)
Sign in to join this conversation.