Skip to content

fix(persistence): order the sync outbox by a monotonic sequence, not by a timestamp - #100

Merged
Adron merged 2 commits into
devfrom
fix/outbox-fifo-ordering
Sep 17, 2026
Merged

Adron merged 2 commits into
devfrom
fix/outbox-fifo-ordering

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Closes #84.

The outbox is a FIFO whose entire contract is replay these changes in the order they happened, and it was ordered by enqueuedAt alone — a non-total key. Two entries stamped in the same instant tie, SortDescriptor specifies no tiebreak, and their relative order was whatever the store returned.

For a document-sync queue that is a data-shaped failure: an .updateDocument replayed before the .createDocument it depends on, or a .deleteFolder overtaking the .renameFolder ahead of it. Silent, and with no stack trace.

The tests already knew

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)

Five sleeps across two files whose only job was to keep the production sort key total. A test that has to slow the system down to make its assertion true is describing a defect in the system.

All five are deleted, and enqueuing back to back with no delay is now the regression test — including a 50-entry tight loop, which timestamp resolution cannot be relied on to separate.

Design notes

max(sequence) + 1, read from the store — not a process-local counter. A counter restarts at zero on the next launch and interleaves new entries among old ones. There is an on-disk reopen test for this, because an in-memory container is created fresh every time and can never exercise a migration.

From the max, not the row count. Dequeuing removes rows, so a count-based sequence would reissue a position already held by an entry still waiting behind it — putting the tie right back. There is a test that dequeues from the middle and then enqueues more.

Additive with a default, so SwiftData's lightweight migration opens an existing store. Rows written before it arrive as 0 and sort ahead of everything new, which is correct — they are older. Their order relative to each other is whatever it already was; this cannot retroactively recover an order that was never recorded, and the comment says so rather than implying it can.

enqueuedAt stays, documented as the human-facing timestamp it is rather than the sort key it was being used as.

Also

The last fixed sleep in DocumentSyncEngineTests (a flat 50 ms wait for an event collector) is converted to post-condition polling — the same lesson as #82. The persistence suite now has no wall-clock waits at all.

Verification

  • xcodebuild build → ** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 968 tests, with 0 failures · ** TEST SUCCEEDED **
  • swift test InterlinedPersistence → Executed 147 tests, with 0 failures (was 140)
  • swift test InterlinedDomain → Executed 1012 tests, with 0 failures
  • swift test InterlinedKit --skip ContractTests → Executed 477 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

New tests (7): same-instant ordering at 50 entries · strictly-increasing sequences · dequeue-from-the-middle · fully-drained-then-refilled · a failed push keeping its place · create-then-update dependency order · on-disk reopen continuing the sequence.

Note

Includes a merge of #99 (fix/xcode27-document-ambiguity), without which the App target does not build on Xcode 27. Merge #99 first and this reduces to the persistence change alone.

🤖 Generated with Claude Code

Adron and others added 2 commits September 16, 2026 12:45
…Xcode 27

`dev` stopped building when Xcode was updated on this machine mid-session. The
macOS 27 SDK adds a `Document` protocol to SwiftUI —

    public protocol Document: ReadableDocument, WritableDocument

— which collides with the domain's `Document` struct in any file importing both.
That is every documents-feature view, and the same unchanged source went from
compiling to eleven `'Document' is ambiguous for type lookup` errors across eight
files.

Only the SwiftUI-importing files are affected, which is what makes the diagnosis
unambiguous: the view models import Foundation, Observation and InterlinedDomain
but not SwiftUI, and they compile untouched.

The fix is to qualify the type at the use sites. Three alternatives were
considered and rejected. Renaming the domain model is the tail wagging the dog —
`Document` is the right name, and it is correct across Kit, Domain, Persistence
and their tests. A module-level typealias would shorten the use sites at the cost
of giving one concept two names, so the next reader has to learn they are the
same thing. Dropping `import SwiftUI` is not available; these are views.

Every edit is a type position. No user-facing string, accessibility label or
other identifier containing the word Document is touched — the diff is eleven
lines, each one a `Document` that the compiler itself pointed at.

Worth knowing rather than fixing: this is a standing hazard. Any domain type
sharing a name with a SwiftUI symbol is one SDK update away from the same break,
and the diagnosis is written down at the top of DocumentsListView so the next
occurrence takes minutes.

Refs #98

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…by a timestamp

The outbox is a FIFO whose entire contract is "replay these changes in the order
they happened", and it was sorted by `enqueuedAt` alone — a non-total key. Two
entries stamped in the same instant tie, `SortDescriptor` specifies no tiebreak,
and their relative order was whatever the store happened to return. For document
sync that means an `.updateDocument` replayed before the `.createDocument` it
depends on, or a `.deleteFolder` overtaking the `.renameFolder` ahead of it.

The tests already knew. Three of them slept between enqueues, commented
"SwiftData uses Date() at enqueue — sleep briefly so timestamps differ." A test
that has to slow the system down to make its assertion true is describing a
defect in the system, not a defect in the test. Those five sleeps are deleted,
and enqueuing back to back with no delay is now the regression test.

`sequence` is derived from `max(sequence) + 1` read from the store, not from a
process-local counter: a counter restarts at zero on the next launch and
interleaves new entries among old ones. It is computed from the max rather than
the row count because dequeuing removes rows — a count would reissue a position
already held by an entry still waiting behind it, which puts the tie right back.
There is a test for exactly that, and an on-disk reopen test, because an
in-memory container is created fresh every time and can never exercise a
migration.

The field is additive with a default so SwiftData's lightweight migration opens
an existing store. Rows written before it arrive as 0 and sort ahead of
everything new, which is correct — they are older. Their order relative to each
other is whatever it already was; this cannot retroactively recover an order
that was never recorded.

`enqueuedAt` stays, and is documented as the human-facing timestamp it is rather
than the sort key it was being used as.

Also converts the last fixed sleep in DocumentSyncEngineTests to post-condition
polling, so the persistence suite has no wall-clock waits left at all — the same
lesson as #82.

Refs #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant