fix(persistence): order the sync outbox by a monotonic sequence, not by a timestamp - #100
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
enqueuedAtalone — a non-total key. Two entries stamped in the same instant tie,SortDescriptorspecifies no tiebreak, and their relative order was whatever the store returned.For a document-sync queue that is a data-shaped failure: an
.updateDocumentreplayed before the.createDocumentit depends on, or a.deleteFolderovertaking the.renameFolderahead of it. Silent, and with no stack trace.The tests already knew
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
0and 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.enqueuedAtstays, 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 failuresswift test InterlinedKit --skip ContractTests→Executed 477 tests, with 0 failuresNew 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