Decide the refresh mtime match from the stored value, not a fixed window - #1912
Conversation
A flat MTIME_TOLERANCE of 2 s absorbs the coarsest filesystem granularity, but it applies that window everywhere -- including on filesystems that store the mtime exactly. There an asset genuinely replaced less than two seconds later at an identical size compares "same" and is silently not refreshed: the inverse of #1907, and one nobody would ever notice, since the symptom is a stale file rather than a slow download. The gap between the record and the local mtime can only have been introduced by the destination quantizing the value we set with os.utime(), so require it to be a gap the *observed* value can actually account for: the stored mtime must be a multiple of some known granularity that is itself wider than the gap. A filesystem truncating to whole seconds cannot have produced a stored ...21.651, so against that value even a millisecond of drift is a real change; a stored ...20.000 is consistent with truncation, and a gap up to two seconds says nothing either way. This needs no probing of the destination and no state -- the evidence is the value already being compared. Add it as is_same_mtime() beside is_same_time() in utils, since it replaces a use of the latter and answers the same kind of question. Compare in integer nanoseconds via st_mtime_ns so the multiple-of test is exact. os.utime() takes the time as a C double, so even an exact filesystem round-trips it a few tens of nanoseconds off (22 ns measured on tmpfs); that, rather than any filesystem property, is what is_same_time()'s 1 us default was really absorbing, and it is now named as MTIME_ROUNDTRIP_SLACK_NS. Tests: unit-test the predicate over the unchanged/changed matrix beside the is_same_time() tests; cover a sub-second change on a precise filesystem, which the fixed window skipped; add exFAT's 10 ms to the simulated granularities and quantize the fixture in integer nanoseconds so that granularity truncates cleanly. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## claude/filesystem-estimate-precision-ys7fuz #1912 +/- ##
===============================================================================
+ Coverage 77.26% 77.32% +0.06%
===============================================================================
Files 89 89
Lines 13264 13313 +49
===============================================================================
+ Hits 10248 10294 +46
- Misses 3016 3019 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
_populate_dandiset_yaml() compared the local dandiset.yaml's mtime against the record with a bare `>=`. That mtime is another one we set ourselves with os.utime() twelve lines below, so a destination that quantizes mtimes reads it back below the value written and the comparison takes it for an older file. The symptom differs from the asset path and is milder: the content-equality check above short-circuits whenever the metadata matches, so there is no re-transfer churn. What is lost is the guard the `>=` exists for -- a locally-edited dandiset.yaml whose mtime lands inside the quantization window looks stale and gets overwritten by ds.update_metadata(). Note this is not a straight substitution of is_same_mtime() for the operator. The comparison is one-sided on purpose: a genuinely newer local copy is current too, and an equality test alone would call it "not the record" and clobber exactly the edits the check protects. So keep the `>=` arm and add is_same_mtime() as the second, for the value we wrote ourselves. The test pins all three branches, and fails against either mistake: the bare `>=` on every coarse granularity, and an is_same_mtime()-only check on all four, including the exact-filesystem case. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD
Nothing in it is specific to dandiset.yaml -- it asks whether a local file whose mtime we set ourselves is at least as new as the record, which is true of any such file. Generalize the docstring to match and rename the test with it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZfG9qzAUVagkBoBxmbfCD
|
Merge if you consider it as a requirement to get the main (important) fix in
Can you describe a real user interaction event that would cause this issue? Should I even mention the fact that people are told not to locally edit the |
|
First, to unblock the merge question: this PR is not a requirement for #1910. #1910 is standalone and fixes the reported bug; this is a refinement stacked on its branch. Merge #1910 whenever it is ready — if it lands first I will retarget this to master and re-verify. A real user event for the asset caseStraight answer: I cannot describe a likely human-driven one, and I would rather say that than manufacture one. Reaching it needs two blob versions at the same path with byte-identical size and The reachable instances are machine-driven:
So the honest framing is not "users hit this". It is that the fix costs nothing to have: no probe, no cached state, no lock, comparable line count, and it is never worse than the constant (its one wrong verdict is a strict subset of the constant's). There is no tradeoff being purchased. If it did cost real complexity, your point would carry and the constant would be the right call. dandiset.yaml — you are right, and I will say so plainlyI checked rather than assume, and the justification I gave in the PR body is weaker than I wrote it:
There is an opt-in escape hatch, What survives for that hunk is consistency, not user impact: The merge decision here is @yarikoptic's, not mine. Generated by Claude Code |
Thinking about it myself (in addition to claude code above), indeed tricky/unlikely to happen in real world, e.g. two parallel processes downloading from the same dandiset while it being modified "upstream" and coinciding to the same moment of time for download of that file. For me it was more of an overall "design" shortcoming for such quantification of mtimes. But indeed, seems pragmatically there should be no such cases. For the sake of simplicity, let's then postpone this |
Builds on #1910 — targets its branch. Not a fix for #1907 (that is #1910's); it addresses the inverse defect the fixed window introduces, and applies the same reasoning to the second site that shares the assumption.
MTIME_TOLERANCE = 2.0absorbs the coarsest filesystem granularity, but applies that window everywhere — including on filesystems that store the mtime exactly. There, an asset genuinely replaced under two seconds later at an identical size compares "same" and is silently not refreshed. Unlike #1907, nobody would ever notice: the symptom is a stale file, not a slow download.Since the gap can only have been introduced by the destination quantizing the value we set with
os.utime(), require it to be a gap the observed value can account for: the stored mtime must be a multiple of some known granularity that is itself wider than the gap. A filesystem truncating to whole seconds cannot have produced a stored…21.651, so against that value even a millisecond of drift is a real change. No probing of the destination and no state — the evidence is the value already being compared.Added as
is_same_mtime()besideis_same_time()inutils.py, since it replaces a use of the latter and answers the same kind of question._populate_dandiset_yaml()compared its ownos.utime()-written mtime with a bare>=, so it gets the same treatment via_is_local_file_current(). Not a straight substitution: that comparison is one-sided on purpose — a genuinely newer local copy is current too, and an equality test alone would call it "not the record" and clobber exactly the local edits the check exists to protect. The>=arm stays;is_same_mtime()is added as the second.Why not sense the filesystem (as #1908 did)
Three reasons a probe does not pay for itself:
os.utime()is free but only happens on runs that download something — not the pure "nothing changed" run where the tolerance is the whole ballgame.op.realpath(path), which a symlink can put on another filesystem.Verdicts: fixed window vs. this
Never worse; its one miss is a strict subset of the fixed window's.
Residual limitation: when the stored value lands on an exact even whole second it is indistinguishable from a FAT value, and behaves like the fixed window. Roughly 1-in-10⁶ for microsecond-resolution
blobDateModified, and it still requires an identical size.Why nanoseconds, and the 1 µs floor
os.utime()takes the time as seconds in a C double, so even an exact filesystem round-trips it slightly off — 22 ns measured on tmpfs. That, rather than any filesystem property, is whatis_same_time()'s 1 µs default was really absorbing; it is now namedMTIME_ROUNDTRIP_SLACK_NS. Comparison is in integer nanoseconds viast_mtime_nsso the multiple-of test is exact.Testing
test_is_same_mtime— 9-case unit matrix, beside theis_same_time()tests.test_download_file_refresh_detects_subsecond_change— end-to-end; fails against the fixed window.test_is_local_file_current_coarse_mtime_fs— pins all three branches; fails against the bare>=on every coarse granularity, and against anis_same_mtime()-only check on all four, including the exact-filesystem case.coarse_mtime_fsgains exFAT's 10 ms, and quantizes in integer nanoseconds so that granularity truncates cleanly.flake8, isort, mypy and black clean on the touched files.
test_utils.py: 89 passed vs. 80 on the base, with an identical set of pre-existing network failures.test_download.py: only the pre-existingtest_download_000027*families fail (versioneer rejects0+untagged…dirtyin a tagless clone).