Skip to content

Commit 535fa0a

Browse files
d-v-bCAOShurong
andcommitted
fix(chunk-grids): keep explicit per-chunk lists rectilinear
Drop the regular-grid collapse for explicit list input in normalize_chunks_1d: a per-chunk size list now always produces VaryingDimension, even when the sizes are uniform or uniform plus a short tail. Scalar specs (ints, numpy integers, and the -1 sentinel) still produce FixedDimension, which is the path that makes array creation O(1) in per-dimension chunk count; explicit lists are already O(n) in the input, so nothing is lost. The grid kind now follows the input syntax, matching 3.2.x behavior: previously an explicitly rectilinear spec whose edges happened to look regular was silently stored as RegularChunkGridMetadata, which changes resize semantics — a regular grid grows by extending the uniform pattern while a rectilinear grid appends an edge chunk, breaking append-oriented layouts like (168,) * 13 + (24,). This is resolution option 1 from gh-4272. Uniform dimensions of mixed scalar/list specs still serialize as the spec's bare-int step-size shorthand; explicit lists serialize as edge lists. The touched-chunk-keys assertions in the resize regression test follow the approach from gh-4290, which fixes the same issue on main via a requested_rectilinear flag. Fixes #4272 Co-authored-by: Shurong Cao <CAOShurong@users.noreply.github.com> Assisted-by: ClaudeCode:claude-fable-5
1 parent d3c5c17 commit 535fa0a

5 files changed

Lines changed: 127 additions & 28 deletions

File tree

‎changes/4272.bugfix.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Explicit per-chunk size lists now always produce a rectilinear chunk grid,
2+
even when the sizes happen to describe a regular grid (all equal, or all equal
3+
with a smaller trailing chunk). Previously such input was silently collapsed to
4+
a regular grid, which changed resize semantics: a regular grid grows by
5+
extending the uniform pattern, while a rectilinear grid appends a new edge
6+
chunk — the behavior an append-oriented layout like `(168,) * 13 + (24,)`
7+
relies on. The grid kind now follows the input syntax, matching 3.2.x:
8+
scalar chunk sizes (including numpy integers and the `-1` sentinel) produce a
9+
regular grid, nested sequences produce a rectilinear grid. Rectilinear grids
10+
remain gated behind `zarr.config.set({"array.rectilinear_chunks": True})`.
11+
See #4174 for the accompanying O(1) chunk normalization change.

‎src/zarr/core/chunk_grids.py‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -703,16 +703,18 @@ def _guess_regular_chunks(
703703
def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionGrid:
704704
"""
705705
Normalize a one-dimensional chunk specification into a dimension grid:
706-
`FixedDimension` for uniform chunk sizes, `VaryingDimension` for explicit
707-
per-chunk sizes that genuinely vary. Both variants bind the chunk sizes to
706+
`FixedDimension` for scalar chunk sizes, `VaryingDimension` for explicit
707+
per-chunk size lists. Both variants bind the chunk sizes to
708708
the span, and the uniform form is O(1) in the number of chunks — a
709709
dimension with `2**62` chunks must not materialize one entry per chunk.
710710
711711
`-1` means "one chunk covering the entire span."
712-
Explicit chunk size lists must sum to the span exactly; lists that describe
713-
a regular grid (all sizes equal, or equal with a smaller boundary chunk)
714-
collapse to `FixedDimension`. For uniform sizes the last chunk may overhang
715-
the span.
712+
Explicit chunk size lists must sum to the span exactly and always produce
713+
`VaryingDimension`, even when the sizes happen to be uniform: the input
714+
syntax declares the grid kind, so a per-chunk list is preserved as a
715+
rectilinear dimension rather than silently collapsed to a regular one,
716+
which would change how the dimension grows on resize. For scalar sizes
717+
the last chunk may overhang the span.
716718
"""
717719
# `numbers.Integral` rather than `int` so that numpy integer scalars (which are not
718720
# `int` subclasses) take the uniform-chunk path instead of being treated as a sequence.
@@ -750,8 +752,6 @@ def normalize_chunks_1d(chunks: int | Iterable[object], span: int) -> DimensionG
750752
raise ValueError(f"All chunk sizes must be positive, got {ints}")
751753
if sum(ints) != span:
752754
raise ValueError(f"Chunk sizes {ints} do not sum to span {span}")
753-
if is_regular_1d(ints):
754-
return FixedDimension(size=ints[0], extent=span)
755755
return VaryingDimension(ints, extent=span)
756756

757757

‎tests/test_chunk_grids.py‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ def test_guess_chunks(shape: tuple[int, ...], itemsize: int) -> None:
7373
((30, -1, -1), (100, 20, 10), (30, 20, 10)),
7474
((30, 20, -1), (100, 20, 10), (30, 20, 10)),
7575
((30, 20, 10), (100, 20, 10), (30, 20, 10)),
76-
# dask-style chunks describing a regular grid collapse to the uniform form
77-
(((100, 100, 100), (50, 50)), (300, 100), (100, 50)),
78-
(((100, 100, 50),), (250,), (100,)),
79-
(((100,),), (100,), (100,)),
80-
# genuinely irregular explicit chunks keep the per-chunk form
81-
(((10, 20, 70), (50, 50)), (100, 100), ((10, 20, 70), 50)),
76+
# dask-style explicit lists always keep the per-chunk rectilinear form,
77+
# even when the sizes describe a regular grid (gh-4272)
78+
(((100, 100, 100), (50, 50)), (300, 100), ((100, 100, 100), (50, 50))),
79+
(((100, 100, 50),), (250,), ((100, 100, 50),)),
80+
(((100,),), (100,), ((100,),)),
81+
(((10, 20, 70), (50, 50)), (100, 100), ((10, 20, 70), (50, 50))),
8282
# no chunking (False means each dimension is one chunk spanning the full extent)
8383
(False, (100,), (100,)),
8484
(False, (100, 50), (100, 50)),
@@ -96,7 +96,7 @@ def test_guess_chunks(shape: tuple[int, ...], itemsize: int) -> None:
9696
((np.int32(30), np.int64(-1)), (100, 20), (30, 20)),
9797
(np.array([10, 10]), (100, 100), (10, 10)),
9898
# rectilinear chunks given as numpy arrays
99-
((np.array([60, 40]), np.array([50, 50])), (100, 100), (60, 50)),
99+
((np.array([60, 40]), np.array([50, 50])), (100, 100), ((60, 40), (50, 50))),
100100
],
101101
)
102102
def test_normalize_chunks(
@@ -112,9 +112,9 @@ def test_normalize_chunks(
112112
((100,), (10,), None, (10,), None),
113113
# explicit regular shards
114114
((100,), (10,), (50,), (50,), (10,)),
115-
# rectilinear shards describing a regular-with-boundary grid collapse
116-
((100,), (10,), ((60, 40),), (60,), (10,)),
117-
# genuinely irregular rectilinear shards keep the per-chunk form
115+
# rectilinear shards keep the per-chunk form even when the sizes
116+
# describe a regular-with-boundary grid (gh-4272)
117+
((100,), (10,), ((60, 40),), ((60, 40),), (10,)),
118118
((100,), (10,), ((30, 60, 10),), ((30, 60, 10),), (10,)),
119119
# dict-style shards
120120
((100, 100), (10, 10), {"shape": (50, 50)}, (50, 50), (10, 10)),
@@ -291,18 +291,18 @@ def test_normalize_chunks_nd_errors(case: ExpectFail[tuple[Any, tuple[int, ...]]
291291
Expect(input=(-1, 100), output=FixedDimension(size=100, extent=100), id="full-span"),
292292
# zero-length span preserves the declared chunk size.
293293
Expect(input=(10, 0), output=FixedDimension(size=10, extent=0), id="uniform-zero-span"),
294-
# explicit lists that describe a regular grid collapse to the uniform form.
294+
# explicit lists always keep the per-chunk form, even when the sizes
295+
# describe a regular grid (gh-4272).
295296
Expect(
296297
input=([10, 10, 10], 30),
297-
output=FixedDimension(size=10, extent=30),
298+
output=VaryingDimension([10, 10, 10], extent=30),
298299
id="explicit-regular",
299300
),
300301
Expect(
301302
input=([10, 10, 5], 25),
302-
output=FixedDimension(size=10, extent=25),
303+
output=VaryingDimension([10, 10, 5], extent=25),
303304
id="explicit-boundary",
304305
),
305-
# genuinely irregular edges keep the explicit per-chunk form.
306306
Expect(
307307
input=([10, 20, 70], 100),
308308
output=VaryingDimension([10, 20, 70], extent=100),
@@ -314,8 +314,8 @@ def test_normalize_chunks_nd_errors(case: ExpectFail[tuple[Any, tuple[int, ...]]
314314
def test_normalize_chunks_1d(
315315
case: Expect[tuple[Any, int], FixedDimension | VaryingDimension],
316316
) -> None:
317-
"""Both output variants bind chunk sizes to the span: uniform specs become
318-
`FixedDimension` (O(1) regardless of chunk count), irregular explicit
317+
"""Both output variants bind chunk sizes to the span: scalar specs become
318+
`FixedDimension` (O(1) regardless of chunk count), explicit per-chunk
319319
lists become `VaryingDimension`."""
320320
chunks, span = case.input
321321
assert normalize_chunks_1d(chunks, span) == case.output

‎tests/test_properties.py‎

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,7 @@ def test_array_metadata_meets_spec(meta: ArrayV2Metadata | ArrayV3Metadata) -> N
452452

453453
def test_chunks_param_from_rectilinear_bare_int_roundtrip() -> None:
454454
"""Bare-int dims in rectilinear metadata (the spec's step-size shorthand,
455-
produced when a uniform dimension of a mixed grid is collapsed) must pass
455+
produced by a scalar dimension of a mixed chunk spec) must pass
456456
through the `chunks=` conversion unchanged. Wrapping one in a
457457
single-element list turns "repeat to cover the axis" into "exactly one
458458
chunk" and re-creation fails the sum-to-span check."""
@@ -461,9 +461,7 @@ def test_chunks_param_from_rectilinear_bare_int_roundtrip() -> None:
461461
from zarr.testing.strategies import chunks_param_from_rectilinear
462462

463463
with zarr.config.set({"array.rectilinear_chunks": True}):
464-
src = zarr.create_array(
465-
MemoryStore(), shape=(3, 3), chunks=[[1, 2], [1, 1, 1]], dtype="uint8"
466-
)
464+
src = zarr.create_array(MemoryStore(), shape=(3, 3), chunks=([1, 2], 1), dtype="uint8")
467465
grid = src.metadata.chunk_grid # type: ignore[union-attr]
468466
assert isinstance(grid, RectilinearChunkGridMetadata)
469467
assert grid.chunk_shapes == ((1, 2), 1)

‎tests/test_unified_chunk_grid.py‎

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1027,6 +1027,96 @@ def test_e2e_chunk_grid_name_regular_from_dict(tmp_path: Path) -> None:
10271027
assert chunk_grid_dict["name"] == "regular"
10281028

10291029

1030+
# ---------------------------------------------------------------------------
1031+
# Input syntax determines grid kind (gh-4272)
1032+
# ---------------------------------------------------------------------------
1033+
1034+
1035+
@pytest.mark.parametrize(
1036+
("shape", "chunks", "expected_type"),
1037+
[
1038+
# scalar specs (including numpy integers and the -1 sentinel) produce a regular grid
1039+
((30,), (10,), RegularChunkGridMetadata),
1040+
((30,), 10, RegularChunkGridMetadata),
1041+
((30,), (np.int64(10),), RegularChunkGridMetadata),
1042+
((30,), -1, RegularChunkGridMetadata),
1043+
# explicit per-chunk lists stay rectilinear even when the sizes are uniform
1044+
((30,), [[10, 10, 10]], RectilinearChunkGridMetadata),
1045+
# ... or uniform with a short tail (the append-oriented time-series layout of gh-4272)
1046+
((168 * 13 + 24,), [[168] * 13 + [24]], RectilinearChunkGridMetadata),
1047+
# genuinely varying edges are rectilinear too
1048+
((60,), [[10, 20, 30]], RectilinearChunkGridMetadata),
1049+
],
1050+
ids=[
1051+
"scalar-tuple",
1052+
"scalar-int",
1053+
"scalar-numpy-int",
1054+
"full-span-sentinel",
1055+
"explicit-uniform",
1056+
"explicit-uniform-short-tail",
1057+
"explicit-varying",
1058+
],
1059+
)
1060+
def test_chunk_grid_kind_follows_input_syntax(
1061+
tmp_path: Path, shape: tuple[int, ...], chunks: Any, expected_type: type
1062+
) -> None:
1063+
"""The stored grid kind is decided by the input syntax, not the chunk sizes:
1064+
scalar specs produce a regular grid, while explicit per-chunk lists produce
1065+
a rectilinear grid even when the sizes happen to describe a regular one
1066+
(gh-4272)."""
1067+
from zarr.core.metadata.v3 import ArrayV3Metadata
1068+
1069+
arr = zarr.create_array(store=tmp_path / "arr.zarr", shape=shape, chunks=chunks, dtype="int32")
1070+
assert isinstance(arr.metadata, ArrayV3Metadata)
1071+
assert isinstance(arr.metadata.chunk_grid, expected_type)
1072+
1073+
1074+
def test_mixed_scalar_and_list_dims_keep_shorthand(tmp_path: Path) -> None:
1075+
"""In a mixed spec, a scalar dimension serializes as the bare-int step-size
1076+
shorthand while an explicit list keeps its per-chunk edges."""
1077+
from zarr.core.metadata.v3 import ArrayV3Metadata
1078+
1079+
arr = zarr.create_array(
1080+
store=tmp_path / "arr.zarr", shape=(30, 100), chunks=(5, [10, 20, 70]), dtype="int32"
1081+
)
1082+
assert isinstance(arr.metadata, ArrayV3Metadata)
1083+
grid = arr.metadata.chunk_grid
1084+
assert isinstance(grid, RectilinearChunkGridMetadata)
1085+
assert grid.chunk_shapes == (5, (10, 20, 70))
1086+
1087+
1088+
def test_resize_uniform_rectilinear_appends_edge() -> None:
1089+
"""Growing an explicitly rectilinear array whose edges look regular appends
1090+
a new edge chunk, while the same sizes declared as a scalar extend the
1091+
uniform pattern instead (gh-4272), so an append-only workload writing the
1092+
grown region touches exactly one new chunk."""
1093+
from zarr.core.metadata.v3 import ArrayV3Metadata
1094+
1095+
rect_store: dict[str, Any] = {}
1096+
rect = zarr.create_array(store=rect_store, shape=(30,), chunks=[[10, 10, 10]], dtype="int32")
1097+
rect.resize((45,))
1098+
assert isinstance(rect.metadata, ArrayV3Metadata)
1099+
rect_grid = rect.metadata.chunk_grid
1100+
assert isinstance(rect_grid, RectilinearChunkGridMetadata)
1101+
assert rect_grid.chunk_shapes == ((10, 10, 10, 15),)
1102+
assert rect.nchunks == 4
1103+
# the appended region is exactly the new edge chunk
1104+
rect[30:45] = 1
1105+
assert sorted(k for k in rect_store if k.startswith("c/")) == ["c/3"]
1106+
1107+
reg_store: dict[str, Any] = {}
1108+
reg = zarr.create_array(store=reg_store, shape=(30,), chunks=(10,), dtype="int32")
1109+
reg.resize((45,))
1110+
assert isinstance(reg.metadata, ArrayV3Metadata)
1111+
reg_grid = reg.metadata.chunk_grid
1112+
assert isinstance(reg_grid, RegularChunkGridMetadata)
1113+
assert reg_grid.chunk_shape == (10,)
1114+
assert reg.nchunks == 5
1115+
# the same write straddles two chunks of the extended uniform pattern
1116+
reg[30:45] = 1
1117+
assert sorted(k for k in reg_store if k.startswith("c/")) == ["c/3", "c/4"]
1118+
1119+
10301120
# ---------------------------------------------------------------------------
10311121
# Sharding compatibility tests
10321122
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)