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.
Files changed (49) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +1 -1
  2. aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
  15. aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
  16. aws_durable_execution_sdk_python_testing/cli.py +48 -1
  17. aws_durable_execution_sdk_python_testing/client.py +21 -10
  18. aws_durable_execution_sdk_python_testing/clock.py +69 -0
  19. aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
  20. aws_durable_execution_sdk_python_testing/execution.py +583 -49
  21. aws_durable_execution_sdk_python_testing/executor.py +1813 -349
  22. aws_durable_execution_sdk_python_testing/invoker.py +356 -85
  23. aws_durable_execution_sdk_python_testing/model.py +81 -73
  24. aws_durable_execution_sdk_python_testing/observer.py +100 -88
  25. aws_durable_execution_sdk_python_testing/runner.py +407 -30
  26. aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
  27. aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
  28. aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
  29. aws_durable_execution_sdk_python_testing/threads.py +157 -0
  30. aws_durable_execution_sdk_python_testing/token.py +11 -2
  31. aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
  32. aws_durable_execution_sdk_python_testing/web/routes.py +14 -6
  33. aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
  34. aws_durable_execution_sdk_python_testing/web/server.py +1 -1
  35. aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
  36. aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
  37. aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
  38. aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
  39. aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
  40. aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
  41. aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
  42. {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
  43. aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
  44. {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
  45. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
  46. aws_durable_execution_sdk_python_testing-1.2.0.dist-info/RECORD +0 -52
  47. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
  48. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
  49. {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 datetime import UTC, datetime
9
- from typing import TYPE_CHECKING
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 Execution
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 ExecutionObserver
58
- from aws_durable_execution_sdk_python_testing.token import CallbackToken
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.invoker import Invoker
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._execution_timeout: Future | None = None
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.start()
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[execution.durable_execution_arn] = completion_event
310
+ self._completion_events[arn] = completion_event
122
311
 
123
312
  # Schedule execution timeout
124
- if input.execution_timeout_seconds > 0:
313
+ if timeout_seconds > 0:
125
314
 
126
315
  def timeout_handler():
127
316
  error = ErrorObject.from_message(
128
- f"Execution timed out after {input.execution_timeout_seconds} seconds."
317
+ f"Execution timed out after {timeout_seconds} seconds."
129
318
  )
130
- self.on_timed_out(execution.durable_execution_arn, error)
319
+ self.on_timed_out(arn, error)
131
320
 
132
- self._execution_timeout = self._scheduler.call_later(
321
+ self._execution_timeouts[arn] = self._scheduler.call_later(
133
322
  timeout_handler,
134
- delay=input.execution_timeout_seconds,
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(execution.durable_execution_arn)
328
+ self._invoke_execution(arn)
140
329
 
141
- return StartDurableExecutionOutput(
142
- execution_arn=execution.durable_execution_arn
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 = f"Execution {execution_arn} not found"
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=f"arn:aws:lambda:us-east-1:123456789012:function:{execution.start_input.function_name}",
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 datetime.now(UTC),
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="1.0",
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(execution, execution.current_status().value)
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 datetime.now(UTC)
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.exception("[%s] Stopping execution.", execution_arn)
342
- execution.complete_stopped(error=stop_error) # Sets CloseStatus.TERMINATED
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=datetime.now(UTC))
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
- """Get execution state with operations.
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
- ResourceNotFoundException: If execution does not exist
368
- InvalidParameterValueException: If checkpoint token is invalid
574
+ InvalidParameterValueException: If the ARN is blank.
369
575
  """
370
- execution = self.get_execution(execution_arn)
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
- # TODO: Validate checkpoint token if provided
373
- if checkpoint_token and checkpoint_token not in execution.used_tokens:
374
- msg: str = f"Invalid checkpoint token: {checkpoint_token}"
375
- raise InvalidParameterValueException(msg)
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
- # Get operations (excluding the initial EXECUTION operation for state)
378
- operations = execution.get_assertable_operations()
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
- # Apply pagination
381
- if max_items is None:
382
- max_items = 100
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
- # Simple pagination - in real implementation would need proper marker handling
385
- start_index = 0
386
- if marker:
387
- try:
388
- start_index = int(marker)
389
- except ValueError:
390
- start_index = 0
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
- end_index = start_index + max_items
393
- paginated_operations = operations[start_index:end_index]
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
- next_marker = None
396
- if end_index < len(operations):
397
- next_marker = str(end_index)
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
- return GetDurableExecutionStateResponse(
400
- operations=paginated_operations, next_marker=next_marker
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 all events first (without final event IDs)
447
- for op in ops:
448
- operation_update: OperationUpdate | None = updates_dict.get(
449
- op.operation_id, None
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
- if op.status is OperationStatus.PENDING:
453
- if (
454
- op.operation_type is not OperationType.CHAINED_INVOKE
455
- or op.start_timestamp is None
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
- context: EventCreationContext = EventCreationContext(
459
- op,
460
- 0, # Temporary event_id, will be reassigned after sorting
461
- durable_execution_arn,
462
- execution.start_input,
463
- execution.result,
464
- operation_update,
465
- include_execution_data,
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
- pending = HistoryEvent.create_chained_invoke_event_pending(context)
468
- all_events.append(pending)
469
- if op.start_timestamp is not None:
470
- context = EventCreationContext(
471
- op,
472
- 0, # Temporary event_id, will be reassigned after sorting
473
- durable_execution_arn,
474
- execution.start_input,
475
- execution.result,
476
- operation_update,
477
- include_execution_data,
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
- started = HistoryEvent.create_event_started(context)
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
- op,
484
- 0, # Temporary event_id, will be reassigned after sorting
800
+ event_op,
801
+ 0,
485
802
  durable_execution_arn,
486
803
  execution.start_input,
487
804
  execution.result,
488
- operation_update,
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
- finished = HistoryEvent.create_event_terminated(context)
492
- all_events.append(finished)
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 for an execution.
962
+ """Process a checkpoint, serializing it on the execution's worker.
560
963
 
561
- Args:
562
- execution_arn: The execution ARN
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
- Returns:
568
- CheckpointDurableExecutionResponse: Updated checkpoint token and state
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 invalid
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
- # Validate checkpoint token
577
- if checkpoint_token not in execution.used_tokens:
578
- msg: str = f"Invalid checkpoint token: {checkpoint_token}"
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 updates:
582
- checkpoint_output = self._checkpoint_processor.process_checkpoint(
583
- checkpoint_token=checkpoint_token,
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
- return CheckpointDurableExecutionResponse(
596
- checkpoint_token=checkpoint_output.checkpoint_token,
597
- new_execution_state=new_execution_state,
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=new_checkpoint_token,
606
- new_execution_state=None,
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
- execution = self.get_execution(callback_token.execution_arn)
634
- execution.complete_callback_success(callback_id, result)
635
- self._store.update(execution)
636
- self._cleanup_callback_timeouts(callback_id)
637
- self._invoke_execution(callback_token.execution_arn)
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
- callback_error: ErrorObject = error or ErrorObject.from_message("")
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
- execution: Execution = self.get_execution(callback_token.execution_arn)
672
- execution.complete_callback_failure(callback_id, callback_error)
673
- self._store.update(execution)
674
- self._cleanup_callback_timeouts(callback_id)
675
- self._invoke_execution(callback_token.execution_arn)
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
- execution: Execution = self.get_execution(callback_token.execution_arn)
705
-
706
- # Find callback operation to verify it exists and is active
707
- _, operation = execution.find_callback_operation(callback_id)
708
- if operation.status != OperationStatus.STARTED:
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._complete_workflow(
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
- if not execution.has_pending_operations(execution):
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 _invoke_handler(self, execution_arn: str) -> Callable[[], Awaitable[None]]:
783
- """Create a parameterless callable that captures execution arn for the scheduler."""
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
- async def invoke() -> None:
786
- execution: Execution = self._store.load(execution_arn)
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
- # Early exit if execution is already completed - like Java's COMPLETED check
789
- if execution.is_complete:
790
- logger.info(
791
- "[%s] Execution already completed, ignoring result", execution_arn
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
- try:
796
- invocation_input: DurableExecutionInvocationInput = (
797
- self._invoker.create_invocation_input(execution=execution)
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
- self._store.save(execution)
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
- invocation_start = datetime.now(UTC)
803
- invoke_response = self._invoker.invoke(
804
- execution.start_input.function_name,
805
- invocation_input,
806
- execution.start_input.lambda_endpoint,
807
- )
808
- invocation_end = datetime.now(UTC)
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
- # Reload execution after invocation in case it was completed via checkpoint
811
- execution = self._store.load(execution_arn)
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
- # Record invocation completion and save immediately
814
- execution.record_invocation_completion(
815
- invocation_start, invocation_end, invoke_response.request_id
816
- )
817
- self._store.save(execution)
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
- if execution.is_complete:
820
- logger.info(
821
- "[%s] Execution completed during invocation, ignoring result",
822
- execution_arn,
823
- )
824
- return
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
- # Process successful received response - validate status and handle accordingly
827
- response = invoke_response.invocation_output
828
- try:
829
- self._validate_invocation_response_and_store(
830
- execution_arn, response, execution
831
- )
832
- except (InvalidParameterValueException, IllegalStateException) as e:
833
- logger.warning(
834
- "[%s] Lambda output validation failure: %s", execution_arn, e
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
- except ResourceNotFoundException:
840
- logger.warning(
841
- "[%s] Function No longer exists: %s",
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
- execution.start_input.function_name,
1825
+ self._invocation_timeout_seconds,
844
1826
  )
845
1827
  error_obj = ErrorObject.from_message(
846
- message=f"Function not found: {execution.start_input.function_name}"
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
- self._retry_invocation(execution, error_obj)
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(self, execution: Execution, error: ErrorObject):
895
- """Handle retry logic or fail execution if retries exhausted."""
896
- if (
897
- execution.consecutive_failed_invocation_attempts
898
- > self.MAX_CONSECUTIVE_FAILED_ATTEMPTS
899
- ):
900
- # Exhausted retries - fail the execution
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
- else:
905
- # Schedule retry with backoff
906
- execution.consecutive_failed_invocation_attempts += 1
907
- self._store.save(execution)
908
- self._invoke_execution(
909
- execution_arn=execution.durable_execution_arn,
910
- delay=self.RETRY_BACKOFF_SECONDS,
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._execution_timeout:
918
- self._execution_timeout.cancel()
919
- self._execution_timeout = None
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) # Sets CloseStatus.COMPLETED
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) # Sets CloseStatus.FAILED
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). Observer method triggered by notifier."""
1013
- logger.exception("[%s] Execution timed out.", execution_arn)
1014
- execution: Execution = self._store.load(execution_arn=execution_arn)
1015
- execution.complete_timeout(error=error) # Sets CloseStatus.TIMED_OUT
1016
- self._store.update(execution)
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. Observer method triggered by notifier."""
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
- execution = self.get_execution(callback_token.execution_arn)
1186
-
1187
- if execution.is_complete:
1188
- return
1189
-
1190
- # Fail the callback with timeout error
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
- execution.complete_callback_timeout(callback_id, timeout_error)
1195
- self._store.update(execution)
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(callback_token.execution_arn)
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
- execution = self.get_execution(callback_token.execution_arn)
1212
-
1213
- if execution.is_complete:
1214
- return
1215
-
1216
- # Fail the callback with heartbeat timeout error
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
- execution.complete_callback_timeout(callback_id, heartbeat_error)
1222
- self._store.update(execution)
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(callback_token.execution_arn)
2690
+ self._invoke_execution(arn)
1227
2691
  except Exception:
1228
2692
  logger.exception(
1229
2693
  "[%s] Error processing callback heartbeat timeout for %s",