Skip to content

Commit 10c25da

Browse files
authored
SEP-1657: Record in-process dispatch runs and pin the recorder's contract (#1301)
1 parent a79b5e7 commit 10c25da

4 files changed

Lines changed: 270 additions & 5 deletions

File tree

app/tasks/celery.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,11 +694,14 @@ async def _dispatch_queue_item(
694694
await await_annotation(result, "STARTED")
695695
else:
696696
schedule_annotation(result, "STARTED")
697-
return result
698697
finally:
699698
async with lock_session_maker() as async_session:
700699
await DispatchLockManager.delete(async_session, dispatch_lock)
701700

701+
if result.status.is_terminal():
702+
await maybe_record_run(result.id, executor)
703+
return result
704+
702705

703706
async def _raise_if_identical_task_conflict(
704707
queue_item: TaskHistory, session: AsyncSession

app/tasks/run_result.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,19 @@ async def maybe_record_run(task_history_id: int, executor: BaseExecutor) -> None
133133
Best-effort: no-op when the history is missing, not terminal, or declares no
134134
recorder; and any failure (unresolvable path, unexpected executor error,
135135
recorder raising, DB error) is logged and swallowed so recording can never
136-
fail the task-history sync.
137-
138-
:param task_history_id: The id of the just-synced ``TaskHistory``.
136+
fail the caller.
137+
138+
Observation contract — coverage follows the entry point driving the
139+
transition, not the status reached. Observed:
140+
:func:`app.tasks.celery.sync_queue_item`, ``POST /history/{id}/sync/``, and
141+
:func:`app.tasks.celery.dispatch_queue_item` for a backend whose dispatch
142+
returns already-terminal (the Celery backend runs its callable inline). Not
143+
observed, each pinned by a test: a run stopped via the stop route, one
144+
failed before dispatch, and one the connectivity probe drives to terminal in
145+
its own poll loop — the first two have no result to read by construction,
146+
and the probe parses its own verdict.
147+
148+
:param task_history_id: The id of the just-terminal ``TaskHistory``.
139149
:param executor: The executor that ran the task, used to read its result.
140150
"""
141151
recorder_path: str | None = None

tests/app/tasks/connectivity/test_service.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from sqlmodel.ext.asyncio.session import AsyncSession
2525

2626
from app.core.db.utils import get_async_session_maker_from_engine
27+
from app.tasks import hook_resolver
2728
from app.tasks.connectivity.constants import (
2829
PROVISIONING_TIMEOUT,
2930
)
@@ -205,6 +206,21 @@ async def run_python_task(self, session: AsyncSession) -> Task:
205206
)
206207
return await TaskManager.create(session, task_write)
207208

209+
@pytest_asyncio.fixture
210+
async def recorder_run_python_task(self, session: AsyncSession) -> Task:
211+
"""Persist a ``run-python`` task row that declares a run-result recorder."""
212+
task_write = TaskWrite.model_validate(
213+
TaskFactory.build(
214+
name="run-python",
215+
backend=TaskBackendEnum.NOMAD,
216+
is_template=False,
217+
protected=False,
218+
alert_on_fail=False,
219+
run_result_recorder="pkg:rec",
220+
)
221+
)
222+
return await TaskManager.create(session, task_write)
223+
208224
@pytest_asyncio.fixture
209225
async def async_session_maker(self, session: AsyncSession):
210226
"""Return a session-maker bound to the current test engine."""
@@ -311,6 +327,67 @@ async def sync_task_history(
311327
session, result.task_history_id
312328
)
313329

330+
async def test_terminal_run_records_nothing(
331+
self,
332+
session: AsyncSession,
333+
recorder_run_python_task: Task,
334+
async_session_maker,
335+
mocker,
336+
) -> None:
337+
"""Skip recording for the probe's own run even when it reaches SUCCESS.
338+
339+
The poll loop drives ``sync_task_history`` directly rather than through
340+
either sync seam, so a recorder declared on the shared ``run-python`` task
341+
must not observe it — the probe parses its own verdict.
342+
"""
343+
recorded = []
344+
345+
async def _recorder(db, history, result):
346+
recorded.append(result)
347+
348+
request = _make_request(timeout=POLL_INTERVAL * 2)
349+
350+
async def sync_task_history(
351+
queue_item: TaskHistory,
352+
writer_session: AsyncSession | None = None,
353+
) -> TaskHistory:
354+
assert writer_session is not None
355+
await self._append_log(
356+
writer_session,
357+
queue_item.id,
358+
TaskLogType.STDOUT,
359+
json.dumps({"success": True}),
360+
)
361+
queue_item.status = TaskHistoryStatusEnum.SUCCESS
362+
return queue_item
363+
364+
mock_executor = MagicMock(spec=BaseExecutor)
365+
mock_executor.sync_task_history = sync_task_history
366+
367+
mocker.patch.dict(hook_resolver._RESOLVED, {"pkg:rec": _recorder}, clear=True)
368+
with (
369+
patch(
370+
"app.tasks.connectivity.service.dispatch_queue_item",
371+
side_effect=self._real_dispatch_running,
372+
),
373+
patch(
374+
"app.tasks.connectivity.service.get_executor_for_task",
375+
return_value=mock_executor,
376+
),
377+
patch(
378+
"app.tasks.connectivity.service.get_async_session_maker",
379+
return_value=async_session_maker,
380+
),
381+
patch(
382+
"app.tasks.run_result.get_async_session_maker",
383+
return_value=async_session_maker,
384+
),
385+
):
386+
result = await check_connectivity(session, request)
387+
388+
assert result.success is True
389+
assert recorded == []
390+
314391
async def test_unresolvable_payload_fails_terminally(
315392
self,
316393
session: AsyncSession,

tests/app/tasks/test_run_result.py

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from app.core.db.utils import get_async_session_maker_from_engine
3131
from app.core.utils import json_serializer, utc_now
3232
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
3434
from app.tasks.crud import TaskHistoryManager, TaskManager
3535
from app.tasks.execution.exceptions import TaskDataNotFoundInExecutorError
3636
from app.tasks.execution.executors.nomad import NomadExecutor
@@ -620,3 +620,178 @@ async def _stop(session, item):
620620
assert stopped.status == TaskHistoryStatusEnum.STOPPED
621621
assert recorded == []
622622
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

Comments
 (0)