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.
- aws_durable_execution_sdk_python/__about__.py +1 -1
- aws_durable_execution_sdk_python/concurrency/executor.py +65 -23
- aws_durable_execution_sdk_python/context.py +31 -13
- aws_durable_execution_sdk_python/exceptions.py +2 -2
- aws_durable_execution_sdk_python/execution.py +230 -125
- aws_durable_execution_sdk_python/identifier.py +4 -4
- aws_durable_execution_sdk_python/lambda_service.py +10 -27
- aws_durable_execution_sdk_python/operation/child.py +30 -0
- aws_durable_execution_sdk_python/operation/invoke.py +6 -0
- aws_durable_execution_sdk_python/plugin.py +169 -35
- aws_durable_execution_sdk_python/plugin_discovery.py +146 -2
- aws_durable_execution_sdk_python/state.py +444 -181
- aws_durable_execution_sdk_python-2.1.0.dist-info/METADATA +261 -0
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/RECORD +15 -15
- {aws_durable_execution_sdk_python-2.0.0.dist-info → aws_durable_execution_sdk_python-2.1.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python-2.0.0.dist-info/METADATA +0 -156
|
@@ -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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
325
|
-
while
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for branch in
|
|
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
|
|
376
|
+
if resume_times:
|
|
344
377
|
raise TimedSuspendExecution(
|
|
345
378
|
"All concurrent work complete or suspended pending retry.",
|
|
346
|
-
|
|
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
|
|
354
|
-
timeout = max(
|
|
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
|
-
|
|
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
|
|
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,
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
"""
|