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.
- aws_durable_execution_sdk_python/.gitignore +0 -0
- aws_durable_execution_sdk_python/__about__.py +4 -0
- aws_durable_execution_sdk_python/__init__.py +34 -0
- aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
- aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
- aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
- aws_durable_execution_sdk_python/concurrency/models.py +469 -0
- aws_durable_execution_sdk_python/config.py +499 -0
- aws_durable_execution_sdk_python/context.py +551 -0
- aws_durable_execution_sdk_python/exceptions.py +374 -0
- aws_durable_execution_sdk_python/execution.py +428 -0
- aws_durable_execution_sdk_python/identifier.py +14 -0
- aws_durable_execution_sdk_python/lambda_service.py +1034 -0
- aws_durable_execution_sdk_python/logger.py +131 -0
- aws_durable_execution_sdk_python/operation/__init__.py +1 -0
- aws_durable_execution_sdk_python/operation/callback.py +123 -0
- aws_durable_execution_sdk_python/operation/child.py +162 -0
- aws_durable_execution_sdk_python/operation/invoke.py +119 -0
- aws_durable_execution_sdk_python/operation/map.py +137 -0
- aws_durable_execution_sdk_python/operation/parallel.py +122 -0
- aws_durable_execution_sdk_python/operation/step.py +269 -0
- aws_durable_execution_sdk_python/operation/wait.py +53 -0
- aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
- aws_durable_execution_sdk_python/py.typed +1 -0
- aws_durable_execution_sdk_python/retries.py +174 -0
- aws_durable_execution_sdk_python/serdes.py +502 -0
- aws_durable_execution_sdk_python/state.py +790 -0
- aws_durable_execution_sdk_python/suspend.py +84 -0
- aws_durable_execution_sdk_python/threading.py +222 -0
- aws_durable_execution_sdk_python/types.py +180 -0
- aws_durable_execution_sdk_python/waits.py +130 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Implementation for Durable Parallel operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
9
|
+
|
|
10
|
+
from aws_durable_execution_sdk_python.concurrency.executor import ConcurrentExecutor
|
|
11
|
+
from aws_durable_execution_sdk_python.concurrency.models import Executable
|
|
12
|
+
from aws_durable_execution_sdk_python.config import ParallelConfig
|
|
13
|
+
from aws_durable_execution_sdk_python.lambda_service import OperationSubType
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from aws_durable_execution_sdk_python.concurrency.models import BatchResult
|
|
17
|
+
from aws_durable_execution_sdk_python.context import DurableContext
|
|
18
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
19
|
+
from aws_durable_execution_sdk_python.serdes import SerDes
|
|
20
|
+
from aws_durable_execution_sdk_python.state import ExecutionState
|
|
21
|
+
from aws_durable_execution_sdk_python.types import SummaryGenerator
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Result type
|
|
26
|
+
R = TypeVar("R")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ParallelExecutor(ConcurrentExecutor[Callable, R]):
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
executables: list[Executable[Callable]],
|
|
33
|
+
max_concurrency: int | None,
|
|
34
|
+
completion_config,
|
|
35
|
+
top_level_sub_type: OperationSubType,
|
|
36
|
+
iteration_sub_type: OperationSubType,
|
|
37
|
+
name_prefix: str,
|
|
38
|
+
serdes: SerDes | None,
|
|
39
|
+
summary_generator: SummaryGenerator | None = None,
|
|
40
|
+
item_serdes: SerDes | None = None,
|
|
41
|
+
):
|
|
42
|
+
super().__init__(
|
|
43
|
+
executables=executables,
|
|
44
|
+
max_concurrency=max_concurrency,
|
|
45
|
+
completion_config=completion_config,
|
|
46
|
+
sub_type_top=top_level_sub_type,
|
|
47
|
+
sub_type_iteration=iteration_sub_type,
|
|
48
|
+
name_prefix=name_prefix,
|
|
49
|
+
serdes=serdes,
|
|
50
|
+
summary_generator=summary_generator,
|
|
51
|
+
item_serdes=item_serdes,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_callables(
|
|
56
|
+
cls,
|
|
57
|
+
callables: Sequence[Callable],
|
|
58
|
+
config: ParallelConfig,
|
|
59
|
+
) -> ParallelExecutor:
|
|
60
|
+
"""Create ParallelExecutor from a sequence of callables."""
|
|
61
|
+
executables: list[Executable[Callable]] = [
|
|
62
|
+
Executable(index=i, func=func) for i, func in enumerate(callables)
|
|
63
|
+
]
|
|
64
|
+
return cls(
|
|
65
|
+
executables=executables,
|
|
66
|
+
max_concurrency=config.max_concurrency,
|
|
67
|
+
completion_config=config.completion_config,
|
|
68
|
+
top_level_sub_type=OperationSubType.PARALLEL,
|
|
69
|
+
iteration_sub_type=OperationSubType.PARALLEL_BRANCH,
|
|
70
|
+
name_prefix="parallel-branch-",
|
|
71
|
+
serdes=config.serdes,
|
|
72
|
+
summary_generator=config.summary_generator,
|
|
73
|
+
item_serdes=config.item_serdes,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def execute_item(self, child_context, executable: Executable[Callable]) -> R: # noqa: PLR6301
|
|
77
|
+
logger.debug("🔀 Processing parallel branch: %s", executable.index)
|
|
78
|
+
result: R = executable.func(child_context)
|
|
79
|
+
logger.debug("✅ Processed parallel branch: %s", executable.index)
|
|
80
|
+
return result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parallel_handler(
|
|
84
|
+
callables: Sequence[Callable],
|
|
85
|
+
config: ParallelConfig | None,
|
|
86
|
+
execution_state: ExecutionState,
|
|
87
|
+
parallel_context: DurableContext,
|
|
88
|
+
operation_identifier: OperationIdentifier,
|
|
89
|
+
) -> BatchResult[R]:
|
|
90
|
+
"""Execute multiple operations in parallel."""
|
|
91
|
+
# Summary Generator Construction (matches TypeScript implementation):
|
|
92
|
+
# Construct the summary generator at the handler level, just like TypeScript does in parallel-handler.ts.
|
|
93
|
+
# This matches the pattern where handlers are responsible for configuring operation-specific behavior.
|
|
94
|
+
#
|
|
95
|
+
# See TypeScript reference: aws-durable-execution-sdk-js/src/handlers/parallel-handler/parallel-handler.ts (~line 112)
|
|
96
|
+
|
|
97
|
+
executor = ParallelExecutor.from_callables(
|
|
98
|
+
callables,
|
|
99
|
+
config or ParallelConfig(summary_generator=ParallelSummaryGenerator()),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
checkpoint = execution_state.get_checkpoint_result(
|
|
103
|
+
operation_identifier.operation_id
|
|
104
|
+
)
|
|
105
|
+
if checkpoint.is_succeeded():
|
|
106
|
+
return executor.replay(execution_state, parallel_context)
|
|
107
|
+
return executor.execute(execution_state, executor_context=parallel_context)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ParallelSummaryGenerator:
|
|
111
|
+
def __call__(self, result: BatchResult) -> str:
|
|
112
|
+
fields = {
|
|
113
|
+
"totalCount": result.total_count,
|
|
114
|
+
"successCount": result.success_count,
|
|
115
|
+
"failureCount": result.failure_count,
|
|
116
|
+
"completionReason": result.completion_reason.value,
|
|
117
|
+
"status": result.status.value,
|
|
118
|
+
"startedCount": result.started_count,
|
|
119
|
+
"type": "ParallelResult",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return json.dumps(fields)
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Implement the Durable step operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.config import (
|
|
9
|
+
StepConfig,
|
|
10
|
+
StepSemantics,
|
|
11
|
+
)
|
|
12
|
+
from aws_durable_execution_sdk_python.exceptions import (
|
|
13
|
+
ExecutionError,
|
|
14
|
+
StepInterruptedError,
|
|
15
|
+
)
|
|
16
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
17
|
+
ErrorObject,
|
|
18
|
+
OperationUpdate,
|
|
19
|
+
)
|
|
20
|
+
from aws_durable_execution_sdk_python.logger import Logger, LogInfo
|
|
21
|
+
from aws_durable_execution_sdk_python.retries import RetryDecision, RetryPresets
|
|
22
|
+
from aws_durable_execution_sdk_python.serdes import deserialize, serialize
|
|
23
|
+
from aws_durable_execution_sdk_python.suspend import (
|
|
24
|
+
suspend_with_optional_resume_delay,
|
|
25
|
+
suspend_with_optional_resume_timestamp,
|
|
26
|
+
)
|
|
27
|
+
from aws_durable_execution_sdk_python.types import StepContext
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
33
|
+
from aws_durable_execution_sdk_python.state import (
|
|
34
|
+
CheckpointedResult,
|
|
35
|
+
ExecutionState,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
T = TypeVar("T")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def step_handler(
|
|
44
|
+
func: Callable[[StepContext], T],
|
|
45
|
+
state: ExecutionState,
|
|
46
|
+
operation_identifier: OperationIdentifier,
|
|
47
|
+
config: StepConfig | None,
|
|
48
|
+
context_logger: Logger,
|
|
49
|
+
) -> T:
|
|
50
|
+
logger.debug(
|
|
51
|
+
"▶️ Executing step for id: %s, name: %s",
|
|
52
|
+
operation_identifier.operation_id,
|
|
53
|
+
operation_identifier.name,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if not config:
|
|
57
|
+
config = StepConfig()
|
|
58
|
+
|
|
59
|
+
checkpointed_result: CheckpointedResult = state.get_checkpoint_result(
|
|
60
|
+
operation_identifier.operation_id
|
|
61
|
+
)
|
|
62
|
+
if checkpointed_result.is_succeeded():
|
|
63
|
+
logger.debug(
|
|
64
|
+
"Step already completed, skipping execution for id: %s, name: %s",
|
|
65
|
+
operation_identifier.operation_id,
|
|
66
|
+
operation_identifier.name,
|
|
67
|
+
)
|
|
68
|
+
if checkpointed_result.result is None:
|
|
69
|
+
return None # type: ignore
|
|
70
|
+
|
|
71
|
+
return deserialize(
|
|
72
|
+
serdes=config.serdes,
|
|
73
|
+
data=checkpointed_result.result,
|
|
74
|
+
operation_id=operation_identifier.operation_id,
|
|
75
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if checkpointed_result.is_failed():
|
|
79
|
+
# have to throw the exact same error on replay as the checkpointed failure
|
|
80
|
+
checkpointed_result.raise_callable_error()
|
|
81
|
+
|
|
82
|
+
if checkpointed_result.is_pending():
|
|
83
|
+
scheduled_timestamp = checkpointed_result.get_next_attempt_timestamp()
|
|
84
|
+
# normally, we'd ensure that a suspension here would be for > 0 seconds;
|
|
85
|
+
# however, this is coming from a checkpoint, and we can trust that it is a correct target timestamp.
|
|
86
|
+
suspend_with_optional_resume_timestamp(
|
|
87
|
+
msg=f"Retry scheduled for {operation_identifier.name or operation_identifier.operation_id} will retry at timestamp {scheduled_timestamp}",
|
|
88
|
+
datetime_timestamp=scheduled_timestamp,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if (
|
|
92
|
+
checkpointed_result.is_started()
|
|
93
|
+
and config.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY
|
|
94
|
+
):
|
|
95
|
+
# step was previously interrupted
|
|
96
|
+
msg = f"Step operation_id={operation_identifier.operation_id} name={operation_identifier.name} was previously interrupted"
|
|
97
|
+
retry_handler(
|
|
98
|
+
StepInterruptedError(msg),
|
|
99
|
+
state,
|
|
100
|
+
operation_identifier,
|
|
101
|
+
config,
|
|
102
|
+
checkpointed_result,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
checkpointed_result.raise_callable_error()
|
|
106
|
+
|
|
107
|
+
if not (
|
|
108
|
+
checkpointed_result.is_started()
|
|
109
|
+
and config.step_semantics is StepSemantics.AT_LEAST_ONCE_PER_RETRY
|
|
110
|
+
):
|
|
111
|
+
# Do not checkpoint start for started & AT_LEAST_ONCE execution
|
|
112
|
+
# Checkpoint start for the other
|
|
113
|
+
start_operation: OperationUpdate = OperationUpdate.create_step_start(
|
|
114
|
+
identifier=operation_identifier,
|
|
115
|
+
)
|
|
116
|
+
# Checkpoint START operation with appropriate synchronization:
|
|
117
|
+
# - AtMostOncePerRetry: Use blocking checkpoint (is_sync=True) to prevent duplicate execution.
|
|
118
|
+
# The step must not execute until the START checkpoint is persisted, ensuring exactly-once semantics.
|
|
119
|
+
# - AtLeastOncePerRetry: Use non-blocking checkpoint (is_sync=False) for performance optimization.
|
|
120
|
+
# The step can execute immediately without waiting for checkpoint persistence, allowing at-least-once semantics.
|
|
121
|
+
is_sync: bool = config.step_semantics is StepSemantics.AT_MOST_ONCE_PER_RETRY
|
|
122
|
+
state.create_checkpoint(operation_update=start_operation, is_sync=is_sync)
|
|
123
|
+
|
|
124
|
+
attempt: int = 0
|
|
125
|
+
if checkpointed_result.operation and checkpointed_result.operation.step_details:
|
|
126
|
+
attempt = checkpointed_result.operation.step_details.attempt
|
|
127
|
+
|
|
128
|
+
step_context = StepContext(
|
|
129
|
+
logger=context_logger.with_log_info(
|
|
130
|
+
LogInfo.from_operation_identifier(
|
|
131
|
+
execution_state=state,
|
|
132
|
+
op_id=operation_identifier,
|
|
133
|
+
attempt=attempt,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
try:
|
|
138
|
+
# this is the actual code provided by the caller to execute durably inside the step
|
|
139
|
+
raw_result: T = func(step_context)
|
|
140
|
+
serialized_result: str = serialize(
|
|
141
|
+
serdes=config.serdes,
|
|
142
|
+
value=raw_result,
|
|
143
|
+
operation_id=operation_identifier.operation_id,
|
|
144
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
success_operation: OperationUpdate = OperationUpdate.create_step_succeed(
|
|
148
|
+
identifier=operation_identifier,
|
|
149
|
+
payload=serialized_result,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Checkpoint SUCCEED operation with blocking (is_sync=True, default).
|
|
153
|
+
# Must ensure the success state is persisted before returning the result to the caller.
|
|
154
|
+
# This guarantees the step result is durable and won't be lost if Lambda terminates.
|
|
155
|
+
state.create_checkpoint(operation_update=success_operation)
|
|
156
|
+
|
|
157
|
+
logger.debug(
|
|
158
|
+
"✅ Successfully completed step for id: %s, name: %s",
|
|
159
|
+
operation_identifier.operation_id,
|
|
160
|
+
operation_identifier.name,
|
|
161
|
+
)
|
|
162
|
+
return raw_result # noqa: TRY300
|
|
163
|
+
except Exception as e:
|
|
164
|
+
if isinstance(e, ExecutionError):
|
|
165
|
+
# no retry on fatal - e.g checkpoint exception
|
|
166
|
+
logger.debug(
|
|
167
|
+
"💥 Fatal error for id: %s, name: %s",
|
|
168
|
+
operation_identifier.operation_id,
|
|
169
|
+
operation_identifier.name,
|
|
170
|
+
)
|
|
171
|
+
# this bubbles up to execution.durable_execution, where it will exit with FAILED
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
logger.exception(
|
|
175
|
+
"❌ failed step for id: %s, name: %s",
|
|
176
|
+
operation_identifier.operation_id,
|
|
177
|
+
operation_identifier.name,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
retry_handler(e, state, operation_identifier, config, checkpointed_result)
|
|
181
|
+
# if we've failed to raise an exception from the retry_handler, then we are in a
|
|
182
|
+
# weird state, and should crash terminate the execution
|
|
183
|
+
msg = "retry handler should have raised an exception, but did not."
|
|
184
|
+
raise ExecutionError(msg) from None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# TODO: I don't much like this func, needs refactor. Messy grab-bag of args, refine.
|
|
188
|
+
def retry_handler(
|
|
189
|
+
error: Exception,
|
|
190
|
+
state: ExecutionState,
|
|
191
|
+
operation_identifier: OperationIdentifier,
|
|
192
|
+
config: StepConfig,
|
|
193
|
+
checkpointed_result: CheckpointedResult,
|
|
194
|
+
):
|
|
195
|
+
"""Checkpoint and suspend for replay if retry required, otherwise raise error."""
|
|
196
|
+
error_object = ErrorObject.from_exception(error)
|
|
197
|
+
|
|
198
|
+
retry_strategy = config.retry_strategy or RetryPresets.default()
|
|
199
|
+
|
|
200
|
+
retry_attempt: int = (
|
|
201
|
+
checkpointed_result.operation.step_details.attempt
|
|
202
|
+
if (
|
|
203
|
+
checkpointed_result.operation and checkpointed_result.operation.step_details
|
|
204
|
+
)
|
|
205
|
+
else 0
|
|
206
|
+
)
|
|
207
|
+
retry_decision: RetryDecision = retry_strategy(error, retry_attempt + 1)
|
|
208
|
+
|
|
209
|
+
if retry_decision.should_retry:
|
|
210
|
+
logger.debug(
|
|
211
|
+
"Retrying step for id: %s, name: %s, attempt: %s",
|
|
212
|
+
operation_identifier.operation_id,
|
|
213
|
+
operation_identifier.name,
|
|
214
|
+
retry_attempt + 1,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# because we are issuing a retry and create an OperationUpdate
|
|
218
|
+
# we enforce a minimum delay second of 1, to match model behaviour.
|
|
219
|
+
# we localize enforcement and keep it outside suspension methods as:
|
|
220
|
+
# a) those are used throughout the codebase, e.g. in wait(..) <- enforcement is done in context
|
|
221
|
+
# b) they shouldn't know model specific details <- enforcement is done above
|
|
222
|
+
# and c) this "issue" arises from retry-decision and we shouldn't push it down
|
|
223
|
+
delay_seconds = retry_decision.delay_seconds
|
|
224
|
+
if delay_seconds < 1:
|
|
225
|
+
logger.warning(
|
|
226
|
+
(
|
|
227
|
+
"Retry delay_seconds step for id: %s, name: %s,"
|
|
228
|
+
"attempt: %s is %d < 1. Setting to minimum of 1 seconds."
|
|
229
|
+
),
|
|
230
|
+
operation_identifier.operation_id,
|
|
231
|
+
operation_identifier.name,
|
|
232
|
+
retry_attempt + 1,
|
|
233
|
+
delay_seconds,
|
|
234
|
+
)
|
|
235
|
+
delay_seconds = 1
|
|
236
|
+
|
|
237
|
+
retry_operation: OperationUpdate = OperationUpdate.create_step_retry(
|
|
238
|
+
identifier=operation_identifier,
|
|
239
|
+
error=error_object,
|
|
240
|
+
next_attempt_delay_seconds=delay_seconds,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Checkpoint RETRY operation with blocking (is_sync=True, default).
|
|
244
|
+
# Must ensure retry state is persisted before suspending execution.
|
|
245
|
+
# This guarantees the retry attempt count and next attempt timestamp are durable.
|
|
246
|
+
state.create_checkpoint(operation_update=retry_operation)
|
|
247
|
+
|
|
248
|
+
suspend_with_optional_resume_delay(
|
|
249
|
+
msg=(
|
|
250
|
+
f"Retry scheduled for {operation_identifier.operation_id}"
|
|
251
|
+
f"in {retry_decision.delay_seconds} seconds"
|
|
252
|
+
),
|
|
253
|
+
delay_seconds=delay_seconds,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# no retry
|
|
257
|
+
fail_operation: OperationUpdate = OperationUpdate.create_step_fail(
|
|
258
|
+
identifier=operation_identifier, error=error_object
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Checkpoint FAIL operation with blocking (is_sync=True, default).
|
|
262
|
+
# Must ensure the failure state is persisted before raising the exception.
|
|
263
|
+
# This guarantees the error is durable and the step won't be retried on replay.
|
|
264
|
+
state.create_checkpoint(operation_update=fail_operation)
|
|
265
|
+
|
|
266
|
+
if isinstance(error, StepInterruptedError):
|
|
267
|
+
raise error
|
|
268
|
+
|
|
269
|
+
raise error_object.to_callable_runtime_error()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Implement the durable wait operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.lambda_service import OperationUpdate, WaitOptions
|
|
9
|
+
from aws_durable_execution_sdk_python.suspend import suspend_with_optional_resume_delay
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
13
|
+
from aws_durable_execution_sdk_python.state import (
|
|
14
|
+
CheckpointedResult,
|
|
15
|
+
ExecutionState,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def wait_handler(
|
|
22
|
+
seconds: int, state: ExecutionState, operation_identifier: OperationIdentifier
|
|
23
|
+
) -> None:
|
|
24
|
+
logger.debug(
|
|
25
|
+
"Wait requested for id: %s, name: %s",
|
|
26
|
+
operation_identifier.operation_id,
|
|
27
|
+
operation_identifier.name,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
checkpointed_result: CheckpointedResult = state.get_checkpoint_result(
|
|
31
|
+
operation_identifier.operation_id
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if checkpointed_result.is_succeeded():
|
|
35
|
+
logger.debug(
|
|
36
|
+
"Wait already completed, skipping wait for id: %s, name: %s",
|
|
37
|
+
operation_identifier.operation_id,
|
|
38
|
+
operation_identifier.name,
|
|
39
|
+
)
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if not checkpointed_result.is_existent():
|
|
43
|
+
operation = OperationUpdate.create_wait_start(
|
|
44
|
+
identifier=operation_identifier,
|
|
45
|
+
wait_options=WaitOptions(wait_seconds=seconds),
|
|
46
|
+
)
|
|
47
|
+
# Checkpoint wait START with blocking (is_sync=True, default).
|
|
48
|
+
# Must ensure the wait operation and scheduled timestamp are persisted before suspending.
|
|
49
|
+
# This guarantees the wait will resume at the correct time on the next invocation.
|
|
50
|
+
state.create_checkpoint(operation_update=operation)
|
|
51
|
+
|
|
52
|
+
msg = f"Wait for {seconds} seconds"
|
|
53
|
+
suspend_with_optional_resume_delay(msg, seconds) # throws suspend
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Implement the durable wait_for_condition operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.exceptions import (
|
|
9
|
+
ExecutionError,
|
|
10
|
+
)
|
|
11
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
12
|
+
ErrorObject,
|
|
13
|
+
OperationUpdate,
|
|
14
|
+
)
|
|
15
|
+
from aws_durable_execution_sdk_python.logger import LogInfo
|
|
16
|
+
from aws_durable_execution_sdk_python.serdes import deserialize, serialize
|
|
17
|
+
from aws_durable_execution_sdk_python.suspend import (
|
|
18
|
+
suspend_with_optional_resume_delay,
|
|
19
|
+
suspend_with_optional_resume_timestamp,
|
|
20
|
+
)
|
|
21
|
+
from aws_durable_execution_sdk_python.types import WaitForConditionCheckContext
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
27
|
+
from aws_durable_execution_sdk_python.logger import Logger
|
|
28
|
+
from aws_durable_execution_sdk_python.state import (
|
|
29
|
+
CheckpointedResult,
|
|
30
|
+
ExecutionState,
|
|
31
|
+
)
|
|
32
|
+
from aws_durable_execution_sdk_python.waits import (
|
|
33
|
+
WaitForConditionConfig,
|
|
34
|
+
WaitForConditionDecision,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
T = TypeVar("T")
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def wait_for_condition_handler(
|
|
44
|
+
check: Callable[[T, WaitForConditionCheckContext], T],
|
|
45
|
+
config: WaitForConditionConfig[T],
|
|
46
|
+
state: ExecutionState,
|
|
47
|
+
operation_identifier: OperationIdentifier,
|
|
48
|
+
context_logger: Logger,
|
|
49
|
+
) -> T:
|
|
50
|
+
"""Handle wait_for_condition operation.
|
|
51
|
+
|
|
52
|
+
wait_for_condition creates a STEP checkpoint.
|
|
53
|
+
"""
|
|
54
|
+
logger.debug(
|
|
55
|
+
"▶️ Executing wait_for_condition for id: %s, name: %s",
|
|
56
|
+
operation_identifier.operation_id,
|
|
57
|
+
operation_identifier.name,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
checkpointed_result: CheckpointedResult = state.get_checkpoint_result(
|
|
61
|
+
operation_identifier.operation_id
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Check if already completed
|
|
65
|
+
if checkpointed_result.is_succeeded():
|
|
66
|
+
logger.debug(
|
|
67
|
+
"wait_for_condition already completed for id: %s, name: %s",
|
|
68
|
+
operation_identifier.operation_id,
|
|
69
|
+
operation_identifier.name,
|
|
70
|
+
)
|
|
71
|
+
if checkpointed_result.result is None:
|
|
72
|
+
return None # type: ignore
|
|
73
|
+
return deserialize(
|
|
74
|
+
serdes=config.serdes,
|
|
75
|
+
data=checkpointed_result.result,
|
|
76
|
+
operation_id=operation_identifier.operation_id,
|
|
77
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if checkpointed_result.is_failed():
|
|
81
|
+
checkpointed_result.raise_callable_error()
|
|
82
|
+
|
|
83
|
+
if checkpointed_result.is_pending():
|
|
84
|
+
scheduled_timestamp = checkpointed_result.get_next_attempt_timestamp()
|
|
85
|
+
suspend_with_optional_resume_timestamp(
|
|
86
|
+
msg=f"wait_for_condition {operation_identifier.name or operation_identifier.operation_id} will retry at timestamp {scheduled_timestamp}",
|
|
87
|
+
datetime_timestamp=scheduled_timestamp,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
attempt: int = 1
|
|
91
|
+
if checkpointed_result.is_started_or_ready():
|
|
92
|
+
# This is a retry - get state from previous checkpoint
|
|
93
|
+
if checkpointed_result.result:
|
|
94
|
+
try:
|
|
95
|
+
current_state = deserialize(
|
|
96
|
+
serdes=config.serdes,
|
|
97
|
+
data=checkpointed_result.result,
|
|
98
|
+
operation_id=operation_identifier.operation_id,
|
|
99
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
100
|
+
)
|
|
101
|
+
except Exception:
|
|
102
|
+
# default to initial state if there's an error getting checkpointed state
|
|
103
|
+
logger.exception(
|
|
104
|
+
"⚠️ wait_for_condition failed to deserialize state for id: %s, name: %s. Using initial state.",
|
|
105
|
+
operation_identifier.operation_id,
|
|
106
|
+
operation_identifier.name,
|
|
107
|
+
)
|
|
108
|
+
current_state = config.initial_state
|
|
109
|
+
else:
|
|
110
|
+
current_state = config.initial_state
|
|
111
|
+
|
|
112
|
+
# at this point operation has to exist. Nonetheless, just in case somehow it's not there.
|
|
113
|
+
if checkpointed_result.operation and checkpointed_result.operation.step_details:
|
|
114
|
+
attempt = checkpointed_result.operation.step_details.attempt
|
|
115
|
+
else:
|
|
116
|
+
# First execution
|
|
117
|
+
current_state = config.initial_state
|
|
118
|
+
|
|
119
|
+
# Checkpoint START for observability.
|
|
120
|
+
if not checkpointed_result.is_started():
|
|
121
|
+
start_operation: OperationUpdate = (
|
|
122
|
+
OperationUpdate.create_wait_for_condition_start(
|
|
123
|
+
identifier=operation_identifier,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
# Checkpoint wait_for_condition START with non-blocking (is_sync=False).
|
|
127
|
+
# This is purely for observability - we don't need to wait for persistence before
|
|
128
|
+
# executing the check function. The START checkpoint just records that polling began.
|
|
129
|
+
state.create_checkpoint(operation_update=start_operation, is_sync=False)
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Execute the check function with the injected logger
|
|
133
|
+
check_context = WaitForConditionCheckContext(
|
|
134
|
+
logger=context_logger.with_log_info(
|
|
135
|
+
LogInfo.from_operation_identifier(
|
|
136
|
+
execution_state=state,
|
|
137
|
+
op_id=operation_identifier,
|
|
138
|
+
attempt=attempt,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
new_state = check(current_state, check_context)
|
|
144
|
+
|
|
145
|
+
# Check if condition is met with the wait strategy
|
|
146
|
+
decision: WaitForConditionDecision = config.wait_strategy(new_state, attempt)
|
|
147
|
+
|
|
148
|
+
serialized_state = serialize(
|
|
149
|
+
serdes=config.serdes,
|
|
150
|
+
value=new_state,
|
|
151
|
+
operation_id=operation_identifier.operation_id,
|
|
152
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
logger.debug(
|
|
156
|
+
"wait_for_condition check completed: %s, name: %s, attempt: %s",
|
|
157
|
+
operation_identifier.operation_id,
|
|
158
|
+
operation_identifier.name,
|
|
159
|
+
attempt,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not decision.should_continue:
|
|
163
|
+
# Condition is met - complete successfully
|
|
164
|
+
success_operation = OperationUpdate.create_wait_for_condition_succeed(
|
|
165
|
+
identifier=operation_identifier,
|
|
166
|
+
payload=serialized_state,
|
|
167
|
+
)
|
|
168
|
+
# Checkpoint SUCCEED operation with blocking (is_sync=True, default).
|
|
169
|
+
# Must ensure the final state is persisted before returning to the caller.
|
|
170
|
+
# This guarantees the condition result is durable and won't be re-evaluated on replay.
|
|
171
|
+
state.create_checkpoint(operation_update=success_operation)
|
|
172
|
+
|
|
173
|
+
logger.debug(
|
|
174
|
+
"✅ wait_for_condition completed for id: %s, name: %s",
|
|
175
|
+
operation_identifier.operation_id,
|
|
176
|
+
operation_identifier.name,
|
|
177
|
+
)
|
|
178
|
+
return new_state
|
|
179
|
+
|
|
180
|
+
# Condition not met - schedule retry
|
|
181
|
+
# we enforce a minimum delay second of 1, to match model behaviour.
|
|
182
|
+
# we localize enforcement and keep it outside suspension methods as:
|
|
183
|
+
# a) those are used throughout the codebase, e.g. in wait(..) <- enforcement is done in context
|
|
184
|
+
# b) they shouldn't know model specific details <- enforcement is done above
|
|
185
|
+
# and c) this "issue" arises from retry-decision and shouldn't be chased deeper.
|
|
186
|
+
delay_seconds = decision.delay_seconds
|
|
187
|
+
if delay_seconds is not None and delay_seconds < 1:
|
|
188
|
+
logger.warning(
|
|
189
|
+
(
|
|
190
|
+
"WaitDecision delay_seconds step for id: %s, name: %s,"
|
|
191
|
+
"is %d < 1. Setting to minimum of 1 seconds."
|
|
192
|
+
),
|
|
193
|
+
operation_identifier.operation_id,
|
|
194
|
+
operation_identifier.name,
|
|
195
|
+
delay_seconds,
|
|
196
|
+
)
|
|
197
|
+
delay_seconds = 1
|
|
198
|
+
|
|
199
|
+
retry_operation = OperationUpdate.create_wait_for_condition_retry(
|
|
200
|
+
identifier=operation_identifier,
|
|
201
|
+
payload=serialized_state,
|
|
202
|
+
next_attempt_delay_seconds=delay_seconds,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Checkpoint RETRY operation with blocking (is_sync=True, default).
|
|
206
|
+
# Must ensure the current state and next attempt timestamp are persisted before suspending.
|
|
207
|
+
# This guarantees the polling state is durable and will resume correctly on the next invocation.
|
|
208
|
+
state.create_checkpoint(operation_update=retry_operation)
|
|
209
|
+
|
|
210
|
+
suspend_with_optional_resume_delay(
|
|
211
|
+
msg=f"wait_for_condition {operation_identifier.name or operation_identifier.operation_id} will retry in {decision.delay_seconds} seconds",
|
|
212
|
+
delay_seconds=decision.delay_seconds,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
# Mark as failed - waitForCondition doesn't have its own retry logic for errors
|
|
217
|
+
# If the check function throws, it's considered a failure
|
|
218
|
+
logger.exception(
|
|
219
|
+
"❌ wait_for_condition failed for id: %s, name: %s",
|
|
220
|
+
operation_identifier.operation_id,
|
|
221
|
+
operation_identifier.name,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
fail_operation = OperationUpdate.create_wait_for_condition_fail(
|
|
225
|
+
identifier=operation_identifier,
|
|
226
|
+
error=ErrorObject.from_exception(e),
|
|
227
|
+
)
|
|
228
|
+
# Checkpoint FAIL operation with blocking (is_sync=True, default).
|
|
229
|
+
# Must ensure the failure state is persisted before raising the exception.
|
|
230
|
+
# This guarantees the error is durable and the condition won't be re-evaluated on replay.
|
|
231
|
+
state.create_checkpoint(operation_update=fail_operation)
|
|
232
|
+
raise
|
|
233
|
+
|
|
234
|
+
msg: str = "wait_for_condition should never reach this point" # pragma: no cover
|
|
235
|
+
raise ExecutionError(msg) # pragma: no cover
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file that indicates this package supports typing
|