aws-durable-execution-sdk-python 2.0.1__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.0.1"
4
+ __version__ = "3.0.0"
@@ -2,6 +2,7 @@
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
@@ -44,7 +45,7 @@ from aws_durable_execution_sdk_python.identifier import (
44
45
  OperationIdentifier,
45
46
  OperationIdNamespace,
46
47
  )
47
- from aws_durable_execution_sdk_python.lambda_service import ErrorObject
48
+ from aws_durable_execution_sdk_python.lambda_service import ErrorObject, OperationType
48
49
  from aws_durable_execution_sdk_python.operation.child import child_handler
49
50
 
50
51
 
@@ -52,7 +53,6 @@ if TYPE_CHECKING:
52
53
  from collections.abc import Callable
53
54
  from aws_durable_execution_sdk_python.config import CompletionConfig
54
55
  from aws_durable_execution_sdk_python.context import DurableContext
55
- from aws_durable_execution_sdk_python.lambda_service import OperationSubType
56
56
  from aws_durable_execution_sdk_python.serdes import SerDes
57
57
  from aws_durable_execution_sdk_python.state import (
58
58
  CheckpointedResult,
@@ -128,8 +128,8 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
128
128
  executables: list[Executable[CallableType]],
129
129
  max_concurrency: int | None,
130
130
  completion_config: CompletionConfig,
131
- sub_type_top: OperationSubType,
132
- sub_type_iteration: OperationSubType,
131
+ sub_type_top: str,
132
+ sub_type_iteration: str,
133
133
  name_prefix: str,
134
134
  serdes: SerDes | None,
135
135
  operation_id_namespace: OperationIdNamespace,
@@ -183,6 +183,7 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
183
183
  executable.index
184
184
  ),
185
185
  sub_type=self.sub_type_iteration,
186
+ operation_type=OperationType.CONTEXT,
186
187
  parent_id=executor_context._parent_id, # noqa: SLF001
187
188
  name=self.get_iteration_name(executable.index),
188
189
  )
@@ -291,13 +292,25 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
291
292
 
292
293
  def submit(branch: Branch[CallableType, ResultType]) -> None:
293
294
  branch.start()
294
- pool.submit(
295
- self._branch_worker,
296
- execution_state,
297
- executor_context,
298
- events,
299
- branch.executable,
300
- )
295
+ if execution_state._plugin_executor._plugins: # noqa: SLF001
296
+ # Every admission/resume gets its own Context. Branch bindings
297
+ # cannot leak into siblings, the coordinator, or reused workers.
298
+ pool.submit(
299
+ contextvars.copy_context().run,
300
+ self._branch_worker,
301
+ execution_state,
302
+ executor_context,
303
+ events,
304
+ branch.executable,
305
+ )
306
+ else:
307
+ pool.submit(
308
+ self._branch_worker,
309
+ execution_state,
310
+ executor_context,
311
+ events,
312
+ branch.executable,
313
+ )
301
314
 
302
315
  try:
303
316
  # Only rebuild the items snapshot after a terminal event changes
@@ -688,7 +701,6 @@ class ConcurrentExecutor(Generic[CallableType, ResultType]):
688
701
  operation_identifier=operation_identifier,
689
702
  config=ChildConfig(
690
703
  serdes=self.item_serdes or self.serdes,
691
- sub_type=self.sub_type_iteration,
692
704
  is_virtual=is_virtual,
693
705
  ),
694
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:
@@ -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
@@ -506,8 +509,15 @@ class DurableContext(DurableContextProtocol):
506
509
  )
507
510
 
508
511
  def _peek_next_checkpoint(self) -> CheckpointedResult:
509
- """Return the checkpoint for this context's next operation, without consuming it."""
510
- 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
+ )
511
521
 
512
522
  def _next_operation_exists(self) -> bool:
513
523
  """True if a checkpoint exists for this context's next operation."""
@@ -515,12 +525,18 @@ class DurableContext(DurableContextProtocol):
515
525
 
516
526
  @contextmanager
517
527
  def _replay_aware(
518
- self, operation_identifier: OperationIdentifier | None = None
528
+ self,
529
+ operation_identifier: OperationIdentifier | None = None,
530
+ *,
531
+ is_virtual: bool = False,
519
532
  ) -> Iterator[None]:
520
533
  """Wrap a single operation with replay-boundary detection.
521
534
 
522
535
  The operation kind is inferred from its own checkpoint (when one
523
- exists), so no per-call-site flag is needed.
536
+ exists). The one per-call-site input is `is_virtual`: a virtual child
537
+ context never checkpoints itself, so with `is_virtual=True` any
538
+ checkpoint at its id is rejected right after the identity check,
539
+ before replay and update hooks run.
524
540
 
525
541
  The boundary has these parts:
526
542
 
@@ -539,9 +555,14 @@ class DurableContext(DurableContextProtocol):
539
555
  # Only look up a checkpoint when replaying. Operation call sites pass
540
556
  # their atomically allocated identifier so concurrent callers inspect
541
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.
542
561
  next_checkpoint: CheckpointedResult | None = (
543
562
  (
544
- 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
+ )
545
566
  if operation_identifier is not None
546
567
  else self._peek_next_checkpoint()
547
568
  )
@@ -555,6 +576,10 @@ class DurableContext(DurableContextProtocol):
555
576
  if was_replaying and next_operation is not None:
556
577
  if operation_identifier is not None:
557
578
  operation_identifier.validate_checkpoint(next_operation)
579
+ if is_virtual:
580
+ ChildOperationExecutor.validate_virtual_checkpoint(
581
+ operation_identifier, next_operation
582
+ )
558
583
 
559
584
  # While replaying, an operation that already has a checkpoint was
560
585
  # observed in a prior invocation. If the backend says the operation
@@ -597,9 +622,11 @@ class DurableContext(DurableContextProtocol):
597
622
  @contextmanager
598
623
  def _operation_replay_aware(
599
624
  self,
600
- sub_type: OperationSubType,
625
+ sub_type: str,
601
626
  name: str | None = None,
602
- operation_type: OperationType | None = None,
627
+ *,
628
+ operation_type: OperationType,
629
+ is_virtual: bool = False,
603
630
  ) -> Iterator[OperationIdentifier]:
604
631
  """Allocate an operation and validate its replay identity before hooks."""
605
632
  operation_identifier = OperationIdentifier(
@@ -609,7 +636,7 @@ class DurableContext(DurableContextProtocol):
609
636
  name=name,
610
637
  operation_type=operation_type,
611
638
  )
612
- with self._replay_aware(operation_identifier):
639
+ with self._replay_aware(operation_identifier, is_virtual=is_virtual):
613
640
  yield operation_identifier
614
641
 
615
642
  # endregion replay status
@@ -629,14 +656,16 @@ class DurableContext(DurableContextProtocol):
629
656
  Args:
630
657
  name (str): Optional name for the operation.
631
658
  config (CallbackConfig): Configuration for the callback.
659
+ ``config.sub_type`` labels the operation; see CallbackConfig.
632
660
 
633
661
  Return:
634
- 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.
635
663
  """
636
664
  if not config:
637
665
  config = CallbackConfig()
666
+ sub_type = config.sub_type or OperationSubType.CALLBACK.value
638
667
  with self._operation_replay_aware(
639
- OperationSubType.CALLBACK, name
668
+ sub_type, name, operation_type=OperationType.CALLBACK
640
669
  ) as operation_identifier:
641
670
  executor: CallbackOperationExecutor = CallbackOperationExecutor(
642
671
  state=self.state,
@@ -672,7 +701,9 @@ class DurableContext(DurableContextProtocol):
672
701
  if not config:
673
702
  config = InvokeConfig[P, R]()
674
703
  with self._operation_replay_aware(
675
- OperationSubType.CHAINED_INVOKE, name
704
+ OperationSubType.CHAINED_INVOKE.value,
705
+ name,
706
+ operation_type=OperationType.CHAINED_INVOKE,
676
707
  ) as operation_identifier:
677
708
  executor: InvokeOperationExecutor[R] = InvokeOperationExecutor(
678
709
  function_name=function_name,
@@ -700,7 +731,7 @@ class DurableContext(DurableContextProtocol):
700
731
  config.completion_config._validate_for_total(len(inputs))
701
732
 
702
733
  with self._operation_replay_aware(
703
- OperationSubType.MAP, map_name
734
+ OperationSubType.MAP.value, map_name, operation_type=OperationType.CONTEXT
704
735
  ) as operation_identifier:
705
736
  operation_id = operation_identifier.operation_id
706
737
  map_context = self.create_child_context(operation_id=operation_id)
@@ -725,7 +756,6 @@ class DurableContext(DurableContextProtocol):
725
756
  state=self.state,
726
757
  operation_identifier=operation_identifier,
727
758
  config=ChildConfig(
728
- sub_type=OperationSubType.MAP,
729
759
  serdes=config.serdes if config else None,
730
760
  # The SDK-owned envelope records the completion decision for
731
761
  # replay. A configured summary_generator only contributes the
@@ -751,7 +781,7 @@ class DurableContext(DurableContextProtocol):
751
781
  config.completion_config._validate_for_total(len(functions))
752
782
 
753
783
  with self._operation_replay_aware(
754
- OperationSubType.PARALLEL, name
784
+ OperationSubType.PARALLEL.value, name, operation_type=OperationType.CONTEXT
755
785
  ) as operation_identifier:
756
786
  operation_id = operation_identifier.operation_id
757
787
  parallel_context = self.create_child_context(operation_id=operation_id)
@@ -775,7 +805,6 @@ class DurableContext(DurableContextProtocol):
775
805
  state=self.state,
776
806
  operation_identifier=operation_identifier,
777
807
  config=ChildConfig(
778
- sub_type=OperationSubType.PARALLEL,
779
808
  serdes=config.serdes if config else None,
780
809
  # The SDK-owned envelope records the completion decision for
781
810
  # replay. A configured summary_generator only contributes the
@@ -798,27 +827,42 @@ class DurableContext(DurableContextProtocol):
798
827
  Use this to nest and group operations.
799
828
 
800
829
  Args:
801
- 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.
802
831
  name (str | None): name for the operation.
803
- 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.
804
835
 
805
836
  Returns:
806
837
  T: The result of the callable.
807
838
  """
808
839
  step_name: str | None = self._resolve_step_name(name, func)
809
840
  sub_type = (
810
- config.sub_type
811
- if config and config.sub_type
812
- else OperationSubType.RUN_IN_CHILD_CONTEXT
813
- )
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
+ """
857
+ is_virtual: bool = config.is_virtual if config else False
814
858
 
815
859
  with self._operation_replay_aware(
816
860
  sub_type,
817
861
  step_name,
818
862
  operation_type=OperationType.CONTEXT,
863
+ is_virtual=is_virtual,
819
864
  ) as operation_identifier:
820
865
  operation_id = operation_identifier.operation_id
821
- is_virtual: bool = config.is_virtual if config else False
822
866
 
823
867
  def callable_with_child_context():
824
868
  return func(
@@ -840,12 +884,24 @@ class DurableContext(DurableContextProtocol):
840
884
  name: str | None = None,
841
885
  config: StepConfig | None = None,
842
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
+ """
843
898
  step_name = self._resolve_step_name(name, func)
844
899
  logger.debug("Step name: %s", step_name)
845
900
  if not config:
846
901
  config = StepConfig()
902
+ sub_type = config.sub_type or OperationSubType.STEP.value
847
903
  with self._operation_replay_aware(
848
- OperationSubType.STEP, step_name
904
+ sub_type, step_name, operation_type=OperationType.STEP
849
905
  ) as operation_identifier:
850
906
  executor: StepOperationExecutor[T] = StepOperationExecutor(
851
907
  func=func,
@@ -868,7 +924,7 @@ class DurableContext(DurableContextProtocol):
868
924
  msg = "duration must be at least 1 second"
869
925
  raise ValidationError(msg)
870
926
  with self._operation_replay_aware(
871
- OperationSubType.WAIT, name
927
+ OperationSubType.WAIT.value, name, operation_type=OperationType.WAIT
872
928
  ) as operation_identifier:
873
929
  wait_seconds = duration.seconds
874
930
  executor: WaitOperationExecutor = WaitOperationExecutor(
@@ -894,10 +950,11 @@ class DurableContext(DurableContextProtocol):
894
950
  # inner failure on __cause__; translate to callback-typed errors here, at
895
951
  # the call site, so it re-fires identically on replay.
896
952
  try:
897
- return self.run_in_child_context(
953
+ return self._run_in_child_context(
898
954
  wait_in_child_context,
899
955
  step_name,
900
- ChildConfig(sub_type=OperationSubType.WAIT_FOR_CALLBACK),
956
+ None,
957
+ OperationSubType.WAIT_FOR_CALLBACK.value,
901
958
  )
902
959
  except ChildContextError as e:
903
960
  inner: BaseException | None = e.__cause__
@@ -938,7 +995,9 @@ class DurableContext(DurableContextProtocol):
938
995
  raise ValidationError(msg)
939
996
 
940
997
  with self._operation_replay_aware(
941
- OperationSubType.WAIT_FOR_CONDITION, name
998
+ OperationSubType.WAIT_FOR_CONDITION.value,
999
+ name,
1000
+ operation_type=OperationType.STEP,
942
1001
  ) as operation_identifier:
943
1002
  executor: WaitForConditionOperationExecutor[T] = (
944
1003
  WaitForConditionOperationExecutor(