Tolerate the filesystem's mtime granularity in download -e refresh - #1910
Conversation
`dandi download -e refresh` re-transferred every asset on every invocation whenever the destination filesystem does not store sub-second mtimes (mounted Windows volumes, FAT/exFAT, some network mounts). The refresh branch of `_download_file()` compares the asset's recorded mtime against the mtime read back from the local file -- but that local mtime is one dandi set itself with `os.utime()` at the end of the previous download, so the comparison is really a filesystem round trip. It was performed with `is_same_time()`'s default `tolerance` of one microsecond, i.e. it assumed the value round-trips exactly. ext4/XFS/tmpfs store nanosecond mtimes so it does, which is why the bug is invisible to most developers and to CI; a filesystem that truncates or rounds reads back a value up to a full second off, `same` ends up `["size"]`, and the file is redownloaded. Every file, every time. Since the value compared against is one we wrote ourselves, the only error the comparison must absorb is the filesystem's own quantization, whose practical worst case is FAT's two seconds -- and the skip additionally requires the size to be unchanged. So compare with a constant `MTIME_TOLERANCE` of 2 s rather than trying to establish each filesystem's exact granularity. Also report both timestamps, their delta, the tolerance and both sizes when a skip is rejected; previously the debug message named only which attributes matched, so someone hitting this saw a slow download and nothing else. Closes #1907 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCMNL9Jkrz4ooAf2bmSfE
`test_download_file_refresh_reports_mtime_mismatch` asserted the literal `delta: 3600.65`, which requires the deliberately stale mtime it writes to round-trip through the filesystem at sub-second precision -- the very assumption this PR stopped making elsewhere. CI's `nfs` job points TMPDIR at an NFS mount, so `tmp_path` there is not necessarily nanosecond-precise. Parse the delta out of the message instead and assert it names the roughly one-hour discrepancy, within the tolerance under test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015TCMNL9Jkrz4ooAf2bmSfE
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1910 +/- ##
==========================================
+ Coverage 77.17% 77.26% +0.08%
==========================================
Files 89 89
Lines 13208 13264 +56
==========================================
+ Hits 10193 10248 +55
- Misses 3015 3016 +1
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:
|
|
@yarikoptic Confirmed this solved my issue on 'my system' |
yarikoptic
left a comment
There was a problem hiding this comment.
The fix is right, and choosing a constant over #1908's probe-and-cache machinery is well argued. Both properties in the description hold up against the code: the compared mtime is one _download_file() set itself at download.py:862, and the stat.st_size == size conjunct is a real backstop. Happy to see this go in once the test constant is fixed.
Verified locally: all four new tests pass; reverting just the tolerance= argument fails coarse_mtime_fs[1.0] and [2.0], so the regression coverage is genuine. flake8, mypy, black and isort are clean on the three touched files (the black diff on test_download.py is pre-existing with mock.patch(...) code this PR doesn't touch). CI is green across all 30 matrix jobs.
One finding worth fixing, inline on COARSE_MTIME_RECORD: the granularity=2.0 case is a silent no-op, so nothing in the suite actually pins the 2 s constant.
_populate_dandiset_yaml() makes the same assumption
download.py:588 is the same bug class in the same command, and it's the more interesting half of the story:
elif existing is DownloadExisting.SKIP or (
existing is DownloadExisting.REFRESH
and os.lstat(dandiset_yaml).st_mtime >= mtime.timestamp()
):Same premise as the site being fixed — that an mtime we wrote with os.utime(dandiset_yaml, (time.time(), mtime.timestamp())) twelve lines below reads back exactly. On a coarse filesystem it reads back below the value we set, so the >= fails.
The symptom is quite different from #1907, though, and much milder — worth spelling out so the issue doesn't get closed on a wrong model of the blast radius:
- No re-transfer churn. The
yaml_load(fp, typ="safe") == metadatacheck above short-circuits with_skip_file("no change")whenever the content matches, which is the steady state. The mtime comparison is only reached when the metadata genuinely differs, and there redownloading is the correct outcome. - The one real loss is protection of local edits. That
>=means "local copy is ahead of the record, leave it alone". Truncation makes a locally-modifieddandiset.yamllook up to 2 s behind the record when it is in fact level with or slightly ahead of it, andds.update_metadata(metadata)overwrites it. Needs the edit to land within the quantization window ofdandiset.modified, so it's a corner — but it's silent data loss when it hits, whereas #1907 was merely slow.
Note the direction differs from the _download_file() site: this one is a one-sided >=, not a symmetric equality, so it wants st_mtime >= mtime.timestamp() - MTIME_TOLERANCE rather than an is_same_time() tolerance. One line, same constant, and it keeps the two round-trip assumptions in download.py from drifting apart. Either fold it in here, or leave a note on #1907 so it isn't marked fully addressed.
Minor, non-blocking
- Log readability. The new debug line prints raw epoch floats (
local mtime: %f, record mtime: %f). Since sharper diagnostics is part of the point, ISO timestamps would serve someone reading a log far better than1787412080.651000. Separately,%rapplied tostr(path)renders'/x/y'where the oldf"{path!r}"renderedPosixPath('/x/y')— an improvement, just noting the format changed. - Fixture hazard.
quantizing_utimeforwardstimespositionally, so a caller usingos.utime(path, ns=...)would hitValueError: you may specify either 'times' or 'ns' but not both. Nothing indandidoes that today; only a trap if the fixture gets reused. test_download_file_refresh_reports_mtime_mismatchregex-parses the debug message to recover the delta, coupling it to an incidental log format. Defensible since the diagnostics are part of this change, but assertingsame == ["size"]more directly would be sturdier.
Generated by Claude Code
Co-authored-by: Yaroslav Halchenko <debian@onerussian.com>
Log readability: the rejected-skip debug line printed raw epoch floats, which
is the least readable form of the one thing the message exists to convey.
Report both timestamps via `ensure_datetime()` instead, normalized to UTC --
which is what `is_same_time()` itself normalizes to before comparing them, so
the two numbers a reader compares are in the frame the check actually used.
The truncation is now visible directly in the log:
local mtime: 2026-08-22 14:21:20+00:00,
record mtime: 2026-08-22 15:21:20.651000+00:00, delta: 3600.651000 s
`coarse_mtime_fs` quantized only the seconds form of `os.utime()`; a caller
passing nanoseconds (`ns=`, as `shutil.copystat()` does) bypassed the
simulation and stored full precision. Quantize `ns` too and forward whichever
form was given. Note this was not a `ValueError` risk: `os.utime()` rejects
`times` and `ns` only when both are non-None, and the old wrapper's `times`
defaulted to None.
`test_download_file_refresh_reports_mtime_mismatch` recovered the delta by
regex-parsing the debug line, coupling it to an incidental format. Assert the
record's mtime is reported in full instead -- the local one is deliberately not
asserted on, since whatever the filesystem stored is the point of the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TCMNL9Jkrz4ooAf2bmSfE
I'm not really concerned about this case TBH - we're talking about the CLI usage in particular here and that kind of time lag just isn't realistic (might be if we were to make some kind of interactive app that needed to constantly talk to the database though) Addressed the other minor suggestions |
yarikoptic
left a comment
There was a problem hiding this comment.
Re-reviewed the current head (32b7bab). The test constant, the ISO timestamps, the ns= fixture hardening and dropping the regex parse all look good — and thanks for catching that os.utime() only rejects times/ns when both are non-None, my ValueError claim was wrong.
Verified locally on this head: flake8/mypy/black/isort clean on the touched files, all four tests pass, and the constant is now genuinely pinned — MTIME_TOLERANCE = 1.0 fails coarse_mtime_fs[2.0], which it did not before. ensure_datetime(stat.st_mtime, tz=timezone.utc) also normalizes the same way is_same_time() does internally, so the two numbers in the log really are in the frame the comparison used.
One item from my first pass is still open — file comment on download.py for _populate_dandiset_yaml().
Generated by Claude Code
I did address that, actually, in my human response Similar to my other comment #1910 (comment), if you really want it to include the incredibly small (and more likely to be updated at a regular basis on remote) The fscacher question is an interesting one too but I haven't run any operations that felt 'slow' where it could make it feel 'fast' |
|
ok, please consider/review |
I already did, 30 minutes earlier #1912 (comment) |
|
🚀 PR was released in |
* Required dandi-cli 0.78.0 in the dandi image dispatch.py runs every download with `-e refresh`, which skipped nothing at all on filesystems whose mtimes are coarser than sub-second (dandi/dandi-cli#1907). On the production runner that meant each pass re-fetched whole incoming dandisets rather than refreshing them. dandi/dandi-cli#1910 fixed it upstream, released in 0.78.0. The image already resolved dandi loosely on every no-cache rebuild, so a rebuild alone picks the fix up; the floor is what makes a build fail loudly instead of quietly resolving back to a dandi without it. Co-Authored-By: Claude Code 2.1.251 / Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgF3exUHnXfZpZ76qwEqJL * Update dispatch/README.md Signed-off-by: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> --------- Signed-off-by: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Closes #1907. Supersedes #1908.
Fix
The refresh branch of
_download_file()compared the recorded mtime against the localone with
is_same_time()'s default 1 µs tolerance, i.e. it assumed the value round-tripsthrough the filesystem exactly. Pass a
MTIME_TOLERANCEof 2 s instead.#1908 fixed this by measuring each destination filesystem's granularity — 121 lines: a
temp file probed inside the user's download directory, a table of known granularities, an
st_dev-keyed cache behind athreading.Lock, and a shared fixture reaching into thatprivate cache so tests could clear it. Two properties of the comparison site make a
constant sufficient:
(
os.utime(path, (time.time(), mtime.timestamp()))). The comparison is thereforeonly a filesystem round trip, and the sole error it must absorb is that filesystem's
own quantization — worst case FAT's 2 s.
stat.st_size == size, so a wider tolerance can only mis-skipan asset that changed with an identical size and an mtime moving under 2 s.
🤖 Generated with Claude Code
https://claude.ai/code/session_015TCMNL9Jkrz4ooAf2bmSfE