aws-durable-execution-sdk-python-testing 1.2.0__py3-none-any.whl → 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. aws_durable_execution_sdk_python_testing/__about__.py +1 -1
  2. aws_durable_execution_sdk_python_testing/checkpoint/core.py +171 -0
  3. aws_durable_execution_sdk_python_testing/checkpoint/effects.py +61 -0
  4. aws_durable_execution_sdk_python_testing/checkpoint/processor.py +124 -45
  5. aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +40 -14
  6. aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +5 -2
  7. aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +6 -0
  8. aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +4 -9
  9. aws_durable_execution_sdk_python_testing/checkpoint/processors/invoke.py +139 -0
  10. aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +15 -21
  11. aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +10 -16
  12. aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +145 -48
  13. aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +65 -8
  14. aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +79 -18
  15. aws_durable_execution_sdk_python_testing/child_dispatcher.py +828 -0
  16. aws_durable_execution_sdk_python_testing/cli.py +48 -1
  17. aws_durable_execution_sdk_python_testing/client.py +21 -10
  18. aws_durable_execution_sdk_python_testing/clock.py +69 -0
  19. aws_durable_execution_sdk_python_testing/exceptions.py +14 -0
  20. aws_durable_execution_sdk_python_testing/execution.py +583 -49
  21. aws_durable_execution_sdk_python_testing/executor.py +1813 -349
  22. aws_durable_execution_sdk_python_testing/invoker.py +356 -85
  23. aws_durable_execution_sdk_python_testing/model.py +81 -73
  24. aws_durable_execution_sdk_python_testing/observer.py +100 -88
  25. aws_durable_execution_sdk_python_testing/runner.py +407 -30
  26. aws_durable_execution_sdk_python_testing/scheduler.py +38 -13
  27. aws_durable_execution_sdk_python_testing/stores/filesystem.py +10 -2
  28. aws_durable_execution_sdk_python_testing/stores/sqlite.py +18 -6
  29. aws_durable_execution_sdk_python_testing/threads.py +157 -0
  30. aws_durable_execution_sdk_python_testing/token.py +11 -2
  31. aws_durable_execution_sdk_python_testing/web/handlers.py +33 -7
  32. aws_durable_execution_sdk_python_testing/web/routes.py +14 -6
  33. aws_durable_execution_sdk_python_testing/web/serialization.py +1 -2
  34. aws_durable_execution_sdk_python_testing/web/server.py +1 -1
  35. aws_durable_execution_sdk_python_testing/worker/__init__.py +9 -0
  36. aws_durable_execution_sdk_python_testing/worker/checkpoint_tasks.py +119 -0
  37. aws_durable_execution_sdk_python_testing/worker/execution_worker.py +117 -0
  38. aws_durable_execution_sdk_python_testing/worker/lane.py +118 -0
  39. aws_durable_execution_sdk_python_testing/worker/registry.py +124 -0
  40. aws_durable_execution_sdk_python_testing/worker/status.py +27 -0
  41. aws_durable_execution_sdk_python_testing/worker/task.py +57 -0
  42. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/METADATA +50 -58
  43. aws_durable_execution_sdk_python_testing-1.3.0.dist-info/RECORD +64 -0
  44. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/WHEEL +1 -1
  45. aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +0 -66
  46. aws_durable_execution_sdk_python_testing-1.2.0.dist-info/RECORD +0 -52
  47. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/LICENSE +0 -0
  48. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info/licenses → aws_durable_execution_sdk_python_testing}/NOTICE +0 -0
  49. {aws_durable_execution_sdk_python_testing-1.2.0.dist-info → aws_durable_execution_sdk_python_testing-1.3.0.dist-info}/entry_points.txt +0 -0
@@ -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
- self._loop.call_soon_threadsafe(self._cleanup_and_stop)
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 and clear all events. Stop the event-loop."""
121
- # Cancel all tasks
122
- for task in asyncio.all_tasks(self._loop):
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
- # Clear events (don't set them)
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 its own
150
- threadpool, so it won't block the event loop.
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 asyncio.iscoroutinefunction(func):
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
- asyncio.wait_for(event.wait(), timeout), self._loop
238
+ wait_for_event_with_timeout(), self._loop
220
239
  )
221
240
 
222
241
  try:
223
- return future.result()
224
- except TimeoutError:
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
- with open(file_path, "w", encoding="utf-8") as f:
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 = f"Execution {execution_arn} not found"
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._get_connection() as conn:
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._get_connection() as conn:
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._get_connection() as conn:
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(f"Execution {execution_arn} not found")
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._get_connection() as conn:
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._get_connection() as conn:
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 = {"arn": self.execution_arn, "seq": self.token_sequence}
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(execution_arn=data["arn"], token_sequence=data["seq"])
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: # noqa: ARG002
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(execution_arn)
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=False,
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", "us-east-1")
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, region_name)
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 and split into segments
49
- segments = [s for s in path.strip("/").split("/") if s]
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=unquote(route.segments[2]),
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=unquote(route.segments[2]),
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=unquote(route.segments[2]),
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
@@ -1,4 +1,4 @@
1
- """Local testing web server for AWS Lambda Durable Functions that mimics the actual Lambda backend services."""
1
+ """Local testing web server for AWS Lambda Durable Functions for local testing."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -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
+ """