Skip to content

Commit e02437c

Browse files
d-v-bclaude
andcommitted
fix: set_range capability detection, bounds check, and managed path prefix
Layered on top of the per-key locking from #178. Addresses branch-review findings not covered by that change: - GpuMemoryStore must not satisfy SupportsSetRange: the inherited MemoryStore implementation mutates a host copy of the GPU buffer and would silently lose the write. MemoryStore/LocalStore now satisfy the protocol *structurally* (dropped the nominal `SupportsSetRange` base), so GpuMemoryStore disclaims set_range/set_range_sync by setting them to None and isinstance(gpu_store, SupportsSetRange) is now False. - Uniform out-of-bounds handling: add _check_set_range_bounds (storage/_utils), enforcing start + len(value) <= len(existing) in both MemoryStore and LocalStore so a too-large write raises a clear ValueError instead of LocalStore silently extending the file or MemoryStore raising an opaque numpy error. Matches zarrs, which validates ranges rather than extending. - ManagedMemoryStore.set_range/set_range_sync now apply the store's path prefix like its other key-bearing methods, so a path-prefixed store targets the correct key. - Expand the SupportsSetRange docstring with the caller-owns-consistency concurrency contract and record the opt-in-protocol-vs-universal-method design decision; update the changelog. Adds out-of-bounds tests (MemoryStore, LocalStore), a GpuMemoryStore capability test, and a path-prefixed ManagedMemoryStore set_range test. Verified on GPU hardware. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 2db2c9a commit e02437c

7 files changed

Lines changed: 163 additions & 11 deletions

File tree

‎changes/3907.feature.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
Add protocols for stores that support byte-range-writes. This is necessary to support in-place writes of sharded arrays.
1+
Add the `SupportsSetRange` protocol for stores that support writing to a byte range within an existing value, implemented by `LocalStore` and `MemoryStore`. This is necessary to support in-place writes of sharded arrays (e.g. writing a single subchunk without rewriting the entire shard).
2+
3+
Byte-range writes are exposed as an opt-in protocol rather than a method on the `Store` ABC. Only a few stores can perform them natively, and most cannot. A universal method with a read-modify-write fallback (as in the Rust `zarrs` crate) would let every store participate, but for the motivating use case that fallback would silently rewrite an entire shard, defeating the purpose. The opt-in protocol keeps the cost model honest and keeps `set_range` out of the signatures of stores that will never support it; any fallback strategy is left to the caller (the sharding codec). Stores satisfy the protocol structurally, so `GpuMemoryStore` (which has no use case for in-place GPU byte-range writes) disclaims it and is correctly reported as unsupported by `isinstance`.
4+
5+
It is entirely the caller's responsibility to ensure consistency: concurrent writes to overlapping ranges are order-dependent, `set_range` racing against `set`/`delete` is a race, and writes are not guaranteed to be atomic with respect to a process crash. A write that does not fit within the existing value raises `ValueError` consistently across implementations.

‎src/zarr/abc/store.py‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -788,16 +788,55 @@ async def delete(self) -> None: ...
788788
async def set_if_not_exists(self, default: Buffer) -> None: ...
789789

790790

791+
# Design note: byte-range writes are exposed as an opt-in protocol rather than a
792+
# method on the `Store` ABC. Only a few stores can do them natively (`LocalStore`,
793+
# `MemoryStore`); most (cloud, zip, read-only) cannot. A universal `Store.set_range`
794+
# with a read-modify-write fallback (as in the Rust `zarrs` crate's
795+
# `WritableStorageTraits::set_partial` + `supports_set_partial`) would let every
796+
# store participate, but for our motivating use case — writing one subchunk without
797+
# rewriting the whole shard — that fallback is a footgun: it would silently rewrite
798+
# an entire (possibly multi-GB) shard, defeating the purpose while appearing to
799+
# succeed. An opt-in protocol instead keeps the cost model honest (a store either
800+
# supports cheap ranged writes or doesn't advertise the capability at all) and keeps
801+
# `set_range` out of the signatures of stores that will never support it.
802+
#
803+
# Stores satisfy this protocol *structurally* (by defining the methods), not by
804+
# nominal inheritance, so a subclass can disclaim it by setting the methods to `None`
805+
# (see `GpuMemoryStore`). Any read-modify-write fallback strategy belongs in the
806+
# caller (the sharding codec), which already has to decide between in-place and
807+
# buffer-and-rewrite — mirroring the zarrs layering (storage writes bytes, codec owns
808+
# strategy) without making every store carry the method. If broad-backend partial
809+
# encoding is wanted later, adding a `supports_set_range()` capability flag plus a
810+
# codec-level fallback is an additive change that does not require retrofitting stores.
791811
@runtime_checkable
792812
class SupportsSetRange(Protocol):
793813
"""Protocol for stores that support writing to a byte range within an existing value.
794814
795-
Overwrites ``len(value)`` bytes starting at byte offset ``start`` within the
796-
existing stored value for ``key``. The key must already exist and the write
797-
must fit within the existing value (i.e., ``start + len(value) <= len(existing)``).
798-
799-
Behavior when the write extends past the end of the existing value is
800-
implementation-specific and should not be relied upon.
815+
Overwrites `len(value)` bytes starting at byte offset `start` within the
816+
existing stored value for `key`. The key must already exist and the write
817+
must fit within the existing value (i.e., `start + len(value) <= len(existing)`);
818+
a write that does not fit raises `ValueError`.
819+
820+
Concurrency and atomicity
821+
-------------------------
822+
**It is entirely the caller's responsibility to ensure consistency.** Any
823+
coordination needed to keep stored values consistent must be arranged by the
824+
caller. In particular:
825+
826+
- Concurrent `set_range` calls that write to **disjoint** byte ranges of the
827+
same key are safe.
828+
- Concurrent `set_range` calls that write to **overlapping** ranges of the same
829+
key have order-dependent, unspecified results. The caller must serialize them.
830+
- A `set_range` racing against a `set` or `delete` on the same key is a race
831+
condition, just as concurrent `set` calls are. The caller must serialize these.
832+
- Writes are **not** guaranteed to be atomic with respect to a process crash:
833+
a crash mid-write may leave the value partially updated. The caller is
834+
responsible for any durability or recovery guarantees it requires.
835+
836+
What an implementation does to honor (or fall short of) this contract — locking,
837+
atomic replacement, and so on — is documented on the implementing store, not here.
838+
The intended consumer (the sharding codec writing inner chunks of deterministic
839+
compressed size) coordinates writes so that they target disjoint ranges.
801840
"""
802841

803842
async def set_range(self, key: str, value: Buffer, start: int) -> None: ...

‎src/zarr/storage/_local.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,11 @@
1818
RangeByteRequest,
1919
Store,
2020
SuffixByteRequest,
21-
SupportsSetRange,
2221
)
2322
from zarr.core.buffer import Buffer
2423
from zarr.core.buffer.core import default_buffer_prototype
2524
from zarr.core.common import AccessModeLiteral, concurrent_map
25+
from zarr.storage._utils import _check_set_range_bounds
2626

2727
if TYPE_CHECKING:
2828
from collections.abc import AsyncIterator, Iterable, Iterator
@@ -96,6 +96,12 @@ def _put_range(path: Path, value: Buffer, start: int) -> None:
9696
"""Write bytes at a specific offset within an existing file."""
9797
view = value.as_buffer_like()
9898
with path.open("r+b") as f:
99+
# Validate bounds before writing: a bare seek+write would silently extend the
100+
# file (zero-filling any gap), but the SupportsSetRange contract requires the
101+
# write to fit within the existing value, so we fail consistently with
102+
# MemoryStore instead.
103+
existing_length = f.seek(0, os.SEEK_END)
104+
_check_set_range_bounds(existing_length, start, len(value))
99105
f.seek(start)
100106
f.write(view)
101107

@@ -108,7 +114,7 @@ def _put(path: Path, value: Buffer, exclusive: bool = False) -> int:
108114
return f.write(view)
109115

110116

111-
class LocalStore(Store, SupportsSetRange):
117+
class LocalStore(Store):
112118
"""
113119
Store for the local file system.
114120

‎src/zarr/storage/_memory.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
from logging import getLogger
88
from typing import TYPE_CHECKING, Any, Self
99

10-
from zarr.abc.store import ByteRequest, Store, SupportsSetRange
10+
from zarr.abc.store import ByteRequest, Store
1111
from zarr.core.buffer import Buffer, gpu
1212
from zarr.core.buffer.core import default_buffer_prototype
1313
from zarr.core.common import concurrent_map
1414
from zarr.storage._utils import (
15+
_check_set_range_bounds,
1516
_join_paths,
1617
_normalize_byte_range_index,
1718
normalize_path,
@@ -27,7 +28,7 @@
2728
logger = getLogger(__name__)
2829

2930

30-
class MemoryStore(Store, SupportsSetRange):
31+
class MemoryStore(Store):
3132
"""
3233
Store for local memory.
3334
@@ -202,6 +203,7 @@ async def delete(self, key: str) -> None:
202203
def _set_range_impl(self, key: str, value: Buffer, start: int) -> None:
203204
buf = self._store_dict[key]
204205
target = buf.as_numpy_array()
206+
_check_set_range_bounds(len(target), start, len(value))
205207
if not target.flags.writeable:
206208
target = target.copy()
207209
self._store_dict[key] = buf.__class__(target)
@@ -566,6 +568,19 @@ async def set(self, key: str, value: Buffer, byte_range: tuple[int, int] | None
566568
gpu_value = value if isinstance(value, gpu.Buffer) else gpu.Buffer.from_buffer(value)
567569
await super().set(key, gpu_value, byte_range=byte_range)
568570

571+
# ``GpuMemoryStore`` deliberately does not support byte-range writes, so it must
572+
# not satisfy ``SupportsSetRange``. The inherited ``MemoryStore`` implementation
573+
# mutates a *host* copy of the GPU buffer (via ``as_numpy_array``) and would
574+
# silently lose the write, and there is no use case for in-place byte-range writes
575+
# into GPU memory (the intended ``set_range`` consumer targets cpu/local storage).
576+
# Disclaiming the inherited methods by setting them to ``None`` makes
577+
# ``isinstance(gpu_store, SupportsSetRange)`` return ``False`` (``runtime_checkable``
578+
# treats a ``None`` attribute as "method absent"). This works because
579+
# ``MemoryStore`` satisfies the protocol structurally rather than by nominal
580+
# inheritance; mirrors the ``__hash__ = None`` idiom.
581+
set_range = None # type: ignore[assignment]
582+
set_range_sync = None # type: ignore[assignment]
583+
569584

570585
# -----------------------------------------------------------------------------
571586
# ManagedMemoryStore and its registry
@@ -860,6 +875,14 @@ async def set_if_not_exists(self, key: str, value: Buffer) -> None:
860875
# docstring inherited
861876
return await super().set_if_not_exists(_join_paths([self.path, key]), value)
862877

878+
async def set_range(self, key: str, value: Buffer, start: int) -> None:
879+
# docstring inherited
880+
return await super().set_range(_join_paths([self.path, key]), value, start)
881+
882+
def set_range_sync(self, key: str, value: Buffer, start: int) -> None:
883+
# docstring inherited
884+
return super().set_range_sync(_join_paths([self.path, key]), value, start)
885+
863886
async def delete(self, key: str) -> None:
864887
# docstring inherited
865888
return await super().delete(_join_paths([self.path, key]))

‎src/zarr/storage/_utils.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,23 @@ def _normalize_byte_range_index(data: Buffer, byte_range: ByteRequest | None) ->
160160
return (start, stop)
161161

162162

163+
def _check_set_range_bounds(existing_length: int, start: int, value_length: int) -> None:
164+
"""
165+
Validate that a ``set_range`` write fits within an existing value.
166+
167+
Stores implementing ``SupportsSetRange`` use this so the out-of-bounds case fails
168+
the same way everywhere (a clear ``ValueError``) rather than silently extending the
169+
value (as a file seek+write would) or raising an opaque numpy shape error.
170+
"""
171+
if start < 0:
172+
raise ValueError(f"set_range start must be non-negative, got {start}.")
173+
if start + value_length > existing_length:
174+
raise ValueError(
175+
f"set_range write of {value_length} bytes at offset {start} does not fit "
176+
f"within the existing value of length {existing_length}."
177+
)
178+
179+
163180
def _join_paths(paths: Iterable[str]) -> str:
164181
"""
165182
Filter out instances of '' and join the remaining strings with '/'.

‎tests/test_store/test_local.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,23 @@ def test_set_range_sync(
213213
assert result is not None
214214
assert result.to_bytes() == expected
215215

216+
@pytest.mark.parametrize(
217+
("start", "patch"),
218+
[(9, b"XX"), (10, b"X"), (0, b"ZZZZZZZZZZZ")],
219+
ids=["overhang", "past-end", "too-long"],
220+
)
221+
async def test_set_range_out_of_bounds(
222+
self, store: LocalStore, start: int, patch: bytes
223+
) -> None:
224+
"""A write that does not fit within the existing value raises, not extends."""
225+
await store.set("test/key", cpu.Buffer.from_bytes(b"AAAAAAAAAA"))
226+
with pytest.raises(ValueError, match="does not fit within the existing value"):
227+
await store.set_range("test/key", cpu.Buffer.from_bytes(patch), start=start)
228+
# The file is left unchanged (not zero-filled / extended).
229+
result = await store.get("test/key", prototype=cpu.buffer_prototype)
230+
assert result is not None
231+
assert result.to_bytes() == b"AAAAAAAAAA"
232+
216233
async def test_set_range_not_open(self, store_not_open: LocalStore) -> None:
217234
"""set_range auto-opens a closed store."""
218235
assert not store_not_open._is_open

‎tests/test_store/test_memory.py‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,20 @@ def test_set_range_sync(
178178
assert result is not None
179179
assert result.to_bytes() == expected
180180

181+
@pytest.mark.parametrize(
182+
("start", "patch"),
183+
[(9, b"XX"), (10, b"X"), (0, b"ZZZZZZZZZZZ")],
184+
ids=["overhang", "past-end", "too-long"],
185+
)
186+
async def test_set_range_out_of_bounds(
187+
self, store: MemoryStore, start: int, patch: bytes
188+
) -> None:
189+
"""A write that does not fit within the existing value raises consistently."""
190+
store._store_dict["test/key"] = cpu.Buffer.from_bytes(b"AAAAAAAAAA")
191+
with pytest.raises(ValueError, match="does not fit within the existing value"):
192+
await store.set_range("test/key", cpu.Buffer.from_bytes(patch), start=start)
193+
assert store._store_dict["test/key"].to_bytes() == b"AAAAAAAAAA"
194+
181195
async def test_set_range_not_open(self, store_not_open: MemoryStore) -> None:
182196
"""set_range auto-opens a closed store."""
183197
assert not store_not_open._is_open
@@ -296,6 +310,18 @@ def test_from_dict(self) -> None:
296310
for v in result._store_dict.values():
297311
assert type(v) is gpu.Buffer
298312

313+
def test_set_range_not_supported(self, store: GpuMemoryStore) -> None:
314+
"""GpuMemoryStore deliberately does not satisfy SupportsSetRange.
315+
316+
Capability detection via isinstance must report False so a consumer (e.g. the
317+
sharding codec) does not select it and crash. The methods are disclaimed
318+
(set to None), so isinstance returns False rather than a false positive.
319+
"""
320+
# mypy statically knows GpuMemoryStore cannot satisfy the protocol (the methods
321+
# are None), which is exactly what we want — but it then flags this runtime
322+
# assertion as unreachable. Keep the runtime check as a regression guard.
323+
assert not isinstance(store, SupportsSetRange) # type: ignore[unreachable]
324+
299325

300326
class TestManagedMemoryStore(StoreTests[ManagedMemoryStore, cpu.Buffer]):
301327
store_cls = ManagedMemoryStore
@@ -565,6 +591,26 @@ async def test_path_prefix_operations(self) -> None:
565591
assert result2 is not None
566592
assert result2.to_bytes() == b"value"
567593

594+
def test_supports_set_range(self, store: ManagedMemoryStore) -> None:
595+
assert isinstance(store, SupportsSetRange)
596+
597+
async def test_set_range_applies_path_prefix(self) -> None:
598+
"""set_range must prepend the store's path prefix, matching set/get.
599+
600+
Regression: an unprefixed inherited set_range would target the wrong key.
601+
"""
602+
store = ManagedMemoryStore(name="set-range-path-test", path="subdir")
603+
await store.set("k", self.buffer_cls.from_bytes(b"AAAAAAAAAA"))
604+
# set() writes to the prefixed backing key.
605+
assert "subdir/k" in store._store_dict
606+
await store.set_range("k", self.buffer_cls.from_bytes(b"XX"), start=2)
607+
store.set_range_sync("k", self.buffer_cls.from_bytes(b"YY"), start=6)
608+
# Both writes landed on the same prefixed value that set/get use.
609+
observed = await store.get("k")
610+
assert observed is not None
611+
assert observed.to_bytes() == b"AAXXAAYYAA"
612+
assert store._store_dict["subdir/k"].to_bytes() == b"AAXXAAYYAA"
613+
568614
async def test_path_list_operations(self) -> None:
569615
"""Test that list operations filter by path prefix."""
570616
store = ManagedMemoryStore(name="list-test")

0 commit comments

Comments
 (0)