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.
@@ -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.1.0"
4
+ __version__ = "3.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: OperationSubType,
133
- sub_type_iteration: OperationSubType,
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
- first_error = next(
522
- (item.error for item in self.all if item.status is BatchItemStatus.FAILED),
519
+ first_failed = next(
520
+ (item for item in self.all if item.status is BatchItemStatus.FAILED),
523
521
  None,
524
522
  )
525
- if first_error is not None:
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 error.
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 and item.result is not None
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: Operation subtype identifier used for tracking and debugging.
506
- Examples: OperationSubType.MAP_ITERATION, OperationSubType.PARALLEL_BRANCH.
507
- Used internally by the execution engine for operation classification.
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: OperationSubType | None = None
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
- return self.state.get_checkpoint_result(self._peek_next_operation_id())
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(operation_identifier.operation_id)
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: OperationSubType,
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 resuilt.
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
- OperationSubType.CALLBACK, name, operation_type=OperationType.CALLBACK
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
- callable (Callable[[DurableContext], T]): Run this callable and pass the child context as the argument to it.
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): c
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
- if config and config.sub_type
829
- else OperationSubType.RUN_IN_CHILD_CONTEXT
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
- OperationSubType.STEP, step_name, operation_type=OperationType.STEP
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.run_in_child_context(
953
+ return self._run_in_child_context(
916
954
  wait_in_child_context,
917
955
  step_name,
918
- ChildConfig(sub_type=OperationSubType.WAIT_FOR_CALLBACK),
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, name, operation_type=OperationType.STEP
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
- DurableInstrumentationPlugin,
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[DurableInstrumentationPlugin] | None = None,
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 plugins to use
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 = plugin_executor._run_in_invocation_context(
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
- plugin_executor.on_invocation_end(
543
- DurableExecutionInvocationOutput.from_dict(output)
544
- )
545
- return output
546
- except Exception as error:
547
- plugin_executor.on_invocation_end(
548
- DurableExecutionInvocationOutput.create_retry(
549
- ErrorObject.from_exception(error)
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: OperationSubType
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 is not self.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={checkpoint_sub_type!r} current={self.sub_type.value!r}"
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(