aws-durable-execution-sdk-python 2.0.0__py3-none-any.whl → 2.1.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.
@@ -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.1.0"
@@ -2,12 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import contextvars
5
6
  import heapq
6
7
  import logging
7
8
  import queue
8
9
  import time
9
10
  from collections import deque
10
11
  from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass, field
11
13
  from typing import TYPE_CHECKING, Generic, TypeVar, cast
12
14
 
13
15
  from aws_durable_execution_sdk_python.concurrency.models import (
@@ -43,7 +45,7 @@ from aws_durable_execution_sdk_python.identifier import (
43
45
  OperationIdentifier,
44
46
  OperationIdNamespace,
45
47
  )
46
- from aws_durable_execution_sdk_python.lambda_service import ErrorObject
48
+ from aws_durable_execution_sdk_python.lambda_service import ErrorObject, OperationType
47
49
  from aws_durable_execution_sdk_python.operation.child import child_handler
48
50
 
49
51
 
@@ -56,6 +58,7 @@ if TYPE_CHECKING:
56
58
  from aws_durable_execution_sdk_python.state import (
57
59
  CheckpointedResult,
58
60
  ExecutionState,
61
+ ScheduledRefresh,
59
62
  )
60
63
 
61
64
 
@@ -86,6 +89,20 @@ def _branch_error_object(err: Exception) -> ErrorObject:
86
89
  return ErrorObject.from_exception(err)
87
90
 
88
91
 
92
+ @dataclass
93
+ class ResumeWave(Generic[CallableType, ResultType]):
94
+ """Branches suspended until one time, and the refresh that resumes them.
95
+
96
+ The refresh is a delayed empty checkpoint requested when the first branch
97
+ suspends. Its response shows the waits complete, so the wave resumes one
98
+ round trip after its time.
99
+ """
100
+
101
+ resume_at: float
102
+ refresh: ScheduledRefresh
103
+ branches: list[Branch[CallableType, ResultType]] = field(default_factory=list)
104
+
105
+
89
106
  class ConcurrentExecutor(Generic[CallableType, ResultType]):
90
107
  """Execute durable operations concurrently. This contains the execution logic for Map and Parallel.
91
108
 
@@ -167,6 +184,7 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
167
184
  executable.index
168
185
  ),
169
186
  sub_type=self.sub_type_iteration,
187
+ operation_type=OperationType.CONTEXT,
170
188
  parent_id=executor_context._parent_id, # noqa: SLF001
171
189
  name=self.get_iteration_name(executable.index),
172
190
  )
@@ -252,7 +270,9 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
252
270
 
253
271
  events: queue.Queue[BranchEvent[ResultType]] = queue.Queue()
254
272
  pending: deque[Branch[CallableType, ResultType]] = deque(self.branches)
255
- timed_resumes: list[tuple[float, int]] = []
273
+ # The heap orders resume times. The dict groups the branches under each.
274
+ resume_times: list[float] = []
275
+ waves: dict[float, ResumeWave[CallableType, ResultType]] = {}
256
276
  branch_by_index: dict[int, Branch[CallableType, ResultType]] = {
257
277
  branch.index: branch for branch in self.branches
258
278
  }
@@ -273,13 +293,25 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
273
293
 
274
294
  def submit(branch: Branch[CallableType, ResultType]) -> None:
275
295
  branch.start()
276
- pool.submit(
277
- self._branch_worker,
278
- execution_state,
279
- executor_context,
280
- events,
281
- branch.executable,
282
- )
296
+ if execution_state._plugin_executor._plugins: # noqa: SLF001
297
+ # Every admission/resume gets its own Context. Branch bindings
298
+ # cannot leak into siblings, the coordinator, or reused workers.
299
+ pool.submit(
300
+ contextvars.copy_context().run,
301
+ self._branch_worker,
302
+ execution_state,
303
+ executor_context,
304
+ events,
305
+ branch.executable,
306
+ )
307
+ else:
308
+ pool.submit(
309
+ self._branch_worker,
310
+ execution_state,
311
+ executor_context,
312
+ events,
313
+ branch.executable,
314
+ )
283
315
 
284
316
  try:
285
317
  # Only rebuild the items snapshot after a terminal event changes
@@ -317,19 +349,20 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
317
349
  running += 1
318
350
  needs_snapshot_rebuild = True
319
351
 
320
- # Resume due timed suspends in-process. One checkpoint
321
- # refresh serves the whole due wave; a failure is terminal
352
+ # Resume due waves in-process. A refresh failure is terminal
322
353
  # for the execution and propagates from this thread.
323
354
  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:
355
+ resumed = False
356
+ while resume_times and resume_times[0] <= now:
357
+ wave = waves.pop(heapq.heappop(resume_times))
358
+ wave.refresh.wait()
359
+ # Branches joined the wave in event order. Resume in index
360
+ # order so scheduling stays deterministic.
361
+ for branch in sorted(wave.branches, key=lambda b: b.index):
331
362
  submit(branch)
332
363
  running += 1
364
+ resumed = True
365
+ if resumed:
333
366
  continue
334
367
 
335
368
  if running == 0:
@@ -340,18 +373,18 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
340
373
  raise retryable_error
341
374
  # Every in-flight branch is suspended and no slot is
342
375
  # free (or no work remains): suspend the parent.
343
- if timed_resumes:
376
+ if resume_times:
344
377
  raise TimedSuspendExecution(
345
378
  "All concurrent work complete or suspended pending retry.",
346
- timed_resumes[0][0],
379
+ resume_times[0],
347
380
  )
348
381
  raise SuspendExecution(
349
382
  "All concurrent work complete or suspended and pending external callback."
350
383
  )
351
384
 
352
385
  timeout: float | None = None
353
- if timed_resumes:
354
- timeout = max(timed_resumes[0][0] - time.time(), 0)
386
+ if resume_times:
387
+ timeout = max(resume_times[0] - time.time(), 0)
355
388
  try:
356
389
  event: BranchEvent[ResultType] = events.get(timeout=timeout)
357
390
  except queue.Empty:
@@ -383,7 +416,13 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
383
416
  needs_snapshot_rebuild = True
384
417
  case BranchEventKind.SUSPENDED_UNTIL if event.resume_at is not None:
385
418
  applied.suspend_until(event.resume_at)
386
- heapq.heappush(timed_resumes, (event.resume_at, event.index))
419
+ if event.resume_at not in waves:
420
+ waves[event.resume_at] = ResumeWave(
421
+ event.resume_at,
422
+ execution_state.schedule_refresh(event.resume_at),
423
+ )
424
+ heapq.heappush(resume_times, event.resume_at)
425
+ waves[event.resume_at].branches.append(applied)
387
426
  running -= 1
388
427
  needs_snapshot_rebuild = True
389
428
  case BranchEventKind.ORPHANED:
@@ -402,6 +441,9 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
402
441
  msg = f"Unhandled branch event: {event}"
403
442
  raise InvalidStateError(msg)
404
443
  finally:
444
+ # Nothing will wait for a refresh whose wave never resumed.
445
+ for wave in waves.values():
446
+ wave.refresh.cancel()
405
447
  # Shutdown without waiting for running threads for early return
406
448
  # when completion criteria are met (e.g., min_successful).
407
449
  # Running threads continue in the background of this invocation
@@ -51,7 +51,10 @@ from aws_durable_execution_sdk_python.operation.callback import (
51
51
  CallbackOperationExecutor,
52
52
  wait_for_callback_handler,
53
53
  )
54
- from aws_durable_execution_sdk_python.operation.child import child_handler
54
+ from aws_durable_execution_sdk_python.operation.child import (
55
+ ChildOperationExecutor,
56
+ child_handler,
57
+ )
55
58
  from aws_durable_execution_sdk_python.operation.invoke import InvokeOperationExecutor
56
59
  from aws_durable_execution_sdk_python.operation.map import map_handler
57
60
  from aws_durable_execution_sdk_python.operation.parallel import parallel_handler
@@ -515,12 +518,18 @@ class DurableContext(DurableContextProtocol):
515
518
 
516
519
  @contextmanager
517
520
  def _replay_aware(
518
- self, operation_identifier: OperationIdentifier | None = None
521
+ self,
522
+ operation_identifier: OperationIdentifier | None = None,
523
+ *,
524
+ is_virtual: bool = False,
519
525
  ) -> Iterator[None]:
520
526
  """Wrap a single operation with replay-boundary detection.
521
527
 
522
528
  The operation kind is inferred from its own checkpoint (when one
523
- exists), so no per-call-site flag is needed.
529
+ exists). The one per-call-site input is `is_virtual`: a virtual child
530
+ context never checkpoints itself, so with `is_virtual=True` any
531
+ checkpoint at its id is rejected right after the identity check,
532
+ before replay and update hooks run.
524
533
 
525
534
  The boundary has these parts:
526
535
 
@@ -555,6 +564,10 @@ class DurableContext(DurableContextProtocol):
555
564
  if was_replaying and next_operation is not None:
556
565
  if operation_identifier is not None:
557
566
  operation_identifier.validate_checkpoint(next_operation)
567
+ if is_virtual:
568
+ ChildOperationExecutor.validate_virtual_checkpoint(
569
+ operation_identifier, next_operation
570
+ )
558
571
 
559
572
  # While replaying, an operation that already has a checkpoint was
560
573
  # observed in a prior invocation. If the backend says the operation
@@ -599,7 +612,9 @@ class DurableContext(DurableContextProtocol):
599
612
  self,
600
613
  sub_type: OperationSubType,
601
614
  name: str | None = None,
602
- operation_type: OperationType | None = None,
615
+ *,
616
+ operation_type: OperationType,
617
+ is_virtual: bool = False,
603
618
  ) -> Iterator[OperationIdentifier]:
604
619
  """Allocate an operation and validate its replay identity before hooks."""
605
620
  operation_identifier = OperationIdentifier(
@@ -609,7 +624,7 @@ class DurableContext(DurableContextProtocol):
609
624
  name=name,
610
625
  operation_type=operation_type,
611
626
  )
612
- with self._replay_aware(operation_identifier):
627
+ with self._replay_aware(operation_identifier, is_virtual=is_virtual):
613
628
  yield operation_identifier
614
629
 
615
630
  # endregion replay status
@@ -636,7 +651,7 @@ class DurableContext(DurableContextProtocol):
636
651
  if not config:
637
652
  config = CallbackConfig()
638
653
  with self._operation_replay_aware(
639
- OperationSubType.CALLBACK, name
654
+ OperationSubType.CALLBACK, name, operation_type=OperationType.CALLBACK
640
655
  ) as operation_identifier:
641
656
  executor: CallbackOperationExecutor = CallbackOperationExecutor(
642
657
  state=self.state,
@@ -672,7 +687,9 @@ class DurableContext(DurableContextProtocol):
672
687
  if not config:
673
688
  config = InvokeConfig[P, R]()
674
689
  with self._operation_replay_aware(
675
- OperationSubType.CHAINED_INVOKE, name
690
+ OperationSubType.CHAINED_INVOKE,
691
+ name,
692
+ operation_type=OperationType.CHAINED_INVOKE,
676
693
  ) as operation_identifier:
677
694
  executor: InvokeOperationExecutor[R] = InvokeOperationExecutor(
678
695
  function_name=function_name,
@@ -700,7 +717,7 @@ class DurableContext(DurableContextProtocol):
700
717
  config.completion_config._validate_for_total(len(inputs))
701
718
 
702
719
  with self._operation_replay_aware(
703
- OperationSubType.MAP, map_name
720
+ OperationSubType.MAP, map_name, operation_type=OperationType.CONTEXT
704
721
  ) as operation_identifier:
705
722
  operation_id = operation_identifier.operation_id
706
723
  map_context = self.create_child_context(operation_id=operation_id)
@@ -751,7 +768,7 @@ class DurableContext(DurableContextProtocol):
751
768
  config.completion_config._validate_for_total(len(functions))
752
769
 
753
770
  with self._operation_replay_aware(
754
- OperationSubType.PARALLEL, name
771
+ OperationSubType.PARALLEL, name, operation_type=OperationType.CONTEXT
755
772
  ) as operation_identifier:
756
773
  operation_id = operation_identifier.operation_id
757
774
  parallel_context = self.create_child_context(operation_id=operation_id)
@@ -811,14 +828,15 @@ class DurableContext(DurableContextProtocol):
811
828
  if config and config.sub_type
812
829
  else OperationSubType.RUN_IN_CHILD_CONTEXT
813
830
  )
831
+ is_virtual: bool = config.is_virtual if config else False
814
832
 
815
833
  with self._operation_replay_aware(
816
834
  sub_type,
817
835
  step_name,
818
836
  operation_type=OperationType.CONTEXT,
837
+ is_virtual=is_virtual,
819
838
  ) as operation_identifier:
820
839
  operation_id = operation_identifier.operation_id
821
- is_virtual: bool = config.is_virtual if config else False
822
840
 
823
841
  def callable_with_child_context():
824
842
  return func(
@@ -845,7 +863,7 @@ class DurableContext(DurableContextProtocol):
845
863
  if not config:
846
864
  config = StepConfig()
847
865
  with self._operation_replay_aware(
848
- OperationSubType.STEP, step_name
866
+ OperationSubType.STEP, step_name, operation_type=OperationType.STEP
849
867
  ) as operation_identifier:
850
868
  executor: StepOperationExecutor[T] = StepOperationExecutor(
851
869
  func=func,
@@ -868,7 +886,7 @@ class DurableContext(DurableContextProtocol):
868
886
  msg = "duration must be at least 1 second"
869
887
  raise ValidationError(msg)
870
888
  with self._operation_replay_aware(
871
- OperationSubType.WAIT, name
889
+ OperationSubType.WAIT, name, operation_type=OperationType.WAIT
872
890
  ) as operation_identifier:
873
891
  wait_seconds = duration.seconds
874
892
  executor: WaitOperationExecutor = WaitOperationExecutor(
@@ -938,7 +956,7 @@ class DurableContext(DurableContextProtocol):
938
956
  raise ValidationError(msg)
939
957
 
940
958
  with self._operation_replay_aware(
941
- OperationSubType.WAIT_FOR_CONDITION, name
959
+ OperationSubType.WAIT_FOR_CONDITION, name, operation_type=OperationType.STEP
942
960
  ) as operation_identifier:
943
961
  executor: WaitForConditionOperationExecutor[T] = (
944
962
  WaitForConditionOperationExecutor(
@@ -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
  """