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.
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates.
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
- __version__ = "2.0.0"
4
+ __version__ = "2.0.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
- timed_resumes: list[tuple[float, int]] = []
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 timed suspends in-process. One checkpoint
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
- due: list[Branch[CallableType, ResultType]] = []
325
- while timed_resumes and timed_resumes[0][0] <= now:
326
- _, index = heapq.heappop(timed_resumes)
327
- due.append(branch_by_index[index])
328
- if due:
329
- execution_state.create_checkpoint()
330
- for branch in due:
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 timed_resumes:
362
+ if resume_times:
344
363
  raise TimedSuspendExecution(
345
364
  "All concurrent work complete or suspended pending retry.",
346
- timed_resumes[0][0],
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 timed_resumes:
354
- timeout = max(timed_resumes[0][0] - time.time(), 0)
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
- heapq.heappush(timed_resumes, (event.resume_at, event.index))
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 Checkpoint Token"
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 Checkpoint Token" → INVOCATION
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: Maximum time to wait before flushing batch (default: 1.0 second)
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
- item = self._checkpoint_queue.get_nowait()
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
- queued_op: QueuedOperation = pending_queue.get_nowait()
1160
+ item = pending_queue.get_nowait()
966
1161
  except queue.Empty:
967
1162
  break
968
- if queued_op.completion_event is not None:
969
- operation_id: str = (
970
- queued_op.operation_update.operation_id
971
- if queued_op.operation_update is not None
972
- else ""
973
- )
974
- queued_op.completion_event.set(
975
- OrphanedChildException(
976
- "Execution already completed; checkpoint will not be processed.",
977
- operation_id=operation_id,
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 multiple checkpoint operations into a batch for API efficiency.
1219
+ """Collect the operations for one checkpoint request.
1022
1220
 
1023
- Processes overflow queue first to maintain FIFO order, then collects from main queue.
1024
- Respects configured size, time, and operation count limits. Blocks for the first
1025
- operation if queues are empty, then collects additional operations within the time
1026
- window.
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
- Empty checkpoints (operation_update=None) are coalesced: the first empty checkpoint
1029
- counts toward the batch operation limit, but subsequent empty checkpoints do not.
1030
- All empty checkpoints remain in the batch so their completion events are signaled.
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: list[QueuedOperation] = []
1039
- has_empty_checkpoint = False
1040
- total_size = 0
1041
- effective_operation_count = 0 # Operations that count toward batch limit
1042
-
1043
- # First, drain overflow queue (FIFO order preserved)
1044
- try:
1045
- while effective_operation_count < self._batcher_config.max_batch_operations:
1046
- overflow_op = self._overflow_queue.get_nowait()
1047
-
1048
- if overflow_op.operation_update is None: # Empty checkpoint
1049
- batch.append(overflow_op)
1050
- if not has_empty_checkpoint:
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
- additional_op = self._checkpoint_queue.get(timeout=remaining_time)
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
- effective_operation_count,
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.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=iMryfzT8zKHfkT0Q7FfA6pgZKIB24Mz7jR5gb7CxJGM,137
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=pi-HAkp_hW-C_J7-5QLaofJqSxr5Tfaxttz-X6mf_Ag,21341
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=94PUbiCtoj2WxsZM9wq7O0tlf9QZb5Ha0kPqnEXGQ_w,53775
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=61Lbm2ahp7VTT6U5-AtjeueaREbu0EX5wBK_EBRN9pw,36221
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.0.dist-info/METADATA,sha256=ganlTgYTZS0rrZKMbNDbrjDZd_HSRgzM6Fyiq_7DxGY,6902
40
- aws_durable_execution_sdk_python-2.0.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
41
- aws_durable_execution_sdk_python-2.0.0.dist-info/RECORD,,
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,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.0
2
+ Generator: hatchling 1.32.3
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any