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,551 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ from typing import TYPE_CHECKING, Any, Concatenate, Generic, ParamSpec, TypeVar
6
+
7
+ from aws_durable_execution_sdk_python.config import (
8
+ BatchedInput,
9
+ CallbackConfig,
10
+ ChildConfig,
11
+ Duration,
12
+ InvokeConfig,
13
+ MapConfig,
14
+ ParallelConfig,
15
+ StepConfig,
16
+ WaitForCallbackConfig,
17
+ )
18
+ from aws_durable_execution_sdk_python.exceptions import (
19
+ CallbackError,
20
+ SuspendExecution,
21
+ ValidationError,
22
+ )
23
+ from aws_durable_execution_sdk_python.identifier import OperationIdentifier
24
+ from aws_durable_execution_sdk_python.lambda_service import OperationSubType
25
+ from aws_durable_execution_sdk_python.logger import Logger, LogInfo
26
+ from aws_durable_execution_sdk_python.operation.callback import (
27
+ create_callback_handler,
28
+ wait_for_callback_handler,
29
+ )
30
+ from aws_durable_execution_sdk_python.operation.child import child_handler
31
+ from aws_durable_execution_sdk_python.operation.invoke import invoke_handler
32
+ from aws_durable_execution_sdk_python.operation.map import map_handler
33
+ from aws_durable_execution_sdk_python.operation.parallel import parallel_handler
34
+ from aws_durable_execution_sdk_python.operation.step import step_handler
35
+ from aws_durable_execution_sdk_python.operation.wait import wait_handler
36
+ from aws_durable_execution_sdk_python.operation.wait_for_condition import (
37
+ wait_for_condition_handler,
38
+ )
39
+ from aws_durable_execution_sdk_python.serdes import (
40
+ PassThroughSerDes,
41
+ SerDes,
42
+ deserialize,
43
+ )
44
+ from aws_durable_execution_sdk_python.state import ExecutionState # noqa: TCH001
45
+ from aws_durable_execution_sdk_python.threading import OrderedCounter
46
+ from aws_durable_execution_sdk_python.types import (
47
+ BatchResult,
48
+ LoggerInterface,
49
+ StepContext,
50
+ WaitForCallbackContext,
51
+ WaitForConditionCheckContext,
52
+ )
53
+ from aws_durable_execution_sdk_python.types import Callback as CallbackProtocol
54
+ from aws_durable_execution_sdk_python.types import (
55
+ DurableContext as DurableContextProtocol,
56
+ )
57
+
58
+ if TYPE_CHECKING:
59
+ from collections.abc import Callable, Sequence
60
+
61
+ from aws_durable_execution_sdk_python.state import CheckpointedResult
62
+ from aws_durable_execution_sdk_python.types import LambdaContext
63
+ from aws_durable_execution_sdk_python.waits import WaitForConditionConfig
64
+
65
+ P = TypeVar("P") # Payload type
66
+ R = TypeVar("R") # Result type
67
+ T = TypeVar("T")
68
+ U = TypeVar("U")
69
+ Params = ParamSpec("Params")
70
+
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+ PASS_THROUGH_SERDES: SerDes[Any] = PassThroughSerDes()
75
+
76
+
77
+ def durable_step(
78
+ func: Callable[Concatenate[StepContext, Params], T],
79
+ ) -> Callable[Params, Callable[[StepContext], T]]:
80
+ """Wrap your callable into a named function that a Durable step can run."""
81
+
82
+ def wrapper(*args, **kwargs):
83
+ def function_with_arguments(context: StepContext):
84
+ return func(context, *args, **kwargs)
85
+
86
+ function_with_arguments._original_name = func.__name__ # noqa: SLF001
87
+ return function_with_arguments
88
+
89
+ return wrapper
90
+
91
+
92
+ def durable_with_child_context(
93
+ func: Callable[Concatenate[DurableContext, Params], T],
94
+ ) -> Callable[Params, Callable[[DurableContext], T]]:
95
+ """Wrap your callable into a Durable child context."""
96
+
97
+ def wrapper(*args, **kwargs):
98
+ def function_with_arguments(child_context: DurableContext):
99
+ return func(child_context, *args, **kwargs)
100
+
101
+ function_with_arguments._original_name = func.__name__ # noqa: SLF001
102
+ return function_with_arguments
103
+
104
+ return wrapper
105
+
106
+
107
+ class Callback(Generic[T], CallbackProtocol[T]): # noqa: PYI059
108
+ """A future that will block on result() until callback_id returns."""
109
+
110
+ def __init__(
111
+ self,
112
+ callback_id: str,
113
+ operation_id: str,
114
+ state: ExecutionState,
115
+ serdes: SerDes[T] | None = None,
116
+ ):
117
+ self.callback_id: str = callback_id
118
+ self.operation_id: str = operation_id
119
+ self.state: ExecutionState = state
120
+ self.serdes: SerDes[T] | None = serdes
121
+
122
+ def result(self) -> T | None:
123
+ """Return the result of the future. Will block until result is available.
124
+
125
+ This will suspend the current execution while waiting for the result to
126
+ become available. Durable Functions will replay the execution once the
127
+ result is ready, and proceed when it reaches the .result() call.
128
+
129
+ Use the callback id with the following APIs to send back the result, error or
130
+ heartbeats: SendDurableExecutionCallbackSuccess, SendDurableExecutionCallbackFailure
131
+ and SendDurableExecutionCallbackHeartbeat.
132
+ """
133
+ checkpointed_result: CheckpointedResult = self.state.get_checkpoint_result(
134
+ self.operation_id
135
+ )
136
+
137
+ if not checkpointed_result.is_existent():
138
+ msg = "Callback operation must exist"
139
+ raise CallbackError(msg)
140
+
141
+ if (
142
+ checkpointed_result.is_failed()
143
+ or checkpointed_result.is_cancelled()
144
+ or checkpointed_result.is_timed_out()
145
+ or checkpointed_result.is_stopped()
146
+ ):
147
+ checkpointed_result.raise_callable_error()
148
+
149
+ if checkpointed_result.is_succeeded():
150
+ if checkpointed_result.result is None:
151
+ return None # type: ignore
152
+
153
+ return deserialize(
154
+ serdes=self.serdes if self.serdes is not None else PASS_THROUGH_SERDES,
155
+ data=checkpointed_result.result,
156
+ operation_id=self.operation_id,
157
+ durable_execution_arn=self.state.durable_execution_arn,
158
+ )
159
+
160
+ # operation exists; it has not terminated (successfully or otherwise)
161
+ # therefore we should wait
162
+ msg = "Callback result not received yet. Suspending execution while waiting for result."
163
+ raise SuspendExecution(msg)
164
+
165
+
166
+ class DurableContext(DurableContextProtocol):
167
+ def __init__(
168
+ self,
169
+ state: ExecutionState,
170
+ lambda_context: LambdaContext | None = None,
171
+ parent_id: str | None = None,
172
+ logger: Logger | None = None,
173
+ ) -> None:
174
+ self.state: ExecutionState = state
175
+ self.lambda_context = lambda_context
176
+ self._parent_id: str | None = parent_id
177
+ self._step_counter: OrderedCounter = OrderedCounter()
178
+
179
+ log_info = LogInfo(
180
+ execution_state=state,
181
+ parent_id=parent_id,
182
+ )
183
+ self._log_info = log_info
184
+ self.logger: Logger = logger or Logger.from_log_info(
185
+ logger=logging.getLogger(),
186
+ info=log_info,
187
+ )
188
+
189
+ # region factories
190
+ @staticmethod
191
+ def from_lambda_context(
192
+ state: ExecutionState,
193
+ lambda_context: LambdaContext,
194
+ ):
195
+ return DurableContext(
196
+ state=state,
197
+ lambda_context=lambda_context,
198
+ parent_id=None,
199
+ )
200
+
201
+ def create_child_context(self, parent_id: str) -> DurableContext:
202
+ """Create a child context from the given parent."""
203
+ logger.debug("Creating child context for parent %s", parent_id)
204
+ return DurableContext(
205
+ state=self.state,
206
+ lambda_context=self.lambda_context,
207
+ parent_id=parent_id,
208
+ logger=self.logger.with_log_info(
209
+ LogInfo(
210
+ execution_state=self.state,
211
+ parent_id=parent_id,
212
+ )
213
+ ),
214
+ )
215
+
216
+ # endregion factories
217
+
218
+ @staticmethod
219
+ def _resolve_step_name(name: str | None, func: Callable) -> str | None:
220
+ """Resolve the step name.
221
+
222
+ Returns:
223
+ str | None: The provided name, and if that doesn't exist the callable function's name if it has one.
224
+ """
225
+ # callable's name will override name if name is falsy ('' or None)
226
+ return name or getattr(func, "_original_name", None)
227
+
228
+ def set_logger(self, new_logger: LoggerInterface):
229
+ """Set the logger for the current context."""
230
+ self.logger = Logger.from_log_info(
231
+ logger=new_logger,
232
+ info=self._log_info,
233
+ )
234
+
235
+ def _create_step_id_for_logical_step(self, step: int) -> str:
236
+ """
237
+ Generate a step_id based on the given logical step.
238
+ This allows us to recover operation ids or even look
239
+ forward without changing the internal state of this context.
240
+ """
241
+ step_id = f"{self._parent_id}-{step}" if self._parent_id else str(step)
242
+ return hashlib.blake2b(step_id.encode()).hexdigest()[:64]
243
+
244
+ def _create_step_id(self) -> str:
245
+ """Generate a thread-safe step id, incrementing in order of invocation.
246
+
247
+ This method is an internal implementation detail. Do not rely the exact format of
248
+ the id generated by this method. It is subject to change without notice.
249
+ """
250
+ new_counter: int = self._step_counter.increment()
251
+ return self._create_step_id_for_logical_step(new_counter)
252
+
253
+ # region Operations
254
+
255
+ def create_callback(
256
+ self, name: str | None = None, config: CallbackConfig | None = None
257
+ ) -> Callback:
258
+ """Create a callback.
259
+
260
+ This generates a future with a callback id. External systems can signal
261
+ your Durable Function to proceed by using this callback id with the
262
+ SendDurableExecutionCallbackSuccess, SendDurableExecutionCallbackFailure and
263
+ SendDurableExecutionCallbackHeartbeat APIs.
264
+
265
+ Args:
266
+ name (str): Optional name for the operation.
267
+ config (CallbackConfig): Configuration for the callback.
268
+
269
+ Return:
270
+ Callback future. Use result() on this future to wait for the callback resuilt.
271
+ """
272
+ if not config:
273
+ config = CallbackConfig()
274
+ operation_id: str = self._create_step_id()
275
+ callback_id: str = create_callback_handler(
276
+ state=self.state,
277
+ operation_identifier=OperationIdentifier(
278
+ operation_id=operation_id, parent_id=self._parent_id, name=name
279
+ ),
280
+ config=config,
281
+ )
282
+ result: Callback = Callback(
283
+ callback_id=callback_id,
284
+ operation_id=operation_id,
285
+ state=self.state,
286
+ serdes=config.serdes,
287
+ )
288
+ self.state.track_replay(operation_id=operation_id)
289
+ return result
290
+
291
+ def invoke(
292
+ self,
293
+ function_name: str,
294
+ payload: P,
295
+ name: str | None = None,
296
+ config: InvokeConfig[P, R] | None = None,
297
+ ) -> R:
298
+ """Invoke another Durable Function.
299
+
300
+ Args:
301
+ function_name: Name of the function to invoke
302
+ payload: Input payload to send to the function
303
+ name: Optional name for the operation
304
+ config: Optional configuration for the invoke operation
305
+
306
+ Returns:
307
+ The result of the invoked function
308
+ """
309
+ operation_id = self._create_step_id()
310
+ result: R = invoke_handler(
311
+ function_name=function_name,
312
+ payload=payload,
313
+ state=self.state,
314
+ operation_identifier=OperationIdentifier(
315
+ operation_id=operation_id,
316
+ parent_id=self._parent_id,
317
+ name=name,
318
+ ),
319
+ config=config,
320
+ )
321
+ self.state.track_replay(operation_id=operation_id)
322
+ return result
323
+
324
+ def map(
325
+ self,
326
+ inputs: Sequence[U],
327
+ func: Callable[[DurableContext, U | BatchedInput[Any, U], int, Sequence[U]], T],
328
+ name: str | None = None,
329
+ config: MapConfig | None = None,
330
+ ) -> BatchResult[R]:
331
+ """Execute a callable for each item in parallel."""
332
+ map_name: str | None = self._resolve_step_name(name, func)
333
+
334
+ operation_id = self._create_step_id()
335
+ operation_identifier = OperationIdentifier(
336
+ operation_id=operation_id, parent_id=self._parent_id, name=map_name
337
+ )
338
+ map_context = self.create_child_context(parent_id=operation_id)
339
+
340
+ def map_in_child_context() -> BatchResult[R]:
341
+ # map_context is a child_context of the context upon which `.map`
342
+ # was called. We are calling it `map_context` to make it explicit
343
+ # that any operations happening from hereon are done on the context
344
+ # that owns the branches
345
+ return map_handler(
346
+ items=inputs,
347
+ func=func,
348
+ config=config,
349
+ execution_state=self.state,
350
+ map_context=map_context,
351
+ operation_identifier=operation_identifier,
352
+ )
353
+
354
+ result: BatchResult[R] = child_handler(
355
+ func=map_in_child_context,
356
+ state=self.state,
357
+ operation_identifier=operation_identifier,
358
+ config=ChildConfig(
359
+ sub_type=OperationSubType.MAP,
360
+ serdes=getattr(config, "serdes", None),
361
+ # child_handler should only know the serdes of the parent serdes,
362
+ # the item serdes will be passed when we are actually executing
363
+ # the branch within its own child_handler.
364
+ item_serdes=None,
365
+ ),
366
+ )
367
+ self.state.track_replay(operation_id=operation_id)
368
+ return result
369
+
370
+ def parallel(
371
+ self,
372
+ functions: Sequence[Callable[[DurableContext], T]],
373
+ name: str | None = None,
374
+ config: ParallelConfig | None = None,
375
+ ) -> BatchResult[T]:
376
+ """Execute multiple callables in parallel."""
377
+ # _create_step_id() is thread-safe. rest of method is safe, since using local copy of parent id
378
+ operation_id = self._create_step_id()
379
+ parallel_context = self.create_child_context(parent_id=operation_id)
380
+ operation_identifier = OperationIdentifier(
381
+ operation_id=operation_id, parent_id=self._parent_id, name=name
382
+ )
383
+
384
+ def parallel_in_child_context() -> BatchResult[T]:
385
+ # parallel_context is a child_context of the context upon which `.map`
386
+ # was called. We are calling it `parallel_context` to make it explicit
387
+ # that any operations happening from hereon are done on the context
388
+ # that owns the branches
389
+ return parallel_handler(
390
+ callables=functions,
391
+ config=config,
392
+ execution_state=self.state,
393
+ parallel_context=parallel_context,
394
+ operation_identifier=operation_identifier,
395
+ )
396
+
397
+ result: BatchResult[T] = child_handler(
398
+ func=parallel_in_child_context,
399
+ state=self.state,
400
+ operation_identifier=operation_identifier,
401
+ config=ChildConfig(
402
+ sub_type=OperationSubType.PARALLEL,
403
+ serdes=getattr(config, "serdes", None),
404
+ # child_handler should only know the serdes of the parent serdes,
405
+ # the item serdes will be passed when we are actually executing
406
+ # the branch within its own child_handler.
407
+ item_serdes=None,
408
+ ),
409
+ )
410
+ self.state.track_replay(operation_id=operation_id)
411
+ return result
412
+
413
+ def run_in_child_context(
414
+ self,
415
+ func: Callable[[DurableContext], T],
416
+ name: str | None = None,
417
+ config: ChildConfig | None = None,
418
+ ) -> T:
419
+ """Run the callable and pass a child context to it.
420
+
421
+ Use this to nest and group operations.
422
+
423
+ Args:
424
+ callable (Callable[[DurableContext], T]): Run this callable and pass the child context as the argument to it.
425
+ name (str | None): name for the operation.
426
+ config (ChildConfig | None = None): c
427
+
428
+ Returns:
429
+ T: The result of the callable.
430
+ """
431
+ step_name: str | None = self._resolve_step_name(name, func)
432
+ # _create_step_id() is thread-safe. rest of method is safe, since using local copy of parent id
433
+ operation_id = self._create_step_id()
434
+
435
+ def callable_with_child_context():
436
+ return func(self.create_child_context(parent_id=operation_id))
437
+
438
+ result: T = child_handler(
439
+ func=callable_with_child_context,
440
+ state=self.state,
441
+ operation_identifier=OperationIdentifier(
442
+ operation_id=operation_id, parent_id=self._parent_id, name=step_name
443
+ ),
444
+ config=config,
445
+ )
446
+ self.state.track_replay(operation_id=operation_id)
447
+ return result
448
+
449
+ def step(
450
+ self,
451
+ func: Callable[[StepContext], T],
452
+ name: str | None = None,
453
+ config: StepConfig | None = None,
454
+ ) -> T:
455
+ step_name = self._resolve_step_name(name, func)
456
+ logger.debug("Step name: %s", step_name)
457
+ operation_id = self._create_step_id()
458
+ result: T = step_handler(
459
+ func=func,
460
+ config=config,
461
+ state=self.state,
462
+ operation_identifier=OperationIdentifier(
463
+ operation_id=operation_id,
464
+ parent_id=self._parent_id,
465
+ name=step_name,
466
+ ),
467
+ context_logger=self.logger,
468
+ )
469
+ self.state.track_replay(operation_id=operation_id)
470
+ return result
471
+
472
+ def wait(self, duration: Duration, name: str | None = None) -> None:
473
+ """Wait for a specified amount of time.
474
+
475
+ Args:
476
+ duration: Duration to wait
477
+ name: Optional name for the wait step
478
+ """
479
+ seconds = duration.to_seconds()
480
+ if seconds < 1:
481
+ msg = "duration must be at least 1 second"
482
+ raise ValidationError(msg)
483
+ operation_id = self._create_step_id()
484
+ wait_handler(
485
+ seconds=seconds,
486
+ state=self.state,
487
+ operation_identifier=OperationIdentifier(
488
+ operation_id=operation_id,
489
+ parent_id=self._parent_id,
490
+ name=name,
491
+ ),
492
+ )
493
+ self.state.track_replay(operation_id=operation_id)
494
+
495
+ def wait_for_callback(
496
+ self,
497
+ submitter: Callable[[str, WaitForCallbackContext], None],
498
+ name: str | None = None,
499
+ config: WaitForCallbackConfig | None = None,
500
+ ) -> Any:
501
+ step_name: str | None = self._resolve_step_name(name, submitter)
502
+ logger.debug("wait_for_callback name: %s", step_name)
503
+
504
+ def wait_in_child_context(context: DurableContext):
505
+ return wait_for_callback_handler(context, submitter, step_name, config)
506
+
507
+ return self.run_in_child_context(
508
+ wait_in_child_context,
509
+ step_name,
510
+ )
511
+
512
+ def wait_for_condition(
513
+ self,
514
+ check: Callable[[T, WaitForConditionCheckContext], T],
515
+ config: WaitForConditionConfig[T],
516
+ name: str | None = None,
517
+ ) -> T:
518
+ """Wait for a condition to be met by polling.
519
+
520
+ Args:
521
+ check (Callable[[T, WaitForConditionCheckContext], T]): Function that checks the condition and returns updated state
522
+ config (WaitForConditionConfig[T]): Configuration including wait strategy and initial state
523
+ name (str | None): Optional name for the operation
524
+
525
+ Returns:
526
+ The final state when condition is met.
527
+ """
528
+ if check is None:
529
+ msg = "`check` is required for wait_for_condition"
530
+ raise ValidationError(msg)
531
+ if not config:
532
+ msg = "`config` is required for wait_for_condition"
533
+ raise ValidationError(msg)
534
+
535
+ operation_id = self._create_step_id()
536
+ result: T = wait_for_condition_handler(
537
+ check=check,
538
+ config=config,
539
+ state=self.state,
540
+ operation_identifier=OperationIdentifier(
541
+ operation_id=operation_id,
542
+ parent_id=self._parent_id,
543
+ name=name,
544
+ ),
545
+ context_logger=self.logger,
546
+ )
547
+ self.state.track_replay(operation_id=operation_id)
548
+ return result
549
+
550
+
551
+ # endregion Operations