P2-6: ETag-first change detection and lazy hashing #26
Labels
No labels
data-integrity
engine
platform
procurement
remote
scaffold
ui
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/cairn-desktop#26
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Depends on P2-3 and P2-5.
The problem this fixes
Task 12 built the engine to hash both sides on every pass. For the remote, that means downloading every file on every sync — correct, but unusable against a real server.
Goal
Decide what changed using cheap signals, and only hash when the cheap signals are ambiguous — without weakening the guarantee that ambiguity never causes a delete.
Files
internal/sync/engine.gointernal/sync/detect.go,internal/sync/detect_test.goThe rules
Remote side — ETag is authoritative. The
Remotecontract (Task 6) guarantees the ETag changes whenever content changes. So:remote.ETag == last.RemoteETag→ unchanged. Do not download. Do not hash. LeaveSide.Hashempty.Local side — mtime plus size is the cheap signal.
mtime == last.LocalMtime && size == last.LocalSize→ assume unchanged, reuselast.ContentHash. No read.Hash only when the decision needs it.
Decideneeds a hash on both sides only to distinguish "converged independently" from "genuine conflict" — the both-changed case. Compute lazily at that point, not up front.The trap to avoid
Do not let this optimisation reintroduce a delete-on-ambiguity path.
sameContentstill requires both hashes to be known and equal. If a hash is unavailable, the outcome must be a conflict copy or a transfer — never a delete. Task 8'sTestDecideNeverDeletesOnUnknownHashmust still pass untouched.Steps
Remote.Getduring a pass (instrument a countingRemotewrapper)OpUpdateState, not an uploadTestConvergence. It must still pass with 50 seeds. If it does not, the optimisation is wrong — fix it, do not weaken the test.git commit -s -m "perf(sync): ETag-first change detection with lazy hashing"Acceptance criteria
TestConvergencestill passes at 50 seeds.TestDecideNeverDeletesOnUnknownHashis unmodified and passing.Amendment — 2026-09-10: files-on-demand is now in scope
Virtual files moved to a planned phase (
phase-4-virtual-files). This task is now load-bearingfor that feature, not merely a performance optimisation.
The rule to add
A placeholder can never have been modified locally. You cannot edit a file whose bytes are
not on disk — the OS hydrates it first, which makes it a normal file. So:
Three consequences, each worth a test:
Hydrated == false. Hashing forces a download. A sync pass thathashes every placeholder hydrates the entire tree — the exact opposite of what
files-on-demand is for. This is the single most destructive mistake available in this task.
unchanged rather than uploading content you would have to fetch first.
hydrated, or the placeholder is simply updated in place, depending on the provider.
Why this lands here rather than in phase 4
Decide(Task 8) stays pure and unchanged — the caller decides whatSide.Hashto present.That is exactly the lazy-hashing responsibility this issue already owns for the
mtime-and-size-unchanged case. Placeholders are the same pattern with a different signal.
Keeping the branch here means phase 4 adds OS integration only, with no changes to the engine's
decision logic. That is what makes the phase-4 work bounded.
Also
Add the placeholder case to this issue's test list: a sync pass over a tree of placeholders
must perform zero content reads and produce zero operations.
Note — 2026-09-11: where the placeholder rule lands (final review X5, carry-forward to #44)
The files-on-demand amendment above states the rule. This note records where phase 1 left the
code, so the rule lands in the right place.
Engine.observeininternal/sync/engine.gois the change-detection layer that spec §4refers to. Today it ignores
vfs.FileInfo.Hydratedand hashes every local file. That isharmless in phase 1, because
MemFSalways reportsHydrated: true.observehere must never open a file withHydrated == false. Itpresents such a file as locally unchanged by reusing the row's
ContentHash.should not arise, since a placeholder is created from a synced server entry, but it must not
be hashed either.
699ec0a(fix(sync): re-check a copy against the scan before replacing or removing it), compares alocal file's size, mtime and FileID only. It deliberately ignores
Hydrated, becausehydration is not a modification (#31 amendment). Keep it that way.
Done
602b80ac38— perf(sync): ETag-first change detection with lazy hashing81e9446ecb— fix(sync): set a row's HashedAt back 2 s so coarse mtimes never vouch for an editWhat was built
detect.go: ETag-first remote check (noGetwhen the ETag is unchanged) and an mtime+size local shortcut (no read when unchanged, reusing the row's hash); the server copy is hashed only onOpConflict.Hydrated == falseis never opened or hashed;put/conflictrefuse to upload one (Skip); a renamed placeholder becomes one MOVE instead of losing its server copy.OpUpdateState, never an upload; a racy re-read is refreshed silently so it isn't re-read forever.mtimeSlackmargin onHashedAtso FAT's 2 s truncation and coarse kernel clocks can't hide a same-size in-place edit;TestConvergence's body became a shared runner plusTestConvergenceOnATestClock, since the wall-clock property test alone vouched only 2 times in 1611 calls.Tests
internal/remote,state,sync,vfs(CGO_ENABLED=0 go test ./..., plus-racelocally per P2-R7).TestConvergenceat 50 seeds (500 once, clean);TestDecideNeverDeletesOnUnknownHashuntouched; 11+ mutations killed (report's tables).81e9446, linux/arm64 — green. Coveragetotal: 90.3%.Acceptance criteria
TestSyncNoOpPassReadsNothing(0 Open, 0 Get over 100 files).TestConvergencepasses at 50 seeds → yes (500 once).TestDecideNeverDeletesOnUnknownHashunmodified and passing → yes,decide.gountouched.Get→TestSyncNeverDownloadsAServerCopyWhoseETagIsUnchanged.TestSyncNeverReadsALocalFileWhoseMtimeAndSizeAreUnchanged.OpUpdateState, not upload →TestSyncTouchedFileWithTheSameContentIsAStateUpdate.TestSyncReadsBothCopiesOnlyWhereBothChanged.TestSyncNeverReadsAPlaceholderand its subtests; a tree of placeholders → zero reads, zero ops.Rulings
-s, Co-Authored-By trailer.mtimeSlackmargin (review F1). Residual, for the record:Deferred
hashed_at(state.go:54) —CREATE TABLE IF NOT EXISTSleaves an older table without the column.rename.go:44) — carried into #44.describesduplicatesvouches' FileID check with plain==instead ofknownEqual(detect.go:150).TestConvergenceitself now vouches 0/1578 times (its wall-clock writes fall inside the 2 s margin); the shortcut is exercised only byTestConvergenceOnATestClock, by design.Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.