Model/utilities/aws_lambda/subtask_handler.py
Jun-te Kim 119ff3740c fix(task_handler): persist cloud_logs_url for modelling_e2e
@task_handler never built or passed cloud_logs_url, so every app using
it (incl. modelling_e2e) ran run_subtask with the None default and the
CloudWatch deep-link was never saved onto the SubTask. @subtask_handler
did this correctly.

Extract the URL builder into a shared utilities/aws_lambda/cloud_logs.py
(public cloudwatch_url()), use it from both handlers, and pass the URL
into run_subtask from @task_handler. Add regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:42:00 +00:00

96 lines
3.6 KiB
Python

"""@subtask_handler decorator for Lambdas that operate on existing SubTasks.
Translates an AWS Lambda invocation (SQS-shaped or direct) into
TaskOrchestrator.run_subtask(...) calls.
"""
import json
import logging
from contextlib import AbstractContextManager
from functools import wraps
from typing import Any, Callable, Optional, cast
from utilities.aws_lambda.cloud_logs import cloudwatch_url
from utilities.aws_lambda.default_orchestrator import default_orchestrator
from utilities.aws_lambda.subtask_trigger_body import SubtaskTriggerBody
from orchestration.task_orchestrator import TaskOrchestrator
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
OrchestratorCM = Callable[[], AbstractContextManager[TaskOrchestrator]]
def subtask_handler(
*,
orchestrator_cm: Optional[OrchestratorCM] = None,
pass_task_orchestrator: bool = True,
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Run the wrapped function as the body of an existing SubTask.
For each record, validates the body via SubtaskTriggerBody (must contain
task_id and sub_task_id), then runs the function inside
orchestrator.run_subtask(...). The orchestrator owns the start/complete/
fail lifecycle and cascades status into the parent Task. On failure the
underlying exception propagates after the SubTask is marked FAILED.
"""
factory = orchestrator_cm or default_orchestrator
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
_wants_orchestrator = pass_task_orchestrator
@wraps(func)
def wrapper(event: dict[str, Any], context: Any) -> None:
cloud_logs_url = cloudwatch_url()
with factory() as orchestrator:
for record in _records(event):
body = _parse_body(record)
trigger = SubtaskTriggerBody.model_validate(body)
logger.info("Running subtask %s", trigger.sub_task_id)
def _work_with(
_body: dict[str, Any] = body,
_o: TaskOrchestrator = orchestrator,
) -> Any:
return func(_body, context, _o)
def _work_without(_body: dict[str, Any] = body) -> Any:
return func(_body, context)
work: Callable[[], Any] = (
_work_with if _wants_orchestrator else _work_without
)
try:
orchestrator.run_subtask(
trigger.sub_task_id,
work=work,
cloud_logs_url=cloud_logs_url,
)
except Exception:
logger.exception("Subtask %s failed", trigger.sub_task_id)
raise
logger.info("Subtask %s completed", trigger.sub_task_id)
return wrapper
return decorator
def _parse_body(record: dict[str, Any]) -> dict[str, Any]:
raw = record.get("body", record)
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {}
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {}
if isinstance(raw, dict):
return cast(dict[str, Any], raw)
return {}
def _records(event: dict[str, Any]) -> list[dict[str, Any]]:
raw_records = event.get("Records")
if isinstance(raw_records, list):
return [r for r in cast(list[Any], raw_records) if isinstance(r, dict)]
return [event]