aws-durable-execution-sdk-python 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. aws_durable_execution_sdk_python/.gitignore +0 -0
  2. aws_durable_execution_sdk_python/__about__.py +4 -0
  3. aws_durable_execution_sdk_python/__init__.py +34 -0
  4. aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
  5. aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
  6. aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
  7. aws_durable_execution_sdk_python/concurrency/models.py +469 -0
  8. aws_durable_execution_sdk_python/config.py +499 -0
  9. aws_durable_execution_sdk_python/context.py +551 -0
  10. aws_durable_execution_sdk_python/exceptions.py +374 -0
  11. aws_durable_execution_sdk_python/execution.py +428 -0
  12. aws_durable_execution_sdk_python/identifier.py +14 -0
  13. aws_durable_execution_sdk_python/lambda_service.py +1034 -0
  14. aws_durable_execution_sdk_python/logger.py +131 -0
  15. aws_durable_execution_sdk_python/operation/__init__.py +1 -0
  16. aws_durable_execution_sdk_python/operation/callback.py +123 -0
  17. aws_durable_execution_sdk_python/operation/child.py +162 -0
  18. aws_durable_execution_sdk_python/operation/invoke.py +119 -0
  19. aws_durable_execution_sdk_python/operation/map.py +137 -0
  20. aws_durable_execution_sdk_python/operation/parallel.py +122 -0
  21. aws_durable_execution_sdk_python/operation/step.py +269 -0
  22. aws_durable_execution_sdk_python/operation/wait.py +53 -0
  23. aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
  24. aws_durable_execution_sdk_python/py.typed +1 -0
  25. aws_durable_execution_sdk_python/retries.py +174 -0
  26. aws_durable_execution_sdk_python/serdes.py +502 -0
  27. aws_durable_execution_sdk_python/state.py +790 -0
  28. aws_durable_execution_sdk_python/suspend.py +84 -0
  29. aws_durable_execution_sdk_python/threading.py +222 -0
  30. aws_durable_execution_sdk_python/types.py +180 -0
  31. aws_durable_execution_sdk_python/waits.py +130 -0
  32. aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
  33. aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
  34. aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
  35. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
  36. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
@@ -0,0 +1,428 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import functools
5
+ import json
6
+ import logging
7
+ from concurrent.futures import ThreadPoolExecutor
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from aws_durable_execution_sdk_python.context import DurableContext
13
+ from aws_durable_execution_sdk_python.exceptions import (
14
+ BackgroundThreadError,
15
+ BotoClientError,
16
+ CheckpointError,
17
+ DurableExecutionsError,
18
+ ExecutionError,
19
+ InvocationError,
20
+ SuspendExecution,
21
+ )
22
+ from aws_durable_execution_sdk_python.lambda_service import (
23
+ DurableServiceClient,
24
+ ErrorObject,
25
+ LambdaClient,
26
+ Operation,
27
+ OperationType,
28
+ OperationUpdate,
29
+ )
30
+ from aws_durable_execution_sdk_python.state import ExecutionState, ReplayStatus
31
+
32
+ if TYPE_CHECKING:
33
+ from collections.abc import Callable, MutableMapping
34
+
35
+ import boto3 # type: ignore
36
+
37
+ from aws_durable_execution_sdk_python.types import LambdaContext
38
+
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # 6MB in bytes, minus 50 bytes for envelope
43
+ LAMBDA_RESPONSE_SIZE_LIMIT = 6 * 1024 * 1024 - 50
44
+
45
+
46
+ # region Invocation models
47
+ @dataclass(frozen=True)
48
+ class InitialExecutionState:
49
+ operations: list[Operation]
50
+ next_marker: str
51
+
52
+ @staticmethod
53
+ def from_dict(input_dict: MutableMapping[str, Any]) -> InitialExecutionState:
54
+ operations = []
55
+ if input_operations := input_dict.get("Operations"):
56
+ operations = [Operation.from_dict(op) for op in input_operations]
57
+ return InitialExecutionState(
58
+ operations=operations,
59
+ next_marker=input_dict.get("NextMarker", ""),
60
+ )
61
+
62
+ def get_execution_operation(self) -> Operation | None:
63
+ if not self.operations:
64
+ # Due to payload size limitations we may have an empty operations list.
65
+ # This will only happen when loading the initial page of results and is
66
+ # expected behaviour. We don't fail, but instead return None
67
+ # as the execution operation does not exist
68
+ msg: str = "No durable operations found in initial execution state."
69
+ logger.debug(msg)
70
+ return None
71
+
72
+ candidate = self.operations[0]
73
+ if candidate.operation_type is not OperationType.EXECUTION:
74
+ msg = f"First operation in initial execution state is not an execution operation: {candidate.operation_type}"
75
+ raise DurableExecutionsError(msg)
76
+
77
+ return candidate
78
+
79
+ def get_input_payload(self) -> str | None:
80
+ # It is possible that backend will not provide an execution operation
81
+ # for the initial page of results.
82
+ if not (operations := self.get_execution_operation()):
83
+ return None
84
+ if not (execution_details := operations.execution_details):
85
+ return None
86
+ return execution_details.input_payload
87
+
88
+ def to_dict(self) -> MutableMapping[str, Any]:
89
+ return {
90
+ "Operations": [op.to_dict() for op in self.operations],
91
+ "NextMarker": self.next_marker,
92
+ }
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class DurableExecutionInvocationInput:
97
+ durable_execution_arn: str
98
+ checkpoint_token: str
99
+ initial_execution_state: InitialExecutionState
100
+
101
+ @staticmethod
102
+ def from_dict(
103
+ input_dict: MutableMapping[str, Any],
104
+ ) -> DurableExecutionInvocationInput:
105
+ return DurableExecutionInvocationInput(
106
+ durable_execution_arn=input_dict["DurableExecutionArn"],
107
+ checkpoint_token=input_dict["CheckpointToken"],
108
+ initial_execution_state=InitialExecutionState.from_dict(
109
+ input_dict.get("InitialExecutionState", {})
110
+ ),
111
+ )
112
+
113
+ def to_dict(self) -> MutableMapping[str, Any]:
114
+ return {
115
+ "DurableExecutionArn": self.durable_execution_arn,
116
+ "CheckpointToken": self.checkpoint_token,
117
+ "InitialExecutionState": self.initial_execution_state.to_dict(),
118
+ }
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class DurableExecutionInvocationInputWithClient(DurableExecutionInvocationInput):
123
+ """Invocation input with Lambda boto client injected.
124
+
125
+ This is useful for testing scenarios where you want to inject a mock client.
126
+ """
127
+
128
+ service_client: DurableServiceClient
129
+
130
+ @staticmethod
131
+ def from_durable_execution_invocation_input(
132
+ invocation_input: DurableExecutionInvocationInput,
133
+ service_client: DurableServiceClient,
134
+ ):
135
+ return DurableExecutionInvocationInputWithClient(
136
+ durable_execution_arn=invocation_input.durable_execution_arn,
137
+ checkpoint_token=invocation_input.checkpoint_token,
138
+ initial_execution_state=invocation_input.initial_execution_state,
139
+ service_client=service_client,
140
+ )
141
+
142
+
143
+ class InvocationStatus(Enum):
144
+ SUCCEEDED = "SUCCEEDED"
145
+ FAILED = "FAILED"
146
+ PENDING = "PENDING"
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class DurableExecutionInvocationOutput:
151
+ """Representation the DurableExecutionInvocationOutput. This is what the Durable lambda handler returns.
152
+
153
+ If the execution has been already completed via an update to the EXECUTION operation via CheckpointDurableExecution,
154
+ payload must be empty for SUCCEEDED/FAILED status.
155
+ """
156
+
157
+ status: InvocationStatus
158
+ result: str | None = None
159
+ error: ErrorObject | None = None
160
+
161
+ @classmethod
162
+ def from_dict(
163
+ cls, data: MutableMapping[str, Any]
164
+ ) -> DurableExecutionInvocationOutput:
165
+ """Create an instance from a dictionary.
166
+
167
+ Args:
168
+ data: Dictionary with camelCase keys matching the original structure
169
+
170
+ Returns:
171
+ A DurableExecutionInvocationOutput instance
172
+ """
173
+ status = InvocationStatus(data.get("Status"))
174
+ error = ErrorObject.from_dict(data["Error"]) if data.get("Error") else None
175
+ return cls(status=status, result=data.get("Result"), error=error)
176
+
177
+ def to_dict(self) -> MutableMapping[str, Any]:
178
+ """Convert to a dictionary with the original field names.
179
+
180
+ Returns:
181
+ Dictionary with the original camelCase keys
182
+ """
183
+ result: MutableMapping[str, Any] = {"Status": self.status.value}
184
+
185
+ if self.result is not None:
186
+ # large payloads return "", because checkpointed already
187
+ result["Result"] = self.result
188
+ if self.error:
189
+ result["Error"] = self.error.to_dict()
190
+
191
+ return result
192
+
193
+ @classmethod
194
+ def create_succeeded(cls, result: str) -> DurableExecutionInvocationOutput:
195
+ """Create a succeeded invocation output."""
196
+ return cls(status=InvocationStatus.SUCCEEDED, result=result)
197
+
198
+
199
+ # endregion Invocation models
200
+
201
+
202
+ def durable_execution(
203
+ func: Callable[[Any, DurableContext], Any] | None = None,
204
+ *,
205
+ boto3_client: boto3.client | None = None,
206
+ ) -> Callable[[Any, LambdaContext], Any]:
207
+ # Decorator called with parameters
208
+ if func is None:
209
+ logger.debug("Decorator called with parameters")
210
+ return functools.partial(durable_execution, boto3_client=boto3_client)
211
+
212
+ logger.debug("Starting durable execution handler...")
213
+
214
+ def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
215
+ invocation_input: DurableExecutionInvocationInput
216
+ service_client: DurableServiceClient
217
+
218
+ # event likely only to be DurableExecutionInvocationInputWithClient when directly injected by test framework
219
+ if isinstance(event, DurableExecutionInvocationInputWithClient):
220
+ logger.debug("durableExecutionArn: %s", event.durable_execution_arn)
221
+ invocation_input = event
222
+ service_client = invocation_input.service_client
223
+ else:
224
+ try:
225
+ logger.debug(
226
+ "durableExecutionArn: %s", event.get("DurableExecutionArn")
227
+ )
228
+ invocation_input = DurableExecutionInvocationInput.from_dict(event)
229
+ except (KeyError, TypeError, AttributeError) as e:
230
+ msg = (
231
+ "Unexpected payload provided to start the durable execution. "
232
+ "Check your resource configurations to confirm the durability is set."
233
+ )
234
+ raise ExecutionError(msg) from e
235
+
236
+ # Use custom client if provided, otherwise initialize from environment
237
+ service_client = (
238
+ LambdaClient(client=boto3_client)
239
+ if boto3_client is not None
240
+ else LambdaClient.initialize_from_env()
241
+ )
242
+
243
+ raw_input_payload: str | None = (
244
+ invocation_input.initial_execution_state.get_input_payload()
245
+ )
246
+
247
+ # Python RIC LambdaMarshaller just uses standard json deserialization for event
248
+ # https://github.com/aws/aws-lambda-python-runtime-interface-client/blob/main/awslambdaric/lambda_runtime_marshaller.py#L46
249
+ input_event: MutableMapping[str, Any] = {}
250
+ if raw_input_payload and raw_input_payload.strip():
251
+ try:
252
+ input_event = json.loads(raw_input_payload)
253
+ except json.JSONDecodeError:
254
+ logger.exception(
255
+ "Failed to parse input payload as JSON: payload: %r",
256
+ raw_input_payload,
257
+ )
258
+ raise
259
+
260
+ execution_state: ExecutionState = ExecutionState(
261
+ durable_execution_arn=invocation_input.durable_execution_arn,
262
+ initial_checkpoint_token=invocation_input.checkpoint_token,
263
+ operations={},
264
+ service_client=service_client,
265
+ # If there are operations other than the initial EXECUTION one, current state is in replay mode
266
+ replay_status=ReplayStatus.REPLAY
267
+ if len(invocation_input.initial_execution_state.operations) > 1
268
+ else ReplayStatus.NEW,
269
+ )
270
+
271
+ execution_state.fetch_paginated_operations(
272
+ invocation_input.initial_execution_state.operations,
273
+ invocation_input.checkpoint_token,
274
+ invocation_input.initial_execution_state.next_marker,
275
+ )
276
+
277
+ durable_context: DurableContext = DurableContext.from_lambda_context(
278
+ state=execution_state, lambda_context=context
279
+ )
280
+
281
+ # Use ThreadPoolExecutor for concurrent execution of user code and background checkpoint processing
282
+ with (
283
+ ThreadPoolExecutor(
284
+ max_workers=2, thread_name_prefix="dex-handler"
285
+ ) as executor,
286
+ contextlib.closing(execution_state) as execution_state,
287
+ ):
288
+ # Thread 1: Run background checkpoint processing
289
+ executor.submit(execution_state.checkpoint_batches_forever)
290
+
291
+ # Thread 2: Execute user function
292
+ logger.debug(
293
+ "%s entering user-space...", invocation_input.durable_execution_arn
294
+ )
295
+ user_future = executor.submit(func, input_event, durable_context)
296
+
297
+ logger.debug(
298
+ "%s waiting for user code completion...",
299
+ invocation_input.durable_execution_arn,
300
+ )
301
+
302
+ try:
303
+ # Background checkpointing errors will propagate through CompletionEvent.wait() as BackgroundThreadError
304
+ result = user_future.result()
305
+
306
+ # done with userland
307
+ logger.debug(
308
+ "%s exiting user-space...",
309
+ invocation_input.durable_execution_arn,
310
+ )
311
+ serialized_result = json.dumps(result)
312
+ # large response handling here. Remember if checkpointing to complete, NOT to include
313
+ # payload in response
314
+ if (
315
+ serialized_result
316
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
317
+ ):
318
+ logger.debug(
319
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
320
+ len(serialized_result),
321
+ LAMBDA_RESPONSE_SIZE_LIMIT,
322
+ )
323
+ success_operation = OperationUpdate.create_execution_succeed(
324
+ payload=serialized_result
325
+ )
326
+ # Checkpoint large result with blocking (is_sync=True, default).
327
+ # Must ensure the result is persisted before returning to Lambda.
328
+ # Large results exceed Lambda response limits and must be stored durably
329
+ # before the execution completes.
330
+ try:
331
+ execution_state.create_checkpoint(
332
+ success_operation, is_sync=True
333
+ )
334
+ except CheckpointError as e:
335
+ return handle_checkpoint_error(e).to_dict()
336
+ return DurableExecutionInvocationOutput.create_succeeded(
337
+ result=""
338
+ ).to_dict()
339
+
340
+ return DurableExecutionInvocationOutput.create_succeeded(
341
+ result=serialized_result
342
+ ).to_dict()
343
+
344
+ except BackgroundThreadError as bg_error:
345
+ # Background checkpoint system failed - propagated through CompletionEvent
346
+ # Do not attempt to checkpoint anything, just terminate immediately
347
+ if isinstance(bg_error.source_exception, BotoClientError):
348
+ logger.exception(
349
+ "Checkpoint processing failed",
350
+ extra=bg_error.source_exception.build_logger_extras(),
351
+ )
352
+ else:
353
+ logger.exception("Checkpoint processing failed")
354
+ # handle the original exception
355
+ if isinstance(bg_error.source_exception, CheckpointError):
356
+ return handle_checkpoint_error(bg_error.source_exception).to_dict()
357
+ raise bg_error.source_exception from bg_error
358
+
359
+ except SuspendExecution:
360
+ # User code suspended - stop background checkpointing thread
361
+ logger.debug("Suspending execution...")
362
+ return DurableExecutionInvocationOutput(
363
+ status=InvocationStatus.PENDING
364
+ ).to_dict()
365
+
366
+ except CheckpointError as e:
367
+ # Checkpoint system is broken - stop background thread and exit immediately
368
+ logger.exception(
369
+ "Checkpoint system failed",
370
+ extra=e.build_logger_extras(),
371
+ )
372
+ return handle_checkpoint_error(e).to_dict()
373
+ except InvocationError:
374
+ logger.exception("Invocation error. Must terminate.")
375
+ # Throw the error to trigger Lambda retry
376
+ raise
377
+ except ExecutionError as e:
378
+ logger.exception("Execution error. Must terminate without retry.")
379
+ return DurableExecutionInvocationOutput(
380
+ status=InvocationStatus.FAILED,
381
+ error=ErrorObject.from_exception(e),
382
+ ).to_dict()
383
+ except Exception as e:
384
+ # all user-space errors go here
385
+ logger.exception("Execution failed")
386
+
387
+ result = DurableExecutionInvocationOutput(
388
+ status=InvocationStatus.FAILED, error=ErrorObject.from_exception(e)
389
+ ).to_dict()
390
+
391
+ serialized_result = json.dumps(result)
392
+
393
+ if (
394
+ serialized_result
395
+ and len(serialized_result) > LAMBDA_RESPONSE_SIZE_LIMIT
396
+ ):
397
+ logger.debug(
398
+ "Response size (%s bytes) exceeds Lambda limit (%s) bytes). Checkpointing result.",
399
+ len(serialized_result),
400
+ LAMBDA_RESPONSE_SIZE_LIMIT,
401
+ )
402
+ failed_operation = OperationUpdate.create_execution_fail(
403
+ error=ErrorObject.from_exception(e)
404
+ )
405
+
406
+ # Checkpoint large result with blocking (is_sync=True, default).
407
+ # Must ensure the result is persisted before returning to Lambda.
408
+ # Large results exceed Lambda response limits and must be stored durably
409
+ # before the execution completes.
410
+ try:
411
+ execution_state.create_checkpoint_sync(failed_operation)
412
+ except CheckpointError as e:
413
+ return handle_checkpoint_error(e).to_dict()
414
+ return DurableExecutionInvocationOutput(
415
+ status=InvocationStatus.FAILED
416
+ ).to_dict()
417
+
418
+ return result
419
+
420
+ return wrapper
421
+
422
+
423
+ def handle_checkpoint_error(error: CheckpointError) -> DurableExecutionInvocationOutput:
424
+ if error.is_retriable():
425
+ raise error from None # Terminate Lambda immediately and have it be retried
426
+ return DurableExecutionInvocationOutput(
427
+ status=InvocationStatus.FAILED, error=ErrorObject.from_exception(error)
428
+ )
@@ -0,0 +1,14 @@
1
+ """Operation identifier types for durable executions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class OperationIdentifier:
10
+ """Container for operation id, parent id, and name."""
11
+
12
+ operation_id: str
13
+ parent_id: str | None = None
14
+ name: str | None = None