55import threading
66import time
77
8+ import anyio
9+ import anyio .from_thread
810import numpy as np
911import numpy .testing as nt
1012import pandas as pd
1517from vcztools import regions as regions_mod
1618from vcztools import retrieval as retrieval_mod
1719from vcztools import samples as samples_mod
18- from vcztools import utils
20+ from vcztools import utils , zarr_direct
1921from vcztools .bcftools_filter import BcftoolsFilter
2022from 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+
307358class _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