Skip to content

Commit 6c6dcb1

Browse files
Add BlockReader for direct chunk fetch + decode
Owns the path from (zarr.Array, chunk_coords) to np.ndarray on top of Zarr's storage and codec layers, without going through arr.blocks[idx] (which spins up a fresh asyncio loop per call). The output matches arr.blocks[coords] byte-for-byte across both Zarr v2 and v3 metadata, including boundary trim and missing-chunk fill values. Sharded arrays are explicitly unsupported and refused at construction. Verified by parity tests against synthetic fixtures (every codec) and real bio2zarr-produced VCZ fixtures (sample.vcz, sample.vcz3, field_type_combos). Nothing in the production code path uses BlockReader yet — the integration lands in the next PR.
1 parent 4341f79 commit 6c6dcb1

4 files changed

Lines changed: 374 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ authors = [
1616
{name = "sgkit Developers", email = "project@sgkit.dev"},
1717
]
1818
dependencies = [
19+
"anyio>=4",
1920
"numpy>=2",
2021
"zarr>=3.1",
2122
"click>=8.2.0",
@@ -139,6 +140,7 @@ unfixable = []
139140

140141
[tool.ruff.lint.isort]
141142
known-third-party = [
143+
"anyio",
142144
"bio2zarr",
143145
"click",
144146
"cyvcf2",

tests/test_zarr_direct.py

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
"""Tests for vcztools.zarr_direct.BlockReader.
2+
3+
The contract: ``BlockReader.read_chunk(coords)`` returns the same
4+
ndarray as ``zarr.Array.blocks[coords]`` for every chunk position,
5+
across both Zarr v2 and v3 metadata, every codec we encounter in
6+
real fixtures, missing chunks (fill values), and boundary chunks.
7+
Sharded arrays are refused at construction.
8+
"""
9+
10+
import itertools
11+
import warnings
12+
13+
import anyio
14+
import numpy as np
15+
import numpy.testing as nt
16+
import pytest
17+
import zarr
18+
import zarr.codecs
19+
import zarr.storage
20+
21+
from vcztools.zarr_direct import BlockReader
22+
23+
24+
def _all_chunk_coords(arr: zarr.Array):
25+
"""Iterate every chunk index tuple for ``arr``."""
26+
return itertools.product(*[range(n) for n in arr.cdata_shape])
27+
28+
29+
def _assert_array_parity(arr: zarr.Array):
30+
"""Every chunk read via BlockReader equals arr.blocks[coords]."""
31+
reader = BlockReader(arr)
32+
for coords in _all_chunk_coords(arr):
33+
expected = arr.blocks[coords]
34+
got = anyio.run(reader.read_chunk, coords)
35+
nt.assert_array_equal(got, expected, err_msg=f"mismatch at {coords}")
36+
assert got.shape == expected.shape, f"shape mismatch at {coords}"
37+
assert got.dtype == expected.dtype, f"dtype mismatch at {coords}"
38+
39+
40+
@pytest.fixture
41+
def synthetic_v3_group():
42+
"""Tiny v3 group with one int array, default codecs (Bytes + Zstd)."""
43+
store = zarr.storage.MemoryStore()
44+
g = zarr.group(store=store, zarr_format=3)
45+
arr = g.create_array(name="ints", shape=(7, 4), chunks=(3, 2), dtype=np.int32)
46+
arr[:] = np.arange(28, dtype=np.int32).reshape(7, 4)
47+
return g
48+
49+
50+
@pytest.fixture
51+
def synthetic_v2_group():
52+
"""v2 group with default Blosc compressor."""
53+
store = zarr.storage.MemoryStore()
54+
g = zarr.group(store=store, zarr_format=2)
55+
arr = g.create_array(name="ints", shape=(7, 4), chunks=(3, 2), dtype=np.int32)
56+
arr[:] = np.arange(28, dtype=np.int32).reshape(7, 4)
57+
return g
58+
59+
60+
class TestBlockReaderSynthetic:
61+
"""Parity + boundary + fill-value tests on tiny in-memory arrays."""
62+
63+
def test_v3_default_codecs(self, synthetic_v3_group):
64+
_assert_array_parity(synthetic_v3_group["ints"])
65+
66+
def test_v2_default_codecs(self, synthetic_v2_group):
67+
_assert_array_parity(synthetic_v2_group["ints"])
68+
69+
def test_uncompressed_v3(self):
70+
store = zarr.storage.MemoryStore()
71+
g = zarr.group(store=store, zarr_format=3)
72+
arr = g.create_array(
73+
name="x",
74+
shape=(7,),
75+
chunks=(3,),
76+
dtype=np.int32,
77+
compressors=None,
78+
filters=None,
79+
)
80+
arr[:] = np.arange(7, dtype=np.int32)
81+
_assert_array_parity(arr)
82+
83+
def test_vlen_strings_v3(self):
84+
store = zarr.storage.MemoryStore()
85+
g = zarr.group(store=store, zarr_format=3)
86+
with warnings.catch_warnings():
87+
warnings.simplefilter("ignore")
88+
data = np.array(
89+
[["hello", "world"], ["foo", ""], ["", "bar"]], dtype="<U16"
90+
)
91+
arr = g.create_array(
92+
name="s", shape=data.shape, chunks=(2, 2), dtype=data.dtype
93+
)
94+
arr[:] = data
95+
_assert_array_parity(arr)
96+
97+
def test_floats(self):
98+
"""Float dtype round-trips; NaN handling is exercised by the
99+
real-fixture suite (which uses ``equal_nan``-aware comparison)."""
100+
store = zarr.storage.MemoryStore()
101+
g = zarr.group(store=store, zarr_format=3)
102+
arr = g.create_array(name="f", shape=(5,), chunks=(2,), dtype=np.float32)
103+
arr[:] = np.array([1.0, 2.5, -3.0, 0.0, 7.7], dtype=np.float32)
104+
_assert_array_parity(arr)
105+
106+
def test_boundary_chunk_trim(self):
107+
"""Boundary chunks return the actual (trimmed) shape, not the
108+
nominal padded shape that's stored on disk."""
109+
store = zarr.storage.MemoryStore()
110+
g = zarr.group(store=store, zarr_format=3)
111+
arr = g.create_array(name="x", shape=(7,), chunks=(3,), dtype=np.int32)
112+
arr[:] = np.arange(7, dtype=np.int32)
113+
reader = BlockReader(arr)
114+
last = anyio.run(reader.read_chunk, (2,))
115+
assert last.shape == (1,)
116+
nt.assert_array_equal(last, [6])
117+
118+
def test_missing_chunk_returns_fill_value(self):
119+
"""A chunk key absent from the store decodes to fill-value-filled
120+
ndarray at the boundary-clamped shape."""
121+
store = zarr.storage.MemoryStore()
122+
g = zarr.group(store=store, zarr_format=3)
123+
arr = g.create_array(
124+
name="x", shape=(6,), chunks=(3,), dtype=np.int32, fill_value=99
125+
)
126+
# Don't write any data — every chunk key is absent.
127+
reader = BlockReader(arr)
128+
for coords in [(0,), (1,)]:
129+
got = anyio.run(reader.read_chunk, coords)
130+
expected = arr.blocks[coords]
131+
nt.assert_array_equal(got, expected)
132+
nt.assert_array_equal(got, [99, 99, 99])
133+
134+
def test_chunk_key_at_root_path(self):
135+
"""An array created at the group root has empty ``array.path``;
136+
the chunk key has no ``"<path>/"`` prefix."""
137+
store = zarr.storage.MemoryStore()
138+
# Create a v3 array directly under the store root (no group).
139+
arr = zarr.create_array(
140+
store=store, shape=(5,), chunks=(2,), dtype=np.int32, zarr_format=3
141+
)
142+
arr[:] = np.arange(5, dtype=np.int32)
143+
reader = BlockReader(arr)
144+
assert reader.chunk_key((0,)) == "c/0"
145+
nt.assert_array_equal(anyio.run(reader.read_chunk, (0,)), [0, 1])
146+
147+
def test_decode_limiter_is_honoured(self):
148+
"""A CapacityLimiter with capacity 1 must let read_chunk complete;
149+
we don't assert serialisation here, just that the path doesn't
150+
deadlock or error when a limiter is supplied."""
151+
store = zarr.storage.MemoryStore()
152+
g = zarr.group(store=store, zarr_format=3)
153+
arr = g.create_array(name="x", shape=(5,), chunks=(2,), dtype=np.int32)
154+
arr[:] = np.arange(5, dtype=np.int32)
155+
reader = BlockReader(arr)
156+
157+
async def run():
158+
limiter = anyio.CapacityLimiter(1)
159+
return await reader.read_chunk((1,), decode_limiter=limiter)
160+
161+
got = anyio.run(run)
162+
nt.assert_array_equal(got, [2, 3])
163+
164+
165+
class TestBlockReaderShardingRefusal:
166+
def test_sharded_array_refused(self):
167+
store = zarr.storage.MemoryStore()
168+
g = zarr.group(store=store, zarr_format=3)
169+
arr = g.create_array(
170+
name="x",
171+
shape=(8, 8),
172+
chunks=(8, 8),
173+
shards=(8, 8),
174+
dtype=np.int32,
175+
)
176+
arr[:] = np.arange(64, dtype=np.int32).reshape(8, 8)
177+
with pytest.raises(NotImplementedError, match="ShardingCodec"):
178+
BlockReader(arr)
179+
180+
181+
class TestBlockReaderRealFixtures:
182+
"""Parity against committed VCZ fixtures — exercises the codecs and
183+
metadata produced by bio2zarr in real workloads (vlen UTF-8, blosc,
184+
zstd, transpose, NaN-bearing floats, etc.)."""
185+
186+
@pytest.fixture(autouse=True)
187+
def _silence_zarr_warnings(self):
188+
with warnings.catch_warnings():
189+
warnings.simplefilter("ignore")
190+
yield
191+
192+
def _check_full_group_parity(self, root: zarr.Group):
193+
"""Read every chunk of every array in the group through both paths
194+
and assert they match. Uses ``equal_nan=True`` since real fixtures
195+
contain NaN-encoded missing values."""
196+
for name in sorted(root.array_keys()):
197+
arr = root[name]
198+
reader = BlockReader(arr)
199+
for coords in _all_chunk_coords(arr):
200+
expected = arr.blocks[coords]
201+
got = anyio.run(reader.read_chunk, coords)
202+
assert got.shape == expected.shape, (
203+
f"{name}{coords}: shape {got.shape} vs {expected.shape}"
204+
)
205+
assert got.dtype == expected.dtype, (
206+
f"{name}{coords}: dtype {got.dtype} vs {expected.dtype}"
207+
)
208+
if np.issubdtype(expected.dtype, np.floating):
209+
nt.assert_array_equal(
210+
np.where(np.isnan(expected), 0, expected),
211+
np.where(np.isnan(got), 0, got),
212+
err_msg=f"{name}{coords}",
213+
)
214+
nt.assert_array_equal(
215+
np.isnan(expected),
216+
np.isnan(got),
217+
err_msg=f"{name}{coords} NaN mask",
218+
)
219+
else:
220+
nt.assert_array_equal(got, expected, err_msg=f"{name}{coords}")
221+
222+
def test_sample_v2(self, fx_sample_vcz):
223+
self._check_full_group_parity(fx_sample_vcz.group)
224+
225+
def test_sample_v3(self, fx_sample_vcz3):
226+
self._check_full_group_parity(fx_sample_vcz3.group)
227+
228+
def test_field_type_combos(self, fx_field_type_combos_vcz):
229+
self._check_full_group_parity(fx_field_type_combos_vcz.group)

uv.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)