fix(store): don't close a shared filesystem in FsspecStore.close()#226
Open
d-v-b wants to merge 7 commits into
Open
fix(store): don't close a shared filesystem in FsspecStore.close()#226d-v-b wants to merge 7 commits into
d-v-b wants to merge 7 commits into
Conversation
* fix: byte-order handling for structured dtypes in the bytes codec (#220) * fix: byte-order handling for structured dtypes in the bytes codec The bytes codec neither byte-swapped structured-dtype fields to its configured endian on encode (numpy reports byteorder '|' for void dtypes, so the top-level byteorder comparison never detected a mismatch) nor honored its endian when decoding, silently corrupting any structured data whose field byte order differed from the stored one (e.g. virtual references to external big-endian data). Encode now detects byte-order mismatches by comparing full dtypes via newbyteorder, and decode reinterprets raw bytes in the stored byte order before converting to the data type's declared byte order, so the stored layout (codec state) and the in-memory layout (array data type) are independent. Closes zarr-developers#4141 Assisted-by: ClaudeCode:claude-fable-5 * test: fold structured byte-order cases into existing bytes codec tests Extend test_endian's parametrization with structured dtypes and test_bytes_codec_sync_roundtrip with endian/dtype parametrization plus stored-layout and decoded-dtype assertions, instead of adding parallel test functions for the same properties. Assisted-by: ClaudeCode:claude-fable-5 * refactor: rename stored_dtype to view_dtype in BytesCodec decode The variable is the dtype used to view the raw chunk bytes (byte order from the codec's endian configuration), not a property of the stored data or of the returned buffer, which always carries the array's declared dtype. Assisted-by: ClaudeCode:claude-fable-5 * docs: note that the decode-side byte-order conversion copies the chunk Assisted-by: ClaudeCode:claude-fable-5 * fix(store): copy buffers on write in MemoryStore Encoding an uncompressed chunk hands the store a zero-copy view of the caller's array, and MemoryStore keeps whatever it is given alive in a dict rather than serializing it. Mutating the source array after a write therefore rewrote chunks already committed to the store, silently. Stores that serialize on write (LocalStore, ZipStore, remote stores) are unaffected, so they keep the full benefit of zarr-developers#3885. Only MemoryStore pays the copy, and only where it was aliasing to begin with: an uncompressed 34 MB write goes from ~23 ms to ~39 ms, while compressed writes are unchanged. Copying at the store boundary rather than narrowing the fast path in _merge_chunk_array also fixes the single-chunk case, which aliased in v3.2.1 too. Assisted-by: ClaudeCode:claude-opus-4.8 * docs: add changelog entry for MemoryStore buffer copy Assisted-by: ClaudeCode:claude-opus-4.8 * docs: correct changelog
d-v-b
force-pushed
the
fix/fsspec-shared-fs-close
branch
from
July 17, 2026 14:11
ce4f83d to
5d83b84
Compare
FsspecStore.close() closed the underlying filesystem's session, on the premise that a store built by from_url "owns" the filesystem it created. That premise does not hold: fsspec caches and shares filesystem instances across callers (its instance cache keys on storage options, not path), and users can hand one filesystem to many stores directly. Closing one store therefore killed the session that sibling stores were still using, and left the dead filesystem in fsspec's cache for later callers. Determining whether a filesystem is actually shared requires reaching into fsspec's private instance cache (_cache, _fs_token, cachable) and walking wrapper chains for caching/proxy filesystems — an implementation detail that leaks upward and that we would have to keep in sync with fsspec forever, getting it subtly wrong in between. The wrapper case alone (simplecache::/dir://) already slipped through a cache-membership check. The filesystem's lifecycle is simply not the store's to manage. This removes the ownership model added in the unreleased zarr-developersgh-4003: no _owns_fs, no _close_fs, no ownership transfer in with_read_only, and close() just marks the store not-open. The only thing given up is suppressing an "Unclosed client session" ResourceWarning, which was true anyway — the session belongs to a cached filesystem that outlives the store. Since zarr-developersgh-4003 never shipped (latest release is v3.2.1), its changelog fragment is removed rather than superseded. Assisted-by: ClaudeCode:claude-opus-4.8
d-v-b
force-pushed
the
fix/fsspec-shared-fs-close
branch
from
July 17, 2026 16:24
5d83b84 to
6503153
Compare
Merged
7 tasks
test_with_read_only_shares_filesystem replaced an ownership test that carried a guard for fsspec < 2024.12.0, and the guard was dropped in the rewrite. The test still opens a file:// URL, which needs AsyncFileSystemWrapper, so it failed the min_deps job. Assisted-by: ClaudeCode:claude-opus-4.8
…atus The fragment said zarr-developersgh-4003 was unreleased with no net change for released versions. Its text is already in the staged 3.3.0 release notes, so the revert is a real behavior change for anyone relying on close() releasing the session. Assisted-by: ClaudeCode:claude-opus-4.8
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Fixes F3 from the v3.3.0 evaluation — high-impact runtime breakage on the default
zarr.open("s3://...")path.The bug
from_urlsets_owns_fs = Trueunconditionally. The premise recorded in zarr-developers#4003 — "Trueonly whenFsspecStoreitself created the filesystem" — is false:url_to_fsgoes through fsspec's_Cachedmetaclass, which tokenizes on storage options only, not the path. Two stores built from two URLs on one host get the sameAbstractFileSystem, and each claims to own it.close()then kills the session for every sibling store, and because the dead filesystem stays incls._cache, stores created afterwards are handed the corpse too. The damage outlives the store that caused it.Two corrections to the finding as written
1. The MVCE in the writeup doesn't actually demonstrate the bug —
set_session()is a coroutine and it's never awaited, soassert session.closedwas passing on a coroutine object's missing attribute rather than a closed session. The underlying bug is real; I re-derived it with an awaited version.2.
skip_instance_cache=Truealone is not sufficient, though the writeup offers it as the fix._owns_fsis a plain attribute with no__getstate__anywhere, so it pickles asTruewhile the filesystem is rebuilt cache-served on the far side. On a dask worker the store owns a shared instance again and the bug is back. Detecting a "newly created" instance is also impossible —_fs_tokenis identical for a fresh and a cache-served instance, and a new cachable instance is inserted into_cachebefore__call__returns.The predicate that actually matters isn't "did I create it" but "can anyone else reach it" — which has to be asked at
close()time, not construction time.The fix (both halves)
from_urlpassesskip_instance_cache=True— the filesystem is genuinely private, so closing it is safe and fix(FsspecStore): close owned async filesystem on store.close() zarr-developers/zarr-python#4003's leak fix keeps working.close()re-checks_fs_is_shared(self.fs)against the live cache — self-correcting after a pickle roundtrip: the fs comes back cache-served, the check turnsclose()into a no-op, and no sibling is harmed.Each half covers where the other fails. The helper guards
cachable/_cache/_fs_tokenwithgetattrand degrades to "assume shared" — the safe answer — if fsspec's internals move.Also fixes
from_mapper, which the writeup doesn't mention. Itsfs is not original_fsheuristic assumes any new object is private, but_make_async's sync→async conversion round-trips throughto_json/from_json, which the cache serves. A sync s3fs mapper therefore wrongly claimed ownership. It now uses the same check.A caller who explicitly passes
skip_instance_cache=Falsestill gets a cached fs and correctly gets_owns_fs=False— the**optsordering respects user intent while staying safe.Tests
Four new tests, all failing on
mainand passing here:test_from_url_filesystem_is_private— distinct URLs, distinct fstest_close_does_not_close_a_sibling_stores_sessiontest_close_does_not_poison_the_instance_cache— a store created after the close still gets a live sessiontest_close_declines_to_close_a_shared_filesystem— the pickle-resurrection caseThe three session tests use HTTP rather than S3 deliberately: s3fs/aiobotocore transparently reconnects, so an S3-based assertion passes even with the bug present. I wrote the S3 versions first and they were vacuous — they passed against unfixed
main. HTTP's aiohttp session stays dead, which is where the user-visibleRuntimeError: Session is closedcomes from. No network I/O —set_session()only constructs the session.That reconnect behavior is also why this went unnoticed: the existing
test_from_url_close_releases_storeonly checksnot store._is_open, never that the session survived.Full suite: 6731 passed, 0 failed.
mypyclean.Known tradeoff
A
from_urlstore no longer compares equal to itself across a pickle roundtrip:skip_instance_cacheperturbs the fs token and isn't carried instorage_options, so unpickling reconstructs through the cache with a different token. Stores built from the same URL still compare equal, and no existing test covers the roundtrip case (test_serializable_store's fixture constructs the store directly with a suppliedfs). Flagging it explicitly sincetest_serializable_store's comment calls roundtrip equality "important for being able to compare (de)serialized stores" — happy to pursue a__getstate__that re-privatizes on unpickle if you'd rather not take this.Relatedly, unpickled stores on a worker share one cached fs and none will close it, so the session leaks there — safe, but zarr-developers#4003's original complaint in that narrow case.
Left alone
with_read_only()mutatingself._owns_fs = Falseon the source store is still there, per your call — a nominally derive-only method with a side effect that silently changes the source'sclose()behavior and makes it non-idempotent. Worth a separate look.