aws-durable-execution-sdk-python 2.1.0__py3-none-any.whl → 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_durable_execution_sdk_python/__about__.py +1 -1
- aws_durable_execution_sdk_python/concurrency/executor.py +2 -4
- aws_durable_execution_sdk_python/concurrency/models.py +31 -21
- aws_durable_execution_sdk_python/config.py +109 -7
- aws_durable_execution_sdk_python/context.py +63 -22
- aws_durable_execution_sdk_python/execution.py +30 -20
- aws_durable_execution_sdk_python/identifier.py +9 -8
- aws_durable_execution_sdk_python/lambda_service.py +55 -36
- aws_durable_execution_sdk_python/operation/child.py +0 -5
- aws_durable_execution_sdk_python/operation/map.py +4 -4
- aws_durable_execution_sdk_python/operation/parallel.py +4 -4
- aws_durable_execution_sdk_python/plugin.py +1190 -152
- aws_durable_execution_sdk_python/plugin_discovery.py +296 -132
- aws_durable_execution_sdk_python/state.py +101 -7
- {aws_durable_execution_sdk_python-2.1.0.dist-info → aws_durable_execution_sdk_python-3.0.0.dist-info}/METADATA +91 -41
- {aws_durable_execution_sdk_python-2.1.0.dist-info → aws_durable_execution_sdk_python-3.0.0.dist-info}/RECORD +17 -17
- {aws_durable_execution_sdk_python-2.1.0.dist-info → aws_durable_execution_sdk_python-3.0.0.dist-info}/WHEEL +0 -0
|
@@ -53,7 +53,6 @@ if TYPE_CHECKING:
|
|
|
53
53
|
from collections.abc import Callable
|
|
54
54
|
from aws_durable_execution_sdk_python.config import CompletionConfig
|
|
55
55
|
from aws_durable_execution_sdk_python.context import DurableContext
|
|
56
|
-
from aws_durable_execution_sdk_python.lambda_service import OperationSubType
|
|
57
56
|
from aws_durable_execution_sdk_python.serdes import SerDes
|
|
58
57
|
from aws_durable_execution_sdk_python.state import (
|
|
59
58
|
CheckpointedResult,
|
|
@@ -129,8 +128,8 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
129
128
|
executables: list[Executable[CallableType]],
|
|
130
129
|
max_concurrency: int | None,
|
|
131
130
|
completion_config: CompletionConfig,
|
|
132
|
-
sub_type_top:
|
|
133
|
-
sub_type_iteration:
|
|
131
|
+
sub_type_top: str,
|
|
132
|
+
sub_type_iteration: str,
|
|
134
133
|
name_prefix: str,
|
|
135
134
|
serdes: SerDes | None,
|
|
136
135
|
operation_id_namespace: OperationIdNamespace,
|
|
@@ -702,7 +701,6 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
|
|
|
702
701
|
operation_identifier=operation_identifier,
|
|
703
702
|
config=ChildConfig(
|
|
704
703
|
serdes=self.item_serdes or self.serdes,
|
|
705
|
-
sub_type=self.sub_type_iteration,
|
|
706
704
|
is_virtual=is_virtual,
|
|
707
705
|
),
|
|
708
706
|
)
|
|
@@ -7,7 +7,7 @@ import logging
|
|
|
7
7
|
from collections import Counter
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
from enum import Enum
|
|
10
|
-
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
10
|
+
from typing import TYPE_CHECKING, Generic, TypeVar, cast
|
|
11
11
|
|
|
12
12
|
from aws_durable_execution_sdk_python.exceptions import (
|
|
13
13
|
ChildContextError,
|
|
@@ -396,6 +396,12 @@ def envelope_summary_generator(
|
|
|
396
396
|
|
|
397
397
|
@dataclass(frozen=True)
|
|
398
398
|
class BatchItem(Generic[R]):
|
|
399
|
+
"""One branch of a map or parallel batch.
|
|
400
|
+
|
|
401
|
+
``error`` may be None on a FAILED item when its source contained no
|
|
402
|
+
ErrorObject. ``BatchResult.throw_if_error`` still raises for it.
|
|
403
|
+
"""
|
|
404
|
+
|
|
399
405
|
index: int
|
|
400
406
|
status: BatchItemStatus
|
|
401
407
|
result: R | None = None
|
|
@@ -489,18 +495,10 @@ class BatchResult(Generic[R], BatchResultProtocol[R]): # noqa: PYI059
|
|
|
489
495
|
}
|
|
490
496
|
|
|
491
497
|
def succeeded(self) -> list[BatchItem[R]]:
|
|
492
|
-
return [
|
|
493
|
-
item
|
|
494
|
-
for item in self.all
|
|
495
|
-
if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
|
|
496
|
-
]
|
|
498
|
+
return [item for item in self.all if item.status is BatchItemStatus.SUCCEEDED]
|
|
497
499
|
|
|
498
500
|
def failed(self) -> list[BatchItem[R]]:
|
|
499
|
-
return [
|
|
500
|
-
item
|
|
501
|
-
for item in self.all
|
|
502
|
-
if item.status is BatchItemStatus.FAILED and item.error is not None
|
|
503
|
-
]
|
|
501
|
+
return [item for item in self.all if item.status is BatchItemStatus.FAILED]
|
|
504
502
|
|
|
505
503
|
def started(self) -> list[BatchItem[R]]:
|
|
506
504
|
return [item for item in self.all if item.status is BatchItemStatus.STARTED]
|
|
@@ -518,32 +516,44 @@ class BatchResult(Generic[R], BatchResultProtocol[R]): # noqa: PYI059
|
|
|
518
516
|
return any(item.status is BatchItemStatus.FAILED for item in self.all)
|
|
519
517
|
|
|
520
518
|
def throw_if_error(self) -> None:
|
|
521
|
-
|
|
522
|
-
(item
|
|
519
|
+
first_failed = next(
|
|
520
|
+
(item for item in self.all if item.status is BatchItemStatus.FAILED),
|
|
523
521
|
None,
|
|
524
522
|
)
|
|
525
|
-
if
|
|
523
|
+
if first_failed is not None:
|
|
524
|
+
first_error = first_failed.error
|
|
525
|
+
if first_error is None:
|
|
526
|
+
raise ChildContextError(
|
|
527
|
+
message=(
|
|
528
|
+
f"Batch item {first_failed.index} failed "
|
|
529
|
+
"without an ErrorObject."
|
|
530
|
+
)
|
|
531
|
+
)
|
|
532
|
+
|
|
526
533
|
# Each item runs in a child context, so a failed item surfaces as a
|
|
527
534
|
# ChildContextError wrapping the escaping error (reconstructed as its
|
|
528
535
|
# typed form where possible, with __cause__ set). Remaining errors
|
|
529
536
|
# stay in get_errors().
|
|
530
537
|
first_error.raise_as_operation_error(ChildContextError)
|
|
531
538
|
if self.completion_reason is CompletionReason.CUSTOM_COMPLETION_FAILED:
|
|
532
|
-
# A custom decision marked the batch failed with no item
|
|
539
|
+
# A custom decision marked the batch failed with no failed item.
|
|
533
540
|
raise BatchCompletionError(self.completion_reason)
|
|
534
541
|
|
|
535
542
|
def get_results(self) -> list[R]:
|
|
543
|
+
# None is a valid result for a branch that returns nothing.
|
|
536
544
|
return [
|
|
537
|
-
item.result
|
|
545
|
+
cast(R, item.result)
|
|
538
546
|
for item in self.all
|
|
539
|
-
if item.status is BatchItemStatus.SUCCEEDED
|
|
547
|
+
if item.status is BatchItemStatus.SUCCEEDED
|
|
540
548
|
]
|
|
541
549
|
|
|
542
|
-
def get_errors(self) -> list[ErrorObject]:
|
|
550
|
+
def get_errors(self) -> list[ErrorObject | None]:
|
|
551
|
+
"""Return one entry per FAILED item, in branch order.
|
|
552
|
+
|
|
553
|
+
An entry is None when the corresponding item has no ErrorObject.
|
|
554
|
+
"""
|
|
543
555
|
return [
|
|
544
|
-
item.error
|
|
545
|
-
for item in self.all
|
|
546
|
-
if item.status is BatchItemStatus.FAILED and item.error is not None
|
|
556
|
+
item.error for item in self.all if item.status is BatchItemStatus.FAILED
|
|
547
557
|
]
|
|
548
558
|
|
|
549
559
|
@property
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import math
|
|
6
6
|
import random
|
|
7
|
+
import string
|
|
7
8
|
from dataclasses import dataclass, field
|
|
8
9
|
from enum import Enum, StrEnum
|
|
9
10
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
@@ -18,7 +19,6 @@ T = TypeVar("T")
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
19
20
|
from collections.abc import Callable
|
|
20
21
|
|
|
21
|
-
from aws_durable_execution_sdk_python.lambda_service import OperationSubType
|
|
22
22
|
from aws_durable_execution_sdk_python.retries import RetryDecision
|
|
23
23
|
from aws_durable_execution_sdk_python.serdes import SerDes
|
|
24
24
|
from aws_durable_execution_sdk_python.types import SummaryGenerator
|
|
@@ -481,13 +481,69 @@ class StepSemantics(Enum):
|
|
|
481
481
|
AT_LEAST_ONCE_PER_RETRY = "AT_LEAST_ONCE_PER_RETRY"
|
|
482
482
|
|
|
483
483
|
|
|
484
|
+
_SUB_TYPE_CHARACTERS = frozenset(string.ascii_letters + string.digits + "-_")
|
|
485
|
+
_SUB_TYPE_MAX_LENGTH = 32
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _validate_sub_type(sub_type: str | None) -> None:
|
|
489
|
+
"""Raise ValidationError when sub_type is set and the service would reject it.
|
|
490
|
+
|
|
491
|
+
The service accepts 1 to 32 characters from A-Z, a-z, 0-9, hyphen, and
|
|
492
|
+
underscore. None and "" select the SDK default, so both are accepted.
|
|
493
|
+
Checking here raises before any checkpoint is sent, so a step body never
|
|
494
|
+
runs under a subtype the service will reject.
|
|
495
|
+
"""
|
|
496
|
+
if not sub_type:
|
|
497
|
+
return
|
|
498
|
+
if (
|
|
499
|
+
len(sub_type) > _SUB_TYPE_MAX_LENGTH
|
|
500
|
+
or not set(sub_type) <= _SUB_TYPE_CHARACTERS
|
|
501
|
+
):
|
|
502
|
+
msg = (
|
|
503
|
+
f"sub_type must be 1 to {_SUB_TYPE_MAX_LENGTH} characters from "
|
|
504
|
+
f"A-Z, a-z, 0-9, hyphen, and underscore, got: {sub_type!r}"
|
|
505
|
+
)
|
|
506
|
+
raise ValidationError(msg)
|
|
507
|
+
|
|
508
|
+
|
|
484
509
|
@dataclass(frozen=True)
|
|
485
510
|
class StepConfig:
|
|
486
|
-
"""Configuration for a step.
|
|
511
|
+
"""Configuration for a step.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
retry_strategy: Decides whether and when a failed attempt is retried.
|
|
515
|
+
If None, the SDK uses its default retry strategy.
|
|
516
|
+
|
|
517
|
+
step_semantics: How often one attempt may run the function.
|
|
518
|
+
AT_LEAST_ONCE_PER_RETRY (the default) checkpoints START without
|
|
519
|
+
waiting, so an interrupted attempt runs again on the next
|
|
520
|
+
invocation. AT_MOST_ONCE_PER_RETRY waits for the START checkpoint
|
|
521
|
+
first, and an interrupted attempt fails with StepInterruptedError
|
|
522
|
+
and goes to the retry strategy instead of running again.
|
|
523
|
+
|
|
524
|
+
serdes: Custom serialization/deserialization for the step result.
|
|
525
|
+
If None, uses the default JSON serializer.
|
|
526
|
+
|
|
527
|
+
sub_type: Subtype recorded in the checkpoint, in replay validation, and
|
|
528
|
+
in plugin events. The default is "Step".
|
|
529
|
+
A custom operation built from steps uses this to label its steps,
|
|
530
|
+
so history and plugins can filter them without parsing names.
|
|
531
|
+
The value must be 1 to 32 characters from A-Z, a-z, 0-9, hyphen,
|
|
532
|
+
and underscore; the config raises ValidationError otherwise. None
|
|
533
|
+
or an empty string selects the default. Do not reuse an
|
|
534
|
+
``OperationSubType`` value: those label the SDK's own operations.
|
|
535
|
+
Replay compares the subtype with the checkpoint. So changing the
|
|
536
|
+
subtype of an existing step is a breaking change for executions
|
|
537
|
+
in flight.
|
|
538
|
+
"""
|
|
487
539
|
|
|
488
540
|
retry_strategy: Callable[[Exception, int], RetryDecision] | None = None
|
|
489
541
|
step_semantics: StepSemantics = StepSemantics.AT_LEAST_ONCE_PER_RETRY
|
|
490
542
|
serdes: SerDes | None = None
|
|
543
|
+
sub_type: str | None = field(default=None, kw_only=True)
|
|
544
|
+
|
|
545
|
+
def __post_init__(self) -> None:
|
|
546
|
+
_validate_sub_type(self.sub_type)
|
|
491
547
|
|
|
492
548
|
|
|
493
549
|
@dataclass(frozen=True)
|
|
@@ -502,9 +558,18 @@ class ChildConfig(Generic[T]):
|
|
|
502
558
|
Applied at the handler level to serialize the entire BatchResult object.
|
|
503
559
|
If None, uses the default JSON serializer for BatchResult.
|
|
504
560
|
|
|
505
|
-
sub_type:
|
|
506
|
-
|
|
507
|
-
|
|
561
|
+
sub_type: Subtype recorded in the checkpoint, in replay validation, and
|
|
562
|
+
in plugin events. The default is "RunInChildContext".
|
|
563
|
+
A custom operation built from several durable operations uses this
|
|
564
|
+
to label its child context, so history and plugins can filter it
|
|
565
|
+
without parsing names. The value must be 1 to 32 characters from
|
|
566
|
+
A-Z, a-z, 0-9, hyphen, and underscore; the config raises
|
|
567
|
+
ValidationError otherwise. None or an empty string selects the
|
|
568
|
+
default. Do not reuse an ``OperationSubType`` value: those label
|
|
569
|
+
the SDK's own operations.
|
|
570
|
+
Replay compares the subtype with the checkpoint. So changing the
|
|
571
|
+
subtype of an existing child context is a breaking change for
|
|
572
|
+
executions in flight.
|
|
508
573
|
|
|
509
574
|
summary_generator: Function generating the checkpoint payload for large
|
|
510
575
|
results (>256KB). When the serialized result exceeds CHECKPOINT_SIZE_LIMIT,
|
|
@@ -528,10 +593,13 @@ class ChildConfig(Generic[T]):
|
|
|
528
593
|
"""
|
|
529
594
|
|
|
530
595
|
serdes: SerDes | None = None
|
|
531
|
-
sub_type:
|
|
596
|
+
sub_type: str | None = None
|
|
532
597
|
summary_generator: SummaryGenerator | None = None
|
|
533
598
|
is_virtual: bool = False
|
|
534
599
|
|
|
600
|
+
def __post_init__(self) -> None:
|
|
601
|
+
_validate_sub_type(self.sub_type)
|
|
602
|
+
|
|
535
603
|
|
|
536
604
|
@dataclass(frozen=True)
|
|
537
605
|
class MapConfig(Generic[T]):
|
|
@@ -649,11 +717,45 @@ class InvokeConfig(Generic[P, R]):
|
|
|
649
717
|
|
|
650
718
|
@dataclass(frozen=True)
|
|
651
719
|
class CallbackConfig:
|
|
652
|
-
"""Configuration for callbacks.
|
|
720
|
+
"""Configuration for callbacks.
|
|
721
|
+
|
|
722
|
+
Args:
|
|
723
|
+
timeout: Maximum time to wait for the callback result. The default,
|
|
724
|
+
a zero Duration, means no timeout.
|
|
725
|
+
|
|
726
|
+
heartbeat_timeout: Maximum time between heartbeats before the callback
|
|
727
|
+
fails. The default, a zero Duration, means no heartbeat timeout.
|
|
728
|
+
|
|
729
|
+
serdes: Custom deserialization for the callback result.
|
|
730
|
+
If None, the payload is returned as is, without JSON parsing.
|
|
731
|
+
|
|
732
|
+
sub_type: Subtype recorded in the checkpoint, in replay validation, and
|
|
733
|
+
in plugin events. The default is "Callback".
|
|
734
|
+
A custom operation built from callbacks uses this to label its
|
|
735
|
+
callbacks, so history and plugins can filter them without parsing
|
|
736
|
+
names. The value must be 1 to 32 characters from A-Z, a-z, 0-9,
|
|
737
|
+
hyphen, and underscore; the config raises ValidationError
|
|
738
|
+
otherwise. None or an empty string selects the default. Do not
|
|
739
|
+
reuse an ``OperationSubType`` value: those label the SDK's own
|
|
740
|
+
operations. Under
|
|
741
|
+
``wait_for_callback`` the subtype labels the callback the
|
|
742
|
+
operation creates; the surrounding context keeps the subtype
|
|
743
|
+
"WaitForCallback".
|
|
744
|
+
Replay compares the subtype with the checkpoint. So changing the
|
|
745
|
+
subtype of an existing callback is a breaking change for
|
|
746
|
+
executions in flight.
|
|
747
|
+
"""
|
|
653
748
|
|
|
654
749
|
timeout: Duration = field(default_factory=Duration)
|
|
655
750
|
heartbeat_timeout: Duration = field(default_factory=Duration)
|
|
656
751
|
serdes: SerDes | None = None
|
|
752
|
+
# Keyword-only, so the positional order of every config, including
|
|
753
|
+
# WaitForCallbackConfig(timeout, heartbeat_timeout, serdes, retry_strategy),
|
|
754
|
+
# is unchanged from 2.x.
|
|
755
|
+
sub_type: str | None = field(default=None, kw_only=True)
|
|
756
|
+
|
|
757
|
+
def __post_init__(self) -> None:
|
|
758
|
+
_validate_sub_type(self.sub_type)
|
|
657
759
|
|
|
658
760
|
@property
|
|
659
761
|
def timeout_seconds(self) -> int:
|
|
@@ -509,8 +509,15 @@ class DurableContext(DurableContextProtocol):
|
|
|
509
509
|
)
|
|
510
510
|
|
|
511
511
|
def _peek_next_checkpoint(self) -> CheckpointedResult:
|
|
512
|
-
"""Return the checkpoint for this context's next operation, without consuming it.
|
|
513
|
-
|
|
512
|
+
"""Return the checkpoint for this context's next operation, without consuming it.
|
|
513
|
+
|
|
514
|
+
The read takes no plugin end hook: the hook of an operation a
|
|
515
|
+
checkpoint response completed during this invocation belongs to the
|
|
516
|
+
read that consumes the result, the operation's own.
|
|
517
|
+
"""
|
|
518
|
+
return self.state.get_checkpoint_result(
|
|
519
|
+
self._peek_next_operation_id(), claim_end_hook=False
|
|
520
|
+
)
|
|
514
521
|
|
|
515
522
|
def _next_operation_exists(self) -> bool:
|
|
516
523
|
"""True if a checkpoint exists for this context's next operation."""
|
|
@@ -548,9 +555,14 @@ class DurableContext(DurableContextProtocol):
|
|
|
548
555
|
# Only look up a checkpoint when replaying. Operation call sites pass
|
|
549
556
|
# their atomically allocated identifier so concurrent callers inspect
|
|
550
557
|
# the checkpoint for their own ID rather than peeking at shared state.
|
|
558
|
+
# Both reads inspect: neither consumes the result, so neither takes
|
|
559
|
+
# the plugin end hook a checkpoint response may have recorded for the
|
|
560
|
+
# operation. The operation's own read, which consumes the result, does.
|
|
551
561
|
next_checkpoint: CheckpointedResult | None = (
|
|
552
562
|
(
|
|
553
|
-
self.state.get_checkpoint_result(
|
|
563
|
+
self.state.get_checkpoint_result(
|
|
564
|
+
operation_identifier.operation_id, claim_end_hook=False
|
|
565
|
+
)
|
|
554
566
|
if operation_identifier is not None
|
|
555
567
|
else self._peek_next_checkpoint()
|
|
556
568
|
)
|
|
@@ -610,7 +622,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
610
622
|
@contextmanager
|
|
611
623
|
def _operation_replay_aware(
|
|
612
624
|
self,
|
|
613
|
-
sub_type:
|
|
625
|
+
sub_type: str,
|
|
614
626
|
name: str | None = None,
|
|
615
627
|
*,
|
|
616
628
|
operation_type: OperationType,
|
|
@@ -644,14 +656,16 @@ class DurableContext(DurableContextProtocol):
|
|
|
644
656
|
Args:
|
|
645
657
|
name (str): Optional name for the operation.
|
|
646
658
|
config (CallbackConfig): Configuration for the callback.
|
|
659
|
+
``config.sub_type`` labels the operation; see CallbackConfig.
|
|
647
660
|
|
|
648
661
|
Return:
|
|
649
|
-
Callback future. Use result() on this future to wait for the callback
|
|
662
|
+
Callback future. Use result() on this future to wait for the callback result.
|
|
650
663
|
"""
|
|
651
664
|
if not config:
|
|
652
665
|
config = CallbackConfig()
|
|
666
|
+
sub_type = config.sub_type or OperationSubType.CALLBACK.value
|
|
653
667
|
with self._operation_replay_aware(
|
|
654
|
-
|
|
668
|
+
sub_type, name, operation_type=OperationType.CALLBACK
|
|
655
669
|
) as operation_identifier:
|
|
656
670
|
executor: CallbackOperationExecutor = CallbackOperationExecutor(
|
|
657
671
|
state=self.state,
|
|
@@ -687,7 +701,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
687
701
|
if not config:
|
|
688
702
|
config = InvokeConfig[P, R]()
|
|
689
703
|
with self._operation_replay_aware(
|
|
690
|
-
OperationSubType.CHAINED_INVOKE,
|
|
704
|
+
OperationSubType.CHAINED_INVOKE.value,
|
|
691
705
|
name,
|
|
692
706
|
operation_type=OperationType.CHAINED_INVOKE,
|
|
693
707
|
) as operation_identifier:
|
|
@@ -717,7 +731,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
717
731
|
config.completion_config._validate_for_total(len(inputs))
|
|
718
732
|
|
|
719
733
|
with self._operation_replay_aware(
|
|
720
|
-
OperationSubType.MAP, map_name, operation_type=OperationType.CONTEXT
|
|
734
|
+
OperationSubType.MAP.value, map_name, operation_type=OperationType.CONTEXT
|
|
721
735
|
) as operation_identifier:
|
|
722
736
|
operation_id = operation_identifier.operation_id
|
|
723
737
|
map_context = self.create_child_context(operation_id=operation_id)
|
|
@@ -742,7 +756,6 @@ class DurableContext(DurableContextProtocol):
|
|
|
742
756
|
state=self.state,
|
|
743
757
|
operation_identifier=operation_identifier,
|
|
744
758
|
config=ChildConfig(
|
|
745
|
-
sub_type=OperationSubType.MAP,
|
|
746
759
|
serdes=config.serdes if config else None,
|
|
747
760
|
# The SDK-owned envelope records the completion decision for
|
|
748
761
|
# replay. A configured summary_generator only contributes the
|
|
@@ -768,7 +781,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
768
781
|
config.completion_config._validate_for_total(len(functions))
|
|
769
782
|
|
|
770
783
|
with self._operation_replay_aware(
|
|
771
|
-
OperationSubType.PARALLEL, name, operation_type=OperationType.CONTEXT
|
|
784
|
+
OperationSubType.PARALLEL.value, name, operation_type=OperationType.CONTEXT
|
|
772
785
|
) as operation_identifier:
|
|
773
786
|
operation_id = operation_identifier.operation_id
|
|
774
787
|
parallel_context = self.create_child_context(operation_id=operation_id)
|
|
@@ -792,7 +805,6 @@ class DurableContext(DurableContextProtocol):
|
|
|
792
805
|
state=self.state,
|
|
793
806
|
operation_identifier=operation_identifier,
|
|
794
807
|
config=ChildConfig(
|
|
795
|
-
sub_type=OperationSubType.PARALLEL,
|
|
796
808
|
serdes=config.serdes if config else None,
|
|
797
809
|
# The SDK-owned envelope records the completion decision for
|
|
798
810
|
# replay. A configured summary_generator only contributes the
|
|
@@ -815,19 +827,33 @@ class DurableContext(DurableContextProtocol):
|
|
|
815
827
|
Use this to nest and group operations.
|
|
816
828
|
|
|
817
829
|
Args:
|
|
818
|
-
|
|
830
|
+
func (Callable[[DurableContext], T]): Run this callable and pass the child context as the argument to it.
|
|
819
831
|
name (str | None): name for the operation.
|
|
820
|
-
config (ChildConfig | None = None):
|
|
832
|
+
config (ChildConfig | None = None): Configuration for the child
|
|
833
|
+
context. ``config.sub_type`` labels the operation; see
|
|
834
|
+
ChildConfig.
|
|
821
835
|
|
|
822
836
|
Returns:
|
|
823
837
|
T: The result of the callable.
|
|
824
838
|
"""
|
|
825
839
|
step_name: str | None = self._resolve_step_name(name, func)
|
|
826
840
|
sub_type = (
|
|
827
|
-
config.sub_type
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
841
|
+
config.sub_type if config else None
|
|
842
|
+
) or OperationSubType.RUN_IN_CHILD_CONTEXT.value
|
|
843
|
+
return self._run_in_child_context(func, step_name, config, sub_type)
|
|
844
|
+
|
|
845
|
+
def _run_in_child_context(
|
|
846
|
+
self,
|
|
847
|
+
func: Callable[[DurableContext], T],
|
|
848
|
+
step_name: str | None,
|
|
849
|
+
config: ChildConfig | None,
|
|
850
|
+
sub_type: str,
|
|
851
|
+
) -> T:
|
|
852
|
+
"""Run ``func`` in a child context that records ``sub_type``.
|
|
853
|
+
|
|
854
|
+
``run_in_child_context`` resolves the caller's ``config.sub_type``
|
|
855
|
+
into ``sub_type``; ``wait_for_callback`` passes its own SDK subtype.
|
|
856
|
+
"""
|
|
831
857
|
is_virtual: bool = config.is_virtual if config else False
|
|
832
858
|
|
|
833
859
|
with self._operation_replay_aware(
|
|
@@ -858,12 +884,24 @@ class DurableContext(DurableContextProtocol):
|
|
|
858
884
|
name: str | None = None,
|
|
859
885
|
config: StepConfig | None = None,
|
|
860
886
|
) -> T:
|
|
887
|
+
"""Run a function once and checkpoint its result.
|
|
888
|
+
|
|
889
|
+
Args:
|
|
890
|
+
func (Callable[[StepContext], T]): The function to run.
|
|
891
|
+
name (str | None): Optional name for the operation.
|
|
892
|
+
config (StepConfig | None): Configuration for the step.
|
|
893
|
+
``config.sub_type`` labels the operation; see StepConfig.
|
|
894
|
+
|
|
895
|
+
Returns:
|
|
896
|
+
T: The function's result, from the checkpoint on replay.
|
|
897
|
+
"""
|
|
861
898
|
step_name = self._resolve_step_name(name, func)
|
|
862
899
|
logger.debug("Step name: %s", step_name)
|
|
863
900
|
if not config:
|
|
864
901
|
config = StepConfig()
|
|
902
|
+
sub_type = config.sub_type or OperationSubType.STEP.value
|
|
865
903
|
with self._operation_replay_aware(
|
|
866
|
-
|
|
904
|
+
sub_type, step_name, operation_type=OperationType.STEP
|
|
867
905
|
) as operation_identifier:
|
|
868
906
|
executor: StepOperationExecutor[T] = StepOperationExecutor(
|
|
869
907
|
func=func,
|
|
@@ -886,7 +924,7 @@ class DurableContext(DurableContextProtocol):
|
|
|
886
924
|
msg = "duration must be at least 1 second"
|
|
887
925
|
raise ValidationError(msg)
|
|
888
926
|
with self._operation_replay_aware(
|
|
889
|
-
OperationSubType.WAIT, name, operation_type=OperationType.WAIT
|
|
927
|
+
OperationSubType.WAIT.value, name, operation_type=OperationType.WAIT
|
|
890
928
|
) as operation_identifier:
|
|
891
929
|
wait_seconds = duration.seconds
|
|
892
930
|
executor: WaitOperationExecutor = WaitOperationExecutor(
|
|
@@ -912,10 +950,11 @@ class DurableContext(DurableContextProtocol):
|
|
|
912
950
|
# inner failure on __cause__; translate to callback-typed errors here, at
|
|
913
951
|
# the call site, so it re-fires identically on replay.
|
|
914
952
|
try:
|
|
915
|
-
return self.
|
|
953
|
+
return self._run_in_child_context(
|
|
916
954
|
wait_in_child_context,
|
|
917
955
|
step_name,
|
|
918
|
-
|
|
956
|
+
None,
|
|
957
|
+
OperationSubType.WAIT_FOR_CALLBACK.value,
|
|
919
958
|
)
|
|
920
959
|
except ChildContextError as e:
|
|
921
960
|
inner: BaseException | None = e.__cause__
|
|
@@ -956,7 +995,9 @@ class DurableContext(DurableContextProtocol):
|
|
|
956
995
|
raise ValidationError(msg)
|
|
957
996
|
|
|
958
997
|
with self._operation_replay_aware(
|
|
959
|
-
OperationSubType.WAIT_FOR_CONDITION,
|
|
998
|
+
OperationSubType.WAIT_FOR_CONDITION.value,
|
|
999
|
+
name,
|
|
1000
|
+
operation_type=OperationType.STEP,
|
|
960
1001
|
) as operation_identifier:
|
|
961
1002
|
executor: WaitForConditionOperationExecutor[T] = (
|
|
962
1003
|
WaitForConditionOperationExecutor(
|
|
@@ -28,7 +28,7 @@ from aws_durable_execution_sdk_python.lambda_service import (
|
|
|
28
28
|
OperationUpdate,
|
|
29
29
|
)
|
|
30
30
|
from aws_durable_execution_sdk_python.plugin import (
|
|
31
|
-
|
|
31
|
+
DurableInstrumentationPluginFactory,
|
|
32
32
|
PluginExecutor,
|
|
33
33
|
)
|
|
34
34
|
from aws_durable_execution_sdk_python.plugin_discovery import (
|
|
@@ -170,7 +170,7 @@ def durable_execution(
|
|
|
170
170
|
func: Callable[[Any, DurableContext], Any] | None = None,
|
|
171
171
|
*,
|
|
172
172
|
boto3_client: Boto3LambdaClient | None = None,
|
|
173
|
-
plugins: list[
|
|
173
|
+
plugins: list[DurableInstrumentationPluginFactory] | None = None,
|
|
174
174
|
) -> Callable[[Any, LambdaContext], Any]:
|
|
175
175
|
"""
|
|
176
176
|
Decorator to create a durable execution handler.
|
|
@@ -178,7 +178,10 @@ def durable_execution(
|
|
|
178
178
|
Args:
|
|
179
179
|
func: The user function to decorate
|
|
180
180
|
boto3_client: Optional boto3 Lambda client to use
|
|
181
|
-
plugins: Optional list of instrumentation
|
|
181
|
+
plugins: Optional list of instrumentation plugin factories. Each
|
|
182
|
+
factory's ``create_plugin`` is called once per invocation with that
|
|
183
|
+
invocation's ``InvocationStartInfo``, and the instance it returns
|
|
184
|
+
serves only that invocation.
|
|
182
185
|
"""
|
|
183
186
|
# Decorator called with parameters
|
|
184
187
|
if func is None:
|
|
@@ -190,9 +193,12 @@ def durable_execution(
|
|
|
190
193
|
logger.debug("Starting durable execution handler...")
|
|
191
194
|
|
|
192
195
|
configured_plugins = load_configured_plugins(plugins)
|
|
193
|
-
plugin_executor = PluginExecutor(configured_plugins)
|
|
194
196
|
|
|
197
|
+
@functools.wraps(func)
|
|
195
198
|
def wrapper(event: Any, context: LambdaContext) -> MutableMapping[str, Any]:
|
|
199
|
+
# Only the factory list outlives an invocation. The worker below owns
|
|
200
|
+
# this fresh executor and all plugin lifecycle hooks.
|
|
201
|
+
plugin_executor = PluginExecutor(configured_plugins)
|
|
196
202
|
invocation_input: DurableExecutionInvocationInput
|
|
197
203
|
service_client: DurableServiceClient
|
|
198
204
|
|
|
@@ -487,10 +493,7 @@ def durable_execution(
|
|
|
487
493
|
# LIFO cleanup: branches join with checkpointing alive;
|
|
488
494
|
# then close stops checkpointing and the wait completes.
|
|
489
495
|
resources.callback(wait_for_checkpoint)
|
|
490
|
-
resources.callback(
|
|
491
|
-
plugin_executor._run_in_invocation_context,
|
|
492
|
-
execution_state.close,
|
|
493
|
-
)
|
|
496
|
+
resources.callback(execution_state.close)
|
|
494
497
|
execution_operation = execution_state.get_execution_operation()
|
|
495
498
|
|
|
496
499
|
# execute the plugins
|
|
@@ -515,9 +518,7 @@ def durable_execution(
|
|
|
515
518
|
checkpoint_future = executor.submit(
|
|
516
519
|
execution_state.checkpoint_batches_forever
|
|
517
520
|
)
|
|
518
|
-
output =
|
|
519
|
-
invoke_handler
|
|
520
|
-
)
|
|
521
|
+
output = invoke_handler()
|
|
521
522
|
# The ExitStack has now closed execution_state and joined the
|
|
522
523
|
# checkpoint thread. So any revocation observed while draining
|
|
523
524
|
# in-flight checkpoint work is visible here. A terminal status
|
|
@@ -539,17 +540,26 @@ def durable_execution(
|
|
|
539
540
|
output = (
|
|
540
541
|
DurableExecutionInvocationOutput.create_pending().to_dict()
|
|
541
542
|
)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
543
|
+
completed = DurableExecutionInvocationOutput.from_dict(output)
|
|
544
|
+
except BaseException as error:
|
|
545
|
+
# Every started plugin gets its End hook, including when
|
|
546
|
+
# cancellation or a thread-control exception escapes. The
|
|
547
|
+
# handler's original failure wins over an End-hook failure.
|
|
548
|
+
try:
|
|
549
|
+
plugin_executor.on_invocation_end(
|
|
550
|
+
DurableExecutionInvocationOutput.create_retry(
|
|
551
|
+
ErrorObject.from_exception(error)
|
|
552
|
+
)
|
|
553
|
+
)
|
|
554
|
+
except BaseException: # noqa: BLE001 - preserve the original failure
|
|
555
|
+
logger.exception(
|
|
556
|
+
"Plugin invocation-end hook failed while the invocation "
|
|
557
|
+
"was already failing; the original failure is raised"
|
|
550
558
|
)
|
|
551
|
-
)
|
|
552
559
|
raise
|
|
560
|
+
# End must run only once; its own failure is not a handler failure.
|
|
561
|
+
plugin_executor.on_invocation_end(completed)
|
|
562
|
+
return output
|
|
553
563
|
|
|
554
564
|
with ThreadPoolExecutor(
|
|
555
565
|
max_workers=2, thread_name_prefix="dex-handler"
|
|
@@ -11,7 +11,6 @@ from aws_durable_execution_sdk_python.exceptions import (
|
|
|
11
11
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
12
12
|
Operation,
|
|
13
13
|
OperationType,
|
|
14
|
-
OperationSubType,
|
|
15
14
|
)
|
|
16
15
|
|
|
17
16
|
|
|
@@ -34,10 +33,15 @@ class OperationIdNamespace:
|
|
|
34
33
|
|
|
35
34
|
@dataclass(frozen=True)
|
|
36
35
|
class OperationIdentifier:
|
|
37
|
-
"""Operation identity with an explicit type independent of its subtype.
|
|
36
|
+
"""Operation identity with an explicit type independent of its subtype.
|
|
37
|
+
|
|
38
|
+
``sub_type`` is the subtype the operation records: the value of an
|
|
39
|
+
``OperationSubType`` member for an SDK operation, or the caller-defined
|
|
40
|
+
string a step, a callback, or a child context was configured with.
|
|
41
|
+
"""
|
|
38
42
|
|
|
39
43
|
operation_id: str
|
|
40
|
-
sub_type:
|
|
44
|
+
sub_type: str
|
|
41
45
|
parent_id: str | None = None
|
|
42
46
|
name: str | None = None
|
|
43
47
|
operation_type: OperationType = field(kw_only=True)
|
|
@@ -61,12 +65,9 @@ class OperationIdentifier:
|
|
|
61
65
|
mismatches.append(
|
|
62
66
|
f"type checkpoint={checkpoint.operation_type.value!r} current={self.type.value!r}"
|
|
63
67
|
)
|
|
64
|
-
if checkpoint.sub_type
|
|
65
|
-
checkpoint_sub_type = (
|
|
66
|
-
checkpoint.sub_type.value if checkpoint.sub_type is not None else None
|
|
67
|
-
)
|
|
68
|
+
if checkpoint.sub_type != self.sub_type:
|
|
68
69
|
mismatches.append(
|
|
69
|
-
f"subtype checkpoint={
|
|
70
|
+
f"subtype checkpoint={checkpoint.sub_type!r} current={self.sub_type!r}"
|
|
70
71
|
)
|
|
71
72
|
if checkpoint_name != expected_name:
|
|
72
73
|
mismatches.append(
|