Skip to content
Merged
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/4307.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed `chunks=-1` on a zero-length axis resolving to an invalid chunk size of 0, which caused a `ValueError`, `ZeroDivisionError`, or infinite loop depending on the sharding configuration. Such axes now get chunk size 1, matching `chunks="auto"`.
13 changes: 10 additions & 3 deletions src/zarr/core/chunk_grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,9 @@ def normalize_chunks_1d(
if isinstance(chunks, numbers.Integral):
chunk_size = int(chunks)
if chunk_size == -1:
return np.array([span], dtype=np.int64)
# A zero-length span still gets chunk size 1 (chunk sizes must be positive),
# matching the auto-chunking clamp in _guess_regular_chunks.
return np.array([max(span, 1)], dtype=np.int64)
if chunk_size <= 0:
raise ValueError(f"Chunk size must be positive, got {chunk_size}")
if span == 0:
Expand Down Expand Up @@ -844,7 +846,10 @@ def _guess_num_chunks_per_axis_shard(

For example, for a (2,2,2) chunk size and item size 4, maximum bytes of 256 would return 2.
In other words the shard would be a (2,2,2) grid of (2,2,2) chunks
i.e., prod(chunk_shape) * (returned_val * len(chunk_shape)) * item_size = 256 bytes.
i.e., prod(chunk_shape) * (returned_val ** len(chunk_shape)) * item_size = 256 bytes.

Degenerate chunk shapes — a 0-dimensional shape, or one containing a zero-length
axis — return 1, as the search loop's stopping conditions can never be met.

Parameters
----------
Expand All @@ -865,7 +870,9 @@ def _guess_num_chunks_per_axis_shard(
if max_bytes < bytes_per_chunk:
return 1
num_axes = len(chunk_shape)
if num_axes == 0:
# For a 0-dimensional chunk shape or one with a zero-length axis, both loop
# conditions below are constant, so the loop would never terminate.
if num_axes == 0 or bytes_per_chunk == 0:
return 1
chunks_per_shard = 1
# First check for byte size, second check to make sure we don't go bigger than the array shape
Expand Down
44 changes: 41 additions & 3 deletions tests/test_chunk_grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ def test_normalize_chunks_nd_errors(case: ExpectFail[tuple[Any, tuple[int, ...]]
Expect(input=([10, 20, 30, 40], 100), output=[10, 20, 30, 40], id="explicit-list"),
# -1 sentinel branch: one chunk covering the full span.
Expect(input=(-1, 100), output=[100], id="full-span-sentinel"),
# -1 on a zero-length span clamps to chunk size 1 (chunk sizes must be positive).
Expect(input=(-1, 0), output=[1], id="full-span-sentinel-empty"),
],
ids=lambda c: c.id,
)
Expand All @@ -282,11 +284,24 @@ def test_normalize_chunks_1d_returns_int64_array(
assert result.tolist() == case.output


def test_guess_num_chunks_per_axis_shard_0d() -> None:
"""Regression test for https://github.com/zarr-developers/zarr-python/issues/4304."""
@pytest.mark.parametrize(
("chunk_shape", "array_shape"),
[((), ()), ((0,), (0,)), ((0, 0), (0, 0))],
ids=["0d", "zero-1d", "zero-2d"],
)
def test_guess_num_chunks_per_axis_shard_degenerate(
chunk_shape: tuple[int, ...], array_shape: tuple[int, ...]
) -> None:
"""Degenerate chunk shapes must return 1 instead of hanging the search loop.

Regression test for https://github.com/zarr-developers/zarr-python/issues/4304.
"""
assert (
_guess_num_chunks_per_axis_shard(
chunk_shape=(), item_size=8, max_bytes=128 * 1024 * 1024, array_shape=()
chunk_shape=chunk_shape,
item_size=8,
max_bytes=128 * 1024 * 1024,
array_shape=array_shape,
)
== 1
)
Expand All @@ -303,3 +318,26 @@ def test_create_0d_array_auto_shards_with_target_shard_size() -> None:
):
arr = zarr.create_array(store={}, shape=(), dtype="int64", shards="auto")
assert arr.shards == ()


@pytest.mark.parametrize(
"target_shard_size_bytes",
[None, 128 * 1024 * 1024],
ids=["no-budget", "budget"],
)
def test_create_zero_length_array_full_span_chunks_auto_shards(
target_shard_size_bytes: int | None,
) -> None:
"""`chunks=-1` on a zero-length axis with shards="auto" must neither hang nor raise.

The -1 sentinel used to resolve to chunk size 0 on zero-length axes, which broke
every sharding code path: a ZeroDivisionError without a shard size budget, and an
infinite loop with one (https://github.com/zarr-developers/zarr-python/issues/4304).
"""
with (
zarr.config.set({"array.target_shard_size_bytes": target_shard_size_bytes}),
pytest.warns(ZarrUserWarning, match="Automatic shard shape inference is experimental"),
):
arr = zarr.create_array(store={}, shape=(0,), dtype="int64", chunks=-1, shards="auto")
assert arr.chunks == (1,)
assert arr.shards == (1,)
Loading