diff --git a/changes/4272.bugfix.md b/changes/4272.bugfix.md new file mode 100644 index 0000000000..de82b6066f --- /dev/null +++ b/changes/4272.bugfix.md @@ -0,0 +1 @@ +A nested-sequence `chunks` specification (an explicit rectilinear request) whose edges happen to be uniform — e.g. `[[10, 10, 4]]` — is no longer silently stored as a regular chunk grid. Since 3.3.0 the grid kind was chosen from the edge values rather than the form of the request, so such arrays were created as regular grids. The two are equivalent at creation time but diverge under `resize`: the regular grid extends the uniform pattern while the rectilinear grid appends an edge, so an append-only workload could get a different (chunk-rewriting) layout from the one it asked for. Nested-sequence specs now always yield rectilinear grids, matching zarr-python 3.2.x; flat specs and auto-chunking still infer the grid kind from the edge values. diff --git a/src/zarr/core/array.py b/src/zarr/core/array.py index 9cb66f339e..cf030cdfe2 100644 --- a/src/zarr/core/array.py +++ b/src/zarr/core/array.py @@ -485,9 +485,16 @@ async def _create( item_size = dtype_parsed.item_size if _raw_chunks is None: outer_chunks = guess_chunks(shape, item_size) + chunk_grid = create_chunk_grid_metadata(outer_chunks) else: outer_chunks = normalize_chunks_nd(_raw_chunks, shape) - chunk_grid = create_chunk_grid_metadata(outer_chunks) + # A nested-sequence chunk spec is an explicit rectilinear + # request: honor it even when the edges happen to be + # uniform (issue #4272, restores 3.2.x behavior). + chunk_grid = create_chunk_grid_metadata( + outer_chunks, + requested_rectilinear=_is_rectilinear_chunks(_raw_chunks), + ) result = await cls._create_v3( store_path, shape=shape, @@ -4526,7 +4533,22 @@ async def init_array( dtype=zdtype, ) sub_codecs = cast("tuple[Codec, ...]", (*array_array, array_bytes, *bytes_bytes)) - grid = create_chunk_grid_metadata(outer_chunks) + # A nested-sequence chunks/shards spec is an explicit rectilinear + # request for the chunk grid: honor it even when the edges happen to + # be uniform (issue #4272, restores 3.2.x behavior). Note the grid + # metadata describes the OUTER layout, so rectilinear *shards* make + # the grid rectilinear even when `chunks=` itself is flat. + # "auto" / flat specs infer the kind from the edge values. + if _is_rectilinear_chunks(shards): + requested_rectilinear_grid: bool | None = True + elif chunks == "auto": + requested_rectilinear_grid = None + else: + requested_rectilinear_grid = _is_rectilinear_chunks(chunks) + grid = create_chunk_grid_metadata( + outer_chunks, + requested_rectilinear=requested_rectilinear_grid, + ) codecs_out: tuple[Codec, ...] if inner is not None: inner_chunks_flat = as_regular_shape(inner.outer_chunks) diff --git a/src/zarr/core/metadata/v3.py b/src/zarr/core/metadata/v3.py index fc47f8fc95..5f18eda7b8 100644 --- a/src/zarr/core/metadata/v3.py +++ b/src/zarr/core/metadata/v3.py @@ -374,6 +374,8 @@ def from_dict(cls, data: RectilinearChunkGridMetadataJSON) -> Self: # type: ign def create_chunk_grid_metadata( chunks: ChunksTuple, + *, + requested_rectilinear: bool | None = None, ) -> ChunkGridMetadata: """Construct a chunk grid metadata object from a normalized `ChunksTuple`. @@ -385,11 +387,32 @@ def create_chunk_grid_metadata( chunks : ChunksTuple Normalized chunk specification, as returned by `normalize_chunks_nd` or `guess_chunks`. + requested_rectilinear : bool, keyword-only, optional + Whether the user *requested* a rectilinear grid (a nested-sequence + chunk spec). When True, a rectilinear grid is produced even if the + requested edges happen to describe a regular layout — the stored grid + then matches the request instead of silently collapsing to regular, + so e.g. `resize` extends the requested per-chunk structure rather + than an inferred uniform pattern (see issue #4272). When False, a + regular grid is produced. When None (the default), the kind is + inferred from the edge values, preserving the behavior for callers + that only have a normalized `ChunksTuple` (e.g. auto-chunking). See Also -------- parse_chunk_grid : Deserialize a chunk grid from stored JSON metadata. """ + if requested_rectilinear is not None: + # Honor the form of the request: a nested-sequence chunk spec always + # yields a rectilinear grid, matching zarr-python 3.2.x semantics, + # even when its edges happen to be uniform (issue #4272). + if requested_rectilinear: + return RectilinearChunkGridMetadata( + chunk_shapes=tuple(tuple(int(x) for x in d) for d in chunks) + ) + return RegularChunkGridMetadata( + chunk_shape=tuple(int(dim_chunks[0]) for dim_chunks in chunks) + ) if is_regular_nd(chunks): # If we know the chunks specification is regular, then we can take the first # chunk size for each dimension as the chunk shape. diff --git a/tests/test_unified_chunk_grid.py b/tests/test_unified_chunk_grid.py index f0b54519ab..f75c911ec2 100644 --- a/tests/test_unified_chunk_grid.py +++ b/tests/test_unified_chunk_grid.py @@ -2590,6 +2590,93 @@ def test_array_read_chunk_sizes_rectilinear() -> None: assert arr.write_chunk_sizes == ((10, 20, 30), (50, 50)) +def test_nested_sequence_request_stays_rectilinear_when_edges_uniform() -> None: + """ + A nested-sequence chunk spec whose edges happen to be uniform-plus-a-short-tail + must be stored as a rectilinear grid — the form of the request decides the grid + kind, not the edge values. + + https://github.com/zarr-developers/zarr-python/issues/4272: since 3.3.0 such a + spec was silently collapsed to a regular grid. The two grids behave identically + at creation time but diverge under ``resize``: a regular grid extends the uniform + pattern while a rectilinear grid appends an edge, so an append-only workload + gets a different (rewriting) chunk layout from the one it asked for. + """ + import hashlib + + def touched(store: dict[str, Any], arr: Any, start: int, stop: int, val: int) -> list[str]: + def snap() -> dict[str, str]: + return { + k: hashlib.md5(bytes(v.to_bytes())).hexdigest() + for k, v in store.items() + if k.startswith("c/") + } + + before = snap() + arr[start:stop] = val + after = snap() + return sorted(k for k in after if after[k] != before.get(k)) + + store_dict: dict[str, Any] = {} + arr = zarr.create_array( + store=store_dict, shape=(24,), chunks=[[10, 10, 4]], dtype="i4", zarr_format=3 + ) + + assert isinstance(arr.metadata.chunk_grid, RectilinearChunkGridMetadata), ( + f"requested rectilinear [[10, 10, 4]] but got {type(arr.metadata.chunk_grid).__name__}" + ) + + # Writing inside existing chunks is unaffected either way. + assert touched(store_dict, arr, 20, 24, 2) == ["c/2"] + + # The divergence: resize of a *regular* grid extends the uniform pattern and + # the appended region straddles c/2 + c/3; for the requested rectilinear grid + # the appended window lands in exactly one new chunk. + arr.resize((34,)) + assert isinstance(arr.metadata.chunk_grid, RectilinearChunkGridMetadata) + assert arr.metadata.chunk_grid.chunk_shapes == ((10, 10, 4, 10),) + assert touched(store_dict, arr, 24, 34, 3) == ["c/3"] + + +@pytest.mark.parametrize( + ("requested", "expected_type"), + [ + (True, RectilinearChunkGridMetadata), + (False, RegularChunkGridMetadata), + # None infers from the values: [[10, 10, 4]] has a short trailing chunk, + # which is_regular_1d counts as regular (uniform-plus-boundary). + (None, RegularChunkGridMetadata), + ], +) +def test_create_chunk_grid_metadata_requested_kind( + requested: bool | None, expected_type: type +) -> None: + """create_chunk_grid_metadata honors an explicit request; None infers from values.""" + from zarr.core.chunk_grids import normalize_chunks_nd + from zarr.core.metadata.v3 import create_chunk_grid_metadata + + chunks = normalize_chunks_nd([[10, 10, 4]], (24,)) + grid = create_chunk_grid_metadata(chunks, requested_rectilinear=requested) + assert isinstance(grid, expected_type) + + # Explicit True keeps genuinely varied edges rectilinear too. + varied = normalize_chunks_nd([[10, 20]], (30,)) + assert isinstance( + create_chunk_grid_metadata(varied, requested_rectilinear=True), + RectilinearChunkGridMetadata, + ) + + +def test_flat_and_auto_specs_still_infer_grid_kind_from_values() -> None: + """Flat / auto chunk specs keep inferring the grid kind from edge values.""" + store = zarr.storage.MemoryStore() + + # Flat spec with a short trailing chunk: inferred as regular (unchanged behavior). + arr = zarr.create_array(store=store, shape=(24,), chunks=(10,), dtype="i4", zarr_format=3) + assert isinstance(arr.metadata.chunk_grid, RegularChunkGridMetadata) + assert arr.metadata.chunk_grid.chunk_shape == (10,) + + def test_array_sharded_chunk_sizes() -> None: """Sharded array read_chunk_sizes reflects inner chunks and write_chunk_sizes reflects shards""" store = zarr.storage.MemoryStore()