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.
Files changed (36) hide show
  1. aws_durable_execution_sdk_python/.gitignore +0 -0
  2. aws_durable_execution_sdk_python/__about__.py +4 -0
  3. aws_durable_execution_sdk_python/__init__.py +34 -0
  4. aws_durable_execution_sdk_python/botocore/data/lambdainternal/2015-03-31/service-2.json +7864 -0
  5. aws_durable_execution_sdk_python/concurrency/__init__.py +0 -0
  6. aws_durable_execution_sdk_python/concurrency/executor.py +436 -0
  7. aws_durable_execution_sdk_python/concurrency/models.py +469 -0
  8. aws_durable_execution_sdk_python/config.py +499 -0
  9. aws_durable_execution_sdk_python/context.py +551 -0
  10. aws_durable_execution_sdk_python/exceptions.py +374 -0
  11. aws_durable_execution_sdk_python/execution.py +428 -0
  12. aws_durable_execution_sdk_python/identifier.py +14 -0
  13. aws_durable_execution_sdk_python/lambda_service.py +1034 -0
  14. aws_durable_execution_sdk_python/logger.py +131 -0
  15. aws_durable_execution_sdk_python/operation/__init__.py +1 -0
  16. aws_durable_execution_sdk_python/operation/callback.py +123 -0
  17. aws_durable_execution_sdk_python/operation/child.py +162 -0
  18. aws_durable_execution_sdk_python/operation/invoke.py +119 -0
  19. aws_durable_execution_sdk_python/operation/map.py +137 -0
  20. aws_durable_execution_sdk_python/operation/parallel.py +122 -0
  21. aws_durable_execution_sdk_python/operation/step.py +269 -0
  22. aws_durable_execution_sdk_python/operation/wait.py +53 -0
  23. aws_durable_execution_sdk_python/operation/wait_for_condition.py +235 -0
  24. aws_durable_execution_sdk_python/py.typed +1 -0
  25. aws_durable_execution_sdk_python/retries.py +174 -0
  26. aws_durable_execution_sdk_python/serdes.py +502 -0
  27. aws_durable_execution_sdk_python/state.py +790 -0
  28. aws_durable_execution_sdk_python/suspend.py +84 -0
  29. aws_durable_execution_sdk_python/threading.py +222 -0
  30. aws_durable_execution_sdk_python/types.py +180 -0
  31. aws_durable_execution_sdk_python/waits.py +130 -0
  32. aws_durable_execution_sdk_python-1.0.0.dist-info/METADATA +679 -0
  33. aws_durable_execution_sdk_python-1.0.0.dist-info/RECORD +36 -0
  34. aws_durable_execution_sdk_python-1.0.0.dist-info/WHEEL +4 -0
  35. aws_durable_execution_sdk_python-1.0.0.dist-info/licenses/LICENSE +175 -0
  36. 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)