feat!: Spaces repoRev/spaceRev and listRepos checkpoints #216
No reviewers
Labels
No labels
breaking-change
bug
documentation
duplicate
enhancement
good first issue
help wanted
performance
question
wontfix
No milestone
No project
No assignees
5 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Grandiras/ATProto.NET!216
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "issue-213-spaces-space-rev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #213.
Brings the Spaces sync model to the 2026-10-01 alpha (upstream bluesky-social/atproto#5574, read from
permissioned-data-alphaat79d6307).Wire changes
listReposentries are{did, repoRev, hash, spaceRev}, in ascendingspaceRevorder.cursoris an exclusivespaceRevcheckpoint: every non-empty page returns its lastspaceRev, and an empty page omits it.notifyWrite:revbecomesrepoRev. The optionalspaceRev/prevSpaceRevare set only on forwards to syncers. New errors:FutureRev(more than 5 minutes ahead) andSpaceNotFound.Authority sequencing
spaceRev: a fresh TID, or one tick past the previous one (the reference'sTID.nextStr(prev)).listReposread.repoRevat or below the recorded one is ignored and consumes no revision.Repo hosts and syncers
SpaceWriteNotifier.NotifyWriteInBackgroundcoalesces per(space, repo)and retries transient failures (network, timeout, 408/429/5xx) with jittered exponential backoff forRetryWindow(default 1 h).SpaceSyncer.NeedsCatchUpchecks for a gap viaprevSpaceRev, andListChangedReposAsyncwalkslistReposfrom a checkpoint.Breaking
Covered in the
docs/migrating-to-0.8.mdSpaces section: the renames, the store interface'sSpaceWriteSequence, old DID cursors being invalid, and the EF Core schema change with a sample migration.SpaceRevneeds an ordinal collation.Build has 0 warnings; 5001 unit tests pass, including store-contract tests on both stores (EF on SQLite) and 200 concurrent writers forming one revision chain. Not run against a live alpha PDS.
🤖 Generated with Claude Code
🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
💬 Verdict: comment — notes only, nothing blocking.
The sequencing design is sound. Allocation and the writer update are atomic per space, a stale
repoRevconsumes no revision, the checkpoint paging semantics are consistent across both stores, and docs, changelog and migration notes are thorough. I have two concerns, both about robustness rather than the happy path.RecordWriteAsyncretry (see the inline comment) may not cover the serialization failures and lock conflicts the PR description says it retries.listRepospath (inline comment) quietly returns an empty page for a pre-0.8 DID cursor instead of an error.Minor notes:
NotifyWriteInBackground: when a newer revision replaces a pending one during backoff, the worker still sleeps out the remaining delay (up to 5 minutes) before sending it. Waking early on replacement would reduce latency.🧠 claude-sonnet-5-5 · ⏱ 0m 29s · 3 turns · tokens: 71.8k in / 3.3k out (+93.7k cache-read)
📜 Full session transcript
@ -201,3 +173,1 @@if (updated > 0)return;catch (DbUpdateException) when (attempt < MaxWriteAttempts)Only
DbUpdateExceptionis retried, but the conditional claims go throughExecuteUpdateAsync, andExecuteUpdate/ExecuteDeletesurface provider exceptions unwrapped (e.g.SqliteExceptionSQLITE_BUSY on a read-then-write upgrade, or a Postgres/SQL Server serialization failure or deadlock). Those would bubble out ofRecordWriteAsyncrather than retry, despite the description saying it retries on "a serialization failure". Only theSaveChangesunique-key violation is covered. Consider also catching provider transient failures (orDbExceptionwith a retryable check), and add a short backoff between attempts, since the current retry is immediate.@ -69,0 +68,4 @@// later revision without an earlier one, and the cursor would then skip the earlier.List<KeyValuePair<Did, WriterState>> rows;lock (state.Sequence)rows = SpacePaging.After(state.Writers, entry => entry.Value.SpaceRev.Value, cursor, limit);A pre-0.8 DID cursor (or any non-TID string) is compared as an ordinal string against
SpaceRev."did:…"sorts above every TID, so the result is an empty page with no cursor. A syncer holding an old checkpoint would conclude it is fully caught up and silently miss data. The EF store behaves the same way. HaveListSpaceReposEndpointvalidatecursoras a TID and answerInvalidRequest, so stale checkpoints fail loudly instead of looking like "nothing changed".Independent adversarial review (read-only Sonnet reviewer): approve, no defects found. Unit suite: 5001 passed. A throwaway concurrency test ran 40 concurrent
RecordWriteAsynccalls on file-backed SQLite with a connection per context: each run gave 40 distinctspaceRevs with no lost writes, including the first-write insert race. The per-space lock (in memory) and the conditioned update (EF Core) both stop a reader from seeing N+1 before N. TID monotonicity, cursor exclusivity, thenotifyWritecheck order and the notifier's bounds all match the reference. Two non-blocking notes: a newer revision doesn't cut short a pending backoff delay (up to ~5 min late), andNeedsCatchUpcan trigger a harmless extra catch-up.✅ Verdict: approve — no blocking issues found.
APPROVE — the 2026-10-01 alpha
spaceRevmodel is implemented carefully and the sequencing guarantees that makelistReposcheckpoints safe hold in both stores.What I verified:
SpacePaging.CheckpointPage): every non-empty page carries its lastspaceRev, an empty one none; the EF store fetcheslimit + 1rows and the in-memory one enumerates the writer dictionary under the sequencing lock, so a page cannot silently include a revision without its predecessors.LastSpaceRevwith a conditionalExecuteUpdate, and every loss path (concurrent first insert, lost claim, writer row changed under it, rollback of an uncommitted claim) retries cleanly. A repoRev at or below the recorded one consumes no revision in both.NextSpaceRev: strictly increasing by construction (one tick pastprevwith the fresh clock id), which is the property the protocol needs.FutureRevcheck: microsecond skew math and ordering relative to auth/live-space checks are correct.CancellationToken.Nonefor fire-and-forget deliveries, and the lock pattern around_pendingare all race-safe. The in-process queue limitation is documented with a workaround.NeedsCatchUp/ListChangedReposAsync: value-equality!=via the record operator, checkpoint-resume cursor math, and walk-termination are consistent with the doc text and covered by tests.Minor notes (non-blocking):
docs/spaces.mdwritesDELETE FROM "AtProtoSpaceWriters"— double-quoted identifiers work on Postgres and SQLite but MySQL's default mode treats"..."as a string literal. Worth a one-line caveat if the EF Core module is meant to be provider-agnostic alongside the collation advice.TID.nextStrclock-id choice from here, but correctness here depends only on strict monotonicity, which holds.Test coverage is strong: store-contract tests on both stores, the 200-concurrent-writer chain test, retry/coalescing tests, and the new
FutureRev/stale-repoRev endpoint tests.⏱ 5m 39s · 11 steps · $0.0411 · tokens: 59.8k in / 1.8k out (+427.1k cache-read) · Go: 5h 2% · wk 1% · mo 91%
📜 Full session transcript
d003cf17889bf1f03500🤖 Reviewing this PR — the review will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the review runs.
🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.
📜 Live transcript — updating every ~5 minutes while the task runs.
✅ Verdict: approve — no blocking issues found.
Both points from my last pass are resolved.
RecordWriteAsyncnow catchesDbUpdateExceptionandDbException, and waits a short jittered delay before the next attempt. The attempt limit is 8. Provider-level serialization failures and busy-database errors fromExecuteUpdateare now retried.ListSpaceReposEndpointrejects a non-TID cursor withInvalidRequest, and a test covers it. A pre-0.8 checkpoint now fails loudly instead of reading as "nothing changed".The rest still looks right: allocating the revision and updating the writer are atomic per space, a stale
repoRevconsumes no revision, and theFutureRevcheck compares the same units on both sides. The docs, changelog and migration notes match the code.Non-blocking notes:
🧠 claude-sonnet-5-5 · ⏱ 0m 7s · 2 turns · tokens: 71.3k in / 763 out (+12.2k cache-read)
📜 Full session transcript
✅ Verdict: approve — no blocking issues found.
Both issues from the previous pass are fixed in this head; nothing new blocks merging.
EfCoreSpaceAuthorityStore.RecordWriteAsync): the catch now covers the provider'sDbExceptionas well asDbUpdateException, soExecuteUpdate-surfaced serialization failures, deadlocks andSQLITE_BUSYretry instead of bubbling out, and retries pause with jitter between attempts instead of hammering. The error paths from the transaction body all roll back through theawait usingscope before the loop continues.ListSpaceReposEndpointvalidates the cursor withTid.TryParseand answersInvalidRequestwith a comment explaining why a silent empty page would be worse, and a test pins the endpoint behavior.Sequencing,
CheckpointPage,NextSpaceRev, theFutureRevcheck, and the notifier retry/coalescing are unchanged since my prior approve and still hold; the contract tests, the 200-writer chain test, and the new cursor/future-rev endpoint tests all cover the changed paths.Remaining minor notes, non-blocking:
NotifyWriteInBackgroundstill sleeps out the whole backoff delay before noticing a newer revision pending; waking early would shave latency.DELETE FROM "AtProtoSpaceWriters"uses double-quoted identifiers, which are not MySQL's default; a one-line caveat would help if the module stays provider-agnostic.⏱ 7m 12s · 5 steps · $0.0083 · tokens: 10.0k in / 826 out (+188.0k cache-read) · Go: 5h 2% · wk 1% · mo 91%
📜 Full session transcript