Skip to content

Commit ecc2d77

Browse files
authored
fix(store): don't close a shared filesystem in FsspecStore.close() (#4165)
* 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 #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): FsspecStore.close() no longer closes the filesystem 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 gh-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 gh-4003 never shipped (latest release is v3.2.1), its changelog fragment is removed rather than superseded. Assisted-by: ClaudeCode:claude-opus-4.8 * test: skip with_read_only fs test when AsyncFileSystemWrapper is absent 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 * docs: correct changelog claim about gh-4003 release status The fragment said gh-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 * docs: remove changelog entry for unreleased versions
1 parent ab76998 commit ecc2d77

2 files changed

Lines changed: 47 additions & 157 deletions

File tree

src/zarr/storage/_fsspec.py

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import json
44
import warnings
55
from contextlib import suppress
6-
from logging import getLogger
76
from typing import TYPE_CHECKING, Any
87

98
from packaging.version import parse as parse_version
@@ -19,8 +18,6 @@
1918
from zarr.errors import ZarrUserWarning
2019
from zarr.storage._utils import _dereference_path
2120

22-
logger = getLogger(__name__)
23-
2421
if TYPE_CHECKING:
2522
from collections.abc import AsyncIterator, Iterable
2623

@@ -38,26 +35,6 @@
3835
)
3936

4037

41-
async def _close_fs(fs: AsyncFileSystem) -> None:
42-
"""
43-
Best-effort async close of an fsspec async filesystem owned by FsspecStore.
44-
45-
For filesystems that expose `set_session()` (e.g. s3fs) the underlying
46-
aiohttp `ClientSession` is closed explicitly, which prevents
47-
"Unclosed client session" `ResourceWarning`s from aiohttp. For all
48-
other filesystem types the call is a no-op (not every implementation
49-
manages an HTTP session directly).
50-
51-
Note that `set_session()` lazily creates a session if none exists yet, so
52-
closing a store that never performed any I/O may instantiate a session
53-
purely to close it. This is accepted best-effort behavior; fsspec does not
54-
expose a stable, cross-implementation way to test for an existing session.
55-
"""
56-
if hasattr(fs, "set_session"):
57-
session = await fs.set_session()
58-
await session.close()
59-
60-
6138
def _make_async(fs: AbstractFileSystem) -> AsyncFileSystem:
6239
"""Convert a sync FSSpec filesystem to an async FFSpec filesystem
6340
@@ -126,6 +103,15 @@ class FsspecStore(Store):
126103
ZarrUserWarning
127104
If the file system (fs) was not created with `asynchronous=True`.
128105
106+
Notes
107+
-----
108+
Closing the store does not close the underlying filesystem or its network
109+
session. fsspec caches and shares filesystem instances across callers, so
110+
the store cannot know whether it is the only user, and closing a shared
111+
session would break other stores. The filesystem's lifecycle belongs to
112+
whoever created it; use fsspec's own tools (e.g. `clear_instance_cache`)
113+
to release it.
114+
129115
See Also
130116
--------
131117
FsspecStore.from_upath
@@ -152,9 +138,6 @@ def __init__(
152138
self.fs = fs
153139
self.path = path
154140
self.allowed_exceptions = allowed_exceptions
155-
# True only when this store created fs itself (from_url / from_mapper with new instance).
156-
# Callers who supply their own fs remain responsible for its lifecycle.
157-
self._owns_fs: bool = False
158141

159142
if not self.fs.async_impl:
160143
raise TypeError("Filesystem needs to support async operations.")
@@ -220,17 +203,13 @@ def from_mapper(
220203
-------
221204
FsspecStore
222205
"""
223-
original_fs = fs_map.fs
224-
fs = _make_async(original_fs)
225-
store = cls(
206+
fs = _make_async(fs_map.fs)
207+
return cls(
226208
fs=fs,
227209
path=fs_map.root,
228210
read_only=read_only,
229211
allowed_exceptions=allowed_exceptions,
230212
)
231-
# _make_async returns a new instance when converting sync→async; own it.
232-
store._owns_fs = fs is not original_fs
233-
return store
234213

235214
@classmethod
236215
def from_url(
@@ -272,39 +251,16 @@ def from_url(
272251
if not fs.async_impl:
273252
fs = _make_async(fs)
274253

275-
store = cls(fs=fs, path=path, read_only=read_only, allowed_exceptions=allowed_exceptions)
276-
store._owns_fs = True
277-
return store
254+
return cls(fs=fs, path=path, read_only=read_only, allowed_exceptions=allowed_exceptions)
278255

279256
def with_read_only(self, read_only: bool = False) -> FsspecStore:
280257
# docstring inherited
281-
new_store = type(self)(
258+
return type(self)(
282259
fs=self.fs,
283260
path=self.path,
284261
allowed_exceptions=self.allowed_exceptions,
285262
read_only=read_only,
286263
)
287-
# The derived store shares the same fs. Transfer ownership so the
288-
# surviving store closes it, and clear ours to avoid a double-close.
289-
# Otherwise the common `from_url(...).with_read_only()` chain would
290-
# drop the only owner (the unreferenced source) and leak the session.
291-
new_store._owns_fs = self._owns_fs
292-
self._owns_fs = False
293-
return new_store
294-
295-
def close(self) -> None:
296-
# docstring inherited
297-
if self._owns_fs:
298-
from zarr.core.sync import sync as zarr_sync
299-
300-
# Best-effort: a failure to release the session must not block close(),
301-
# but log it so a genuine regression in the close path stays observable
302-
# rather than silently reverting to the leaking behavior.
303-
try:
304-
zarr_sync(_close_fs(self.fs))
305-
except Exception:
306-
logger.debug("Failed to close owned filesystem %r", self.fs, exc_info=True)
307-
super().close()
308264

309265
async def clear(self) -> None:
310266
# docstring inherited

tests/test_store/test_fsspec.py

Lines changed: 34 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -276,75 +276,20 @@ async def test_delete_dir_unsupported_deletes(self, store: FsspecStore) -> None:
276276
):
277277
await store.delete_dir("test_prefix")
278278

279-
# ── Filesystem lifecycle (ownership) ──────────────────────────────────────
279+
# ── Filesystem lifecycle ──────────────────────────────────────────────────
280280

281-
def test_from_url_owns_filesystem(self, endpoint_url: str) -> None:
282-
"""FsspecStore.from_url() creates the async fs; it must own it."""
281+
async def test_close_marks_store_closed(self, endpoint_url: str) -> None:
282+
"""close() must succeed and mark the store not-open."""
283283
store = FsspecStore.from_url(
284284
f"s3://{test_bucket_name}/lifecycle/",
285285
storage_options={"endpoint_url": endpoint_url, "anon": False},
286286
)
287-
assert store._owns_fs
288-
store.close()
289-
290-
async def test_from_url_close_releases_store(self, endpoint_url: str) -> None:
291-
"""
292-
close() on a from_url() store must succeed without error and mark the
293-
store as closed. For the owned filesystem, _close_fs() is invoked to
294-
release the underlying S3 client / aiohttp connection pool.
295-
"""
296-
store = FsspecStore.from_url(
297-
f"s3://{test_bucket_name}/lifecycle/",
298-
storage_options={"endpoint_url": endpoint_url, "anon": False},
299-
)
300-
# Materialise the S3 client and connection pool.
301287
await store.set("probe", cpu.Buffer.from_bytes(b"x"))
302288

303289
store.close()
304290

305291
assert not store._is_open
306292

307-
def test_direct_construction_does_not_own_filesystem(self, endpoint_url: str) -> None:
308-
"""Direct FsspecStore() must not claim ownership — the caller owns the fs."""
309-
try:
310-
from fsspec import url_to_fs
311-
except ImportError:
312-
from fsspec.core import url_to_fs
313-
fs, path = url_to_fs(
314-
f"s3://{test_bucket_name}", endpoint_url=endpoint_url, anon=False, asynchronous=True
315-
)
316-
store = FsspecStore(fs=fs, path=path)
317-
assert not store._owns_fs
318-
319-
@pytest.mark.skipif(
320-
parse_version(fsspec.__version__) < parse_version("2024.03.01"),
321-
reason="Prior bug in from_upath",
322-
)
323-
def test_from_upath_does_not_own_filesystem(self, endpoint_url: str) -> None:
324-
"""from_upath() uses the UPath's existing fs; the store must not own it."""
325-
upath = pytest.importorskip("upath")
326-
path = upath.UPath(
327-
f"s3://{test_bucket_name}/foo/bar/",
328-
endpoint_url=endpoint_url,
329-
anon=False,
330-
asynchronous=True,
331-
)
332-
store = FsspecStore.from_upath(path)
333-
assert not store._owns_fs
334-
335-
def test_from_mapper_does_not_own_already_async_filesystem(self, endpoint_url: str) -> None:
336-
"""from_mapper() with an already-async fs must not claim ownership."""
337-
s3_filesystem = s3fs.S3FileSystem(
338-
asynchronous=True,
339-
endpoint_url=endpoint_url,
340-
anon=False,
341-
skip_instance_cache=True,
342-
)
343-
mapper = s3_filesystem.get_mapper(f"s3://{test_bucket_name}/")
344-
store = FsspecStore.from_mapper(mapper)
345-
# _make_async returns the same instance for an already-async fs.
346-
assert not store._owns_fs
347-
348293

349294
def array_roundtrip(store: FsspecStore) -> None:
350295
"""
@@ -574,80 +519,69 @@ def test_open_s3map_raises(endpoint_url: str) -> None:
574519
zarr.open(store=mapper, storage_options={"anon": True}, mode="w", shape=(3, 3))
575520

576521

577-
async def test_close_fs_closes_s3_client() -> None:
578-
"""
579-
_close_fs() must call set_session() and then close() on the returned
580-
S3 client. This is verified with mocks to avoid a real S3 connection.
581-
"""
582-
from unittest.mock import AsyncMock
522+
async def test_close_does_not_close_filesystem_session() -> None:
523+
"""close() must not touch the filesystem's session.
583524
584-
from zarr.storage._fsspec import _close_fs
525+
fsspec caches and shares filesystem instances across callers, so the
526+
session is not the store's to close. HTTP is used because its aiohttp
527+
session is observably closed for good; s3fs transparently reconnects, which
528+
would hide a regression. No request is issued — set_session() only
529+
constructs the session.
530+
"""
531+
pytest.importorskip("aiohttp")
532+
store = FsspecStore.from_url("http://example.com/a")
533+
session = await store.fs.set_session()
585534

586-
mock_client = AsyncMock()
587-
mock_fs = AsyncMock()
588-
mock_fs.set_session = AsyncMock(return_value=mock_client)
535+
store.close()
589536

590-
await _close_fs(mock_fs)
537+
assert not session.closed
591538

592-
mock_fs.set_session.assert_called_once()
593-
mock_client.close.assert_called_once()
594539

540+
async def test_close_does_not_break_a_sibling_store() -> None:
541+
"""Closing one store must not close a session another store is using.
595542
596-
async def test_close_fs_no_op_for_fs_without_set_session() -> None:
597-
"""_close_fs() must be a no-op for filesystems that don't expose set_session()."""
598-
from unittest.mock import AsyncMock
543+
Two stores from different URLs on one host are handed the same cached
544+
filesystem; a store that closed it on close() would take the sibling's
545+
session down too. This is the regression guard for that bug.
546+
"""
547+
pytest.importorskip("aiohttp")
548+
s1 = FsspecStore.from_url("http://example.com/a")
549+
s2 = FsspecStore.from_url("http://example.com/b")
550+
session = await s2.fs.set_session()
599551

600-
from zarr.storage._fsspec import _close_fs
552+
s1.close()
601553

602-
mock_fs = AsyncMock(spec=[]) # empty spec — no set_session attribute
603-
await _close_fs(mock_fs) # must not raise
554+
assert not session.closed
604555

605556

606557
@pytest.mark.skipif(
607558
parse_version(fsspec.__version__) < parse_version("2024.12.0"),
608559
reason="No AsyncFileSystemWrapper",
609560
)
610-
def test_from_mapper_owns_wrapped_sync_filesystem(tmp_path: pathlib.Path) -> None:
611-
"""
612-
from_mapper() with a sync fs must wrap it in AsyncFileSystemWrapper and
613-
claim ownership so that close() cleans it up.
614-
615-
The local filesystem is synchronous; _make_async() produces a new
616-
AsyncFileSystemWrapper instance — a different object from the original fs.
617-
"""
561+
def test_from_mapper_wraps_sync_filesystem(tmp_path: pathlib.Path) -> None:
562+
"""from_mapper() with a sync fs wraps it in an AsyncFileSystemWrapper."""
618563
import fsspec as _fsspec
619564
from fsspec.implementations.asyn_wrapper import AsyncFileSystemWrapper
620565

621566
fs = _fsspec.filesystem("file", auto_mkdir=True)
622567
mapper = fs.get_mapper(str(tmp_path))
623568
store = FsspecStore.from_mapper(mapper)
624569
assert isinstance(store.fs, AsyncFileSystemWrapper)
625-
assert store._owns_fs
626570

627571

628572
@pytest.mark.skipif(
629573
parse_version(fsspec.__version__) < parse_version("2024.12.0"),
630574
reason="No AsyncFileSystemWrapper",
631575
)
632-
def test_with_read_only_transfers_filesystem_ownership(tmp_path: pathlib.Path) -> None:
633-
"""
634-
with_read_only() must transfer fs ownership to the derived store and clear
635-
it on the source, so the surviving store closes the shared fs exactly once.
636-
637-
In the common ``from_url(...).with_read_only()`` chain the source store is
638-
immediately unreferenced; if ownership were not transferred, the only owner
639-
would be garbage-collected without close() and the session would leak.
640-
"""
576+
def test_with_read_only_shares_filesystem(tmp_path: pathlib.Path) -> None:
577+
"""with_read_only() returns a store sharing the source's filesystem."""
641578
source = FsspecStore.from_url(f"file://{tmp_path}", storage_options={"auto_mkdir": False})
642-
assert source._owns_fs
643579

644580
derived = source.with_read_only(read_only=True)
645581

646-
# Ownership moved to the survivor; the source no longer owns it (no double-close).
647-
assert derived._owns_fs
648-
assert not source._owns_fs
649-
# The derived store shares the same underlying fs.
650582
assert derived.fs is source.fs
583+
assert derived.read_only
584+
assert not source.read_only
651585

652586

653587
@pytest.mark.parametrize("asynchronous", [True, False])

0 commit comments

Comments
 (0)