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,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The model expects >= 1 seconds when receiving an OperationUpdate via api calls
|
|
3
|
+
(see StepOptions, WaitOptions, etc)
|
|
4
|
+
We don't force a minimum delay_seconds OR time to timestamp here because:
|
|
5
|
+
|
|
6
|
+
1. suspension can be reached from multiple handlers,
|
|
7
|
+
2. suspension can be reached from multiple "contexts", e.g. top level wait, or a child retry
|
|
8
|
+
3. we use TimedSuspendExecution as an optimization mechanism within concurrent executions (map/parallel)
|
|
9
|
+
to know when to retry without being told so.
|
|
10
|
+
|
|
11
|
+
As such, it is up to the caller to ensure consistency wrt what dataplane sees and what happens within a
|
|
12
|
+
function.
|
|
13
|
+
|
|
14
|
+
Behaviour:
|
|
15
|
+
- When we suspend without a target delay / timestamp THEN we suspend indefinitely.
|
|
16
|
+
- When `delay_seconds` or timestamp exist, THEN we suspend for `max(delay_seconds, 0)` or suspend for "now".
|
|
17
|
+
- When suspension happens within a child branch and the branch suspends for `0` seconds, THEN execution will
|
|
18
|
+
resume immediately as it will be inserted at the top of the queue
|
|
19
|
+
- When suspension happens within a child branch and the branch suspends for > 0 seconds, THEN execution will
|
|
20
|
+
resume as soon as `delay_seconds` have passed
|
|
21
|
+
- When suspension happens at the top level, then the Lambda will terminate and Dataplane is responsible
|
|
22
|
+
for resuming
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import datetime
|
|
27
|
+
from typing import NoReturn
|
|
28
|
+
|
|
29
|
+
from aws_durable_execution_sdk_python.exceptions import (
|
|
30
|
+
SuspendExecution,
|
|
31
|
+
TimedSuspendExecution,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def suspend_with_optional_resume_timestamp(
|
|
36
|
+
msg: str, datetime_timestamp: datetime.datetime | None = None
|
|
37
|
+
) -> NoReturn:
|
|
38
|
+
"""Suspend execution with optional timestamp.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
msg: Descriptive message for the suspension
|
|
42
|
+
timestamp: Timestamp to suspend until, or None for indefinite
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
TimedSuspendExecution: When timestamp is in the future or now()
|
|
46
|
+
SuspendExecution: When timestamp is None or in the past
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
if datetime_timestamp is None:
|
|
50
|
+
msg = f"No timestamp provided. Suspending without retry timestamp. Original operation: [{msg}]"
|
|
51
|
+
raise SuspendExecution(msg)
|
|
52
|
+
|
|
53
|
+
if datetime_timestamp < datetime.datetime.now(tz=datetime.UTC):
|
|
54
|
+
msg = f"Invalid timestamp {datetime_timestamp}, suspending with immediate retry, original operation: [{msg}]"
|
|
55
|
+
raise TimedSuspendExecution.from_datetime(
|
|
56
|
+
msg, datetime.datetime.now(tz=datetime.UTC)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
raise TimedSuspendExecution.from_datetime(msg, datetime_timestamp)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def suspend_with_optional_resume_delay(
|
|
63
|
+
msg: str, delay_seconds: int | None = None
|
|
64
|
+
) -> NoReturn:
|
|
65
|
+
"""Suspend execution with optional delay.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
msg: Descriptive message for the suspension
|
|
69
|
+
delay_seconds: Duration to suspend in seconds, or None for indefinite
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
TimedSuspendExecution: When delay_seconds when delay_seconds is not None
|
|
73
|
+
SuspendExecution: When delay_seconds is None
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
if delay_seconds is None:
|
|
77
|
+
msg = f"No delay_seconds provided, suspending without retry timestamp, original operation: [{msg}]"
|
|
78
|
+
raise SuspendExecution(msg)
|
|
79
|
+
|
|
80
|
+
if delay_seconds < 0:
|
|
81
|
+
msg = f"Invalid delay_seconds {delay_seconds}, suspending with delay 0, original operation: [{msg}]"
|
|
82
|
+
raise TimedSuspendExecution.from_delay(msg, 0)
|
|
83
|
+
|
|
84
|
+
raise TimedSuspendExecution.from_delay(msg, delay_seconds)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Concurrency and locking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
from threading import Event, Lock
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from aws_durable_execution_sdk_python.exceptions import OrderedLockError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CompletionEvent:
|
|
16
|
+
"""Threading event that can signal completion or propagate errors.
|
|
17
|
+
|
|
18
|
+
This event allows a background thread to wake up a waiting thread either
|
|
19
|
+
with a successful completion signal or by propagating an exception. When
|
|
20
|
+
an error is set, the waiting thread will raise that exception.
|
|
21
|
+
|
|
22
|
+
This is used for checkpoint operations where the background checkpoint
|
|
23
|
+
thread needs to signal completion to the user thread, or interrupt it
|
|
24
|
+
with a critical error.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
>>> event = CompletionEvent()
|
|
28
|
+
>>> # In background thread:
|
|
29
|
+
>>> try:
|
|
30
|
+
... process_checkpoint()
|
|
31
|
+
... event.set() # Success
|
|
32
|
+
... except Exception as e:
|
|
33
|
+
... event.set(BackgroundThreadError(..., e)) # Error
|
|
34
|
+
>>>
|
|
35
|
+
>>> # In user thread:
|
|
36
|
+
>>> event.wait() # Raises BackgroundThreadError if set
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
"""Initialize completion event."""
|
|
41
|
+
self._event: Event = Event()
|
|
42
|
+
self._error: BaseException | None = None
|
|
43
|
+
|
|
44
|
+
def set(self, error: BaseException | None = None) -> None:
|
|
45
|
+
"""Signal completion, optionally with an error.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
error: Optional exception to propagate to waiting thread.
|
|
49
|
+
If provided, wait() will raise this exception.
|
|
50
|
+
"""
|
|
51
|
+
# Only set error if none is already set (first error wins)
|
|
52
|
+
if self._error is None:
|
|
53
|
+
self._error = error
|
|
54
|
+
self._event.set()
|
|
55
|
+
|
|
56
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
57
|
+
"""Wait for completion and raise if error occurred.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
timeout: Optional timeout in seconds
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if event was set, False if timeout occurred
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
BaseException: If an error was set via set(error=...)
|
|
67
|
+
"""
|
|
68
|
+
result = self._event.wait(timeout)
|
|
69
|
+
if self._error is not None:
|
|
70
|
+
raise self._error
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
def is_set(self) -> bool:
|
|
74
|
+
"""Return True if the event is set, False otherwise."""
|
|
75
|
+
return self._event.is_set()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class OrderedLock:
|
|
79
|
+
"""Lock that guarantees callers acquire in the invocation order.
|
|
80
|
+
|
|
81
|
+
Locks acquire in first-in,first-out (FIFO) order.
|
|
82
|
+
|
|
83
|
+
This class is necessary because in a standard Lock the order of pending calls
|
|
84
|
+
acquiring the lock is not necessarily guaranteed by the thread scheduler.
|
|
85
|
+
|
|
86
|
+
For example, assume calls to acquire the lock in order A -> B -> C.
|
|
87
|
+
A blocks with B and C pending. When A releases, the thread scheduler could favour
|
|
88
|
+
C rather than B next, which is out of order.
|
|
89
|
+
|
|
90
|
+
This OrderedLock instead will guarantee that the order in which callers will
|
|
91
|
+
acquire the lock is the order of invocation. In the case of example, this means
|
|
92
|
+
that the order of lock acquire would always be A -> B -> C.
|
|
93
|
+
|
|
94
|
+
Once an error occurs in a lock, this instance of the lock is broken and no subsequent lock attempts
|
|
95
|
+
can succeed, because if any subsequent locks acquire it would violate the order guarantee.
|
|
96
|
+
|
|
97
|
+
If a lock fails to acquire, OrderedLock will raise the causing exception to the caller.
|
|
98
|
+
If there are any other blocked callers waiting in queue, those callers will receive a
|
|
99
|
+
OrderedLockError, which contains the original causing exception too.
|
|
100
|
+
|
|
101
|
+
You can use OrderedLock as a context manager.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
"""Initialize ordered lock."""
|
|
106
|
+
self._lock: Lock = Lock()
|
|
107
|
+
self._waiters: deque[Event] = deque()
|
|
108
|
+
self._is_broken: bool = False
|
|
109
|
+
self._exception: Exception | None = None
|
|
110
|
+
|
|
111
|
+
def acquire(self) -> bool:
|
|
112
|
+
"""Acquire lock.
|
|
113
|
+
|
|
114
|
+
Returns: True if acquired successfully
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
OrderedLockError: When a preceding caller could not release its lock because it errored.
|
|
118
|
+
"""
|
|
119
|
+
with self._lock:
|
|
120
|
+
if self._is_broken:
|
|
121
|
+
# don't grow queue if already broken
|
|
122
|
+
msg = "Cannot acquire lock in guaranteed order because a previous lock exited with an exception."
|
|
123
|
+
raise OrderedLockError(msg, self._exception)
|
|
124
|
+
|
|
125
|
+
event = Event()
|
|
126
|
+
self._waiters.append(event)
|
|
127
|
+
|
|
128
|
+
if len(self._waiters) == 1:
|
|
129
|
+
# first waiter, nothing else in queue so no need to wait
|
|
130
|
+
event.set()
|
|
131
|
+
|
|
132
|
+
# block until it's our turn to proceed
|
|
133
|
+
event.wait()
|
|
134
|
+
|
|
135
|
+
# this is the only thread progressing and holding the lock, so doesn't need to be under lock
|
|
136
|
+
if self._is_broken:
|
|
137
|
+
msg = "Cannot acquire lock in guaranteed order because a previous lock exited with an exception."
|
|
138
|
+
raise OrderedLockError(msg, self._exception)
|
|
139
|
+
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def release(self) -> None:
|
|
143
|
+
"""Release lock. This makes the lock available for the next queued up waiter."""
|
|
144
|
+
with self._lock:
|
|
145
|
+
if not self._waiters:
|
|
146
|
+
msg = "You have to acquire a lock before you can release it."
|
|
147
|
+
raise OrderedLockError(msg)
|
|
148
|
+
# remove the current lock from the queue, since it's done
|
|
149
|
+
self._waiters.popleft()
|
|
150
|
+
if self._waiters and not self._is_broken:
|
|
151
|
+
# let the next-in-line waiter proceed
|
|
152
|
+
self._waiters[0].set()
|
|
153
|
+
|
|
154
|
+
def reset(self) -> None:
|
|
155
|
+
"""Reset the lock.
|
|
156
|
+
|
|
157
|
+
This assumes all waiters have cleared.
|
|
158
|
+
|
|
159
|
+
Raises: OrderedLockError when there still are pending waiters.
|
|
160
|
+
"""
|
|
161
|
+
with self._lock:
|
|
162
|
+
if self._waiters:
|
|
163
|
+
msg = (
|
|
164
|
+
"Cannot reset lock because there are callers waiting for the lock."
|
|
165
|
+
)
|
|
166
|
+
raise OrderedLockError(msg)
|
|
167
|
+
self._is_broken = False
|
|
168
|
+
self._exception = None
|
|
169
|
+
|
|
170
|
+
def is_broken(self) -> bool:
|
|
171
|
+
"""Return True if the lock is broken."""
|
|
172
|
+
with self._lock:
|
|
173
|
+
return self._is_broken
|
|
174
|
+
|
|
175
|
+
# region Context Manager
|
|
176
|
+
def __enter__(self) -> Self:
|
|
177
|
+
"""Acquire lock."""
|
|
178
|
+
self.acquire()
|
|
179
|
+
return self
|
|
180
|
+
|
|
181
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
182
|
+
"""Exit the context manager by releasing the current lock."""
|
|
183
|
+
if exc_type is not None:
|
|
184
|
+
# can't allow any subsequent locks to succeed, because that would break order guarantee
|
|
185
|
+
with self._lock:
|
|
186
|
+
self._is_broken = True
|
|
187
|
+
self._exception = exc_val
|
|
188
|
+
# break the queue and let all waiters know
|
|
189
|
+
for waiter in self._waiters:
|
|
190
|
+
waiter.set()
|
|
191
|
+
|
|
192
|
+
self.release()
|
|
193
|
+
|
|
194
|
+
# endregion Context Manager
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class OrderedCounter:
|
|
198
|
+
"""Thread-safe counter that guarantees callers get the next increment in the invocation order.
|
|
199
|
+
|
|
200
|
+
The counter starts at 0.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self) -> None:
|
|
204
|
+
self._lock: OrderedLock = OrderedLock()
|
|
205
|
+
self._counter: int = 0
|
|
206
|
+
|
|
207
|
+
def increment(self) -> int:
|
|
208
|
+
"""Increment the counter by 1."""
|
|
209
|
+
with self._lock:
|
|
210
|
+
self._counter += 1
|
|
211
|
+
return self._counter
|
|
212
|
+
|
|
213
|
+
def decrement(self) -> int:
|
|
214
|
+
"""Decrement the counter by 1."""
|
|
215
|
+
with self._lock:
|
|
216
|
+
self._counter -= 1
|
|
217
|
+
return self._counter
|
|
218
|
+
|
|
219
|
+
def get_current(self) -> int:
|
|
220
|
+
"""Return the current value of the counter."""
|
|
221
|
+
with self._lock:
|
|
222
|
+
return self._counter
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Types and Protocols. Don't import anything other than config here - the reason it exists is to avoid circular references."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from aws_durable_execution_sdk_python.config import (
|
|
13
|
+
BatchedInput,
|
|
14
|
+
CallbackConfig,
|
|
15
|
+
ChildConfig,
|
|
16
|
+
Duration,
|
|
17
|
+
MapConfig,
|
|
18
|
+
ParallelConfig,
|
|
19
|
+
StepConfig,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
U = TypeVar("U")
|
|
24
|
+
C_co = TypeVar("C_co", covariant=True)
|
|
25
|
+
C_contra = TypeVar("C_contra", contravariant=True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LoggerInterface(Protocol):
|
|
29
|
+
def debug(
|
|
30
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
31
|
+
) -> None: ... # pragma: no cover
|
|
32
|
+
|
|
33
|
+
def info(
|
|
34
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
35
|
+
) -> None: ... # pragma: no cover
|
|
36
|
+
|
|
37
|
+
def warning(
|
|
38
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
39
|
+
) -> None: ... # pragma: no cover
|
|
40
|
+
|
|
41
|
+
def error(
|
|
42
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
43
|
+
) -> None: ... # pragma: no cover
|
|
44
|
+
|
|
45
|
+
def exception(
|
|
46
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
47
|
+
) -> None: ... # pragma: no cover
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class OperationContext:
|
|
52
|
+
logger: LoggerInterface
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class StepContext(OperationContext):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class WaitForCallbackContext(OperationContext):
|
|
62
|
+
"""Context provided to waitForCallback submitter functions."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class WaitForConditionCheckContext(OperationContext):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Callback(Protocol, Generic[C_co]):
|
|
71
|
+
"""Protocol for callback futures."""
|
|
72
|
+
|
|
73
|
+
callback_id: str
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def result(self) -> C_co | None:
|
|
77
|
+
"""Return the result of the future. Will block until result is available."""
|
|
78
|
+
... # pragma: no cover
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class BatchResult(Protocol, Generic[T]):
|
|
82
|
+
"""Protocol for batch operation results."""
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def get_results(self) -> list[T]:
|
|
86
|
+
"""Get all successful results."""
|
|
87
|
+
... # pragma: no cover
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class DurableContext(Protocol):
|
|
91
|
+
"""Protocol defining the interface for durable execution contexts."""
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def step(
|
|
95
|
+
self,
|
|
96
|
+
func: Callable[[StepContext], T],
|
|
97
|
+
name: str | None = None,
|
|
98
|
+
config: StepConfig | None = None,
|
|
99
|
+
) -> T:
|
|
100
|
+
"""Execute a step durably."""
|
|
101
|
+
... # pragma: no cover
|
|
102
|
+
|
|
103
|
+
@abstractmethod
|
|
104
|
+
def run_in_child_context(
|
|
105
|
+
self,
|
|
106
|
+
func: Callable[[DurableContext], T],
|
|
107
|
+
name: str | None = None,
|
|
108
|
+
config: ChildConfig | None = None,
|
|
109
|
+
) -> T:
|
|
110
|
+
"""Run callable in a child context."""
|
|
111
|
+
... # pragma: no cover
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def map(
|
|
115
|
+
self,
|
|
116
|
+
inputs: Sequence[U],
|
|
117
|
+
func: Callable[[DurableContext, U | BatchedInput[Any, U], int, Sequence[U]], T],
|
|
118
|
+
name: str | None = None,
|
|
119
|
+
config: MapConfig | None = None,
|
|
120
|
+
) -> BatchResult[T]:
|
|
121
|
+
"""Apply function durably to each item in inputs."""
|
|
122
|
+
... # pragma: no cover
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
def parallel(
|
|
126
|
+
self,
|
|
127
|
+
functions: Sequence[Callable[[DurableContext], T]],
|
|
128
|
+
name: str | None = None,
|
|
129
|
+
config: ParallelConfig | None = None,
|
|
130
|
+
) -> BatchResult[T]:
|
|
131
|
+
"""Execute callables durably in parallel."""
|
|
132
|
+
... # pragma: no cover
|
|
133
|
+
|
|
134
|
+
@abstractmethod
|
|
135
|
+
def wait(self, duration: Duration, name: str | None = None) -> None:
|
|
136
|
+
"""Wait for a specified amount of time."""
|
|
137
|
+
... # pragma: no cover
|
|
138
|
+
|
|
139
|
+
@abstractmethod
|
|
140
|
+
def create_callback(
|
|
141
|
+
self, name: str | None = None, config: CallbackConfig | None = None
|
|
142
|
+
) -> Callback:
|
|
143
|
+
"""Create a callback."""
|
|
144
|
+
... # pragma: no cover
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class LambdaContext(Protocol): # pragma: no cover
|
|
148
|
+
aws_request_id: str
|
|
149
|
+
log_group_name: str | None = None
|
|
150
|
+
log_stream_name: str | None = None
|
|
151
|
+
function_name: str | None = None
|
|
152
|
+
memory_limit_in_mb: str | None = None
|
|
153
|
+
function_version: str | None = None
|
|
154
|
+
invoked_function_arn: str | None = None
|
|
155
|
+
tenant_id: str | None = None
|
|
156
|
+
client_context: Any | None = None
|
|
157
|
+
identity: Any | None = None
|
|
158
|
+
|
|
159
|
+
def get_remaining_time_in_millis(self) -> int: ...
|
|
160
|
+
def log(self, msg) -> None: ...
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# region Summary
|
|
164
|
+
|
|
165
|
+
"""Summary generators for concurrent operations.
|
|
166
|
+
|
|
167
|
+
Summary generators create compact JSON representations of large BatchResult objects
|
|
168
|
+
when the serialized result exceeds the 256KB checkpoint size limit. This prevents
|
|
169
|
+
large payloads from being stored in checkpoints while maintaining operation metadata.
|
|
170
|
+
|
|
171
|
+
When a summary is used, the operation is marked with ReplayChildren=true, causing
|
|
172
|
+
the child context to be re-executed during replay to reconstruct the full result.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SummaryGenerator(Protocol[C_contra]):
|
|
177
|
+
def __call__(self, result: C_contra) -> str: ... # pragma: no cover
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# endregion Summary
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Ready-made wait strategies and wait creators."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Generic
|
|
8
|
+
|
|
9
|
+
from aws_durable_execution_sdk_python.config import Duration, JitterStrategy, T
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from aws_durable_execution_sdk_python.serdes import SerDes
|
|
15
|
+
|
|
16
|
+
Numeric = int | float
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class WaitDecision:
|
|
21
|
+
"""Decision about whether to wait a step and with what delay."""
|
|
22
|
+
|
|
23
|
+
should_wait: bool
|
|
24
|
+
delay: Duration
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def delay_seconds(self) -> int:
|
|
28
|
+
"""Get delay in seconds."""
|
|
29
|
+
return self.delay.to_seconds()
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def wait(cls, delay: Duration) -> WaitDecision:
|
|
33
|
+
"""Create a wait decision."""
|
|
34
|
+
return cls(should_wait=True, delay=delay)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def no_wait(cls) -> WaitDecision:
|
|
38
|
+
"""Create a no-wait decision."""
|
|
39
|
+
return cls(should_wait=False, delay=Duration())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WaitStrategyConfig(Generic[T]):
|
|
44
|
+
should_continue_polling: Callable[[T], bool]
|
|
45
|
+
max_attempts: int = 60
|
|
46
|
+
initial_delay: Duration = field(default_factory=lambda: Duration.from_seconds(5))
|
|
47
|
+
max_delay: Duration = field(
|
|
48
|
+
default_factory=lambda: Duration.from_minutes(5)
|
|
49
|
+
) # 5 minutes
|
|
50
|
+
backoff_rate: Numeric = 1.5
|
|
51
|
+
jitter_strategy: JitterStrategy = field(default=JitterStrategy.FULL)
|
|
52
|
+
timeout: Duration | None = None # Not implemented yet
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def initial_delay_seconds(self) -> int:
|
|
56
|
+
"""Get initial delay in seconds."""
|
|
57
|
+
return self.initial_delay.to_seconds()
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def max_delay_seconds(self) -> int:
|
|
61
|
+
"""Get max delay in seconds."""
|
|
62
|
+
return self.max_delay.to_seconds()
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def timeout_seconds(self) -> int | None:
|
|
66
|
+
"""Get timeout in seconds."""
|
|
67
|
+
if self.timeout is None:
|
|
68
|
+
return None
|
|
69
|
+
return self.timeout.to_seconds()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_wait_strategy(
|
|
73
|
+
config: WaitStrategyConfig[T],
|
|
74
|
+
) -> Callable[[T, int], WaitDecision]:
|
|
75
|
+
def wait_strategy(result: T, attempts_made: int) -> WaitDecision:
|
|
76
|
+
# Check if condition is met
|
|
77
|
+
if not config.should_continue_polling(result):
|
|
78
|
+
return WaitDecision.no_wait()
|
|
79
|
+
|
|
80
|
+
# Check if we've exceeded max attempts
|
|
81
|
+
if attempts_made >= config.max_attempts:
|
|
82
|
+
return WaitDecision.no_wait()
|
|
83
|
+
|
|
84
|
+
# Calculate delay with exponential backoff
|
|
85
|
+
base_delay: float = min(
|
|
86
|
+
config.initial_delay_seconds * (config.backoff_rate ** (attempts_made - 1)),
|
|
87
|
+
config.max_delay_seconds,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Apply jitter to get final delay
|
|
91
|
+
delay_with_jitter: float = config.jitter_strategy.apply_jitter(base_delay)
|
|
92
|
+
|
|
93
|
+
# Round up and ensure minimum of 1 second
|
|
94
|
+
final_delay: int = max(1, math.ceil(delay_with_jitter))
|
|
95
|
+
|
|
96
|
+
return WaitDecision.wait(Duration(seconds=final_delay))
|
|
97
|
+
|
|
98
|
+
return wait_strategy
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class WaitForConditionDecision:
|
|
103
|
+
"""Decision about whether to continue waiting."""
|
|
104
|
+
|
|
105
|
+
should_continue: bool
|
|
106
|
+
delay: Duration
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def delay_seconds(self) -> int:
|
|
110
|
+
"""Get delay in seconds."""
|
|
111
|
+
return self.delay.to_seconds()
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def continue_waiting(cls, delay: Duration) -> WaitForConditionDecision:
|
|
115
|
+
"""Create a decision to continue waiting for delay_seconds."""
|
|
116
|
+
return cls(should_continue=True, delay=delay)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def stop_polling(cls) -> WaitForConditionDecision:
|
|
120
|
+
"""Create a decision to stop polling."""
|
|
121
|
+
return cls(should_continue=False, delay=Duration())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class WaitForConditionConfig(Generic[T]):
|
|
126
|
+
"""Configuration for wait_for_condition."""
|
|
127
|
+
|
|
128
|
+
wait_strategy: Callable[[T, int], WaitForConditionDecision]
|
|
129
|
+
initial_state: T
|
|
130
|
+
serdes: SerDes | None = None
|