22
33import json
44import threading
5+ import time
56from typing import Any , Dict , List , Optional
67from unittest .mock import Mock , patch
78
@@ -81,17 +82,36 @@ def _rest_items(count: int) -> List[Dict[str, Any]]:
8182 ]
8283
8384
84- def _build_dataset (endpoint : Optional [FakeItemsEndpoint ]) -> Dataset :
85+ def _mock_rest_client (endpoint = None , version_hash : Optional [str ] = None ) -> Mock :
86+ """A rest client whose dataset-id and version lookups are both controlled.
87+
88+ ``version_hash=None`` models a backend with no version to pin a read to,
89+ which is the default so the assertions about the ``version`` query
90+ parameter stay meaningful.
91+ """
8592 mock_rest_client = Mock ()
8693 mock_rest_client .datasets .get_dataset_by_identifier .return_value .id = DATASET_ID
94+
95+ versions_page = Mock ()
96+ versions_page .content = (
97+ [Mock (version_hash = version_hash )] if version_hash is not None else []
98+ )
99+ mock_rest_client .datasets .list_dataset_versions .return_value = versions_page
100+
87101 if endpoint is not None :
88102 mock_rest_client ._client_wrapper .httpx_client .request .side_effect = endpoint
89103
104+ return mock_rest_client
105+
106+
107+ def _build_dataset (
108+ endpoint : Optional [FakeItemsEndpoint ], version_hash : Optional [str ] = None
109+ ) -> Dataset :
90110 return Dataset (
91111 name = "test-dataset" ,
92112 description = None ,
93113 project_name = None ,
94- rest_client = mock_rest_client ,
114+ rest_client = _mock_rest_client ( endpoint , version_hash ) ,
95115 )
96116
97117
@@ -466,33 +486,25 @@ def test_stream_items__malformed_total__raises_instead_of_truncating(total):
466486 if total is not None :
467487 body ["total" ] = total
468488
469- mock_rest_client = Mock ()
470- mock_rest_client .datasets .get_dataset_by_identifier .return_value .id = DATASET_ID
471- mock_rest_client ._client_wrapper .httpx_client .request .side_effect = (
472- _endpoint_returning (body )
473- )
474489 dataset = Dataset (
475490 name = "test-dataset" ,
476491 description = None ,
477492 project_name = None ,
478- rest_client = mock_rest_client ,
493+ rest_client = _mock_rest_client ( _endpoint_returning ( body )) ,
479494 )
480495
481496 with pytest .raises (exceptions .OpikException , match = "Malformed response" ):
482497 list (dataset .stream_items ())
483498
484499
485500def test_stream_items__non_list_content__raises ():
486- mock_rest_client = Mock ()
487- mock_rest_client .datasets .get_dataset_by_identifier .return_value .id = DATASET_ID
488- mock_rest_client ._client_wrapper .httpx_client .request .side_effect = (
489- _endpoint_returning ({"total" : 2 , "content" : {"not" : "a list" }})
490- )
491501 dataset = Dataset (
492502 name = "test-dataset" ,
493503 description = None ,
494504 project_name = None ,
495- rest_client = mock_rest_client ,
505+ rest_client = _mock_rest_client (
506+ _endpoint_returning ({"total" : 2 , "content" : {"not" : "a list" }})
507+ ),
496508 )
497509
498510 with pytest .raises (exceptions .OpikException , match = "Malformed response" ):
@@ -501,16 +513,11 @@ def test_stream_items__non_list_content__raises():
501513
502514def test_stream_items__total_zero_with_empty_content__reads_nothing ():
503515 """A genuinely empty dataset is not malformed."""
504- mock_rest_client = Mock ()
505- mock_rest_client .datasets .get_dataset_by_identifier .return_value .id = DATASET_ID
506- mock_rest_client ._client_wrapper .httpx_client .request .side_effect = (
507- _endpoint_returning ({"total" : 0 , "content" : []})
508- )
509516 dataset = Dataset (
510517 name = "test-dataset" ,
511518 description = None ,
512519 project_name = None ,
513- rest_client = mock_rest_client ,
520+ rest_client = _mock_rest_client ( _endpoint_returning ({ "total" : 0 , "content" : []})) ,
514521 )
515522
516523 assert list (dataset .stream_items ()) == []
@@ -561,3 +568,127 @@ def test_get_items__chunk_size_cap_applies_through_get_items():
561568 call ["params" ]["size" ] <= constants .DATASET_ITEMS_READ_MAX_CHUNK_SIZE
562569 for call in endpoint .calls
563570 )
571+
572+
573+ def test_stream_items__version_available__every_page_pinned_to_it ():
574+ """Pages are addressed by offset, so they must all read one version --
575+ otherwise an insert landing at offset 0 shifts the unfetched pages."""
576+ endpoint = FakeItemsEndpoint (_rest_items (25 ))
577+ dataset = _build_dataset (endpoint , version_hash = "v-hash-abc" )
578+
579+ list (dataset .stream_items (chunk_size = 10 , num_threads = 4 ))
580+
581+ assert len (endpoint .calls ) == 3
582+ assert {call ["params" ]["version" ] for call in endpoint .calls } == {"v-hash-abc" }
583+
584+
585+ def test_stream_items__no_version_available__reads_the_live_state ():
586+ endpoint = FakeItemsEndpoint (_rest_items (25 ))
587+ dataset = _build_dataset (endpoint , version_hash = None )
588+
589+ list (dataset .stream_items (chunk_size = 10 , num_threads = 4 ))
590+
591+ assert {call ["params" ]["version" ] for call in endpoint .calls } == {None }
592+
593+
594+ def test_stream_items__version_resolved_once__not_per_page ():
595+ endpoint = FakeItemsEndpoint (_rest_items (50 ))
596+ dataset = _build_dataset (endpoint , version_hash = "v-hash-abc" )
597+
598+ list (dataset .stream_items (chunk_size = 10 , num_threads = 4 ))
599+
600+ assert dataset ._rest_client .datasets .list_dataset_versions .call_count == 1
601+
602+
603+ def test_stream_items__version_lookup_deferred_until_iteration ():
604+ endpoint = FakeItemsEndpoint (_rest_items (10 ))
605+ dataset = _build_dataset (endpoint , version_hash = "v-hash-abc" )
606+ versions = dataset ._rest_client .datasets .list_dataset_versions
607+
608+ stream = dataset .stream_items ()
609+
610+ assert versions .call_count == 0
611+ next (iter (stream ))
612+ assert versions .call_count == 1
613+
614+
615+ def test_get_items__pins_the_read_to_a_version_too ():
616+ endpoint = FakeItemsEndpoint (_rest_items (25 ))
617+ dataset = _build_dataset (endpoint , version_hash = "v-hash-abc" )
618+
619+ dataset .get_items (chunk_size = 10 )
620+
621+ assert {call ["params" ]["version" ] for call in endpoint .calls } == {"v-hash-abc" }
622+
623+
624+ class ShiftingItemsEndpoint (FakeItemsEndpoint ):
625+ """Simulates an insert landing between page 1 and page 2.
626+
627+ Ids sort newest-first, so a new item takes offset 0 and pushes every later
628+ item one slot further down -- the exact shift that makes an offset-paged
629+ read of a live dataset return one item twice and skip another.
630+ """
631+
632+ def __call__ (self , path , * , method , params ):
633+ response = super ().__call__ (path , method = method , params = params )
634+ if params ["page" ] == 1 and params .get ("version" ) is None :
635+ self ._items .insert (0 , {"id" : "i-new" , "data" : {"question" : "inserted" }})
636+ return response
637+
638+
639+ def test_stream_items__unversioned_read_with_a_concurrent_insert__shifts ():
640+ """Documents the failure mode the version pin exists to prevent, so a
641+ regression in the pinning shows up as this test starting to pass."""
642+ endpoint = ShiftingItemsEndpoint (
643+ [{"id" : f"i{ i } " , "data" : {"question" : f"q{ i } " }} for i in range (4 )]
644+ )
645+ dataset = _build_dataset (endpoint , version_hash = None )
646+
647+ ids = [
648+ item ["id" ]
649+ for chunk in dataset .stream_items (chunk_size = 2 , num_threads = 1 )
650+ for item in chunk
651+ ]
652+
653+ # i1 is returned twice and i3 never arrives.
654+ assert len (ids ) != len (set (ids )), (
655+ "expected the unversioned read to duplicate an item once the dataset shifted"
656+ )
657+
658+
659+ def test_stream_items__version_pinned_read_is_unaffected_by_a_concurrent_insert ():
660+ endpoint = ShiftingItemsEndpoint (
661+ [{"id" : f"i{ i } " , "data" : {"question" : f"q{ i } " }} for i in range (4 )]
662+ )
663+ dataset = _build_dataset (endpoint , version_hash = "v-hash-abc" )
664+
665+ ids = [
666+ item ["id" ]
667+ for chunk in dataset .stream_items (chunk_size = 2 , num_threads = 1 )
668+ for item in chunk
669+ ]
670+
671+ assert ids == ["i0" , "i1" , "i2" , "i3" ]
672+ assert len (ids ) == len (set (ids ))
673+
674+
675+ def test_stream_items__abandoned_early__does_not_block_on_in_flight_pages ():
676+ """Closing the generator must not join the outstanding requests: the yields
677+ happen inside the pool's scope, so a `with` block would turn a plain
678+ `break` into a wait for every page still in flight."""
679+ page_delay = 0.5
680+ endpoint = FakeItemsEndpoint (_rest_items (200 ), delay_seconds = page_delay )
681+ dataset = _build_dataset (endpoint )
682+
683+ stream = dataset .stream_items (chunk_size = 10 , num_threads = 4 )
684+ for _ in stream :
685+ break # abandon after the first chunk, with pages still in flight
686+
687+ started = time .perf_counter ()
688+ stream .close ()
689+ elapsed = time .perf_counter () - started
690+
691+ assert elapsed < page_delay , (
692+ f"closing the stream blocked for { elapsed :.2f} s; it must not wait for "
693+ "in-flight pages"
694+ )
0 commit comments