Skip to content

fix(metadata): validate codec chains against the threaded chunk spec - #4352

Open
d-v-b wants to merge 4 commits into
zarr-developers:mainfrom
d-v-b:fix/codec-chain-validation
Open

d-v-b wants to merge 4 commits into
zarr-developers:mainfrom
d-v-b:fix/codec-chain-validation

Conversation

@d-v-b

@d-v-b d-v-b commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Validate each codec in a chain against the chunk geometry produced by the codecs before it, including the inner codecs of a sharding codec and the actual fill value. For example, a sharding codec placed after a transpose must now divide the transposed chunks; previously it was checked against the untransposed chunk grid, which could accept chains that read back wrong data and reject valid ones.

Fixes #4354.

Codec validation needs the geometry and dtype produced by preceding codecs. evolve_and_validate_codecs threads chunk specs through evolution, validation, and metadata resolution. Inner sharding chains use the actual inner spec, including its fill value.

Geometry is threaded as a whole chunk grid

Following zarrs (encoded_chunk_grid / chunk-local grids), codecs describe how they map the chunk grid through a new BaseCodec.resolve_chunk_grid(shape=..., chunk_grid=...) method, and validation carries the grid through the chain rather than enumerating chunk shapes. Cost is proportional to the number of codecs and dimensions, independent of how many distinct chunk shapes a rectilinear grid has.

  • Codecs that do not override resolve_metadata declare the identity by default.
  • CastValue, ScaleOffset and the numcodecs Delta, FixedScaleOffset and AsType declare the identity explicitly (they change dtype or fill value, never shape).
  • TransposeCodec permutes the shape and the per-axis edge lists.
  • A declaration is ignored if a subclass overrides resolve_metadata without the hook, or if it disagrees with resolve_metadata on the representative chunk.

Trade-off: chunk-local validation

A codec that overrides resolve_metadata and returns None from resolve_chunk_grid (the default) is still validated exactly on a regular grid, since every chunk has one shape. On a rectilinear grid it makes the rest of the chain chunk-local: later codecs are validated against one representative chunk (largest edge per axis). A chain that is invalid only for some other chunk shape is accepted at creation and fails when such a chunk is first encoded or decoded.

To make that failure an error rather than corruption, ShardingCodec._get_chunks_per_shard now checks divisibility at run time (previously it floor-divided; with the check removed, a non-dividing shard writes and reads back wrong rows). Codecs whose correctness depends on chunk shape must do the same.

The same applies to resize: growing a rectilinear array behind an undeclared filter can add a non-dividing edge. The resize succeeds, and the error appears when a write touches that shard.

Writes to these arrays validate the distinct chunk shapes they touch before any I/O, so a write that includes a bad shard stores nothing. Without that check, a write spanning good and bad shards raised, and the good shard was still stored shortly after the error. Arrays validated exactly when their metadata was created skip the check. A codec that checks chunk shape only while encoding is not covered by this pre-write check.

The trade-off is documented in the docstrings of evolve_and_validate_codecs, resolve_chunk_grid and the sharding check, in a new "Chunk geometry and validation" section of the extending guide, in the rectilinear sharding docs, and in the changelog entry.

Other behavior changes

  • Sharded scale_offset is accepted again. fix(sharding): validate nested sharding codecs and reject an inner chunk size of 0 #4480 added an inner-chain check to ShardingCodec.validate that resolved against a zero fill value, which has rejected valid arrays since 3.4.1, such as scale_offset with a non-zero offset on uint8. This PR removes that check because evolve_from_array_spec validates the same chain with the real fill value. Calling ShardingCodec.validate directly therefore no longer checks nested codecs; every metadata path still does.
  • Stored arrays with an invalid chain now raise on open, which also fails listing the members of their group. These chains do not read back correctly on main.
  • Rectilinear pipelines are evolved against the representative chunk shape instead of an all-ones placeholder that broke shape-dependent codecs.

Tests

Accepted and rejected transpose/sharding chains (also behind Delta), reshape followed by transpose, nested sharding, rectilinear divisibility, actual fill values, data and metadata round trips. Chunk-local behavior is pinned from both sides: an undeclared filter on a rectilinear grid still rejects a shard shape the representative chunk fails, accepts one that only a smaller chunk fails and then raises on the first write to that shard, and does not enumerate chunks to find a shape-changing codec's error. A table test covers when a declared grid is trusted or ignored. The bounded-call-count regression now includes Delta and an undeclared filter chains at ranks 4 and 12.

Local validation on the rebased branch: full suite 13318 passed, 1 failed, 1319 skipped, 9 xfailed; pre-commit hooks incl. mypy pass. The failure is tests/test_store/test_zip.py::TestZipStore::test_unused_copy_of_open_writer_does_not_write, an intermittent ResourceWarning for an unclosed file seen in two of four full runs; it passes when test_zip.py runs alone, and this PR does not touch zip code.

Rebase notes

The agent rebuilt the branch on main as three commits; the previous head was 5a52e85f944c8309570d0f81ebc883726adb1cfd. Open review questions from earlier comments still stand, including whether an undeclared codec on a rectilinear grid should fail outright instead of becoming chunk-local. Supersedes d-v-b#303.

🤖 Generated with Claude Code

@read-the-docs-community

read-the-docs-community Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.47059% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.85%. Comparing base (a981abf) to head (ae8ddb1).

Files with missing lines Patch % Lines
src/zarr/abc/codec.py 75.00% 1 Missing ⚠️
src/zarr/codecs/sharding.py 80.00% 1 Missing ⚠️
src/zarr/core/metadata/v3.py 98.03% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4352      +/-   ##
==========================================
+ Coverage   94.84%   94.85%   +0.01%     
==========================================
  Files          96       96              
  Lines       13997    14056      +59     
==========================================
+ Hits        13275    13333      +58     
- Misses        722      723       +1     
Files with missing lines Coverage Δ
src/zarr/codecs/cast_value.py 98.75% <100.00%> (+0.01%) ⬆️
src/zarr/codecs/numcodecs/_codecs.py 95.62% <100.00%> (+0.14%) ⬆️
src/zarr/codecs/scale_offset.py 100.00% <100.00%> (ø)
src/zarr/codecs/transpose.py 92.30% <100.00%> (+0.78%) ⬆️
src/zarr/core/array.py 98.44% <100.00%> (+<0.01%) ⬆️
src/zarr/abc/codec.py 97.70% <75.00%> (-1.10%) ⬇️
src/zarr/codecs/sharding.py 95.60% <80.00%> (-0.19%) ⬇️
src/zarr/core/metadata/v3.py 97.20% <98.03%> (+0.29%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@d-v-b
d-v-b marked this pull request as ready for review September 14, 2026 14:43
@d-v-b d-v-b added the benchmark Code will be benchmarked in a CI job. label Sep 14, 2026
@d-v-b

d-v-b commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

this PR has a performance impact for codec creation with rectilinear chunk grids, where we loop over every chunk shape defined in a grid, checking each one for compatibility with the entire codec chain. I'm working on a mitigation.

@codspeed

codspeed Bot commented Sep 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 113 untouched benchmarks
⏩ 37 skipped benchmarks1


Comparing d-v-b:fix/codec-chain-validation (2c56eed) with main (ba883a5)2

Open in CodSpeed

Footnotes

  1. 37 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (0de6077) during the generation of this report, so ba883a5 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@d-v-b
d-v-b requested a review from ilan-gold September 14, 2026 15:52
@d-v-b

d-v-b commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

requesting review because this adds to codec API

Comment thread src/zarr/abc/codec.py
"""
return chunk_spec

def resolve_chunk_grid(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ilan-gold this is new API on the codec abc. we need the whole chunk grid to be in-scope for resolution because combining a rectilinear chunk grid with e.g. a transpose or reshape codec creates a large number of chunk shapes, and each chunk size needs to be consistent with the subchunk grid of e.g. a sharding codec. The meaning of "consistent" will in theory vary with the downstream codecs, but for sharding we just need to ensure that each incoming chunk shape tiles the sharding chunk grid.

this would be a lot easier if the sharding codec chunk grid was semi-regular.

@d-v-b d-v-b added this to the 3.5.0 milestone Sep 16, 2026

@kokhlo kokhlo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified review — I duplicated the issue #4354 scenario on main (7291469) and on this branch (5a52e85) before reading the diff closely; findings below are all reproduced locally, not read off the code.

Root cause confirmed independently

On main, ArrayV3Metadata.__init__ validates every codec against the array-level chunk_grid (v3.py, old loop: c.validate(shape=spec.shape, dtype=spec.dtype, chunk_grid=chunk_grid_parsed)), and ShardingCodec.validate checks every rectilinear edge of that original grid against chunk_shape. After a shape/rank-changing array→array codec the geometry no longer matches the original grid, so main produces axis-confused false rejections and, worse, would accept chains that read back wrong data. Reproduced on main:

zarr 3.4.1.dev12+g7291469 [main]
rectilinear chunks ((30,),(34,)) + transpose(1,0) + sharding(chunk_shape=(17,15))
REJECTED: ValueError: Chunk edge length 30 in dimension 0 is not divisible by the shard's inner chunk size 17.

(17 divides the transposed dim-0 edge 34, 15 divides the transposed dim-1 edge 30 — the chain is valid; the error is checking the untransposed grid.) Same construction on this branch is accepted and round-trips metadata:

zarr 3.4.1.dev26+g5a52e85 [pr]
metadata ACCEPTED
JSON roundtrip of metadata: OK

Also verified on the branch: representative-chunk evolution cost stays flat (3-d rectilinear, 100 distinct edges/axis, Delta+Bytes+Zstd → 0.0001s metadata construction), the chunk-local accept path ends in the new loud _get_chunks_per_shard error rather than silent mislayout, and tests/test_codecs/test_codec_chain_validation{,_properties}.py pass (40 passed). My a-priori gap candidates — inner sharding grids and create_codec_pipeline's old all-ones placeholder — are both addressed (evolve_and_validate_codecs inside evolve_from_array_spec; representative-chunk evolution in array.py).

Trust-boundary probe: declaration spoofing fails on all three paths

I tried to produce a codec that lies via resolve_chunk_grid (identity declaration while reshaping at runtime), through subclassing rather than a from-scratch codec, since that's the path the docstring's distrust rules exist for:

  1. Subclassing TransposeCodec and overriding only resolve_metadata → correctly treated as undeclared (_defining_class MRO walk: metadata_owner is not grid_owner → None → chunk-local).
  2. Subclassing TransposeCodec with an inconsistent resolve_chunk_grid override → caught by the repr-disagreement check.
  3. Subclassing a no-resolve_metadata codec (GzipCodec) that reshapes at runtime → inherited identity declaration is trusted, but this is the right call: for every shipped codec that inherits the default, the declaration is structurally true, and the runtime divisibility guard catches the damage on the sharding path.

No hole found. One consideration, non-blocking:

  • PackBits.resolve_metadata returns a 1-d packed shape, so it now drives the chain chunk-local on rectilinear grids even though the edge structure is preserved in a flattened form; fine, but if a future grid type could express "flattened edges" it would restore exact validation behind PackBits.

One gap worth closing before merge

This PR never references #4354 — body, changelog entry (changes/4352.bugfix.md), and docs have no mention. It was opened ~1 hour before the issue and links only the fork PR, so GitHub won't cross-link them and the issue has no visible resolution path. Since this branch is the resolution of that issue, adding "#4354" to the changelog entry (or the body) would close the loop.

The design matches zarrs' grid-mapping approach, the chunk-local trade-off is documented on all three surfaces (docstrings, extending guide, changelog) with both failure directions stated, and CI is green. Happy to re-review if the reference is added or anything above needs rework.

@ilan-gold ilan-gold left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zarr has done breaking changes before, and this seems kind of important. It feels like there should be a safer way to do this than allowing users to create technically invalid arrays only to fail at read/write time.

Does that make sense?

Comment thread src/zarr/codecs/transpose.py Outdated
self, *, shape: tuple[int, ...], chunk_grid: ChunkGridMetadata
) -> tuple[tuple[int, ...], ChunkGridMetadata]:
"""Permute the array shape and the per-axis chunk edges by `order`."""
from zarr.core.metadata.v3 import RectilinearChunkGridMetadata, RegularChunkGridMetadata

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this need to be a local import?

Comment thread src/zarr/abc/codec.py
Comment on lines +167 to +171
makes the remaining chain "chunk-local": later codecs are validated
against one representative chunk only, so a chain that is invalid for
some other chunk shape is accepted at creation and fails when such a
chunk is first encoded or decoded (see
`zarr.core.metadata.v3.evolve_and_validate_codecs`).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we do not fail completely in this case? To protect people who have already written Codecs that don't implement chunk grid resolution from unnecessary errors?

d-v-b added 3 commits November 2, 2026 16:04
Validate each codec against the chunk geometry produced by the codecs
before it, including the inner codecs of a sharding codec and the actual
fill value. A sharding codec after a transpose must divide the transposed
chunks; it used to be checked against the untransposed grid, which could
accept chains that read back wrong data and reject valid ones.

Geometry is threaded as a whole chunk grid through the new
`BaseCodec.resolve_chunk_grid`, following zarrs, so the cost does not
depend on the number of distinct chunk shapes. On a rectilinear grid, a
codec that overrides `resolve_metadata` without declaring a grid makes the
rest of its chain chunk-local: later codecs see one representative chunk,
and `ShardingCodec` checks divisibility at encode and decode time so a
shape validation never saw raises instead of corrupting data.

Rectilinear codec pipelines are evolved against the same representative
chunk shape, replacing an all-ones placeholder.

Assisted-by: ClaudeCode:claude-opus-5-5
`ShardingCodec.validate` also validated its inner codecs, resolving them
against a zero fill value it made up. `scale_offset` encodes the fill value
when it resolves, so a non-zero offset on an unsigned dtype was rejected
even though the array is valid. `evolve_from_array_spec` now validates the
same chain with the real spec, so the check in `validate` is removed.

Assisted-by: ClaudeCode:claude-opus-5-5
A chunk holding only the fill value is never stored, and a representative
chunk can lie past the array's extent. The changelog now also says that a
stored array with an invalid chain raises on open.

Assisted-by: ClaudeCode:claude-opus-5-5
@d-v-b
d-v-b force-pushed the fix/codec-chain-validation branch from 5a52e85 to 60cdf79 Compare November 2, 2026 16:32
…hing

On a chunk-local array, a write spanning good and bad shards raised on the
bad one while the good one was still stored shortly after the error. Writes
to these arrays now validate the distinct chunk shapes they touch before any
I/O. Arrays whose chains were validated exactly skip the check.

Assisted-by: ClaudeCode:claude-opus-5-5

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark Code will be benchmarked in a CI job.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codec validation requires chunk grid info

3 participants