Guard snapshot hash reuse against unsettled mtime buckets - #111
Conversation
in-jun
left a comment
There was a problem hiding this comment.
Thanks for taking this on — the #44 side is fully covered and the surrounding safety choices are right. But I don't think the #51 path is actually closed, and I'd like to sort that out before we merge.
The guard measures now - mtime > granularity against the current scan clock. The miss #51 describes, though, is a same-size edit that lands in the mtime bucket after the hint was recorded. Once that stale (size, mtime, hash) is committed to the ancestor, every later scan sees the mtime comfortably in the past, reads "settled", and reuses the stale hash — permanently, which is exactly the failure the issue warns about.
Concrete timeline (2 s bucket):
12:00:00.4file rewritten to C1 (size S, mtime truncates to12:00:00)12:00:00.6a sync scans, hashes C1, commits ancestor(S, 12:00:00, H1)12:00:01.3file rewritten to C2, same size S, mtime still12:00:0012:15:00next sync:now - mtimeis ~15 min> 2 s→ settled → size+mtime match → reuseH1. C2 never propagates, and stays that way until a later edit happens to shift size or mtime.
The granularity window that matters is the one around when the hint was captured, not the one around the current scan. Anchoring to now only catches the case where the recovering scan itself falls inside the window (a prompt re-scan right after the racy edit); a scan a window or more later — the normal cadence, and the one that leaves the loss permanent — reads the same stale pair as settled. The doc comment's framing ("a same-size edit could still land unseen in the same bucket") is forward-looking from now, but the edit that gets missed is one that already landed since the hint was written.
The usual way to close this fully is to anchor the check to the hint's own capture time (git's racy-clean compares the entry mtime against the index/snapshot timestamp, not the wall clock): record the scan time alongside the ancestor stat and treat a hash as reusable only once its recorded mtime was already older than that capture time by the granularity — equivalently, mark any entry scanned while its mtime is within the window of that scan as non-reusable, so the following pass re-hashes it once. Either keeps a single durable timestamp per record and closes the persistent-stale case.
Everything else looks good to me: the fake now honors the hint with a hash counter, SnapshotBuilderTest pins the reuse/re-hash matrix cleanly, the executor test asserts reuse on an unchanged file, and disabling reuse on an unknown server clock plus re-hashing future-dated mtimes are both the safe direction. No regressions or scope creep — the remaining gap is just the anchor point of the settle check.
|
Good catch on the #51 path — you're right that the now-anchored guard only recovers the race when the recovering scan itself falls inside the window, which the normal cadence never does. I reproduced it against Fixed by anchoring the second half of the reuse test to the hint's own capture rather than the current clock (git's racily-clean approach):
Added a |
|
The rework closes the path I flagged before, and the coverage around it is convincing. The persisted One gap remains, on the write-commit path. Concrete repro (2s bucket, remote side of a push):
The fix mirrors what the scan path already does: |
Reuse a scanned file's cached hash only once its mtime has settled past a filesystem-granularity window; within it a same-size edit can land in the same coarse mtime bucket and silently keep the stale hash. Remote scans pass server time and disable reuse when the server clock is unknown. Cover the reuse-vs-rehash matrix in a new SnapshotBuilderTest and, via a scan hash counter on the fake, assert the executor serves unchanged files from the hint.
The settle guard re-hashed an entry whose mtime was within the granularity window of the CURRENT scan, but that only catches an edit made before the scan. It missed the #51 case where a same-size edit lands in the mtime bucket AFTER a hint was already recorded: once that stale (size, mtime, hash) is committed to the ancestor, every later scan sees the mtime far in the past, reads "settled", matches the stat, and reuses the stale hash forever. A prompt re-scan inside the window was the only thing that recovered it, never the normal sync cadence. Carry a per-side racy flag on the recorded stat (git's racily-clean idea): a stat captured while its mtime bucket was still open is flagged, and a flagged hint is never reused on a stat match — it is re-hashed once, after which the bucket has settled and the flag clears. Each scan flags its own output when the mtime is within the window now, so the protection persists across passes through the ancestor store. Reuse now requires the stat to match, the mtime to be settled at this scan, and the hint to have been settled at its own capture. Persist the flag per side in the ancestor table (migration 2->3); existing rows default to settled, matching prior behaviour, with new rows carrying the real flag so the loss path is closed going forward.
de25beb to
16e8c46
Compare
in-jun
left a comment
There was a problem hiding this comment.
Reviewed against #51 and #44. This correctly closes the miss and the test gap, and I built both modules plus ran the core-sync suite green.
The fix holds where it matters. The settle guard is anchored to the hint's own capture rather than the current scan clock, which is the subtle part: a same-size edit that lands in the bucket after a racy hint was recorded looks settled to every later scan, so without the persisted per-side flag its stale hash would be reused forever. The racyMtime flag threads cleanly from SnapshotBuilder through the scan snapshot into the Push/Pull actions and the committed ancestor, so the flagship in-place same-size rewrite is re-hashed on the next pass. Disabling reuse entirely when the SMB offset is unknown (rather than trusting a device clock that may run ahead of the server) is the right call.
No reconciliation regression: racyMtime joins FileMeta's equals, but classification still keys on sameContentAs (hash only), so the added field can't perturb the engine. refreshHints now also re-records a flipped flag, which is cheap and self-healing. Migration 2->3 matches the entity column definition and is wired into DatabaseModule; defaulting existing rows to settled is a reasonable one-time upgrade tradeoff and it's documented in the migration.
The tests are the right ones — SnapshotBuilderTest pins reuse vs. re-hash across settled/racy/size/mtime/absent/unknown-clock/future-mtime, and the counter on the fake lets SyncExecutorTest actually assert an unchanged file is served from the hint while a changed one is re-hashed. That's exactly the pathway #44 flagged as untested.
One narrow follow-up, not blocking: writtenMeta records the just-written destination side with racyMtime = false even though that stat's mtime is fresh. A concurrent external same-size edit that lands in that bucket could still be trusted on a later settled scan. It's a genuine multi-writer edge rather than the in-place-rewrite case #51 targets, and it's strictly better than the prior no-guard behavior, so I'd leave it for a separate change if it's worth chasing at all.
Looks good to me."
The scan hash-reuse shortcut trusted a cached hash on any exact size+mtime match, with no allowance for filesystem mtime granularity. On coarse-mtime backends (FAT SD cards at 2 s, SMB servers that truncate to whole seconds) two same-size edits can share one mtime bucket, so the second edit keeps the stale hash, gets classified UNCHANGED, and never propagates. That path was also untested: the fake honored the hint but nothing pinned the reuse condition or asserted reuse actually happens.
SnapshotBuildernow reuses a hint hash only once the entry's mtime has settled past a granularity window of the scan clock; nearer than that (or against an unknown clock, or a future-dated mtime) it re-hashes. Local scans pass device time; remote scans pass server time and disable reuse when the server clock is unknown rather than trust a device clock that may run ahead.SnapshotBuilderTestpinning reuse vs. re-hash across settled/racy/size/mtime/absent-hint/unknown-clock/future-mtime cases, and give the in-memory fake a scan hash counter soSyncExecutorTestasserts an unchanged file is served from the hint while a changed one is re-hashed.Fixes #51
Fixes #44