async-durable-execution 2.2.0__tar.gz → 2.3.0__tar.gz

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 (67) hide show
  1. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/PKG-INFO +5 -2
  2. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/README.md +2 -0
  3. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/__about__.py +1 -1
  4. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/__init__.py +28 -14
  5. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/__init__.py +4 -3
  6. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/client.py +6 -10
  7. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/config.py +1 -1
  8. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/context.py +162 -21
  9. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/exceptions.py +13 -13
  10. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/execution.py +4 -4
  11. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/logger.py +4 -1
  12. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/models.py +251 -283
  13. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/serdes.py +7 -16
  14. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/state.py +7 -7
  15. async_durable_execution-2.3.0/async_durable_execution/_extension/__init__.py +23 -0
  16. async_durable_execution-2.3.0/async_durable_execution/_operation/__init__.py +1 -0
  17. async_durable_execution-2.3.0/async_durable_execution/_operation/callback.py +41 -0
  18. async_durable_execution-2.3.0/async_durable_execution/_operation/child.py +47 -0
  19. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/flow.py +34 -8
  20. async_durable_execution-2.3.0/async_durable_execution/_operation/invoke.py +50 -0
  21. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/map.py +28 -7
  22. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/parallel.py +244 -47
  23. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/recurse.py +13 -8
  24. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/replay_safe.py +1 -1
  25. async_durable_execution-2.3.0/async_durable_execution/_operation/step.py +56 -0
  26. async_durable_execution-2.3.0/async_durable_execution/_operation/wait.py +40 -0
  27. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/wait_for_callback.py +60 -4
  28. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/wait_for_condition.py +72 -38
  29. {async_durable_execution-2.2.0/async_durable_execution/_extension → async_durable_execution-2.3.0/async_durable_execution/_operation}/with_retry.py +41 -3
  30. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/base.py +32 -2
  31. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/callback.py +13 -36
  32. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/child.py +10 -10
  33. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/invoke.py +11 -35
  34. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/step.py +301 -34
  35. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/wait.py +4 -33
  36. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/cloud/__init__.py +2 -20
  37. async_durable_execution-2.3.0/async_durable_execution/_runner/exceptions.py +55 -0
  38. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/__init__.py +7 -8
  39. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/execution.py +1 -100
  40. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/executor.py +6 -76
  41. async_durable_execution-2.3.0/async_durable_execution/_runner/local/model.py +205 -0
  42. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processor.py +1 -1
  43. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/base.py +11 -2
  44. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/step.py +25 -10
  45. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/scheduler.py +9 -35
  46. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/model.py +199 -885
  47. async_durable_execution-2.3.0/async_durable_execution/extension.py +701 -0
  48. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/pyproject.toml +9 -3
  49. async_durable_execution-2.2.0/async_durable_execution/_extension/__init__.py +0 -1
  50. async_durable_execution-2.2.0/async_durable_execution/_runner/exceptions.py +0 -276
  51. async_durable_execution-2.2.0/async_durable_execution/_runner/local/model.py +0 -275
  52. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/.gitignore +0 -0
  53. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/LICENSE +0 -0
  54. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/NOTICE +0 -0
  55. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/.gitignore +0 -0
  56. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_core/task.py +0 -0
  57. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_primitive/__init__.py +0 -0
  58. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/__init__.py +0 -0
  59. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/__init__.py +0 -0
  60. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/callback.py +0 -0
  61. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/context.py +0 -0
  62. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/execution.py +0 -0
  63. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/invoke.py +0 -0
  64. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/processors/wait.py +0 -0
  65. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/local/time_scale.py +0 -0
  66. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/_runner/py.typed +0 -0
  67. {async_durable_execution-2.2.0 → async_durable_execution-2.3.0}/async_durable_execution/py.typed +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: async-durable-execution
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Community-maintained durable execution SDK for AWS Lambda in Python
5
5
  Project-URL: Documentation, https://github.com/zhongkechen/async-durable-execution#readme
6
6
  Project-URL: Issues, https://github.com/zhongkechen/async-durable-execution/issues
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: 3.15
20
21
  Classifier: Programming Language :: Python :: Implementation :: CPython
21
22
  Classifier: Programming Language :: Python :: Implementation :: PyPy
22
23
  Requires-Python: >=3.10
@@ -72,6 +73,7 @@ composition, and APIs designed for modern Python applications.
72
73
 
73
74
  - **[Async-first durable code](https://zhongkechen.github.io/async-durable-execution/official-python-sdk-comparison.html#programming-model)** - Compared with the official AWS SDK, user-provided durable handlers, steps, child contexts, `flow` nodes, callback submitters, map item functions, parallel branches, and wait-for-condition checks are written with `async def`.
74
75
  - **[Operations not available in the official SDK](https://zhongkechen.github.io/async-durable-execution/api/extension/replay_safe.html)** - This SDK adds [replay-safe helpers](https://zhongkechen.github.io/async-durable-execution/api/extension/replay_safe.html) (`random()`, `now()`, `timestamp()`, and `uuid()`) and [durable self-invocation](https://zhongkechen.github.io/async-durable-execution/api/extension/recurse.html) (`recurse()`).
76
+ - **[Stable custom operation SPI](https://zhongkechen.github.io/async-durable-execution/custom-operations.html)** - Third-party packages can reserve opaque deterministic primitive identities, use custom subtypes, and build stateful replay-safe operations without importing SDK internals.
75
77
  - **[Declarative DAG workflows](https://zhongkechen.github.io/async-durable-execution/api/extension/flow.html#quick-start)** - Define acyclic workflows with typed node inputs, inferred or conditional dependencies, failure routes, and durable operations inside each node. The SDK validates the graph before execution and skips nodes that are not required by the selected outputs.
76
78
  - **[Background operation tasks](https://zhongkechen.github.io/async-durable-execution/advanced-usage.html#background-operation-tasks)** - Durable operations such as `step(...)`, `wait(...)`, `invoke(...)`, `recurse(...)`, `run_in_child_context(...)`, and `flow(...)` return `asyncio.Task` objects, so independent operations can run in the background and be awaited together with `asyncio.gather` without using `parallel()` or `map()`.
77
79
  - **[Pythonic operation parameters](https://zhongkechen.github.io/async-durable-execution/migrating-from-official-python-sdk.html#api-mapping)** - Operations use direct keyword arguments, standard Python types such as `datetime.timedelta`, and keyword-only names instead of configuration wrapper objects.
@@ -247,6 +249,7 @@ For the developer workflow to run or deploy example integration tests, see the [
247
249
  - **[Deploy and Invoke](https://zhongkechen.github.io/async-durable-execution/deployment.html)** - Configure IAM, qualified function identifiers, invocations, CloudFormation, and SAM
248
250
  - **[Using Synchronous Code](https://zhongkechen.github.io/async-durable-execution/using-synchronous-code.html)** - Wrap existing synchronous business logic and blocking clients safely
249
251
  - **[Advanced Usage](https://zhongkechen.github.io/async-durable-execution/advanced-usage.html)** - Explore background operation tasks, batch completion conditions, Lambda clients, and Lambda layers
252
+ - **[Custom Durable Operations](https://zhongkechen.github.io/async-durable-execution/custom-operations.html)** - Build third-party durable operation libraries on the stable extension-author interface
250
253
  - **[Runner Architecture](https://zhongkechen.github.io/async-durable-execution/runner-architecture.html)** - Local and cloud runner execution flow, components, and diagrams
251
254
  - **[Contributing Guide](https://github.com/zhongkechen/async-durable-execution/blob/main/CONTRIBUTING.md)** - Development workflow, Hatch commands, testing, and pull request guidance
252
255
 
@@ -43,6 +43,7 @@ composition, and APIs designed for modern Python applications.
43
43
 
44
44
  - **[Async-first durable code](https://zhongkechen.github.io/async-durable-execution/official-python-sdk-comparison.html#programming-model)** - Compared with the official AWS SDK, user-provided durable handlers, steps, child contexts, `flow` nodes, callback submitters, map item functions, parallel branches, and wait-for-condition checks are written with `async def`.
45
45
  - **[Operations not available in the official SDK](https://zhongkechen.github.io/async-durable-execution/api/extension/replay_safe.html)** - This SDK adds [replay-safe helpers](https://zhongkechen.github.io/async-durable-execution/api/extension/replay_safe.html) (`random()`, `now()`, `timestamp()`, and `uuid()`) and [durable self-invocation](https://zhongkechen.github.io/async-durable-execution/api/extension/recurse.html) (`recurse()`).
46
+ - **[Stable custom operation SPI](https://zhongkechen.github.io/async-durable-execution/custom-operations.html)** - Third-party packages can reserve opaque deterministic primitive identities, use custom subtypes, and build stateful replay-safe operations without importing SDK internals.
46
47
  - **[Declarative DAG workflows](https://zhongkechen.github.io/async-durable-execution/api/extension/flow.html#quick-start)** - Define acyclic workflows with typed node inputs, inferred or conditional dependencies, failure routes, and durable operations inside each node. The SDK validates the graph before execution and skips nodes that are not required by the selected outputs.
47
48
  - **[Background operation tasks](https://zhongkechen.github.io/async-durable-execution/advanced-usage.html#background-operation-tasks)** - Durable operations such as `step(...)`, `wait(...)`, `invoke(...)`, `recurse(...)`, `run_in_child_context(...)`, and `flow(...)` return `asyncio.Task` objects, so independent operations can run in the background and be awaited together with `asyncio.gather` without using `parallel()` or `map()`.
48
49
  - **[Pythonic operation parameters](https://zhongkechen.github.io/async-durable-execution/migrating-from-official-python-sdk.html#api-mapping)** - Operations use direct keyword arguments, standard Python types such as `datetime.timedelta`, and keyword-only names instead of configuration wrapper objects.
@@ -218,6 +219,7 @@ For the developer workflow to run or deploy example integration tests, see the [
218
219
  - **[Deploy and Invoke](https://zhongkechen.github.io/async-durable-execution/deployment.html)** - Configure IAM, qualified function identifiers, invocations, CloudFormation, and SAM
219
220
  - **[Using Synchronous Code](https://zhongkechen.github.io/async-durable-execution/using-synchronous-code.html)** - Wrap existing synchronous business logic and blocking clients safely
220
221
  - **[Advanced Usage](https://zhongkechen.github.io/async-durable-execution/advanced-usage.html)** - Explore background operation tasks, batch completion conditions, Lambda clients, and Lambda layers
222
+ - **[Custom Durable Operations](https://zhongkechen.github.io/async-durable-execution/custom-operations.html)** - Build third-party durable operation libraries on the stable extension-author interface
221
223
  - **[Runner Architecture](https://zhongkechen.github.io/async-durable-execution/runner-architecture.html)** - Local and cloud runner execution flow, components, and diagrams
222
224
  - **[Contributing Guide](https://github.com/zhongkechen/async-durable-execution/blob/main/CONTRIBUTING.md)** - Development workflow, Hatch commands, testing, and pull request guidance
223
225
 
@@ -2,4 +2,4 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- __version__ = "2.2.0"
5
+ __version__ = "2.3.0"
@@ -36,13 +36,13 @@ from ._core import (
36
36
  )
37
37
 
38
38
  # Durable operations
39
- from ._extension.with_retry import (
39
+ from ._operation.with_retry import (
40
40
  WithRetryContext,
41
41
  get_with_retry_context,
42
42
  with_retry,
43
43
  )
44
- from ._extension.map import MapItemContext, get_map_item_context, map
45
- from ._extension.flow import (
44
+ from ._operation.map import MapItemContext, get_map_item_context, map
45
+ from ._operation.flow import (
46
46
  FlowDefinitionError,
47
47
  FlowExecutionError,
48
48
  FlowNode,
@@ -56,7 +56,7 @@ from ._extension.flow import (
56
56
  get_node_context,
57
57
  node,
58
58
  )
59
- from ._extension.parallel import (
59
+ from ._operation.parallel import (
60
60
  BatchItem,
61
61
  BatchItemStatus,
62
62
  BatchResult,
@@ -66,38 +66,46 @@ from ._extension.parallel import (
66
66
  CompletionStatus,
67
67
  NestingType,
68
68
  )
69
- from ._extension.wait_for_condition import (
69
+ from ._operation.wait_for_condition import (
70
70
  PollingStrategy,
71
71
  WaitForConditionCheckContext,
72
72
  WaitForConditionError,
73
73
  get_wait_for_condition_check_context,
74
74
  wait_for_condition,
75
75
  )
76
- from ._primitive.invoke import invoke
77
- from ._extension.recurse import recurse
78
- from ._extension.parallel import (
76
+ from ._operation.invoke import invoke
77
+ from ._operation.recurse import recurse
78
+ from ._operation.parallel import (
79
79
  parallel,
80
80
  )
81
- from ._primitive.callback import (
81
+ from ._operation.callback import (
82
82
  Callback,
83
83
  CallbackError,
84
84
  create_callback,
85
85
  )
86
- from ._extension.wait_for_callback import (
86
+ from ._operation.wait_for_callback import (
87
87
  WaitForCallbackContext,
88
88
  get_wait_for_callback_context,
89
89
  wait_for_callback,
90
90
  )
91
- from ._primitive.child import SummaryGenerator, run_in_child_context
92
- from ._primitive.step import (
91
+ from ._operation.child import SummaryGenerator, run_in_child_context
92
+ from ._operation.step import (
93
93
  StepContext,
94
94
  StepInterruptedError,
95
95
  StepSemantics,
96
96
  get_step_context,
97
97
  step,
98
98
  )
99
- from ._extension.replay_safe import now, random, timestamp, uuid
100
- from ._primitive.wait import wait
99
+ from ._operation.replay_safe import now, random, timestamp, uuid
100
+ from ._operation.wait import wait
101
+ from .extension import (
102
+ ExtensionContext,
103
+ ExtensionOperation,
104
+ ExtensionStepFunction,
105
+ ExtensionStepResult,
106
+ ExtensionStepRetryStrategy,
107
+ get_extension_context,
108
+ )
101
109
  from ._runner import (
102
110
  DurableFunctionCloudTestRunner,
103
111
  DurableFunctionLocalTestRunner,
@@ -119,6 +127,7 @@ __all__ = [
119
127
  "flow",
120
128
  "get_current_context",
121
129
  "get_durable_context",
130
+ "get_extension_context",
122
131
  "get_map_item_context",
123
132
  "get_node_context",
124
133
  "get_serdes_context",
@@ -159,6 +168,11 @@ __all__ = [
159
168
  "DurableExecutionsError",
160
169
  "ErrorObject",
161
170
  "ExecutionError",
171
+ "ExtensionContext",
172
+ "ExtensionOperation",
173
+ "ExtensionStepFunction",
174
+ "ExtensionStepResult",
175
+ "ExtensionStepRetryStrategy",
162
176
  "ExtendedTypeSerDes",
163
177
  "FlowDefinitionError",
164
178
  "FlowExecutionError",
@@ -15,6 +15,7 @@ from .config import (
15
15
  from .context import (
16
16
  DurableContext,
17
17
  OperationContext,
18
+ SerDesContext,
18
19
  bind_current_context,
19
20
  bind_durable_definition,
20
21
  ensure_durable_operations_allowed,
@@ -50,6 +51,7 @@ from .execution import (
50
51
  durable_execution,
51
52
  )
52
53
  from .models import (
54
+ BotoSerializableModel,
53
55
  CallbackDetails,
54
56
  CallbackOptions,
55
57
  CallbackTimeoutType,
@@ -64,21 +66,21 @@ from .models import (
64
66
  ExecutionDetails,
65
67
  InvocationStatus,
66
68
  LambdaContext,
69
+ MappingModel,
67
70
  Operation,
68
71
  OperationAction,
69
72
  OperationIdentifier,
70
73
  OperationPayload,
71
74
  OperationStatus,
72
75
  OperationSubType,
76
+ OperationSubTypeValue,
73
77
  OperationType,
74
78
  OperationUpdate,
75
- SerializableModel,
76
79
  StateOutput,
77
80
  StepDetails,
78
81
  TimestampConverter,
79
82
  WaitDetails,
80
83
  WaitOptions,
81
- _metadata,
82
84
  )
83
85
  from .serdes import (
84
86
  DEFAULT_JSON_SERDES,
@@ -87,7 +89,6 @@ from .serdes import (
87
89
  JsonSerDes,
88
90
  PassThroughSerDes,
89
91
  SerDes,
90
- SerDesContext,
91
92
  TypeCodecExtension,
92
93
  TypeTag,
93
94
  deserialize,
@@ -25,13 +25,9 @@ logger = logging.getLogger(__name__)
25
25
  class LambdaApiClient(Protocol):
26
26
  """Minimal Lambda client surface needed by durable execution."""
27
27
 
28
- def checkpoint_durable_execution(
29
- self, **kwargs: Any
30
- ) -> Mapping[str, Any]: ... # pragma: no cover
28
+ def checkpoint_durable_execution(self, **kwargs: Any) -> Mapping[str, Any]: ...
31
29
 
32
- def get_durable_execution_state(
33
- self, **kwargs: Any
34
- ) -> Mapping[str, Any]: ... # pragma: no cover
30
+ def get_durable_execution_state(self, **kwargs: Any) -> Mapping[str, Any]: ...
35
31
 
36
32
 
37
33
  class AsyncLambdaApiClient(Protocol):
@@ -39,11 +35,11 @@ class AsyncLambdaApiClient(Protocol):
39
35
 
40
36
  def checkpoint_durable_execution(
41
37
  self, **kwargs: Any
42
- ) -> Awaitable[Mapping[str, Any]]: ... # pragma: no cover
38
+ ) -> Awaitable[Mapping[str, Any]]: ...
43
39
 
44
40
  def get_durable_execution_state(
45
41
  self, **kwargs: Any
46
- ) -> Awaitable[Mapping[str, Any]]: ... # pragma: no cover
42
+ ) -> Awaitable[Mapping[str, Any]]: ...
47
43
 
48
44
 
49
45
  class DurableServiceClient(Protocol):
@@ -55,7 +51,7 @@ class DurableServiceClient(Protocol):
55
51
  checkpoint_token: str,
56
52
  updates: list[OperationUpdate],
57
53
  client_token: str | None,
58
- ) -> CheckpointOutput: ... # pragma: no cover
54
+ ) -> CheckpointOutput: ...
59
55
 
60
56
  async def get_execution_state(
61
57
  self,
@@ -63,7 +59,7 @@ class DurableServiceClient(Protocol):
63
59
  checkpoint_token: str,
64
60
  next_marker: str,
65
61
  max_items: int = 1000,
66
- ) -> StateOutput: ... # pragma: no cover
62
+ ) -> StateOutput: ...
67
63
 
68
64
 
69
65
  def _create_client_config() -> Config:
@@ -85,7 +85,7 @@ class _DelayStrategy:
85
85
  jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
86
86
  increment: Duration | None = None
87
87
 
88
- def __post_init__(self):
88
+ def __post_init__(self) -> None:
89
89
  self.initial_delay = duration_to_seconds(self.initial_delay, "initial_delay")
90
90
  self.max_delay = duration_to_seconds(self.max_delay, "max_delay")
91
91
  if self.increment is not None:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import functools
4
4
  import hashlib
5
5
  import logging
6
+ from collections.abc import Iterator
6
7
  from contextlib import contextmanager
7
8
  from contextvars import ContextVar, Token
8
9
  from dataclasses import dataclass
@@ -18,19 +19,32 @@ from .models import (
18
19
 
19
20
  if TYPE_CHECKING:
20
21
  from .models import LambdaContext
21
- from .serdes import SerDesContext
22
22
  from .state import ExecutionState
23
23
 
24
24
 
25
25
  logger = logging.getLogger(__name__)
26
26
 
27
27
 
28
+ @dataclass(frozen=True)
29
+ class SerDesContext:
30
+ """Context for serialization operations."""
31
+
32
+ operation_id: str = ""
33
+ durable_execution_arn: str = ""
34
+ recursive_level: int = 0
35
+
36
+
28
37
  class OperationIdGenerator:
29
38
  """Generate deterministic operation ids within a durable execution scope."""
30
39
 
31
40
  def __init__(self, prefix: str | None) -> None:
32
41
  self._prefix = prefix
33
42
  self._counter = 0
43
+ self._claimed_local_ids: set[str] = set()
44
+ self._unconsumed_reservations: dict[str, bool] = {}
45
+ self._unconsumed_checkpoint_count = 0
46
+ self._reservation_selection_started = False
47
+ self._replay_frontier_pending = False
34
48
 
35
49
  def increment(self) -> int:
36
50
  self._counter += 1
@@ -39,16 +53,89 @@ class OperationIdGenerator:
39
53
  def get_current(self) -> int:
40
54
  return self._counter
41
55
 
42
- def _create_step_id_for_logical_step(self, step: int) -> str:
43
- """Generate the stable operation id for a logical step."""
56
+ def _create_id(self, value: str) -> str:
57
+ """Hash one context-local identity value."""
44
58
  prefix = self._prefix
45
- step_id = f"{prefix}-{step}" if prefix else str(step)
59
+ step_id = f"{prefix}-{value}" if prefix else value
46
60
  return hashlib.blake2b(step_id.encode()).hexdigest()[:64]
47
61
 
62
+ def _create_id_for_local_id(self, local_id: str) -> str:
63
+ """Generate an id in the caller-provided local-id namespace."""
64
+ return self._create_id(f"local:{local_id}")
65
+
66
+ def _create_step_id_for_logical_step(self, step: int) -> str:
67
+ """Generate the stable operation id for a logical step."""
68
+ return self._create_id(str(step))
69
+
48
70
  def create_step_id(self) -> str:
49
71
  """Generate an operation id and advance the logical step counter."""
50
72
  return self._create_step_id_for_logical_step(self.increment())
51
73
 
74
+ def create_step_id_for_local_id(self, local_id: str) -> str:
75
+ """Generate an operation id from a stable caller-provided local id."""
76
+ if not isinstance(local_id, str):
77
+ msg = "local_operation_id must be a string"
78
+ raise TypeError(msg)
79
+ if not local_id.strip():
80
+ msg = "local_operation_id must not be blank"
81
+ raise ValueError(msg)
82
+ if self._reservation_selection_started:
83
+ msg = (
84
+ "local_operation_id reservations must be created before any "
85
+ "reserved operation is selected"
86
+ )
87
+ raise RuntimeError(msg)
88
+ if local_id in self._claimed_local_ids:
89
+ msg = f"local_operation_id is already reserved: {local_id}"
90
+ raise ValueError(msg)
91
+
92
+ self._claimed_local_ids.add(local_id)
93
+ return self._create_id_for_local_id(local_id)
94
+
95
+ def _register_reservation(
96
+ self,
97
+ operation_id: str,
98
+ *,
99
+ has_checkpoint: bool,
100
+ ) -> None:
101
+ """Track an allocated reservation until workflow code selects it."""
102
+ previous = self._unconsumed_reservations.get(operation_id)
103
+ if operation_id in self._unconsumed_reservations:
104
+ if previous == has_checkpoint:
105
+ return
106
+ if previous:
107
+ self._unconsumed_checkpoint_count -= 1
108
+ self._unconsumed_reservations[operation_id] = has_checkpoint
109
+ if has_checkpoint:
110
+ self._unconsumed_checkpoint_count += 1
111
+
112
+ def _consume_reservation(self, operation_id: str) -> None:
113
+ """Discard a reservation after workflow code selects it."""
114
+ self._mark_reservation_selected()
115
+ has_checkpoint = self._unconsumed_reservations.pop(operation_id, False)
116
+ if has_checkpoint:
117
+ self._unconsumed_checkpoint_count -= 1
118
+
119
+ def _mark_reservation_selected(self) -> None:
120
+ """Prevent explicit local ids from being registered after selection."""
121
+ self._reservation_selection_started = True
122
+
123
+ def _has_unconsumed_checkpoint(self) -> bool:
124
+ """Return whether any allocated reservation still has replay history."""
125
+ return self._unconsumed_checkpoint_count > 0
126
+
127
+ def _mark_replay_frontier(self) -> None:
128
+ """Remember that replay ended immediately before a virtual scope."""
129
+ self._replay_frontier_pending = True
130
+
131
+ def _clear_replay_frontier(self) -> None:
132
+ """Clear a replay frontier after the next operation boundary is known."""
133
+ self._replay_frontier_pending = False
134
+
135
+ def _is_replay_frontier_pending(self) -> bool:
136
+ """Return whether a virtual scope may still contain flattened history."""
137
+ return self._replay_frontier_pending
138
+
52
139
 
53
140
  @dataclass(frozen=True)
54
141
  class OperationContext:
@@ -113,7 +200,11 @@ class DurableContext(OperationContext):
113
200
  )
114
201
 
115
202
  def create_child_context(
116
- self, operation_id: str, *, is_virtual: bool = False
203
+ self,
204
+ operation_id: str,
205
+ *,
206
+ is_virtual: bool = False,
207
+ replaying: bool | None = None,
117
208
  ) -> DurableContext:
118
209
  """Create a child context for the given operation."""
119
210
  child_parent_id = self.parent_id if is_virtual else operation_id
@@ -130,7 +221,7 @@ class DurableContext(OperationContext):
130
221
  parent_id=child_parent_id,
131
222
  ),
132
223
  step_id_prefix=operation_id,
133
- replaying=self.is_replaying(),
224
+ replaying=self.is_replaying() if replaying is None else replaying,
134
225
  )
135
226
 
136
227
  def is_replaying(self) -> bool:
@@ -139,6 +230,18 @@ class DurableContext(OperationContext):
139
230
 
140
231
  def _set_replay_status_new(self) -> None:
141
232
  object.__setattr__(self, "replaying", False)
233
+ self.step_counter._clear_replay_frontier() # noqa: SLF001
234
+
235
+ def _set_replay_status_frontier(self) -> None:
236
+ """End parent replay while retaining a snapshot for a virtual child."""
237
+ object.__setattr__(self, "replaying", False)
238
+ self.step_counter._mark_replay_frontier() # noqa: SLF001
239
+
240
+ def _virtual_child_replay_snapshot(self) -> bool:
241
+ """Return replay state including flattened history past the frontier."""
242
+ return (
243
+ self.is_replaying() or self.step_counter._is_replay_frontier_pending() # noqa: SLF001
244
+ )
142
245
 
143
246
  def _peek_next_operation_id(self) -> str:
144
247
  return self.step_counter._create_step_id_for_logical_step( # noqa: SLF001
@@ -146,13 +249,24 @@ class DurableContext(OperationContext):
146
249
  )
147
250
 
148
251
  def _next_operation_result(self) -> Operation | None:
149
- return self.execution_state.operations.get(self._peek_next_operation_id())
252
+ return self._operation_result(self._peek_next_operation_id())
253
+
254
+ def _operation_result(self, operation_id: str) -> Operation | None:
255
+ return self.execution_state.operations.get(operation_id)
150
256
 
151
257
  def _next_operation_exists(self) -> bool:
152
258
  return self._next_operation_result() is not None
153
259
 
260
+ def _next_reserved_or_sequential_operation_exists(self) -> bool:
261
+ if self.step_counter._has_unconsumed_checkpoint(): # noqa: SLF001
262
+ return True
263
+ return self._next_operation_exists()
264
+
154
265
  def _next_operation_is_terminal_checkpoint(self) -> bool:
155
- operation = self._next_operation_result()
266
+ return self._operation_is_terminal_checkpoint(self._peek_next_operation_id())
267
+
268
+ def _operation_is_terminal_checkpoint(self, operation_id: str) -> bool:
269
+ operation = self._operation_result(operation_id)
156
270
  if operation is None:
157
271
  return False
158
272
  return operation.status in {
@@ -164,20 +278,39 @@ class DurableContext(OperationContext):
164
278
  }
165
279
 
166
280
  @contextmanager
167
- def _replay_aware(self, *, executes_user_code: bool = False):
168
- """Update this context's replay status around one durable operation."""
281
+ def _replay_aware(
282
+ self,
283
+ *,
284
+ operation_id: str | None = None,
285
+ executes_user_code: bool = False,
286
+ consume_reservation: bool = True,
287
+ ) -> Iterator[None]:
288
+ """Update replay status around one durable operation.
289
+
290
+ `operation_id` identifies an operation that was allocated before entering
291
+ this scope. Pre-allocated reservations are tracked independently from the
292
+ sequential counter so launch order does not end replay prematurely.
293
+ """
169
294
  was_replaying = self.is_replaying()
170
- next_exists = was_replaying and self._next_operation_exists()
171
- next_terminal = was_replaying and self._next_operation_is_terminal_checkpoint()
295
+ self.step_counter._clear_replay_frontier() # noqa: SLF001
296
+ current_operation_id = operation_id or self._peek_next_operation_id()
297
+ if operation_id is not None and consume_reservation:
298
+ self.step_counter._consume_reservation(operation_id) # noqa: SLF001
299
+ current_exists = was_replaying and (
300
+ self._operation_result(current_operation_id) is not None
301
+ )
302
+ current_terminal = was_replaying and self._operation_is_terminal_checkpoint(
303
+ current_operation_id
304
+ )
172
305
  flip_after = (
173
306
  was_replaying
174
307
  and not executes_user_code
175
- and next_exists
176
- and not next_terminal
308
+ and current_exists
309
+ and not current_terminal
177
310
  )
178
311
 
179
312
  if was_replaying and (
180
- not next_exists or (executes_user_code and not next_terminal)
313
+ not current_exists or (executes_user_code and not current_terminal)
181
314
  ):
182
315
  self._set_replay_status_new()
183
316
 
@@ -185,9 +318,15 @@ class DurableContext(OperationContext):
185
318
  yield
186
319
  finally:
187
320
  if flip_after:
188
- self._set_replay_status_new()
189
- elif self.is_replaying() and not self._next_operation_exists():
190
- self._set_replay_status_new()
321
+ self._set_replay_status_frontier()
322
+ elif self.is_replaying():
323
+ next_operation_exists = (
324
+ self._next_reserved_or_sequential_operation_exists()
325
+ if operation_id is not None
326
+ else self._next_operation_exists()
327
+ )
328
+ if not next_operation_exists:
329
+ self._set_replay_status_frontier()
191
330
 
192
331
 
193
332
  _current_context: ContextVar = ContextVar(
@@ -215,7 +354,7 @@ def ensure_durable_operations_allowed(operation_name: str) -> None:
215
354
 
216
355
 
217
356
  @contextmanager
218
- def bind_durable_definition(operation_name: str):
357
+ def bind_durable_definition(operation_name: str) -> Iterator[None]:
219
358
  """Mark a synchronous definition phase in the current context."""
220
359
  token = _durable_definition_operation.set(operation_name)
221
360
  try:
@@ -234,7 +373,7 @@ def reset_current_context(token: Token) -> None:
234
373
  _current_context.reset(token)
235
374
 
236
375
 
237
- def get_current_context():
376
+ def get_current_context() -> OperationContext | SerDesContext:
238
377
  """Return the currently active durable execution context.
239
378
 
240
379
  Raises:
@@ -271,7 +410,9 @@ def get_durable_context() -> DurableContext:
271
410
 
272
411
 
273
412
  @contextmanager
274
- def bind_current_context(context: OperationContext | SerDesContext):
413
+ def bind_current_context(
414
+ context: OperationContext | SerDesContext,
415
+ ) -> Iterator[None]:
275
416
  """Temporarily bind the supplied durable context while invoking user code."""
276
417
  token = set_current_context(context)
277
418
  try:
@@ -238,7 +238,7 @@ class DurableExecutionsError(Exception):
238
238
  class UnrecoverableError(DurableExecutionsError):
239
239
  """Base class for errors that terminate execution."""
240
240
 
241
- def __init__(self, message: str, termination_reason: TerminationReason):
241
+ def __init__(self, message: str, termination_reason: TerminationReason) -> None:
242
242
  super().__init__(message)
243
243
  self.termination_reason = termination_reason
244
244
 
@@ -250,7 +250,7 @@ class ExecutionError(UnrecoverableError):
250
250
  self,
251
251
  message: str,
252
252
  termination_reason: TerminationReason = TerminationReason.EXECUTION_ERROR,
253
- ):
253
+ ) -> None:
254
254
  super().__init__(message, termination_reason)
255
255
 
256
256
 
@@ -263,7 +263,7 @@ class _RestoredExecutionError(ExecutionError):
263
263
  *,
264
264
  original_error_type: str,
265
265
  termination_reason: TerminationReason,
266
- ):
266
+ ) -> None:
267
267
  super().__init__(message, termination_reason)
268
268
  self.original_error_type = original_error_type
269
269
 
@@ -275,7 +275,7 @@ class InvocationError(UnrecoverableError):
275
275
  self,
276
276
  message: str,
277
277
  termination_reason: TerminationReason = TerminationReason.INVOCATION_ERROR,
278
- ):
278
+ ) -> None:
279
279
  super().__init__(message, termination_reason)
280
280
 
281
281
  def is_retryable(self) -> bool:
@@ -318,7 +318,7 @@ class BotoClientError(InvocationError):
318
318
  error: AwsErrorObj | None = None,
319
319
  response_metadata: AwsErrorMetadata | None = None,
320
320
  termination_reason=TerminationReason.INVOCATION_ERROR,
321
- ):
321
+ ) -> None:
322
322
  super().__init__(message=message, termination_reason=termination_reason)
323
323
  self.error: AwsErrorObj | None = error
324
324
  self.response_metadata: AwsErrorMetadata | None = response_metadata
@@ -409,7 +409,7 @@ class _RestoredInvocationError(InvocationError):
409
409
  error_category: DurableApiErrorCategory | None = None,
410
410
  error: AwsErrorObj | None = None,
411
411
  response_metadata: AwsErrorMetadata | None = None,
412
- ):
412
+ ) -> None:
413
413
  super().__init__(message, termination_reason)
414
414
  self.original_error_type = original_error_type
415
415
  self.retryable = retryable
@@ -643,7 +643,7 @@ def _restore_sdk_control_error(
643
643
  class NonDeterministicExecutionError(ExecutionError):
644
644
  """Error when execution is non-deterministic."""
645
645
 
646
- def __init__(self, message: str, step_id: str | None = None):
646
+ def __init__(self, message: str, step_id: str | None = None) -> None:
647
647
  super().__init__(message, TerminationReason.NON_DETERMINISTIC_EXECUTION)
648
648
  self.step_id = step_id
649
649
 
@@ -657,7 +657,7 @@ class CheckpointError(BotoClientError):
657
657
  error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
658
658
  error: AwsErrorObj | None = None,
659
659
  response_metadata: AwsErrorMetadata | None = None,
660
- ):
660
+ ) -> None:
661
661
  super().__init__(
662
662
  message,
663
663
  error_category,
@@ -680,7 +680,7 @@ class GetExecutionStateError(BotoClientError):
680
680
  error_category: DurableApiErrorCategory = DurableApiErrorCategory.INVOCATION,
681
681
  error: AwsErrorObj | None = None,
682
682
  response_metadata: AwsErrorMetadata | None = None,
683
- ):
683
+ ) -> None:
684
684
  super().__init__(
685
685
  message,
686
686
  error_category,
@@ -740,7 +740,7 @@ class BackgroundThreadError(BaseException):
740
740
  source_exception: The original exception from the background thread
741
741
  """
742
742
 
743
- def __init__(self, message: str, source_exception: Exception):
743
+ def __init__(self, message: str, source_exception: Exception) -> None:
744
744
  super().__init__(message)
745
745
  self.source_exception = source_exception
746
746
 
@@ -752,7 +752,7 @@ class OrphanedChildException(BaseException):
752
752
  with broad exception handlers like ``except Exception``.
753
753
  """
754
754
 
755
- def __init__(self, message: str, operation_id: str):
755
+ def __init__(self, message: str, operation_id: str) -> None:
756
756
  super().__init__(message)
757
757
  self.operation_id = operation_id
758
758
 
@@ -764,7 +764,7 @@ class SuspendExecution(BaseException):
764
764
  KeyboardInterrupt or SystemExit.
765
765
  """
766
766
 
767
- def __init__(self, message: str):
767
+ def __init__(self, message: str) -> None:
768
768
  super().__init__(message)
769
769
 
770
770
 
@@ -777,7 +777,7 @@ class TimedSuspendExecution(SuspendExecution):
777
777
  scheduled_timestamp (float): Unix timestamp in seconds at which to resume.
778
778
  """
779
779
 
780
- def __init__(self, message: str, scheduled_timestamp: float):
780
+ def __init__(self, message: str, scheduled_timestamp: float) -> None:
781
781
  super().__init__(message)
782
782
  self.scheduled_timestamp = scheduled_timestamp
783
783