Coverage for async-durable-execution/src/async_durable_execution/execution.py: 96%
175 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
1from __future__ import annotations
3import asyncio
4import functools
5import json
6import logging
7from collections.abc import Awaitable, Callable
8from dataclasses import dataclass, field
9from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, cast
11from .context import bind_current_context
12from .primitive.child import DurableContext
13from .exceptions import (
14 CheckpointError,
15 ExecutionError,
16 InvocationError,
17 SuspendExecution,
18)
19from .models import (
20 DurableExecutionInvocationOutput,
21 ErrorObject,
22 InvocationStatus,
23 Operation,
24 OperationUpdate,
25 OperationIdentifier,
26 SerializableModel,
27)
28from .client import (
29 AsyncLambdaClient,
30 ThreadedSyncLambdaClient,
31 create_default_client,
32 lambda_api_client_is_async,
33)
34from .logger import configure_durable_logger
35from .state import ExecutionState
38if TYPE_CHECKING:
39 from collections.abc import MutableMapping
41 from .client import AsyncLambdaApiClient, DurableServiceClient, LambdaApiClient
42 from .models import LambdaContext
44configure_durable_logger(logging.getLogger())
45logger = logging.getLogger(__name__)
47# 6MB in bytes, minus 50 bytes for envelope
48LAMBDA_RESPONSE_SIZE_LIMIT = 6 * 1024 * 1024 - 50
50T = TypeVar("T")
51Params = ParamSpec("Params")
54def durable_callable(
55 func: Callable[Params, Awaitable[T]],
56) -> Callable[Params, Callable[[], Awaitable[T]]]:
57 """Wrap an async function so calling it returns a zero-argument durable callable.
59 The returned callable can be passed to durable operations such as `step()`
60 and `run_in_child_context()`, keeping durable operation creation explicit
61 while avoiding manual `functools.partial(...)` wrapping at the callsite.
63 Class and static methods are supported with either decorator order:
64 `@classmethod`/`@staticmethod` may appear above or below `@durable_callable`.
65 """
66 if isinstance(func, classmethod):
67 return classmethod(durable_callable(func.__func__))
68 if isinstance(func, staticmethod):
69 return staticmethod(durable_callable(func.__func__))
71 @functools.wraps(func)
72 def wrapper(
73 *args: Params.args, **kwargs: Params.kwargs
74 ) -> Callable[[], Awaitable[T]]:
75 bound = functools.partial(func, *args, **kwargs)
76 setattr(bound, "__name__", func.__name__)
77 return bound
79 return wrapper
82@dataclass(frozen=True)
83class InitialExecutionState(SerializableModel):
84 """Initial page of operation history included with an invocation event."""
86 operations: list[Operation] = field(
87 default_factory=list,
88 metadata={"alias": "Operations"},
89 )
90 next_marker: str = field(default="", metadata={"alias": "NextMarker"})
93@dataclass(frozen=True)
94class DurableExecutionInvocationInput(SerializableModel):
95 """Event payload delivered to a durable Lambda invocation."""
97 durable_execution_arn: str = field(metadata={"alias": "DurableExecutionArn"})
98 checkpoint_token: str = field(metadata={"alias": "CheckpointToken"})
99 initial_execution_state: InitialExecutionState = field(
100 default_factory=InitialExecutionState,
101 metadata={"alias": "InitialExecutionState"},
102 )
105def _bind_service_client_to_handler(
106 handler: Callable[[Any, LambdaContext], Any],
107 service_client: DurableServiceClient,
108) -> Callable[[Any, LambdaContext], Any]:
109 """Recreate a durable handler with a specific service client bound."""
111 handler_attrs = getattr(handler, "__dict__", {})
112 original_handler = handler_attrs.get("_durable_execution_original")
113 if original_handler is None:
114 return handler
116 boto3_client = handler_attrs.get("_durable_execution_boto3_client")
117 return durable_execution(
118 original_handler,
119 boto3_client=boto3_client,
120 service_client=service_client,
121 )
124@dataclass(frozen=True)
125class DurableConfig:
126 boto3_client: LambdaApiClient | AsyncLambdaApiClient | None = None
127 service_client: DurableServiceClient | None = None
130def durable_execution(
131 func: Callable[..., Awaitable[Any]] | None = None,
132 /,
133 **kwargs,
134) -> Callable[[Any, LambdaContext], Any]:
135 """
136 Decorator to create a durable execution handler.
138 Args:
139 func: The user function to decorate
140 boto3_client: Optional sync or async Lambda API client to use
141 service_client: Optional durable service client to use. Intended for
142 testing and local execution tooling.
143 """
144 # Decorator called with parameters
145 if func is None:
146 logger.debug("Decorator called with parameters")
147 return functools.partial(
148 durable_execution,
149 **kwargs,
150 )
151 config = DurableConfig(**kwargs)
152 logger.debug("Starting durable execution handler...")
154 # Use the explicitly provided durable client when present. Otherwise, delay
155 # Lambda API client construction until invocation so importing decorated handlers
156 # does not require AWS environment configuration.
157 active_service_client = config.service_client
158 active_service_client_loop: asyncio.AbstractEventLoop | None = None
159 handler_loop: asyncio.AbstractEventLoop | None = None
161 def get_or_create_handler_loop() -> asyncio.AbstractEventLoop:
162 nonlocal handler_loop
163 if handler_loop is None or handler_loop.is_closed():
164 handler_loop = asyncio.new_event_loop()
165 return handler_loop
167 async def get_active_service_client() -> DurableServiceClient:
168 nonlocal active_service_client, active_service_client_loop
169 current_loop = asyncio.get_running_loop()
170 if active_service_client is not None:
171 if (
172 active_service_client_loop is None
173 or active_service_client_loop is current_loop
174 ):
175 return active_service_client
177 # The SDK-owned async client is bound to the loop that first used it.
178 # If test code calls the async handler on another loop, rebuild it there.
179 active_service_client = None
180 active_service_client_loop = None
182 if config.boto3_client is not None:
183 if lambda_api_client_is_async(config.boto3_client):
184 active_service_client = AsyncLambdaClient(
185 cast("AsyncLambdaApiClient", config.boto3_client)
186 )
187 else:
188 active_service_client = ThreadedSyncLambdaClient(
189 client=cast("LambdaApiClient", config.boto3_client)
190 )
191 return active_service_client
193 lambda_client = create_default_client()
194 if lambda_api_client_is_async(lambda_client):
195 active_service_client = AsyncLambdaClient(
196 cast("AsyncLambdaApiClient", lambda_client)
197 )
198 active_service_client_loop = current_loop
199 return active_service_client
201 active_service_client = ThreadedSyncLambdaClient(
202 client=cast("LambdaApiClient", lambda_client)
203 )
204 return active_service_client
206 async def async_wrapper(
207 event: Any, context: LambdaContext
208 ) -> MutableMapping[str, Any]:
209 service_client = await get_active_service_client()
210 return (await _wrapper_async(func, event, context, service_client)).to_dict()
212 @functools.wraps(func)
213 def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
214 return _run_on_event_loop(
215 get_or_create_handler_loop(), async_wrapper, event, context
216 )
218 setattr(wrapper, "_async_handler", async_wrapper)
219 setattr(wrapper, "_durable_execution_original", func)
220 setattr(wrapper, "_durable_execution_boto3_client", config.boto3_client)
222 return wrapper
225def _run_on_event_loop(
226 loop: asyncio.AbstractEventLoop,
227 async_func: Callable[..., Awaitable[MutableMapping[str, Any]]],
228 *args: Any,
229) -> MutableMapping[str, Any]:
230 try:
231 asyncio.get_running_loop()
232 except RuntimeError:
233 pass
234 else:
235 msg = (
236 "durable_execution sync handlers cannot be called from a running "
237 "event loop. Use the handler's _async_handler attribute instead."
238 )
239 raise RuntimeError(msg)
241 previous_loop: asyncio.AbstractEventLoop | None = None
242 had_previous_loop = True
243 try:
244 previous_loop = asyncio.get_event_loop()
245 except RuntimeError:
246 had_previous_loop = False
248 asyncio.set_event_loop(loop)
249 try:
250 return loop.run_until_complete(async_func(*args))
251 finally:
252 asyncio.set_event_loop(previous_loop if had_previous_loop else None)
255def deserialize_input(event: Any) -> DurableExecutionInvocationInput:
256 try:
257 logger.debug("durableExecutionArn: %s", event.get("DurableExecutionArn"))
258 return DurableExecutionInvocationInput.from_json_dict(event)
259 except (KeyError, TypeError, AttributeError) as e:
260 msg = (
261 "Unexpected payload provided to start the durable execution. "
262 "Check your resource configurations to confirm the durability is set."
263 )
264 raise ExecutionError(msg) from e
267async def _wrapper_async(
268 user_func: Callable[[Any], Any],
269 event: Any,
270 context: LambdaContext,
271 service_client: DurableServiceClient,
272) -> DurableExecutionInvocationOutput:
273 invocation_input = deserialize_input(event)
274 execution_state: ExecutionState = ExecutionState(
275 durable_execution_arn=invocation_input.durable_execution_arn,
276 initial_checkpoint_token=invocation_input.checkpoint_token,
277 service_client=service_client,
278 lambda_context=context,
279 )
281 try:
282 await execution_state.initialize(invocation_input)
284 input_event = execution_state.get_input_event()
286 root_context = DurableContext(
287 execution_state=execution_state,
288 operation_identifier=OperationIdentifier.create_execution_op(),
289 replaying=execution_state.has_prior_operations(),
290 )
292 if execution_state.get_execution_operation() is None:
293 msg = "Execution state is missing the root execution operation."
294 raise RuntimeError(msg)
295 execution_state.start_checkpointing()
297 logger.debug("execution arn: %s", invocation_input.durable_execution_arn)
299 with bind_current_context(root_context):
300 result = await user_func(input_event)
301 return await handle_user_function_result(execution_state, result)
303 except SuspendExecution:
304 logger.debug("Suspending execution...")
305 return DurableExecutionInvocationOutput(status=InvocationStatus.PENDING)
306 except Exception as e:
307 return await handle_user_function_exception(execution_state, e)
308 finally:
309 await execution_state.aclose()
312async def handle_user_function_result(
313 execution_state, result
314) -> DurableExecutionInvocationOutput:
315 # done with userland
316 serialized_result = json.dumps(result)
317 # large response handling here. Remember if checkpointing to complete, NOT to include
318 # payload in response
319 if serialized_result and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT:
320 logger.debug(
321 "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
322 len(serialized_result),
323 LAMBDA_RESPONSE_SIZE_LIMIT,
324 )
325 success_operation = OperationUpdate.create_execution_succeed(
326 payload=serialized_result
327 )
328 # Checkpoint large result with blocking (is_sync=True, default).
329 # Must ensure the result is persisted before returning to Lambda.
330 # Large results exceed Lambda response limits and must be stored durably
331 # before the execution completes.
332 await execution_state.create_checkpoint(success_operation, is_sync=True)
334 return DurableExecutionInvocationOutput.create_succeeded(result="")
335 return DurableExecutionInvocationOutput.create_succeeded(result=serialized_result)
338async def handle_user_function_exception(
339 execution_state, e: Exception
340) -> DurableExecutionInvocationOutput:
341 if isinstance(e, CheckpointError):
342 return handle_checkpoint_error(e)
343 if isinstance(e, InvocationError):
344 # Non-retryable Durable API errors (e.g., customer configuration issues,
345 # 4xx client errors) will never succeed on retry — fail the execution immediately.
346 if not e.is_retryable():
347 logger.exception(
348 "Non-retryable Durable API error. Must fail execution without retry.",
349 extra=e.build_logger_extras(),
350 )
351 return DurableExecutionInvocationOutput(
352 status=InvocationStatus.FAILED,
353 error=ErrorObject.from_exception(e),
354 )
355 logger.exception("Invocation error. Must terminate.")
356 # Throw the error to trigger Lambda retry
357 raise
358 if isinstance(e, ExecutionError):
359 logger.exception("Execution error. Must fail execution without retry.")
360 return DurableExecutionInvocationOutput(
361 status=InvocationStatus.FAILED,
362 error=ErrorObject.from_exception(e),
363 )
365 # all user-space errors go here
366 logger.exception("Execution failed")
368 result = DurableExecutionInvocationOutput(
369 status=InvocationStatus.FAILED, error=ErrorObject.from_exception(e)
370 )
372 serialized_result = json.dumps(result.to_dict())
374 if serialized_result and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT:
375 logger.debug(
376 "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
377 len(serialized_result),
378 LAMBDA_RESPONSE_SIZE_LIMIT,
379 )
380 failed_operation = OperationUpdate.create_execution_fail(
381 error=ErrorObject.from_exception(e)
382 )
384 # Checkpoint large result with blocking (is_sync=True, default).
385 # Must ensure the result is persisted before returning to Lambda.
386 # Large results exceed Lambda response limits and must be stored durably
387 # before the execution completes.
388 try:
389 await execution_state.create_checkpoint(failed_operation, is_sync=True)
390 except CheckpointError as e:
391 return handle_checkpoint_error(e)
392 return DurableExecutionInvocationOutput(status=InvocationStatus.FAILED)
393 return result
396def handle_checkpoint_error(error: CheckpointError) -> DurableExecutionInvocationOutput:
397 """Convert checkpoint failures into a final result or retry trigger."""
398 # Checkpoint system is broken - stop background thread and exit immediately
399 logger.exception("Checkpoint system failed", extra=error.build_logger_extras())
400 if error.is_retryable():
401 raise error from None # Terminate Lambda immediately and have it be retried
402 return DurableExecutionInvocationOutput(
403 status=InvocationStatus.FAILED, error=ErrorObject.from_exception(error)
404 )