Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/needs_release_notes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
pull-requests: write # Required to add labels to PRs
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
sync-labels: true
2 changes: 1 addition & 1 deletion .github/workflows/releases.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ jobs:
name: releases
path: dist
- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*
- name: Publish package to PyPI
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/zarr-metadata-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
path: dist

- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*

Expand All @@ -107,7 +107,7 @@ jobs:
path: dist

- name: Generate artifact attestation
uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
with:
subject-path: dist/*

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/zizmor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
persist-credentials: false

- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
uses: zizmorcore/zizmor-action@6599ee8b7a49aef6a770f63d261d214911a7ce02 # v0.6.0
10 changes: 10 additions & 0 deletions changes/247.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
`FusedCodecPipeline` no longer runs chunk IO and codec compute on the thread
driving zarr's internal event loop. Previously each read/write executed its
synchronous fast path inline on that loop thread, and because every sync-API
call from every user thread is serviced by the same loop, concurrent
operations serialized behind each other's codec work — reported as the fused
pipeline being slower than `BatchedCodecPipeline` for zstd-compressed data
under multi-threaded (e.g. dask) access. The synchronous batch now runs on a
worker thread (one hop per batch, not per chunk), keeping the loop free.
Multi-threaded single-chunk reads of zstd data are ~4.5x faster than before
and now scale with reader threads; single-threaded performance is unchanged.
4 changes: 4 additions & 0 deletions changes/4187.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
`ZipStore` now accepts an open binary file-like object in place of a path, enabling
zip archives on remote storage (e.g. a file opened with `fsspec` or an
`obstore.ReadableFile`). Operations that require a filesystem location
(`clear`, `move`) raise `NotImplementedError` for file-object-backed stores.
13 changes: 13 additions & 0 deletions docs/user-guide/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,19 @@ array = zarr.create_array(store=store, shape=(2,), dtype='float64')
print(array)
```

In place of a path, `ZipStore` also accepts an open binary file object (for
example a file opened with `fsspec`, or an `obstore` reader), enabling zip
archives on remote storage. The file must stay open for as long as the store
is in use:

```python exec="true" session="storage" source="above" result="ansi"
store.close()
f = open('data.zip', mode='rb') # must stay open while the store is used
array = zarr.open_array(store=zarr.storage.ZipStore(f), mode='r')
print(array[:])
f.close()
```

### Remote Store

The [`zarr.storage.FsspecStore`][] stores the contents of a Zarr hierarchy following the same
Expand Down
16 changes: 14 additions & 2 deletions src/zarr/core/codec_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -1182,7 +1182,15 @@ async def read(
(isinstance(first_bg, StorePath) and isinstance(first_bg.store, SupportsGetSync))
or (not isinstance(first_bg, StorePath) and isinstance(first_bg, SyncByteGetter))
):
return self.read_sync(batch, out, drop_axes, max_workers=_resolve_max_workers())
# One thread hop for the WHOLE batch — not per chunk, so the fused
# design's win over per-chunk async scheduling is preserved. Running
# read_sync inline here would block the event loop for the duration
# of the batch's IO+compute; every sync-API call from every user
# thread shares this one loop, so inline execution serializes
# concurrent callers behind each other's codec compute.
return await asyncio.to_thread(
self.read_sync, batch, out, drop_axes, max_workers=_resolve_max_workers()
)

# Non-sync store (e.g. ZipStore): can't use the sync fast path. But if the
# array-bytes codec supports partial decoding (sharding), still route
Expand Down Expand Up @@ -1235,7 +1243,11 @@ async def write(
(isinstance(first_bs, StorePath) and isinstance(first_bs.store, SupportsSetSync))
or (not isinstance(first_bs, StorePath) and isinstance(first_bs, SyncByteSetter))
):
self.write_sync(batch, value, drop_axes, max_workers=_resolve_max_workers())
# One thread hop for the whole batch; see the matching comment in
# `read` for why write_sync must not run inline on the event loop.
await asyncio.to_thread(
self.write_sync, batch, value, drop_axes, max_workers=_resolve_max_workers()
)
return

await _async_write_fallback(self, batch, value, drop_axes)
Expand Down
70 changes: 13 additions & 57 deletions src/zarr/storage/_fsspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import json
import warnings
from contextlib import suppress
from logging import getLogger
from typing import TYPE_CHECKING, Any

from packaging.version import parse as parse_version
Expand All @@ -19,8 +18,6 @@
from zarr.errors import ZarrUserWarning
from zarr.storage._utils import _dereference_path

logger = getLogger(__name__)

if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterable

Expand All @@ -38,26 +35,6 @@
)


async def _close_fs(fs: AsyncFileSystem) -> None:
"""
Best-effort async close of an fsspec async filesystem owned by FsspecStore.

For filesystems that expose `set_session()` (e.g. s3fs) the underlying
aiohttp `ClientSession` is closed explicitly, which prevents
"Unclosed client session" `ResourceWarning`s from aiohttp. For all
other filesystem types the call is a no-op (not every implementation
manages an HTTP session directly).

Note that `set_session()` lazily creates a session if none exists yet, so
closing a store that never performed any I/O may instantiate a session
purely to close it. This is accepted best-effort behavior; fsspec does not
expose a stable, cross-implementation way to test for an existing session.
"""
if hasattr(fs, "set_session"):
session = await fs.set_session()
await session.close()


def _make_async(fs: AbstractFileSystem) -> AsyncFileSystem:
"""Convert a sync FSSpec filesystem to an async FFSpec filesystem

Expand Down Expand Up @@ -126,6 +103,15 @@ class FsspecStore(Store):
ZarrUserWarning
If the file system (fs) was not created with `asynchronous=True`.

Notes
-----
Closing the store does not close the underlying filesystem or its network
session. fsspec caches and shares filesystem instances across callers, so
the store cannot know whether it is the only user, and closing a shared
session would break other stores. The filesystem's lifecycle belongs to
whoever created it; use fsspec's own tools (e.g. `clear_instance_cache`)
to release it.

See Also
--------
FsspecStore.from_upath
Expand All @@ -152,9 +138,6 @@ def __init__(
self.fs = fs
self.path = path
self.allowed_exceptions = allowed_exceptions
# True only when this store created fs itself (from_url / from_mapper with new instance).
# Callers who supply their own fs remain responsible for its lifecycle.
self._owns_fs: bool = False

if not self.fs.async_impl:
raise TypeError("Filesystem needs to support async operations.")
Expand Down Expand Up @@ -220,17 +203,13 @@ def from_mapper(
-------
FsspecStore
"""
original_fs = fs_map.fs
fs = _make_async(original_fs)
store = cls(
fs = _make_async(fs_map.fs)
return cls(
fs=fs,
path=fs_map.root,
read_only=read_only,
allowed_exceptions=allowed_exceptions,
)
# _make_async returns a new instance when converting sync→async; own it.
store._owns_fs = fs is not original_fs
return store

@classmethod
def from_url(
Expand Down Expand Up @@ -272,39 +251,16 @@ def from_url(
if not fs.async_impl:
fs = _make_async(fs)

store = cls(fs=fs, path=path, read_only=read_only, allowed_exceptions=allowed_exceptions)
store._owns_fs = True
return store
return cls(fs=fs, path=path, read_only=read_only, allowed_exceptions=allowed_exceptions)

def with_read_only(self, read_only: bool = False) -> FsspecStore:
# docstring inherited
new_store = type(self)(
return type(self)(
fs=self.fs,
path=self.path,
allowed_exceptions=self.allowed_exceptions,
read_only=read_only,
)
# The derived store shares the same fs. Transfer ownership so the
# surviving store closes it, and clear ours to avoid a double-close.
# Otherwise the common `from_url(...).with_read_only()` chain would
# drop the only owner (the unreferenced source) and leak the session.
new_store._owns_fs = self._owns_fs
self._owns_fs = False
return new_store

def close(self) -> None:
# docstring inherited
if self._owns_fs:
from zarr.core.sync import sync as zarr_sync

# Best-effort: a failure to release the session must not block close(),
# but log it so a genuine regression in the close path stays observable
# rather than silently reverting to the leaking behavior.
try:
zarr_sync(_close_fs(self.fs))
except Exception:
logger.debug("Failed to close owned filesystem %r", self.fs, exc_info=True)
super().close()

async def clear(self) -> None:
# docstring inherited
Expand Down
Loading
Loading