Skip to content

Commit cd7d264

Browse files
committed
fix(chunk-grids): enforce one zero-length-axis invariant across model, clamps and metadata
Invariant: a chunk edge length is always >= 1; a dimension's extent may be 0, in which case the dimension has zero chunks (ceildiv(0, size) == 0). Zero-length-axis bugs have recurred since 2017 (#150, #241, #303, zarr-developers#972, zarr-developers#1977, zarr-developers#2434, zarr-developers#3711, zarr-developers#4305, zarr-developers#4307, zarr-developers#4328) because the layers disagreed on this invariant and every span-derived chunk spelling clamped on its own: - The metadata layer (common.py, metadata/v3.py) required chunk edges >= 1, but the in-memory FixedDimension allowed size == 0 with four special-case branches left over from zarr-developers#2434, so normalization could build a grid the metadata constructor then rejected. FixedDimension now rejects size < 1 and the four `if self.size == 0` branches are gone. VaryingDimension already required edges > 0 and is unchanged. - `chunks=-1`, `chunks=False`, `chunks="auto"` (_guess_regular_chunks, both the typesize == 0 early return and the np.maximum line) and `shards="auto"` each derived "one chunk covering the axis" independently. They now all go through one helper, `_full_span_chunk_size(span) = max(span, 1)`, which is the single definition of that phrase for a possibly zero-length axis. - Zarr format 2 metadata had no chunk >= 1 check, so a legacy `chunks: [0]` document opened fine and read uninitialised memory after a resize. It now raises a clear ValueError at parse time, matching the format 3 grid. - Rectilinear grids had no creation-time spelling for a zero-length axis: normalize_chunks_1d required sum(edges) == span, which no list of positive edges can satisfy for span 0, even though the same state is reachable via resize((0,)) and round-trips through reopen. For span == 0 any non-empty list of positive edges is now accepted verbatim, producing the same VaryingDimension(edges, extent=0) that resize produces; the strict sum check is kept for span > 0. Tests: the per-spelling regression test from zarr-developers#4328 is replaced by one matrix over {-1, False, "auto", 1, (1,...), [[2, 2]]} x {(0,), (0, 4), (4, 0), (0, 0), ()} x {v2, v3} x {no shards, shards="auto" with and without a byte budget, explicit shards}, with separate small tests for each error case. Tests that constructed FixedDimension(size=0) now assert it raises, and a zero-extent test covers the behaviour the old special cases were guarding. Assisted-by: ClaudeCode:claude-fable-5-1
1 parent 10f8abe commit cd7d264

7 files changed

Lines changed: 284 additions & 123 deletions

File tree

changes/0000.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Zero-length dimensions are now handled by a single rule instead of a per-spelling patch: a chunk edge length is always at least 1, while a dimension's extent may be 0 (such a dimension simply has zero chunks). Every way of asking for "one chunk covering the axis" — `chunks=-1`, `chunks=False`, `chunks="auto"`, and `shards="auto"` — now derives the chunk size from the same helper, so they agree on chunk size 1 for a zero-length axis in both Zarr formats. Zarr format 2 metadata now rejects a chunk edge length of 0 with a clear error, matching Zarr format 3, instead of reading uninitialised data after a resize. Rectilinear chunk grids (`chunks=[[...], ...]`) can now be created on a zero-length dimension: since no list of positive edge lengths can sum to 0, the given edge lengths are stored as-is and describe the chunks the dimension will grow into on `append` or `resize`, exactly the state a rectilinear dimension is in after being resized down to 0. The private `FixedDimension(size=0, ...)` model, which previously carried its own zero-size special cases, now raises `ValueError`.

docs/user-guide/arrays.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,12 @@ z.append(np.arange(10, dtype='float64'))
708708
print(f"After append: shape={z.shape}, chunk_sizes={z.write_chunk_sizes}")
709709
```
710710

711+
A rectilinear array can also be created with a zero-length dimension: because no
712+
list of positive chunk sizes can sum to 0, the chunk sizes given for such a
713+
dimension are stored as-is and describe the chunks the dimension will grow into
714+
on `append` or `resize` — the same state as resizing an existing rectilinear
715+
dimension down to 0.
716+
711717
### Compressors and filters
712718

713719
Rectilinear arrays work with all codecs — compressors, filters, and checksums.

src/zarr/core/chunk_grids.py

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -45,22 +45,24 @@
4545
@dataclass(frozen=True)
4646
class FixedDimension:
4747
"""Uniform chunk size. Boundary chunks contain less data but are
48-
encoded at full size by the codec pipeline."""
48+
encoded at full size by the codec pipeline.
4949
50-
size: int # chunk edge length (>= 0)
51-
extent: int # array dimension length
50+
The chunk edge length is always at least 1, matching the invariant the
51+
metadata layer enforces for every stored chunk grid. The extent may be 0:
52+
a zero-length axis simply has zero chunks (``ceildiv(0, size) == 0``).
53+
"""
54+
55+
size: int # chunk edge length (>= 1)
56+
extent: int # array dimension length (>= 0)
5257
nchunks: int = field(init=False, repr=False)
5358
ngridcells: int = field(init=False, repr=False)
5459

5560
def __post_init__(self) -> None:
56-
if self.size < 0:
57-
raise ValueError(f"FixedDimension size must be >= 0, got {self.size}")
61+
if self.size < 1:
62+
raise ValueError(f"FixedDimension size must be >= 1, got {self.size}")
5863
if self.extent < 0:
5964
raise ValueError(f"FixedDimension extent must be >= 0, got {self.extent}")
60-
if self.size == 0:
61-
n = 0
62-
else:
63-
n = ceildiv(self.extent, self.size)
65+
n = ceildiv(self.extent, self.size)
6466
object.__setattr__(self, "nchunks", n)
6567
object.__setattr__(self, "ngridcells", n)
6668

@@ -69,8 +71,6 @@ def index_to_chunk(self, idx: int) -> int:
6971
raise IndexError(f"Negative index {idx} is not allowed")
7072
if idx >= self.extent:
7173
raise IndexError(f"Index {idx} is out of bounds for extent {self.extent}")
72-
if self.size == 0:
73-
return 0
7474
return idx // self.size
7575

7676
def chunk_offset(self, chunk_ix: int) -> int:
@@ -95,8 +95,6 @@ def data_size(self, chunk_ix: int) -> int:
9595
Does not validate *chunk_ix* — callers must ensure it is in
9696
``[0, nchunks)``. Use ``ChunkGrid.__getitem__`` for safe access.
9797
"""
98-
if self.size == 0:
99-
return 0
10098
return max(0, min(self.size, self.extent - chunk_ix * self.size))
10199

102100
@property
@@ -110,8 +108,6 @@ def _unique_edge_lengths(self) -> Iterable[int]:
110108
return (self.size,)
111109

112110
def indices_to_chunks(self, indices: npt.NDArray[np.intp]) -> npt.NDArray[np.intp]:
113-
if self.size == 0:
114-
return np.zeros_like(indices)
115111
return indices // self.size
116112

117113
def with_extent(self, new_extent: int) -> FixedDimension:
@@ -640,6 +636,20 @@ class ChunkLayout(NamedTuple):
640636
inner: ChunkLayout | None = None
641637

642638

639+
def _full_span_chunk_size(span: int) -> int:
640+
"""The edge length of one chunk covering an entire axis of length *span*.
641+
642+
This is *the* definition of "one chunk spans the axis" for a possibly
643+
zero-length axis. Chunk edge lengths must be at least 1 (the invariant
644+
shared by `FixedDimension`, `VaryingDimension` and the stored chunk grid
645+
metadata), so a zero-length axis gets chunk size 1 and zero chunks. Every
646+
spelling that derives a chunk size from a span — ``chunks=-1``,
647+
``chunks=False``, ``chunks="auto"``, ``shards="auto"`` — must route
648+
through this helper rather than clamping on its own.
649+
"""
650+
return max(span, 1)
651+
652+
643653
def _guess_regular_chunks(
644654
shape: tuple[int, ...] | int,
645655
typesize: int,
@@ -677,11 +687,10 @@ def _guess_regular_chunks(
677687
shape = (shape,)
678688

679689
if typesize == 0:
680-
return shape
690+
return tuple(_full_span_chunk_size(s) for s in shape)
681691

682692
ndims = len(shape)
683-
# require chunks to have non-zero length for all dimensions
684-
chunks = np.maximum(np.array(shape, dtype="=f8"), 1)
693+
chunks = np.array([_full_span_chunk_size(s) for s in shape], dtype="=f8")
685694

686695
# Determine the optimal chunk size in bytes using a PyTables expression.
687696
# This is kept as a float.
@@ -724,13 +733,21 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
724733
the span, and the uniform form is O(1) in the number of chunks — a
725734
dimension with `2**62` chunks must not materialize one entry per chunk.
726735
727-
`-1` means "one chunk covering the entire span."
736+
`-1` means "one chunk covering the entire span" (see `_full_span_chunk_size`
737+
for what that means on a zero-length span).
728738
Explicit chunk size lists must sum to the span exactly and always produce
729739
`VaryingDimension`, even when the sizes happen to be uniform: the input
730740
syntax declares the grid kind, so a per-chunk list is preserved as a
731741
rectilinear dimension rather than silently collapsed to a regular one,
732742
which would change how the dimension grows on resize. For scalar sizes
733743
the last chunk may overhang the span.
744+
745+
The one exception to the sum rule is a zero-length span: no list of
746+
positive edges can sum to 0, so any non-empty list is accepted verbatim
747+
and the edges describe the chunks the axis will grow into on `append` /
748+
`resize`. This is the same state a rectilinear axis reaches when it is
749+
resized down to 0 — `VaryingDimension` allows trailing edges beyond the
750+
extent — so creating at length 0 and shrinking to 0 are indistinguishable.
734751
"""
735752
# `numbers.Integral` rather than `int` so that numpy integer scalars (which are not
736753
# `int` subclasses) take the uniform-chunk path instead of being treated as a sequence.
@@ -741,9 +758,7 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
741758
if chunk_size < -1 or chunk_size == 0:
742759
raise ValueError(f"Chunk size must be positive or -1, got {chunk_size}")
743760
if chunk_size == -1:
744-
# A zero-length span still gets chunk size 1 (chunk sizes must be positive),
745-
# matching the auto-chunking clamp in _guess_regular_chunks.
746-
return FixedDimension(size=max(span, 1), extent=span)
761+
return FixedDimension(size=_full_span_chunk_size(span), extent=span)
747762
return FixedDimension(size=chunk_size, extent=span)
748763
else:
749764
try:
@@ -768,7 +783,9 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
768783
ints: list[int] = [int(c) for c in chunk_list] # type: ignore[call-overload]
769784
if any(c <= 0 for c in ints):
770785
raise ValueError(f"All chunk sizes must be positive, got {ints}")
771-
if sum(ints) != span:
786+
# A zero-length span cannot be covered by positive edges; the edges are the
787+
# chunks the axis will grow into, exactly as after ``resize(0)``.
788+
if span > 0 and sum(ints) != span:
772789
raise ValueError(f"Chunk sizes {ints} do not sum to span {span}")
773790
return VaryingDimension(ints, extent=span)
774791

@@ -809,7 +826,7 @@ def normalize_chunks_nd(
809826
)
810827

811828
# handle no chunking: one chunk covering every axis. Routed through the -1 sentinel so
812-
# the zero-length-axis clamp lives in one place (normalize_chunks_1d).
829+
# the zero-length-axis rule lives in one place (_full_span_chunk_size).
813830
if chunks is False:
814831
chunks = -1
815832

@@ -864,8 +881,10 @@ def _guess_num_chunks_per_axis_shard(
864881
In other words the shard would be a (2,2,2) grid of (2,2,2) chunks
865882
i.e., prod(chunk_shape) * (returned_val ** len(chunk_shape)) * item_size = 256 bytes.
866883
867-
Degenerate chunk shapes — a 0-dimensional shape, or one containing a zero-length
868-
axis — return 1, as the search loop's stopping conditions can never be met.
884+
Degenerate inputs — a 0-dimensional chunk shape, or a zero-byte chunk (``item_size``
885+
of 0; chunk edge lengths themselves are always at least 1) — return 1, as the
886+
search loop's stopping conditions can never be met. A zero-length *array* axis
887+
needs no special case: the array-bound check fails immediately for it.
869888
870889
Parameters
871890
----------
@@ -886,8 +905,8 @@ def _guess_num_chunks_per_axis_shard(
886905
if max_bytes < bytes_per_chunk:
887906
return 1
888907
num_axes = len(chunk_shape)
889-
# For a 0-dimensional chunk shape or one with a zero-length axis, both loop
890-
# conditions below are constant, so the loop would never terminate.
908+
# For a 0-dimensional chunk shape or a zero-byte chunk, both loop conditions
909+
# below are constant, so the loop would never terminate.
891910
if num_axes == 0 or bytes_per_chunk == 0:
892911
return 1
893912
chunks_per_shard = 1

src/zarr/core/metadata/v2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,13 @@ def __init__(
8989
"""
9090
shape_parsed = parse_shapelike(shape)
9191
chunks_parsed = parse_shapelike(chunks)
92+
# Same invariant as the Zarr format 3 chunk grid metadata: every chunk edge
93+
# length is at least 1, even on a zero-length axis.
94+
for dim_idx, chunk in enumerate(chunks_parsed):
95+
if chunk < 1:
96+
raise ValueError(
97+
f"Dimension {dim_idx}: chunk edge length must be >= 1, got {chunk}"
98+
)
9299
compressor_parsed = parse_compressor(compressor)
93100
order_parsed = parse_indexing_order(order)
94101
dimension_separator_parsed = parse_separator(dimension_separator)

0 commit comments

Comments
 (0)