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,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