aws-durable-execution-sdk-python 2.0.0__py3-none-any.whl → 2.0.1__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/__about__.py +1 -1
- aws_durable_execution_sdk_python/concurrency/executor.py +43 -15
- aws_durable_execution_sdk_python/exceptions.py +2 -2
- aws_durable_execution_sdk_python/state.py +316 -136
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.0.1.dist-info}/METADATA +1 -1
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.0.1.dist-info}/RECORD +7 -7
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.0.1.dist-info}/WHEEL +1 -1
|
@@ -8,6 +8,7 @@ import queue
|
|
|
8
8
|
import time
|
|
9
9
|
from collections import deque
|
|
10
10
|
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from dataclasses import dataclass, field
|
|
11
12
|
from typing import TYPE_CHECKING, Generic, TypeVar, cast
|
|
12
13
|
|
|
13
14
|
from aws_durable_execution_sdk_python.concurrency.models import (
|
|
@@ -56,6 +57,7 @@ if TYPE_CHECKING:
|
|
|
56
57
|
from aws_durable_execution_sdk_python.state import (
|
|
57
58
|
CheckpointedResult,
|
|
58
59
|
ExecutionState,
|
|
60
|
+
ScheduledRefresh,
|
|
59
61
|
)
|
|
60
62
|
|
|
61
63
|
|
|
@@ -86,6 +88,20 @@ def _branch_error_object(err: Exception) -> ErrorObject:
|
|
|
86
88
|
return ErrorObject.from_exception(err)
|
|
87
89
|
|
|
88
90
|
|
|
91
|
+
@dataclass
|
|
92
|
+
class ResumeWave(Generic[CallableType, ResultType]):
|
|
93
|
+
"""Branches suspended until one time, and the refresh that resumes them.
|
|
94
|
+
|
|
95
|
+
The refresh is a delayed empty checkpoint requested when the first branch
|
|
96
|
+
suspends. Its response shows the waits complete, so the wave resumes one
|
|
97
|
+
round trip after its time.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
resume_at: float
|
|
101
|
+
refresh: ScheduledRefresh
|
|
102
|
+
branches: list[Branch[CallableType, ResultType]] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
|
|
89
105
|
class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
90
106
|
"""Execute durable operations concurrently. This contains the execution logic for Map and Parallel.
|
|
91
107
|
|
|
@@ -252,7 +268,9 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
252
268
|
|
|
253
269
|
events: queue.Queue[BranchEvent[ResultType]] = queue.Queue()
|
|
254
270
|
pending: deque[Branch[CallableType, ResultType]] = deque(self.branches)
|
|
255
|
-
|
|
271
|
+
# The heap orders resume times. The dict groups the branches under each.
|
|
272
|
+
resume_times: list[float] = []
|
|
273
|
+
waves: dict[float, ResumeWave[CallableType, ResultType]] = {}
|
|
256
274
|
branch_by_index: dict[int, Branch[CallableType, ResultType]] = {
|
|
257
275
|
branch.index: branch for branch in self.branches
|
|
258
276
|
}
|
|
@@ -317,19 +335,20 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
317
335
|
running += 1
|
|
318
336
|
needs_snapshot_rebuild = True
|
|
319
337
|
|
|
320
|
-
# Resume due
|
|
321
|
-
# refresh serves the whole due wave; a failure is terminal
|
|
338
|
+
# Resume due waves in-process. A refresh failure is terminal
|
|
322
339
|
# for the execution and propagates from this thread.
|
|
323
340
|
now: float = time.time()
|
|
324
|
-
|
|
325
|
-
while
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for branch in
|
|
341
|
+
resumed = False
|
|
342
|
+
while resume_times and resume_times[0] <= now:
|
|
343
|
+
wave = waves.pop(heapq.heappop(resume_times))
|
|
344
|
+
wave.refresh.wait()
|
|
345
|
+
# Branches joined the wave in event order. Resume in index
|
|
346
|
+
# order so scheduling stays deterministic.
|
|
347
|
+
for branch in sorted(wave.branches, key=lambda b: b.index):
|
|
331
348
|
submit(branch)
|
|
332
349
|
running += 1
|
|
350
|
+
resumed = True
|
|
351
|
+
if resumed:
|
|
333
352
|
continue
|
|
334
353
|
|
|
335
354
|
if running == 0:
|
|
@@ -340,18 +359,18 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
340
359
|
raise retryable_error
|
|
341
360
|
# Every in-flight branch is suspended and no slot is
|
|
342
361
|
# free (or no work remains): suspend the parent.
|
|
343
|
-
if
|
|
362
|
+
if resume_times:
|
|
344
363
|
raise TimedSuspendExecution(
|
|
345
364
|
"All concurrent work complete or suspended pending retry.",
|
|
346
|
-
|
|
365
|
+
resume_times[0],
|
|
347
366
|
)
|
|
348
367
|
raise SuspendExecution(
|
|
349
368
|
"All concurrent work complete or suspended and pending external callback."
|
|
350
369
|
)
|
|
351
370
|
|
|
352
371
|
timeout: float | None = None
|
|
353
|
-
if
|
|
354
|
-
timeout = max(
|
|
372
|
+
if resume_times:
|
|
373
|
+
timeout = max(resume_times[0] - time.time(), 0)
|
|
355
374
|
try:
|
|
356
375
|
event: BranchEvent[ResultType] = events.get(timeout=timeout)
|
|
357
376
|
except queue.Empty:
|
|
@@ -383,7 +402,13 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
383
402
|
needs_snapshot_rebuild = True
|
|
384
403
|
case BranchEventKind.SUSPENDED_UNTIL if event.resume_at is not None:
|
|
385
404
|
applied.suspend_until(event.resume_at)
|
|
386
|
-
|
|
405
|
+
if event.resume_at not in waves:
|
|
406
|
+
waves[event.resume_at] = ResumeWave(
|
|
407
|
+
event.resume_at,
|
|
408
|
+
execution_state.schedule_refresh(event.resume_at),
|
|
409
|
+
)
|
|
410
|
+
heapq.heappush(resume_times, event.resume_at)
|
|
411
|
+
waves[event.resume_at].branches.append(applied)
|
|
387
412
|
running -= 1
|
|
388
413
|
needs_snapshot_rebuild = True
|
|
389
414
|
case BranchEventKind.ORPHANED:
|
|
@@ -402,6 +427,9 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
402
427
|
msg = f"Unhandled branch event: {event}"
|
|
403
428
|
raise InvalidStateError(msg)
|
|
404
429
|
finally:
|
|
430
|
+
# Nothing will wait for a refresh whose wave never resumed.
|
|
431
|
+
for wave in waves.values():
|
|
432
|
+
wave.refresh.cancel()
|
|
405
433
|
# Shutdown without waiting for running threads for early return
|
|
406
434
|
# when completion criteria are met (e.g., min_successful).
|
|
407
435
|
# Running threads continue in the background of this invocation
|
|
@@ -13,7 +13,7 @@ BAD_REQUEST_ERROR: int = 400
|
|
|
13
13
|
TOO_MANY_REQUESTS_ERROR: int = 429
|
|
14
14
|
SERVICE_ERROR: int = 500
|
|
15
15
|
INVALID_PARAMETER_VALUE_EXCEPTION: str = "InvalidParameterValueException"
|
|
16
|
-
INVALID_CHECKPOINT_TOKEN_PREFIX: str = "Invalid
|
|
16
|
+
INVALID_CHECKPOINT_TOKEN_PREFIX: str = "Invalid checkpoint token"
|
|
17
17
|
|
|
18
18
|
# Non-retryable customer error codes that arrive as non-4xx (e.g. HTTP 502) from Lambda.
|
|
19
19
|
# Unlike typical 5xx errors, these require customer intervention (e.g., fixing
|
|
@@ -162,7 +162,7 @@ class BotoClientError(InvocationError):
|
|
|
162
162
|
These arrive as HTTP 502 but require customer intervention to fix.
|
|
163
163
|
- 4xx errors → EXECUTION, except:
|
|
164
164
|
- 429 (TooManyRequests) → INVOCATION (throttling is transient)
|
|
165
|
-
- InvalidParameterValueException with "Invalid
|
|
165
|
+
- InvalidParameterValueException with "Invalid checkpoint token" (exact match) → INVOCATION
|
|
166
166
|
(stale token from a concurrent checkpoint; next invocation gets a fresh token)
|
|
167
167
|
- 5xx, network errors → INVOCATION
|
|
168
168
|
"""
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import functools
|
|
6
|
+
import heapq
|
|
7
|
+
import itertools
|
|
6
8
|
import json
|
|
7
9
|
import logging
|
|
8
10
|
import queue
|
|
9
11
|
import threading
|
|
10
12
|
import time
|
|
11
13
|
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
-
from dataclasses import dataclass
|
|
14
|
+
from dataclasses import dataclass, field
|
|
13
15
|
from enum import Enum
|
|
14
16
|
from threading import Lock
|
|
15
17
|
from typing import TYPE_CHECKING, Callable, NoReturn
|
|
@@ -47,6 +49,14 @@ if TYPE_CHECKING:
|
|
|
47
49
|
|
|
48
50
|
logger = logging.getLogger(__name__)
|
|
49
51
|
|
|
52
|
+
# Longest wait on an empty queue before re-checking the shutdown signal.
|
|
53
|
+
_STOP_SIGNAL_POLL_SECONDS = 0.1
|
|
54
|
+
|
|
55
|
+
# Longest wait on an empty queue when a caller is already blocked on the batch.
|
|
56
|
+
# Long enough for sibling branch threads to arrive. Short enough that the
|
|
57
|
+
# blocked caller stays fast.
|
|
58
|
+
_BLOCKED_CALLER_WAIT_SECONDS = 0.001
|
|
59
|
+
|
|
50
60
|
|
|
51
61
|
@dataclass(frozen=True)
|
|
52
62
|
class CheckpointBatcherConfig:
|
|
@@ -54,7 +64,10 @@ class CheckpointBatcherConfig:
|
|
|
54
64
|
|
|
55
65
|
Attributes:
|
|
56
66
|
max_batch_size_bytes: Maximum batch size in bytes (default: 750KB)
|
|
57
|
-
max_batch_time_seconds:
|
|
67
|
+
max_batch_time_seconds: Longest a batch keeps accumulating (default:
|
|
68
|
+
1.0 second). The collector flushes as soon as a wait on the queue
|
|
69
|
+
finds it empty. So this value only matters while operations keep
|
|
70
|
+
arriving.
|
|
58
71
|
max_batch_operations: Maximum number of operations per batch (default: 250)
|
|
59
72
|
"""
|
|
60
73
|
|
|
@@ -76,6 +89,82 @@ class QueuedOperation:
|
|
|
76
89
|
completion_event: CompletionEvent | None = None
|
|
77
90
|
|
|
78
91
|
|
|
92
|
+
class _Signal(Enum):
|
|
93
|
+
"""Control items that travel through the checkpoint queue, never sent."""
|
|
94
|
+
|
|
95
|
+
# A refresh was scheduled. The collector re-reads the heap and its timeout.
|
|
96
|
+
REFRESH_WAKE = "refresh_wake"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ScheduledRefresh:
|
|
101
|
+
"""Handle for a delayed empty checkpoint from ExecutionState.schedule_refresh.
|
|
102
|
+
|
|
103
|
+
Internal to the SDK. Not part of the public API.
|
|
104
|
+
|
|
105
|
+
A response fetched before earliest_check_time cannot show the wait complete,
|
|
106
|
+
so the collector holds the checkpoint until then. wait() blocks until it has
|
|
107
|
+
been sent and the operations reloaded, or raises the error that stopped
|
|
108
|
+
checkpointing. cancel() drops it if it has not been sent yet. One already in
|
|
109
|
+
flight completes normally.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
earliest_check_time: float
|
|
113
|
+
completion_event: CompletionEvent
|
|
114
|
+
cancelled: threading.Event = field(default_factory=threading.Event)
|
|
115
|
+
|
|
116
|
+
def wait(self, timeout: float | None = None) -> bool:
|
|
117
|
+
return self.completion_event.wait(timeout)
|
|
118
|
+
|
|
119
|
+
def is_set(self) -> bool:
|
|
120
|
+
return self.completion_event.is_set()
|
|
121
|
+
|
|
122
|
+
def is_cancelled(self) -> bool:
|
|
123
|
+
return self.cancelled.is_set()
|
|
124
|
+
|
|
125
|
+
def cancel(self) -> None:
|
|
126
|
+
self.cancelled.set()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _BatchAccumulator:
|
|
130
|
+
"""The operations of one checkpoint request, with the limit accounting.
|
|
131
|
+
|
|
132
|
+
Empty checkpoints carry no bytes. The first one counts toward the operation
|
|
133
|
+
limit and later ones do not, so a resume wave of any width fits one request.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, config: CheckpointBatcherConfig) -> None:
|
|
137
|
+
self._config = config
|
|
138
|
+
self.operations: list[QueuedOperation] = []
|
|
139
|
+
self.total_size = 0
|
|
140
|
+
self.effective_count = 0
|
|
141
|
+
self.has_empty = False
|
|
142
|
+
# A sync checkpoint's caller is blocked until the batch persists, so it
|
|
143
|
+
# cannot queue more work. Waiting long for more work is then pointless.
|
|
144
|
+
self.has_blocked_caller = False
|
|
145
|
+
|
|
146
|
+
def is_empty(self) -> bool:
|
|
147
|
+
return not self.operations
|
|
148
|
+
|
|
149
|
+
def is_full(self) -> bool:
|
|
150
|
+
return self.effective_count >= self._config.max_batch_operations
|
|
151
|
+
|
|
152
|
+
def fits(self, size: int) -> bool:
|
|
153
|
+
return self.total_size + size <= self._config.max_batch_size_bytes
|
|
154
|
+
|
|
155
|
+
def add(self, op: QueuedOperation, size: int = 0) -> None:
|
|
156
|
+
self.operations.append(op)
|
|
157
|
+
if op.completion_event is not None:
|
|
158
|
+
self.has_blocked_caller = True
|
|
159
|
+
if op.operation_update is None:
|
|
160
|
+
if not self.has_empty:
|
|
161
|
+
self.effective_count += 1
|
|
162
|
+
self.has_empty = True
|
|
163
|
+
return
|
|
164
|
+
self.total_size += size
|
|
165
|
+
self.effective_count += 1
|
|
166
|
+
|
|
167
|
+
|
|
79
168
|
# Statuses indicating an operation has finished and will not change on a later
|
|
80
169
|
# replay. Includes TIMED_OUT/CANCELLED/STOPPED in addition to SUCCEEDED/FAILED
|
|
81
170
|
_TERMINAL_OPERATION_STATUSES: frozenset[OperationStatus] = frozenset(
|
|
@@ -266,7 +355,10 @@ class ReplayStatus(Enum):
|
|
|
266
355
|
|
|
267
356
|
|
|
268
357
|
class ExecutionState:
|
|
269
|
-
"""Get, set and maintain execution state. This is mutable. Create and check checkpoints.
|
|
358
|
+
"""Get, set and maintain execution state. This is mutable. Create and check checkpoints.
|
|
359
|
+
|
|
360
|
+
Internal to the SDK. Not part of the public API.
|
|
361
|
+
"""
|
|
270
362
|
|
|
271
363
|
def __init__(
|
|
272
364
|
self,
|
|
@@ -291,8 +383,14 @@ class ExecutionState:
|
|
|
291
383
|
)
|
|
292
384
|
|
|
293
385
|
# Checkpoint batching components
|
|
294
|
-
self._checkpoint_queue: queue.Queue[QueuedOperation] = queue.Queue()
|
|
386
|
+
self._checkpoint_queue: queue.Queue[QueuedOperation | _Signal] = queue.Queue()
|
|
295
387
|
self._overflow_queue: queue.Queue[QueuedOperation] = queue.Queue()
|
|
388
|
+
# Refreshes not yet sent, ordered by earliest_check_time. Producers push
|
|
389
|
+
# and the collector pops, so every access holds _completion_lock.
|
|
390
|
+
self._pending_refreshes: list[tuple[float, int, ScheduledRefresh]] = []
|
|
391
|
+
self._pending_refresh_seq = itertools.count()
|
|
392
|
+
# True while a REFRESH_WAKE is in the queue, so many refreshes make one.
|
|
393
|
+
self._refresh_wake_enqueued = False
|
|
296
394
|
self._checkpointing_stopped: threading.Event = threading.Event()
|
|
297
395
|
self._checkpointing_failed: CompletionEvent = CompletionEvent()
|
|
298
396
|
# Set once the service confirms the execution has completed (a checkpoint
|
|
@@ -720,6 +818,77 @@ class ExecutionState:
|
|
|
720
818
|
else:
|
|
721
819
|
logger.debug("Enqueued checkpoint operation for asynchronous processing")
|
|
722
820
|
|
|
821
|
+
def schedule_refresh(self, earliest_check_time: float) -> ScheduledRefresh:
|
|
822
|
+
"""Enqueue a delayed empty checkpoint and return at once.
|
|
823
|
+
|
|
824
|
+
The collector holds it until earliest_check_time, then sends it with
|
|
825
|
+
every other one due at that time in one request, however far apart they
|
|
826
|
+
were requested. One requested after its time joins the next batch. The
|
|
827
|
+
caller waits on the handle when it needs the refreshed operations, and
|
|
828
|
+
cancels it if it stops needing them.
|
|
829
|
+
"""
|
|
830
|
+
refresh = ScheduledRefresh(earliest_check_time, CompletionEvent())
|
|
831
|
+
with self._completion_lock:
|
|
832
|
+
if self._checkpointing_failed.is_set():
|
|
833
|
+
self._checkpointing_failed.wait()
|
|
834
|
+
self._reject_if_execution_completed(None)
|
|
835
|
+
if self._checkpointing_stopped.is_set():
|
|
836
|
+
raise OrphanedChildException(
|
|
837
|
+
"Checkpointing stopped. The refresh will not be processed.",
|
|
838
|
+
operation_id="",
|
|
839
|
+
)
|
|
840
|
+
heapq.heappush(
|
|
841
|
+
self._pending_refreshes,
|
|
842
|
+
(earliest_check_time, next(self._pending_refresh_seq), refresh),
|
|
843
|
+
)
|
|
844
|
+
if not self._refresh_wake_enqueued:
|
|
845
|
+
self._refresh_wake_enqueued = True
|
|
846
|
+
self._checkpoint_queue.put(_Signal.REFRESH_WAKE)
|
|
847
|
+
return refresh
|
|
848
|
+
|
|
849
|
+
def _consume_refresh_wake(self) -> None:
|
|
850
|
+
with self._completion_lock:
|
|
851
|
+
self._refresh_wake_enqueued = False
|
|
852
|
+
|
|
853
|
+
@staticmethod
|
|
854
|
+
def _settle_cancelled_refresh(refresh: ScheduledRefresh) -> None:
|
|
855
|
+
# Nobody waits on a cancelled refresh by contract. Setting the event
|
|
856
|
+
# keeps a waiter that broke the contract from blocking forever.
|
|
857
|
+
refresh.completion_event.set()
|
|
858
|
+
|
|
859
|
+
def _seconds_until_next_refresh(self, now: float) -> float | None:
|
|
860
|
+
# A cancelled refresh must not set the wake time. Each needless wake
|
|
861
|
+
# would extend the collection by one more read.
|
|
862
|
+
with self._completion_lock:
|
|
863
|
+
while (
|
|
864
|
+
self._pending_refreshes and self._pending_refreshes[0][2].is_cancelled()
|
|
865
|
+
):
|
|
866
|
+
self._settle_cancelled_refresh(
|
|
867
|
+
heapq.heappop(self._pending_refreshes)[2]
|
|
868
|
+
)
|
|
869
|
+
if not self._pending_refreshes:
|
|
870
|
+
return None
|
|
871
|
+
return self._pending_refreshes[0][0] - now
|
|
872
|
+
|
|
873
|
+
def _add_due_refreshes(self, batch: _BatchAccumulator, now: float) -> int:
|
|
874
|
+
"""Move every refresh whose time has come into the batch. Returns how many."""
|
|
875
|
+
added = 0
|
|
876
|
+
with self._completion_lock:
|
|
877
|
+
while self._pending_refreshes and self._pending_refreshes[0][0] <= now:
|
|
878
|
+
refresh = heapq.heappop(self._pending_refreshes)[2]
|
|
879
|
+
if refresh.is_cancelled():
|
|
880
|
+
self._settle_cancelled_refresh(refresh)
|
|
881
|
+
continue
|
|
882
|
+
batch.add(QueuedOperation(None, refresh.completion_event))
|
|
883
|
+
added += 1
|
|
884
|
+
return added
|
|
885
|
+
|
|
886
|
+
def _drain_pending_refreshes(self) -> list[ScheduledRefresh]:
|
|
887
|
+
"""Take every unsent refresh. The caller holds _completion_lock."""
|
|
888
|
+
drained = [entry[2] for entry in self._pending_refreshes]
|
|
889
|
+
self._pending_refreshes.clear()
|
|
890
|
+
return drained
|
|
891
|
+
|
|
723
892
|
def create_checkpoint_sync(
|
|
724
893
|
self,
|
|
725
894
|
operation_update: OperationUpdate | None = None,
|
|
@@ -924,18 +1093,24 @@ class ExecutionState:
|
|
|
924
1093
|
while not self._overflow_queue.empty():
|
|
925
1094
|
try:
|
|
926
1095
|
item = self._overflow_queue.get_nowait()
|
|
927
|
-
if item.completion_event:
|
|
928
|
-
item.completion_event.set(bg_error)
|
|
929
1096
|
except queue.Empty:
|
|
930
1097
|
break
|
|
1098
|
+
if item.completion_event:
|
|
1099
|
+
item.completion_event.set(bg_error)
|
|
931
1100
|
|
|
932
1101
|
while not self._checkpoint_queue.empty():
|
|
933
1102
|
try:
|
|
934
|
-
|
|
935
|
-
if item.completion_event:
|
|
936
|
-
item.completion_event.set(bg_error)
|
|
1103
|
+
queued = self._checkpoint_queue.get_nowait()
|
|
937
1104
|
except queue.Empty:
|
|
938
1105
|
break
|
|
1106
|
+
if (
|
|
1107
|
+
isinstance(queued, QueuedOperation)
|
|
1108
|
+
and queued.completion_event
|
|
1109
|
+
):
|
|
1110
|
+
queued.completion_event.set(bg_error)
|
|
1111
|
+
|
|
1112
|
+
for refresh in self._drain_pending_refreshes():
|
|
1113
|
+
refresh.completion_event.set(bg_error)
|
|
939
1114
|
|
|
940
1115
|
# Future checkpoint attempts fail immediately.
|
|
941
1116
|
self._checkpointing_failed.set(bg_error)
|
|
@@ -943,6 +1118,19 @@ class ExecutionState:
|
|
|
943
1118
|
# Exit the loop - error has been signaled to main thread via completion events
|
|
944
1119
|
break
|
|
945
1120
|
|
|
1121
|
+
# A refresh still pending at shutdown will never be sent. Its caller, if
|
|
1122
|
+
# one is waiting, must not block forever. Settle it as orphaned. The lock
|
|
1123
|
+
# orders this against schedule_refresh, which refuses once stopped.
|
|
1124
|
+
with self._completion_lock:
|
|
1125
|
+
unsent_refreshes = self._drain_pending_refreshes()
|
|
1126
|
+
for refresh in unsent_refreshes:
|
|
1127
|
+
refresh.completion_event.set(
|
|
1128
|
+
OrphanedChildException(
|
|
1129
|
+
"Checkpointing stopped before the refresh time.",
|
|
1130
|
+
operation_id="",
|
|
1131
|
+
)
|
|
1132
|
+
)
|
|
1133
|
+
|
|
946
1134
|
logger.debug("Background checkpoint processing stopped")
|
|
947
1135
|
|
|
948
1136
|
def _settle_after_execution_completed(self) -> None:
|
|
@@ -959,24 +1147,34 @@ class ExecutionState:
|
|
|
959
1147
|
self._execution_completed.set()
|
|
960
1148
|
self._checkpointing_stopped.set()
|
|
961
1149
|
|
|
1150
|
+
orphaned = OrphanedChildException(
|
|
1151
|
+
"Execution already completed; checkpoint will not be processed.",
|
|
1152
|
+
operation_id="",
|
|
1153
|
+
)
|
|
1154
|
+
for refresh in self._drain_pending_refreshes():
|
|
1155
|
+
refresh.completion_event.set(orphaned)
|
|
1156
|
+
unsent: list[QueuedOperation] = []
|
|
962
1157
|
for pending_queue in (self._overflow_queue, self._checkpoint_queue):
|
|
963
1158
|
while not pending_queue.empty():
|
|
964
1159
|
try:
|
|
965
|
-
|
|
1160
|
+
item = pending_queue.get_nowait()
|
|
966
1161
|
except queue.Empty:
|
|
967
1162
|
break
|
|
968
|
-
if
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
queued_op.
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1163
|
+
if isinstance(item, QueuedOperation):
|
|
1164
|
+
unsent.append(item)
|
|
1165
|
+
for queued_op in unsent:
|
|
1166
|
+
if queued_op.completion_event is not None:
|
|
1167
|
+
operation_id: str = (
|
|
1168
|
+
queued_op.operation_update.operation_id
|
|
1169
|
+
if queued_op.operation_update is not None
|
|
1170
|
+
else ""
|
|
1171
|
+
)
|
|
1172
|
+
queued_op.completion_event.set(
|
|
1173
|
+
OrphanedChildException(
|
|
1174
|
+
"Execution already completed; checkpoint will not be processed.",
|
|
1175
|
+
operation_id=operation_id,
|
|
979
1176
|
)
|
|
1177
|
+
)
|
|
980
1178
|
|
|
981
1179
|
def stop_checkpointing(self) -> None:
|
|
982
1180
|
"""Signal background thread to stop checkpointing.
|
|
@@ -1018,134 +1216,116 @@ class ExecutionState:
|
|
|
1018
1216
|
return True
|
|
1019
1217
|
|
|
1020
1218
|
def _collect_checkpoint_batch(self) -> list[QueuedOperation]:
|
|
1021
|
-
"""Collect
|
|
1219
|
+
"""Collect the operations for one checkpoint request.
|
|
1022
1220
|
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1221
|
+
Intake order is the overflow queue, then refreshes whose time has come,
|
|
1222
|
+
then the main queue. The first read blocks until an operation arrives, a
|
|
1223
|
+
refresh comes due, or shutdown is signalled. Later reads wait
|
|
1224
|
+
_BLOCKED_CALLER_WAIT_SECONDS once the batch holds a sync checkpoint,
|
|
1225
|
+
because that caller is blocked and cannot add work, and otherwise up to
|
|
1226
|
+
max_batch_time_seconds.
|
|
1027
1227
|
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
This avoids unnecessary batches when many concurrent map/parallel branches resume
|
|
1032
|
-
simultaneously and each queues an empty checkpoint.
|
|
1228
|
+
A refresh waits in the heap until its earliest_check_time, so refreshes
|
|
1229
|
+
due at the same time share one request. The final due check seals the
|
|
1230
|
+
batch. A refresh scheduled after it joins the next batch.
|
|
1033
1231
|
|
|
1034
|
-
Returns
|
|
1035
|
-
List of QueuedOperation objects ready for batch processing. Returns empty list
|
|
1036
|
-
if no operations are available.
|
|
1232
|
+
Returns the operations for the request, or an empty list at shutdown.
|
|
1037
1233
|
"""
|
|
1038
|
-
batch
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
effective_operation_count += (
|
|
1052
|
-
1 # First empty counts toward limit
|
|
1053
|
-
)
|
|
1054
|
-
has_empty_checkpoint = True
|
|
1055
|
-
# Subsequent empties don't count toward limit
|
|
1056
|
-
else:
|
|
1057
|
-
op_size = self._calculate_operation_size(overflow_op)
|
|
1058
|
-
if total_size + op_size > self._batcher_config.max_batch_size_bytes:
|
|
1059
|
-
# Put back and stop
|
|
1060
|
-
self._overflow_queue.put(overflow_op)
|
|
1061
|
-
break
|
|
1062
|
-
batch.append(overflow_op)
|
|
1063
|
-
total_size += op_size
|
|
1064
|
-
effective_operation_count += 1
|
|
1065
|
-
except queue.Empty:
|
|
1066
|
-
pass
|
|
1067
|
-
|
|
1068
|
-
# If batch is empty, get first operation from main queue
|
|
1069
|
-
if not batch:
|
|
1070
|
-
# Block for first operation, checking stop signal periodically
|
|
1071
|
-
while not self._checkpointing_stopped.is_set():
|
|
1072
|
-
try:
|
|
1073
|
-
first_op = self._checkpoint_queue.get(
|
|
1074
|
-
timeout=0.1
|
|
1075
|
-
) # Check stop signal every 100ms
|
|
1076
|
-
self._checkpoint_queue.task_done()
|
|
1077
|
-
batch.append(first_op)
|
|
1078
|
-
|
|
1079
|
-
if first_op.operation_update is None:
|
|
1080
|
-
has_empty_checkpoint = True
|
|
1081
|
-
else:
|
|
1082
|
-
total_size += self._calculate_operation_size(first_op)
|
|
1083
|
-
|
|
1084
|
-
effective_operation_count = 1
|
|
1085
|
-
break
|
|
1086
|
-
except queue.Empty:
|
|
1087
|
-
continue
|
|
1088
|
-
|
|
1089
|
-
# If stopped and no operation retrieved, return empty batch
|
|
1090
|
-
if not batch:
|
|
1091
|
-
return batch
|
|
1092
|
-
|
|
1093
|
-
# Start batching window using configured time
|
|
1094
|
-
batch_deadline = time.time() + self._batcher_config.max_batch_time_seconds
|
|
1095
|
-
|
|
1096
|
-
# Collect additional operations within the time window
|
|
1097
|
-
while (
|
|
1098
|
-
time.time() < batch_deadline
|
|
1099
|
-
and effective_operation_count < self._batcher_config.max_batch_operations
|
|
1100
|
-
and not self._checkpointing_stopped.is_set()
|
|
1101
|
-
):
|
|
1102
|
-
remaining_time = min(
|
|
1103
|
-
batch_deadline - time.time(),
|
|
1104
|
-
0.1, # Check stop signal every 100ms
|
|
1105
|
-
)
|
|
1106
|
-
|
|
1107
|
-
if remaining_time <= 0:
|
|
1234
|
+
batch = _BatchAccumulator(self._batcher_config)
|
|
1235
|
+
self._drain_overflow(batch)
|
|
1236
|
+
self._add_due_refreshes(batch, time.time())
|
|
1237
|
+
|
|
1238
|
+
if batch.is_empty() and not self._wait_for_first_operation(batch):
|
|
1239
|
+
return []
|
|
1240
|
+
|
|
1241
|
+
deadline = time.time() + self._batcher_config.max_batch_time_seconds
|
|
1242
|
+
while not batch.is_full() and not self._checkpointing_stopped.is_set():
|
|
1243
|
+
now = time.time()
|
|
1244
|
+
self._add_due_refreshes(batch, now)
|
|
1245
|
+
timeout = self._read_timeout(batch, now, deadline)
|
|
1246
|
+
if timeout <= 0:
|
|
1108
1247
|
break
|
|
1109
|
-
|
|
1110
1248
|
try:
|
|
1111
|
-
|
|
1112
|
-
self._checkpoint_queue.task_done()
|
|
1113
|
-
|
|
1114
|
-
if additional_op.operation_update is None: # Empty checkpoint
|
|
1115
|
-
batch.append(additional_op)
|
|
1116
|
-
if not has_empty_checkpoint:
|
|
1117
|
-
effective_operation_count += (
|
|
1118
|
-
1 # First empty counts toward limit
|
|
1119
|
-
)
|
|
1120
|
-
has_empty_checkpoint = True
|
|
1121
|
-
# Subsequent empties don't count toward limit
|
|
1122
|
-
else:
|
|
1123
|
-
op_size = self._calculate_operation_size(additional_op)
|
|
1124
|
-
# Check if adding this operation would exceed size limit
|
|
1125
|
-
if total_size + op_size > self._batcher_config.max_batch_size_bytes:
|
|
1126
|
-
# Put in overflow queue for next batch
|
|
1127
|
-
self._overflow_queue.put(additional_op)
|
|
1128
|
-
logger.debug(
|
|
1129
|
-
"Batch size limit reached, moving operation to overflow queue"
|
|
1130
|
-
)
|
|
1131
|
-
break
|
|
1132
|
-
batch.append(additional_op)
|
|
1133
|
-
total_size += op_size
|
|
1134
|
-
effective_operation_count += 1
|
|
1135
|
-
|
|
1249
|
+
item = self._checkpoint_queue.get(timeout=timeout)
|
|
1136
1250
|
except queue.Empty:
|
|
1251
|
+
# A due refresh can end the read early. Keep collecting only if one
|
|
1252
|
+
# joined, so refreshes due within one wait share the batch.
|
|
1253
|
+
if self._add_due_refreshes(batch, time.time()):
|
|
1254
|
+
continue
|
|
1255
|
+
break
|
|
1256
|
+
self._checkpoint_queue.task_done()
|
|
1257
|
+
if isinstance(item, _Signal):
|
|
1258
|
+
self._consume_refresh_wake()
|
|
1259
|
+
continue
|
|
1260
|
+
size = self._calculate_operation_size(item)
|
|
1261
|
+
if not batch.fits(size):
|
|
1262
|
+
self._overflow_queue.put(item)
|
|
1263
|
+
logger.debug(
|
|
1264
|
+
"Batch size limit reached, moving operation to overflow queue"
|
|
1265
|
+
)
|
|
1137
1266
|
break
|
|
1267
|
+
batch.add(item, size)
|
|
1268
|
+
|
|
1269
|
+
self._add_due_refreshes(batch, time.time())
|
|
1138
1270
|
|
|
1139
|
-
empty_count = sum(1 for q in batch if q.operation_update is None)
|
|
1271
|
+
empty_count = sum(1 for q in batch.operations if q.operation_update is None)
|
|
1140
1272
|
logger.debug(
|
|
1141
1273
|
"Collected batch of %d operations (%d effective, %d non-empty, %d empty), total size: %d bytes",
|
|
1142
|
-
len(batch),
|
|
1143
|
-
|
|
1144
|
-
len(batch) - empty_count,
|
|
1274
|
+
len(batch.operations),
|
|
1275
|
+
batch.effective_count,
|
|
1276
|
+
len(batch.operations) - empty_count,
|
|
1145
1277
|
empty_count,
|
|
1146
|
-
total_size,
|
|
1278
|
+
batch.total_size,
|
|
1147
1279
|
)
|
|
1148
|
-
return batch
|
|
1280
|
+
return batch.operations
|
|
1281
|
+
|
|
1282
|
+
def _drain_overflow(self, batch: _BatchAccumulator) -> None:
|
|
1283
|
+
"""Take operations left over from earlier batches, oldest first."""
|
|
1284
|
+
try:
|
|
1285
|
+
while not batch.is_full():
|
|
1286
|
+
op = self._overflow_queue.get_nowait()
|
|
1287
|
+
size = self._calculate_operation_size(op)
|
|
1288
|
+
if not batch.fits(size):
|
|
1289
|
+
self._overflow_queue.put(op)
|
|
1290
|
+
break
|
|
1291
|
+
batch.add(op, size)
|
|
1292
|
+
except queue.Empty:
|
|
1293
|
+
pass
|
|
1294
|
+
|
|
1295
|
+
def _wait_for_first_operation(self, batch: _BatchAccumulator) -> bool:
|
|
1296
|
+
"""Block until the batch has an operation. Returns False at shutdown."""
|
|
1297
|
+
while not self._checkpointing_stopped.is_set():
|
|
1298
|
+
now = time.time()
|
|
1299
|
+
timeout = _STOP_SIGNAL_POLL_SECONDS
|
|
1300
|
+
until_refresh = self._seconds_until_next_refresh(now)
|
|
1301
|
+
if until_refresh is not None:
|
|
1302
|
+
timeout = max(0.0, min(timeout, until_refresh))
|
|
1303
|
+
try:
|
|
1304
|
+
item = self._checkpoint_queue.get(timeout=timeout)
|
|
1305
|
+
except queue.Empty:
|
|
1306
|
+
if self._add_due_refreshes(batch, time.time()):
|
|
1307
|
+
return True
|
|
1308
|
+
continue
|
|
1309
|
+
self._checkpoint_queue.task_done()
|
|
1310
|
+
if isinstance(item, _Signal):
|
|
1311
|
+
self._consume_refresh_wake()
|
|
1312
|
+
continue
|
|
1313
|
+
# The first operation is never refused for size. It could not be
|
|
1314
|
+
# sent otherwise.
|
|
1315
|
+
batch.add(item, self._calculate_operation_size(item))
|
|
1316
|
+
return True
|
|
1317
|
+
return False
|
|
1318
|
+
|
|
1319
|
+
def _read_timeout(
|
|
1320
|
+
self, batch: _BatchAccumulator, now: float, deadline: float
|
|
1321
|
+
) -> float:
|
|
1322
|
+
timeout = min(deadline - now, _STOP_SIGNAL_POLL_SECONDS)
|
|
1323
|
+
until_refresh = self._seconds_until_next_refresh(now)
|
|
1324
|
+
if until_refresh is not None:
|
|
1325
|
+
timeout = min(timeout, until_refresh)
|
|
1326
|
+
if batch.has_blocked_caller:
|
|
1327
|
+
timeout = min(timeout, _BLOCKED_CALLER_WAIT_SECONDS)
|
|
1328
|
+
return timeout
|
|
1149
1329
|
|
|
1150
1330
|
@staticmethod
|
|
1151
1331
|
def _calculate_operation_size(queued_op: QueuedOperation) -> int:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: aws-durable-execution-sdk-python
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.1
|
|
4
4
|
Summary: AWS Durable Execution SDK for Python
|
|
5
5
|
Project-URL: Documentation, https://github.com/aws/aws-durable-execution-sdk-python#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/aws/aws-durable-execution-sdk-python/issues
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
aws_durable_execution_sdk_python/.gitignore,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
aws_durable_execution_sdk_python/__about__.py,sha256=
|
|
2
|
+
aws_durable_execution_sdk_python/__about__.py,sha256=JTKxzhd0vBceCgP4Nsi3Wjz8K-nKVjiSzkQR_y8dkAY,137
|
|
3
3
|
aws_durable_execution_sdk_python/__init__.py,sha256=K10hqr0JcYNhf41AkKVvboKK1AXLrZVQJ3Foy4h3taE,2488
|
|
4
4
|
aws_durable_execution_sdk_python/config.py,sha256=lTV4g_LXiFcyjiXQ5Kn7XvtFDuybw8tvgigetY55u9I,29176
|
|
5
5
|
aws_durable_execution_sdk_python/constants.py,sha256=Pdgfagm9wcWt5UZRv1tLXvCUqkalv89zrWoEoUSL4EA,259
|
|
6
6
|
aws_durable_execution_sdk_python/context.py,sha256=D1Z-0pXzUTDXTE3PMK6Mw-XpPFV9q-XWtAy6k_nYLBI,37408
|
|
7
|
-
aws_durable_execution_sdk_python/exceptions.py,sha256=
|
|
7
|
+
aws_durable_execution_sdk_python/exceptions.py,sha256=nBg9aZxfKSYilmy-xdMIjR2gVGqKfOf2BZA9TJO4RlU,21355
|
|
8
8
|
aws_durable_execution_sdk_python/execution.py,sha256=kLDGU0EvwuJdjxVvE9ZcGKUz8lBhWhS8pirGDjYz2Kc,19968
|
|
9
9
|
aws_durable_execution_sdk_python/filesystem_serdes.py,sha256=wPURZb9bwfUqTUhnNp3kOBojMyFnXxjL-QPkO6Yk1aw,10685
|
|
10
10
|
aws_durable_execution_sdk_python/identifier.py,sha256=0NQBir435WivGGZWlj6520u4UqT0yxIig2iUFLAzuVQ,3023
|
|
@@ -16,13 +16,13 @@ aws_durable_execution_sdk_python/preview.py,sha256=taizk3reZ-1SZYsM-0YGW5Oyze-tD
|
|
|
16
16
|
aws_durable_execution_sdk_python/py.typed,sha256=DbCDcAirv769HS1ibzZLXq8VPazYf7JCkKSzj3ctudM,58
|
|
17
17
|
aws_durable_execution_sdk_python/retries.py,sha256=X8hfFPnrNR_LLJdiQ9g-KX8aQN5BKZTscmlQBVTt9NU,13678
|
|
18
18
|
aws_durable_execution_sdk_python/serdes.py,sha256=_AH09m5vLKzd_6vn8krnit7p9ogqV4eLvH0shyoog4M,17347
|
|
19
|
-
aws_durable_execution_sdk_python/state.py,sha256=
|
|
19
|
+
aws_durable_execution_sdk_python/state.py,sha256=7C56NoRA44k9TRpISjUDQbA7YYLZeepp8tNkSZMK1wA,61123
|
|
20
20
|
aws_durable_execution_sdk_python/suspend.py,sha256=PIgc6w5-diZIq58-tPv51dzBSHGW7e4oR0QFam8Vgi0,3323
|
|
21
21
|
aws_durable_execution_sdk_python/threading.py,sha256=cU7vIEWHYco-qqkgoAMyPHSOQ8b_Q_r_rZGG7m1g7I4,7650
|
|
22
22
|
aws_durable_execution_sdk_python/types.py,sha256=MFTaRL8XN2PoelJKfonMhNEgTZkOGYDkGRqx2WzQHNo,7022
|
|
23
23
|
aws_durable_execution_sdk_python/waits.py,sha256=NIF77G5VtXYb4748IC8MEvbE_L1ipBPA_HSkSQE4PlA,4119
|
|
24
24
|
aws_durable_execution_sdk_python/concurrency/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
-
aws_durable_execution_sdk_python/concurrency/executor.py,sha256=
|
|
25
|
+
aws_durable_execution_sdk_python/concurrency/executor.py,sha256=2EZiXHAJTX3zBnuji56zBiuS86AgXQRqD6W8OuZN5i0,37433
|
|
26
26
|
aws_durable_execution_sdk_python/concurrency/models.py,sha256=Agi0Y_N8aKzWxQBeqhoiN97n-o0mkcw0n7-Fuw_FHt0,25135
|
|
27
27
|
aws_durable_execution_sdk_python/operation/__init__.py,sha256=xjtSpukZb6sftgJ5oVPSc6TcgKgVQg4vXHeYIeKOPks,25
|
|
28
28
|
aws_durable_execution_sdk_python/operation/base.py,sha256=kuUroQnJFo36rH3Ad0IJjuLi4XlL2d13tN9fmKuqw3o,7632
|
|
@@ -36,6 +36,6 @@ aws_durable_execution_sdk_python/operation/wait.py,sha256=YMVomY1QpPfyACBnN-kQk8
|
|
|
36
36
|
aws_durable_execution_sdk_python/operation/wait_for_condition.py,sha256=BS8rFZGQqQoV0q3gG_1MZSZxXXdl8u8dyYTRGcguMS4,13082
|
|
37
37
|
aws_durable_execution_sdk_python/LICENSE,sha256=CeipvOyAZxBGUsFoaFqwkx54aPnIKEtm9a5u2uXxEws,10142
|
|
38
38
|
aws_durable_execution_sdk_python/NOTICE,sha256=1CkO1kwu3Q_OHYTj-d-yiBJA_lNN73a4zSntavaD4oc,67
|
|
39
|
-
aws_durable_execution_sdk_python-2.0.
|
|
40
|
-
aws_durable_execution_sdk_python-2.0.
|
|
41
|
-
aws_durable_execution_sdk_python-2.0.
|
|
39
|
+
aws_durable_execution_sdk_python-2.0.1.dist-info/METADATA,sha256=NMLsc77gVyCpm75B39WaUlEP9Hkob5NYhCy_5mNni-k,6902
|
|
40
|
+
aws_durable_execution_sdk_python-2.0.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
|
|
41
|
+
aws_durable_execution_sdk_python-2.0.1.dist-info/RECORD,,
|