mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
"""@task_handler decorator for Lambdas that own the entire pipeline.
|
|
|
|
Translates an AWS Lambda invocation (SQS-shaped or direct) into
|
|
TaskOrchestrator.create_task_with_subtask(...) + run_subtask(...).
|
|
"""
|
|
|
|
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 domain.tasks.tasks import Source
|
|
from orchestration.task_orchestrator import TaskOrchestrator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OrchestratorCM = Callable[[], AbstractContextManager[TaskOrchestrator]]
|
|
|
|
|
|
class NonRetriableTaskError(Exception):
|
|
"""A failure that must not be retried by SQS redelivery.
|
|
|
|
The SubTask is marked failed (the failure record is the visibility
|
|
surface) but the message is not returned to the queue, so the work is
|
|
never re-run. Raise it for failures where a retry could duplicate an
|
|
irreversible side effect — e.g. a job already logged in OpenHousing
|
|
whose HubSpot write-back failed.
|
|
"""
|
|
|
|
|
|
def task_handler(
|
|
*,
|
|
task_source: str,
|
|
source: Source,
|
|
orchestrator_cm: Optional[OrchestratorCM] = None,
|
|
pass_task_orchestrator: bool = False,
|
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
"""Run the wrapped function as the body of a freshly-created Task + SubTask.
|
|
|
|
For each record, creates a new Task + initial SubTask, then runs the
|
|
wrapped function inside orchestrator.run_subtask(...). `source_id` is
|
|
read from body[source.value] (silent None if absent — preserved from
|
|
legacy ADR-0001).
|
|
|
|
Records-style events use SQS partial-batch-failure semantics: individual
|
|
failures are reported via {"batchItemFailures": [...]} rather than
|
|
propagating. Direct invocations re-raise.
|
|
"""
|
|
factory = orchestrator_cm or default_orchestrator
|
|
|
|
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
@wraps(func)
|
|
def wrapper(event: dict[str, Any], context: Any) -> Any:
|
|
cloud_logs_url = cloudwatch_url()
|
|
with factory() as orchestrator:
|
|
task_ids: list[dict[str, str]] = []
|
|
failures: list[dict[str, Any]] = []
|
|
|
|
for record in _records(event):
|
|
body = _parse_body(record)
|
|
raw_source_id = body.get(source.value)
|
|
source_id = (
|
|
str(raw_source_id) if raw_source_id is not None else None
|
|
)
|
|
|
|
task, subtask = orchestrator.create_task_with_subtask(
|
|
task_source=task_source,
|
|
inputs=body,
|
|
source=source,
|
|
source_id=source_id,
|
|
)
|
|
task_ids.append(
|
|
{"task_id": str(task.id), "subtask_id": str(subtask.id)}
|
|
)
|
|
|
|
try:
|
|
if pass_task_orchestrator:
|
|
orchestrator.run_subtask(
|
|
subtask.id,
|
|
work=lambda: func(body, context, orchestrator, task.id),
|
|
cloud_logs_url=cloud_logs_url,
|
|
)
|
|
else:
|
|
orchestrator.run_subtask(
|
|
subtask.id,
|
|
work=lambda: func(body, context),
|
|
cloud_logs_url=cloud_logs_url,
|
|
)
|
|
except NonRetriableTaskError:
|
|
# Failed, but never redelivered: the SubTask's failure
|
|
# record is the only follow-up surface.
|
|
logger.exception(
|
|
"subtask failed non-retriably "
|
|
"(task_source=%s source_id=%s)",
|
|
task_source,
|
|
source_id,
|
|
)
|
|
if "Records" not in event:
|
|
raise
|
|
except Exception:
|
|
logger.exception(
|
|
"subtask failed (task_source=%s source_id=%s)",
|
|
task_source,
|
|
source_id,
|
|
)
|
|
if "Records" in event:
|
|
message_id = record.get("messageId", "")
|
|
failures.append({"itemIdentifier": message_id})
|
|
else:
|
|
raise
|
|
|
|
if "Records" in event:
|
|
return {"batchItemFailures": failures, "tasks": task_ids}
|
|
return task_ids
|
|
|
|
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]
|