aws-durable-execution-sdk-python-testing 1.2.0__py3-none-any.whl → 1.3.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_testing/__about__.py +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
- aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
- aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
- aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
- aws_durable_execution_sdk_python_testing/cli.py +48 -1
- aws_durable_execution_sdk_python_testing/client.py +21 -10
- aws_durable_execution_sdk_python_testing/clock.py +69 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
- aws_durable_execution_sdk_python_testing/execution.py +583 -49
- aws_durable_execution_sdk_python_testing/executor.py +1813 -349
- aws_durable_execution_sdk_python_testing/invoker.py +356 -85
- aws_durable_execution_sdk_python_testing/model.py +81 -73
- aws_durable_execution_sdk_python_testing/observer.py +100 -88
- aws_durable_execution_sdk_python_testing/runner.py +407 -30
- aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
- aws_durable_execution_sdk_python_testing/threads.py +157 -0
- aws_durable_execution_sdk_python_testing/token.py +11 -2
- aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
- aws_durable_execution_sdk_python_testing/web/routes.py +14 -6
- aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
- aws_durable_execution_sdk_python_testing/web/server.py +1 -1
- aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
- aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
- aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
- aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
- aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
- aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
- aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
- aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
- aws_durable_execution_sdk_python_testing-1.2.0.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import logging
|
|
7
|
+
import math
|
|
8
|
+
import threading
|
|
6
9
|
import time
|
|
7
10
|
import uuid
|
|
8
|
-
from
|
|
9
|
-
from
|
|
11
|
+
from concurrent.futures import wait
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import TYPE_CHECKING, assert_never
|
|
10
14
|
|
|
11
15
|
from aws_durable_execution_sdk_python.execution import (
|
|
12
16
|
DurableExecutionInvocationInput,
|
|
@@ -14,27 +18,58 @@ from aws_durable_execution_sdk_python.execution import (
|
|
|
14
18
|
InvocationStatus,
|
|
15
19
|
)
|
|
16
20
|
from aws_durable_execution_sdk_python.lambda_service import (
|
|
17
|
-
CallbackTimeoutType,
|
|
18
21
|
ErrorObject,
|
|
19
22
|
Operation,
|
|
23
|
+
OperationAction,
|
|
20
24
|
OperationUpdate,
|
|
21
25
|
OperationStatus,
|
|
22
26
|
OperationType,
|
|
23
27
|
CallbackOptions,
|
|
28
|
+
CallbackTimeoutType,
|
|
29
|
+
StepDetails,
|
|
24
30
|
)
|
|
25
31
|
|
|
32
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.core import CheckpointCore
|
|
33
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
|
|
34
|
+
DEFAULT_MAX_INVOCATION_PAGE_BYTES,
|
|
35
|
+
)
|
|
36
|
+
from aws_durable_execution_sdk_python_testing.child_dispatcher import (
|
|
37
|
+
CHILD_EXECUTION_OUTPUT_TOO_LARGE_MESSAGE,
|
|
38
|
+
FUNCTION_NOT_FOUND_ERROR_TYPE,
|
|
39
|
+
INVALID_FUNCTION_ARN_MESSAGE_FORMAT,
|
|
40
|
+
MAX_CHAINED_INVOKE_PAYLOAD_BYTES,
|
|
41
|
+
ChainedInvokeRequest,
|
|
42
|
+
ChildOutcome,
|
|
43
|
+
DispatchResult,
|
|
44
|
+
FunctionTarget,
|
|
45
|
+
KnownOutcome,
|
|
46
|
+
RunInvocation,
|
|
47
|
+
StartChild,
|
|
48
|
+
chained_invoke_timeout_error,
|
|
49
|
+
failed_to_start,
|
|
50
|
+
invoke_identifier,
|
|
51
|
+
parse_function_target,
|
|
52
|
+
payload_size_bytes,
|
|
53
|
+
)
|
|
54
|
+
from aws_durable_execution_sdk_python_testing.clock import Clock, RealClock
|
|
55
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.transformer import (
|
|
56
|
+
CheckpointRequestDispatcher,
|
|
57
|
+
)
|
|
26
58
|
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
27
|
-
ExecutionAlreadyStartedException,
|
|
28
59
|
IllegalStateException,
|
|
60
|
+
InvalidInvocationOutputError,
|
|
29
61
|
InvalidParameterValueException,
|
|
30
62
|
ResourceNotFoundException,
|
|
31
63
|
)
|
|
32
|
-
from aws_durable_execution_sdk_python_testing.execution import
|
|
64
|
+
from aws_durable_execution_sdk_python_testing.execution import (
|
|
65
|
+
Execution,
|
|
66
|
+
ExecutionStatus,
|
|
67
|
+
OperationPaginatorState,
|
|
68
|
+
)
|
|
33
69
|
from aws_durable_execution_sdk_python_testing.model import (
|
|
34
70
|
CheckpointDurableExecutionResponse,
|
|
35
71
|
CheckpointUpdatedExecutionState,
|
|
36
72
|
EventCreationContext,
|
|
37
|
-
EventType,
|
|
38
73
|
GetDurableExecutionHistoryResponse,
|
|
39
74
|
GetDurableExecutionResponse,
|
|
40
75
|
GetDurableExecutionStateResponse,
|
|
@@ -54,8 +89,20 @@ from aws_durable_execution_sdk_python_testing.model import (
|
|
|
54
89
|
from aws_durable_execution_sdk_python_testing.model import (
|
|
55
90
|
Execution as ExecutionSummary,
|
|
56
91
|
)
|
|
57
|
-
from aws_durable_execution_sdk_python_testing.observer import
|
|
58
|
-
|
|
92
|
+
from aws_durable_execution_sdk_python_testing.observer import (
|
|
93
|
+
ExecutionObserver,
|
|
94
|
+
apply_effects,
|
|
95
|
+
)
|
|
96
|
+
from aws_durable_execution_sdk_python_testing.threads import DaemonThreadPool
|
|
97
|
+
from aws_durable_execution_sdk_python_testing.token import (
|
|
98
|
+
CallbackToken,
|
|
99
|
+
CheckpointToken,
|
|
100
|
+
)
|
|
101
|
+
from aws_durable_execution_sdk_python_testing.worker.checkpoint_tasks import (
|
|
102
|
+
CallableTask,
|
|
103
|
+
)
|
|
104
|
+
from aws_durable_execution_sdk_python_testing.worker.registry import ExecutionRegistry
|
|
105
|
+
from aws_durable_execution_sdk_python_testing.worker.status import InvocationState
|
|
59
106
|
|
|
60
107
|
|
|
61
108
|
if TYPE_CHECKING:
|
|
@@ -65,7 +112,13 @@ if TYPE_CHECKING:
|
|
|
65
112
|
from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
|
|
66
113
|
CheckpointProcessor,
|
|
67
114
|
)
|
|
68
|
-
from aws_durable_execution_sdk_python_testing.
|
|
115
|
+
from aws_durable_execution_sdk_python_testing.child_dispatcher import (
|
|
116
|
+
ChildDispatcher,
|
|
117
|
+
)
|
|
118
|
+
from aws_durable_execution_sdk_python_testing.invoker import (
|
|
119
|
+
Invoker,
|
|
120
|
+
InvokeResponse,
|
|
121
|
+
)
|
|
69
122
|
from aws_durable_execution_sdk_python_testing.scheduler import Event, Scheduler
|
|
70
123
|
from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
|
|
71
124
|
|
|
@@ -73,8 +126,20 @@ logger = logging.getLogger(__name__)
|
|
|
73
126
|
|
|
74
127
|
|
|
75
128
|
class Executor(ExecutionObserver):
|
|
129
|
+
# Workers for the quick half of chained-invoke dispatch: resolving a
|
|
130
|
+
# target and creating a durable child. Each takes milliseconds.
|
|
131
|
+
QUICK_DISPATCH_WORKERS: int = 8
|
|
76
132
|
MAX_CONSECUTIVE_FAILED_ATTEMPTS: int = 5
|
|
133
|
+
# The service fails an execution on an invalid invocation output at
|
|
134
|
+
# once, except PENDING with no pending operations. An SDK race that
|
|
135
|
+
# resolves on replay can produce that one, so it gets this many
|
|
136
|
+
# consecutive attempts.
|
|
137
|
+
MAX_CONSECUTIVE_INVALID_PENDING_ATTEMPTS: int = 3
|
|
77
138
|
RETRY_BACKOFF_SECONDS: int = 5
|
|
139
|
+
# GetDurableExecutionState page-count bounds, mirroring the service
|
|
140
|
+
# (default 100, hard cap 1000 operations per page).
|
|
141
|
+
DEFAULT_STATE_PAGE_MAX_ITEMS: int = 100
|
|
142
|
+
MAX_STATE_PAGE_MAX_ITEMS: int = 1000
|
|
78
143
|
|
|
79
144
|
def __init__(
|
|
80
145
|
self,
|
|
@@ -82,23 +147,141 @@ class Executor(ExecutionObserver):
|
|
|
82
147
|
scheduler: Scheduler,
|
|
83
148
|
invoker: Invoker,
|
|
84
149
|
checkpoint_processor: CheckpointProcessor,
|
|
150
|
+
max_invocation_page_bytes: int | None = None,
|
|
151
|
+
invocation_timeout_seconds: int = 900,
|
|
152
|
+
registry: ExecutionRegistry | None = None,
|
|
153
|
+
clock: Clock | None = None,
|
|
154
|
+
child_dispatcher: ChildDispatcher | None = None,
|
|
155
|
+
region: str = "us-west-2",
|
|
85
156
|
):
|
|
86
157
|
self._store = store
|
|
158
|
+
# The region this runner stands in for. Executions record it, so a
|
|
159
|
+
# chained-invoke target ARN in another region is rejected as the
|
|
160
|
+
# service rejects one.
|
|
161
|
+
self._region = region
|
|
87
162
|
self._scheduler = scheduler
|
|
88
163
|
self._invoker = invoker
|
|
89
164
|
self._checkpoint_processor = checkpoint_processor
|
|
165
|
+
self._registry = (
|
|
166
|
+
registry if registry is not None else ExecutionRegistry(store, scheduler)
|
|
167
|
+
)
|
|
168
|
+
self._clock: Clock = clock if clock is not None else RealClock()
|
|
169
|
+
self._invocation_timeout_seconds = invocation_timeout_seconds
|
|
170
|
+
self._dispatcher = CheckpointRequestDispatcher(
|
|
171
|
+
chained_invoke_preflight=self.preflight_chained_invoke
|
|
172
|
+
)
|
|
173
|
+
self._child_dispatcher = child_dispatcher
|
|
174
|
+
self._max_invocation_page_bytes = (
|
|
175
|
+
max_invocation_page_bytes
|
|
176
|
+
if max_invocation_page_bytes is not None
|
|
177
|
+
else DEFAULT_MAX_INVOCATION_PAGE_BYTES
|
|
178
|
+
)
|
|
179
|
+
# Guards the pending-wakeup map below so concurrent callers
|
|
180
|
+
# re-arming the earliest-pending timer can't leak a Future
|
|
181
|
+
# into the dict.
|
|
182
|
+
self._pending_wakeup_lock: threading.Lock = threading.Lock()
|
|
183
|
+
# Single earliest-pending wake-up timer per execution: wait
|
|
184
|
+
# timers and step retries share one timer that fires at their
|
|
185
|
+
# earliest aggregate moment. Entries are cancelled on terminal
|
|
186
|
+
# transitions and never persisted.
|
|
187
|
+
self._pending_wakeup: dict[str, Future] = {}
|
|
90
188
|
self._completion_events: dict[str, Event] = {}
|
|
91
189
|
self._callback_timeouts: dict[str, Future] = {}
|
|
92
190
|
self._callback_heartbeats: dict[str, Future] = {}
|
|
93
|
-
self.
|
|
191
|
+
self._execution_timeouts: dict[str, Future] = {}
|
|
192
|
+
# Chained-invoke linkage: child execution ARN -> (parent
|
|
193
|
+
# execution ARN, operation id). Consulted on every terminal
|
|
194
|
+
# transition to complete the parent's CHAINED_INVOKE operation.
|
|
195
|
+
self._chained_invoke_links: dict[str, tuple[str, str]] = {}
|
|
196
|
+
self._links_lock: threading.Lock = threading.Lock()
|
|
197
|
+
# Dedicated pool for chained-invoke dispatch. Dispatching a
|
|
198
|
+
# non-durable target blocks for one invocation of it, so these
|
|
199
|
+
# calls stay off the scheduler's default executor, whose single
|
|
200
|
+
# thread every handler invocation depends on. Dispatching a
|
|
201
|
+
# durable target does not block: the child is launched and its
|
|
202
|
+
# terminal transition completes the parent's operation.
|
|
203
|
+
# Two pools, because the work has two durations. Resolving a
|
|
204
|
+
# target and creating a durable child take milliseconds. The
|
|
205
|
+
# Invoke of a plain target blocks until the target returns, up
|
|
206
|
+
# to the read timeout. Sharing one cap would let a wall of long
|
|
207
|
+
# Invokes delay a child start, which the service never does.
|
|
208
|
+
self._dispatch_pool: DaemonThreadPool = DaemonThreadPool(
|
|
209
|
+
max_workers=self.QUICK_DISPATCH_WORKERS,
|
|
210
|
+
thread_name_prefix="durable-chained-invoke",
|
|
211
|
+
)
|
|
212
|
+
self._target_pool: DaemonThreadPool = DaemonThreadPool(
|
|
213
|
+
thread_name_prefix="durable-chained-invoke-target",
|
|
214
|
+
)
|
|
215
|
+
# Dedicated pool for handler invocations, so concurrent
|
|
216
|
+
# executions (a parent and its chained children, or parallel
|
|
217
|
+
# web-driven executions) invoke their handlers in parallel.
|
|
218
|
+
self._invocation_pool: DaemonThreadPool = DaemonThreadPool(
|
|
219
|
+
thread_name_prefix="durable-invocation"
|
|
220
|
+
)
|
|
221
|
+
# Set by shutdown(). A blocking call that returns after it
|
|
222
|
+
# must not touch the store or re-invoke anything. Child starts
|
|
223
|
+
# are counted under the condition, so shutdown can wait for the
|
|
224
|
+
# ones already running (milliseconds each) before it returns.
|
|
225
|
+
self._closing: bool = False
|
|
226
|
+
self._closing_cond = threading.Condition()
|
|
227
|
+
self._child_starts_in_flight: int = 0
|
|
228
|
+
|
|
229
|
+
def shutdown(self) -> None:
|
|
230
|
+
"""Release executor-owned resources without waiting for blocked calls.
|
|
231
|
+
|
|
232
|
+
Python cannot interrupt a thread blocked in a socket read. So a
|
|
233
|
+
handler invocation or plain-target Invoke in flight is left to
|
|
234
|
+
run until its endpoint answers or its read timeout expires. The
|
|
235
|
+
pools' workers are daemon threads, so neither this call nor
|
|
236
|
+
process exit waits for them. Queued work is cancelled and never
|
|
237
|
+
starts. A result that lands after this call is dropped by the
|
|
238
|
+
coroutine that awaited it. Inside a process that keeps running,
|
|
239
|
+
such a thread idles until its read timeout, holding one thread
|
|
240
|
+
and one socket.
|
|
241
|
+
"""
|
|
242
|
+
with self._closing_cond:
|
|
243
|
+
self._closing = True
|
|
244
|
+
# A child start already past its check finishes under the
|
|
245
|
+
# same condition; a new one sees the flag and does nothing.
|
|
246
|
+
while self._child_starts_in_flight > 0:
|
|
247
|
+
self._closing_cond.wait(timeout=5)
|
|
248
|
+
self._dispatch_pool.shutdown(wait=False, cancel_futures=True)
|
|
249
|
+
self._target_pool.shutdown(wait=False, cancel_futures=True)
|
|
250
|
+
self._invocation_pool.shutdown(wait=False, cancel_futures=True)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def region(self) -> str:
|
|
254
|
+
"""The one region this runner emulates.
|
|
255
|
+
|
|
256
|
+
A function has one region for its life, so the region is fixed
|
|
257
|
+
when the runner starts. Every execution is created in it and
|
|
258
|
+
reports it.
|
|
259
|
+
"""
|
|
260
|
+
return self._region
|
|
94
261
|
|
|
95
262
|
def start_execution(
|
|
96
263
|
self,
|
|
97
264
|
input: StartDurableExecutionInput, # noqa: A002
|
|
98
265
|
) -> StartDurableExecutionOutput:
|
|
266
|
+
execution = self._create_execution(input)
|
|
267
|
+
self._launch_execution(execution, input.execution_timeout_seconds)
|
|
268
|
+
return StartDurableExecutionOutput(
|
|
269
|
+
execution_arn=execution.durable_execution_arn
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _create_execution(
|
|
273
|
+
self,
|
|
274
|
+
input: StartDurableExecutionInput, # noqa: A002
|
|
275
|
+
parent_execution_arn: str | None = None,
|
|
276
|
+
) -> Execution:
|
|
277
|
+
"""Create and persist a new execution without launching it.
|
|
278
|
+
|
|
279
|
+
``parent_execution_arn`` marks a child started by a chained
|
|
280
|
+
invoke; the child's output limit depends on it.
|
|
281
|
+
"""
|
|
99
282
|
# Generate invocation_id if not provided
|
|
100
283
|
if input.invocation_id is None:
|
|
101
|
-
input = StartDurableExecutionInput(
|
|
284
|
+
input = StartDurableExecutionInput( # noqa: A001
|
|
102
285
|
account_id=input.account_id,
|
|
103
286
|
function_name=input.function_name,
|
|
104
287
|
function_qualifier=input.function_qualifier,
|
|
@@ -113,34 +296,52 @@ class Executor(ExecutionObserver):
|
|
|
113
296
|
)
|
|
114
297
|
|
|
115
298
|
execution = Execution.new(input=input)
|
|
116
|
-
execution.
|
|
299
|
+
execution.region = self._region
|
|
300
|
+
execution.parent_execution_arn = parent_execution_arn
|
|
301
|
+
execution.start(now=self._clock.now())
|
|
117
302
|
self._store.save(execution)
|
|
118
303
|
logger.debug("Created execution with ARN: %s", execution.durable_execution_arn)
|
|
304
|
+
return execution
|
|
119
305
|
|
|
306
|
+
def _launch_execution(self, execution: Execution, timeout_seconds: int) -> None:
|
|
307
|
+
"""Arm the execution's timeout and schedule its first invocation."""
|
|
308
|
+
arn: str = execution.durable_execution_arn
|
|
120
309
|
completion_event = self._scheduler.create_event()
|
|
121
|
-
self._completion_events[
|
|
310
|
+
self._completion_events[arn] = completion_event
|
|
122
311
|
|
|
123
312
|
# Schedule execution timeout
|
|
124
|
-
if
|
|
313
|
+
if timeout_seconds > 0:
|
|
125
314
|
|
|
126
315
|
def timeout_handler():
|
|
127
316
|
error = ErrorObject.from_message(
|
|
128
|
-
f"Execution timed out after {
|
|
317
|
+
f"Execution timed out after {timeout_seconds} seconds."
|
|
129
318
|
)
|
|
130
|
-
self.on_timed_out(
|
|
319
|
+
self.on_timed_out(arn, error)
|
|
131
320
|
|
|
132
|
-
self.
|
|
321
|
+
self._execution_timeouts[arn] = self._scheduler.call_later(
|
|
133
322
|
timeout_handler,
|
|
134
|
-
delay=
|
|
323
|
+
delay=timeout_seconds,
|
|
135
324
|
completion_event=completion_event,
|
|
136
325
|
)
|
|
137
326
|
|
|
138
327
|
# Schedule initial invocation to run immediately
|
|
139
|
-
self._invoke_execution(
|
|
328
|
+
self._invoke_execution(arn)
|
|
140
329
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
330
|
+
@staticmethod
|
|
331
|
+
def _validate_execution_arn(execution_arn: str) -> None:
|
|
332
|
+
"""Reject a blank or non-string execution ARN before it reaches the registry or store.
|
|
333
|
+
|
|
334
|
+
This runner's execution ARNs are opaque local identifiers, not
|
|
335
|
+
real AWS ARNs (see ``Execution.new``), so this only rules out
|
|
336
|
+
empty/malformed input rather than checking AWS ARN shape.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
InvalidParameterValueException: If the ARN is not a
|
|
340
|
+
non-empty string.
|
|
341
|
+
"""
|
|
342
|
+
if not isinstance(execution_arn, str) or not execution_arn.strip():
|
|
343
|
+
msg: str = "Invalid Durable Execution ARN"
|
|
344
|
+
raise InvalidParameterValueException(msg)
|
|
144
345
|
|
|
145
346
|
def get_execution(self, execution_arn: str) -> Execution:
|
|
146
347
|
"""Get execution by ARN.
|
|
@@ -152,12 +353,14 @@ class Executor(ExecutionObserver):
|
|
|
152
353
|
Execution: The execution object
|
|
153
354
|
|
|
154
355
|
Raises:
|
|
356
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
155
357
|
ResourceNotFoundException: If execution does not exist
|
|
156
358
|
"""
|
|
359
|
+
self._validate_execution_arn(execution_arn)
|
|
157
360
|
try:
|
|
158
361
|
return self._store.load(execution_arn)
|
|
159
362
|
except KeyError as e:
|
|
160
|
-
msg: str =
|
|
363
|
+
msg: str = "Durable Execution does not exist"
|
|
161
364
|
raise ResourceNotFoundException(msg) from e
|
|
162
365
|
|
|
163
366
|
def get_execution_details(self, execution_arn: str) -> GetDurableExecutionResponse:
|
|
@@ -190,11 +393,11 @@ class Executor(ExecutionObserver):
|
|
|
190
393
|
return GetDurableExecutionResponse(
|
|
191
394
|
durable_execution_arn=execution.durable_execution_arn,
|
|
192
395
|
durable_execution_name=execution.start_input.execution_name,
|
|
193
|
-
function_arn=
|
|
396
|
+
function_arn=execution.function_arn(self._region),
|
|
194
397
|
status=status,
|
|
195
398
|
start_timestamp=execution_op.start_timestamp
|
|
196
399
|
if execution_op.start_timestamp
|
|
197
|
-
else
|
|
400
|
+
else self._clock.now(),
|
|
198
401
|
input_payload=execution_op.execution_details.input_payload
|
|
199
402
|
if execution_op.execution_details
|
|
200
403
|
else None,
|
|
@@ -203,7 +406,7 @@ class Executor(ExecutionObserver):
|
|
|
203
406
|
end_timestamp=execution_op.end_timestamp
|
|
204
407
|
if execution_op.end_timestamp
|
|
205
408
|
else None,
|
|
206
|
-
version=
|
|
409
|
+
version=execution.executed_version(),
|
|
207
410
|
)
|
|
208
411
|
|
|
209
412
|
def list_executions(
|
|
@@ -256,7 +459,9 @@ class Executor(ExecutionObserver):
|
|
|
256
459
|
|
|
257
460
|
# Convert to ExecutionSummary objects
|
|
258
461
|
execution_summaries: list[ExecutionSummary] = [
|
|
259
|
-
ExecutionSummary.from_execution(
|
|
462
|
+
ExecutionSummary.from_execution(
|
|
463
|
+
execution, execution.current_status().value, self._region
|
|
464
|
+
)
|
|
260
465
|
for execution in executions
|
|
261
466
|
]
|
|
262
467
|
|
|
@@ -322,14 +527,24 @@ class Executor(ExecutionObserver):
|
|
|
322
527
|
StopDurableExecutionResponse: Response containing end timestamp
|
|
323
528
|
|
|
324
529
|
Raises:
|
|
530
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
325
531
|
ResourceNotFoundException: If execution does not exist
|
|
326
532
|
"""
|
|
533
|
+
self._validate_execution_arn(execution_arn)
|
|
534
|
+
return self._registry.submit(
|
|
535
|
+
execution_arn,
|
|
536
|
+
CallableTask(lambda: self._apply_stop(execution_arn, error)),
|
|
537
|
+
).result()
|
|
538
|
+
|
|
539
|
+
def _apply_stop(
|
|
540
|
+
self, execution_arn: str, error: ErrorObject | None
|
|
541
|
+
) -> StopDurableExecutionResponse:
|
|
327
542
|
execution = self.get_execution(execution_arn)
|
|
328
543
|
|
|
329
544
|
if execution.is_complete:
|
|
330
545
|
# Idempotent: return the existing stop timestamp
|
|
331
546
|
execution_op = execution.get_operation_execution_started()
|
|
332
|
-
stop_timestamp = execution_op.end_timestamp or
|
|
547
|
+
stop_timestamp = execution_op.end_timestamp or self._clock.now()
|
|
333
548
|
return StopDurableExecutionResponse(stop_timestamp=stop_timestamp)
|
|
334
549
|
|
|
335
550
|
# Use provided error or create a default one
|
|
@@ -338,12 +553,13 @@ class Executor(ExecutionObserver):
|
|
|
338
553
|
)
|
|
339
554
|
|
|
340
555
|
# Stop sets TERMINATED close status (different from fail)
|
|
341
|
-
logger.
|
|
342
|
-
|
|
556
|
+
logger.info("[%s] Stopping execution.", execution_arn)
|
|
557
|
+
stop_time: datetime = self._clock.now()
|
|
558
|
+
execution.complete_stopped(error=stop_error, now=stop_time)
|
|
343
559
|
self._store.update(execution)
|
|
344
560
|
self._complete_events(execution_arn=execution_arn)
|
|
345
561
|
|
|
346
|
-
return StopDurableExecutionResponse(stop_timestamp=
|
|
562
|
+
return StopDurableExecutionResponse(stop_timestamp=stop_time)
|
|
347
563
|
|
|
348
564
|
def get_execution_state(
|
|
349
565
|
self,
|
|
@@ -352,53 +568,67 @@ class Executor(ExecutionObserver):
|
|
|
352
568
|
marker: str | None = None,
|
|
353
569
|
max_items: int | None = None,
|
|
354
570
|
) -> GetDurableExecutionStateResponse:
|
|
355
|
-
"""
|
|
356
|
-
|
|
357
|
-
Args:
|
|
358
|
-
execution_arn: The execution ARN
|
|
359
|
-
checkpoint_token: Checkpoint token for state consistency
|
|
360
|
-
marker: Pagination marker
|
|
361
|
-
max_items: Maximum items to return
|
|
362
|
-
|
|
363
|
-
Returns:
|
|
364
|
-
GetDurableExecutionStateResponse: Execution state with operations
|
|
571
|
+
"""Return a page of operations, serialized on the execution's worker.
|
|
365
572
|
|
|
366
573
|
Raises:
|
|
367
|
-
|
|
368
|
-
InvalidParameterValueException: If checkpoint token is invalid
|
|
574
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
369
575
|
"""
|
|
370
|
-
|
|
576
|
+
self._validate_execution_arn(execution_arn)
|
|
577
|
+
return self._registry.submit(
|
|
578
|
+
execution_arn,
|
|
579
|
+
CallableTask(
|
|
580
|
+
lambda: self._get_execution_state(
|
|
581
|
+
execution_arn, checkpoint_token, marker, max_items
|
|
582
|
+
)
|
|
583
|
+
),
|
|
584
|
+
).result()
|
|
371
585
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
586
|
+
def _get_execution_state(
|
|
587
|
+
self,
|
|
588
|
+
execution_arn: str,
|
|
589
|
+
checkpoint_token: str | None = None,
|
|
590
|
+
marker: str | None = None,
|
|
591
|
+
max_items: int | None = None,
|
|
592
|
+
) -> GetDurableExecutionStateResponse:
|
|
593
|
+
"""Return a page of operations from the pinned snapshot.
|
|
376
594
|
|
|
377
|
-
|
|
378
|
-
|
|
595
|
+
Valid only while the execution is ``INVOKING``. The
|
|
596
|
+
call is a pure read: no ``handler_seen_seq`` advance,
|
|
597
|
+
no ``token_sequence`` bump, no idempotency mutation.
|
|
379
598
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
599
|
+
The page is bounded by both the invocation byte budget and
|
|
600
|
+
``max_items`` (clamped to the service's per-page count bounds),
|
|
601
|
+
matching the service's dual byte-and-count paging.
|
|
383
602
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
603
|
+
Raises:
|
|
604
|
+
ResourceNotFoundException: execution does not exist.
|
|
605
|
+
InvalidParameterValueException: when the invocation gate
|
|
606
|
+
is not ``INVOKING``, the token is stale, or the
|
|
607
|
+
marker does not resolve against the pinned sequence.
|
|
608
|
+
"""
|
|
609
|
+
execution = self.get_execution(execution_arn)
|
|
391
610
|
|
|
392
|
-
|
|
393
|
-
|
|
611
|
+
# Invocation gate: reads are valid only while INVOKING.
|
|
612
|
+
if self._invocation_gate(execution_arn) is not InvocationState.INVOKING:
|
|
613
|
+
msg = "Invalid checkpoint token"
|
|
614
|
+
raise InvalidParameterValueException(msg)
|
|
394
615
|
|
|
395
|
-
|
|
396
|
-
if
|
|
397
|
-
|
|
616
|
+
# Token check: reject a stale or superseded checkpoint token.
|
|
617
|
+
if not self._is_current_token(execution, checkpoint_token):
|
|
618
|
+
msg = "Invalid checkpoint token"
|
|
619
|
+
raise InvalidParameterValueException(msg)
|
|
398
620
|
|
|
399
|
-
|
|
400
|
-
|
|
621
|
+
resolved_max_items: int = (
|
|
622
|
+
self.DEFAULT_STATE_PAGE_MAX_ITEMS
|
|
623
|
+
if max_items is None
|
|
624
|
+
else max(1, min(max_items, self.MAX_STATE_PAGE_MAX_ITEMS))
|
|
401
625
|
)
|
|
626
|
+
paginator = OperationPaginatorState.pin(execution)
|
|
627
|
+
ops, next_marker = paginator.page(
|
|
628
|
+
marker, self._max_invocation_page_bytes, resolved_max_items
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
return GetDurableExecutionStateResponse(operations=ops, next_marker=next_marker)
|
|
402
632
|
|
|
403
633
|
def get_execution_history(
|
|
404
634
|
self,
|
|
@@ -427,9 +657,6 @@ class Executor(ExecutionObserver):
|
|
|
427
657
|
|
|
428
658
|
# Generate events
|
|
429
659
|
all_events: list[HistoryEvent] = []
|
|
430
|
-
ops: list[Operation] = execution.operations
|
|
431
|
-
updates: list[OperationUpdate] = execution.updates
|
|
432
|
-
updates_dict: dict[str, OperationUpdate] = {u.operation_id: u for u in updates}
|
|
433
660
|
durable_execution_arn: str = execution.durable_execution_arn
|
|
434
661
|
|
|
435
662
|
# Add InvocationCompleted events
|
|
@@ -443,53 +670,229 @@ class Executor(ExecutionObserver):
|
|
|
443
670
|
)
|
|
444
671
|
all_events.append(invocation_event)
|
|
445
672
|
|
|
446
|
-
# Generate
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
673
|
+
# Generate events from update history (one event per update).
|
|
674
|
+
# Each checkpoint update increments the history event counter,
|
|
675
|
+
updates: list[OperationUpdate] = execution.updates
|
|
676
|
+
timestamps: list[datetime] = execution.update_timestamps
|
|
677
|
+
# Track cumulative step_details per operation for retry details
|
|
678
|
+
op_step_attempt: dict[str, int] = {}
|
|
451
679
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
680
|
+
if updates:
|
|
681
|
+
# Build set of operation IDs covered by updates
|
|
682
|
+
update_op_ids: set[str] = {u.operation_id for u in updates}
|
|
683
|
+
|
|
684
|
+
# For operations NOT covered by updates (e.g., EXECUTION
|
|
685
|
+
# created by start_execution, WAITs completed via
|
|
686
|
+
# complete_wait, callbacks completed async), generate events
|
|
687
|
+
# from the final operation snapshot.
|
|
688
|
+
for op in execution.operations:
|
|
689
|
+
if op.operation_id in update_op_ids:
|
|
457
690
|
continue
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
691
|
+
if op.start_timestamp is not None:
|
|
692
|
+
context = EventCreationContext(
|
|
693
|
+
op,
|
|
694
|
+
0,
|
|
695
|
+
durable_execution_arn,
|
|
696
|
+
execution.start_input,
|
|
697
|
+
execution.result,
|
|
698
|
+
None,
|
|
699
|
+
include_execution_data,
|
|
700
|
+
)
|
|
701
|
+
all_events.append(HistoryEvent.create_event_started(context))
|
|
702
|
+
if op.end_timestamp is not None and op.status in TERMINAL_STATUSES:
|
|
703
|
+
context = EventCreationContext(
|
|
704
|
+
op,
|
|
705
|
+
0,
|
|
706
|
+
durable_execution_arn,
|
|
707
|
+
execution.start_input,
|
|
708
|
+
execution.result,
|
|
709
|
+
None,
|
|
710
|
+
include_execution_data,
|
|
711
|
+
)
|
|
712
|
+
all_events.append(HistoryEvent.create_event_terminated(context))
|
|
713
|
+
|
|
714
|
+
# For operations WITH updates, generate one event per update
|
|
715
|
+
# (captures retry cycles faithfully). Use the real operation
|
|
716
|
+
# (which has all details like callback_id, wait_details, etc.)
|
|
717
|
+
# but override status/timestamps per update action.
|
|
718
|
+
ops_by_id: dict[str, Operation] = {
|
|
719
|
+
op.operation_id: op for op in execution.operations
|
|
720
|
+
}
|
|
721
|
+
for idx, update in enumerate(updates):
|
|
722
|
+
ts: datetime = (
|
|
723
|
+
timestamps[idx] if idx < len(timestamps) else self._clock.now()
|
|
466
724
|
)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
725
|
+
|
|
726
|
+
real_op: Operation | None = ops_by_id.get(update.operation_id)
|
|
727
|
+
if real_op is None:
|
|
728
|
+
continue
|
|
729
|
+
|
|
730
|
+
status: OperationStatus
|
|
731
|
+
start_ts: datetime | None = real_op.start_timestamp or ts
|
|
732
|
+
end_ts: datetime | None = None
|
|
733
|
+
step_details: StepDetails | None = real_op.step_details
|
|
734
|
+
|
|
735
|
+
match update.action:
|
|
736
|
+
case OperationAction.START:
|
|
737
|
+
status = OperationStatus.STARTED
|
|
738
|
+
start_ts = ts
|
|
739
|
+
op_step_attempt.setdefault(update.operation_id, 0)
|
|
740
|
+
op_step_attempt[update.operation_id] += 1
|
|
741
|
+
case OperationAction.RETRY:
|
|
742
|
+
# Match JS getRetryHistoryEventDetail: RETRY with
|
|
743
|
+
# error → StepFailed, without error → StepSucceeded
|
|
744
|
+
status = (
|
|
745
|
+
OperationStatus.FAILED
|
|
746
|
+
if update.error
|
|
747
|
+
else OperationStatus.SUCCEEDED
|
|
748
|
+
)
|
|
749
|
+
end_ts = ts
|
|
750
|
+
attempt: int = op_step_attempt.get(update.operation_id, 1)
|
|
751
|
+
step_details = StepDetails(
|
|
752
|
+
attempt=attempt,
|
|
753
|
+
error=update.error,
|
|
754
|
+
)
|
|
755
|
+
case OperationAction.SUCCEED:
|
|
756
|
+
status = OperationStatus.SUCCEEDED
|
|
757
|
+
end_ts = ts
|
|
758
|
+
case OperationAction.FAIL:
|
|
759
|
+
status = OperationStatus.FAILED
|
|
760
|
+
end_ts = ts
|
|
761
|
+
if update.operation_type == OperationType.STEP:
|
|
762
|
+
attempt = op_step_attempt.get(update.operation_id, 1)
|
|
763
|
+
step_details = StepDetails(
|
|
764
|
+
attempt=attempt,
|
|
765
|
+
error=update.error,
|
|
766
|
+
)
|
|
767
|
+
case _:
|
|
768
|
+
continue
|
|
769
|
+
|
|
770
|
+
# Create a copy of the real operation with overridden
|
|
771
|
+
# status and timestamps for this specific transition.
|
|
772
|
+
event_op: Operation = Operation(
|
|
773
|
+
operation_id=real_op.operation_id,
|
|
774
|
+
operation_type=real_op.operation_type,
|
|
775
|
+
status=status,
|
|
776
|
+
parent_id=real_op.parent_id,
|
|
777
|
+
name=real_op.name,
|
|
778
|
+
start_timestamp=start_ts,
|
|
779
|
+
end_timestamp=end_ts,
|
|
780
|
+
sub_type=real_op.sub_type,
|
|
781
|
+
step_details=step_details,
|
|
782
|
+
callback_details=real_op.callback_details,
|
|
783
|
+
wait_details=real_op.wait_details,
|
|
784
|
+
context_details=real_op.context_details,
|
|
785
|
+
execution_details=real_op.execution_details,
|
|
786
|
+
chained_invoke_details=real_op.chained_invoke_details,
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
op_update_ref: OperationUpdate | None = (
|
|
790
|
+
update
|
|
791
|
+
if update.action in (OperationAction.RETRY, OperationAction.FAIL)
|
|
792
|
+
or (
|
|
793
|
+
update.action == OperationAction.START
|
|
794
|
+
and update.operation_type == OperationType.CHAINED_INVOKE
|
|
795
|
+
)
|
|
796
|
+
else None
|
|
478
797
|
)
|
|
479
|
-
|
|
480
|
-
all_events.append(started)
|
|
481
|
-
if op.end_timestamp is not None and op.status in TERMINAL_STATUSES:
|
|
798
|
+
|
|
482
799
|
context = EventCreationContext(
|
|
483
|
-
|
|
484
|
-
0,
|
|
800
|
+
event_op,
|
|
801
|
+
0,
|
|
485
802
|
durable_execution_arn,
|
|
486
803
|
execution.start_input,
|
|
487
804
|
execution.result,
|
|
488
|
-
|
|
805
|
+
op_update_ref,
|
|
489
806
|
include_execution_data,
|
|
807
|
+
child_execution_arn=execution.chained_invoke_children.get(
|
|
808
|
+
update.operation_id
|
|
809
|
+
),
|
|
490
810
|
)
|
|
491
|
-
|
|
492
|
-
|
|
811
|
+
|
|
812
|
+
if update.action == OperationAction.START:
|
|
813
|
+
if update.operation_type == OperationType.CHAINED_INVOKE:
|
|
814
|
+
all_events.append(
|
|
815
|
+
HistoryEvent.create_chained_invoke_event_started(context)
|
|
816
|
+
)
|
|
817
|
+
else:
|
|
818
|
+
all_events.append(HistoryEvent.create_event_started(context))
|
|
819
|
+
else:
|
|
820
|
+
all_events.append(HistoryEvent.create_event_terminated(context))
|
|
821
|
+
|
|
822
|
+
# Operations started via checkpoint but completed async (WAITs
|
|
823
|
+
# by timer, CALLBACKs by external call) have their terminal
|
|
824
|
+
# transition only in the operation state, not in updates.
|
|
825
|
+
last_update_action: dict[str, OperationAction] = {}
|
|
826
|
+
for u in updates:
|
|
827
|
+
last_update_action[u.operation_id] = u.action
|
|
828
|
+
for op in execution.operations:
|
|
829
|
+
if op.operation_id not in update_op_ids:
|
|
830
|
+
continue
|
|
831
|
+
if (
|
|
832
|
+
last_update_action.get(op.operation_id)
|
|
833
|
+
not in (OperationAction.SUCCEED, OperationAction.FAIL)
|
|
834
|
+
and op.end_timestamp is not None
|
|
835
|
+
and op.status in TERMINAL_STATUSES
|
|
836
|
+
):
|
|
837
|
+
context = EventCreationContext(
|
|
838
|
+
op,
|
|
839
|
+
0,
|
|
840
|
+
durable_execution_arn,
|
|
841
|
+
execution.start_input,
|
|
842
|
+
execution.result,
|
|
843
|
+
None,
|
|
844
|
+
include_execution_data,
|
|
845
|
+
)
|
|
846
|
+
all_events.append(HistoryEvent.create_event_terminated(context))
|
|
847
|
+
else:
|
|
848
|
+
# Fallback: generate events from final operation state (legacy
|
|
849
|
+
# path for tests that set up operations directly without going
|
|
850
|
+
# through the checkpoint pipeline).
|
|
851
|
+
ops: list[Operation] = execution.operations
|
|
852
|
+
for op in ops:
|
|
853
|
+
if op.status is OperationStatus.PENDING:
|
|
854
|
+
if (
|
|
855
|
+
op.operation_type is not OperationType.CHAINED_INVOKE
|
|
856
|
+
or op.start_timestamp is None
|
|
857
|
+
):
|
|
858
|
+
continue
|
|
859
|
+
context = EventCreationContext(
|
|
860
|
+
op,
|
|
861
|
+
0,
|
|
862
|
+
durable_execution_arn,
|
|
863
|
+
execution.start_input,
|
|
864
|
+
execution.result,
|
|
865
|
+
None,
|
|
866
|
+
include_execution_data,
|
|
867
|
+
child_execution_arn=execution.chained_invoke_children.get(
|
|
868
|
+
op.operation_id
|
|
869
|
+
),
|
|
870
|
+
)
|
|
871
|
+
all_events.append(
|
|
872
|
+
HistoryEvent.create_chained_invoke_event_started(context)
|
|
873
|
+
)
|
|
874
|
+
if op.start_timestamp is not None:
|
|
875
|
+
context = EventCreationContext(
|
|
876
|
+
op,
|
|
877
|
+
0,
|
|
878
|
+
durable_execution_arn,
|
|
879
|
+
execution.start_input,
|
|
880
|
+
execution.result,
|
|
881
|
+
None,
|
|
882
|
+
include_execution_data,
|
|
883
|
+
)
|
|
884
|
+
all_events.append(HistoryEvent.create_event_started(context))
|
|
885
|
+
if op.end_timestamp is not None and op.status in TERMINAL_STATUSES:
|
|
886
|
+
context = EventCreationContext(
|
|
887
|
+
op,
|
|
888
|
+
0,
|
|
889
|
+
durable_execution_arn,
|
|
890
|
+
execution.start_input,
|
|
891
|
+
execution.result,
|
|
892
|
+
None,
|
|
893
|
+
include_execution_data,
|
|
894
|
+
)
|
|
895
|
+
all_events.append(HistoryEvent.create_event_terminated(context))
|
|
493
896
|
|
|
494
897
|
# Sort events by timestamp to get correct chronological order
|
|
495
898
|
all_events.sort(key=lambda event: event.event_timestamp)
|
|
@@ -556,56 +959,292 @@ class Executor(ExecutionObserver):
|
|
|
556
959
|
updates: list[OperationUpdate] | None = None,
|
|
557
960
|
client_token: str | None = None,
|
|
558
961
|
) -> CheckpointDurableExecutionResponse:
|
|
559
|
-
"""Process checkpoint
|
|
962
|
+
"""Process a checkpoint, serializing it on the execution's worker.
|
|
560
963
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
checkpoint_token: Current checkpoint token
|
|
564
|
-
updates: List of operation updates to process
|
|
565
|
-
client_token: Client token for idempotency
|
|
964
|
+
Routes through the per-execution worker so checkpoints for one
|
|
965
|
+
execution never overlap.
|
|
566
966
|
|
|
567
|
-
|
|
568
|
-
|
|
967
|
+
Raises:
|
|
968
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
969
|
+
"""
|
|
970
|
+
self._validate_execution_arn(execution_arn)
|
|
971
|
+
return self._registry.submit(
|
|
972
|
+
execution_arn,
|
|
973
|
+
CallableTask(
|
|
974
|
+
lambda: self._checkpoint_execution(
|
|
975
|
+
execution_arn, checkpoint_token, updates, client_token
|
|
976
|
+
)
|
|
977
|
+
),
|
|
978
|
+
).result()
|
|
979
|
+
|
|
980
|
+
def _checkpoint_execution(
|
|
981
|
+
self,
|
|
982
|
+
execution_arn: str,
|
|
983
|
+
checkpoint_token: str,
|
|
984
|
+
updates: list[OperationUpdate] | None = None,
|
|
985
|
+
client_token: str | None = None,
|
|
986
|
+
) -> CheckpointDurableExecutionResponse:
|
|
987
|
+
"""Process a checkpoint request for an execution.
|
|
988
|
+
|
|
989
|
+
Applies ``updates`` in place, advances ``token_sequence`` once,
|
|
990
|
+
computes the delta of operations the handler has not yet seen,
|
|
991
|
+
truncates to one page, and advances ``handler_seen_seq`` only
|
|
992
|
+
for the ops actually returned.
|
|
993
|
+
|
|
994
|
+
Runs on the execution's worker lane, so concurrent callers
|
|
995
|
+
against the same execution can never read a half-applied
|
|
996
|
+
mutation.
|
|
569
997
|
|
|
570
998
|
Raises:
|
|
571
|
-
ResourceNotFoundException: If execution does not exist
|
|
572
|
-
InvalidParameterValueException: If checkpoint token is
|
|
999
|
+
ResourceNotFoundException: If the execution does not exist.
|
|
1000
|
+
InvalidParameterValueException: If the checkpoint token is
|
|
1001
|
+
invalid (wrong ``token_sequence`` or the execution is
|
|
1002
|
+
already complete).
|
|
573
1003
|
"""
|
|
574
1004
|
execution = self.get_execution(execution_arn)
|
|
575
1005
|
|
|
576
|
-
#
|
|
577
|
-
|
|
578
|
-
|
|
1006
|
+
# Idempotency first. A caller retrying a previously-
|
|
1007
|
+
# successful checkpoint is entitled to the cached response
|
|
1008
|
+
# even if the execution has since moved on (though in
|
|
1009
|
+
# practice it won't — the SDK waits for a response before
|
|
1010
|
+
# advancing). Idempotency check runs before the token check.
|
|
1011
|
+
cached = self._maybe_replay_cached(execution, checkpoint_token, client_token)
|
|
1012
|
+
if cached is not None:
|
|
1013
|
+
return cached
|
|
1014
|
+
|
|
1015
|
+
# Invocation-state gate: reject stale checkpoints from
|
|
1016
|
+
# handler processes that died or timed out and came back after
|
|
1017
|
+
# the worker tore down.
|
|
1018
|
+
if self._invocation_gate(execution_arn) is not InvocationState.INVOKING:
|
|
1019
|
+
msg = "Invalid checkpoint token"
|
|
579
1020
|
raise InvalidParameterValueException(msg)
|
|
580
1021
|
|
|
581
|
-
if
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
updates=updates,
|
|
585
|
-
client_token=client_token,
|
|
586
|
-
)
|
|
587
|
-
|
|
588
|
-
new_execution_state = None
|
|
589
|
-
if checkpoint_output.new_execution_state:
|
|
590
|
-
new_execution_state = CheckpointUpdatedExecutionState(
|
|
591
|
-
operations=checkpoint_output.new_execution_state.operations,
|
|
592
|
-
next_marker=checkpoint_output.new_execution_state.next_marker,
|
|
593
|
-
)
|
|
1022
|
+
if not self._is_current_token(execution, checkpoint_token):
|
|
1023
|
+
msg = "Invalid checkpoint token"
|
|
1024
|
+
raise InvalidParameterValueException(msg)
|
|
594
1025
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1026
|
+
now = self._clock.now()
|
|
1027
|
+
result = CheckpointCore.apply(
|
|
1028
|
+
execution,
|
|
1029
|
+
checkpoint_token,
|
|
1030
|
+
updates or [],
|
|
1031
|
+
client_token,
|
|
1032
|
+
self._dispatcher,
|
|
1033
|
+
now,
|
|
1034
|
+
)
|
|
599
1035
|
|
|
600
|
-
# Save execution state after generating new token
|
|
601
|
-
new_checkpoint_token = execution.get_new_checkpoint_token()
|
|
602
1036
|
self._store.update(execution)
|
|
603
1037
|
|
|
1038
|
+
response = CheckpointDurableExecutionResponse(
|
|
1039
|
+
checkpoint_token=result.checkpoint_token,
|
|
1040
|
+
new_execution_state=CheckpointUpdatedExecutionState(
|
|
1041
|
+
operations=result.operations,
|
|
1042
|
+
next_marker=None,
|
|
1043
|
+
),
|
|
1044
|
+
)
|
|
1045
|
+
|
|
1046
|
+
# Apply lifecycle effects after the write, not mid-apply:
|
|
1047
|
+
# completion and failure submit follow-up work onto this
|
|
1048
|
+
# execution's lane, which a running lane task cannot re-enter.
|
|
1049
|
+
apply_effects(result.effects, self)
|
|
1050
|
+
|
|
1051
|
+
# Re-arm the earliest-pending wake-up so the next
|
|
1052
|
+
# scheduler-driven completion fires at the minimum pending
|
|
1053
|
+
# timestamp across Wait / Step ops.
|
|
1054
|
+
self._schedule_earliest_pending(execution_arn)
|
|
1055
|
+
|
|
1056
|
+
return response
|
|
1057
|
+
|
|
1058
|
+
def _maybe_replay_cached(
|
|
1059
|
+
self,
|
|
1060
|
+
execution: Execution,
|
|
1061
|
+
checkpoint_token: str,
|
|
1062
|
+
client_token: str | None,
|
|
1063
|
+
) -> CheckpointDurableExecutionResponse | None:
|
|
1064
|
+
"""Replay the cached response for a retried checkpoint call.
|
|
1065
|
+
|
|
1066
|
+
Returns ``None`` unless the last checkpoint matches the incoming
|
|
1067
|
+
``(client_token, checkpoint_token)`` pair. A match is a
|
|
1068
|
+
byte-identical replay; no state is mutated.
|
|
1069
|
+
"""
|
|
1070
|
+
cached = CheckpointCore.match_cached(execution, checkpoint_token, client_token)
|
|
1071
|
+
if cached is None:
|
|
1072
|
+
return None
|
|
604
1073
|
return CheckpointDurableExecutionResponse(
|
|
605
|
-
checkpoint_token=
|
|
606
|
-
new_execution_state=
|
|
1074
|
+
checkpoint_token=cached.outbound_checkpoint_token,
|
|
1075
|
+
new_execution_state=CheckpointUpdatedExecutionState(
|
|
1076
|
+
operations=list(cached.operations),
|
|
1077
|
+
next_marker=cached.next_marker,
|
|
1078
|
+
),
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
def _is_current_token(
|
|
1082
|
+
self,
|
|
1083
|
+
execution: Execution,
|
|
1084
|
+
checkpoint_token: str | None,
|
|
1085
|
+
) -> bool:
|
|
1086
|
+
"""Check that ``checkpoint_token`` parses cleanly and matches the
|
|
1087
|
+
execution's current ``token_sequence`` and invocation identity.
|
|
1088
|
+
Terminal executions reject all tokens."""
|
|
1089
|
+
if checkpoint_token is None:
|
|
1090
|
+
return False
|
|
1091
|
+
try:
|
|
1092
|
+
parsed = CheckpointToken.from_str(checkpoint_token)
|
|
1093
|
+
except (ValueError, KeyError):
|
|
1094
|
+
return False
|
|
1095
|
+
return (
|
|
1096
|
+
not execution.is_complete
|
|
1097
|
+
and parsed.token_sequence == execution.token_sequence
|
|
1098
|
+
and parsed.invocation_id == execution.current_invocation_id
|
|
607
1099
|
)
|
|
608
1100
|
|
|
1101
|
+
def _invocation_gate(self, arn: str) -> InvocationState:
|
|
1102
|
+
"""Read the invocation gate from the execution's worker.
|
|
1103
|
+
|
|
1104
|
+
Absent worker means no invocation has started, i.e. PRE_INVOKE.
|
|
1105
|
+
"""
|
|
1106
|
+
worker = self._registry.get(arn)
|
|
1107
|
+
return worker.status if worker is not None else InvocationState.PRE_INVOKE
|
|
1108
|
+
|
|
1109
|
+
def _set_invocation_gate(self, arn: str, status: InvocationState) -> None:
|
|
1110
|
+
"""Set the invocation gate on the execution's worker."""
|
|
1111
|
+
self._registry.get_or_create(arn).set_status(status)
|
|
1112
|
+
|
|
1113
|
+
@staticmethod
|
|
1114
|
+
def _earliest_pending_timestamp(execution: Execution) -> datetime | None:
|
|
1115
|
+
"""Minimum wake-up timestamp across scheduler-driven pending
|
|
1116
|
+
completions. Returns None if none exist.
|
|
1117
|
+
|
|
1118
|
+
Sources:
|
|
1119
|
+
|
|
1120
|
+
* ``wait.scheduled_end_timestamp`` for ``STARTED`` Wait ops
|
|
1121
|
+
|
|
1122
|
+
* ``step.next_attempt_timestamp`` for ``PENDING`` Step ops
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
Callback timeouts are deliberately excluded: they
|
|
1126
|
+
keep their existing per-callback timers and would
|
|
1127
|
+
double-fire if folded in here.
|
|
1128
|
+
"""
|
|
1129
|
+
candidates: list[datetime] = []
|
|
1130
|
+
for op in execution.operations:
|
|
1131
|
+
if (
|
|
1132
|
+
op.operation_type == OperationType.WAIT
|
|
1133
|
+
and op.status == OperationStatus.STARTED
|
|
1134
|
+
and op.wait_details is not None
|
|
1135
|
+
and op.wait_details.scheduled_end_timestamp is not None
|
|
1136
|
+
):
|
|
1137
|
+
candidates.append(op.wait_details.scheduled_end_timestamp)
|
|
1138
|
+
elif (
|
|
1139
|
+
op.operation_type == OperationType.STEP
|
|
1140
|
+
and op.status == OperationStatus.PENDING
|
|
1141
|
+
and op.step_details is not None
|
|
1142
|
+
and op.step_details.next_attempt_timestamp is not None
|
|
1143
|
+
):
|
|
1144
|
+
candidates.append(op.step_details.next_attempt_timestamp)
|
|
1145
|
+
if not candidates:
|
|
1146
|
+
return None
|
|
1147
|
+
return min(candidates)
|
|
1148
|
+
|
|
1149
|
+
def _schedule_earliest_pending(self, execution_arn: str) -> None:
|
|
1150
|
+
"""Arm a single wake-up timer at the earliest pending
|
|
1151
|
+
completion moment for ``execution_arn``. Cancels any
|
|
1152
|
+
previously armed wake-up first, so each re-invocation or
|
|
1153
|
+
checkpoint commit re-computes the horizon fresh.
|
|
1154
|
+
|
|
1155
|
+
Thread-safe: the pop+scheduler-call+put sequence runs under
|
|
1156
|
+
the supervisor lock so two concurrent callers (e.g. HTTP
|
|
1157
|
+
thread post-checkpoint + scheduler thread post-invoke) can't
|
|
1158
|
+
both leak a Future into the dict.
|
|
1159
|
+
"""
|
|
1160
|
+
try:
|
|
1161
|
+
execution = self._store.load(execution_arn)
|
|
1162
|
+
except Exception: # noqa: BLE001 — defensive; store may be torn down
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
if execution.is_complete:
|
|
1166
|
+
# Drop any stale wake-up without re-arming.
|
|
1167
|
+
with self._pending_wakeup_lock:
|
|
1168
|
+
existing = self._pending_wakeup.pop(execution_arn, None)
|
|
1169
|
+
if existing is not None and not existing.done():
|
|
1170
|
+
existing.cancel()
|
|
1171
|
+
return
|
|
1172
|
+
|
|
1173
|
+
earliest = self._earliest_pending_timestamp(execution)
|
|
1174
|
+
if earliest is None:
|
|
1175
|
+
with self._pending_wakeup_lock:
|
|
1176
|
+
existing = self._pending_wakeup.pop(execution_arn, None)
|
|
1177
|
+
if existing is not None and not existing.done():
|
|
1178
|
+
existing.cancel()
|
|
1179
|
+
return
|
|
1180
|
+
|
|
1181
|
+
delay = self._clock.arm(earliest)
|
|
1182
|
+
|
|
1183
|
+
completion_event = self._completion_events.get(execution_arn)
|
|
1184
|
+
# Cancel-then-arm atomically under the supervisor lock so
|
|
1185
|
+
# concurrent callers can't leave orphan Futures in the dict.
|
|
1186
|
+
with self._pending_wakeup_lock:
|
|
1187
|
+
existing = self._pending_wakeup.pop(execution_arn, None)
|
|
1188
|
+
future = self._scheduler.call_later(
|
|
1189
|
+
self._fire_due_and_invoke_handler(execution_arn),
|
|
1190
|
+
delay=delay,
|
|
1191
|
+
completion_event=completion_event,
|
|
1192
|
+
)
|
|
1193
|
+
self._pending_wakeup[execution_arn] = future
|
|
1194
|
+
if existing is not None and not existing.done():
|
|
1195
|
+
existing.cancel()
|
|
1196
|
+
|
|
1197
|
+
def _fire_due_operations(self, execution_arn: str) -> bool:
|
|
1198
|
+
"""Complete any Wait/Step ops whose scheduled moment has passed.
|
|
1199
|
+
|
|
1200
|
+
Returns True if at least one operation completed (so the caller
|
|
1201
|
+
re-invokes the handler).
|
|
1202
|
+
"""
|
|
1203
|
+
try:
|
|
1204
|
+
execution = self._store.load(execution_arn)
|
|
1205
|
+
except Exception: # noqa: BLE001
|
|
1206
|
+
return False
|
|
1207
|
+
|
|
1208
|
+
if execution.is_complete:
|
|
1209
|
+
return False
|
|
1210
|
+
|
|
1211
|
+
completed_any = execution.complete_due_operations(self._clock.now())
|
|
1212
|
+
if completed_any:
|
|
1213
|
+
self._store.update(execution)
|
|
1214
|
+
return completed_any
|
|
1215
|
+
|
|
1216
|
+
def _fire_due_and_invoke_handler(
|
|
1217
|
+
self, execution_arn: str
|
|
1218
|
+
) -> Callable[[], Awaitable[None]]:
|
|
1219
|
+
"""Build the coroutine armed by ``_schedule_earliest_pending``.
|
|
1220
|
+
|
|
1221
|
+
Walks every Wait/Step op whose scheduled moment has passed
|
|
1222
|
+
by ``now``, transitions them via
|
|
1223
|
+
:meth:`Execution.complete_wait` / :meth:`complete_retry`
|
|
1224
|
+
(both of which call ``touch_operation``), then routes through
|
|
1225
|
+
the normal ``_invoke_execution`` path so the same
|
|
1226
|
+
invariants apply to the resulting handler call.
|
|
1227
|
+
"""
|
|
1228
|
+
|
|
1229
|
+
async def fire_due() -> None:
|
|
1230
|
+
# Await the lane future on the scheduler loop via wrap_future
|
|
1231
|
+
# rather than asyncio.to_thread(...result()). The scheduler's
|
|
1232
|
+
# executor thread is occupied by the in-flight blocking invoke,
|
|
1233
|
+
# and an invocation that force-checkpoints depends on this timer
|
|
1234
|
+
# to complete the wait or retry it is blocked on. Bridging
|
|
1235
|
+
# through the executor would deadlock.
|
|
1236
|
+
future = self._registry.submit(
|
|
1237
|
+
execution_arn,
|
|
1238
|
+
CallableTask(lambda: self._fire_due_operations(execution_arn)),
|
|
1239
|
+
)
|
|
1240
|
+
completed_any = await asyncio.wrap_future(future)
|
|
1241
|
+
# Outside the lane: invoke and arm the next wake-up.
|
|
1242
|
+
if completed_any:
|
|
1243
|
+
self._invoke_execution(execution_arn)
|
|
1244
|
+
self._schedule_earliest_pending(execution_arn)
|
|
1245
|
+
|
|
1246
|
+
return fire_due
|
|
1247
|
+
|
|
609
1248
|
def send_callback_success(
|
|
610
1249
|
self,
|
|
611
1250
|
callback_id: str,
|
|
@@ -630,11 +1269,14 @@ class Executor(ExecutionObserver):
|
|
|
630
1269
|
|
|
631
1270
|
try:
|
|
632
1271
|
callback_token = CallbackToken.from_str(callback_id)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1272
|
+
arn = callback_token.execution_arn
|
|
1273
|
+
self._registry.submit(
|
|
1274
|
+
arn,
|
|
1275
|
+
CallableTask(
|
|
1276
|
+
lambda: self._complete_callback_success(callback_id, result)
|
|
1277
|
+
),
|
|
1278
|
+
).result()
|
|
1279
|
+
self._invoke_execution(arn)
|
|
638
1280
|
logger.info("Callback success completed for callback_id: %s", callback_id)
|
|
639
1281
|
except Exception as e:
|
|
640
1282
|
msg = f"Failed to process callback success: {e}"
|
|
@@ -642,6 +1284,15 @@ class Executor(ExecutionObserver):
|
|
|
642
1284
|
|
|
643
1285
|
return SendDurableExecutionCallbackSuccessResponse()
|
|
644
1286
|
|
|
1287
|
+
def _complete_callback_success(
|
|
1288
|
+
self, callback_id: str, result: bytes | None
|
|
1289
|
+
) -> None:
|
|
1290
|
+
callback_token = CallbackToken.from_str(callback_id)
|
|
1291
|
+
execution = self.get_execution(callback_token.execution_arn)
|
|
1292
|
+
execution.complete_callback_success(callback_id, result, now=self._clock.now())
|
|
1293
|
+
self._store.update(execution)
|
|
1294
|
+
self._cleanup_callback_timeouts(callback_id)
|
|
1295
|
+
|
|
645
1296
|
def send_callback_failure(
|
|
646
1297
|
self,
|
|
647
1298
|
callback_id: str,
|
|
@@ -664,15 +1315,24 @@ class Executor(ExecutionObserver):
|
|
|
664
1315
|
msg: str = "callback_id is required"
|
|
665
1316
|
raise InvalidParameterValueException(msg)
|
|
666
1317
|
|
|
667
|
-
|
|
1318
|
+
# A callback failure sent with no error payload must leave the
|
|
1319
|
+
# error fields absent, not synthesize an empty
|
|
1320
|
+
# ErrorMessage. ErrorObject.to_dict() omits None fields, so an all-None
|
|
1321
|
+
# error serializes to {} and the SDK surfaces undefined fields.
|
|
1322
|
+
callback_error: ErrorObject = error or ErrorObject(
|
|
1323
|
+
message=None, type=None, data=None, stack_trace=None
|
|
1324
|
+
)
|
|
668
1325
|
|
|
669
1326
|
try:
|
|
670
1327
|
callback_token: CallbackToken = CallbackToken.from_str(callback_id)
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1328
|
+
arn = callback_token.execution_arn
|
|
1329
|
+
self._registry.submit(
|
|
1330
|
+
arn,
|
|
1331
|
+
CallableTask(
|
|
1332
|
+
lambda: self._complete_callback_failure(callback_id, callback_error)
|
|
1333
|
+
),
|
|
1334
|
+
).result()
|
|
1335
|
+
self._invoke_execution(arn)
|
|
676
1336
|
logger.info("Callback failure completed for callback_id: %s", callback_id)
|
|
677
1337
|
except Exception as e:
|
|
678
1338
|
msg = f"Failed to process callback failure: {e}"
|
|
@@ -680,6 +1340,17 @@ class Executor(ExecutionObserver):
|
|
|
680
1340
|
|
|
681
1341
|
return SendDurableExecutionCallbackFailureResponse()
|
|
682
1342
|
|
|
1343
|
+
def _complete_callback_failure(
|
|
1344
|
+
self, callback_id: str, callback_error: ErrorObject
|
|
1345
|
+
) -> None:
|
|
1346
|
+
callback_token = CallbackToken.from_str(callback_id)
|
|
1347
|
+
execution = self.get_execution(callback_token.execution_arn)
|
|
1348
|
+
execution.complete_callback_failure(
|
|
1349
|
+
callback_id, callback_error, now=self._clock.now()
|
|
1350
|
+
)
|
|
1351
|
+
self._store.update(execution)
|
|
1352
|
+
self._cleanup_callback_timeouts(callback_id)
|
|
1353
|
+
|
|
683
1354
|
def send_callback_heartbeat(
|
|
684
1355
|
self, callback_id: str
|
|
685
1356
|
) -> SendDurableExecutionCallbackHeartbeatResponse:
|
|
@@ -701,18 +1372,11 @@ class Executor(ExecutionObserver):
|
|
|
701
1372
|
|
|
702
1373
|
try:
|
|
703
1374
|
callback_token: CallbackToken = CallbackToken.from_str(callback_id)
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
msg = f"Callback {callback_id} is not active"
|
|
710
|
-
raise ResourceNotFoundException(msg)
|
|
711
|
-
|
|
712
|
-
# Reset heartbeat timeout if configured
|
|
713
|
-
self._reset_callback_heartbeat_timeout(
|
|
714
|
-
callback_id, execution.durable_execution_arn
|
|
715
|
-
)
|
|
1375
|
+
arn = callback_token.execution_arn
|
|
1376
|
+
self._registry.submit(
|
|
1377
|
+
arn,
|
|
1378
|
+
CallableTask(lambda: self._process_callback_heartbeat(callback_id)),
|
|
1379
|
+
).result()
|
|
716
1380
|
logger.info("Callback heartbeat processed for callback_id: %s", callback_id)
|
|
717
1381
|
except Exception as e:
|
|
718
1382
|
msg = f"Failed to process callback heartbeat: {e}"
|
|
@@ -720,14 +1384,32 @@ class Executor(ExecutionObserver):
|
|
|
720
1384
|
|
|
721
1385
|
return SendDurableExecutionCallbackHeartbeatResponse()
|
|
722
1386
|
|
|
1387
|
+
def _process_callback_heartbeat(self, callback_id: str) -> None:
|
|
1388
|
+
callback_token = CallbackToken.from_str(callback_id)
|
|
1389
|
+
execution = self.get_execution(callback_token.execution_arn)
|
|
1390
|
+
|
|
1391
|
+
# Verify the callback operation exists and is active.
|
|
1392
|
+
_, operation = execution.find_callback_operation(callback_id)
|
|
1393
|
+
if operation.status != OperationStatus.STARTED:
|
|
1394
|
+
msg = f"Callback {callback_id} is not active"
|
|
1395
|
+
raise ResourceNotFoundException(msg)
|
|
1396
|
+
|
|
1397
|
+
self._reset_callback_heartbeat_timeout(
|
|
1398
|
+
callback_id, execution.durable_execution_arn
|
|
1399
|
+
)
|
|
1400
|
+
|
|
723
1401
|
def _validate_invocation_response_and_store(
|
|
724
1402
|
self,
|
|
725
1403
|
execution_arn: str,
|
|
726
1404
|
response: DurableExecutionInvocationOutput,
|
|
727
1405
|
execution: Execution,
|
|
1406
|
+
invocation_seq: int | None = None,
|
|
728
1407
|
):
|
|
729
1408
|
"""Validate response status and save it to the store if fine.
|
|
730
1409
|
|
|
1410
|
+
``invocation_seq`` is the execution's ``seq_counter`` when the
|
|
1411
|
+
invocation's input was built.
|
|
1412
|
+
|
|
731
1413
|
Raises:
|
|
732
1414
|
InvalidParameterValueException: If the response status is invalid.
|
|
733
1415
|
IllegalStateException: If the response status is valid but the execution is already completed.
|
|
@@ -750,9 +1432,7 @@ class Executor(ExecutionObserver):
|
|
|
750
1432
|
)
|
|
751
1433
|
raise InvalidParameterValueException(msg_failed_result)
|
|
752
1434
|
logger.info("[%s] Execution failed", execution_arn)
|
|
753
|
-
self.
|
|
754
|
-
execution_arn, result=None, error=response.error
|
|
755
|
-
)
|
|
1435
|
+
self._fail_workflow(execution_arn, response.error)
|
|
756
1436
|
|
|
757
1437
|
case InvocationStatus.SUCCEEDED:
|
|
758
1438
|
if response.error is not None:
|
|
@@ -760,13 +1440,47 @@ class Executor(ExecutionObserver):
|
|
|
760
1440
|
"Cannot provide an Error for SUCCEEDED status."
|
|
761
1441
|
)
|
|
762
1442
|
raise InvalidParameterValueException(msg_success_error)
|
|
1443
|
+
if (
|
|
1444
|
+
execution.parent_execution_arn is not None
|
|
1445
|
+
and response.result is not None
|
|
1446
|
+
and payload_size_bytes(response.result)
|
|
1447
|
+
> MAX_CHAINED_INVOKE_PAYLOAD_BYTES
|
|
1448
|
+
):
|
|
1449
|
+
# A child returns its result to the parent's chained
|
|
1450
|
+
# invoke, which caps it; the service applies the same
|
|
1451
|
+
# bound to the invocation output as to a checkpoint.
|
|
1452
|
+
logger.info("[%s] Child result exceeds the limit", execution_arn)
|
|
1453
|
+
self._fail_workflow(
|
|
1454
|
+
execution_arn,
|
|
1455
|
+
ErrorObject.from_message(
|
|
1456
|
+
CHILD_EXECUTION_OUTPUT_TOO_LARGE_MESSAGE
|
|
1457
|
+
),
|
|
1458
|
+
)
|
|
1459
|
+
return
|
|
763
1460
|
logger.info("[%s] Execution succeeded", execution_arn)
|
|
764
1461
|
self._complete_workflow(
|
|
765
1462
|
execution_arn, result=response.result, error=None
|
|
766
1463
|
)
|
|
767
1464
|
|
|
768
1465
|
case InvocationStatus.PENDING:
|
|
769
|
-
|
|
1466
|
+
# A paused execution answers the running invocation's
|
|
1467
|
+
# checkpoint without a token and defers its next invocation;
|
|
1468
|
+
# that invocation must stop as PENDING even with nothing
|
|
1469
|
+
# pending, and resume re-invokes it. Any other PENDING needs
|
|
1470
|
+
# pending operations or a change the handler has not seen
|
|
1471
|
+
# since its input was built. The unseen-change case happens
|
|
1472
|
+
# when an operation completes after this invocation's input
|
|
1473
|
+
# was built but before this response is validated.
|
|
1474
|
+
if (
|
|
1475
|
+
not execution.has_deferred_invocation
|
|
1476
|
+
and not execution.has_pending_operations(execution)
|
|
1477
|
+
and not (
|
|
1478
|
+
invocation_seq is not None
|
|
1479
|
+
and execution.has_changes_after(
|
|
1480
|
+
max(invocation_seq, execution.handler_seen_seq)
|
|
1481
|
+
)
|
|
1482
|
+
)
|
|
1483
|
+
):
|
|
770
1484
|
msg_pending_ops: str = (
|
|
771
1485
|
"Cannot return PENDING status with no pending operations."
|
|
772
1486
|
)
|
|
@@ -779,79 +1493,368 @@ class Executor(ExecutionObserver):
|
|
|
779
1493
|
)
|
|
780
1494
|
raise IllegalStateException(msg_unexpected_status)
|
|
781
1495
|
|
|
782
|
-
def
|
|
783
|
-
|
|
1496
|
+
def _begin_invocation(
|
|
1497
|
+
self, execution_arn: str
|
|
1498
|
+
) -> tuple[Execution, DurableExecutionInvocationInput, int] | None:
|
|
1499
|
+
"""Claim the invocation gate and build the handler input.
|
|
784
1500
|
|
|
785
|
-
|
|
786
|
-
|
|
1501
|
+
Returns the execution, its invocation input, and the execution's
|
|
1502
|
+
``seq_counter`` at that moment when this call claims the gate
|
|
1503
|
+
(PRE_INVOKE -> INVOKING); returns None when the execution is
|
|
1504
|
+
already complete, the gate is COMPLETED, or another invocation
|
|
1505
|
+
is in flight (recording a deferred re-invoke).
|
|
787
1506
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
return
|
|
1507
|
+
The counter is captured here, on the execution's serial lane,
|
|
1508
|
+
because a completion queued behind this call mutates the same
|
|
1509
|
+
object before the caller resumes.
|
|
1510
|
+
"""
|
|
1511
|
+
execution = self._store.load(execution_arn)
|
|
794
1512
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1513
|
+
if execution.is_complete:
|
|
1514
|
+
logger.info(
|
|
1515
|
+
"[%s] Execution already completed, ignoring invoke",
|
|
1516
|
+
execution_arn,
|
|
1517
|
+
)
|
|
1518
|
+
self._set_invocation_gate(execution_arn, InvocationState.COMPLETED)
|
|
1519
|
+
return None
|
|
799
1520
|
|
|
800
|
-
|
|
1521
|
+
current_state = self._invocation_gate(execution_arn)
|
|
1522
|
+
if current_state is InvocationState.COMPLETED:
|
|
1523
|
+
logger.info(
|
|
1524
|
+
"[%s] Invocation state already COMPLETED, not re-invoking",
|
|
1525
|
+
execution_arn,
|
|
1526
|
+
)
|
|
1527
|
+
return None
|
|
1528
|
+
if current_state is InvocationState.INVOKING:
|
|
1529
|
+
# Another invocation is in flight. Record the
|
|
1530
|
+
# re-invoke intent; the in-flight handler's post-invoke
|
|
1531
|
+
# hook consults needs_reinvoke and schedules a follow-up.
|
|
1532
|
+
execution.needs_reinvoke = True
|
|
1533
|
+
self._store.save(execution)
|
|
1534
|
+
logger.info(
|
|
1535
|
+
"[%s] Handler already INVOKING; deferring re-invoke",
|
|
1536
|
+
execution_arn,
|
|
1537
|
+
)
|
|
1538
|
+
return None
|
|
801
1539
|
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1540
|
+
if execution.is_paused:
|
|
1541
|
+
execution.defer_invocation()
|
|
1542
|
+
self._store.save(execution)
|
|
1543
|
+
logger.debug(
|
|
1544
|
+
"[%s] Holding back scheduled invocation while paused",
|
|
1545
|
+
execution_arn,
|
|
1546
|
+
)
|
|
1547
|
+
return None
|
|
1548
|
+
|
|
1549
|
+
# Claim the gate: at most one handler invocation per
|
|
1550
|
+
# execution in flight.
|
|
1551
|
+
self._set_invocation_gate(execution_arn, InvocationState.INVOKING)
|
|
1552
|
+
execution.begin_new_invocation()
|
|
1553
|
+
invocation_input = self._invoker.create_invocation_input(execution=execution)
|
|
1554
|
+
execution.mark_state_delivered()
|
|
1555
|
+
self._store.save(execution)
|
|
1556
|
+
return execution, invocation_input, execution.seq_counter
|
|
809
1557
|
|
|
810
|
-
|
|
811
|
-
|
|
1558
|
+
def _finish_invocation(
|
|
1559
|
+
self,
|
|
1560
|
+
execution_arn: str,
|
|
1561
|
+
invoke_response: InvokeResponse,
|
|
1562
|
+
invocation_start: datetime,
|
|
1563
|
+
invocation_end: datetime,
|
|
1564
|
+
invocation_seq: int | None = None,
|
|
1565
|
+
) -> None:
|
|
1566
|
+
"""Apply a completed handler invocation.
|
|
812
1567
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1568
|
+
Records the attempt, validates the response (completing, failing
|
|
1569
|
+
or retrying), resets the retry counter on a clean run, and releases
|
|
1570
|
+
the gate to PRE_INVOKE (re-invoking if a trigger was deferred) or
|
|
1571
|
+
COMPLETED.
|
|
1572
|
+
"""
|
|
1573
|
+
execution = self._store.load(execution_arn)
|
|
818
1574
|
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
1575
|
+
execution.record_invocation_completion(
|
|
1576
|
+
invocation_start,
|
|
1577
|
+
invocation_end,
|
|
1578
|
+
invoke_response.request_id,
|
|
1579
|
+
)
|
|
1580
|
+
self._store.save(execution)
|
|
825
1581
|
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
error_obj = ErrorObject.from_exception(e)
|
|
837
|
-
self._retry_invocation(execution, error_obj)
|
|
1582
|
+
if execution.is_complete:
|
|
1583
|
+
# A stop / timeout landed while we were invoking — the
|
|
1584
|
+
# terminal state is authoritative; the handler's
|
|
1585
|
+
# response does not overwrite it.
|
|
1586
|
+
logger.info(
|
|
1587
|
+
"[%s] Execution completed during invocation, ignoring result",
|
|
1588
|
+
execution_arn,
|
|
1589
|
+
)
|
|
1590
|
+
self._set_invocation_gate(execution_arn, InvocationState.COMPLETED)
|
|
1591
|
+
return
|
|
838
1592
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1593
|
+
response = invoke_response.invocation_output
|
|
1594
|
+
try:
|
|
1595
|
+
self._validate_invocation_response_and_store(
|
|
1596
|
+
execution_arn, response, execution, invocation_seq
|
|
1597
|
+
)
|
|
1598
|
+
except (InvalidParameterValueException, IllegalStateException) as e:
|
|
1599
|
+
logger.warning(
|
|
1600
|
+
"[%s] Lambda output validation failure: %s", execution_arn, e
|
|
1601
|
+
)
|
|
1602
|
+
error_obj = self._invalid_output_error(str(e))
|
|
1603
|
+
# Release the gate before retry scheduling so the retry's
|
|
1604
|
+
# invoke finds PRE_INVOKE.
|
|
1605
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1606
|
+
# Only PENDING with no pending operations is retried; every
|
|
1607
|
+
# other invalid output fails the execution at once.
|
|
1608
|
+
if response.status is InvocationStatus.PENDING:
|
|
1609
|
+
self._retry_invocation(
|
|
1610
|
+
execution,
|
|
1611
|
+
error_obj,
|
|
1612
|
+
max_attempts=self.MAX_CONSECUTIVE_INVALID_PENDING_ATTEMPTS,
|
|
1613
|
+
)
|
|
1614
|
+
else:
|
|
1615
|
+
self._fail_workflow(execution_arn, error_obj)
|
|
1616
|
+
return
|
|
1617
|
+
|
|
1618
|
+
# A clean invocation resets the retry counter.
|
|
1619
|
+
reset_target = self._store.load(execution_arn)
|
|
1620
|
+
reset_target.consecutive_failed_invocation_attempts = 0
|
|
1621
|
+
self._store.save(reset_target)
|
|
1622
|
+
|
|
1623
|
+
# Release the gate: terminal -> COMPLETED, otherwise PRE_INVOKE
|
|
1624
|
+
# (re-invoking if a trigger was deferred mid-invocation).
|
|
1625
|
+
reloaded = self._store.load(execution_arn)
|
|
1626
|
+
if reloaded.is_complete:
|
|
1627
|
+
self._set_invocation_gate(execution_arn, InvocationState.COMPLETED)
|
|
1628
|
+
should_reinvoke = False
|
|
1629
|
+
else:
|
|
1630
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1631
|
+
# A deferred trigger is only actionable if something
|
|
1632
|
+
# changed that the in-flight handler did not already
|
|
1633
|
+
# observe through a checkpoint response. A completion the
|
|
1634
|
+
# handler consumed mid-invocation earns no extra
|
|
1635
|
+
# invocation.
|
|
1636
|
+
should_reinvoke = reloaded.needs_reinvoke and reloaded.has_unseen_changes()
|
|
1637
|
+
if reloaded.needs_reinvoke:
|
|
1638
|
+
reloaded.needs_reinvoke = False
|
|
1639
|
+
self._store.save(reloaded)
|
|
1640
|
+
|
|
1641
|
+
if should_reinvoke:
|
|
1642
|
+
self._invoke_execution(execution_arn)
|
|
1643
|
+
else:
|
|
1644
|
+
# Arm the earliest-pending wake-up.
|
|
1645
|
+
self._schedule_earliest_pending(execution_arn)
|
|
1646
|
+
|
|
1647
|
+
def _fail_invocation_not_found(
|
|
1648
|
+
self, execution_arn: str, error: ErrorObject
|
|
1649
|
+
) -> None:
|
|
1650
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1651
|
+
self._fail_workflow(execution_arn, error)
|
|
1652
|
+
|
|
1653
|
+
@staticmethod
|
|
1654
|
+
def _invalid_output_error(message: str) -> ErrorObject:
|
|
1655
|
+
"""The error the service fails an execution with for an invalid output."""
|
|
1656
|
+
return ErrorObject(
|
|
1657
|
+
message=message,
|
|
1658
|
+
type="InvalidParameterValueException",
|
|
1659
|
+
data=None,
|
|
1660
|
+
stack_trace=None,
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
def _fail_after_invalid_output(
|
|
1664
|
+
self,
|
|
1665
|
+
execution_arn: str,
|
|
1666
|
+
error: ErrorObject,
|
|
1667
|
+
invocation_start: datetime,
|
|
1668
|
+
invocation_end: datetime,
|
|
1669
|
+
request_id: str | None,
|
|
1670
|
+
) -> None:
|
|
1671
|
+
"""Fail the execution for an output the invoker could not parse."""
|
|
1672
|
+
execution = self._store.load(execution_arn)
|
|
1673
|
+
execution.record_invocation_completion(
|
|
1674
|
+
invocation_start, invocation_end, request_id or str(uuid.uuid4())
|
|
1675
|
+
)
|
|
1676
|
+
self._store.save(execution)
|
|
1677
|
+
if execution.is_complete:
|
|
1678
|
+
# A stop or timeout landed during the invocation and stands.
|
|
1679
|
+
self._set_invocation_gate(execution_arn, InvocationState.COMPLETED)
|
|
1680
|
+
return
|
|
1681
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1682
|
+
self._fail_workflow(execution_arn, error)
|
|
1683
|
+
|
|
1684
|
+
def _retry_after_timeout(
|
|
1685
|
+
self,
|
|
1686
|
+
execution_arn: str,
|
|
1687
|
+
error: ErrorObject,
|
|
1688
|
+
invocation_start: datetime,
|
|
1689
|
+
invocation_end: datetime,
|
|
1690
|
+
) -> None:
|
|
1691
|
+
execution = self._store.load(execution_arn)
|
|
1692
|
+
execution.record_invocation_completion(
|
|
1693
|
+
invocation_start, invocation_end, str(uuid.uuid4())
|
|
1694
|
+
)
|
|
1695
|
+
self._store.save(execution)
|
|
1696
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1697
|
+
self._retry_invocation(execution, error)
|
|
1698
|
+
|
|
1699
|
+
def _retry_after_error(self, execution_arn: str, error: ErrorObject) -> None:
|
|
1700
|
+
self._set_invocation_gate(execution_arn, InvocationState.PRE_INVOKE)
|
|
1701
|
+
try:
|
|
1702
|
+
execution = self._store.load(execution_arn)
|
|
1703
|
+
except Exception: # noqa: BLE001
|
|
1704
|
+
return
|
|
1705
|
+
self._retry_invocation(execution, error)
|
|
1706
|
+
|
|
1707
|
+
def _invoke_handler(self, execution_arn: str) -> Callable[[], Awaitable[None]]:
|
|
1708
|
+
"""Create a parameterless callable that captures execution arn for the scheduler."""
|
|
1709
|
+
|
|
1710
|
+
async def invoke() -> None:
|
|
1711
|
+
# The gate claim, the blocking Lambda invoke, and the
|
|
1712
|
+
# post-invoke processing run as separate steps: the two
|
|
1713
|
+
# state-mutation windows run on the execution's worker lane
|
|
1714
|
+
# (serialized with checkpoints), while the blocking invoke
|
|
1715
|
+
# runs on the invocation pool so concurrent executions
|
|
1716
|
+
# (for example a chained child alongside its parent) invoke
|
|
1717
|
+
# in parallel instead of queueing on one thread.
|
|
1718
|
+
loop = asyncio.get_running_loop()
|
|
1719
|
+
try:
|
|
1720
|
+
claim = await asyncio.wrap_future(
|
|
1721
|
+
self._registry.submit(
|
|
1722
|
+
execution_arn,
|
|
1723
|
+
CallableTask(lambda: self._begin_invocation(execution_arn)),
|
|
1724
|
+
)
|
|
1725
|
+
)
|
|
1726
|
+
if claim is None:
|
|
1727
|
+
return
|
|
1728
|
+
execution, invocation_input, invocation_seq = claim
|
|
1729
|
+
|
|
1730
|
+
invocation_start = self._clock.now()
|
|
1731
|
+
invoke_response = await asyncio.wait_for(
|
|
1732
|
+
loop.run_in_executor(
|
|
1733
|
+
self._invocation_pool,
|
|
1734
|
+
lambda: self._invoker.invoke(
|
|
1735
|
+
invoke_identifier(
|
|
1736
|
+
execution.start_input.function_name,
|
|
1737
|
+
execution.start_input.function_qualifier,
|
|
1738
|
+
),
|
|
1739
|
+
invocation_input,
|
|
1740
|
+
execution.start_input.lambda_endpoint,
|
|
1741
|
+
tenant_id=execution.start_input.tenant_id,
|
|
1742
|
+
account_id=execution.start_input.account_id,
|
|
1743
|
+
region_name=execution.region,
|
|
1744
|
+
),
|
|
1745
|
+
),
|
|
1746
|
+
timeout=self._invocation_timeout_seconds,
|
|
1747
|
+
)
|
|
1748
|
+
if self._closing:
|
|
1749
|
+
return
|
|
1750
|
+
invocation_end = self._clock.now()
|
|
1751
|
+
await asyncio.wrap_future(
|
|
1752
|
+
self._registry.submit(
|
|
1753
|
+
execution_arn,
|
|
1754
|
+
CallableTask(
|
|
1755
|
+
lambda: self._finish_invocation(
|
|
1756
|
+
execution_arn,
|
|
1757
|
+
invoke_response,
|
|
1758
|
+
invocation_start,
|
|
1759
|
+
invocation_end,
|
|
1760
|
+
invocation_seq,
|
|
1761
|
+
)
|
|
1762
|
+
),
|
|
1763
|
+
)
|
|
1764
|
+
)
|
|
1765
|
+
|
|
1766
|
+
except ResourceNotFoundException as err:
|
|
1767
|
+
if self._closing:
|
|
1768
|
+
return
|
|
1769
|
+
logger.warning("[%s] Function No longer exists", execution_arn)
|
|
1770
|
+
# Keep the Lambda API error code, so a parent chained
|
|
1771
|
+
# invoke sees ResourceNotFoundException on its operation.
|
|
1772
|
+
error_obj = ErrorObject(
|
|
1773
|
+
message=str(err) or "Function not found",
|
|
1774
|
+
type=FUNCTION_NOT_FOUND_ERROR_TYPE,
|
|
1775
|
+
data=None,
|
|
1776
|
+
stack_trace=None,
|
|
1777
|
+
)
|
|
1778
|
+
await asyncio.wrap_future(
|
|
1779
|
+
self._registry.submit(
|
|
1780
|
+
execution_arn,
|
|
1781
|
+
CallableTask(
|
|
1782
|
+
lambda: self._fail_invocation_not_found(
|
|
1783
|
+
execution_arn, error_obj
|
|
1784
|
+
)
|
|
1785
|
+
),
|
|
1786
|
+
)
|
|
1787
|
+
)
|
|
1788
|
+
|
|
1789
|
+
except InvalidInvocationOutputError as err:
|
|
1790
|
+
if self._closing:
|
|
1791
|
+
return
|
|
1792
|
+
# The handler ran, but the service cannot parse its output,
|
|
1793
|
+
# so it fails the execution without invoking it again.
|
|
1794
|
+
invocation_end = self._clock.now()
|
|
1795
|
+
logger.warning(
|
|
1796
|
+
"[%s] Lambda output validation failure: %s", execution_arn, err
|
|
1797
|
+
)
|
|
1798
|
+
error_obj = self._invalid_output_error(str(err))
|
|
1799
|
+
request_id = err.request_id
|
|
1800
|
+
await asyncio.wrap_future(
|
|
1801
|
+
self._registry.submit(
|
|
1802
|
+
execution_arn,
|
|
1803
|
+
CallableTask(
|
|
1804
|
+
lambda: self._fail_after_invalid_output(
|
|
1805
|
+
execution_arn,
|
|
1806
|
+
error_obj,
|
|
1807
|
+
invocation_start,
|
|
1808
|
+
invocation_end,
|
|
1809
|
+
request_id,
|
|
1810
|
+
)
|
|
1811
|
+
),
|
|
1812
|
+
)
|
|
1813
|
+
)
|
|
1814
|
+
|
|
1815
|
+
except asyncio.TimeoutError:
|
|
1816
|
+
if self._closing:
|
|
1817
|
+
return
|
|
1818
|
+
# Invocation killed by Lambda timeout. Step operations
|
|
1819
|
+
# stay in their current state (STARTED) — no checkpoint
|
|
1820
|
+
# was sent. Record the failed invocation and re-invoke.
|
|
1821
|
+
invocation_end = self._clock.now()
|
|
1822
|
+
logger.warning(
|
|
1823
|
+
"[%s] Invocation timed out after %ds",
|
|
842
1824
|
execution_arn,
|
|
843
|
-
|
|
1825
|
+
self._invocation_timeout_seconds,
|
|
844
1826
|
)
|
|
845
1827
|
error_obj = ErrorObject.from_message(
|
|
846
|
-
message=f"Function
|
|
1828
|
+
message=f"Function timed out after {self._invocation_timeout_seconds} seconds"
|
|
1829
|
+
)
|
|
1830
|
+
await asyncio.wrap_future(
|
|
1831
|
+
self._registry.submit(
|
|
1832
|
+
execution_arn,
|
|
1833
|
+
CallableTask(
|
|
1834
|
+
lambda: self._retry_after_timeout(
|
|
1835
|
+
execution_arn,
|
|
1836
|
+
error_obj,
|
|
1837
|
+
invocation_start,
|
|
1838
|
+
invocation_end,
|
|
1839
|
+
)
|
|
1840
|
+
),
|
|
1841
|
+
)
|
|
847
1842
|
)
|
|
848
|
-
self._fail_workflow(execution_arn, error_obj)
|
|
849
1843
|
|
|
850
1844
|
except Exception as e: # noqa: BLE001
|
|
1845
|
+
if self._closing:
|
|
1846
|
+
return
|
|
851
1847
|
# Handle invocation errors (network, function not found, etc.)
|
|
852
1848
|
logger.warning("[%s] Invocation failed: %s", execution_arn, e)
|
|
853
1849
|
error_obj = ErrorObject.from_exception(e)
|
|
854
|
-
|
|
1850
|
+
await asyncio.wrap_future(
|
|
1851
|
+
self._registry.submit(
|
|
1852
|
+
execution_arn,
|
|
1853
|
+
CallableTask(
|
|
1854
|
+
lambda: self._retry_after_error(execution_arn, error_obj)
|
|
1855
|
+
),
|
|
1856
|
+
)
|
|
1857
|
+
)
|
|
855
1858
|
|
|
856
1859
|
return invoke
|
|
857
1860
|
|
|
@@ -864,6 +1867,107 @@ class Executor(ExecutionObserver):
|
|
|
864
1867
|
completion_event=completion_event,
|
|
865
1868
|
)
|
|
866
1869
|
|
|
1870
|
+
def pause_execution(self, execution_arn: str, timeout: float = 60) -> None:
|
|
1871
|
+
"""Experimental; may change or be removed in a future release.
|
|
1872
|
+
|
|
1873
|
+
Pause an execution and wait for its current invocation to stop.
|
|
1874
|
+
|
|
1875
|
+
If a running invocation sends another checkpoint after the pause is
|
|
1876
|
+
applied, its updates are accepted and saved. The response omits the
|
|
1877
|
+
checkpoint token, causing the SDK to end the invocation with PENDING.
|
|
1878
|
+
Further checkpoints from that invocation are rejected.
|
|
1879
|
+
|
|
1880
|
+
Returns once the pause request is applied and no invocation is running.
|
|
1881
|
+
A running step is not interrupted.
|
|
1882
|
+
|
|
1883
|
+
Repeated calls leave an existing pause unchanged and wait again for
|
|
1884
|
+
the running invocation to stop. Completed executions are unchanged.
|
|
1885
|
+
|
|
1886
|
+
If this call times out, the pause request is not cancelled.
|
|
1887
|
+
|
|
1888
|
+
Args:
|
|
1889
|
+
execution_arn: Execution to pause.
|
|
1890
|
+
timeout: Maximum wait in real seconds. Must be finite and positive.
|
|
1891
|
+
|
|
1892
|
+
Raises:
|
|
1893
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
1894
|
+
ResourceNotFoundException: If the execution does not exist.
|
|
1895
|
+
ValueError: If timeout is not finite and positive.
|
|
1896
|
+
TimeoutError: If the pause wait times out.
|
|
1897
|
+
"""
|
|
1898
|
+
|
|
1899
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
1900
|
+
msg = "timeout must be finite and positive"
|
|
1901
|
+
raise ValueError(msg)
|
|
1902
|
+
deadline = time.monotonic() + timeout
|
|
1903
|
+
|
|
1904
|
+
self._validate_execution_arn(execution_arn)
|
|
1905
|
+
pause = self._registry.submit(
|
|
1906
|
+
execution_arn,
|
|
1907
|
+
CallableTask(lambda: self._set_paused(execution_arn)),
|
|
1908
|
+
)
|
|
1909
|
+
|
|
1910
|
+
timeout_message = (
|
|
1911
|
+
f"Execution {execution_arn} did not become idle within {timeout:g} seconds "
|
|
1912
|
+
"of requesting pause. The pause request has not been cancelled."
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
done, _ = wait([pause], timeout=max(0, deadline - time.monotonic()))
|
|
1916
|
+
if not done:
|
|
1917
|
+
raise TimeoutError(timeout_message)
|
|
1918
|
+
pause.result()
|
|
1919
|
+
|
|
1920
|
+
self._wait_until_idle(execution_arn, deadline, timeout_message)
|
|
1921
|
+
|
|
1922
|
+
def resume_execution(self, execution_arn: str) -> None:
|
|
1923
|
+
"""Experimental; may change or be removed in a future release.
|
|
1924
|
+
|
|
1925
|
+
Make the local checkpoint server answer this execution's
|
|
1926
|
+
checkpoints with a token again.
|
|
1927
|
+
|
|
1928
|
+
Starts the invocation that pause_execution(arn) held back, if any.
|
|
1929
|
+
|
|
1930
|
+
Repeated calls leave an execution that is not paused unchanged.
|
|
1931
|
+
Completed executions are unchanged.
|
|
1932
|
+
|
|
1933
|
+
Raises:
|
|
1934
|
+
InvalidParameterValueException: If the ARN is blank.
|
|
1935
|
+
ResourceNotFoundException: If the execution does not exist.
|
|
1936
|
+
"""
|
|
1937
|
+
self._validate_execution_arn(execution_arn)
|
|
1938
|
+
self._registry.submit(
|
|
1939
|
+
execution_arn,
|
|
1940
|
+
CallableTask(lambda: self._resume_execution(execution_arn)),
|
|
1941
|
+
).result()
|
|
1942
|
+
|
|
1943
|
+
def _set_paused(self, execution_arn: str) -> None:
|
|
1944
|
+
execution = self.get_execution(execution_arn)
|
|
1945
|
+
if execution.is_complete or execution.is_paused:
|
|
1946
|
+
return
|
|
1947
|
+
execution.pause()
|
|
1948
|
+
self._store.save(execution)
|
|
1949
|
+
|
|
1950
|
+
def _resume_execution(self, execution_arn: str) -> None:
|
|
1951
|
+
execution = self.get_execution(execution_arn)
|
|
1952
|
+
if execution.is_complete or not execution.is_paused:
|
|
1953
|
+
return
|
|
1954
|
+
|
|
1955
|
+
deferred = execution.resume()
|
|
1956
|
+
|
|
1957
|
+
self._store.save(execution)
|
|
1958
|
+
if deferred:
|
|
1959
|
+
self._invoke_execution(execution_arn)
|
|
1960
|
+
|
|
1961
|
+
def _wait_until_idle(
|
|
1962
|
+
self, execution_arn: str, deadline: float, timeout_message: str
|
|
1963
|
+
) -> None:
|
|
1964
|
+
"""Wait for no invocation to be running. Raise at the deadline."""
|
|
1965
|
+
while self._invocation_gate(execution_arn) is InvocationState.INVOKING:
|
|
1966
|
+
remaining = deadline - time.monotonic()
|
|
1967
|
+
if remaining <= 0:
|
|
1968
|
+
raise TimeoutError(timeout_message)
|
|
1969
|
+
time.sleep(min(0.005, remaining))
|
|
1970
|
+
|
|
867
1971
|
def _complete_workflow(
|
|
868
1972
|
self, execution_arn: str, result: str | None, error: ErrorObject | None
|
|
869
1973
|
):
|
|
@@ -880,7 +1984,7 @@ class Executor(ExecutionObserver):
|
|
|
880
1984
|
else:
|
|
881
1985
|
self.complete_execution(execution_arn, result)
|
|
882
1986
|
|
|
883
|
-
def _fail_workflow(self, execution_arn: str, error: ErrorObject):
|
|
1987
|
+
def _fail_workflow(self, execution_arn: str, error: ErrorObject | None):
|
|
884
1988
|
"""Fail workflow with terminal state validation."""
|
|
885
1989
|
execution = self._store.load(execution_arn)
|
|
886
1990
|
|
|
@@ -891,32 +1995,202 @@ class Executor(ExecutionObserver):
|
|
|
891
1995
|
|
|
892
1996
|
self.fail_execution(execution_arn, error)
|
|
893
1997
|
|
|
894
|
-
def _retry_invocation(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
1998
|
+
def _retry_invocation(
|
|
1999
|
+
self,
|
|
2000
|
+
execution: Execution,
|
|
2001
|
+
error: ErrorObject,
|
|
2002
|
+
max_attempts: int | None = None,
|
|
2003
|
+
) -> None:
|
|
2004
|
+
"""Handle retry logic or fail execution if retries exhausted.
|
|
2005
|
+
|
|
2006
|
+
Budget: ``max_attempts`` attempts, by default
|
|
2007
|
+
``MAX_CONSECUTIVE_FAILED_ATTEMPTS``, per ``RETRY_BACKOFF_SECONDS``
|
|
2008
|
+
backoff. Increments the counter BEFORE the threshold check so an
|
|
2009
|
+
attempt-N failure that reaches the ceiling fails the execution
|
|
2010
|
+
rather than scheduling a pointless retry.
|
|
2011
|
+
"""
|
|
2012
|
+
if max_attempts is None:
|
|
2013
|
+
max_attempts = self.MAX_CONSECUTIVE_FAILED_ATTEMPTS
|
|
2014
|
+
execution.consecutive_failed_invocation_attempts += 1
|
|
2015
|
+
self._store.save(execution)
|
|
2016
|
+
|
|
2017
|
+
if execution.consecutive_failed_invocation_attempts >= max_attempts:
|
|
2018
|
+
# Budget exhausted — fail the execution with the last
|
|
2019
|
+
# observed error.
|
|
901
2020
|
self._fail_workflow(
|
|
902
2021
|
execution_arn=execution.durable_execution_arn, error=error
|
|
903
2022
|
)
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
2023
|
+
return
|
|
2024
|
+
|
|
2025
|
+
# Schedule retry with backoff via the same _invoke_execution
|
|
2026
|
+
# entry point, so the at-most-one-invocation guarantee still
|
|
2027
|
+
# applies.
|
|
2028
|
+
self._invoke_execution(
|
|
2029
|
+
execution_arn=execution.durable_execution_arn,
|
|
2030
|
+
delay=self.RETRY_BACKOFF_SECONDS,
|
|
2031
|
+
)
|
|
912
2032
|
|
|
913
2033
|
def _complete_events(self, execution_arn: str):
|
|
914
2034
|
# complete doesn't actually checkpoint explicitly
|
|
915
2035
|
if event := self._completion_events.get(execution_arn):
|
|
916
2036
|
event.set()
|
|
917
|
-
if self.
|
|
918
|
-
|
|
919
|
-
|
|
2037
|
+
if timeout_future := self._execution_timeouts.pop(execution_arn, None):
|
|
2038
|
+
timeout_future.cancel()
|
|
2039
|
+
self._notify_parent_of_terminal_child(execution_arn)
|
|
2040
|
+
|
|
2041
|
+
def _notify_parent_of_terminal_child(self, child_arn: str) -> None:
|
|
2042
|
+
"""Complete the parent's CHAINED_INVOKE operation for a terminal child.
|
|
2043
|
+
|
|
2044
|
+
The link from a child to its parent operation is looked up in
|
|
2045
|
+
memory first. A runner restart empties that map. Both halves of
|
|
2046
|
+
the link are persisted: the child's parent ARN and the parent's
|
|
2047
|
+
map of operation id to child ARN. So a miss falls back to the
|
|
2048
|
+
store, and a child that completes after a restart still reaches
|
|
2049
|
+
its parent. No-op for an execution without a parent. The
|
|
2050
|
+
parent-side transition runs on the parent's worker lane and the
|
|
2051
|
+
parent is re-invoked to observe it.
|
|
2052
|
+
"""
|
|
2053
|
+
with self._links_lock:
|
|
2054
|
+
link: tuple[str, str] | None = self._chained_invoke_links.pop(
|
|
2055
|
+
child_arn, None
|
|
2056
|
+
)
|
|
2057
|
+
child: Execution | None = None
|
|
2058
|
+
try:
|
|
2059
|
+
child = self._store.load(child_arn)
|
|
2060
|
+
except Exception: # noqa: BLE001 — never let linkage failure kill the child's terminal path
|
|
2061
|
+
logger.exception("[%s] Failed to read terminal execution", child_arn)
|
|
2062
|
+
if link is None:
|
|
2063
|
+
link = self._persisted_link(child)
|
|
2064
|
+
if link is None:
|
|
2065
|
+
return
|
|
2066
|
+
parent_arn, operation_id = link
|
|
2067
|
+
|
|
2068
|
+
if child is None:
|
|
2069
|
+
status: OperationStatus = OperationStatus.FAILED
|
|
2070
|
+
result: str | None = None
|
|
2071
|
+
error: ErrorObject | None = ErrorObject.from_message(
|
|
2072
|
+
"Chained invoke target completed but its outcome could not be read."
|
|
2073
|
+
)
|
|
2074
|
+
else:
|
|
2075
|
+
status, result, error = self._child_terminal_to_completion(child)
|
|
2076
|
+
|
|
2077
|
+
self._registry.submit(
|
|
2078
|
+
parent_arn,
|
|
2079
|
+
CallableTask(
|
|
2080
|
+
lambda: self._apply_chained_invoke_completion(
|
|
2081
|
+
parent_arn, operation_id, status, result, error
|
|
2082
|
+
)
|
|
2083
|
+
),
|
|
2084
|
+
)
|
|
2085
|
+
self._invoke_execution(parent_arn)
|
|
2086
|
+
|
|
2087
|
+
def _persisted_link(self, child: Execution | None) -> tuple[str, str] | None:
|
|
2088
|
+
"""Rebuild a child's link to its parent operation from the store.
|
|
2089
|
+
|
|
2090
|
+
Returns None when the child has no parent, when the parent cannot
|
|
2091
|
+
be read, or when the parent no longer lists the child.
|
|
2092
|
+
"""
|
|
2093
|
+
if child is None or child.parent_execution_arn is None:
|
|
2094
|
+
return None
|
|
2095
|
+
try:
|
|
2096
|
+
parent: Execution = self._store.load(child.parent_execution_arn)
|
|
2097
|
+
except Exception: # noqa: BLE001 — a missing parent must not kill the child's terminal path
|
|
2098
|
+
logger.exception(
|
|
2099
|
+
"[%s] Failed to read parent %s",
|
|
2100
|
+
child.durable_execution_arn,
|
|
2101
|
+
child.parent_execution_arn,
|
|
2102
|
+
)
|
|
2103
|
+
return None
|
|
2104
|
+
for operation_id, child_arn in parent.chained_invoke_children.items():
|
|
2105
|
+
if child_arn == child.durable_execution_arn:
|
|
2106
|
+
return (parent.durable_execution_arn, operation_id)
|
|
2107
|
+
return None
|
|
2108
|
+
|
|
2109
|
+
@staticmethod
|
|
2110
|
+
def _child_terminal_to_completion(
|
|
2111
|
+
child: Execution,
|
|
2112
|
+
) -> tuple[OperationStatus, str | None, ErrorObject | None]:
|
|
2113
|
+
"""Map a terminal child execution to the parent operation outcome."""
|
|
2114
|
+
result_output = child.result
|
|
2115
|
+
status: ExecutionStatus = child.current_status()
|
|
2116
|
+
match status:
|
|
2117
|
+
case ExecutionStatus.SUCCEEDED:
|
|
2118
|
+
return (
|
|
2119
|
+
OperationStatus.SUCCEEDED,
|
|
2120
|
+
result_output.result if result_output else None,
|
|
2121
|
+
None,
|
|
2122
|
+
)
|
|
2123
|
+
case ExecutionStatus.TIMED_OUT:
|
|
2124
|
+
# A timed-out child carries no payload; the service reports
|
|
2125
|
+
# a fixed error type and the child's execution timeout.
|
|
2126
|
+
return (
|
|
2127
|
+
OperationStatus.TIMED_OUT,
|
|
2128
|
+
None,
|
|
2129
|
+
chained_invoke_timeout_error(
|
|
2130
|
+
child.start_input.execution_timeout_seconds
|
|
2131
|
+
),
|
|
2132
|
+
)
|
|
2133
|
+
case ExecutionStatus.STOPPED:
|
|
2134
|
+
# A stopped child surfaces the error object that the stop
|
|
2135
|
+
# request carried, as the service does.
|
|
2136
|
+
return (
|
|
2137
|
+
OperationStatus.STOPPED,
|
|
2138
|
+
None,
|
|
2139
|
+
result_output.error if result_output else None,
|
|
2140
|
+
)
|
|
2141
|
+
case ExecutionStatus.FAILED:
|
|
2142
|
+
error = (
|
|
2143
|
+
result_output.error
|
|
2144
|
+
if result_output and result_output.error
|
|
2145
|
+
else ErrorObject.from_message("Chained invoke failed.")
|
|
2146
|
+
)
|
|
2147
|
+
return (OperationStatus.FAILED, None, error)
|
|
2148
|
+
case ExecutionStatus.RUNNING:
|
|
2149
|
+
msg: str = (
|
|
2150
|
+
f"Child execution {child.durable_execution_arn} has not "
|
|
2151
|
+
"reached a terminal state."
|
|
2152
|
+
)
|
|
2153
|
+
raise IllegalStateException(msg)
|
|
2154
|
+
case _:
|
|
2155
|
+
assert_never(status)
|
|
2156
|
+
|
|
2157
|
+
def _apply_chained_invoke_completion(
|
|
2158
|
+
self,
|
|
2159
|
+
parent_arn: str,
|
|
2160
|
+
operation_id: str,
|
|
2161
|
+
status: OperationStatus,
|
|
2162
|
+
result: str | None,
|
|
2163
|
+
error: ErrorObject | None,
|
|
2164
|
+
) -> None:
|
|
2165
|
+
"""Stamp a chained-invoke outcome onto the parent operation.
|
|
2166
|
+
|
|
2167
|
+
Runs on the parent's worker lane. Drops the outcome when the
|
|
2168
|
+
parent is already terminal (its terminal state is authoritative)
|
|
2169
|
+
or when the operation already completed.
|
|
2170
|
+
"""
|
|
2171
|
+
try:
|
|
2172
|
+
execution = self.get_execution(parent_arn)
|
|
2173
|
+
except ResourceNotFoundException:
|
|
2174
|
+
logger.warning(
|
|
2175
|
+
"[%s] Parent not found for chained invoke %s", parent_arn, operation_id
|
|
2176
|
+
)
|
|
2177
|
+
return
|
|
2178
|
+
if execution.is_complete:
|
|
2179
|
+
return
|
|
2180
|
+
_, operation = execution.find_operation(operation_id)
|
|
2181
|
+
# Must agree with Execution.complete_chained_invoke, which
|
|
2182
|
+
# accepts only STARTED; a raise here would land in a lane
|
|
2183
|
+
# future nobody awaits.
|
|
2184
|
+
if operation.status is not OperationStatus.STARTED:
|
|
2185
|
+
return
|
|
2186
|
+
execution.complete_chained_invoke(
|
|
2187
|
+
operation_id,
|
|
2188
|
+
status,
|
|
2189
|
+
result=result,
|
|
2190
|
+
error=error,
|
|
2191
|
+
now=self._clock.now(),
|
|
2192
|
+
)
|
|
2193
|
+
self._store.update(execution)
|
|
920
2194
|
|
|
921
2195
|
def wait_until_complete(
|
|
922
2196
|
self, execution_arn: str, timeout: float | None = None
|
|
@@ -941,18 +2215,18 @@ class Executor(ExecutionObserver):
|
|
|
941
2215
|
"""Complete execution successfully (COMPLETE_WORKFLOW_EXECUTION decision)."""
|
|
942
2216
|
logger.debug("[%s] Completing execution with result: %s", execution_arn, result)
|
|
943
2217
|
execution: Execution = self._store.load(execution_arn=execution_arn)
|
|
944
|
-
execution.complete_success(result=result
|
|
2218
|
+
execution.complete_success(result=result, now=self._clock.now())
|
|
945
2219
|
self._store.update(execution)
|
|
946
2220
|
if execution.result is None:
|
|
947
2221
|
msg: str = "Execution result is required"
|
|
948
2222
|
raise IllegalStateException(msg)
|
|
949
2223
|
self._complete_events(execution_arn=execution_arn)
|
|
950
2224
|
|
|
951
|
-
def fail_execution(self, execution_arn: str, error: ErrorObject) -> None:
|
|
952
|
-
"""Fail execution with error (FAIL_WORKFLOW_EXECUTION decision)."""
|
|
2225
|
+
def fail_execution(self, execution_arn: str, error: ErrorObject | None) -> None:
|
|
2226
|
+
"""Fail execution with optional error (FAIL_WORKFLOW_EXECUTION decision)."""
|
|
953
2227
|
logger.error("[%s] Completing execution with error: %s", execution_arn, error)
|
|
954
2228
|
execution: Execution = self._store.load(execution_arn=execution_arn)
|
|
955
|
-
execution.complete_fail(error=error
|
|
2229
|
+
execution.complete_fail(error=error, now=self._clock.now())
|
|
956
2230
|
self._store.update(execution)
|
|
957
2231
|
# set by complete_fail
|
|
958
2232
|
if execution.result is None:
|
|
@@ -960,102 +2234,34 @@ class Executor(ExecutionObserver):
|
|
|
960
2234
|
raise IllegalStateException(msg)
|
|
961
2235
|
self._complete_events(execution_arn=execution_arn)
|
|
962
2236
|
|
|
963
|
-
def _on_wait_succeeded(self, execution_arn: str, operation_id: str) -> None:
|
|
964
|
-
"""Private method - called when a wait operation completes successfully."""
|
|
965
|
-
execution = self._store.load(execution_arn)
|
|
966
|
-
|
|
967
|
-
if execution.is_complete:
|
|
968
|
-
logger.info(
|
|
969
|
-
"[%s] Execution already completed, ignoring wait succeeded event",
|
|
970
|
-
execution_arn,
|
|
971
|
-
)
|
|
972
|
-
return
|
|
973
|
-
|
|
974
|
-
try:
|
|
975
|
-
execution.complete_wait(operation_id=operation_id)
|
|
976
|
-
self._store.update(execution)
|
|
977
|
-
logger.debug(
|
|
978
|
-
"[%s] Wait succeeded for operation %s", execution_arn, operation_id
|
|
979
|
-
)
|
|
980
|
-
except Exception:
|
|
981
|
-
logger.exception("[%s] Error processing wait succeeded.", execution_arn)
|
|
982
|
-
|
|
983
|
-
def _on_retry_ready(self, execution_arn: str, operation_id: str) -> None:
|
|
984
|
-
"""Private method - called when a retry delay has elapsed and retry is ready."""
|
|
985
|
-
execution = self._store.load(execution_arn)
|
|
986
|
-
|
|
987
|
-
if execution.is_complete:
|
|
988
|
-
logger.info(
|
|
989
|
-
"[%s] Execution already completed, ignoring retry", execution_arn
|
|
990
|
-
)
|
|
991
|
-
return
|
|
992
|
-
|
|
993
|
-
try:
|
|
994
|
-
execution.complete_retry(operation_id=operation_id)
|
|
995
|
-
self._store.update(execution)
|
|
996
|
-
logger.debug(
|
|
997
|
-
"[%s] Retry ready for operation %s", execution_arn, operation_id
|
|
998
|
-
)
|
|
999
|
-
except Exception:
|
|
1000
|
-
logger.exception("[%s] Error processing retry ready.", execution_arn)
|
|
1001
|
-
|
|
1002
2237
|
# region ExecutionObserver
|
|
1003
2238
|
def on_completed(self, execution_arn: str, result: str | None = None) -> None:
|
|
1004
2239
|
"""Complete execution successfully. Observer method triggered by notifier."""
|
|
1005
2240
|
self.complete_execution(execution_arn, result)
|
|
1006
2241
|
|
|
1007
|
-
def on_failed(self, execution_arn: str, error: ErrorObject) -> None:
|
|
2242
|
+
def on_failed(self, execution_arn: str, error: ErrorObject | None) -> None:
|
|
1008
2243
|
"""Fail execution. Observer method triggered by notifier."""
|
|
1009
2244
|
self.fail_execution(execution_arn, error)
|
|
1010
2245
|
|
|
1011
2246
|
def on_timed_out(self, execution_arn: str, error: ErrorObject) -> None:
|
|
1012
|
-
"""Handle execution timeout (workflow timeout).
|
|
1013
|
-
logger.
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
2247
|
+
"""Handle execution timeout (workflow timeout)."""
|
|
2248
|
+
logger.warning("[%s] Execution timed out.", execution_arn)
|
|
2249
|
+
self._registry.submit(
|
|
2250
|
+
execution_arn,
|
|
2251
|
+
CallableTask(lambda: self._apply_timeout(execution_arn, error)),
|
|
2252
|
+
).result()
|
|
1017
2253
|
self._complete_events(execution_arn=execution_arn)
|
|
1018
2254
|
|
|
2255
|
+
def _apply_timeout(self, execution_arn: str, error: ErrorObject) -> None:
|
|
2256
|
+
execution = self._store.load(execution_arn=execution_arn)
|
|
2257
|
+
execution.complete_timeout(error=error, now=self._clock.now())
|
|
2258
|
+
self._store.update(execution)
|
|
2259
|
+
|
|
1019
2260
|
def on_stopped(self, execution_arn: str, error: ErrorObject) -> None:
|
|
1020
|
-
"""Handle execution stop.
|
|
2261
|
+
"""Handle execution stop."""
|
|
1021
2262
|
# This should not be called directly - stop_execution handles termination
|
|
1022
2263
|
self.fail_execution(execution_arn, error)
|
|
1023
2264
|
|
|
1024
|
-
def on_wait_timer_scheduled(
|
|
1025
|
-
self, execution_arn: str, operation_id: str, delay: float
|
|
1026
|
-
) -> None:
|
|
1027
|
-
"""Schedule a wait operation. Observer method triggered by notifier."""
|
|
1028
|
-
logger.debug("[%s] scheduling wait with delay: %d", execution_arn, delay)
|
|
1029
|
-
|
|
1030
|
-
def wait_handler() -> None:
|
|
1031
|
-
self._on_wait_succeeded(execution_arn, operation_id)
|
|
1032
|
-
self._invoke_execution(execution_arn, delay=0)
|
|
1033
|
-
|
|
1034
|
-
completion_event = self._completion_events.get(execution_arn)
|
|
1035
|
-
self._scheduler.call_later(
|
|
1036
|
-
wait_handler, delay=delay, completion_event=completion_event
|
|
1037
|
-
)
|
|
1038
|
-
|
|
1039
|
-
def on_step_retry_scheduled(
|
|
1040
|
-
self, execution_arn: str, operation_id: str, delay: float
|
|
1041
|
-
) -> None:
|
|
1042
|
-
"""Schedule a retry a step. Observer method triggered by notifier."""
|
|
1043
|
-
logger.debug(
|
|
1044
|
-
"[%s] scheduling retry for %s with delay: %d",
|
|
1045
|
-
execution_arn,
|
|
1046
|
-
operation_id,
|
|
1047
|
-
delay,
|
|
1048
|
-
)
|
|
1049
|
-
|
|
1050
|
-
def retry_handler() -> None:
|
|
1051
|
-
self._on_retry_ready(execution_arn, operation_id)
|
|
1052
|
-
self._invoke_execution(execution_arn, delay=0)
|
|
1053
|
-
|
|
1054
|
-
completion_event = self._completion_events.get(execution_arn)
|
|
1055
|
-
self._scheduler.call_later(
|
|
1056
|
-
retry_handler, delay=delay, completion_event=completion_event
|
|
1057
|
-
)
|
|
1058
|
-
|
|
1059
2265
|
def on_callback_created(
|
|
1060
2266
|
self,
|
|
1061
2267
|
execution_arn: str,
|
|
@@ -1075,8 +2281,255 @@ class Executor(ExecutionObserver):
|
|
|
1075
2281
|
# Schedule callback timeouts if configured
|
|
1076
2282
|
self._schedule_callback_timeouts(execution_arn, callback_options, callback_id)
|
|
1077
2283
|
|
|
2284
|
+
def on_chained_invoke_started(
|
|
2285
|
+
self,
|
|
2286
|
+
execution_arn: str,
|
|
2287
|
+
operation_id: str,
|
|
2288
|
+
function_name: str,
|
|
2289
|
+
tenant_id: str | None,
|
|
2290
|
+
payload: str | None,
|
|
2291
|
+
) -> None:
|
|
2292
|
+
"""Dispatch a chained-invoke target. Observer method triggered by notifier.
|
|
2293
|
+
|
|
2294
|
+
Scheduling only: the dispatch itself runs off the caller's
|
|
2295
|
+
worker lane so the checkpoint that raised the effect returns
|
|
2296
|
+
without waiting on the target.
|
|
2297
|
+
"""
|
|
2298
|
+
completion_event = self._completion_events.get(execution_arn)
|
|
2299
|
+
self._scheduler.call_later(
|
|
2300
|
+
self._dispatch_chained_invoke(
|
|
2301
|
+
execution_arn, operation_id, function_name, tenant_id, payload
|
|
2302
|
+
),
|
|
2303
|
+
completion_event=completion_event,
|
|
2304
|
+
)
|
|
2305
|
+
|
|
1078
2306
|
# endregion ExecutionObserver
|
|
1079
2307
|
|
|
2308
|
+
# region Chained invoke
|
|
2309
|
+
def _dispatch_chained_invoke(
|
|
2310
|
+
self,
|
|
2311
|
+
execution_arn: str,
|
|
2312
|
+
operation_id: str,
|
|
2313
|
+
function_name: str,
|
|
2314
|
+
tenant_id: str | None,
|
|
2315
|
+
payload: str | None,
|
|
2316
|
+
) -> Callable[[], Awaitable[None]]:
|
|
2317
|
+
"""Build the dispatch coroutine for a chained-invoke operation."""
|
|
2318
|
+
|
|
2319
|
+
async def dispatch() -> None:
|
|
2320
|
+
loop = asyncio.get_running_loop()
|
|
2321
|
+
try:
|
|
2322
|
+
result: DispatchResult = await loop.run_in_executor(
|
|
2323
|
+
self._dispatch_pool,
|
|
2324
|
+
lambda: self._resolve_dispatch(
|
|
2325
|
+
execution_arn,
|
|
2326
|
+
operation_id,
|
|
2327
|
+
function_name,
|
|
2328
|
+
tenant_id,
|
|
2329
|
+
payload,
|
|
2330
|
+
),
|
|
2331
|
+
)
|
|
2332
|
+
if self._closing:
|
|
2333
|
+
return
|
|
2334
|
+
|
|
2335
|
+
match result:
|
|
2336
|
+
case StartChild(child_start=child_start):
|
|
2337
|
+
await loop.run_in_executor(
|
|
2338
|
+
self._dispatch_pool,
|
|
2339
|
+
lambda: self._start_child_execution(
|
|
2340
|
+
execution_arn, operation_id, child_start
|
|
2341
|
+
),
|
|
2342
|
+
)
|
|
2343
|
+
case RunInvocation(invocation=invocation):
|
|
2344
|
+
invocation_result: ChildOutcome = await loop.run_in_executor(
|
|
2345
|
+
self._target_pool, invocation
|
|
2346
|
+
)
|
|
2347
|
+
if self._closing:
|
|
2348
|
+
return
|
|
2349
|
+
await self._finish_from_outcome(
|
|
2350
|
+
execution_arn, operation_id, invocation_result
|
|
2351
|
+
)
|
|
2352
|
+
case KnownOutcome(outcome=outcome):
|
|
2353
|
+
await self._finish_from_outcome(
|
|
2354
|
+
execution_arn, operation_id, outcome
|
|
2355
|
+
)
|
|
2356
|
+
case _:
|
|
2357
|
+
assert_never(result)
|
|
2358
|
+
except Exception:
|
|
2359
|
+
if self._closing:
|
|
2360
|
+
# The pools refuse work after shutdown, and a target
|
|
2361
|
+
# may raise after it. Neither outcome is applied.
|
|
2362
|
+
return
|
|
2363
|
+
logger.exception(
|
|
2364
|
+
"[%s] Chained invoke dispatch failed for %s",
|
|
2365
|
+
execution_arn,
|
|
2366
|
+
operation_id,
|
|
2367
|
+
)
|
|
2368
|
+
# An unexpected dispatch failure has no Lambda API error
|
|
2369
|
+
# code, so the error carries a message only.
|
|
2370
|
+
error = ErrorObject(
|
|
2371
|
+
message="Chained invoke could not be dispatched.",
|
|
2372
|
+
type=None,
|
|
2373
|
+
data=None,
|
|
2374
|
+
stack_trace=None,
|
|
2375
|
+
)
|
|
2376
|
+
await self._finish_from_outcome(
|
|
2377
|
+
execution_arn, operation_id, ChildOutcome(error=error)
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
return dispatch
|
|
2381
|
+
|
|
2382
|
+
def preflight_chained_invoke(self, function_name: str) -> ErrorObject | None:
|
|
2383
|
+
"""Resolve a chained-invoke target at checkpoint time.
|
|
2384
|
+
|
|
2385
|
+
Returns the error that fails the operation in the checkpoint
|
|
2386
|
+
response, or ``None`` when the target can be dispatched. Runs on
|
|
2387
|
+
the parent's worker lane inside the checkpoint, so it must not
|
|
2388
|
+
block: the dispatcher answers from what it already holds.
|
|
2389
|
+
"""
|
|
2390
|
+
if self._child_dispatcher is None:
|
|
2391
|
+
return failed_to_start(
|
|
2392
|
+
"This runner has no chained-invoke dispatcher configured."
|
|
2393
|
+
).outcome.error
|
|
2394
|
+
try:
|
|
2395
|
+
target: FunctionTarget = parse_function_target(function_name)
|
|
2396
|
+
except ValueError:
|
|
2397
|
+
return failed_to_start(
|
|
2398
|
+
INVALID_FUNCTION_ARN_MESSAGE_FORMAT.format(function_name)
|
|
2399
|
+
).outcome.error
|
|
2400
|
+
outcome: ChildOutcome | None = self._child_dispatcher.preflight(target)
|
|
2401
|
+
return outcome.error if outcome is not None else None
|
|
2402
|
+
|
|
2403
|
+
def _resolve_dispatch(
|
|
2404
|
+
self,
|
|
2405
|
+
execution_arn: str,
|
|
2406
|
+
operation_id: str, # noqa: ARG002 — part of the request identity in logs
|
|
2407
|
+
function_name: str,
|
|
2408
|
+
tenant_id: str | None,
|
|
2409
|
+
payload: str | None,
|
|
2410
|
+
) -> DispatchResult:
|
|
2411
|
+
"""Resolve the dispatch result for a chained-invoke request."""
|
|
2412
|
+
if self._child_dispatcher is None:
|
|
2413
|
+
return failed_to_start(
|
|
2414
|
+
"This runner has no chained-invoke dispatcher configured."
|
|
2415
|
+
)
|
|
2416
|
+
parent: Execution = self._store.load(execution_arn)
|
|
2417
|
+
if parent.is_complete:
|
|
2418
|
+
# The parent reached a terminal state between the checkpoint
|
|
2419
|
+
# and this dispatch; its terminal state is authoritative.
|
|
2420
|
+
return KnownOutcome(outcome=ChildOutcome())
|
|
2421
|
+
# Checkpoint validation already rejected a malformed target; this
|
|
2422
|
+
# guards a dispatch that bypassed it.
|
|
2423
|
+
try:
|
|
2424
|
+
target: FunctionTarget = parse_function_target(function_name)
|
|
2425
|
+
except ValueError:
|
|
2426
|
+
return failed_to_start(
|
|
2427
|
+
INVALID_FUNCTION_ARN_MESSAGE_FORMAT.format(function_name)
|
|
2428
|
+
)
|
|
2429
|
+
request = ChainedInvokeRequest(
|
|
2430
|
+
parent_execution_arn=execution_arn,
|
|
2431
|
+
operation_id=operation_id,
|
|
2432
|
+
function_name=target.name,
|
|
2433
|
+
qualifier=target.qualifier,
|
|
2434
|
+
tenant_id=tenant_id,
|
|
2435
|
+
# An invoke without a payload delivers an empty JSON object
|
|
2436
|
+
# to the target, matching the Lambda Invoke API.
|
|
2437
|
+
payload=payload if payload is not None else "{}",
|
|
2438
|
+
account_id=parent.start_input.account_id,
|
|
2439
|
+
trace_fields=parent.start_input.trace_fields,
|
|
2440
|
+
)
|
|
2441
|
+
return self._child_dispatcher.dispatch(request)
|
|
2442
|
+
|
|
2443
|
+
async def _finish_from_outcome(
|
|
2444
|
+
self, execution_arn: str, operation_id: str, outcome: ChildOutcome
|
|
2445
|
+
) -> None:
|
|
2446
|
+
"""Complete the operation from a known outcome and re-invoke."""
|
|
2447
|
+
status: OperationStatus
|
|
2448
|
+
if outcome.timed_out:
|
|
2449
|
+
status = OperationStatus.TIMED_OUT
|
|
2450
|
+
elif outcome.error is None:
|
|
2451
|
+
status = OperationStatus.SUCCEEDED
|
|
2452
|
+
else:
|
|
2453
|
+
status = OperationStatus.FAILED
|
|
2454
|
+
await asyncio.wrap_future(
|
|
2455
|
+
self._registry.submit(
|
|
2456
|
+
execution_arn,
|
|
2457
|
+
CallableTask(
|
|
2458
|
+
lambda: self._apply_chained_invoke_completion(
|
|
2459
|
+
execution_arn,
|
|
2460
|
+
operation_id,
|
|
2461
|
+
status,
|
|
2462
|
+
outcome.result,
|
|
2463
|
+
outcome.error,
|
|
2464
|
+
)
|
|
2465
|
+
),
|
|
2466
|
+
)
|
|
2467
|
+
)
|
|
2468
|
+
self._invoke_execution(execution_arn)
|
|
2469
|
+
|
|
2470
|
+
def _start_child_execution(
|
|
2471
|
+
self,
|
|
2472
|
+
parent_arn: str,
|
|
2473
|
+
operation_id: str,
|
|
2474
|
+
child_start: StartDurableExecutionInput,
|
|
2475
|
+
) -> None:
|
|
2476
|
+
"""Create, link, and launch a child durable execution.
|
|
2477
|
+
|
|
2478
|
+
The linkage is registered and recorded on the parent before the
|
|
2479
|
+
child's first invocation is scheduled, so a child that
|
|
2480
|
+
completes immediately still finds its link. The child is pinned
|
|
2481
|
+
to its parent's endpoint before that first invocation, so it is
|
|
2482
|
+
invoked where the parent's chained targets go.
|
|
2483
|
+
"""
|
|
2484
|
+
# Shutdown can begin while this task runs. The check and the
|
|
2485
|
+
# count are one step under the condition, and shutdown waits for
|
|
2486
|
+
# the count to reach zero. So nothing is persisted or scheduled
|
|
2487
|
+
# after shutdown: a child created then would never be invoked.
|
|
2488
|
+
with self._closing_cond:
|
|
2489
|
+
if self._closing:
|
|
2490
|
+
return
|
|
2491
|
+
self._child_starts_in_flight += 1
|
|
2492
|
+
try:
|
|
2493
|
+
child: Execution = self._create_execution(
|
|
2494
|
+
child_start, parent_execution_arn=parent_arn
|
|
2495
|
+
)
|
|
2496
|
+
child_arn: str = child.durable_execution_arn
|
|
2497
|
+
self._invoker.inherit_endpoint(child_arn, parent_arn)
|
|
2498
|
+
with self._links_lock:
|
|
2499
|
+
self._chained_invoke_links[child_arn] = (parent_arn, operation_id)
|
|
2500
|
+
self._registry.submit(
|
|
2501
|
+
parent_arn,
|
|
2502
|
+
CallableTask(
|
|
2503
|
+
lambda: self._record_child_on_parent(
|
|
2504
|
+
parent_arn, operation_id, child_arn
|
|
2505
|
+
)
|
|
2506
|
+
),
|
|
2507
|
+
).result()
|
|
2508
|
+
self._launch_execution(child, child_start.execution_timeout_seconds)
|
|
2509
|
+
finally:
|
|
2510
|
+
with self._closing_cond:
|
|
2511
|
+
self._child_starts_in_flight -= 1
|
|
2512
|
+
self._closing_cond.notify_all()
|
|
2513
|
+
|
|
2514
|
+
def _record_child_on_parent(
|
|
2515
|
+
self, parent_arn: str, operation_id: str, child_arn: str
|
|
2516
|
+
) -> None:
|
|
2517
|
+
"""Record a child execution ARN on the parent's operation.
|
|
2518
|
+
|
|
2519
|
+
Runs on the parent's worker lane. No-op when the parent is
|
|
2520
|
+
already terminal.
|
|
2521
|
+
"""
|
|
2522
|
+
try:
|
|
2523
|
+
execution = self.get_execution(parent_arn)
|
|
2524
|
+
except ResourceNotFoundException:
|
|
2525
|
+
return
|
|
2526
|
+
if execution.is_complete:
|
|
2527
|
+
return
|
|
2528
|
+
execution.record_chained_invoke_child(operation_id, child_arn)
|
|
2529
|
+
self._store.update(execution)
|
|
2530
|
+
|
|
2531
|
+
# endregion Chained invoke
|
|
2532
|
+
|
|
1080
2533
|
# region Callback Timeouts
|
|
1081
2534
|
def _schedule_callback_timeouts(
|
|
1082
2535
|
self,
|
|
@@ -1178,23 +2631,33 @@ class Executor(ExecutionObserver):
|
|
|
1178
2631
|
if heartbeat_future := self._callback_heartbeats.pop(callback_id, None):
|
|
1179
2632
|
heartbeat_future.cancel()
|
|
1180
2633
|
|
|
2634
|
+
def _apply_callback_timeout(self, callback_id: str, error: ErrorObject) -> None:
|
|
2635
|
+
callback_token = CallbackToken.from_str(callback_id)
|
|
2636
|
+
execution = self.get_execution(callback_token.execution_arn)
|
|
2637
|
+
if execution.is_complete:
|
|
2638
|
+
return
|
|
2639
|
+
execution.complete_callback_timeout(callback_id, error, now=self._clock.now())
|
|
2640
|
+
self._store.update(execution)
|
|
2641
|
+
|
|
1181
2642
|
def _on_callback_timeout(self, execution_arn: str, callback_id: str) -> None:
|
|
1182
2643
|
"""Handle callback timeout."""
|
|
1183
2644
|
try:
|
|
1184
2645
|
callback_token = CallbackToken.from_str(callback_id)
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
timeout_error = ErrorObject.from_message(
|
|
1192
|
-
f"Callback timed out: {CallbackTimeoutType.TIMEOUT.value}"
|
|
2646
|
+
arn = callback_token.execution_arn
|
|
2647
|
+
timeout_error = ErrorObject(
|
|
2648
|
+
message="Callback timed out",
|
|
2649
|
+
type=CallbackTimeoutType.TIMEOUT.value,
|
|
2650
|
+
data=None,
|
|
2651
|
+
stack_trace=None,
|
|
1193
2652
|
)
|
|
1194
|
-
|
|
1195
|
-
|
|
2653
|
+
self._registry.submit(
|
|
2654
|
+
arn,
|
|
2655
|
+
CallableTask(
|
|
2656
|
+
lambda: self._apply_callback_timeout(callback_id, timeout_error)
|
|
2657
|
+
),
|
|
2658
|
+
).result()
|
|
1196
2659
|
logger.warning("[%s] Callback %s timed out", execution_arn, callback_id)
|
|
1197
|
-
self._invoke_execution(
|
|
2660
|
+
self._invoke_execution(arn)
|
|
1198
2661
|
except Exception:
|
|
1199
2662
|
logger.exception(
|
|
1200
2663
|
"[%s] Error processing callback timeout for %s",
|
|
@@ -1208,22 +2671,23 @@ class Executor(ExecutionObserver):
|
|
|
1208
2671
|
"""Handle callback heartbeat timeout."""
|
|
1209
2672
|
try:
|
|
1210
2673
|
callback_token = CallbackToken.from_str(callback_id)
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
heartbeat_error = ErrorObject.from_message(
|
|
1219
|
-
f"Callback heartbeat timed out: {CallbackTimeoutType.HEARTBEAT.value}"
|
|
2674
|
+
arn = callback_token.execution_arn
|
|
2675
|
+
heartbeat_error = ErrorObject(
|
|
2676
|
+
message="Callback timed out on heartbeat",
|
|
2677
|
+
type=CallbackTimeoutType.HEARTBEAT.value,
|
|
2678
|
+
data=None,
|
|
2679
|
+
stack_trace=None,
|
|
1220
2680
|
)
|
|
1221
|
-
|
|
1222
|
-
|
|
2681
|
+
self._registry.submit(
|
|
2682
|
+
arn,
|
|
2683
|
+
CallableTask(
|
|
2684
|
+
lambda: self._apply_callback_timeout(callback_id, heartbeat_error)
|
|
2685
|
+
),
|
|
2686
|
+
).result()
|
|
1223
2687
|
logger.warning(
|
|
1224
2688
|
"[%s] Callback %s heartbeat timed out", execution_arn, callback_id
|
|
1225
2689
|
)
|
|
1226
|
-
self._invoke_execution(
|
|
2690
|
+
self._invoke_execution(arn)
|
|
1227
2691
|
except Exception:
|
|
1228
2692
|
logger.exception(
|
|
1229
2693
|
"[%s] Error processing callback heartbeat timeout for %s",
|