Repository navigation
bug(persistence): the sync outbox orders by a non-total key, so same-instant changes can replay out of order #84
Description
Found while fixing #82. Split out rather than folded in, because the fix is a SwiftData schema change and #82 is test hygiene.
The defect
The document sync outbox is a FIFO queue whose order is established by a non-total sort key.
SwiftDataDocumentStore.enqueueOutboxstampsenqueuedAt: Date()—Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataDocumentStore.swift:166SwiftDataDocumentStore.outboxEntriessorts by that field alone —…/SwiftDataDocumentStore.swift:176
FetchDescriptor<OutboxEntryRecord>(
sortBy: [SortDescriptor(\.enqueuedAt, order: .forward)]
)Two entries stamped in the same instant tie, and SortDescriptor specifies no tiebreak, so their relative order is whatever the store returns. For a queue whose entire contract is replay these changes in the order they happened, that is a correctness hole rather than a cosmetic one: .updateDocument replayed before the .createDocument it depends on, or a .deleteFolder overtaking the .renameFolder ahead of it.
The evidence that this is known, not theoretical
The tests already work around it, and say so:
try await store.enqueueOutbox(.deleteDocument(id: "first"))
// SwiftData uses Date() at enqueue — sleep briefly so timestamps differ.
try await Task.sleep(nanoseconds: 5_000_000)— Packages/InterlinedPersistence/Tests/InterlinedPersistenceTests/SwiftDataDocumentStoreTests.swift:181
and again at :183 and :246 (the latter sleeping 2 ms between each of six enqueues). Four sleeps whose only job is to keep the production sort key total. A test that has to slow the system down to make the assertion true is describing a defect in the system, not a defect in the test.
Why it has not obviously bitten
Each enqueueOutbox performs a context.save(), which costs far more than Date()'s resolution — so in practice consecutive enqueues usually do get distinct stamps. "Usually" is the problem: the failure mode is silent, data-shaped, and would present as a sync bug with no stack trace.
Fix
Add a monotonic sequence to OutboxEntryRecord and sort by it, keeping enqueuedAt as the human-facing timestamp it already is:
sortBy: [SortDescriptor(\.sequence, order: .forward)]- The column is additive with a default, so SwiftData lightweight migration covers existing stores — but confirm that against a populated store, not only an in-memory one.
- The counter must be derived from the store (
max(sequence) + 1inside the same save), not from a process-local static, or two app launches restart the numbering. - Once it lands, delete the four sleeps in
SwiftDataDocumentStoreTests— they are the regression test: if enqueuing six changes back-to-back with no delay still reads back in order, the fix works.
Acceptance
outboxEntries()returns insertion order for entries enqueued with no delay between them.- The six-change round-trip test at
SwiftDataDocumentStoreTests:246passes with zero sleeps. - An existing on-disk store opens without loss after the migration.
- BDD quartet: happy (ordered replay), invalid (unreadable payload still dropped, order of the rest preserved), upstream-failure (a failed dequeue leaves order intact), boundary (empty outbox; entries enqueued in the same millisecond).
Activity
Adron commentedon Sep 16, 2026
Implemented in PR #100. Work continues on the PR.
sequenceis derived frommax(sequence) + 1read from the store — not a process-local counter (restarts at zero next launch) and not the row count (dequeuing frees a position that an entry still waiting already holds, which puts the tie right back). Both have tests.All five workaround sleeps are deleted, and enqueuing back to back is the regression test — including a 50-entry tight loop. The persistence suite now has no wall-clock waits at all.
The on-disk reopen test matters more than it looks: an in-memory container is created fresh every time and can never exercise the migration.