Skip to content

Commit 069fd20

Browse files
authored
fix(sharding): validate nested sharding codecs and reject an inner chunk size of 0 (#4480)
* fix(sharding): validate nested sharding codecs and reject an inner chunk size of 0 `ShardingCodec.validate` checked its own inner chunk shape against the chunk grid but never validated the codecs that encode each inner chunk. A sharding codec nested in another one could therefore declare an inner chunk shape that does not divide the chunk it splits: the array was created and opened without error, and then read back wrong data or its fill value. `validate` now validates the inner codecs as array metadata validates its codecs: each against the chunk the codecs before it leave it (so permuted after a transpose), as a regular grid of one inner chunk. `ShardingCodec` also rejects an inner chunk size of 0 (or `False`) when it is constructed or unpickled, as tensorstore and zarrs do when they parse the codec configuration. It used to accept one, which raised a `ZeroDivisionError` when an array used the codec or, where the codec was nested, on the first write. Assisted-by: ClaudeCode:claude-fable-5.1 * docs(changes): name the changelog fragment after the pull request Assisted-by: ClaudeCode:claude-fable-5.1
1 parent a53d51f commit 069fd20

4 files changed

Lines changed: 133 additions & 11 deletions

File tree

‎changes/4480.bugfix.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
A sharding codec nested in the codecs of another sharding codec is now validated as the outer one is: its inner chunk shape must divide the chunk it encodes. Such an array used to be created, and opened, without error, and then read back wrong data or its fill value. `ShardingCodec` now rejects an inner chunk size of 0 (or `False`) when it is constructed, with a `ValueError` that names the dimension, as other Zarr implementations do; it used to accept one, which raised a `ZeroDivisionError` when an array was created or opened with the codec or, where the codec was nested in another sharding codec, on the first write to the array.

‎src/zarr/codecs/sharding.py‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
)
4747
from zarr.core.common import (
4848
ShapeLike,
49+
parse_chunk_shape,
4950
parse_named_configuration,
5051
parse_shapelike,
5152
product,
@@ -437,6 +438,12 @@ def _check_index_codecs_fixed_size(index_codecs: tuple[Codec, ...]) -> None:
437438
)
438439

439440

441+
def _parse_inner_chunk_shape(data: ShapeLike) -> tuple[int, ...]:
442+
"""Parse the inner chunk shape of a sharding codec: a shape (see `parse_shapelike`)
443+
whose every size is a chunk edge length, so at least 1 (see `parse_chunk_shape`)."""
444+
return parse_chunk_shape(parse_shapelike(data))
445+
446+
440447
@dataclass(frozen=True)
441448
class ShardingCodec(
442449
ArrayBytesCodec, ArrayBytesCodecPartialDecodeMixin, ArrayBytesCodecPartialEncodeMixin
@@ -468,7 +475,7 @@ def __init__(
468475
index_location: ShardingCodecIndexLocation | IndexLocation = "end",
469476
subchunk_write_order: SubchunkWriteOrder = "morton",
470477
) -> None:
471-
chunk_shape_parsed = parse_shapelike(chunk_shape)
478+
chunk_shape_parsed = _parse_inner_chunk_shape(chunk_shape)
472479
codecs_parsed = parse_codecs(codecs)
473480
index_codecs_parsed = parse_codecs(index_codecs)
474481
_check_index_codecs_fixed_size(index_codecs_parsed)
@@ -507,7 +514,7 @@ def __getstate__(self) -> dict[str, Any]:
507514

508515
def __setstate__(self, state: dict[str, Any]) -> None:
509516
config = state["configuration"]
510-
object.__setattr__(self, "chunk_shape", parse_shapelike(config["chunk_shape"]))
517+
object.__setattr__(self, "chunk_shape", _parse_inner_chunk_shape(config["chunk_shape"]))
511518
object.__setattr__(self, "codecs", parse_codecs(config["codecs"]))
512519
object.__setattr__(self, "index_codecs", parse_codecs(config["index_codecs"]))
513520
object.__setattr__(self, "index_location", _parse_index_location(config["index_location"]))
@@ -635,6 +642,28 @@ def validate(
635642
f"Chunk edge length {edge} in dimension {i} is not "
636643
f"divisible by the shard's inner chunk size {inner}."
637644
)
645+
self._validate_inner_codecs(dtype)
646+
647+
def _validate_inner_codecs(self, dtype: ZDType[TBaseDType, TBaseScalar]) -> None:
648+
"""Validate the codecs that encode each inner chunk, as array metadata validates
649+
its codecs: each against the chunk the codecs before it leave it, a regular
650+
grid of one inner chunk. A sharding codec among them is thereby checked against
651+
the chunk it splits, which `validate` of the outer codec alone does not see.
652+
"""
653+
spec = ArraySpec(
654+
shape=self.chunk_shape,
655+
dtype=dtype,
656+
fill_value=dtype.default_scalar(),
657+
config=ArrayConfig.from_dict({}),
658+
prototype=default_buffer_prototype(),
659+
)
660+
for codec in self.codecs:
661+
codec.validate(
662+
shape=spec.shape,
663+
dtype=spec.dtype,
664+
chunk_grid=RegularChunkGridMetadata(chunk_shape=spec.shape),
665+
)
666+
spec = codec.resolve_metadata(spec)
638667

639668
def _get_inner_chunk_transform(self, shard_spec: ArraySpec) -> Any:
640669
"""The synchronous transform for the inner codec chain.

‎tests/test_codecs/test_sharding.py‎

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
_ShardReader,
3434
)
3535
from zarr.core.buffer import NDArrayLike, default_buffer_prototype
36+
from zarr.core.dtype import Int32
3637
from zarr.core.indexing import lexicographic_order_coords
3738
from zarr.core.metadata.v3 import ArrayV3Metadata
3839
from zarr.storage import MemoryStore, StorePath, ZipStore
@@ -768,15 +769,13 @@ def test_structured_dtype_fill_value() -> None:
768769
[
769770
ShardingCodec(chunk_shape=(8, 8)),
770771
ShardingCodec(chunk_shape=(8, 8), subchunk_write_order="lexicographic"),
771-
ShardingCodec(chunk_shape=(0,)),
772772
],
773-
ids=["default", "lexicographic", "zero-chunk-size"],
773+
ids=["default", "lexicographic"],
774774
)
775775
def test_pickle(codec: ShardingCodec) -> None:
776776
"""ShardingCodec round-trips through pickle, including the non-serialized
777777
``subchunk_write_order`` (which ``to_dict`` omits and which must not silently
778-
revert to the ``morton`` default), and an inner chunk size of 0, which the
779-
constructor accepts."""
778+
revert to the ``morton`` default)."""
780779
restored = pickle.loads(pickle.dumps(codec))
781780
assert restored == codec
782781
assert restored.chunk_shape == codec.chunk_shape
@@ -1436,3 +1435,90 @@ def test_sharding_truncated_shard_raises(
14361435
}
14371436
with pytest.raises(ValueError, match="too short to hold its index"):
14381437
ops[op]()
1438+
1439+
1440+
@pytest.mark.filterwarnings(
1441+
"ignore:Combining a `sharding_indexed` codec:zarr.errors.ZarrUserWarning"
1442+
)
1443+
@pytest.mark.parametrize(
1444+
"inner_codecs",
1445+
[
1446+
(ShardingCodec(chunk_shape=(3, 10)),),
1447+
(ShardingCodec(chunk_shape=(6, 20)),),
1448+
(TransposeCodec(order=(1, 0)), ShardingCodec(chunk_shape=(10, 3))),
1449+
(ShardingCodec(chunk_shape=(3, 10), codecs=(ShardingCodec(chunk_shape=(1, 5)),)),),
1450+
],
1451+
ids=["divides", "equal", "after-transpose", "three-levels"],
1452+
)
1453+
def test_nested_sharding_roundtrip(inner_codecs: tuple[Any, ...]) -> None:
1454+
"""A sharding codec nested in a sharding codec is accepted when its inner chunk
1455+
shape divides the chunk it encodes (the outer codec's inner chunk, as the codecs
1456+
before it leave it, so permuted after a transpose), and data round-trips."""
1457+
data = np.arange(1, 121, dtype="int32").reshape(6, 20)
1458+
store = MemoryStore()
1459+
arr = zarr.create_array(
1460+
store,
1461+
shape=data.shape,
1462+
dtype=data.dtype,
1463+
chunks=(6, 20),
1464+
serializer=ShardingCodec(chunk_shape=(6, 20), codecs=inner_codecs),
1465+
)
1466+
arr[:] = data
1467+
np.testing.assert_array_equal(zarr.open_array(store, mode="r")[:], data)
1468+
1469+
1470+
@pytest.mark.filterwarnings(
1471+
"ignore:Combining a `sharding_indexed` codec:zarr.errors.ZarrUserWarning"
1472+
)
1473+
@pytest.mark.parametrize("nested_chunk_shape", [(4, 7), (12, 40), (3, 20, 1)])
1474+
def test_nested_sharding_rejects_indivisible_inner_chunk_shape(
1475+
nested_chunk_shape: tuple[int, ...],
1476+
) -> None:
1477+
"""A sharding codec nested in a sharding codec is checked as the outer one is: an
1478+
inner chunk shape that does not divide the chunk it encodes, or has another number
1479+
of dimensions, is rejected when the array is created, and nothing is stored. Such
1480+
arrays used to be created and to read back wrong data."""
1481+
store = MemoryStore()
1482+
with pytest.raises(ValueError, match="not divisible|same number of dimensions"):
1483+
zarr.create_array(
1484+
store,
1485+
shape=(6, 20),
1486+
dtype="int32",
1487+
chunks=(6, 20),
1488+
serializer=ShardingCodec(
1489+
chunk_shape=(6, 20), codecs=(ShardingCodec(chunk_shape=nested_chunk_shape),)
1490+
),
1491+
)
1492+
assert store._store_dict == {}
1493+
1494+
1495+
@pytest.mark.parametrize("chunk_shape", [(0, 5), (5, 0), (False, 5), [0], 0])
1496+
def test_sharding_codec_rejects_inner_chunk_size_zero(chunk_shape: Any) -> None:
1497+
"""`ShardingCodec` rejects an inner chunk size of 0 (or `False`) when it is
1498+
constructed, with a `ValueError` naming the dimension, so no array metadata, nested
1499+
codec or stored document can hold one."""
1500+
with pytest.raises(ValueError, match=r"Dimension \d: chunk edge length must be >= 1, got"):
1501+
ShardingCodec(chunk_shape=chunk_shape)
1502+
1503+
1504+
@pytest.mark.filterwarnings(
1505+
"ignore:Combining a `sharding_indexed` codec:zarr.errors.ZarrUserWarning"
1506+
)
1507+
def test_open_rejects_stored_nested_sharding_with_indivisible_inner_chunk_shape() -> None:
1508+
"""A stored array document whose nested sharding codec has an inner chunk shape
1509+
that does not divide the chunk it encodes is rejected when the metadata is parsed,
1510+
not when chunks are read or written."""
1511+
valid = ArrayV3Metadata(
1512+
shape=(6, 20),
1513+
data_type=Int32(),
1514+
chunk_grid={"name": "regular", "configuration": {"chunk_shape": (6, 20)}},
1515+
chunk_key_encoding={"name": "default"},
1516+
fill_value=0,
1517+
codecs=(ShardingCodec(chunk_shape=(6, 20), codecs=(ShardingCodec(chunk_shape=(3, 10)),)),),
1518+
attributes={},
1519+
dimension_names=None,
1520+
)
1521+
doc: Any = valid.to_dict()
1522+
doc["codecs"][0]["configuration"]["codecs"][0]["configuration"]["chunk_shape"] = (4, 7)
1523+
with pytest.raises(ValueError, match="not divisible"):
1524+
ArrayV3Metadata.from_dict(doc)

‎tests/test_metadata/test_repair.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -519,24 +519,30 @@ def _sharding_chunk_shape(chunks: Any) -> tuple[tuple[int, ...], object]:
519519
((np.int64(4),), (4,)),
520520
(np.array([4]), (4,)),
521521
((True,), (1,)),
522-
((0,), (0,)),
523-
((False,), (0,)),
524522
(range(4, 5), (4,)),
525523
],
526524
)
527525
def test_chunk_shape_read_as_array_shape(
528526
site: str, chunks: object, expected: tuple[int, ...]
529527
) -> None:
530528
"""`ArrayV2Metadata` and `ShardingCodec` read their chunk shape as `parse_shapelike`
531-
reads an array shape: an integer or an iterable of non-negative integers, including
532-
NumPy integers and bools. A chunk size of 0 is written back as given; reading a
533-
stored 0 is `zarr.core.metadata.repair`' business."""
529+
reads an array shape: an integer or an iterable of integers, including NumPy
530+
integers and bools."""
534531
parsed, written = CHUNK_SHAPE_SITES[site](chunks)
535532
assert parsed == expected
536533
assert all(type(size) is int for size in parsed)
537534
assert written == expected
538535

539536

537+
@pytest.mark.parametrize("chunks", [(0,), (False,)])
538+
def test_v2_chunk_size_zero_written_as_given(chunks: tuple[Any, ...]) -> None:
539+
"""`ArrayV2Metadata` accepts a chunk size of 0 and writes it back as given; reading
540+
a stored 0 is `zarr.core.metadata.repair`' business. `ShardingCodec` rejects one."""
541+
metadata = _v2_metadata(chunks)
542+
assert metadata.chunks == (0,)
543+
assert metadata.to_dict()["chunks"] == (0,)
544+
545+
540546
@pytest.mark.parametrize("site", CHUNK_SHAPE_SITES)
541547
def test_chunk_shape_read_as_array_shape_rejects_negative(site: str) -> None:
542548
with pytest.raises(ValueError, match="Expected all values to be non-negative"):

0 commit comments

Comments
 (0)