aws-durable-execution-sdk-python-testing 1.2.1__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 (48) 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/serialization.py +1 -2
  33. aws_durable_execution_sdk_python_testing/web/server.py +1 -1
  34. aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
  35. aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
  36. aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
  37. aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
  38. aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
  39. aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
  40. aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
  41. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
  42. aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
  43. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
  44. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
  45. aws_durable_execution_sdk_python_testing-1.2.1.dist-info/RECORD +0 -52
  46. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
  47. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
  48. {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import replace
4
- from datetime import UTC, datetime
3
+ import json
4
+ import logging
5
+ from dataclasses import dataclass, replace
6
+ from datetime import datetime
5
7
  from enum import Enum
6
8
  from threading import Lock
7
9
  from typing import Any
@@ -12,6 +14,7 @@ from aws_durable_execution_sdk_python.execution import (
12
14
  InvocationStatus,
13
15
  )
14
16
  from aws_durable_execution_sdk_python.lambda_service import (
17
+ ChainedInvokeDetails,
15
18
  ErrorObject,
16
19
  ExecutionDetails,
17
20
  Operation,
@@ -20,6 +23,7 @@ from aws_durable_execution_sdk_python.lambda_service import (
20
23
  OperationUpdate,
21
24
  )
22
25
 
26
+ from aws_durable_execution_sdk_python_testing.clock import real_now
23
27
  from aws_durable_execution_sdk_python_testing.exceptions import (
24
28
  IllegalStateException,
25
29
  InvalidParameterValueException,
@@ -27,6 +31,7 @@ from aws_durable_execution_sdk_python_testing.exceptions import (
27
31
 
28
32
  # Import AWS exceptions
29
33
  from aws_durable_execution_sdk_python_testing.model import (
34
+ executed_version,
30
35
  InvocationCompletedDetails,
31
36
  StartDurableExecutionInput,
32
37
  )
@@ -34,6 +39,18 @@ from aws_durable_execution_sdk_python_testing.token import (
34
39
  CheckpointToken,
35
40
  )
36
41
 
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ _CHAINED_INVOKE_TERMINAL_STATUSES: frozenset[OperationStatus] = frozenset(
46
+ {
47
+ OperationStatus.SUCCEEDED,
48
+ OperationStatus.FAILED,
49
+ OperationStatus.TIMED_OUT,
50
+ OperationStatus.STOPPED,
51
+ }
52
+ )
53
+
37
54
 
38
55
  class ExecutionStatus(Enum):
39
56
  """Execution status for API responses."""
@@ -45,6 +62,60 @@ class ExecutionStatus(Enum):
45
62
  TIMED_OUT = "TIMED_OUT"
46
63
 
47
64
 
65
+ class PauseState(Enum):
66
+ """Whether a test paused this execution, and whether resume must invoke."""
67
+
68
+ NOT_PAUSED = "NOT_PAUSED"
69
+ # Paused while idle: no handler was running and no invocation was due.
70
+ # Resume lifts the pause and the next trigger invokes as usual.
71
+ PAUSED = "PAUSED"
72
+ # Paused while progress was owed. Either the running handler checkpointed
73
+ # and was answered without a token, so it stopped as PENDING before it
74
+ # finished, or a scheduled invocation came due and was held back.
75
+ # Resume must start one new invocation to make up for it.
76
+ PAUSED_INVOCATION_DEFERRED = "PAUSED_INVOCATION_DEFERRED"
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class CheckpointIdempotencyRecord:
81
+ """Single-slot cache of the most recent accepted checkpoint response
82
+ with a checkpoint token.
83
+
84
+ A matching ``(client_token, inbound_checkpoint_token)`` pair replays
85
+ this response without applying updates again. Pause-interrupted
86
+ checkpoints leave this record unchanged so their retries are rejected.
87
+ """
88
+
89
+ client_token: str
90
+ inbound_checkpoint_token: str
91
+ outbound_checkpoint_token: str | None
92
+ operations: list[Operation]
93
+ next_marker: str | None
94
+
95
+ def to_json_dict(self) -> dict[str, Any]:
96
+ """Serialize to JSON-compatible dict for persistence."""
97
+ return {
98
+ "ClientToken": self.client_token,
99
+ "InboundCheckpointToken": self.inbound_checkpoint_token,
100
+ "OutboundCheckpointToken": self.outbound_checkpoint_token,
101
+ "Operations": [op.to_json_dict() for op in self.operations],
102
+ "NextMarker": self.next_marker,
103
+ }
104
+
105
+ @classmethod
106
+ def from_json_dict(cls, data: dict[str, Any]) -> CheckpointIdempotencyRecord:
107
+ """Reconstruct from a to_json_dict output."""
108
+ return cls(
109
+ client_token=data["ClientToken"],
110
+ inbound_checkpoint_token=data["InboundCheckpointToken"],
111
+ outbound_checkpoint_token=data.get("OutboundCheckpointToken"),
112
+ operations=[
113
+ Operation.from_json_dict(op_data) for op_data in data["Operations"]
114
+ ],
115
+ next_marker=data.get("NextMarker"),
116
+ )
117
+
118
+
48
119
  class Execution:
49
120
  """Execution state."""
50
121
 
@@ -59,20 +130,111 @@ class Execution:
59
130
  self.start_input: StartDurableExecutionInput = start_input
60
131
  self.operations: list[Operation] = operations
61
132
  self.updates: list[OperationUpdate] = []
133
+ self.update_timestamps: list[datetime] = []
62
134
  self.invocation_completions: list[InvocationCompletedDetails] = []
63
- self.used_tokens: set[str] = set()
64
- # TODO: this will need to persist/rehydrate depending on inmemory vs sqllite store
65
- self._token_sequence: int = 0
135
+ self.updated_operation_ids: list[str] = []
136
+ # Two monotonic counters. `token_sequence` is the checkpoint-response
137
+ # version carried on the wire; `seq_counter` is the internal
138
+ # event counter bumped on every state-affecting mutation.
139
+ self.token_sequence: int = 0
140
+ self.seq_counter: int = 0
141
+ # Watermark of the highest `seq_counter` the handler has been told
142
+ # about (via InitialExecutionState or a successful checkpoint
143
+ # response). Filters the "unseen" delta on the next checkpoint.
144
+ self.handler_seen_seq: int = 0
145
+ # Per-op "last touched at `seq_counter`" bookkeeping. Sparse:
146
+ # ops not in the dict are treated as touch-seq 0.
147
+ self.operation_last_touched_seq: dict[str, int] = {}
148
+ # Per-op payload size, tracked as a sidecar dict because
149
+ # ``Operation`` is frozen upstream.
150
+ self.operation_size_bytes: dict[str, int] = {}
151
+ # Chained-invoke linkage: operation_id -> the invoked child
152
+ # execution's ARN. Populated when a durable child execution is
153
+ # started for a CHAINED_INVOKE operation; absent for targets
154
+ # that run as a single invocation without their own execution.
155
+ self.chained_invoke_children: dict[str, str] = {}
156
+ # The parent execution's ARN when this execution was started by
157
+ # a chained invoke. A child's output is capped at the chained
158
+ # invoke limit, so validation needs to know.
159
+ self.parent_execution_arn: str | None = None
160
+ # The runner's region at creation; None when unknown (for
161
+ # example an execution built directly in a test).
162
+ self.region: str | None = None
163
+ # Set when a trigger arrived while an invocation was already
164
+ # in flight; the gate-release path consults it to decide whether
165
+ # to schedule another invocation.
166
+ self.needs_reinvoke: bool = False
167
+ # Single-slot cache for idempotent checkpoint replays.
168
+ self.last_checkpoint: CheckpointIdempotencyRecord | None = None
169
+ # Identity of the handler invocation currently allowed to
170
+ # checkpoint. Regenerated each time a new invocation is
171
+ # dispatched, so a checkpoint carrying a superseded invocation's
172
+ # token is rejected. Held in memory semantics: it defaults empty
173
+ # for executions that never went through an invocation.
174
+ self.current_invocation_id: str = ""
66
175
  self._state_lock: Lock = Lock()
67
176
  self.is_complete: bool = False
68
177
  self.result: DurableExecutionInvocationOutput | None = None
69
178
  self.consecutive_failed_invocation_attempts: int = 0
70
179
  self.close_status: ExecutionStatus | None = None
180
+ self._pause_state: PauseState = PauseState.NOT_PAUSED
181
+
182
+ @property
183
+ def pause_state(self) -> PauseState:
184
+ return self._pause_state
185
+
186
+ @property
187
+ def is_paused(self) -> bool:
188
+ return self._pause_state is not PauseState.NOT_PAUSED
71
189
 
72
190
  @property
73
- def token_sequence(self) -> int:
74
- """Get current token sequence value."""
75
- return self._token_sequence
191
+ def has_deferred_invocation(self) -> bool:
192
+ return self._pause_state is PauseState.PAUSED_INVOCATION_DEFERRED
193
+
194
+ def pause(self) -> None:
195
+ if self._pause_state is PauseState.NOT_PAUSED:
196
+ self._pause_state = PauseState.PAUSED
197
+
198
+ def defer_invocation(self) -> None:
199
+ if self._pause_state is PauseState.PAUSED:
200
+ self._pause_state = PauseState.PAUSED_INVOCATION_DEFERRED
201
+
202
+ def resume(self) -> bool:
203
+ """Clear the pause and return whether the caller must schedule an invocation."""
204
+ deferred: bool = self._pause_state is PauseState.PAUSED_INVOCATION_DEFERRED
205
+ self._pause_state = PauseState.NOT_PAUSED
206
+ return deferred
207
+
208
+ def touch_operation(self, operation_id: str) -> None:
209
+ """Record a state-affecting event on an operation.
210
+
211
+ Bumps ``seq_counter`` by 1 and records the new value as the
212
+ operation's "last touched" sequence. Called from the checkpoint
213
+ dispatcher (once per accepted update) and from every async
214
+ completion path (``complete_wait``, ``complete_retry``,
215
+ ``complete_callback_*``, terminal transitions).
216
+ """
217
+ self.seq_counter += 1
218
+ self.operation_last_touched_seq[operation_id] = self.seq_counter
219
+
220
+ def advance_token_sequence(self) -> int:
221
+ """Bump the checkpoint-response version counter.
222
+
223
+ Called exactly once per accepted non-idempotent checkpoint call.
224
+ Idempotent replays and async completions do not call this.
225
+ Returns the new value.
226
+ """
227
+ self.token_sequence += 1
228
+ return self.token_sequence
229
+
230
+ def begin_new_invocation(self) -> str:
231
+ """Assign a fresh identity to the next handler invocation and
232
+ return it. A checkpoint token minted for a prior invocation no
233
+ longer matches once this is called, so a superseded invocation's
234
+ checkpoints are rejected.
235
+ """
236
+ self.current_invocation_id = str(uuid4())
237
+ return self.current_invocation_id
76
238
 
77
239
  def current_status(self) -> ExecutionStatus:
78
240
  """Get execution status."""
@@ -105,15 +267,29 @@ class Execution:
105
267
  "StartInput": self.start_input.to_dict(),
106
268
  "Operations": [op.to_json_dict() for op in self.operations],
107
269
  "Updates": [update.to_dict() for update in self.updates],
270
+ "UpdateTimestamps": [ts.isoformat() for ts in self.update_timestamps],
108
271
  "InvocationCompletions": [
109
272
  completion.to_json_dict() for completion in self.invocation_completions
110
273
  ],
111
- "UsedTokens": list(self.used_tokens),
112
- "TokenSequence": self._token_sequence,
274
+ "UpdatedOperationIds": self.updated_operation_ids,
275
+ "TokenSequence": self.token_sequence,
276
+ "SeqCounter": self.seq_counter,
277
+ "HandlerSeenSeq": self.handler_seen_seq,
278
+ "OperationLastTouchedSeq": dict(self.operation_last_touched_seq),
279
+ "OperationSizeBytes": dict(self.operation_size_bytes),
280
+ "ChainedInvokeChildren": dict(self.chained_invoke_children),
281
+ "ParentExecutionArn": self.parent_execution_arn,
282
+ "Region": self.region,
283
+ "NeedsReinvoke": self.needs_reinvoke,
284
+ "LastCheckpoint": (
285
+ self.last_checkpoint.to_json_dict() if self.last_checkpoint else None
286
+ ),
113
287
  "IsComplete": self.is_complete,
114
288
  "Result": self.result.to_dict() if self.result else None,
115
289
  "ConsecutiveFailedInvocationAttempts": self.consecutive_failed_invocation_attempts,
116
290
  "CloseStatus": self.close_status.value if self.close_status else None,
291
+ "CurrentInvocationId": self.current_invocation_id,
292
+ "PauseState": self._pause_state.value,
117
293
  }
118
294
 
119
295
  @classmethod
@@ -138,12 +314,37 @@ class Execution:
138
314
  execution.updates = [
139
315
  OperationUpdate.from_dict(update_data) for update_data in data["Updates"]
140
316
  ]
317
+ execution.update_timestamps = [
318
+ datetime.fromisoformat(ts) for ts in data.get("UpdateTimestamps", [])
319
+ ]
141
320
  execution.invocation_completions = [
142
321
  InvocationCompletedDetails.from_json_dict(item)
143
322
  for item in data.get("InvocationCompletions", [])
144
323
  ]
145
- execution.used_tokens = set(data["UsedTokens"])
146
- execution._token_sequence = data["TokenSequence"] # noqa: SLF001
324
+ execution.updated_operation_ids = list(data.get("UpdatedOperationIds", []))
325
+ # NOTE: prior format included a "UsedTokens" set. Current
326
+ # removed it (the Executor no longer consults it — token
327
+ # validity is via _is_current_token against token_sequence).
328
+ # Old-format dicts are loaded by ignoring "UsedTokens".
329
+ execution.token_sequence = data["TokenSequence"]
330
+ # Safe defaults for fields added after the original schema.
331
+ execution.seq_counter = data.get("SeqCounter", 0)
332
+ execution.handler_seen_seq = data.get("HandlerSeenSeq", 0)
333
+ execution.operation_last_touched_seq = dict(
334
+ data.get("OperationLastTouchedSeq", {})
335
+ )
336
+ execution.operation_size_bytes = dict(data.get("OperationSizeBytes", {}))
337
+ execution.chained_invoke_children = dict(data.get("ChainedInvokeChildren", {}))
338
+ execution.parent_execution_arn = data.get("ParentExecutionArn")
339
+ execution.region = data.get("Region")
340
+ execution.needs_reinvoke = data.get("NeedsReinvoke", False)
341
+ execution.current_invocation_id = data.get("CurrentInvocationId", "")
342
+ last_checkpoint_data = data.get("LastCheckpoint")
343
+ execution.last_checkpoint = (
344
+ CheckpointIdempotencyRecord.from_json_dict(last_checkpoint_data)
345
+ if last_checkpoint_data
346
+ else None
347
+ )
147
348
  execution.is_complete = data["IsComplete"]
148
349
  execution.result = (
149
350
  DurableExecutionInvocationOutput.from_dict(data["Result"])
@@ -157,10 +358,13 @@ class Execution:
157
358
  execution.close_status = (
158
359
  ExecutionStatus(close_status_str) if close_status_str else None
159
360
  )
361
+ execution._pause_state = PauseState(
362
+ data.get("PauseState", PauseState.NOT_PAUSED.value)
363
+ )
160
364
 
161
365
  return execution
162
366
 
163
- def start(self) -> None:
367
+ def start(self, now: datetime | None = None) -> None:
164
368
  if self.start_input.invocation_id is None:
165
369
  msg: str = "invocation_id is required"
166
370
  raise InvalidParameterValueException(msg)
@@ -170,7 +374,7 @@ class Execution:
170
374
  operation_id=self.start_input.invocation_id,
171
375
  parent_id=None,
172
376
  name=self.start_input.execution_name,
173
- start_timestamp=datetime.now(UTC),
377
+ start_timestamp=now if now is not None else real_now(),
174
378
  operation_type=OperationType.EXECUTION,
175
379
  status=OperationStatus.STARTED,
176
380
  execution_details=ExecutionDetails(
@@ -188,26 +392,49 @@ class Execution:
188
392
  return self.operations[0]
189
393
 
190
394
  def get_new_checkpoint_token(self) -> str:
191
- """Generate a new checkpoint token with incremented sequence"""
395
+ """Serialise the current ``token_sequence`` as a checkpoint token.
396
+
397
+ Does NOT bump ``token_sequence``. Only
398
+ ``Executor.checkpoint_execution`` (via
399
+ :meth:`advance_token_sequence`) is allowed to advance the
400
+ counter. This method retains its name for backward
401
+ compatibility but is now semantically
402
+ "get_current_checkpoint_token".
403
+ """
192
404
  with self._state_lock:
193
- self._token_sequence += 1
194
- new_token_sequence = self._token_sequence
195
405
  token = CheckpointToken(
196
406
  execution_arn=self.durable_execution_arn,
197
- token_sequence=new_token_sequence,
407
+ token_sequence=self.token_sequence,
408
+ invocation_id=self.current_invocation_id,
198
409
  )
199
- token_str = token.to_str()
200
- self.used_tokens.add(token_str)
201
- return token_str
410
+ return token.to_str()
202
411
 
203
412
  def get_navigable_operations(self) -> list[Operation]:
204
- """Get list of operations, but exclude child operations where the parent has already completed."""
205
- return self.operations
413
+ """Return a snapshot copy of the operation list.
414
+
415
+ The copy is taken under ``_state_lock`` so callers iterating the
416
+ result are not exposed to concurrent operation mutations. Operations
417
+ are frozen dataclasses, so a shallow copy is a stable snapshot.
418
+ """
419
+ with self._state_lock:
420
+ return list(self.operations)
206
421
 
207
422
  def get_assertable_operations(self) -> list[Operation]:
208
- """Get list of operations, but exclude the EXECUTION operations"""
209
- # TODO: this excludes EXECUTION at start, but can there be an EXECUTION at the end if there was a checkpoint with large payload?
210
- return self.operations[1:]
423
+ """Return a snapshot of operations, excluding EXECUTION operations.
424
+
425
+ Filters by ``operation_type`` rather than assuming the EXECUTION
426
+ operation is always the first entry, so a second EXECUTION-type
427
+ entry (e.g. a future large-payload checkpoint recorded at the end
428
+ of the list) is excluded too. Snapshot taken under ``_state_lock``,
429
+ matching ``get_navigable_operations``, so a concurrent checkpoint
430
+ can't produce a torn read.
431
+ """
432
+ with self._state_lock:
433
+ return [
434
+ operation
435
+ for operation in self.operations
436
+ if operation.operation_type != OperationType.EXECUTION
437
+ ]
211
438
 
212
439
  def has_pending_operations(self, execution: Execution) -> bool:
213
440
  """True if execution has pending operations."""
@@ -228,6 +455,17 @@ class Execution:
228
455
  return True
229
456
  return False
230
457
 
458
+ def has_unseen_changes(self) -> bool:
459
+ """True if any operation changed after the handler's last
460
+ observed sequence watermark."""
461
+ return self.has_changes_after(self.handler_seen_seq)
462
+
463
+ def has_changes_after(self, seq: int) -> bool:
464
+ """True if any operation changed after ``seq_counter`` was ``seq``."""
465
+ return any(
466
+ touched > seq for touched in self.operation_last_touched_seq.values()
467
+ )
468
+
231
469
  def record_invocation_completion(
232
470
  self, start_timestamp: datetime, end_timestamp: datetime, request_id: str
233
471
  ) -> None:
@@ -240,41 +478,62 @@ class Execution:
240
478
  )
241
479
  )
242
480
 
243
- def complete_success(self, result: str | None) -> None:
481
+ def mark_state_delivered(self) -> None:
482
+ """Reset the list of changed operations once an invocation's input
483
+ carries them.
484
+
485
+ The service reports, on each invocation, the operations that
486
+ changed after the last state the handler observed. The runner
487
+ delivers state in the invocation input. So the list is reset when
488
+ that input is built, not when the invocation completes: an
489
+ operation that completes while the handler is still running is
490
+ reported on the next invocation.
491
+ """
492
+ self.updated_operation_ids = []
493
+
494
+ def _record_updated_operation(self, operation_id: str) -> None:
495
+ """Remember an operation that changed since the handler last saw
496
+ the state."""
497
+ if operation_id not in self.updated_operation_ids:
498
+ self.updated_operation_ids.append(operation_id)
499
+
500
+ def complete_success(self, result: str | None, now: datetime | None = None) -> None:
244
501
  """Complete execution successfully (DecisionType.COMPLETE_WORKFLOW_EXECUTION)."""
245
502
  self.result = DurableExecutionInvocationOutput(
246
503
  status=InvocationStatus.SUCCEEDED, result=result
247
504
  )
248
505
  self.is_complete = True
249
506
  self.close_status = ExecutionStatus.SUCCEEDED
250
- self._end_execution(OperationStatus.SUCCEEDED)
507
+ self._end_execution(OperationStatus.SUCCEEDED, now)
251
508
 
252
- def complete_fail(self, error: ErrorObject) -> None:
509
+ def complete_fail(
510
+ self, error: ErrorObject | None, now: datetime | None = None
511
+ ) -> None:
253
512
  """Complete execution with failure (DecisionType.FAIL_WORKFLOW_EXECUTION)."""
254
513
  self.result = DurableExecutionInvocationOutput(
255
514
  status=InvocationStatus.FAILED, error=error
256
515
  )
257
516
  self.is_complete = True
258
517
  self.close_status = ExecutionStatus.FAILED
259
- self._end_execution(OperationStatus.FAILED)
518
+ self._end_execution(OperationStatus.FAILED, now)
260
519
 
261
- def complete_timeout(self, error: ErrorObject) -> None:
520
+ def complete_timeout(self, error: ErrorObject, now: datetime | None = None) -> None:
262
521
  """Complete execution with timeout."""
263
522
  self.result = DurableExecutionInvocationOutput(
264
523
  status=InvocationStatus.FAILED, error=error
265
524
  )
266
525
  self.is_complete = True
267
526
  self.close_status = ExecutionStatus.TIMED_OUT
268
- self._end_execution(OperationStatus.TIMED_OUT)
527
+ self._end_execution(OperationStatus.TIMED_OUT, now)
269
528
 
270
- def complete_stopped(self, error: ErrorObject) -> None:
529
+ def complete_stopped(self, error: ErrorObject, now: datetime | None = None) -> None:
271
530
  """Complete execution as terminated (TerminateWorkflowExecutionV2Request)."""
272
531
  self.result = DurableExecutionInvocationOutput(
273
532
  status=InvocationStatus.FAILED, error=error
274
533
  )
275
534
  self.is_complete = True
276
535
  self.close_status = ExecutionStatus.STOPPED
277
- self._end_execution(OperationStatus.STOPPED)
536
+ self._end_execution(OperationStatus.STOPPED, now)
278
537
 
279
538
  def find_operation(self, operation_id: str) -> tuple[int, Operation]:
280
539
  """Find operation by ID, return index and operation."""
@@ -296,7 +555,9 @@ class Execution:
296
555
  msg: str = f"Callback operation with callback_id [{callback_id}] not found"
297
556
  raise IllegalStateException(msg)
298
557
 
299
- def complete_wait(self, operation_id: str) -> Operation:
558
+ def complete_wait(
559
+ self, operation_id: str, now: datetime | None = None
560
+ ) -> Operation:
300
561
  """Complete WAIT operation when timer fires."""
301
562
  index, operation = self.find_operation(operation_id)
302
563
 
@@ -312,13 +573,14 @@ class Execution:
312
573
 
313
574
  # Thread-safe increment sequence and operation update
314
575
  with self._state_lock:
315
- self._token_sequence += 1
576
+ self.touch_operation(operation_id)
316
577
  # Build and assign updated operation
317
578
  self.operations[index] = replace(
318
579
  operation,
319
580
  status=OperationStatus.SUCCEEDED,
320
- end_timestamp=datetime.now(UTC),
581
+ end_timestamp=now if now is not None else real_now(),
321
582
  )
583
+ self._record_updated_operation(operation_id)
322
584
  return self.operations[index]
323
585
 
324
586
  def complete_retry(self, operation_id: str) -> Operation:
@@ -337,7 +599,7 @@ class Execution:
337
599
 
338
600
  # Thread-safe increment sequence and operation update
339
601
  with self._state_lock:
340
- self._token_sequence += 1
602
+ self.touch_operation(operation_id)
341
603
  # Build updated step_details with cleared next_attempt_timestamp
342
604
  new_step_details = None
343
605
  if operation.step_details:
@@ -352,10 +614,62 @@ class Execution:
352
614
 
353
615
  # Assign
354
616
  self.operations[index] = updated_operation
617
+ self._record_updated_operation(operation_id)
355
618
  return updated_operation
356
619
 
620
+ def complete_due_operations(self, now: datetime) -> bool:
621
+ """Complete every operation whose scheduled moment has passed.
622
+
623
+ Transitions due WAIT operations (``STARTED`` with a
624
+ ``scheduled_end_timestamp`` at or before ``now``) via
625
+ :meth:`complete_wait` and due STEP retries (``PENDING`` with a
626
+ ``next_attempt_timestamp`` at or before ``now``) via
627
+ :meth:`complete_retry`. A transition failure is logged and
628
+ skipped so one bad operation cannot block the rest.
629
+
630
+ Returns True when at least one operation completed.
631
+ """
632
+ completed_any: bool = False
633
+ for op in list(self.operations):
634
+ if (
635
+ op.operation_type is OperationType.WAIT
636
+ and op.status is OperationStatus.STARTED
637
+ and op.wait_details is not None
638
+ and op.wait_details.scheduled_end_timestamp is not None
639
+ and op.wait_details.scheduled_end_timestamp <= now
640
+ ):
641
+ try:
642
+ self.complete_wait(op.operation_id, now=now)
643
+ completed_any = True
644
+ except Exception: # noqa: BLE001 — skip one bad op, keep the rest
645
+ logger.exception(
646
+ "[%s] complete_wait failed for due operation %s",
647
+ self.durable_execution_arn,
648
+ op.operation_id,
649
+ )
650
+ elif (
651
+ op.operation_type is OperationType.STEP
652
+ and op.status is OperationStatus.PENDING
653
+ and op.step_details is not None
654
+ and op.step_details.next_attempt_timestamp is not None
655
+ and op.step_details.next_attempt_timestamp <= now
656
+ ):
657
+ try:
658
+ self.complete_retry(op.operation_id)
659
+ completed_any = True
660
+ except Exception: # noqa: BLE001 — skip one bad op, keep the rest
661
+ logger.exception(
662
+ "[%s] complete_retry failed for due operation %s",
663
+ self.durable_execution_arn,
664
+ op.operation_id,
665
+ )
666
+ return completed_any
667
+
357
668
  def complete_callback_success(
358
- self, callback_id: str, result: bytes | None = None
669
+ self,
670
+ callback_id: str,
671
+ result: bytes | None = None,
672
+ now: datetime | None = None,
359
673
  ) -> Operation:
360
674
  """Complete CALLBACK operation with success."""
361
675
  index, operation = self.find_callback_operation(callback_id)
@@ -364,7 +678,7 @@ class Execution:
364
678
  raise IllegalStateException(msg)
365
679
 
366
680
  with self._state_lock:
367
- self._token_sequence += 1
681
+ self.touch_operation(operation.operation_id)
368
682
  updated_callback_details = None
369
683
  if operation.callback_details:
370
684
  updated_callback_details = replace(
@@ -375,13 +689,13 @@ class Execution:
375
689
  self.operations[index] = replace(
376
690
  operation,
377
691
  status=OperationStatus.SUCCEEDED,
378
- end_timestamp=datetime.now(UTC),
692
+ end_timestamp=now if now is not None else real_now(),
379
693
  callback_details=updated_callback_details,
380
694
  )
381
695
  return self.operations[index]
382
696
 
383
697
  def complete_callback_failure(
384
- self, callback_id: str, error: ErrorObject
698
+ self, callback_id: str, error: ErrorObject, now: datetime | None = None
385
699
  ) -> Operation:
386
700
  """Complete CALLBACK operation with failure."""
387
701
  index, operation = self.find_callback_operation(callback_id)
@@ -391,7 +705,7 @@ class Execution:
391
705
  raise IllegalStateException(msg)
392
706
 
393
707
  with self._state_lock:
394
- self._token_sequence += 1
708
+ self.touch_operation(operation.operation_id)
395
709
  updated_callback_details = None
396
710
  if operation.callback_details:
397
711
  updated_callback_details = replace(
@@ -401,13 +715,13 @@ class Execution:
401
715
  self.operations[index] = replace(
402
716
  operation,
403
717
  status=OperationStatus.FAILED,
404
- end_timestamp=datetime.now(UTC),
718
+ end_timestamp=now if now is not None else real_now(),
405
719
  callback_details=updated_callback_details,
406
720
  )
407
721
  return self.operations[index]
408
722
 
409
723
  def complete_callback_timeout(
410
- self, callback_id: str, error: ErrorObject
724
+ self, callback_id: str, error: ErrorObject, now: datetime | None = None
411
725
  ) -> Operation:
412
726
  """Complete CALLBACK operation with timeout."""
413
727
  index, operation = self.find_callback_operation(callback_id)
@@ -417,7 +731,7 @@ class Execution:
417
731
  raise IllegalStateException(msg)
418
732
 
419
733
  with self._state_lock:
420
- self._token_sequence += 1
734
+ self.touch_operation(operation.operation_id)
421
735
  updated_callback_details = None
422
736
  if operation.callback_details:
423
737
  updated_callback_details = replace(
@@ -427,18 +741,238 @@ class Execution:
427
741
  self.operations[index] = replace(
428
742
  operation,
429
743
  status=OperationStatus.TIMED_OUT,
430
- end_timestamp=datetime.now(UTC),
744
+ end_timestamp=now if now is not None else real_now(),
431
745
  callback_details=updated_callback_details,
432
746
  )
433
747
  return self.operations[index]
434
748
 
435
- def _end_execution(self, status: OperationStatus) -> None:
749
+ def complete_chained_invoke(
750
+ self,
751
+ operation_id: str,
752
+ status: OperationStatus,
753
+ result: str | None = None,
754
+ error: ErrorObject | None = None,
755
+ now: datetime | None = None,
756
+ ) -> Operation:
757
+ """Transition a CHAINED_INVOKE operation to a terminal status.
758
+
759
+ ``status`` must be SUCCEEDED, FAILED, TIMED_OUT, or STOPPED.
760
+ SUCCEEDED carries ``result``; the failure statuses carry
761
+ ``error``.
762
+ """
763
+ index, operation = self.find_operation(operation_id)
764
+ self._require_chained_invoke(operation)
765
+
766
+ if status not in _CHAINED_INVOKE_TERMINAL_STATUSES:
767
+ msg_bad_status: str = (
768
+ f"Invalid terminal status for chained invoke: {status}"
769
+ )
770
+ raise IllegalStateException(msg_bad_status)
771
+ if operation.status is not OperationStatus.STARTED:
772
+ msg_not_active: str = (
773
+ f"Chained invoke operation [{operation_id}] is not active"
774
+ )
775
+ raise IllegalStateException(msg_not_active)
776
+ if status is OperationStatus.SUCCEEDED and error is not None:
777
+ msg_success_error: str = (
778
+ "Cannot provide an Error for a SUCCEEDED chained invoke."
779
+ )
780
+ raise IllegalStateException(msg_success_error)
781
+
782
+ with self._state_lock:
783
+ self.touch_operation(operation_id)
784
+ self.operations[index] = replace(
785
+ operation,
786
+ status=status,
787
+ end_timestamp=now if now is not None else real_now(),
788
+ chained_invoke_details=ChainedInvokeDetails(result=result, error=error),
789
+ )
790
+ # State paging sizes a completed operation by its result or
791
+ # error, not by its START payload.
792
+ self.operation_size_bytes[operation_id] = len(
793
+ (result if result is not None else "").encode()
794
+ ) + (len(json.dumps(error.to_dict())) if error is not None else 0)
795
+ self._record_updated_operation(operation_id)
796
+ return self.operations[index]
797
+
798
+ def executed_version(self) -> str:
799
+ """The version this execution runs, as the service reports it."""
800
+ return executed_version(self.start_input.function_qualifier)
801
+
802
+ def function_arn(self, default_region: str) -> str:
803
+ """The qualified function ARN of this execution.
804
+
805
+ The service qualifies the ARN with the executed version, and
806
+ ``Version`` carries the same value. An execution stored before the
807
+ runner recorded regions has none; it reports ``default_region``.
808
+ """
809
+ region: str = self.region if self.region is not None else default_region
810
+ return (
811
+ f"arn:aws:lambda:{region}:{self.start_input.account_id}"
812
+ f":function:{self.start_input.function_name}:{self.executed_version()}"
813
+ )
814
+
815
+ def record_chained_invoke_child(
816
+ self, operation_id: str, child_execution_arn: str
817
+ ) -> None:
818
+ """Record the child execution ARN for a CHAINED_INVOKE operation."""
819
+ with self._state_lock:
820
+ self.chained_invoke_children[operation_id] = child_execution_arn
821
+
822
+ @staticmethod
823
+ def _require_chained_invoke(operation: Operation) -> None:
824
+ if operation.operation_type != OperationType.CHAINED_INVOKE:
825
+ msg: str = (
826
+ f"Expected CHAINED_INVOKE operation, got {operation.operation_type}"
827
+ )
828
+ raise IllegalStateException(msg)
829
+
830
+ def _end_execution(
831
+ self, status: OperationStatus, now: datetime | None = None
832
+ ) -> None:
436
833
  """Set the end_timestamp on the main EXECUTION operation when execution completes."""
437
834
  execution_op: Operation = self.get_operation_execution_started()
438
835
  if execution_op.operation_type == OperationType.EXECUTION:
439
836
  with self._state_lock:
837
+ # Terminal transition of the EXECUTION op is a real
838
+ # state change — record it via touch_operation so
839
+ # introspection / GetDurableExecutionState reflects
840
+ # it. The handler has returned by this point, so the
841
+ # touch cannot change any checkpoint delta.
842
+ self.touch_operation(execution_op.operation_id)
440
843
  self.operations[0] = replace(
441
844
  execution_op,
442
845
  status=status,
443
- end_timestamp=datetime.now(UTC),
846
+ end_timestamp=now if now is not None else real_now(),
444
847
  )
848
+
849
+
850
+ @dataclass(frozen=True)
851
+ class OperationPaginatorState:
852
+ """Pinned context for serving operation state to the handler.
853
+
854
+ One class does three jobs that share a pinned snapshot of the
855
+ execution's operation graph:
856
+
857
+ * :meth:`page` — bytes-bounded pagination across the snapshot,
858
+ used by invocation-input construction and
859
+ ``GetDurableExecutionState``.
860
+ * :meth:`unseen_operations` — strict ``> handler_seen_seq``
861
+ delta filter, used by ``checkpoint_execution`` to decide which
862
+ ops go back to the handler.
863
+ * :meth:`advance_handler_seen` — monotonic forward update of the
864
+ delivery watermark on the wrapped :class:`Execution`, called
865
+ after a checkpoint response is constructed and its ops are
866
+ known.
867
+
868
+
869
+ Pinning ``token_sequence`` and ``operations`` at construction
870
+ guarantees that paginated reads and delta computation see the same
871
+ snapshot, even if the underlying :class:`Execution` is mutated
872
+ between method calls.
873
+ """
874
+
875
+ execution: Execution
876
+ pinned_token_sequence: int
877
+ snapshot_operations: list[Operation]
878
+
879
+ @classmethod
880
+ def pin(cls, execution: Execution) -> OperationPaginatorState:
881
+ """Capture the execution's current token_sequence and operation
882
+ list. Subsequent mutations on the Execution do not affect this
883
+ instance's ``page`` / ``unseen_operations`` results."""
884
+ return cls(
885
+ execution=execution,
886
+ pinned_token_sequence=execution.token_sequence,
887
+ snapshot_operations=list(execution.operations),
888
+ )
889
+
890
+ def page(
891
+ self,
892
+ marker: str | None,
893
+ max_size_bytes: int,
894
+ max_items: int | None = None,
895
+ ) -> tuple[list[Operation], str | None]:
896
+ """Return a page of ops starting after ``marker``, bounded by
897
+ ``max_size_bytes`` and, when given, ``max_items``. Second element
898
+ of the tuple is a marker for the next page, or ``None`` when the
899
+ page fits everything."""
900
+ start_idx = self._resolve_marker(marker)
901
+ return self._walk_page(
902
+ self.snapshot_operations, start_idx, max_size_bytes, max_items
903
+ )
904
+
905
+ def unseen_operations(self) -> list[Operation]:
906
+ """Operations whose ``operation_last_touched_seq`` is strictly
907
+ greater than the wrapped execution's ``handler_seen_seq``,
908
+ in creation order.
909
+
910
+ Ops absent from ``operation_last_touched_seq`` are treated as
911
+ touch-seq 0 and never appear in a delta once the
912
+ watermark has moved.
913
+ """
914
+ touched = self.execution.operation_last_touched_seq
915
+ cutoff = self.execution.handler_seen_seq
916
+ return [
917
+ op
918
+ for op in self.snapshot_operations
919
+ if touched.get(op.operation_id, 0) > cutoff
920
+ ]
921
+
922
+ def advance_handler_seen(self, seq: int) -> None:
923
+ """Advance the wrapped execution's ``handler_seen_seq`` to
924
+ ``seq`` if that represents forward progress. Monotonic:
925
+ smaller or equal values are ignored."""
926
+ if seq > self.execution.handler_seen_seq:
927
+ self.execution.handler_seen_seq = seq
928
+
929
+ # --- internals -------------------------------------------------
930
+
931
+ def _walk_page(
932
+ self,
933
+ ops: list[Operation],
934
+ start_idx: int,
935
+ max_size_bytes: int,
936
+ max_items: int | None = None,
937
+ ) -> tuple[list[Operation], str | None]:
938
+ selected: list[Operation] = []
939
+ total = 0
940
+ for i in range(start_idx, len(ops)):
941
+ op = ops[i]
942
+ size = self._size_for(op)
943
+ over_bytes: bool = total + size > max_size_bytes
944
+ over_count: bool = max_items is not None and len(selected) >= max_items
945
+ if selected and (over_bytes or over_count):
946
+ return selected, self._encode_marker(i)
947
+ selected.append(op)
948
+ total += size
949
+ return selected, None
950
+
951
+ def _size_for(self, op: Operation) -> int:
952
+ # Zero-size ops are possible (no payload / no error / no input);
953
+ # floor at 1 to guarantee pagination always advances.
954
+ return max(self.execution.operation_size_bytes.get(op.operation_id, 0), 1)
955
+
956
+ def _resolve_marker(self, marker: str | None) -> int:
957
+ if marker is None:
958
+ return 0
959
+ seq, idx = self._decode_marker(marker)
960
+ if seq != self.pinned_token_sequence:
961
+ msg = "Invalid marker"
962
+ raise InvalidParameterValueException(msg)
963
+ if idx < 0 or idx > len(self.snapshot_operations):
964
+ msg = "Invalid marker"
965
+ raise InvalidParameterValueException(msg)
966
+ return idx
967
+
968
+ def _encode_marker(self, next_idx: int) -> str:
969
+ return f"{self.pinned_token_sequence}:{next_idx}"
970
+
971
+ @staticmethod
972
+ def _decode_marker(marker: str) -> tuple[int, int]:
973
+ try:
974
+ seq_part, idx_part = marker.split(":", 1)
975
+ return int(seq_part), int(idx_part)
976
+ except (ValueError, AttributeError) as exc:
977
+ msg = "Invalid marker"
978
+ raise InvalidParameterValueException(msg) from exc