|
17 | 17 | import pytest |
18 | 18 | import pytest_asyncio |
19 | 19 |
|
20 | | -from hindsight_api.engine.task_backend import BrokerTaskBackend, SyncTaskBackend |
| 20 | +from hindsight_api.engine.task_backend import BrokerTaskBackend, SyncTaskBackend, WorkerTaskBackend |
21 | 21 |
|
22 | 22 |
|
23 | 23 | async def _ensure_bank(pool, bank_id: str) -> None: |
@@ -1317,6 +1317,197 @@ async def failing_executor(task_dict): |
1317 | 1317 | await backend.submit_task({"type": "test"}) |
1318 | 1318 |
|
1319 | 1319 |
|
| 1320 | +class TestWorkerTaskBackend: |
| 1321 | + """Tests for WorkerTaskBackend (used by worker processes). |
| 1322 | +
|
| 1323 | + WorkerTaskBackend.submit_task is a no-op: child tasks spawned during |
| 1324 | + execution (e.g. consolidation triggered by retain) are left as pending |
| 1325 | + rows in async_operations for the poller to pick up on the next cycle, |
| 1326 | + instead of being executed inline which would block the parent task. |
| 1327 | + """ |
| 1328 | + |
| 1329 | + @pytest.mark.asyncio |
| 1330 | + async def test_worker_backend_does_not_execute_inline(self): |
| 1331 | + """WorkerTaskBackend.submit_task must NOT call the executor.""" |
| 1332 | + executed = [] |
| 1333 | + |
| 1334 | + async def mock_executor(task_dict): |
| 1335 | + executed.append(task_dict) |
| 1336 | + |
| 1337 | + backend = WorkerTaskBackend() |
| 1338 | + backend.set_executor(mock_executor) |
| 1339 | + await backend.initialize() |
| 1340 | + |
| 1341 | + await backend.submit_task({"type": "consolidation", "bank_id": "b1"}) |
| 1342 | + |
| 1343 | + assert len(executed) == 0, ( |
| 1344 | + "WorkerTaskBackend must not execute tasks inline — " |
| 1345 | + "child tasks should be picked up by the poller" |
| 1346 | + ) |
| 1347 | + |
| 1348 | + @pytest.mark.asyncio |
| 1349 | + async def test_sync_backend_blocks_parent_on_child_task(self): |
| 1350 | + """Demonstrate the blocking problem: with SyncTaskBackend, a parent |
| 1351 | + task that calls submit_task for a child is blocked until the child |
| 1352 | + completes, because submit_task executes inline. |
| 1353 | + """ |
| 1354 | + execution_order: list[str] = [] |
| 1355 | + |
| 1356 | + async def mock_executor(task_dict): |
| 1357 | + task_type = task_dict.get("type", "unknown") |
| 1358 | + execution_order.append(f"{task_type}:start") |
| 1359 | + if task_type == "parent": |
| 1360 | + # Parent spawns a child via submit_task |
| 1361 | + await backend.submit_task({"type": "child", "bank_id": "b1"}) |
| 1362 | + # This line only runs AFTER the child finishes (blocking) |
| 1363 | + execution_order.append(f"{task_type}:end") |
| 1364 | + |
| 1365 | + backend = SyncTaskBackend() |
| 1366 | + backend.set_executor(mock_executor) |
| 1367 | + await backend.initialize() |
| 1368 | + |
| 1369 | + await backend.submit_task({"type": "parent", "bank_id": "b1"}) |
| 1370 | + |
| 1371 | + # With SyncTaskBackend the child runs inline, nested inside the parent |
| 1372 | + assert execution_order == [ |
| 1373 | + "parent:start", |
| 1374 | + "child:start", |
| 1375 | + "child:end", |
| 1376 | + "parent:end", |
| 1377 | + ], f"SyncTaskBackend should execute child inline (blocking parent). Got: {execution_order}" |
| 1378 | + |
| 1379 | + @pytest.mark.asyncio |
| 1380 | + async def test_worker_backend_does_not_block_parent_on_child_task(self): |
| 1381 | + """With WorkerTaskBackend, a parent task that calls submit_task for a |
| 1382 | + child task is NOT blocked — submit_task is a no-op, so the parent |
| 1383 | + completes immediately and the child row stays pending for the poller. |
| 1384 | + """ |
| 1385 | + execution_order: list[str] = [] |
| 1386 | + |
| 1387 | + async def mock_executor(task_dict): |
| 1388 | + task_type = task_dict.get("type", "unknown") |
| 1389 | + execution_order.append(f"{task_type}:start") |
| 1390 | + if task_type == "parent": |
| 1391 | + # Parent spawns a child via submit_task — should be a no-op |
| 1392 | + await backend.submit_task({"type": "child", "bank_id": "b1"}) |
| 1393 | + execution_order.append(f"{task_type}:end") |
| 1394 | + |
| 1395 | + backend = WorkerTaskBackend() |
| 1396 | + backend.set_executor(mock_executor) |
| 1397 | + await backend.initialize() |
| 1398 | + |
| 1399 | + # Simulate the poller calling the executor directly for the parent task |
| 1400 | + await mock_executor({"type": "parent", "bank_id": "b1"}) |
| 1401 | + |
| 1402 | + # The child should NOT have been executed — only the parent runs |
| 1403 | + assert execution_order == [ |
| 1404 | + "parent:start", |
| 1405 | + "parent:end", |
| 1406 | + ], ( |
| 1407 | + f"WorkerTaskBackend must not execute child tasks inline. " |
| 1408 | + f"Got: {execution_order}" |
| 1409 | + ) |
| 1410 | + |
| 1411 | + @pytest.mark.asyncio |
| 1412 | + async def test_worker_backend_child_task_stays_pending_in_db(self, pool, clean_operations): |
| 1413 | + """End-to-end: when a worker-executed task spawns a child operation, |
| 1414 | + the child row stays as 'pending' in async_operations (not executed inline). |
| 1415 | + A subsequent poll cycle can then claim and execute it independently. |
| 1416 | + """ |
| 1417 | + from hindsight_api.worker import WorkerPoller |
| 1418 | + from hindsight_api.worker.poller import ClaimedTask |
| 1419 | + |
| 1420 | + bank_id = f"test-worker-{uuid.uuid4().hex[:8]}" |
| 1421 | + await _ensure_bank(pool, bank_id) |
| 1422 | + |
| 1423 | + # Pre-create the parent operation (as the poller would claim it) |
| 1424 | + parent_op_id = uuid.uuid4() |
| 1425 | + parent_payload = { |
| 1426 | + "type": "batch_retain", |
| 1427 | + "operation_id": str(parent_op_id), |
| 1428 | + "bank_id": bank_id, |
| 1429 | + } |
| 1430 | + await pool.execute( |
| 1431 | + """ |
| 1432 | + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at) |
| 1433 | + VALUES ($1, $2, 'retain', 'processing', $3::jsonb, 'test-worker-1', now()) |
| 1434 | + """, |
| 1435 | + parent_op_id, |
| 1436 | + bank_id, |
| 1437 | + json.dumps(parent_payload), |
| 1438 | + ) |
| 1439 | + |
| 1440 | + # Pre-create the child operation row (as _submit_async_operation would) |
| 1441 | + child_op_id = uuid.uuid4() |
| 1442 | + child_payload = { |
| 1443 | + "type": "consolidation", |
| 1444 | + "operation_id": str(child_op_id), |
| 1445 | + "bank_id": bank_id, |
| 1446 | + } |
| 1447 | + await pool.execute( |
| 1448 | + """ |
| 1449 | + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) |
| 1450 | + VALUES ($1, $2, 'consolidation', 'pending', $3::jsonb) |
| 1451 | + """, |
| 1452 | + child_op_id, |
| 1453 | + bank_id, |
| 1454 | + json.dumps(child_payload), |
| 1455 | + ) |
| 1456 | + |
| 1457 | + # The executor simulates retain: marks parent completed, then calls |
| 1458 | + # submit_task for the child (which WorkerTaskBackend should ignore). |
| 1459 | + backend = WorkerTaskBackend() |
| 1460 | + |
| 1461 | + async def executor(task_dict): |
| 1462 | + # Mark parent as completed |
| 1463 | + await pool.execute( |
| 1464 | + "UPDATE async_operations SET status = 'completed', completed_at = now() WHERE operation_id = $1", |
| 1465 | + parent_op_id, |
| 1466 | + ) |
| 1467 | + # Trigger child task via submit_task (should be no-op for WorkerTaskBackend) |
| 1468 | + await backend.submit_task(child_payload) |
| 1469 | + |
| 1470 | + poller = WorkerPoller( |
| 1471 | + pool=pool, |
| 1472 | + worker_id="test-worker-1", |
| 1473 | + executor=executor, |
| 1474 | + ) |
| 1475 | + |
| 1476 | + # Execute the parent task |
| 1477 | + claimed_task = ClaimedTask( |
| 1478 | + operation_id=str(parent_op_id), |
| 1479 | + task_dict=parent_payload, |
| 1480 | + schema=None, |
| 1481 | + ) |
| 1482 | + await poller.execute_task(claimed_task) |
| 1483 | + completed = await poller.wait_for_active_tasks(timeout=5.0) |
| 1484 | + assert completed, "Parent task did not complete within timeout" |
| 1485 | + |
| 1486 | + # Parent should be completed |
| 1487 | + parent_row = await pool.fetchrow( |
| 1488 | + "SELECT status FROM async_operations WHERE operation_id = $1", |
| 1489 | + parent_op_id, |
| 1490 | + ) |
| 1491 | + assert parent_row["status"] == "completed" |
| 1492 | + |
| 1493 | + # Child should still be pending (NOT executed inline) |
| 1494 | + child_row = await pool.fetchrow( |
| 1495 | + "SELECT status, worker_id FROM async_operations WHERE operation_id = $1", |
| 1496 | + child_op_id, |
| 1497 | + ) |
| 1498 | + assert child_row["status"] == "pending", ( |
| 1499 | + f"Child task should remain 'pending' for the next poll cycle, " |
| 1500 | + f"but got '{child_row['status']}'. This means it was executed inline, " |
| 1501 | + f"blocking the parent task." |
| 1502 | + ) |
| 1503 | + assert child_row["worker_id"] is None |
| 1504 | + |
| 1505 | + # Now verify the poller can claim the child on the next cycle |
| 1506 | + claimed = await poller.claim_batch() |
| 1507 | + child_claimed = [c for c in claimed if c.operation_id == str(child_op_id)] |
| 1508 | + assert len(child_claimed) == 1, "Child task should be claimable on next poll" |
| 1509 | + |
| 1510 | + |
1320 | 1511 | class TestDynamicTenantDiscovery: |
1321 | 1512 | """Tests for dynamic tenant discovery via TenantExtension.""" |
1322 | 1513 |
|
|
0 commit comments