fix(syncer): a behind sync clock no longer eats local edits (BEA-196) - #214
Merged
Merged
Conversation
A device whose stored lamport clock had fallen below the project wrote every op at a losing clock. journal.Less orders on lamport BEFORE wall-clock time, so replay resolved the path to a peer's older op and materialize wrote those bytes back over the file — in the same cycle that reported "remote: pushed", with a zero exit code. Silent data loss behind a success message. It was permanent because nothing ever re-derived the clock. st.Lamport was only raised from a pull's NEW tail, and pull returns nothing once a peer's journal is fully downloaded (it skips an object no larger than the local copy, and one whose bytes are identical). So however many times the device synced, it stayed behind. The cycle now opens by folding every op already in the volume's journal dir through absorbLamport — a derivation, not a fix at one site, because any desync produces this: a crash between AppendOps and SaveSync, a restored backup, a pull that died on a 403. It is folded through absorbLamport rather than taken as a raw max so the maxLamport ceiling still stops a hostile peer installing a write lock, and it runs AFTER `joining` is read so adoption is bit-for-bit unchanged. The ErrForbidden arm of the pull returned before the absorb that sat below it, while having already written the peer journals it did get and then committing local ops under them — a second, independent route in. The absorb moved above the switch. Conflict copies no longer require a pull. A local op can lose to an op already on this disk — a peer op above maxLamport, which absorbLamport refuses by design — and a settled peer journal yields nothing on every later cycle, so that loss was reverted with no copy and no message, forever. The candidate set is every unpushed local op replay did not resolve to, deduped on the copy's own path since a losing op stays unpushed and is re-examined every cycle. Telling the user, since every surface said "healthy" throughout: * `restore` re-reads the file after the cycle it ends with and exits non-zero if the bytes are not the version asked for. The success line is what an agent trusts, so it is checked rather than assumed. * `restore --list` marks exactly one row `*`. It starred every op sharing the current blob, and restoring puts old bytes back under a new op — so a restored file showed two starred rows and read as two competing heads. That is what sent the original diagnosis to "duplicate head records per path". * `status` names a behind clock. `pending` is zero by construction after a push and Drift compares disk against the cache materialize just made agree; both were correct and neither could express this. Not done, deliberately: the acceptance criterion asking that an out-of-band delete be restored by the next sync contradicts the delete design — scan journals a delete for every cached path the walk did not see, which is how deleting a file deletes it for the team. And the spec's Fault B (materializeFile trusting the cache before stat'ing) is not reachable on main: scan drops the cache entry whenever it journals a delete, and the one path that keeps it — a read-only folder — is already handled by readOnlyDrifted, with TestReadOnlyFolderRestoresA LocalDelete covering it. An unconditional stat per path per cycle would be cost with no bug behind it. Co-Authored-By: Claude Opus 5 (1M context) <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.
TL;DR
bdrive synccould reportremote: pushedfor a file, exit 0, and revert that file to the old bytes in the same run. An agent reports the task done; the work is gone. That's fixed.restorenow exits non-zero if the file doesn't hold the version you asked for, instead of printing a success line naming one it doesn't.restore --listmarks exactly one row*. Starring every op that shared the current blob is what made this look like "duplicate head records per path" — it was one head rendered twice.The fault
journal.Lessorders on lamport before wall-clock time. So an op written today at a low lamport loses to a peer's op from this morning at a high one. Replay resolves the path to the peer's version, and materialize writes it back over your file — in the same cycle that really did push yours.It was permanent because of where the clock was raised.
st.Lamportwas only ever raised from a pull's new tail, andpullreturns nothing once a peer's journal is fully downloaded: it skips an object no larger than the local copy (o.Size <= localSize) and one whose bytes are identical (bytes.Equal). So a device whosesync.jsonsat below the journals in its own volume dir never caught up, however many times it synced.The cycle now opens by folding every op already in the volume's journal dir through
absorbLamport. A derivation, not a fix at one site, because any desync produces this state: a crash betweenAppendOpsandSaveSync, a restored backup, a pull that died on a 403. Two things it is careful about:absorbLamport, not taken as a raw max — a journal on disk holds a peer's bytes, and themaxLamportceiling is what stops a hostile peer installing a permanent write lock.joiningis read.joininggates the adoption path, so deriving first would turn a device whose first cycle crashed into a non-joiner and change whatbdrive initadopts.Second route in: the
ErrForbiddenarm of the pull returned before the absorb loop that sat below it — having already written the peer journals it did get, and then committing local ops under them. The absorb moved above the switch.Third: conflict copies required a pull. A local op can lose to an op already on this disk — a peer op above
maxLamport, whichabsorbLamportrefuses by design — and a settled peer journal yields nothing on every later cycle, so that loss was reverted with no copy and no message, forever. The candidate set is now every unpushed local op that replay didn't resolve to, deduped on the copy's own path (a losing op stays unpushed and is re-examined every cycle, so without the dedupe it re-journals the same bytes forever).Telling the user
Every surface said "healthy" throughout the incident, and each for a correct reason:
statuspendinglen(myOps) - PushedOps— zero by construction after a successful pushwarning:line when the clock is below the local journalsstatuslocal(Drift)restoresuccess linerestore --list*Testing
Every fix has a test that was verified to fail first — I wrote them against unmodified code and confirmed each reproduces, including reverting individual fixes to check the test catches it:
TestBehindClockConverges— the reported loss verbatim: before the fix,LocalOps=1 PulledOps=0 Materialized=1and the file holds the peer's old bytes.TestBehindClockIsRederivedFromLocalJournals— asserts the clock comes back withPulledOps == 0, i.e. from disk and nothing else.TestForbiddenPullStillAbsorbsTheClock— re-verified against a temporary revert of that hunk:clock 0 is below the ops the aborted pull already wrote to disk (1).TestLosingEditIsPreservedWithoutAPull— the unabsorbable-lamport case, plus a second cycle asserting no duplicate copy.TestListMarksExactlyOneCurrentRow— re-verified against a temporary revert: prints two*rows, the reporter's "two head records" exactly.TestRestoreReportsWhatIsActuallyOnDisk,TestStatusNamesABehindClock— real cobra commands over a real folder.go test ./...green (full suite, includinginternal/webapp). The acceptance criterion'sgo test ./internal/syncer/... ./cmd/bdrive/...is green. The whole existing syncer suite — adoption, conflict, merge, read-only, and the security suites — passes unchanged, which is the signal that mattered for a change to clock semantics.Two things in the spec I did not implement
1. "An out-of-band delete is restored by the next sync, reporting
files updated: 1." This contradicts the delete design:scanjournals a delete for every cached path the walk did not see, which is how deleting a file locally deletes it for the team. I wrote this test, watched it fail, and traced the failure to the scan correctly minting a delete. Implementing the criterion as worded would mean a locally deleted file coming back — I'm not making that call in a bug fix. Needs a product decision; the clock fix stops new occurrences of the stuck-subtree symptom, andbdrive restorerecovers an existing one.2. Fault B —
materializeFiletrusting the cache beforeos.Stat. Not reachable on current main.scandrops the cache entry whenever it journals a delete, so cache-says-present + file-absent can't survive a scan — except on a read-only folder, where the entry is deliberately kept, and that path is already handled byreadOnlyDriftedwithTestReadOnlyFolderRestoresALocalDeletecovering it. An unconditional stat per path per cycle (the daemon ticks every 3s) would be cost with no bug behind it.Both are written up on the issue rather than shipped around.
Architecture changes
No types, fields, seams or relationships changed — the fix is behaviour inside
cycleLockedandconflictCopies. Butarchitecture/cli-sync.mddraws the canonical cycle sequence in a note, and the cycle gained a step at the front, so that note is updated (and a second note added for why). Excerpt of the change:flowchart TB Session["<div style='text-align:left'><b>Session</b><br/>-cycleLocked(ctx) Result<br/>-conflictCopies(...)<br/>-pull(ctx, cache)</div>"] Seq["<div style='text-align:left'>internal/syncer — <span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ re-derive the clock →</span> scan → commit local ops →<br/>pull peer journals → adopt on join → re-assert withdrawn ops →<br/>preserve conflicts → refresh rules → prune → materialize →<br/>push blobs then own journal</div>"] Why["<div style='text-align:left'>✅ The cycle OPENS by folding every op already in this volume's<br/>journal dir through absorbLamport. Nothing else re-derives<br/>st.Lamport — the pull absorbs only its NEW tail, and a settled<br/>peer journal yields none — so a device whose sync.json fell below<br/>the journals beside it stayed there forever, and since Less orders<br/>on lamport before time, every op it wrote lost replay (BEA-196).<br/>Derived AFTER `joining` is read, or adoption changes.</div>"] Session -.- Seq Session -.- Why classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2 class Seq noteBox class Why addedDocs updated:
README.md(restore + status rows) andweb/docs/.../reference/cli.md(restore's new failure mode, thewarning:line).Closes BEA-196.
🤖 Generated with Claude Code