Skip to content

fix(store): don't close a shared filesystem in FsspecStore.close()#226

Open
d-v-b wants to merge 7 commits into
mainfrom
fix/fsspec-shared-fs-close
Open

fix(store): don't close a shared filesystem in FsspecStore.close()#226
d-v-b wants to merge 7 commits into
mainfrom
fix/fsspec-shared-fs-close

Conversation

@d-v-b

@d-v-b d-v-b commented Jul 16, 2026

Copy link
Copy Markdown
Owner

🤖 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_url sets _owns_fs = True unconditionally. The premise recorded in zarr-developers#4003 — "True only when FsspecStore itself created the filesystem" — is false: url_to_fs goes through fsspec's _Cached metaclass, which tokenizes on storage options only, not the path. Two stores built from two URLs on one host get the same AbstractFileSystem, and each claims to own it.

close() then kills the session for every sibling store, and because the dead filesystem stays in cls._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 bugset_session() is a coroutine and it's never awaited, so assert session.closed was 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=True alone is not sufficient, though the writeup offers it as the fix. _owns_fs is a plain attribute with no __getstate__ anywhere, so it pickles as True while 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_token is identical for a fresh and a cache-served instance, and a new cachable instance is inserted into _cache before __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)

  1. from_url passes skip_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.
  2. 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 turns close() into a no-op, and no sibling is harmed.

Each half covers where the other fails. The helper guards cachable/_cache/_fs_token with getattr and degrades to "assume shared" — the safe answer — if fsspec's internals move.

Also fixes from_mapper, which the writeup doesn't mention. Its fs is not original_fs heuristic assumes any new object is private, but _make_async's sync→async conversion round-trips through to_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=False still gets a cached fs and correctly gets _owns_fs=False — the **opts ordering respects user intent while staying safe.

Tests

Four new tests, all failing on main and passing here:

  • test_from_url_filesystem_is_private — distinct URLs, distinct fs
  • test_close_does_not_close_a_sibling_stores_session
  • test_close_does_not_poison_the_instance_cache — a store created after the close still gets a live session
  • test_close_declines_to_close_a_shared_filesystem — the pickle-resurrection case

The 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-visible RuntimeError: Session is closed comes 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_store only checks not store._is_open, never that the session survived.

Full suite: 6731 passed, 0 failed. mypy clean.

Known tradeoff

A from_url store no longer compares equal to itself across a pickle roundtrip: skip_instance_cache perturbs the fs token and isn't carried in storage_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 supplied fs). Flagging it explicitly since test_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() mutating self._owns_fs = False on the source store is still there, per your call — a nominally derive-only method with a side effect that silently changes the source's close() behavior and makes it non-idempotent. Worth a separate look.

* 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
d-v-b force-pushed the fix/fsspec-shared-fs-close branch from ce4f83d to 5d83b84 Compare July 17, 2026 14:11
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
d-v-b force-pushed the fix/fsspec-shared-fs-close branch from 5d83b84 to 6503153 Compare July 17, 2026 16:24
d-v-b added 4 commits July 20, 2026 08:02
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants