|
30 | 30 | from app.core.db.utils import get_async_session_maker_from_engine |
31 | 31 | from app.core.utils import json_serializer, utc_now |
32 | 32 | from app.tasks import hook_resolver |
33 | | -from app.tasks.celery import sync_queue_item |
| 33 | +from app.tasks.celery import dispatch_queue_item, sync_queue_item |
34 | 34 | from app.tasks.crud import TaskHistoryManager, TaskManager |
35 | 35 | from app.tasks.execution.exceptions import TaskDataNotFoundInExecutorError |
36 | 36 | from app.tasks.execution.executors.nomad import NomadExecutor |
@@ -620,3 +620,178 @@ async def _stop(session, item): |
620 | 620 | assert stopped.status == TaskHistoryStatusEnum.STOPPED |
621 | 621 | assert recorded == [] |
622 | 622 | executor.stream_file.assert_not_called() |
| 623 | + |
| 624 | + |
| 625 | +def _dispatch_to(target: TaskHistoryStatusEnum, *, result: dict | None = None): |
| 626 | + """Return a fake executor whose ``dispatch_task`` lands the run on ``target``.""" |
| 627 | + |
| 628 | + async def _fake_dispatch(session, item, task=None): |
| 629 | + del task |
| 630 | + item.started_at = utc_now() |
| 631 | + item.status = target |
| 632 | + if target.is_terminal(): |
| 633 | + item.finished_at = utc_now() |
| 634 | + return await TaskHistoryManager.save(session, item) |
| 635 | + |
| 636 | + executor = _fake_executor( |
| 637 | + _raising(_response_error(404)) |
| 638 | + if result is None |
| 639 | + else _yielding(_result_bytes(result)) |
| 640 | + ) |
| 641 | + executor.dispatch_task = AsyncMock(side_effect=_fake_dispatch) |
| 642 | + return executor |
| 643 | + |
| 644 | + |
| 645 | +async def _run_dispatch(mocker, maker, history_id, executor): |
| 646 | + """Drive ``dispatch_queue_item`` for ``history_id`` through ``executor``. |
| 647 | +
|
| 648 | + ``schedule_annotation`` is patched out because it spawns an unawaited |
| 649 | + ``asyncio.create_task``, which would otherwise leak PMM work past the test. |
| 650 | + """ |
| 651 | + mocker.patch("app.tasks.celery.get_async_session_maker", return_value=maker) |
| 652 | + mocker.patch("app.tasks.run_result.get_async_session_maker", return_value=maker) |
| 653 | + mocker.patch("app.tasks.celery.get_executor_for_task", return_value=executor) |
| 654 | + mocker.patch("app.tasks.celery.schedule_annotation") |
| 655 | + async with maker() as session: |
| 656 | + queue_item = await TaskHistoryManager.get_or_404( |
| 657 | + session, |
| 658 | + select_related=(TaskHistory.task,), |
| 659 | + query_options=[undefer(TaskHistory.execution_request)], |
| 660 | + id=history_id, |
| 661 | + ) |
| 662 | + return await dispatch_queue_item(queue_item, session) |
| 663 | + |
| 664 | + |
| 665 | +class TestDispatchSeam: |
| 666 | + """Cover the recorder firing through the in-process dispatch seam. |
| 667 | +
|
| 668 | + A backend that runs its callable inline reaches a terminal status without an |
| 669 | + intervening sync, so the sync seams never see it. |
| 670 | + """ |
| 671 | + |
| 672 | + @pytest.fixture(autouse=True) |
| 673 | + def _clear_cache(self, mocker): |
| 674 | + """Reset the resolver cache before each test.""" |
| 675 | + mocker.patch.dict(hook_resolver._RESOLVED, {}, clear=True) |
| 676 | + |
| 677 | + @pytest.mark.asyncio |
| 678 | + async def test_records_run_result_on_in_process_success(self, mocker): |
| 679 | + """Fire the recorder with the run's result when dispatch lands on SUCCESS.""" |
| 680 | + recorded = [] |
| 681 | + |
| 682 | + async def _recorder(session, history, result): |
| 683 | + recorded.append(result) |
| 684 | + |
| 685 | + mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}) |
| 686 | + executor = _dispatch_to(TaskHistoryStatusEnum.SUCCESS, result=_RESULT) |
| 687 | + async with _recorder_db( |
| 688 | + recorder="pkg:rec", status=TaskHistoryStatusEnum.PENDING |
| 689 | + ) as (maker, history_id): |
| 690 | + dispatched = await _run_dispatch(mocker, maker, history_id, executor) |
| 691 | + |
| 692 | + assert dispatched.status == TaskHistoryStatusEnum.SUCCESS |
| 693 | + assert recorded == [_RESULT] |
| 694 | + |
| 695 | + @pytest.mark.asyncio |
| 696 | + async def test_records_none_on_in_process_failure(self, mocker): |
| 697 | + """Fire the recorder with ``None`` when dispatch lands on FAILED.""" |
| 698 | + recorded = [] |
| 699 | + |
| 700 | + async def _recorder(session, history, result): |
| 701 | + recorded.append(result) |
| 702 | + |
| 703 | + mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}) |
| 704 | + executor = _dispatch_to(TaskHistoryStatusEnum.FAILED) |
| 705 | + async with _recorder_db( |
| 706 | + recorder="pkg:rec", status=TaskHistoryStatusEnum.PENDING |
| 707 | + ) as (maker, history_id): |
| 708 | + dispatched = await _run_dispatch(mocker, maker, history_id, executor) |
| 709 | + |
| 710 | + assert dispatched.status == TaskHistoryStatusEnum.FAILED |
| 711 | + assert recorded == [None] |
| 712 | + |
| 713 | + @pytest.mark.asyncio |
| 714 | + async def test_does_not_record_when_dispatch_leaves_the_run_running(self, mocker): |
| 715 | + """Skip the seam entirely for a backend that dispatches asynchronously.""" |
| 716 | + recorded = [] |
| 717 | + |
| 718 | + async def _recorder(session, history, result): |
| 719 | + recorded.append(result) |
| 720 | + |
| 721 | + mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}) |
| 722 | + executor = _dispatch_to(TaskHistoryStatusEnum.RUNNING, result=_RESULT) |
| 723 | + async with _recorder_db( |
| 724 | + recorder="pkg:rec", status=TaskHistoryStatusEnum.PENDING |
| 725 | + ) as (maker, history_id): |
| 726 | + dispatched = await _run_dispatch(mocker, maker, history_id, executor) |
| 727 | + |
| 728 | + assert dispatched.status == TaskHistoryStatusEnum.RUNNING |
| 729 | + assert recorded == [] |
| 730 | + executor.stream_file.assert_not_called() |
| 731 | + |
| 732 | + @pytest.mark.asyncio |
| 733 | + async def test_recorder_failure_cannot_fail_dispatch(self, mocker): |
| 734 | + """Swallow a raising recorder so it cannot fail the dispatch it observes.""" |
| 735 | + |
| 736 | + async def _recorder(session, history, result): |
| 737 | + raise RuntimeError("recorder exploded") |
| 738 | + |
| 739 | + mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}) |
| 740 | + executor = _dispatch_to(TaskHistoryStatusEnum.SUCCESS, result=_RESULT) |
| 741 | + async with _recorder_db( |
| 742 | + recorder="pkg:rec", status=TaskHistoryStatusEnum.PENDING |
| 743 | + ) as (maker, history_id): |
| 744 | + dispatched = await _run_dispatch( # must not raise |
| 745 | + mocker, maker, history_id, executor |
| 746 | + ) |
| 747 | + |
| 748 | + assert dispatched.status == TaskHistoryStatusEnum.SUCCESS |
| 749 | + |
| 750 | + |
| 751 | +class TestDispatchFailureCarveOut: |
| 752 | + """Cover the deliberate exclusion of the pre-dispatch failure path.""" |
| 753 | + |
| 754 | + @pytest.fixture(autouse=True) |
| 755 | + def _clear_cache(self, mocker): |
| 756 | + """Reset the resolver cache before each test.""" |
| 757 | + mocker.patch.dict(hook_resolver._RESOLVED, {}, clear=True) |
| 758 | + |
| 759 | + @pytest.mark.asyncio |
| 760 | + async def test_failed_dispatch_records_nothing(self, mocker): |
| 761 | + """Skip recording a run that failed before it ever held an allocation.""" |
| 762 | + recorded = [] |
| 763 | + |
| 764 | + async def _recorder(session, history, result): |
| 765 | + recorded.append(result) |
| 766 | + |
| 767 | + mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}) |
| 768 | + executor = _dispatch_to(TaskHistoryStatusEnum.SUCCESS, result=_RESULT) |
| 769 | + async with _recorder_db( |
| 770 | + recorder="pkg:rec", status=TaskHistoryStatusEnum.PENDING |
| 771 | + ) as (maker, history_id): |
| 772 | + mocker.patch("app.tasks.celery.get_async_session_maker", return_value=maker) |
| 773 | + mocker.patch( |
| 774 | + "app.tasks.run_result.get_async_session_maker", return_value=maker |
| 775 | + ) |
| 776 | + mocker.patch( |
| 777 | + "app.tasks.celery.get_executor_for_task", return_value=executor |
| 778 | + ) |
| 779 | + mocker.patch( |
| 780 | + "app.tasks.celery.alert_service.trigger", new_callable=AsyncMock |
| 781 | + ) |
| 782 | + async with maker() as session: |
| 783 | + queue_item = await TaskHistoryManager.get_or_404( |
| 784 | + session, |
| 785 | + select_related=(TaskHistory.task,), |
| 786 | + query_options=[undefer(TaskHistory.execution_request)], |
| 787 | + id=history_id, |
| 788 | + ) |
| 789 | + queue_item.execution_request.payload = "file:///sep/missing-payload.py" |
| 790 | + queue_item = await TaskHistoryManager.save( |
| 791 | + session, queue_item, flag_modified_fields=["execution_request"] |
| 792 | + ) |
| 793 | + failed = await dispatch_queue_item(queue_item, session) |
| 794 | + |
| 795 | + assert failed.status == TaskHistoryStatusEnum.FAILED |
| 796 | + assert recorded == [] |
| 797 | + executor.dispatch_task.assert_not_called() |
0 commit comments