Skip to content

Commit 2703b43

Browse files
Drive block reads through BlockReader + anyio portal
VczReader now owns a single anyio BlockingPortal (started lazily on first variant_chunks() call) and a CapacityLimiter sized to DEFAULT_DECODE_THREADS (default os.cpu_count()). The 32-worker ThreadPoolExecutor still schedules block reads cross-chunk; each worker now blocks on portal.call(_read_block_async, ...) instead of arr.blocks[idx], so fetch and decode flow through the new BlockReader path that PR 1 introduced. ReadaheadPipeline takes the portal, decode_limiter, and a get_block_reader callable. BlockReadTemplate carries a BlockReader instead of a raw zarr.Array. _read_block_async handles slab fetches (slice(None) over non-variants axes) by resolving slices via BlockReader.cdata_shape, fetching every chunk concurrently inside an anyio.create_task_group, and assembling with np.block. VczReader gains a thread-safe _ensure_portal() (so concurrent variant_chunks() callers don't race on portal startup), a per-field _get_block_reader() cache, and an explicit close() that tears down both the executor and the portal. The full test suite passes unchanged. Performance benchmarks against the four backends are deferred to before merging — the dataset isn't pre-generated and a full sweep belongs in a separate review step.
1 parent 6c6dcb1 commit 2703b43

3 files changed

Lines changed: 335 additions & 56 deletions

File tree

tests/test_retrieval.py

Lines changed: 163 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import threading
66
import time
77

8+
import anyio
9+
import anyio.from_thread
810
import numpy as np
911
import numpy.testing as nt
1012
import pandas as pd
@@ -15,7 +17,7 @@
1517
from vcztools import regions as regions_mod
1618
from vcztools import retrieval as retrieval_mod
1719
from vcztools import samples as samples_mod
18-
from vcztools import utils
20+
from vcztools import utils, zarr_direct
1921
from vcztools.bcftools_filter import BcftoolsFilter
2022
from vcztools.retrieval import CachedVariantChunk, VczReader
2123

@@ -265,6 +267,9 @@ def _make_pipeline(
265267
read_fields=None,
266268
n_chunks=None,
267269
executor=None,
270+
portal=None,
271+
decode_limiter=None,
272+
block_readers=None,
268273
):
269274
"""Construct a ``ReadaheadPipeline`` directly against ``root``,
270275
matching the wiring ``VczReader.variant_chunks`` does (default
@@ -276,11 +281,31 @@ def _make_pipeline(
276281
block); ``None`` means "build a small pool here", which is the
277282
common case for tests that only need the pipeline to run once and
278283
don't share the executor with other pipelines.
284+
285+
``portal`` and ``decode_limiter`` are the anyio primitives the
286+
pipeline drives stores against. ``None`` for either falls back to
287+
the module-level test fixtures so individual tests don't have to
288+
plumb them through.
279289
"""
280290
if read_fields is None:
281291
read_fields = ["variant_position"]
282292
if executor is None:
283293
executor = cf.ThreadPoolExecutor(max_workers=2)
294+
if portal is None:
295+
portal = _shared_test_portal()
296+
if decode_limiter is None:
297+
decode_limiter = anyio.CapacityLimiter(2)
298+
if block_readers is None:
299+
block_readers = {}
300+
301+
def get_block_reader(name):
302+
cached = block_readers.get(name)
303+
if cached is not None:
304+
return cached
305+
reader = zarr_direct.BlockReader(root[name])
306+
block_readers[name] = reader
307+
return reader
308+
284309
samples_chunk_size = int(root["sample_id"].chunks[0])
285310
raw_sample_ids = root["sample_id"][:]
286311
samples_selection = np.flatnonzero(raw_sample_ids != "")
@@ -301,9 +326,35 @@ def _make_pipeline(
301326
read_fields,
302327
readahead_bytes=readahead_bytes,
303328
executor=executor,
329+
portal=portal,
330+
decode_limiter=decode_limiter,
331+
get_block_reader=get_block_reader,
304332
)
305333

306334

335+
_TEST_PORTAL_LOCK = threading.Lock()
336+
_TEST_PORTAL_STATE: dict = {"cm": None, "portal": None}
337+
338+
339+
def _shared_test_portal() -> anyio.from_thread.BlockingPortal:
340+
"""Lazily start a single anyio portal shared across pipeline tests.
341+
342+
Building one BlockingPortal per test would multiply asyncio thread
343+
starts across the suite; sharing one keeps the test run fast while
344+
matching what ``VczReader`` does at production runtime (one portal
345+
per reader). The portal is process-global and intentionally never
346+
torn down — daemon threads exit with the interpreter.
347+
"""
348+
with _TEST_PORTAL_LOCK:
349+
if _TEST_PORTAL_STATE["portal"] is None:
350+
cm = anyio.from_thread.start_blocking_portal(
351+
backend="asyncio", name="vcztools-test-portal"
352+
)
353+
_TEST_PORTAL_STATE["cm"] = cm
354+
_TEST_PORTAL_STATE["portal"] = cm.__enter__()
355+
return _TEST_PORTAL_STATE["portal"]
356+
357+
307358
class _DepthTrackingPipeline(retrieval_mod.ReadaheadPipeline):
308359
"""Pipeline subclass that records ``len(_in_flight)`` after each
309360
``_refill`` call. Used to assert depth-control behaviour under
@@ -359,29 +410,47 @@ def _sample_plan(self, root):
359410
non_null, samples_chunk_size=samples_chunk_size
360411
)
361412

413+
def _get_block_reader(self, root):
414+
cache = {}
415+
416+
def get(name):
417+
cached = cache.get(name)
418+
if cached is not None:
419+
return cached
420+
reader = zarr_direct.BlockReader(root[name])
421+
cache[name] = reader
422+
return reader
423+
424+
return get
425+
362426
def test_static_field_rejected(self):
363427
root = _vcz_for_template_tests()
364428
with pytest.raises(AssertionError, match="non-variants-axis"):
365429
retrieval_mod.create_chunk_read_list(
366-
root, self._sample_plan(root), ["sample_id"]
430+
root,
431+
self._sample_plan(root),
432+
["sample_id"],
433+
get_block_reader=self._get_block_reader(root),
367434
)
368435

369436
def test_variant_axis_1d_field(self):
370437
root = _vcz_for_template_tests()
438+
get = self._get_block_reader(root)
371439
templates = retrieval_mod.create_chunk_read_list(
372-
root, self._sample_plan(root), ["variant_position"]
440+
root, self._sample_plan(root), ["variant_position"], get_block_reader=get
373441
)
374442
assert len(templates) == 1
375443
t = templates[0]
376444
assert t.key == ("variant_position",)
377-
assert t.arr == root["variant_position"]
445+
assert t.block_reader is get("variant_position")
378446
# 1-D variants axis → no extra dims after the variant chunk slot.
379447
assert t.block_index_suffix == ()
380448

381449
def test_variant_axis_2d_field(self):
382450
root = _vcz_for_template_tests()
451+
get = self._get_block_reader(root)
383452
templates = retrieval_mod.create_chunk_read_list(
384-
root, self._sample_plan(root), ["variant_allele"]
453+
root, self._sample_plan(root), ["variant_allele"], get_block_reader=get
385454
)
386455
assert len(templates) == 1
387456
t = templates[0]
@@ -391,22 +460,28 @@ def test_variant_axis_2d_field(self):
391460

392461
def test_call_field_2d_fans_out_per_sample_chunk(self):
393462
root = _vcz_for_template_tests()
463+
get = self._get_block_reader(root)
394464
plan = self._sample_plan(root)
395465
# 4 samples, samples_chunk_size=2 → 2 sample chunks.
396466
assert len(plan.chunk_reads) == 2
397-
templates = retrieval_mod.create_chunk_read_list(root, plan, ["call_DP"])
467+
templates = retrieval_mod.create_chunk_read_list(
468+
root, plan, ["call_DP"], get_block_reader=get
469+
)
398470
assert len(templates) == 2
399471
assert [t.key for t in templates] == [("call_DP", 0), ("call_DP", 1)]
400-
call_dp = root["call_DP"]
472+
# All templates for call_DP share the same BlockReader instance.
473+
assert templates[0].block_reader is templates[1].block_reader
401474
for t, cr in zip(templates, plan.chunk_reads):
402-
assert t.arr == call_dp
403475
# 2-D (variants, samples) → suffix is (sci,), no trailing slices.
404476
assert t.block_index_suffix == (cr.index,)
405477

406478
def test_call_field_3d_keeps_trailing_slice(self):
407479
root = _vcz_for_template_tests()
480+
get = self._get_block_reader(root)
408481
plan = self._sample_plan(root)
409-
templates = retrieval_mod.create_chunk_read_list(root, plan, ["call_genotype"])
482+
templates = retrieval_mod.create_chunk_read_list(
483+
root, plan, ["call_genotype"], get_block_reader=get
484+
)
410485
assert len(templates) == len(plan.chunk_reads)
411486
for t, cr in zip(templates, plan.chunk_reads):
412487
assert t.key == ("call_genotype", cr.index)
@@ -418,9 +493,13 @@ def test_multiple_fields_in_input_order(self):
418493
# templates; ordering is "fields in input order, then sample
419494
# chunks within a call_*".
420495
root = _vcz_for_template_tests()
496+
get = self._get_block_reader(root)
421497
plan = self._sample_plan(root)
422498
templates = retrieval_mod.create_chunk_read_list(
423-
root, plan, ["variant_position", "call_DP", "variant_allele"]
499+
root,
500+
plan,
501+
["variant_position", "call_DP", "variant_allele"],
502+
get_block_reader=get,
424503
)
425504
assert [t.key for t in templates] == [
426505
("variant_position",),
@@ -436,14 +515,30 @@ class TestUpdateChunkReadList:
436515
list is reusable across every chunk in a query.
437516
"""
438517

518+
def _get_block_reader(self, root):
519+
cache = {}
520+
521+
def get(name):
522+
cached = cache.get(name)
523+
if cached is not None:
524+
return cached
525+
reader = zarr_direct.BlockReader(root[name])
526+
cache[name] = reader
527+
return reader
528+
529+
return get
530+
439531
def test_variant_non_call_template_prepends_variant_chunk_index(self):
440532
root = _vcz_for_template_tests()
441533
plan = samples_mod.SampleChunkPlan(
442534
chunk_reads=[utils.ChunkRead(index=0, num_selected=2)],
443535
permutation=None,
444536
)
445537
templates = retrieval_mod.create_chunk_read_list(
446-
root, plan, ["variant_position", "variant_allele"]
538+
root,
539+
plan,
540+
["variant_position", "variant_allele"],
541+
get_block_reader=self._get_block_reader(root),
447542
)
448543
reads = retrieval_mod.update_chunk_read_list(templates, 3)
449544
keys = [r[0] for r in reads]
@@ -456,7 +551,9 @@ def test_call_template_keeps_sample_chunk_index_after_variant(self):
456551
plan = samples_mod.build_chunk_plan(
457552
np.array([0, 1, 2, 3], dtype=np.int64), samples_chunk_size=2
458553
)
459-
templates = retrieval_mod.create_chunk_read_list(root, plan, ["call_DP"])
554+
templates = retrieval_mod.create_chunk_read_list(
555+
root, plan, ["call_DP"], get_block_reader=self._get_block_reader(root)
556+
)
460557
reads = retrieval_mod.update_chunk_read_list(templates, 5)
461558
assert [r[0] for r in reads] == [("call_DP", 0), ("call_DP", 1)]
462559
assert [r[2] for r in reads] == [(5, 0), (5, 1)]
@@ -471,7 +568,10 @@ def test_two_calls_yield_independent_lists(self):
471568
permutation=None,
472569
)
473570
templates = retrieval_mod.create_chunk_read_list(
474-
root, plan, ["variant_position"]
571+
root,
572+
plan,
573+
["variant_position"],
574+
get_block_reader=self._get_block_reader(root),
475575
)
476576
reads_a = retrieval_mod.update_chunk_read_list(templates, 0)
477577
reads_b = retrieval_mod.update_chunk_read_list(templates, 4)
@@ -537,22 +637,40 @@ def test_bootstrap_runs_first_chunk_solo(self):
537637
assert pipeline._per_chunk_bytes == expected
538638
gen.close()
539639

640+
def _build_depth_tracking(self, root, *, readahead_bytes, executor):
641+
cache: dict[str, zarr_direct.BlockReader] = {}
642+
643+
def get(name):
644+
cached = cache.get(name)
645+
if cached is not None:
646+
return cached
647+
reader = zarr_direct.BlockReader(root[name])
648+
cache[name] = reader
649+
return reader
650+
651+
return _DepthTrackingPipeline(
652+
root,
653+
[utils.ChunkRead(index=i, num_selected=3) for i in range(4)],
654+
samples_mod.build_chunk_plan(
655+
np.arange(2, dtype=np.int64), samples_chunk_size=2
656+
),
657+
None,
658+
["variant_position"],
659+
readahead_bytes=readahead_bytes,
660+
executor=executor,
661+
portal=_shared_test_portal(),
662+
decode_limiter=anyio.CapacityLimiter(2),
663+
get_block_reader=get,
664+
)
665+
540666
def test_readahead_bytes_zero_keeps_depth_one(self):
541667
# Budget=0 → after every refill, exactly one chunk is queued
542668
# ahead of the consumer (and zero on the final, plan-exhausted
543669
# refill).
544670
root = self._vcz(num_variants=12, variants_chunk_size=3)
545671
with cf.ThreadPoolExecutor(max_workers=2) as executor:
546-
pipeline = _DepthTrackingPipeline(
547-
root,
548-
[utils.ChunkRead(index=i, num_selected=3) for i in range(4)],
549-
samples_mod.build_chunk_plan(
550-
np.arange(2, dtype=np.int64), samples_chunk_size=2
551-
),
552-
None,
553-
["variant_position"],
554-
readahead_bytes=0,
555-
executor=executor,
672+
pipeline = self._build_depth_tracking(
673+
root, readahead_bytes=0, executor=executor
556674
)
557675
list(pipeline)
558676
# 4 chunks → 5 refills (one per consume + the post-final empty refill).
@@ -563,16 +681,8 @@ def test_large_readahead_schedules_all_remaining_after_bootstrap(self):
563681
# refill fills with every remaining chunk in one go.
564682
root = self._vcz(num_variants=12, variants_chunk_size=3)
565683
with cf.ThreadPoolExecutor(max_workers=2) as executor:
566-
pipeline = _DepthTrackingPipeline(
567-
root,
568-
[utils.ChunkRead(index=i, num_selected=3) for i in range(4)],
569-
samples_mod.build_chunk_plan(
570-
np.arange(2, dtype=np.int64), samples_chunk_size=2
571-
),
572-
None,
573-
["variant_position"],
574-
readahead_bytes=10**9,
575-
executor=executor,
684+
pipeline = self._build_depth_tracking(
685+
root, readahead_bytes=10**9, executor=executor
576686
)
577687
list(pipeline)
578688
# Bootstrap depth=1, then post-yield-1 schedules the remaining
@@ -1017,9 +1127,24 @@ def _make_cached_chunk(
10171127
num_selected=variant_num_selected,
10181128
selection=variant_selection,
10191129
)
1020-
templates = retrieval_mod.create_chunk_read_list(root, sample_chunk_plan, fields)
1130+
block_readers: dict[str, zarr_direct.BlockReader] = {}
1131+
1132+
def get_block_reader(name):
1133+
cached = block_readers.get(name)
1134+
if cached is not None:
1135+
return cached
1136+
reader = zarr_direct.BlockReader(root[name])
1137+
block_readers[name] = reader
1138+
return reader
1139+
1140+
templates = retrieval_mod.create_chunk_read_list(
1141+
root, sample_chunk_plan, fields, get_block_reader=get_block_reader
1142+
)
10211143
reads = retrieval_mod.update_chunk_read_list(templates, variant_chunk.index)
1022-
blocks = {key: retrieval_mod._read_block(arr, idx) for key, arr, idx in reads}
1144+
# Build expected blocks via Zarr's high-level path; the
1145+
# CachedVariantChunk under test consumes the dict shape, not the
1146+
# source of the bytes.
1147+
blocks = {key: root[key[0]].blocks[idx] for key, _reader, idx in reads}
10231148
return CachedVariantChunk(
10241149
root,
10251150
variant_chunk,
@@ -2401,9 +2526,9 @@ def test_filter_referenced_static_field_not_in_pipeline(self, monkeypatch):
24012526
seen_fields: list[str] = []
24022527
original = retrieval_mod._read_block
24032528

2404-
def capturing_read_block(arr, block_index):
2405-
seen_fields.append(arr.path.rsplit("/", 1)[-1])
2406-
return original(arr, block_index)
2529+
def capturing_read_block(portal, reader, block_index, decode_limiter):
2530+
seen_fields.append(reader._path.rsplit("/", 1)[-1])
2531+
return original(portal, reader, block_index, decode_limiter)
24072532

24082533
monkeypatch.setattr(retrieval_mod, "_read_block", capturing_read_block)
24092534

0 commit comments

Comments
 (0)