Coverage for async_durable_execution/_operation/recurse.py: 96%
55 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-30 23:43 +0000
1"""Recursive self-invocation built on the backend-supported invoke operation."""
3from __future__ import annotations
5import asyncio
6from typing import TypeVar, cast
8from .._core import (
9 RECURSIVE_LEVEL_INPUT_FIELD,
10 DurableContext,
11 OperationSubType,
12 SerDes,
13 ValidationError,
14 get_durable_context,
15)
16from ..extension import get_extension_context
19P = TypeVar("P")
20R = TypeVar("R")
23def _is_qualified_function_arn(function_name: str) -> bool:
24 """Return whether a Lambda function ARN includes a qualifier."""
25 parts = function_name.split(":")
26 return len(parts) >= 8 and parts[0] == "arn" and parts[5] == "function"
29def _is_qualified_function_name(function_name: str) -> bool:
30 """Return whether a short Lambda function name includes a qualifier."""
31 return ":" in function_name and not function_name.startswith("arn:")
34def _append_qualifier(function_name: str, qualifier: str | None) -> str:
35 """Append a Lambda qualifier when one is available and needed."""
36 if not qualifier:
37 return function_name
38 if _is_qualified_function_arn(function_name) or _is_qualified_function_name(
39 function_name
40 ):
41 return function_name
42 return f"{function_name}:{qualifier}"
45def _resolve_recursive_function_name(
46 context: DurableContext,
47 explicit_function_name: str | None,
48) -> str:
49 if explicit_function_name:
50 return explicit_function_name
52 lambda_context = context.lambda_context
53 if lambda_context is None: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 msg = "recurse requires a Lambda context or explicit function_name."
55 raise RuntimeError(msg)
57 function_version = getattr(lambda_context, "function_version", None)
58 invoked_function_arn = getattr(lambda_context, "invoked_function_arn", None)
59 if invoked_function_arn:
60 return _append_qualifier(invoked_function_arn, function_version)
62 context_function_name = getattr(lambda_context, "function_name", None)
63 if context_function_name:
64 return _append_qualifier(context_function_name, function_version)
66 msg = "recurse could not determine the current Lambda function name."
67 raise RuntimeError(msg)
70def _validate_recursive_payload(context: DurableContext, payload: P) -> None:
71 current_input = context.execution_state.get_input_event()
72 if payload == current_input:
73 msg = "recurse payload must differ from the current execution input."
74 raise ValidationError(msg)
77def _add_recursive_level(context: DurableContext, payload: P) -> P:
78 if not isinstance(payload, dict):
79 msg = "recurse payload must be a dict when with_recursive_level is enabled."
80 raise ValidationError(msg)
82 payload_with_level = payload.copy()
83 payload_with_level[RECURSIVE_LEVEL_INPUT_FIELD] = context.recursive_level + 1
84 return cast("P", payload_with_level)
87def recurse(
88 payload: P,
89 *,
90 name: str | None = None,
91 function_name: str | None = None,
92 with_recursive_level: bool = False,
93 serdes_payload: SerDes[P] | None = None,
94 serdes_result: SerDes[R] | None = None,
95 tenant_id: str | None = None,
96) -> asyncio.Task[R]:
97 """Invoke the current durable Lambda function and wait for its result.
99 This is a convenience wrapper around :func:`invoke` for recursive workflows
100 such as divide-and-conquer algorithms. Each recursive call is a separate
101 durable execution, so the current execution records a chained invoke instead
102 of growing a Python call stack.
104 Args:
105 payload: Payload to send to the recursive invocation.
106 name: Optional durable operation name.
107 function_name: Optional qualified Lambda function name or ARN. When omitted,
108 the current Lambda context is used.
109 with_recursive_level: When true, copy the payload and set
110 ``__recursive_level`` to the current context level plus one.
111 serdes_payload: Optional serializer for the invocation payload.
112 serdes_result: Optional deserializer for the invocation result.
113 tenant_id: Optional tenant identifier. Defaults to the current Lambda context
114 tenant id when present.
115 """
116 context = get_durable_context()
117 recursive_payload = (
118 _add_recursive_level(context, payload) if with_recursive_level else payload
119 )
120 _validate_recursive_payload(context, recursive_payload)
121 target_function_name = _resolve_recursive_function_name(context, function_name)
122 recursive_tenant_id = tenant_id
123 if recursive_tenant_id is None and context.lambda_context is not None:
124 recursive_tenant_id = getattr(context.lambda_context, "tenant_id", None)
126 return (
127 get_extension_context()
128 ._reserve_sdk_operation(name) # noqa: SLF001
129 ._run_invoke( # noqa: SLF001
130 target_function_name,
131 recursive_payload,
132 sub_type=OperationSubType.CHAINED_INVOKE,
133 serdes_payload=serdes_payload,
134 serdes_result=serdes_result,
135 tenant_id=recursive_tenant_id,
136 )
137 )