[bug] Fix range reads on whole-file caches and crossed ranges in cat_file - #2101
[bug] Fix range reads on whole-file caches and crossed ranges in cat_file#2101nvbkdw wants to merge 4 commits into
Conversation
`filecache` and `simplecache` serve range reads from the local copy of a file rather than from the remote, and three things go wrong there: 1. Negative `start`/`end` are documented on `AbstractFileSystem.cat_file` to count backwards from the end of the file, and remote backends implement them as HTTP suffix ranges. `_cat_file` instead passed the value straight to `f.seek()`, so a negative offset raised `OSError(EINVAL)` after the whole file had already been downloaded. 2. The sync path reached `AbstractFileSystem.cat_file`, which needs `f.size` to resolve negative offsets. Both `_open` implementations return a bare `io.BufferedReader`, so it raised `AttributeError`. `LocalFileOpener` already attaches `size` to the raw file this way. 3. `WholeFileCacheFileSystem._check_file` returns a `(detail, path)` tuple while the `SimpleCacheFileSystem` override returns the path alone. `_cat_file` and `_cat_ranges` assumed the latter, so on `filecache` they raised `TypeError`/`AttributeError` for a cached path, and `_cat_ranges` never downloaded an uncached one (the miss sentinel is `False`, not `None`). This surfaced with zarr v3 sharded arrays, whose chunk index lives at the end of each object and is read with a suffix range: every read through `simplecache` downloaded the full shard and then failed on the seek. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two follow-ups to the negative-offset fix in the parent commit.
`_cat_file` computed `end - f.tell()` as the read size. A range crossed
by exactly one byte makes that `-1`, which `read()` treats as "the rest
of the file": on a 10-byte cached file, `cat_file(start=6, end=-5)`
returned `b"6789"` instead of `b""`. Any wider crossing raised
`ValueError` instead. Resolving negative offsets to absolute ones made
this reachable from plausible input, so clamp the size at zero.
`_cat_ranges` used `rset` only as a membership set, so the second and
later occurrences of one uncached path fell through to
`lpaths.append(fn)` with the cache-miss sentinel still in `fn`. Every
occurrence after the first came back as
`AttributeError("'bool' object has no attribute 'startswith'")` from
`LocalFileSystem.cat_ranges`, silently, since `on_error` defaults to
`"return"`. Several ranges of one object is the normal access pattern
for sharded formats, so track the resolved local path per unique path
instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
``AbstractFileSystem.cat_file`` documents start/end as behaving "like usual python slices", but computed the read length as ``end - f.tell()`` with no lower bound. ``read`` treats a negative length as "to the end of the file", so a range crossed by exactly one byte returned the rest of the file instead of nothing: on a 10-byte file, ``cat_file(start=6, end=-5)`` gave ``b"6789"``. Wider crossings computed -2 or less, which ``BufferedReader`` rejects outright with ``ValueError``, so the failure mode also depended on how far the range was crossed. Clamping the length at zero covers both, and incidentally fixes the unclamped negative ``end``: a suffix longer than the file (``end=-100`` on 10 bytes) resolved to -90 and raised, where a slice yields ``b""``. Negative ``start`` was already clamped this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
``cat_file`` documents start/end as behaving "like usual python slices", but implemented that by hand across three branches: a clamped seek for a negative start, a separate rewrite of a negative end, and ``None`` handled by control flow rather than as a value. The two offsets did not resolve the same way -- a negative start clamped, a negative end did not -- and the read length was still computed by arithmetic that could collide with ``read``'s "to the end of the file" sentinel. ``slice.indices`` performs exactly this normalization, clamping and all, so hand it the file size and let it produce a pair of absolute offsets. The one wrinkle is that it needs the size up front, and not every file object has one: ``TarFileSystem`` hands back a ``tarfile.ExFileObject``, which would raise ``AttributeError`` for even a plain full-file read. So the size is only consulted when an offset is actually negative, which is the same condition under which the old code read ``f.size`` -- no backend loses a case that used to work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fallenmi
left a comment
There was a problem hiding this comment.
filecache still does not record files downloaded through the async range paths, so the main async/Zarr use case repeatedly fetches the full remote object instead of serving later ranges from the cache.
On exact head 95df12a, I wrapped MemoryFileSystem.get / get_file with counters and awaited the same read twice. For WholeFileCacheFileSystem, both _cat_file and _cat_ranges made one backend download per invocation (counts 1 then 2), and _check_file("/afile") remained False after each successful download. The same probes on SimpleCacheFileSystem made one download total and then returned the cached local path.
The miss branches currently write directly to the hash path but never create/save the metadata that WholeFileCacheFileSystem._check_file() requires. This also means the new repeated-range test's comment “again now that the file is cached” is false for its filecache parameter: the bytes pass because the second invocation silently downloads the object again.
Please preserve the filecache metadata contract after a successful async download and assert the backend download count across a second _cat_file and _cat_ranges call. That is important here because sharded readers repeatedly request suffix/range data from the same full object.
For context, I independently confirmed the intended functional fixes: exact base returns errors/wrong bytes for the added cases, while this head returns the expected ranges. Both touched test files pass (151 passed, 17 skipped), as do pinned Ruff 0.14.3 check/format, compileall, and git diff --check.
Disclosure: OpenAI Codex assisted with this independent review and the deterministic probes above.
filecacheandsimplecacheserve range reads out of the local copy of a file rather than from the remote. Several defects in that path make range reads on a cached file fail, and chasing them down surfaced one more in the shared base-class implementation.WholeFileCacheFileSystem/SimpleCacheFileSystem1. Negative
start/endraiseOSError(EINVAL)AbstractFileSystem.cat_filedocuments negative offsets as counting backwards from the end of the file ("like usual python slices"), and remote backends such ass3fsimplement them as an HTTP suffix range.WholeFileCacheFileSystem._cat_filepassed the value straight tof.seek(start), which asks for a negative absolute position:The failure happens after the whole object has been downloaded into the cache, so the local copy that could trivially serve the read is fetched and then discarded.
2.
AttributeError: '_io.BufferedReader' object has no attribute 'size'The sync path goes through
AbstractFileSystem.cat_file, which needsf.sizeto resolve a negative offset. Both_openimplementations here return a bareio.BufferedReader, so it raises.LocalFileOpeneralready attachessizeto the raw file object (local.py), so this follows the existing idiom.3.
_check_filereturn type is not handled consistentlyWholeFileCacheFileSystem._check_filereturns a(detail, path)tuple, while theSimpleCacheFileSystemoverride returns the path alone._cat_fileand_cat_rangesassume the latter, so onfilecache:_cat_rangesadditionally never downloaded an uncached path, because it testsfn is Nonewhile the cache-miss sentinel here isFalse.4. A range crossed by one byte reads the rest of the file
_cat_filecomputed the read length asend - f.tell().read()treats a negative length as "to the end of the file", so a range crossed by exactly one byte hit that sentinel by accident. Resolving negative offsets to absolute ones (defect 1) makes this reachable from ordinary input:Note the two different failures: a one-byte crossing computes
-1and silently returns data, anything wider computes-2or less and raises.5. Repeated paths in
_cat_rangesresolve to the cache-miss sentinel_cat_rangesusedrsetpurely as a membership set, so only the first occurrence of an uncached path was assigned a local path. Every later occurrence appended the falsy sentinel tolpaths, andLocalFileSystem.cat_rangesreturned an exception object in that slot — silently, sinceon_errordefaults to"return":Asking for several ranges of one object is the normal access pattern for sharded formats, which is exactly what this PR is about.
AbstractFileSystem.cat_fileDefect 4 is not specific to the caches — the base implementation computes the length the same way, so it is reachable on every backend that does not override
cat_file:The offsets were also normalized inconsistently: a negative
startclamped at 0, a negativeenddid not, so a suffix longer than the file raised instead of yielding empty (data[:-100]isb"", butcat_file(end=-100)raisedValueError). AndNonewas handled by control flow rather than as a value, giving three different mechanisms for one idea.slice.indicesalready performs exactly this normalization — clamping included — so both offsets now resolve through it, which is literally what the docstring promises. It needs the size up front and not every file object has one (TarFileSystemyields atarfile.ExFileObject, which would then break even a plain full-file read), so the size is consulted only when an offset is actually negative — the same condition under which the old code readf.size. No backend loses a case that previously worked.How this came up
zarr v3 sharded arrays store the inner-chunk index at the end of each shard object, so every read begins with a suffix-range request. Reading such an array over
simplecache::s3://downloads the full shard and use negative index to read files.Changes
fsspec/implementations/cached.py_cat_file: resolve negativestart/endagainst the file size before seeking, mirroringAbstractFileSystem.cat_file, and clamp the read length so a crossed range cannot collide withread()'s to-EOF sentinel._open(both classes): exposesizeon the returned file object._cat_file/_cat_ranges: accept either_check_filereturn shape, and treat any falsy value as a cache miss._cat_ranges: track the resolved local path per unique path, so repeated paths all resolve.fsspec/spec.pycat_file: resolvestart/endthroughslice.indicesagainst the file size, consulted only when an offset is negative.Tests
cat_rangeson bothfilecacheandsimplecache, over synccat_fileand the awaited_cat_filethat async consumers (e.g. zarr'sFsspecStore) use._cat_ranges, against an async target so the download branch is actually exercised.cat_filestart/end combinations checked againstdata[start:end]directly, since python slice semantics are what the docstring promises.