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