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.
- aws_durable_execution_sdk_python/__about__.py +1 -1
- aws_durable_execution_sdk_python/concurrency/executor.py +24 -12
- 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 +87 -28
- aws_durable_execution_sdk_python/execution.py +243 -128
- aws_durable_execution_sdk_python/identifier.py +12 -11
- aws_durable_execution_sdk_python/lambda_service.py +65 -63
- aws_durable_execution_sdk_python/operation/child.py +30 -5
- aws_durable_execution_sdk_python/operation/invoke.py +6 -0
- 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 +1295 -123
- aws_durable_execution_sdk_python/plugin_discovery.py +393 -85
- aws_durable_execution_sdk_python/state.py +240 -63
- aws_durable_execution_sdk_python-3.0.0.dist-info/METADATA +311 -0
- {aws_durable_execution_sdk_python-2.0.1.dist-info → aws_durable_execution_sdk_python-3.0.0.dist-info}/RECORD +18 -18
- {aws_durable_execution_sdk_python-2.0.1.dist-info → aws_durable_execution_sdk_python-3.0.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python-2.0.1.dist-info/METADATA +0 -156
|
@@ -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:
|
|
132
|
-
sub_type_iteration:
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -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
|
|
@@ -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
|
-
|
|
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,
|
|
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)
|
|
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(
|
|
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:
|
|
625
|
+
sub_type: str,
|
|
601
626
|
name: str | None = None,
|
|
602
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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.
|
|
953
|
+
return self._run_in_child_context(
|
|
898
954
|
wait_in_child_context,
|
|
899
955
|
step_name,
|
|
900
|
-
|
|
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,
|
|
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(
|