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.
- aws_durable_execution_sdk_python_testing/__about__.py +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
- aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
- aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
- aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
- aws_durable_execution_sdk_python_testing/cli.py +48 -1
- aws_durable_execution_sdk_python_testing/client.py +21 -10
- aws_durable_execution_sdk_python_testing/clock.py +69 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
- aws_durable_execution_sdk_python_testing/execution.py +583 -49
- aws_durable_execution_sdk_python_testing/executor.py +1813 -349
- aws_durable_execution_sdk_python_testing/invoker.py +356 -85
- aws_durable_execution_sdk_python_testing/model.py +81 -73
- aws_durable_execution_sdk_python_testing/observer.py +100 -88
- aws_durable_execution_sdk_python_testing/runner.py +407 -30
- aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
- aws_durable_execution_sdk_python_testing/threads.py +157 -0
- aws_durable_execution_sdk_python_testing/token.py +11 -2
- aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
- aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
- aws_durable_execution_sdk_python_testing/web/server.py +1 -1
- aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
- aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
- aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
- aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
- aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
- aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
- aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
- aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
- aws_durable_execution_sdk_python_testing-1.2.1.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.1.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {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
|
-
|
|
4
|
-
|
|
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.
|
|
64
|
-
#
|
|
65
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
"
|
|
112
|
-
"TokenSequence": self.
|
|
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.
|
|
146
|
-
|
|
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=
|
|
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
|
-
"""
|
|
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=
|
|
407
|
+
token_sequence=self.token_sequence,
|
|
408
|
+
invocation_id=self.current_invocation_id,
|
|
198
409
|
)
|
|
199
|
-
|
|
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
|
-
"""
|
|
205
|
-
|
|
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
|
-
"""
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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.
|
|
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=
|
|
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.
|
|
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,
|
|
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.
|
|
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=
|
|
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.
|
|
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=
|
|
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.
|
|
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=
|
|
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
|
|
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=
|
|
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
|