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
1 change: 1 addition & 0 deletions changes/4272.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 24 additions & 2 deletions src/zarr/core/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 23 additions & 0 deletions src/zarr/core/metadata/v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/test_unified_chunk_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading