aws-durable-execution-sdk-python-testing 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_durable_execution_sdk_python_testing/__about__.py +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
- aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
- aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
- aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
- aws_durable_execution_sdk_python_testing/cli.py +48 -1
- aws_durable_execution_sdk_python_testing/client.py +21 -10
- aws_durable_execution_sdk_python_testing/clock.py +69 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
- aws_durable_execution_sdk_python_testing/execution.py +583 -49
- aws_durable_execution_sdk_python_testing/executor.py +1813 -349
- aws_durable_execution_sdk_python_testing/invoker.py +356 -85
- aws_durable_execution_sdk_python_testing/model.py +81 -73
- aws_durable_execution_sdk_python_testing/observer.py +100 -88
- aws_durable_execution_sdk_python_testing/runner.py +407 -30
- aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
- aws_durable_execution_sdk_python_testing/threads.py +157 -0
- aws_durable_execution_sdk_python_testing/token.py +11 -2
- aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
- aws_durable_execution_sdk_python_testing/web/routes.py +14 -6
- aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
- aws_durable_execution_sdk_python_testing/web/server.py +1 -1
- aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
- aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
- aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
- aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
- aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
- aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
- aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
- aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
- aws_durable_execution_sdk_python_testing-1.2.0.dist-info/RECORD +0 -52
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
- {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import inspect
|
|
6
7
|
import itertools
|
|
7
8
|
import logging
|
|
8
9
|
import threading
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from concurrent.futures import TimeoutError as FutureTimeoutError
|
|
9
12
|
from typing import TYPE_CHECKING, Any
|
|
10
13
|
|
|
11
14
|
|
|
@@ -64,6 +67,10 @@ class Scheduler:
|
|
|
64
67
|
|
|
65
68
|
def __init__(self) -> None:
|
|
66
69
|
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
|
|
70
|
+
self._thread_executor = ThreadPoolExecutor(
|
|
71
|
+
max_workers=1, thread_name_prefix="durable-scheduler"
|
|
72
|
+
)
|
|
73
|
+
self._loop.set_default_executor(self._thread_executor)
|
|
67
74
|
self._ready_event: threading.Event = threading.Event()
|
|
68
75
|
self._thread: threading.Thread = threading.Thread(
|
|
69
76
|
target=self._start_loop, daemon=True
|
|
@@ -99,7 +106,8 @@ class Scheduler:
|
|
|
99
106
|
return
|
|
100
107
|
|
|
101
108
|
self._running = False
|
|
102
|
-
|
|
109
|
+
future = asyncio.run_coroutine_threadsafe(self._cleanup_and_stop(), self._loop)
|
|
110
|
+
future.result()
|
|
103
111
|
self._thread.join()
|
|
104
112
|
|
|
105
113
|
def is_started(self) -> bool:
|
|
@@ -116,16 +124,24 @@ class Scheduler:
|
|
|
116
124
|
return 0
|
|
117
125
|
return len(asyncio.all_tasks(self._loop))
|
|
118
126
|
|
|
119
|
-
def _cleanup_and_stop(self):
|
|
120
|
-
"""Cancel all tasks
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
async def _cleanup_and_stop(self):
|
|
128
|
+
"""Cancel all tasks, clear events, and stop the event loop."""
|
|
129
|
+
current_task = asyncio.current_task(self._loop)
|
|
130
|
+
tasks = [
|
|
131
|
+
task for task in asyncio.all_tasks(self._loop) if task is not current_task
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
for task in tasks:
|
|
123
135
|
task.cancel()
|
|
124
136
|
|
|
125
|
-
|
|
137
|
+
if tasks:
|
|
138
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
139
|
+
|
|
140
|
+
await self._loop.shutdown_default_executor()
|
|
141
|
+
|
|
126
142
|
self._events.clear()
|
|
127
143
|
|
|
128
|
-
self._loop.stop
|
|
144
|
+
self._loop.call_soon(self._loop.stop)
|
|
129
145
|
|
|
130
146
|
def _start_loop(self):
|
|
131
147
|
"""Initialize the event-loop. The ready event notifies that the loop is started."""
|
|
@@ -146,8 +162,8 @@ class Scheduler:
|
|
|
146
162
|
) -> Future[Any]:
|
|
147
163
|
"""Call func after the delay.
|
|
148
164
|
|
|
149
|
-
If func is async it runs inside a thread-safe coroutine. If func is sync it runs in
|
|
150
|
-
|
|
165
|
+
If func is async it runs inside a thread-safe coroutine. If func is sync it runs in the
|
|
166
|
+
scheduler worker thread, so it won't block the event loop.
|
|
151
167
|
|
|
152
168
|
Args:
|
|
153
169
|
func (Callable[[], Any]): The function to call later. This can be an async or a standard
|
|
@@ -170,7 +186,7 @@ class Scheduler:
|
|
|
170
186
|
await asyncio.sleep(delay)
|
|
171
187
|
|
|
172
188
|
try:
|
|
173
|
-
if
|
|
189
|
+
if inspect.iscoroutinefunction(func):
|
|
174
190
|
result = await func()
|
|
175
191
|
else:
|
|
176
192
|
result = await asyncio.to_thread(func)
|
|
@@ -215,13 +231,22 @@ class Scheduler:
|
|
|
215
231
|
if event not in self._events:
|
|
216
232
|
return False
|
|
217
233
|
|
|
234
|
+
async def wait_for_event_with_timeout() -> bool:
|
|
235
|
+
return await asyncio.wait_for(event.wait(), timeout)
|
|
236
|
+
|
|
218
237
|
future: Future[bool] = asyncio.run_coroutine_threadsafe(
|
|
219
|
-
|
|
238
|
+
wait_for_event_with_timeout(), self._loop
|
|
220
239
|
)
|
|
221
240
|
|
|
222
241
|
try:
|
|
223
|
-
|
|
224
|
-
|
|
242
|
+
if timeout is None:
|
|
243
|
+
return future.result()
|
|
244
|
+
# Enforce the timeout from the waiting thread too. If the scheduler
|
|
245
|
+
# loop is blocked, the inner asyncio.wait_for timeout cannot fire.
|
|
246
|
+
margin = min(1.0, max(0.01, timeout * 0.1))
|
|
247
|
+
return future.result(timeout=timeout + margin)
|
|
248
|
+
except (TimeoutError, FutureTimeoutError):
|
|
249
|
+
future.cancel()
|
|
225
250
|
return False
|
|
226
251
|
|
|
227
252
|
def set_event(self, event: asyncio.Event):
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
@@ -46,14 +47,21 @@ class FileSystemExecutionStore(BaseExecutionStore):
|
|
|
46
47
|
file_path = self._get_file_path(execution.durable_execution_arn)
|
|
47
48
|
data = execution.to_json_dict()
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
# Write to a temporary file in the same directory, then replace
|
|
51
|
+
# the target in one atomic step. A concurrent reader then sees
|
|
52
|
+
# either the old file or the new file, never a partial or empty
|
|
53
|
+
# one. Saves for one execution run on its worker lane, so two
|
|
54
|
+
# writes never target the same temp path at once.
|
|
55
|
+
tmp_path = file_path.with_name(f"{file_path.name}.tmp")
|
|
56
|
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
50
57
|
json.dump(data, f, indent=2)
|
|
58
|
+
os.replace(tmp_path, file_path)
|
|
51
59
|
|
|
52
60
|
def load(self, execution_arn: str) -> Execution:
|
|
53
61
|
"""Load execution from file system."""
|
|
54
62
|
file_path = self._get_file_path(execution_arn)
|
|
55
63
|
if not file_path.exists():
|
|
56
|
-
msg =
|
|
64
|
+
msg = "Durable Execution does not exist"
|
|
57
65
|
raise ResourceNotFoundException(msg)
|
|
58
66
|
|
|
59
67
|
with open(file_path, encoding="utf-8") as f:
|
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import sqlite3
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
7
9
|
from datetime import datetime
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import Any, cast
|
|
@@ -43,10 +45,20 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
43
45
|
conn.execute("PRAGMA synchronous=NORMAL;")
|
|
44
46
|
return conn
|
|
45
47
|
|
|
48
|
+
@contextmanager
|
|
49
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
50
|
+
"""Open a connection, preserve transaction handling, and always close it."""
|
|
51
|
+
conn = self._get_connection()
|
|
52
|
+
try:
|
|
53
|
+
with conn:
|
|
54
|
+
yield conn
|
|
55
|
+
finally:
|
|
56
|
+
conn.close()
|
|
57
|
+
|
|
46
58
|
def _init_db(self) -> None:
|
|
47
59
|
"""Initialize database schema."""
|
|
48
60
|
try:
|
|
49
|
-
with self.
|
|
61
|
+
with self._connection() as conn:
|
|
50
62
|
conn.execute("""
|
|
51
63
|
CREATE TABLE IF NOT EXISTS executions (
|
|
52
64
|
durable_execution_arn TEXT PRIMARY KEY,
|
|
@@ -80,7 +92,7 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
80
92
|
execution_op = execution.get_operation_execution_started()
|
|
81
93
|
status: str = execution.current_status().value
|
|
82
94
|
|
|
83
|
-
with self.
|
|
95
|
+
with self._connection() as conn:
|
|
84
96
|
conn.execute(
|
|
85
97
|
"""
|
|
86
98
|
INSERT OR REPLACE INTO executions
|
|
@@ -111,7 +123,7 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
111
123
|
def load(self, execution_arn: str) -> Execution:
|
|
112
124
|
"""Load execution from SQLite."""
|
|
113
125
|
try:
|
|
114
|
-
with self.
|
|
126
|
+
with self._connection() as conn:
|
|
115
127
|
cursor: sqlite3.Cursor = conn.execute(
|
|
116
128
|
"SELECT data FROM executions WHERE durable_execution_arn = ?",
|
|
117
129
|
(execution_arn,),
|
|
@@ -119,7 +131,7 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
119
131
|
row: tuple[str] | None = cursor.fetchone()
|
|
120
132
|
|
|
121
133
|
if not row:
|
|
122
|
-
raise ResourceNotFoundException(
|
|
134
|
+
raise ResourceNotFoundException("Durable Execution does not exist")
|
|
123
135
|
|
|
124
136
|
return Execution.from_json_dict(json.loads(row[0]))
|
|
125
137
|
except sqlite3.Error as e:
|
|
@@ -204,7 +216,7 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
204
216
|
)
|
|
205
217
|
params_with_limit = params
|
|
206
218
|
|
|
207
|
-
with self.
|
|
219
|
+
with self._connection() as conn:
|
|
208
220
|
# Get total count for pagination
|
|
209
221
|
total_count: int = int(conn.execute(count_query, params).fetchone()[0])
|
|
210
222
|
|
|
@@ -247,7 +259,7 @@ class SQLiteExecutionStore(ExecutionStore):
|
|
|
247
259
|
def get_execution_metadata(self, execution_arn: str) -> dict[str, Any] | None:
|
|
248
260
|
"""Get just the metadata without full deserialization for performance."""
|
|
249
261
|
try:
|
|
250
|
-
with self.
|
|
262
|
+
with self._connection() as conn:
|
|
251
263
|
cursor: sqlite3.Cursor = conn.execute(
|
|
252
264
|
"SELECT function_name, execution_name, status, start_timestamp, end_timestamp FROM executions WHERE durable_execution_arn = ?",
|
|
253
265
|
(execution_arn,),
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""A bounded pool of daemon threads for blocking calls.
|
|
2
|
+
|
|
3
|
+
The runner runs two kinds of blocking call off the scheduler loop: a
|
|
4
|
+
handler invocation, and the synchronous Invoke of a plain chained-invoke
|
|
5
|
+
target. Each blocks until the endpoint answers or the read timeout
|
|
6
|
+
expires. Python cannot interrupt a thread blocked in a socket read. So
|
|
7
|
+
the runner cannot stop such a call. It can stop the call from holding
|
|
8
|
+
the process.
|
|
9
|
+
|
|
10
|
+
``concurrent.futures.ThreadPoolExecutor`` does not allow that. Its
|
|
11
|
+
workers are not daemon threads, and the interpreter joins them at exit.
|
|
12
|
+
So a runner closed while one Invoke is blocked keeps the process alive
|
|
13
|
+
until that read timeout. This pool has the same shape (a worker cap, a
|
|
14
|
+
FIFO queue, lazily started workers) and differs in two ways. Its workers
|
|
15
|
+
are daemon threads, which the interpreter does not wait for. Its
|
|
16
|
+
``shutdown`` drops queued work, refuses new work, and never joins.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import queue
|
|
23
|
+
import threading
|
|
24
|
+
from concurrent.futures import Executor, Future
|
|
25
|
+
from typing import TYPE_CHECKING, Any
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_max_workers() -> int:
|
|
32
|
+
"""The worker cap ``ThreadPoolExecutor`` uses when none is given."""
|
|
33
|
+
return min(32, (os.cpu_count() or 1) + 4)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _WorkItem:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
future: Future[Any],
|
|
40
|
+
fn: Callable[..., Any],
|
|
41
|
+
args: tuple[Any, ...],
|
|
42
|
+
kwargs: dict[str, Any],
|
|
43
|
+
) -> None:
|
|
44
|
+
self.future = future
|
|
45
|
+
self.fn = fn
|
|
46
|
+
self.args = args
|
|
47
|
+
self.kwargs = kwargs
|
|
48
|
+
|
|
49
|
+
def run(self) -> None:
|
|
50
|
+
if not self.future.set_running_or_notify_cancel():
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
result = self.fn(*self.args, **self.kwargs)
|
|
54
|
+
except BaseException as exc: # noqa: BLE001 — the future carries the error
|
|
55
|
+
self.future.set_exception(exc)
|
|
56
|
+
else:
|
|
57
|
+
self.future.set_result(result)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Stop:
|
|
61
|
+
"""Sentinel that makes an idle worker return."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DaemonThreadPool(Executor):
|
|
65
|
+
"""Run callables on at most ``max_workers`` daemon threads, in FIFO order.
|
|
66
|
+
|
|
67
|
+
A worker is started when work is submitted and no worker is idle,
|
|
68
|
+
until the cap is reached. Beyond the cap, work waits in the queue in
|
|
69
|
+
submission order, as in ``ThreadPoolExecutor``.
|
|
70
|
+
|
|
71
|
+
``shutdown`` marks the pool closed, cancels every queued future, and
|
|
72
|
+
tells idle workers to return. A worker busy in a blocking call is
|
|
73
|
+
left alone: it finishes when its call returns, and its result lands
|
|
74
|
+
on a future nobody reads. ``wait`` is accepted for API compatibility
|
|
75
|
+
and ignored, because joining a blocked worker would wait for the read
|
|
76
|
+
timeout this pool exists to avoid. Workers are daemon threads, so
|
|
77
|
+
process exit does not wait for them either.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self, max_workers: int | None = None, thread_name_prefix: str = ""
|
|
82
|
+
) -> None:
|
|
83
|
+
if max_workers is None:
|
|
84
|
+
max_workers = default_max_workers()
|
|
85
|
+
if max_workers <= 0:
|
|
86
|
+
msg = "max_workers must be greater than 0"
|
|
87
|
+
raise ValueError(msg)
|
|
88
|
+
self._max_workers = max_workers
|
|
89
|
+
self._thread_name_prefix = thread_name_prefix or "daemon-pool"
|
|
90
|
+
self._queue: queue.SimpleQueue[_WorkItem | _Stop] = queue.SimpleQueue()
|
|
91
|
+
self._idle = threading.Semaphore(0)
|
|
92
|
+
self._threads: list[threading.Thread] = []
|
|
93
|
+
self._closed = False
|
|
94
|
+
self._lock = threading.Lock()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def max_workers(self) -> int:
|
|
98
|
+
return self._max_workers
|
|
99
|
+
|
|
100
|
+
def submit(
|
|
101
|
+
self, fn: Callable[..., Any], /, *args: Any, **kwargs: Any
|
|
102
|
+
) -> Future[Any]:
|
|
103
|
+
with self._lock:
|
|
104
|
+
if self._closed:
|
|
105
|
+
msg = "cannot schedule new futures after shutdown"
|
|
106
|
+
raise RuntimeError(msg)
|
|
107
|
+
future: Future[Any] = Future()
|
|
108
|
+
self._queue.put(_WorkItem(future, fn, args, kwargs))
|
|
109
|
+
self._adjust_thread_count()
|
|
110
|
+
return future
|
|
111
|
+
|
|
112
|
+
def _adjust_thread_count(self) -> None:
|
|
113
|
+
# An idle worker will take the item; do not start another.
|
|
114
|
+
if self._idle.acquire(blocking=False):
|
|
115
|
+
return
|
|
116
|
+
if len(self._threads) >= self._max_workers:
|
|
117
|
+
return
|
|
118
|
+
thread = threading.Thread(
|
|
119
|
+
target=self._worker,
|
|
120
|
+
name=f"{self._thread_name_prefix}_{len(self._threads)}",
|
|
121
|
+
daemon=True,
|
|
122
|
+
)
|
|
123
|
+
self._threads.append(thread)
|
|
124
|
+
thread.start()
|
|
125
|
+
|
|
126
|
+
def _worker(self) -> None:
|
|
127
|
+
while True:
|
|
128
|
+
item: _WorkItem | _Stop = self._queue.get()
|
|
129
|
+
if isinstance(item, _Stop):
|
|
130
|
+
return
|
|
131
|
+
item.run()
|
|
132
|
+
del item
|
|
133
|
+
self._idle.release()
|
|
134
|
+
|
|
135
|
+
def shutdown(self, wait: bool = True, *, cancel_futures: bool = False) -> None: # noqa: ARG002, FBT001, FBT002
|
|
136
|
+
"""Close the pool without waiting for busy workers.
|
|
137
|
+
|
|
138
|
+
Queued work is cancelled whatever ``cancel_futures`` says: work
|
|
139
|
+
that has not started must not start after shutdown. ``wait`` is
|
|
140
|
+
ignored, see the class docstring.
|
|
141
|
+
"""
|
|
142
|
+
with self._lock:
|
|
143
|
+
if self._closed:
|
|
144
|
+
return
|
|
145
|
+
self._closed = True
|
|
146
|
+
drained: list[_WorkItem] = []
|
|
147
|
+
while True:
|
|
148
|
+
try:
|
|
149
|
+
item = self._queue.get_nowait()
|
|
150
|
+
except queue.Empty:
|
|
151
|
+
break
|
|
152
|
+
if isinstance(item, _WorkItem):
|
|
153
|
+
drained.append(item)
|
|
154
|
+
for _ in self._threads:
|
|
155
|
+
self._queue.put(_Stop())
|
|
156
|
+
for item in drained:
|
|
157
|
+
item.future.cancel()
|
|
@@ -13,9 +13,14 @@ class CheckpointToken:
|
|
|
13
13
|
|
|
14
14
|
execution_arn: str
|
|
15
15
|
token_sequence: int
|
|
16
|
+
invocation_id: str = ""
|
|
16
17
|
|
|
17
18
|
def to_str(self) -> str:
|
|
18
|
-
data = {
|
|
19
|
+
data = {
|
|
20
|
+
"arn": self.execution_arn,
|
|
21
|
+
"seq": self.token_sequence,
|
|
22
|
+
"inv": self.invocation_id,
|
|
23
|
+
}
|
|
19
24
|
json_str = json.dumps(data, separators=(",", ":"))
|
|
20
25
|
# str -> bytes -> base64 bytes -> str
|
|
21
26
|
return base64.b64encode(json_str.encode()).decode()
|
|
@@ -25,7 +30,11 @@ class CheckpointToken:
|
|
|
25
30
|
# str -> base64 bytes -> str
|
|
26
31
|
decoded = base64.b64decode(token).decode()
|
|
27
32
|
data = json.loads(decoded)
|
|
28
|
-
return cls(
|
|
33
|
+
return cls(
|
|
34
|
+
execution_arn=data["arn"],
|
|
35
|
+
token_sequence=data["seq"],
|
|
36
|
+
invocation_id=data.get("inv", ""),
|
|
37
|
+
)
|
|
29
38
|
|
|
30
39
|
|
|
31
40
|
@dataclass(frozen=True)
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import base64
|
|
6
5
|
import json
|
|
7
6
|
import logging
|
|
8
7
|
from abc import ABC, abstractmethod
|
|
@@ -26,7 +25,6 @@ from aws_durable_execution_sdk_python_testing.model import (
|
|
|
26
25
|
ListDurableExecutionsResponse,
|
|
27
26
|
SendDurableExecutionCallbackFailureRequest,
|
|
28
27
|
SendDurableExecutionCallbackFailureResponse,
|
|
29
|
-
SendDurableExecutionCallbackHeartbeatRequest,
|
|
30
28
|
SendDurableExecutionCallbackHeartbeatResponse,
|
|
31
29
|
SendDurableExecutionCallbackSuccessResponse,
|
|
32
30
|
StartDurableExecutionInput,
|
|
@@ -438,7 +436,7 @@ class StopDurableExecutionHandler(EndpointHandler):
|
|
|
438
436
|
class GetDurableExecutionStateHandler(EndpointHandler):
|
|
439
437
|
"""Handler for GET /2025-12-01/durable-executions/{arn}/state."""
|
|
440
438
|
|
|
441
|
-
def handle(self, parsed_route: Route, request: HTTPRequest) -> HTTPResponse:
|
|
439
|
+
def handle(self, parsed_route: Route, request: HTTPRequest) -> HTTPResponse:
|
|
442
440
|
"""Handle get durable execution state request.
|
|
443
441
|
|
|
444
442
|
Args:
|
|
@@ -452,8 +450,19 @@ class GetDurableExecutionStateHandler(EndpointHandler):
|
|
|
452
450
|
state_route = cast(GetDurableExecutionStateRoute, parsed_route)
|
|
453
451
|
execution_arn: str = state_route.arn
|
|
454
452
|
|
|
453
|
+
checkpoint_token: str | None = self._parse_query_param(
|
|
454
|
+
request, "CheckpointToken"
|
|
455
|
+
)
|
|
456
|
+
marker: str | None = self._parse_query_param(request, "Marker") or None
|
|
457
|
+
max_items: str | None = self._parse_query_param(request, "MaxItems")
|
|
458
|
+
|
|
455
459
|
state_response: GetDurableExecutionStateResponse = (
|
|
456
|
-
self.executor.get_execution_state(
|
|
460
|
+
self.executor.get_execution_state(
|
|
461
|
+
execution_arn,
|
|
462
|
+
checkpoint_token=checkpoint_token,
|
|
463
|
+
marker=marker,
|
|
464
|
+
max_items=int(max_items) if max_items else None,
|
|
465
|
+
)
|
|
457
466
|
)
|
|
458
467
|
|
|
459
468
|
response_data: dict[str, Any] = state_response.to_dict()
|
|
@@ -493,12 +502,18 @@ class GetDurableExecutionHistoryHandler(EndpointHandler):
|
|
|
493
502
|
if include_execution_data_str
|
|
494
503
|
else False
|
|
495
504
|
)
|
|
505
|
+
reverse_order_str: str | None = self._parse_query_param(
|
|
506
|
+
request, "ReverseOrder"
|
|
507
|
+
)
|
|
508
|
+
reverse_order: bool = (
|
|
509
|
+
reverse_order_str == "true" if reverse_order_str else False
|
|
510
|
+
)
|
|
496
511
|
|
|
497
512
|
history_response: GetDurableExecutionHistoryResponse = (
|
|
498
513
|
self.executor.get_execution_history(
|
|
499
514
|
execution_arn,
|
|
500
515
|
include_execution_data=include_execution_data,
|
|
501
|
-
reverse_order=
|
|
516
|
+
reverse_order=reverse_order,
|
|
502
517
|
marker=marker,
|
|
503
518
|
max_items=int(max_items) if max_items else None,
|
|
504
519
|
)
|
|
@@ -794,17 +809,28 @@ class UpdateLambdaEndpointHandler(EndpointHandler):
|
|
|
794
809
|
try:
|
|
795
810
|
body = self._parse_json_body(request)
|
|
796
811
|
endpoint_url = body.get("EndpointUrl")
|
|
797
|
-
region_name = body.get("RegionName"
|
|
812
|
+
region_name = body.get("RegionName")
|
|
798
813
|
|
|
799
814
|
if not endpoint_url:
|
|
800
815
|
return self._handle_aws_exception(
|
|
801
816
|
InvalidParameterValueException("EndpointUrl is required")
|
|
802
817
|
)
|
|
818
|
+
# The runner emulates one region, fixed at startup, and every
|
|
819
|
+
# execution is created in it. A request that names another
|
|
820
|
+
# region would give the runner two regions at once, so it is
|
|
821
|
+
# rejected instead of applied to the invoker alone.
|
|
822
|
+
if region_name is not None and region_name != self.executor.region:
|
|
823
|
+
return self._handle_aws_exception(
|
|
824
|
+
InvalidParameterValueException(
|
|
825
|
+
f"RegionName must be {self.executor.region}, "
|
|
826
|
+
f"the region this runner emulates; got {region_name}"
|
|
827
|
+
)
|
|
828
|
+
)
|
|
803
829
|
|
|
804
830
|
# Update the invoker's Lambda endpoint
|
|
805
831
|
invoker = self.executor._invoker # noqa: SLF001
|
|
806
832
|
logger.info("Updating lambda endpoint to %s", endpoint_url)
|
|
807
|
-
invoker.update_endpoint(endpoint_url,
|
|
833
|
+
invoker.update_endpoint(endpoint_url, self.executor.region)
|
|
808
834
|
return self._success_response(
|
|
809
835
|
{"message": "Lambda endpoint updated successfully"}
|
|
810
836
|
)
|
|
@@ -39,14 +39,22 @@ class Route:
|
|
|
39
39
|
def from_string(cls, path: str) -> Route:
|
|
40
40
|
"""Create a Route from a string.
|
|
41
41
|
|
|
42
|
+
Each segment is URL-decoded; ``raw_path`` is preserved as the
|
|
43
|
+
original wire path. Splitting on ``/`` happens before decoding so
|
|
44
|
+
that an encoded ``%2F`` inside a captured value (e.g. an ARN that
|
|
45
|
+
contains ``/``) stays inside its segment instead of being treated
|
|
46
|
+
as a path separator.
|
|
47
|
+
|
|
42
48
|
Args:
|
|
43
49
|
path: The raw path string
|
|
44
50
|
|
|
45
51
|
Returns:
|
|
46
|
-
Route instance with parsed segments
|
|
52
|
+
Route instance with parsed, URL-decoded segments
|
|
47
53
|
"""
|
|
48
|
-
# Remove leading/trailing slashes
|
|
49
|
-
|
|
54
|
+
# Remove leading/trailing slashes, split on '/', then URL-decode each
|
|
55
|
+
# segment. Order matters: split on the literal '/' first so '%2F'-
|
|
56
|
+
# encoded slashes inside values don't act as separators.
|
|
57
|
+
segments = [unquote(s) for s in path.strip("/").split("/") if s]
|
|
50
58
|
return cls(raw_path=path, segments=segments)
|
|
51
59
|
|
|
52
60
|
def matches_pattern(self, pattern: list[str]) -> bool:
|
|
@@ -445,7 +453,7 @@ class CallbackSuccessRoute(BytesPayloadRoute):
|
|
|
445
453
|
return cls(
|
|
446
454
|
raw_path=route.raw_path,
|
|
447
455
|
segments=route.segments,
|
|
448
|
-
callback_id=
|
|
456
|
+
callback_id=route.segments[2],
|
|
449
457
|
)
|
|
450
458
|
|
|
451
459
|
|
|
@@ -488,7 +496,7 @@ class CallbackFailureRoute(BytesPayloadRoute):
|
|
|
488
496
|
return cls(
|
|
489
497
|
raw_path=route.raw_path,
|
|
490
498
|
segments=route.segments,
|
|
491
|
-
callback_id=
|
|
499
|
+
callback_id=route.segments[2],
|
|
492
500
|
)
|
|
493
501
|
|
|
494
502
|
|
|
@@ -531,7 +539,7 @@ class CallbackHeartbeatRoute(Route):
|
|
|
531
539
|
return cls(
|
|
532
540
|
raw_path=route.raw_path,
|
|
533
541
|
segments=route.segments,
|
|
534
|
-
callback_id=
|
|
542
|
+
callback_id=route.segments[2],
|
|
535
543
|
)
|
|
536
544
|
|
|
537
545
|
|
|
@@ -7,11 +7,10 @@ along with AWS-compatible implementations using boto's rest-json serializers.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import json
|
|
10
|
-
import os
|
|
10
|
+
import os # noqa: F401 - accessed via module namespace in tests (mock.patch)
|
|
11
11
|
from typing import Any, Protocol
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
|
|
14
|
-
import aws_durable_execution_sdk_python
|
|
15
14
|
import botocore.loaders # type: ignore
|
|
16
15
|
from botocore.model import ServiceModel # type: ignore
|
|
17
16
|
from botocore.parsers import create_parser # type: ignore
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Per-execution serial actor model for the local runner.
|
|
2
|
+
|
|
3
|
+
Each execution is served by a single worker that runs all of its
|
|
4
|
+
operations (checkpoint, get-state, invoke, timer, callback, completion)
|
|
5
|
+
one at a time on its own lane. Serializing the operations of one
|
|
6
|
+
execution keeps its state consistent when several callers act on it at
|
|
7
|
+
once; different executions are served by different workers and run in
|
|
8
|
+
parallel.
|
|
9
|
+
"""
|