Skip to content

Commit 9055ed5

Browse files
authored
Merge branch 'main' into claude/zarr-metadata-api-docs-93fa91
2 parents 9c804f5 + ba83236 commit 9055ed5

5 files changed

Lines changed: 290 additions & 37 deletions

File tree

changes/4201.bugfix.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the opt-in `FusedCodecPipeline` for serializers that advertise the partial-decode/encode mixins with only the documented async partial methods: the partial dispatch previously asserted on the private `_decode_partial_sync`/`_encode_partial_sync` hooks (an `AssertionError`, or an `AttributeError` mid-IO under `python -O`); such codecs now take the full-chunk sync path.

changes/4202.bugfix.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Fixed `FusedCodecPipeline` (the opt-in synchronous pipeline) silently skipping
2+
array-array/bytes-bytes codecs placed outside a sharding serializer on its
3+
partial-decode/partial-encode fast paths. With an outer compressor (e.g.
4+
`compressors=[GzipCodec()]` around a `ShardingCodec` serializer), the fused
5+
pipeline wrote non-conforming stored bytes that `BatchedCodecPipeline` (and any
6+
other conforming reader) could not read, and could fail to read data that
7+
`BatchedCodecPipeline` had written. With an outer array-array codec (e.g.
8+
`TransposeCodec`), it silently returned wrong data in both directions with no
9+
error. Only the opt-in `FusedCodecPipeline` was affected; the default
10+
`BatchedCodecPipeline` was never impacted.

src/zarr/core/codec_pipeline.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,10 @@ def pipeline_supports_partial_decode(
143143
selection non-contiguous, a BB codec can rewrite the bytes), making partial
144144
decode infeasible.
145145
146-
NOTE: the two pipelines currently pass different ``require_no_aa_bb`` values
147-
(Batched: True; Fused: False). That divergence is intentional-for-now and
148-
tracked separately; this function centralizes the predicate without changing
149-
either pipeline's behavior.
146+
Both pipelines pass `require_no_aa_bb=True`: an outer AA/BB codec (e.g. a
147+
compressor wrapping a sharding serializer) must see every byte of the
148+
chunk, so a partial branch that only re-decodes/re-encodes the inner
149+
sharding codec would silently bypass it.
150150
"""
151151
if require_no_aa_bb and (len(array_array_codecs) + len(bytes_bytes_codecs)) != 0:
152152
return False
@@ -162,8 +162,7 @@ def pipeline_supports_partial_encode(
162162
) -> bool:
163163
"""Whether a codec pipeline can encode a partial selection without a full rewrite.
164164
165-
Mirror of ``pipeline_supports_partial_decode`` for encoding. See its note re:
166-
the per-pipeline ``require_no_aa_bb`` divergence.
165+
Mirror of `pipeline_supports_partial_decode` for encoding.
167166
"""
168167
if require_no_aa_bb and (len(array_array_codecs) + len(bytes_bytes_codecs)) != 0:
169168
return False
@@ -934,14 +933,11 @@ def __iter__(self) -> Iterator[Codec]:
934933

935934
@property
936935
def supports_partial_decode(self) -> bool:
937-
# NOTE: unlike BatchedCodecPipeline this does NOT require the AA/BB codec
938-
# lists to be empty (require_no_aa_bb=False). That divergence is tracked
939-
# separately; see pipeline_supports_partial_decode.
940936
return pipeline_supports_partial_decode(
941937
self.array_bytes_codec,
942938
array_array_codecs=self.array_array_codecs,
943939
bytes_bytes_codecs=self.bytes_bytes_codecs,
944-
require_no_aa_bb=False,
940+
require_no_aa_bb=True,
945941
)
946942

947943
@property
@@ -950,7 +946,7 @@ def supports_partial_encode(self) -> bool:
950946
self.array_bytes_codec,
951947
array_array_codecs=self.array_array_codecs,
952948
bytes_bytes_codecs=self.bytes_bytes_codecs,
953-
require_no_aa_bb=False,
949+
require_no_aa_bb=True,
954950
)
955951

956952
def validate(
@@ -1039,10 +1035,14 @@ def read_sync(
10391035

10401036
# Partial-decode fast path: the AB codec owns IO (read only the
10411037
# byte ranges needed for the requested selection). Same condition
1042-
# and dispatch as BatchedCodecPipeline.read_batch.
1043-
if self.supports_partial_decode:
1044-
codec = self.array_bytes_codec
1045-
assert hasattr(codec, "_decode_partial_sync")
1038+
# and dispatch as BatchedCodecPipeline.read_batch, plus a gate on the
1039+
# sync partial method: the public partial-decode contract
1040+
# (`ArrayBytesCodecPartialDecodeMixin`) only requires the async
1041+
# `_decode_partial_single`, so a codec may support partial decode
1042+
# without `_decode_partial_sync` — such codecs take the full-chunk
1043+
# path below instead.
1044+
codec = self.array_bytes_codec
1045+
if self.supports_partial_decode and hasattr(codec, "_decode_partial_sync"):
10461046

10471047
def _read_one(
10481048
item: tuple[Any, ArraySpec, SelectorTuple, SelectorTuple, bool],
@@ -1111,10 +1111,14 @@ def write_sync(
11111111

11121112
# Partial-encode path: the AB codec owns IO (read, merge, encode,
11131113
# write). Same condition and calling convention as
1114-
# BatchedCodecPipeline.write_batch.
1115-
if self.supports_partial_encode:
1116-
codec = self.array_bytes_codec
1117-
assert hasattr(codec, "_encode_partial_sync")
1114+
# BatchedCodecPipeline.write_batch, plus a gate on the sync partial
1115+
# method: the public partial-encode contract
1116+
# (`ArrayBytesCodecPartialEncodeMixin`) only requires the async
1117+
# `_encode_partial_single`, so a codec may support partial encode
1118+
# without `_encode_partial_sync` — such codecs take the full-chunk
1119+
# path below instead.
1120+
codec = self.array_bytes_codec
1121+
if self.supports_partial_encode and hasattr(codec, "_encode_partial_sync"):
11181122
scalar = len(value.shape) == 0
11191123

11201124
def _write_one(

tests/test_fused_pipeline.py

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,32 @@
22

33
from __future__ import annotations
44

5-
from typing import Any
5+
from dataclasses import dataclass, field, replace
6+
from typing import TYPE_CHECKING, Any
67

78
import numpy as np
89
import pytest
910

1011
import zarr
11-
from zarr.abc.codec import BytesBytesCodec
12+
from zarr.abc.codec import (
13+
ArrayBytesCodec,
14+
ArrayBytesCodecPartialDecodeMixin,
15+
ArrayBytesCodecPartialEncodeMixin,
16+
BytesBytesCodec,
17+
)
1218
from zarr.codecs.bytes import BytesCodec
1319
from zarr.codecs.gzip import GzipCodec
1420
from zarr.codecs.transpose import TransposeCodec
1521
from zarr.codecs.zstd import ZstdCodec
1622
from zarr.core.codec_pipeline import FusedCodecPipeline
1723
from zarr.core.config import config as zarr_config
24+
from zarr.registry import register_codec
1825
from zarr.storage import MemoryStore, StorePath
1926

27+
if TYPE_CHECKING:
28+
from zarr.core.array_spec import ArraySpec
29+
from zarr.core.buffer import Buffer, NDBuffer
30+
2031

2132
@pytest.mark.parametrize(
2233
"codecs",
@@ -261,7 +272,7 @@ def test_chunk_transform_uses_runtime_prototype() -> None:
261272
"""
262273
from zarr.abc.codec import BytesBytesCodec
263274
from zarr.core.array_spec import ArrayConfig, ArraySpec
264-
from zarr.core.buffer import Buffer, BufferPrototype, default_buffer_prototype
275+
from zarr.core.buffer import BufferPrototype, default_buffer_prototype
265276
from zarr.core.chunk_utils import ChunkTransform
266277
from zarr.core.dtype import get_data_type_from_native_dtype
267278

@@ -831,3 +842,127 @@ def test_async_decode_encode_passes_through_none_chunks() -> None:
831842
assert decoded[1] is None
832843
assert decoded[0] is not None
833844
np.testing.assert_array_equal(decoded[0].as_numpy_array(), data)
845+
846+
847+
# ---------------------------------------------------------------------------
848+
# Graceful fallback for partial-mixin codecs without private sync-partial hooks
849+
#
850+
# The public partial-decode/encode contract (`ArrayBytesCodecPartialDecodeMixin`
851+
# / `ArrayBytesCodecPartialEncodeMixin`) only requires the async
852+
# `_decode_partial_single` / `_encode_partial_single`. The fused pipeline must
853+
# route such codecs through its full-chunk sync path instead of asserting on
854+
# the private `_decode_partial_sync` / `_encode_partial_sync` hooks. The double
855+
# below is a minimal conforming implementer of that contract; it guards the
856+
# public extension API, so it must not grow the private sync-partial methods.
857+
# ---------------------------------------------------------------------------
858+
859+
860+
@dataclass(frozen=True)
861+
class PartialMixinCodec(
862+
ArrayBytesCodec, ArrayBytesCodecPartialDecodeMixin, ArrayBytesCodecPartialEncodeMixin
863+
):
864+
"""Serializer with sync whole-chunk methods plus ONLY async partial methods.
865+
866+
This is the pre-fused public contract for partial-capable codecs: the
867+
mixins' `_decode_partial_single` / `_encode_partial_single`. It must not
868+
implement `_decode_partial_sync` / `_encode_partial_sync`.
869+
"""
870+
871+
inner: BytesCodec = field(default_factory=BytesCodec)
872+
873+
@classmethod
874+
def from_dict(cls, data: dict[str, Any]) -> PartialMixinCodec:
875+
return cls()
876+
877+
def to_dict(self) -> dict[str, Any]:
878+
return {"name": "test-partial-mixin"}
879+
880+
def evolve_from_array_spec(self, array_spec: ArraySpec) -> PartialMixinCodec:
881+
return replace(self, inner=self.inner.evolve_from_array_spec(array_spec))
882+
883+
def compute_encoded_size(self, input_byte_length: int, chunk_spec: ArraySpec) -> int:
884+
return self.inner.compute_encoded_size(input_byte_length, chunk_spec)
885+
886+
def _decode_sync(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer:
887+
return self.inner._decode_sync(chunk_bytes, chunk_spec)
888+
889+
def _encode_sync(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None:
890+
return self.inner._encode_sync(chunk_array, chunk_spec)
891+
892+
async def _decode_single(self, chunk_bytes: Buffer, chunk_spec: ArraySpec) -> NDBuffer:
893+
return self._decode_sync(chunk_bytes, chunk_spec)
894+
895+
async def _encode_single(self, chunk_array: NDBuffer, chunk_spec: ArraySpec) -> Buffer | None:
896+
return self._encode_sync(chunk_array, chunk_spec)
897+
898+
async def _decode_partial_single(
899+
self, byte_getter: Any, selection: Any, chunk_spec: ArraySpec
900+
) -> NDBuffer | None:
901+
chunk_bytes = await byte_getter.get(prototype=chunk_spec.prototype)
902+
if chunk_bytes is None:
903+
return None
904+
return self._decode_sync(chunk_bytes, chunk_spec)[selection]
905+
906+
async def _encode_partial_single(
907+
self, byte_setter: Any, chunk_array: NDBuffer, selection: Any, chunk_spec: ArraySpec
908+
) -> None:
909+
existing = await byte_setter.get(prototype=chunk_spec.prototype)
910+
if existing is None:
911+
full = chunk_spec.prototype.nd_buffer.create(
912+
shape=chunk_spec.shape,
913+
dtype=chunk_spec.dtype.to_native_dtype(),
914+
fill_value=chunk_spec.fill_value,
915+
)
916+
else:
917+
full = self._decode_sync(existing, chunk_spec)
918+
full[selection] = chunk_array
919+
encoded = self._encode_sync(full, chunk_spec)
920+
assert encoded is not None
921+
await byte_setter.set(encoded)
922+
923+
924+
register_codec("test-partial-mixin", PartialMixinCodec)
925+
926+
_FUSED = {"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}
927+
_BATCHED = {"codec_pipeline.path": "zarr.core.codec_pipeline.BatchedCodecPipeline"}
928+
929+
930+
@pytest.mark.filterwarnings("ignore::zarr.errors.UnstableSpecificationWarning")
931+
@pytest.mark.parametrize("dtype", ["uint8", "float64"])
932+
def test_partial_mixin_codec_async_partial_only_round_trip(dtype: str) -> None:
933+
"""A serializer advertising the partial mixins with only async partial
934+
methods must round-trip under the fused pipeline: full write, full read,
935+
partial read, partial write, plus cross-pipeline parity with
936+
BatchedCodecPipeline."""
937+
data = np.arange(64, dtype=dtype).reshape(8, 8)
938+
939+
with zarr_config.set(_FUSED):
940+
store = MemoryStore()
941+
arr = zarr.create_array(
942+
store,
943+
shape=(8, 8),
944+
chunks=(4, 4),
945+
dtype=dtype,
946+
serializer=PartialMixinCodec(),
947+
compressors=None,
948+
filters=None,
949+
fill_value=0,
950+
)
951+
952+
pipeline = arr._async_array.codec_pipeline
953+
assert isinstance(pipeline, FusedCodecPipeline)
954+
assert pipeline.supports_partial_decode
955+
assert pipeline.supports_partial_encode
956+
assert pipeline.sync_transform is not None
957+
958+
arr[:] = data
959+
np.testing.assert_array_equal(arr[:], data)
960+
np.testing.assert_array_equal(arr[1:5, 2:7], data[1:5, 2:7])
961+
962+
expected = data.copy()
963+
expected[2:6, 1:3] = 7
964+
arr[2:6, 1:3] = expected[2:6, 1:3]
965+
np.testing.assert_array_equal(arr[:], expected)
966+
967+
with zarr_config.set(_BATCHED):
968+
np.testing.assert_array_equal(zarr.open_array(store, mode="r")[:], expected)

0 commit comments

Comments
 (0)