Skip to content

bug(persistence): the sync outbox orders by a non-total key, so same-instant changes can replay out of order #84

Description

@Adron

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.enqueueOutbox stamps enqueuedAt: Date() — Packages/InterlinedPersistence/Sources/InterlinedPersistence/Stores/SwiftDataDocumentStore.swift:166
  • SwiftDataDocumentStore.outboxEntries sorts 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) + 1 inside 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:246 passes 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions