Coverage for async-durable-execution/src/async_durable_execution/primitive/invoke.py: 100%
58 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 16:54 +0000
1"""Implement the Durable invoke operation."""
3from __future__ import annotations
5import asyncio
6import logging
7from typing import TYPE_CHECKING, TypeVar, cast
9# Import base classes for operation executor pattern
10from .base import OperationExecutor
11from .child import get_durable_context
12from ..exceptions import (
13 CallableRuntimeError,
14 ExecutionError,
15 suspend_with_optional_resume_delay,
16)
17from ..models import (
18 ChainedInvokeOptions,
19 Operation,
20 OperationIdentifier,
21 OperationStatus,
22 OperationUpdate,
23 OperationSubType,
24)
25from ..serdes import (
26 DEFAULT_JSON_SERDES,
27)
28from ..task import create_eager_task
30if TYPE_CHECKING:
31 from .child import DurableContext
32 from ..serdes import SerDes
33 from ..state import ExecutionState
35P = TypeVar("P") # Payload type
36R = TypeVar("R") # Result type
38logger = logging.getLogger(__name__)
41class InvokeOperationExecutor(OperationExecutor[R]):
42 """Executor for invoke operations."""
44 def __init__(
45 self,
46 function_name: str,
47 payload: P,
48 state: ExecutionState,
49 operation_identifier: OperationIdentifier,
50 serdes_payload: SerDes[P] | None = None,
51 serdes_result: SerDes[R] | None = None,
52 tenant_id: str | None = None,
53 ):
54 """Initialize the invoke operation executor.
56 Args:
57 function_name: Name of the function to invoke
58 payload: The payload to pass to the invoked function
59 state: The execution state
60 operation_identifier: The operation identifier
61 serdes_payload: Optional serializer for the invocation payload
62 serdes_result: Optional deserializer for the invocation result
63 tenant_id: Optional tenant identifier for the chained invocation
64 """
65 super().__init__(state=state, operation_identifier=operation_identifier)
66 self.function_name = function_name
67 self.payload = payload
68 self.serdes_payload = serdes_payload
69 self.serdes_result = serdes_result
70 self.tenant_id = tenant_id
72 async def start(self) -> R:
73 """Start a new invoke operation."""
74 serialized_payload: str = await self.serialize_value(
75 value=self.payload,
76 serdes=self.serdes_payload or DEFAULT_JSON_SERDES,
77 )
78 start_operation: OperationUpdate = OperationUpdate.create_invoke_start(
79 identifier=self.operation_identifier,
80 payload=serialized_payload,
81 chained_invoke_options=ChainedInvokeOptions(
82 function_name=self.function_name,
83 tenant_id=self.tenant_id,
84 ),
85 )
86 await self.create_checkpoint(start_operation, is_sync=True)
88 logger.debug(
89 "🚀 Invoke %s started, will suspend for completion",
90 self.operation_name or self.function_name,
91 )
93 return await self.execute()
95 async def replay(self, operation: Operation) -> R:
96 """Replay an existing invoke operation from its checkpoint."""
97 invoke_details = operation.chained_invoke_details
98 if operation.status is OperationStatus.SUCCEEDED:
99 result_data = invoke_details.result if invoke_details else None
100 if result_data is None:
101 return cast("R", None)
103 result: R = await self.deserialize_value(
104 data=result_data,
105 serdes=self.serdes_result or DEFAULT_JSON_SERDES,
106 )
107 return result
109 # Terminal failures
110 if (
111 operation.status is OperationStatus.FAILED
112 or operation.status is OperationStatus.TIMED_OUT
113 or operation.status is OperationStatus.STOPPED
114 ):
115 error = invoke_details.error if invoke_details else None
116 if error is None:
117 raise CallableRuntimeError(
118 message="Unknown error. No ErrorObject exists on the Checkpoint Operation.",
119 error_type=None,
120 data=None,
121 stack_trace=None,
122 )
124 raise CallableRuntimeError.from_error_object(error)
126 if operation.status is OperationStatus.STARTED:
127 logger.debug(
128 "⏳ Invoke %s still in progress, will suspend",
129 self.operation_name or self.function_name,
130 )
131 return await self.execute()
133 return await self.execute()
135 async def execute(self, operation: Operation | None = None) -> R:
136 """Execute invoke operation by suspending to wait for async completion.
138 The invoke operation doesn't execute synchronously - it suspends and
139 the backend executes the invoked function asynchronously.
141 Returns:
142 Never returns - always suspends
144 Raises:
145 Always suspends via suspend_with_optional_resume_delay
146 ExecutionError: If suspend doesn't raise (should never happen)
147 """
148 msg: str = f"Invoke {self.operation_identifier.operation_id} started, suspending for completion"
149 suspend_with_optional_resume_delay(msg)
150 # This line should never be reached since suspend_with_optional_resume_delay always raises
151 error_msg: str = "suspend_with_optional_resume_delay should have raised an exception, but did not."
152 raise ExecutionError(error_msg) from None
155def invoke(
156 function_name: str,
157 payload: P,
158 *,
159 name: str | None = None,
160 serdes_payload: SerDes[P] | None = None,
161 serdes_result: SerDes[R] | None = None,
162 tenant_id: str | None = None,
163) -> asyncio.Task[R]:
164 """Invoke another durable Lambda function and wait for its durable result.
166 Args:
167 function_name: Qualified Lambda function name or ARN to invoke.
168 payload: Payload to send to the invoked function.
169 name: Optional durable operation name.
170 serdes_payload: Optional serializer for the invocation payload.
171 serdes_result: Optional deserializer for the invocation result.
172 tenant_id: Optional tenant identifier for the chained invocation.
173 """
174 context = get_durable_context("invoke")
176 with context._replay_aware():
177 operation_id = context.step_counter.create_step_id()
178 operation_identifier = OperationIdentifier(
179 operation_id=operation_id,
180 sub_type=OperationSubType.CHAINED_INVOKE,
181 parent_id=context.parent_id,
182 name=name,
183 )
185 return create_eager_task(
186 lambda: _invoke(
187 function_name=function_name,
188 payload=payload,
189 context=context,
190 operation_identifier=operation_identifier,
191 serdes_payload=serdes_payload,
192 serdes_result=serdes_result,
193 tenant_id=tenant_id,
194 ),
195 )
198async def _invoke(
199 function_name: str,
200 payload: P,
201 *,
202 context: DurableContext,
203 operation_identifier: OperationIdentifier,
204 serdes_payload: SerDes[P] | None = None,
205 serdes_result: SerDes[R] | None = None,
206 tenant_id: str | None = None,
207) -> R:
208 executor: InvokeOperationExecutor[R] = InvokeOperationExecutor(
209 function_name=function_name,
210 payload=payload,
211 state=context.execution_state,
212 operation_identifier=operation_identifier,
213 serdes_payload=serdes_payload,
214 serdes_result=serdes_result,
215 tenant_id=tenant_id,
216 )
217 return await executor.process()