Repository navigation
The DID-matlab#191 guard poisons the file cache: after it fires, that member reads as empty bytes forever #73
Description
Found while writing the read-side tests for #68. _open_series_member's guard against a handler that returns the manifest's own bytes causes, on the way to refusing, exactly the lasting silent corruption it exists to prevent — for the same member uid, permanently, with no error at any later read.
What happens
_fetch_remote_to_cache does not hand back the temporary download. It moves the file into the file cache under the member's uid and returns the path inside the cache:
cached = self._add_to_file_cache(dest_path, uid) # cache.add_file(source_path, name)
if cached is not None:
...
return cached, None_open_series_member then applies the #191 comparison and, on a match, deletes that path with a raw os.remove:
if f_fetched.read() == f_manifest.read():
with contextlib.suppress(OSError):
os.remove(fetched_path) # <- removes the cache's copy
failures.append(... "handler returned the manifest's own bytes" ...)
continueThe file is gone; the cache's index entry is not. So cache.is_file(member_uid) stays True forever, pointing at a path that no longer exists.
Every later open of that member then takes this route:
- the local-first check,
cached_path_for_uid, correctly returnsNone— it looks at the filesystem; _fetch_remote_to_cachetakes the fetch lock and re-checks the cache index —cache.is_file(uid)isTrue, so it returns the stale path without ever calling the handler;- back in
_open_series_member,os.path.getsize(fetched_path)raisesOSError,fetched_sizebecomesNone, the #191 comparison is skipped as "comparison unavailable; accept the fetch"; ReadOnlyFileobjis handed back over a path that does not exist.Fileobj.fopen()swallows theOSError, so the caller readsb""and sees no error at all.
That last step is the failure mode this file's own comment names, twenty lines above the guard: "Raise rather than hand back a Fileobj over a path that does not exist: Fileobj.fopen() swallows the OSError, so a caller that does not check it reads b'' and sees no error at all."
Reproduction
Store a demoSeries document whose manifest location is remote, evict the ingested members, then fetch one member with a handler that resolves the manifest location instead of the member uid — the exact mistake #191 anticipates. Then retry with a correct handler:
refused: DID:SQLITEDB:FileSeries:HandlerReturnedManifest
cache.is_file(member_uid): True
that path exists on disk: False
cached_path_for_uid: None
handler called on the retry: 0
bytes read back: b''
The correct handler is never called, and the member reads empty from then on. A cache clear is the only recovery, and nothing tells the caller one is needed.
Scope
Specific to _open_series_member. The ordinary-file read path also goes through _fetch_remote_to_cache, but nothing deletes its result afterwards, so the cache stays consistent there. An ordinary failed fetch (handler raises, or writes nothing) is also safe: _fetch_remote_to_cache deletes its partial before the cache add, so no index entry is ever created.
Suggested fix
Remove the rejected bytes through the cache rather than around it, so the index goes with the file:
cache = self._file_cache()
if cache is not None and cache.is_file(member_uid):
cache.remove_file(member_uid)
else:
with contextlib.suppress(OSError):
os.remove(fetched_path)Two things worth fixing alongside, both visible from the same trace:
fetched_size is Noneshould not mean "accept". Whenos.path.getsizeon a file that was just fetched raises, the fetch did not produce a usable file; treating that as "comparison unavailable, accept the fetch" is what turns a stale cache entry into a silent empty read. Failing the fetch there would have surfaced this._open_series_membershould not return aReadOnlyFileobjover a path it has not confirmed exists, for the reason its own neighbour comment gives.
Effect on the tests for #68
The read-side PR asserts that the guard fires with HandlerReturnedManifest and that the manifest stays readable as an ordinary file. The test that a later correct fetch still works is the one that found this, and it is left out of that PR rather than inverted to assert the broken behaviour — it should go in as the regression test when this is fixed.
Activity
stevevanhooser commentedon Sep 8, 2026
Fixed on
main(981cebc), in97d5642/ #78. All three arms this issue named are in.os.removeis replaced by_discard_fetched(fetched_path, uid)(sqlitedb.py:1835), which removes viacache.remove_file(uid)when the path is the cache's own copy — so the index entry goes with the file — and falls back to a plain remove otherwise.cache.is_file(uid)no longer answersTruefor a file that is not there.fetched_size is Noneno longer means "accept". Agetsizefailure on a file that was just fetched is now a failed fetch: it discards and appends tofailures(sqlitedb.py:1797-1804).ReadOnlyFileobjover an unconfirmed path. The remote arm returns only aftergetsizehas succeeded on it; the local arm goes throughcached_path_for_uid, which looks at the filesystem.The regression test this issue said should land with the fix is there —
test_a_correct_fetch_still_works_after_the_guard_has_fired— alongsidetest_the_refused_bytes_are_not_left_in_the_cacheandtest_a_fetch_that_cannot_be_measured_is_a_failure_not_a_pass.Verified against the code, not the commit message, and each arm mutation-checked here: disabling the cache-aware removal reddens
test_the_refused_bytes_are_not_left_in_the_cache; restoring "comparison unavailable, accept the fetch" reddenstest_a_fetch_that_cannot_be_measured_is_a_failure_not_a_pass. Full suite green at 693 passed, 10 skipped, 1 xfailed.Nothing outstanding. Closing as completed.
Generated by Claude Code