aws-durable-execution-sdk-python 1.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/.gitignore +0 -0
- aws_durable_execution_sdk_python/__about__.py +4 -0
- aws_durable_execution_sdk_python/__init__.py +34 -0
- aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
- aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
- aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
- aws_durable_execution_sdk_python/concurrency/models.py +469 -0
- aws_durable_execution_sdk_python/config.py +499 -0
- aws_durable_execution_sdk_python/context.py +551 -0
- aws_durable_execution_sdk_python/exceptions.py +374 -0
- aws_durable_execution_sdk_python/execution.py +428 -0
- aws_durable_execution_sdk_python/identifier.py +14 -0
- aws_durable_execution_sdk_python/lambda_service.py +1034 -0
- aws_durable_execution_sdk_python/logger.py +131 -0
- aws_durable_execution_sdk_python/operation/__init__.py +1 -0
- aws_durable_execution_sdk_python/operation/callback.py +123 -0
- aws_durable_execution_sdk_python/operation/child.py +162 -0
- aws_durable_execution_sdk_python/operation/invoke.py +119 -0
- aws_durable_execution_sdk_python/operation/map.py +137 -0
- aws_durable_execution_sdk_python/operation/parallel.py +122 -0
- aws_durable_execution_sdk_python/operation/step.py +269 -0
- aws_durable_execution_sdk_python/operation/wait.py +53 -0
- aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
- aws_durable_execution_sdk_python/py.typed +1 -0
- aws_durable_execution_sdk_python/retries.py +174 -0
- aws_durable_execution_sdk_python/serdes.py +502 -0
- aws_durable_execution_sdk_python/state.py +790 -0
- aws_durable_execution_sdk_python/suspend.py +84 -0
- aws_durable_execution_sdk_python/threading.py +222 -0
- aws_durable_execution_sdk_python/types.py +180 -0
- aws_durable_execution_sdk_python/waits.py +130 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
- aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Custom logging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.types import LoggerInterface
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
12
|
+
|
|
13
|
+
from aws_durable_execution_sdk_python.context import ExecutionState
|
|
14
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class LogInfo:
|
|
19
|
+
execution_state: ExecutionState
|
|
20
|
+
parent_id: str | None = None
|
|
21
|
+
operation_id: str | None = None
|
|
22
|
+
name: str | None = None
|
|
23
|
+
attempt: int | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_operation_identifier(
|
|
27
|
+
cls,
|
|
28
|
+
execution_state: ExecutionState,
|
|
29
|
+
op_id: OperationIdentifier,
|
|
30
|
+
attempt: int | None = None,
|
|
31
|
+
) -> LogInfo:
|
|
32
|
+
"""Create new log info from an execution arn, OperationIdentifier and attempt."""
|
|
33
|
+
return cls(
|
|
34
|
+
execution_state=execution_state,
|
|
35
|
+
parent_id=op_id.parent_id,
|
|
36
|
+
operation_id=op_id.operation_id,
|
|
37
|
+
name=op_id.name,
|
|
38
|
+
attempt=attempt,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def with_parent_id(self, parent_id: str) -> LogInfo:
|
|
42
|
+
"""Clone the log info with a new parent id."""
|
|
43
|
+
return LogInfo(
|
|
44
|
+
execution_state=self.execution_state,
|
|
45
|
+
parent_id=parent_id,
|
|
46
|
+
operation_id=self.operation_id,
|
|
47
|
+
name=self.name,
|
|
48
|
+
attempt=self.attempt,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Logger(LoggerInterface):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
logger: LoggerInterface,
|
|
56
|
+
default_extra: Mapping[str, object],
|
|
57
|
+
execution_state: ExecutionState,
|
|
58
|
+
) -> None:
|
|
59
|
+
self._logger = logger
|
|
60
|
+
self._default_extra = default_extra
|
|
61
|
+
self._execution_state = execution_state
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_log_info(cls, logger: LoggerInterface, info: LogInfo) -> Logger:
|
|
65
|
+
"""Create a new logger with the given LogInfo."""
|
|
66
|
+
extra: MutableMapping[str, object] = {
|
|
67
|
+
"executionArn": info.execution_state.durable_execution_arn
|
|
68
|
+
}
|
|
69
|
+
if info.parent_id:
|
|
70
|
+
extra["parentId"] = info.parent_id
|
|
71
|
+
if info.name:
|
|
72
|
+
# Use 'operation_name' instead of 'name' as key because the stdlib LogRecord internally reserved 'name' parameter
|
|
73
|
+
extra["operationName"] = info.name
|
|
74
|
+
if info.attempt is not None:
|
|
75
|
+
extra["attempt"] = info.attempt + 1
|
|
76
|
+
if info.operation_id:
|
|
77
|
+
extra["operationId"] = info.operation_id
|
|
78
|
+
return cls(
|
|
79
|
+
logger=logger, default_extra=extra, execution_state=info.execution_state
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def with_log_info(self, info: LogInfo) -> Logger:
|
|
83
|
+
"""Clone the existing logger with new LogInfo."""
|
|
84
|
+
return Logger.from_log_info(
|
|
85
|
+
logger=self._logger,
|
|
86
|
+
info=info,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def get_logger(self) -> LoggerInterface:
|
|
90
|
+
"""Get the underlying logger."""
|
|
91
|
+
return self._logger
|
|
92
|
+
|
|
93
|
+
def debug(
|
|
94
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
95
|
+
) -> None:
|
|
96
|
+
self._log(self._logger.debug, msg, *args, extra=extra)
|
|
97
|
+
|
|
98
|
+
def info(
|
|
99
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
100
|
+
) -> None:
|
|
101
|
+
self._log(self._logger.info, msg, *args, extra=extra)
|
|
102
|
+
|
|
103
|
+
def warning(
|
|
104
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
105
|
+
) -> None:
|
|
106
|
+
self._log(self._logger.warning, msg, *args, extra=extra)
|
|
107
|
+
|
|
108
|
+
def error(
|
|
109
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
110
|
+
) -> None:
|
|
111
|
+
self._log(self._logger.error, msg, *args, extra=extra)
|
|
112
|
+
|
|
113
|
+
def exception(
|
|
114
|
+
self, msg: object, *args: object, extra: Mapping[str, object] | None = None
|
|
115
|
+
) -> None:
|
|
116
|
+
self._log(self._logger.exception, msg, *args, extra=extra)
|
|
117
|
+
|
|
118
|
+
def _log(
|
|
119
|
+
self,
|
|
120
|
+
log_func: Callable,
|
|
121
|
+
msg: object,
|
|
122
|
+
*args: object,
|
|
123
|
+
extra: Mapping[str, object] | None = None,
|
|
124
|
+
):
|
|
125
|
+
if not self._should_log():
|
|
126
|
+
return
|
|
127
|
+
merged_extra = {**self._default_extra, **(extra or {})}
|
|
128
|
+
log_func(msg, *args, extra=merged_extra)
|
|
129
|
+
|
|
130
|
+
def _should_log(self) -> bool:
|
|
131
|
+
return not self._execution_state.is_replaying()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Operation modules."""
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Implementation for the Durable create_callback and wait_for_callback operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from aws_durable_execution_sdk_python.config import StepConfig
|
|
8
|
+
from aws_durable_execution_sdk_python.exceptions import CallbackError
|
|
9
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
10
|
+
CallbackOptions,
|
|
11
|
+
OperationUpdate,
|
|
12
|
+
)
|
|
13
|
+
from aws_durable_execution_sdk_python.types import WaitForCallbackContext
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from aws_durable_execution_sdk_python.config import (
|
|
19
|
+
CallbackConfig,
|
|
20
|
+
WaitForCallbackConfig,
|
|
21
|
+
)
|
|
22
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
23
|
+
from aws_durable_execution_sdk_python.state import (
|
|
24
|
+
CheckpointedResult,
|
|
25
|
+
ExecutionState,
|
|
26
|
+
)
|
|
27
|
+
from aws_durable_execution_sdk_python.types import (
|
|
28
|
+
Callback,
|
|
29
|
+
DurableContext,
|
|
30
|
+
StepContext,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_callback_handler(
|
|
35
|
+
state: ExecutionState,
|
|
36
|
+
operation_identifier: OperationIdentifier,
|
|
37
|
+
config: CallbackConfig | None = None,
|
|
38
|
+
) -> str:
|
|
39
|
+
"""Create the callback checkpoint and return the callback id."""
|
|
40
|
+
callback_options: CallbackOptions = (
|
|
41
|
+
CallbackOptions(
|
|
42
|
+
timeout_seconds=config.timeout_seconds,
|
|
43
|
+
heartbeat_timeout_seconds=config.heartbeat_timeout_seconds,
|
|
44
|
+
)
|
|
45
|
+
if config
|
|
46
|
+
else CallbackOptions()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
checkpointed_result: CheckpointedResult = state.get_checkpoint_result(
|
|
50
|
+
operation_identifier.operation_id
|
|
51
|
+
)
|
|
52
|
+
if checkpointed_result.is_failed():
|
|
53
|
+
# have to throw the exact same error on replay as the checkpointed failure
|
|
54
|
+
checkpointed_result.raise_callable_error()
|
|
55
|
+
|
|
56
|
+
if (
|
|
57
|
+
checkpointed_result.is_started()
|
|
58
|
+
or checkpointed_result.is_succeeded()
|
|
59
|
+
or checkpointed_result.is_timed_out()
|
|
60
|
+
):
|
|
61
|
+
# callback id should already exist
|
|
62
|
+
if (
|
|
63
|
+
not checkpointed_result.operation
|
|
64
|
+
or not checkpointed_result.operation.callback_details
|
|
65
|
+
):
|
|
66
|
+
msg = f"Missing callback details for operation: {operation_identifier.operation_id}"
|
|
67
|
+
raise CallbackError(msg)
|
|
68
|
+
|
|
69
|
+
return checkpointed_result.operation.callback_details.callback_id
|
|
70
|
+
|
|
71
|
+
create_callback_operation = OperationUpdate.create_callback(
|
|
72
|
+
identifier=operation_identifier,
|
|
73
|
+
callback_options=callback_options,
|
|
74
|
+
)
|
|
75
|
+
# Checkpoint callback START with blocking (is_sync=True, default).
|
|
76
|
+
# Must wait for the API to generate and return the callback ID before proceeding.
|
|
77
|
+
# The callback ID is needed immediately by the caller to pass to external systems.
|
|
78
|
+
state.create_checkpoint(operation_update=create_callback_operation)
|
|
79
|
+
|
|
80
|
+
result: CheckpointedResult = state.get_checkpoint_result(
|
|
81
|
+
operation_identifier.operation_id
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if not result.operation or not result.operation.callback_details:
|
|
85
|
+
msg = f"Missing callback details for operation: {operation_identifier.operation_id}"
|
|
86
|
+
raise CallbackError(msg)
|
|
87
|
+
|
|
88
|
+
return result.operation.callback_details.callback_id
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def wait_for_callback_handler(
|
|
92
|
+
context: DurableContext,
|
|
93
|
+
submitter: Callable[[str, WaitForCallbackContext], None],
|
|
94
|
+
name: str | None = None,
|
|
95
|
+
config: WaitForCallbackConfig | None = None,
|
|
96
|
+
) -> Any:
|
|
97
|
+
"""Wait for a callback to be invoked by an external system.
|
|
98
|
+
|
|
99
|
+
This is a helper function that is used to create a callback and wait for it to be invoked by an external system.
|
|
100
|
+
"""
|
|
101
|
+
name_with_space: str = f"{name} " if name else ""
|
|
102
|
+
callback: Callback = context.create_callback(
|
|
103
|
+
name=f"{name_with_space}create callback id", config=config
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def submitter_step(step_context: StepContext):
|
|
107
|
+
return submitter(
|
|
108
|
+
callback.callback_id, WaitForCallbackContext(logger=step_context.logger)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
step_config = (
|
|
112
|
+
StepConfig(
|
|
113
|
+
retry_strategy=config.retry_strategy,
|
|
114
|
+
serdes=config.serdes,
|
|
115
|
+
)
|
|
116
|
+
if config
|
|
117
|
+
else None
|
|
118
|
+
)
|
|
119
|
+
context.step(
|
|
120
|
+
func=submitter_step, name=f"{name_with_space}submitter", config=step_config
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return callback.result()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Implementation for run_in_child_context."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.config import ChildConfig
|
|
9
|
+
from aws_durable_execution_sdk_python.exceptions import (
|
|
10
|
+
InvocationError,
|
|
11
|
+
SuspendExecution,
|
|
12
|
+
)
|
|
13
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
14
|
+
ContextOptions,
|
|
15
|
+
ErrorObject,
|
|
16
|
+
OperationSubType,
|
|
17
|
+
OperationUpdate,
|
|
18
|
+
)
|
|
19
|
+
from aws_durable_execution_sdk_python.serdes import deserialize, serialize
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
25
|
+
from aws_durable_execution_sdk_python.state import ExecutionState
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
T = TypeVar("T")
|
|
30
|
+
|
|
31
|
+
# Checkpoint size limit in bytes (256KB)
|
|
32
|
+
CHECKPOINT_SIZE_LIMIT = 256 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def child_handler(
|
|
36
|
+
func: Callable[[], T],
|
|
37
|
+
state: ExecutionState,
|
|
38
|
+
operation_identifier: OperationIdentifier,
|
|
39
|
+
config: ChildConfig | None,
|
|
40
|
+
) -> T:
|
|
41
|
+
logger.debug(
|
|
42
|
+
"▶️ Executing child context for id: %s, name: %s",
|
|
43
|
+
operation_identifier.operation_id,
|
|
44
|
+
operation_identifier.name,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if not config:
|
|
48
|
+
config = ChildConfig()
|
|
49
|
+
|
|
50
|
+
checkpointed_result = state.get_checkpoint_result(operation_identifier.operation_id)
|
|
51
|
+
if (
|
|
52
|
+
checkpointed_result.is_succeeded()
|
|
53
|
+
and not checkpointed_result.is_replay_children()
|
|
54
|
+
):
|
|
55
|
+
logger.debug(
|
|
56
|
+
"Child context already completed, skipping execution for id: %s, name: %s",
|
|
57
|
+
operation_identifier.operation_id,
|
|
58
|
+
operation_identifier.name,
|
|
59
|
+
)
|
|
60
|
+
if checkpointed_result.result is None:
|
|
61
|
+
return None # type: ignore
|
|
62
|
+
return deserialize(
|
|
63
|
+
serdes=config.serdes,
|
|
64
|
+
data=checkpointed_result.result,
|
|
65
|
+
operation_id=operation_identifier.operation_id,
|
|
66
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
67
|
+
)
|
|
68
|
+
if checkpointed_result.is_failed():
|
|
69
|
+
checkpointed_result.raise_callable_error()
|
|
70
|
+
sub_type = config.sub_type or OperationSubType.RUN_IN_CHILD_CONTEXT
|
|
71
|
+
|
|
72
|
+
if not checkpointed_result.is_existent():
|
|
73
|
+
start_operation = OperationUpdate.create_context_start(
|
|
74
|
+
identifier=operation_identifier,
|
|
75
|
+
sub_type=sub_type,
|
|
76
|
+
)
|
|
77
|
+
# Checkpoint child context START with non-blocking (is_sync=False).
|
|
78
|
+
# This is a fire-and-forget operation for performance - we don't need to wait for
|
|
79
|
+
# persistence before executing the child context. The START checkpoint is purely
|
|
80
|
+
# for observability and tracking the operation hierarchy.
|
|
81
|
+
state.create_checkpoint(operation_update=start_operation, is_sync=False)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
raw_result: T = func()
|
|
85
|
+
if checkpointed_result.is_replay_children():
|
|
86
|
+
logger.debug(
|
|
87
|
+
"ReplayChildren mode: Executed child context again on replay due to large payload. Exiting child context without creating another checkpoint. id: %s, name: %s",
|
|
88
|
+
operation_identifier.operation_id,
|
|
89
|
+
operation_identifier.name,
|
|
90
|
+
)
|
|
91
|
+
return raw_result
|
|
92
|
+
serialized_result: str = serialize(
|
|
93
|
+
serdes=config.serdes,
|
|
94
|
+
value=raw_result,
|
|
95
|
+
operation_id=operation_identifier.operation_id,
|
|
96
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
97
|
+
)
|
|
98
|
+
# Summary Generator Logic:
|
|
99
|
+
# When the serialized result exceeds 256KB, we use ReplayChildren mode to avoid
|
|
100
|
+
# checkpointing large payloads. Instead, we checkpoint a compact summary and mark
|
|
101
|
+
# the operation for replay. This matches the TypeScript implementation behavior.
|
|
102
|
+
#
|
|
103
|
+
# See TypeScript reference:
|
|
104
|
+
# - aws-durable-execution-sdk-js/src/handlers/run-in-child-context-handler/run-in-child-context-handler.ts (lines ~200-220)
|
|
105
|
+
#
|
|
106
|
+
# The summary generator creates a JSON summary with metadata (type, counts, status)
|
|
107
|
+
# instead of the full BatchResult. During replay, the child context is re-executed
|
|
108
|
+
# to reconstruct the full result rather than deserializing from the checkpoint.
|
|
109
|
+
replay_children: bool = False
|
|
110
|
+
if len(serialized_result) > CHECKPOINT_SIZE_LIMIT:
|
|
111
|
+
logger.debug(
|
|
112
|
+
"Large payload detected, using ReplayChildren mode: id: %s, name: %s, payload_size: %d, limit: %d",
|
|
113
|
+
operation_identifier.operation_id,
|
|
114
|
+
operation_identifier.name,
|
|
115
|
+
len(serialized_result),
|
|
116
|
+
CHECKPOINT_SIZE_LIMIT,
|
|
117
|
+
)
|
|
118
|
+
replay_children = True
|
|
119
|
+
# Use summary generator if provided, otherwise use empty string (matches TypeScript)
|
|
120
|
+
serialized_result = (
|
|
121
|
+
config.summary_generator(raw_result) if config.summary_generator else ""
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
success_operation = OperationUpdate.create_context_succeed(
|
|
125
|
+
identifier=operation_identifier,
|
|
126
|
+
payload=serialized_result,
|
|
127
|
+
sub_type=sub_type,
|
|
128
|
+
context_options=ContextOptions(replay_children=replay_children),
|
|
129
|
+
)
|
|
130
|
+
# Checkpoint child context SUCCEED with blocking (is_sync=True, default).
|
|
131
|
+
# Must ensure the child context result is persisted before returning to the parent.
|
|
132
|
+
# This guarantees the result is durable and child operations won't be re-executed on replay
|
|
133
|
+
# (unless replay_children=True for large payloads).
|
|
134
|
+
state.create_checkpoint(operation_update=success_operation)
|
|
135
|
+
|
|
136
|
+
logger.debug(
|
|
137
|
+
"✅ Successfully completed child context for id: %s, name: %s",
|
|
138
|
+
operation_identifier.operation_id,
|
|
139
|
+
operation_identifier.name,
|
|
140
|
+
)
|
|
141
|
+
return raw_result # noqa: TRY300
|
|
142
|
+
except SuspendExecution:
|
|
143
|
+
# Don't checkpoint SuspendExecution - let it bubble up
|
|
144
|
+
raise
|
|
145
|
+
except Exception as e:
|
|
146
|
+
error_object = ErrorObject.from_exception(e)
|
|
147
|
+
fail_operation = OperationUpdate.create_context_fail(
|
|
148
|
+
identifier=operation_identifier, error=error_object, sub_type=sub_type
|
|
149
|
+
)
|
|
150
|
+
# Checkpoint child context FAIL with blocking (is_sync=True, default).
|
|
151
|
+
# Must ensure the failure state is persisted before raising the exception.
|
|
152
|
+
# This guarantees the error is durable and child operations won't be re-executed on replay.
|
|
153
|
+
state.create_checkpoint(operation_update=fail_operation)
|
|
154
|
+
|
|
155
|
+
# InvocationError and its derivatives can be retried
|
|
156
|
+
# When we encounter an invocation error (in all of its forms), we bubble that
|
|
157
|
+
# error upwards (with the checkpoint in place) such that we reach the
|
|
158
|
+
# execution handler at the very top, which will then induce a retry from the
|
|
159
|
+
# dataplane.
|
|
160
|
+
if isinstance(e, InvocationError):
|
|
161
|
+
raise
|
|
162
|
+
raise error_object.to_callable_runtime_error() from e
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Implement the Durable invoke operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python.config import InvokeConfig
|
|
9
|
+
from aws_durable_execution_sdk_python.exceptions import ExecutionError
|
|
10
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
11
|
+
ChainedInvokeOptions,
|
|
12
|
+
OperationUpdate,
|
|
13
|
+
)
|
|
14
|
+
from aws_durable_execution_sdk_python.serdes import (
|
|
15
|
+
DEFAULT_JSON_SERDES,
|
|
16
|
+
deserialize,
|
|
17
|
+
serialize,
|
|
18
|
+
)
|
|
19
|
+
from aws_durable_execution_sdk_python.suspend import suspend_with_optional_resume_delay
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
23
|
+
from aws_durable_execution_sdk_python.state import ExecutionState
|
|
24
|
+
|
|
25
|
+
P = TypeVar("P") # Payload type
|
|
26
|
+
R = TypeVar("R") # Result type
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def invoke_handler(
|
|
32
|
+
function_name: str,
|
|
33
|
+
payload: P,
|
|
34
|
+
state: ExecutionState,
|
|
35
|
+
operation_identifier: OperationIdentifier,
|
|
36
|
+
config: InvokeConfig[P, R] | None,
|
|
37
|
+
) -> R:
|
|
38
|
+
"""Invoke another Durable Function."""
|
|
39
|
+
logger.debug(
|
|
40
|
+
"🔗 Invoke %s (%s)",
|
|
41
|
+
operation_identifier.name or function_name,
|
|
42
|
+
operation_identifier.operation_id,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if not config:
|
|
46
|
+
config = InvokeConfig[P, R]()
|
|
47
|
+
tenant_id = config.tenant_id
|
|
48
|
+
|
|
49
|
+
# Check if we have existing step data
|
|
50
|
+
checkpointed_result = state.get_checkpoint_result(operation_identifier.operation_id)
|
|
51
|
+
|
|
52
|
+
if checkpointed_result.is_succeeded():
|
|
53
|
+
# Return persisted result - no need to check for errors in successful operations
|
|
54
|
+
if (
|
|
55
|
+
checkpointed_result.operation
|
|
56
|
+
and checkpointed_result.operation.chained_invoke_details
|
|
57
|
+
and checkpointed_result.operation.chained_invoke_details.result
|
|
58
|
+
):
|
|
59
|
+
return deserialize(
|
|
60
|
+
serdes=config.serdes_result or DEFAULT_JSON_SERDES,
|
|
61
|
+
data=checkpointed_result.operation.chained_invoke_details.result,
|
|
62
|
+
operation_id=operation_identifier.operation_id,
|
|
63
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
64
|
+
)
|
|
65
|
+
return None # type: ignore
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
checkpointed_result.is_failed()
|
|
69
|
+
or checkpointed_result.is_timed_out()
|
|
70
|
+
or checkpointed_result.is_stopped()
|
|
71
|
+
):
|
|
72
|
+
# Operation failed, throw the exact same error on replay as the checkpointed failure
|
|
73
|
+
checkpointed_result.raise_callable_error()
|
|
74
|
+
|
|
75
|
+
if checkpointed_result.is_started():
|
|
76
|
+
# Operation is still running, suspend until completion
|
|
77
|
+
logger.debug(
|
|
78
|
+
"⏳ Invoke %s still in progress, suspending",
|
|
79
|
+
operation_identifier.name or function_name,
|
|
80
|
+
)
|
|
81
|
+
msg = f"Invoke {operation_identifier.operation_id} still in progress"
|
|
82
|
+
suspend_with_optional_resume_delay(msg, config.timeout_seconds)
|
|
83
|
+
|
|
84
|
+
serialized_payload: str = serialize(
|
|
85
|
+
serdes=config.serdes_payload or DEFAULT_JSON_SERDES,
|
|
86
|
+
value=payload,
|
|
87
|
+
operation_id=operation_identifier.operation_id,
|
|
88
|
+
durable_execution_arn=state.durable_execution_arn,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# the backend will do the invoke once it gets this checkpoint
|
|
92
|
+
start_operation: OperationUpdate = OperationUpdate.create_invoke_start(
|
|
93
|
+
identifier=operation_identifier,
|
|
94
|
+
payload=serialized_payload,
|
|
95
|
+
chained_invoke_options=ChainedInvokeOptions(
|
|
96
|
+
function_name=function_name,
|
|
97
|
+
tenant_id=tenant_id,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Checkpoint invoke START with blocking (is_sync=True, default).
|
|
102
|
+
# Must ensure the chained invocation is recorded before suspending execution.
|
|
103
|
+
# This guarantees the invoke operation is durable and will be tracked by the backend.
|
|
104
|
+
state.create_checkpoint(operation_update=start_operation)
|
|
105
|
+
|
|
106
|
+
logger.debug(
|
|
107
|
+
"🚀 Invoke %s started, suspending for async execution",
|
|
108
|
+
operation_identifier.name or function_name,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Suspend so invoke executes asynchronously without consuming cpu here
|
|
112
|
+
msg = (
|
|
113
|
+
f"Invoke {operation_identifier.operation_id} started, suspending for completion"
|
|
114
|
+
)
|
|
115
|
+
suspend_with_optional_resume_delay(msg, config.timeout_seconds)
|
|
116
|
+
# This line should never be reached since suspend_with_optional_resume_delay always raises
|
|
117
|
+
# if it is ever reached, we will crash in a non-retryable manner via ExecutionError
|
|
118
|
+
msg = "suspend_with_optional_resume_delay should have raised an exception, but did not."
|
|
119
|
+
raise ExecutionError(msg) from None
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Implementation for Durable Map operation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Callable, Sequence
|
|
8
|
+
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
from aws_durable_execution_sdk_python.concurrency.executor import ConcurrentExecutor
|
|
11
|
+
from aws_durable_execution_sdk_python.concurrency.models import (
|
|
12
|
+
BatchResult,
|
|
13
|
+
Executable,
|
|
14
|
+
)
|
|
15
|
+
from aws_durable_execution_sdk_python.config import MapConfig
|
|
16
|
+
from aws_durable_execution_sdk_python.lambda_service import OperationSubType
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from aws_durable_execution_sdk_python.context import DurableContext
|
|
20
|
+
from aws_durable_execution_sdk_python.identifier import OperationIdentifier
|
|
21
|
+
from aws_durable_execution_sdk_python.serdes import SerDes
|
|
22
|
+
from aws_durable_execution_sdk_python.state import (
|
|
23
|
+
CheckpointedResult,
|
|
24
|
+
ExecutionState,
|
|
25
|
+
)
|
|
26
|
+
from aws_durable_execution_sdk_python.types import SummaryGenerator
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# Input item type
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
# Result type
|
|
33
|
+
R = TypeVar("R")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MapExecutor(Generic[T, R], ConcurrentExecutor[Callable, R]): # noqa: PYI059
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
executables: list[Executable[Callable]],
|
|
40
|
+
items: Sequence[T],
|
|
41
|
+
max_concurrency: int | None,
|
|
42
|
+
completion_config,
|
|
43
|
+
top_level_sub_type: OperationSubType,
|
|
44
|
+
iteration_sub_type: OperationSubType,
|
|
45
|
+
name_prefix: str,
|
|
46
|
+
serdes: SerDes | None,
|
|
47
|
+
summary_generator: SummaryGenerator | None = None,
|
|
48
|
+
item_serdes: SerDes | None = None,
|
|
49
|
+
):
|
|
50
|
+
super().__init__(
|
|
51
|
+
executables=executables,
|
|
52
|
+
max_concurrency=max_concurrency,
|
|
53
|
+
completion_config=completion_config,
|
|
54
|
+
sub_type_top=top_level_sub_type,
|
|
55
|
+
sub_type_iteration=iteration_sub_type,
|
|
56
|
+
name_prefix=name_prefix,
|
|
57
|
+
serdes=serdes,
|
|
58
|
+
summary_generator=summary_generator,
|
|
59
|
+
item_serdes=item_serdes,
|
|
60
|
+
)
|
|
61
|
+
self.items = items
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_items(
|
|
65
|
+
cls,
|
|
66
|
+
items: Sequence[T],
|
|
67
|
+
func: Callable,
|
|
68
|
+
config: MapConfig,
|
|
69
|
+
) -> MapExecutor[T, R]:
|
|
70
|
+
"""Create MapExecutor from items and a callable."""
|
|
71
|
+
executables: list[Executable[Callable]] = [
|
|
72
|
+
Executable(index=i, func=func) for i in range(len(items))
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
return cls(
|
|
76
|
+
executables=executables,
|
|
77
|
+
items=items,
|
|
78
|
+
max_concurrency=config.max_concurrency,
|
|
79
|
+
completion_config=config.completion_config,
|
|
80
|
+
top_level_sub_type=OperationSubType.MAP,
|
|
81
|
+
iteration_sub_type=OperationSubType.MAP_ITERATION,
|
|
82
|
+
name_prefix="map-item-",
|
|
83
|
+
serdes=config.serdes,
|
|
84
|
+
summary_generator=config.summary_generator,
|
|
85
|
+
item_serdes=config.item_serdes,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def execute_item(self, child_context, executable: Executable[Callable]) -> R:
|
|
89
|
+
logger.debug("🗺️ Processing map item: %s", executable.index)
|
|
90
|
+
item = self.items[executable.index]
|
|
91
|
+
result: R = executable.func(child_context, item, executable.index, self.items)
|
|
92
|
+
logger.debug("✅ Processed map item: %s", executable.index)
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def map_handler(
|
|
97
|
+
items: Sequence[T],
|
|
98
|
+
func: Callable,
|
|
99
|
+
config: MapConfig | None,
|
|
100
|
+
execution_state: ExecutionState,
|
|
101
|
+
map_context: DurableContext,
|
|
102
|
+
operation_identifier: OperationIdentifier,
|
|
103
|
+
) -> BatchResult[R]:
|
|
104
|
+
"""Execute a callable for each item in parallel."""
|
|
105
|
+
# Summary Generator Construction (matches TypeScript implementation):
|
|
106
|
+
# Construct the summary generator at the handler level, just like TypeScript does in map-handler.ts.
|
|
107
|
+
# This matches the pattern where handlers are responsible for configuring operation-specific behavior.
|
|
108
|
+
#
|
|
109
|
+
# See TypeScript reference: aws-durable-execution-sdk-js/src/handlers/map-handler/map-handler.ts (~line 79)
|
|
110
|
+
|
|
111
|
+
executor: MapExecutor[T, R] = MapExecutor.from_items(
|
|
112
|
+
items=items,
|
|
113
|
+
func=func,
|
|
114
|
+
config=config or MapConfig(summary_generator=MapSummaryGenerator()),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
checkpoint: CheckpointedResult = execution_state.get_checkpoint_result(
|
|
118
|
+
operation_identifier.operation_id
|
|
119
|
+
)
|
|
120
|
+
if checkpoint.is_succeeded():
|
|
121
|
+
# if we've reached this point, then not only is the step succeeded, but it is also `replay_children`.
|
|
122
|
+
return executor.replay(execution_state, map_context)
|
|
123
|
+
# we are making it explicit that we are now executing within the map_context
|
|
124
|
+
return executor.execute(execution_state, executor_context=map_context)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class MapSummaryGenerator:
|
|
128
|
+
def __call__(self, result: BatchResult) -> str:
|
|
129
|
+
fields = {
|
|
130
|
+
"totalCount": result.total_count,
|
|
131
|
+
"successCount": result.success_count,
|
|
132
|
+
"failureCount": result.failure_count,
|
|
133
|
+
"completionReason": result.completion_reason.value,
|
|
134
|
+
"status": result.status.value,
|
|
135
|
+
"type": "MapResult",
|
|
136
|
+
}
|
|
137
|
+
return json.dumps(fields)
|