@@ -1430,3 +1430,124 @@ async def test_scheduler_rg_semaphore_deadlock_with_transient_failures() -> None
14301430
14311431 assert tracker .is_row_group_complete (0 , 2 , ["seed" , "col" ])
14321432 assert tracker .is_row_group_complete (1 , 2 , ["seed" , "col" ])
1433+
1434+
1435+ # -- TrackingSemaphore tests ---------------------------------------------------
1436+
1437+
1438+ def test_tracking_semaphore_try_acquire () -> None :
1439+ """try_acquire returns True when permits are available, False when exhausted."""
1440+ from data_designer .engine .dataset_builders .async_scheduler import TrackingSemaphore
1441+
1442+ sem = TrackingSemaphore (2 )
1443+ assert sem .available_permits == 2
1444+
1445+ assert sem .try_acquire () is True
1446+ assert sem .available_permits == 1
1447+
1448+ assert sem .try_acquire () is True
1449+ assert sem .available_permits == 0
1450+
1451+ assert sem .try_acquire () is False
1452+ assert sem .available_permits == 0
1453+
1454+ sem .release ()
1455+ assert sem .available_permits == 1
1456+ assert sem .try_acquire () is True
1457+ assert sem .available_permits == 0
1458+
1459+
1460+ # -- Pipeline parallelism (stale dispatch fix, issue #504) ---------------------
1461+
1462+
1463+ class SlowCellGenerator (ColumnGenerator [ExpressionColumnConfig ]):
1464+ """Cell-by-cell generator with configurable async delay."""
1465+
1466+ def __init__ (self , * args : Any , delay : float = 0.05 , ** kwargs : Any ) -> None :
1467+ super ().__init__ (* args , ** kwargs )
1468+ self ._delay = delay
1469+
1470+ @staticmethod
1471+ def get_generation_strategy () -> GenerationStrategy :
1472+ return GenerationStrategy .CELL_BY_CELL
1473+
1474+ def generate (self , data : dict ) -> dict :
1475+ data [self .config .name ] = f"gen_{ data .get ('seed' , '?' )} "
1476+ return data
1477+
1478+ async def agenerate (self , data : dict ) -> dict :
1479+ await asyncio .sleep (self ._delay )
1480+ return self .generate (data )
1481+
1482+
1483+ @pytest .mark .asyncio (loop_scope = "session" )
1484+ async def test_scheduler_downstream_interleaves_with_upstream () -> None :
1485+ """Downstream judge tasks begin before all upstream gen tasks complete (issue #504).
1486+
1487+ Mirrors the reported pipeline topology:
1488+
1489+ topic (sampler, instant)
1490+ ├── gen_a (slow, 50ms) → judge_a (instant)
1491+ ├── gen_b (slow, 50ms) → judge_b (instant)
1492+ └── gen_c (slow, 50ms) → judge_c (instant)
1493+
1494+ With a small semaphore (4) and 10 records, the 30 gen tasks (3 cols x 10 rows)
1495+ saturate the semaphore. The dispatch loop must re-query the frontier when the
1496+ semaphore is full so that judge tasks from completed gen rows are picked up
1497+ before all gen tasks finish.
1498+ """
1499+ provider = _mock_provider ()
1500+ gen_names = ["gen_a" , "gen_b" , "gen_c" ]
1501+ judge_names = ["judge_a" , "judge_b" , "judge_c" ]
1502+
1503+ configs = [
1504+ SamplerColumnConfig (name = "topic" , sampler_type = SamplerType .CATEGORY , params = {"values" : ["A" ]}),
1505+ * [LLMTextColumnConfig (name = g , prompt = "{{ topic }}" , model_alias = MODEL_ALIAS ) for g in gen_names ],
1506+ * [
1507+ LLMTextColumnConfig (name = j , prompt = f"{{{{ { g } }}}}" , model_alias = MODEL_ALIAS )
1508+ for j , g in zip (judge_names , gen_names )
1509+ ],
1510+ ]
1511+ all_col_names = ["topic" , * gen_names , * judge_names ]
1512+ strategies : dict [str , GenerationStrategy ] = {"topic" : GenerationStrategy .FULL_COLUMN }
1513+ strategies .update ({c : GenerationStrategy .CELL_BY_CELL for c in gen_names + judge_names })
1514+
1515+ generators : dict [str , ColumnGenerator ] = {
1516+ "topic" : MockSeedGenerator (config = _expr_config ("topic" ), resource_provider = provider ),
1517+ }
1518+ for g in gen_names :
1519+ generators [g ] = SlowCellGenerator (config = _expr_config (g ), resource_provider = provider , delay = 0.05 )
1520+ for j in judge_names :
1521+ generators [j ] = MockCellGenerator (config = _expr_config (j ), resource_provider = provider )
1522+
1523+ graph = ExecutionGraph .create (configs , strategies )
1524+ row_groups = [(0 , 10 )]
1525+ tracker = CompletionTracker .with_graph (graph , row_groups )
1526+ buffer_manager = RowGroupBufferManager (graph .columns )
1527+
1528+ scheduler = AsyncTaskScheduler (
1529+ generators = generators ,
1530+ graph = graph ,
1531+ tracker = tracker ,
1532+ row_groups = row_groups ,
1533+ buffer_manager = buffer_manager ,
1534+ max_submitted_tasks = 4 ,
1535+ trace = True ,
1536+ )
1537+ await asyncio .wait_for (scheduler .run (), timeout = 10.0 )
1538+
1539+ assert tracker .is_row_group_complete (0 , 10 , all_col_names )
1540+
1541+ gen_traces = [t for t in scheduler .traces if t .column in gen_names ]
1542+ judge_traces = [t for t in scheduler .traces if t .column in judge_names ]
1543+ assert len (gen_traces ) == 30 # 3 cols x 10 rows
1544+ assert len (judge_traces ) == 30
1545+
1546+ last_gen_dispatched = max (t .dispatched_at for t in gen_traces )
1547+ first_judge_dispatched = min (t .dispatched_at for t in judge_traces )
1548+
1549+ assert first_judge_dispatched < last_gen_dispatched , (
1550+ "Judge tasks should begin before all gen tasks are dispatched. "
1551+ f"First judge dispatched at { first_judge_dispatched :.4f} , "
1552+ f"last gen dispatched at { last_gen_dispatched :.4f} ."
1553+ )
0 commit comments