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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Worker tasks for the checkpoint and get-state operations.
|
|
2
|
+
|
|
3
|
+
Routing these through a per-execution worker serializes all checkpoint
|
|
4
|
+
and get-state calls for one execution, so concurrent callers can never
|
|
5
|
+
observe or write half-applied state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
11
|
+
|
|
12
|
+
from aws_durable_execution_sdk_python.lambda_service import (
|
|
13
|
+
CheckpointOutput,
|
|
14
|
+
StateOutput,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from aws_durable_execution_sdk_python_testing.worker.status import InvocationState
|
|
18
|
+
from aws_durable_execution_sdk_python_testing.worker.task import (
|
|
19
|
+
ExecutionTask,
|
|
20
|
+
TaskOutcome,
|
|
21
|
+
WorkerContext,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
from aws_durable_execution_sdk_python.lambda_service import OperationUpdate
|
|
28
|
+
|
|
29
|
+
from aws_durable_execution_sdk_python_testing.checkpoint.processor import (
|
|
30
|
+
CheckpointProcessor,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _status_after(worker: WorkerContext) -> InvocationState:
|
|
37
|
+
"""The status to carry forward after a task.
|
|
38
|
+
|
|
39
|
+
COMPLETED once the execution is terminal (so the worker tears down);
|
|
40
|
+
otherwise whatever status the task left on the worker. A store error
|
|
41
|
+
during the completion check is treated as not-yet-terminal.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
is_complete: bool = worker.reload().is_complete
|
|
45
|
+
except Exception: # noqa: BLE001 — store may be unavailable; keep the worker
|
|
46
|
+
return worker.status
|
|
47
|
+
return InvocationState.COMPLETED if is_complete else worker.status
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CallableTask(ExecutionTask[T]):
|
|
51
|
+
"""Runs a checkpoint-style callable on the worker lane.
|
|
52
|
+
|
|
53
|
+
Lets a caller serialize an existing operation on the execution's
|
|
54
|
+
lane without restating it as a task; the lane is released once the
|
|
55
|
+
execution completes.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, fn: Callable[[], T]) -> None:
|
|
59
|
+
self._fn: Callable[[], T] = fn
|
|
60
|
+
|
|
61
|
+
def execute(
|
|
62
|
+
self,
|
|
63
|
+
status: InvocationState, # noqa: ARG002
|
|
64
|
+
worker: WorkerContext,
|
|
65
|
+
) -> TaskOutcome[T]:
|
|
66
|
+
result: T = self._fn()
|
|
67
|
+
return TaskOutcome(_status_after(worker), result)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CheckpointTask(ExecutionTask[CheckpointOutput]):
|
|
71
|
+
"""Applies a checkpoint for one execution."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
processor: CheckpointProcessor,
|
|
76
|
+
checkpoint_token: str,
|
|
77
|
+
updates: list[OperationUpdate],
|
|
78
|
+
client_token: str | None,
|
|
79
|
+
) -> None:
|
|
80
|
+
self._processor: CheckpointProcessor = processor
|
|
81
|
+
self._checkpoint_token: str = checkpoint_token
|
|
82
|
+
self._updates: list[OperationUpdate] = updates
|
|
83
|
+
self._client_token: str | None = client_token
|
|
84
|
+
|
|
85
|
+
def execute(
|
|
86
|
+
self,
|
|
87
|
+
status: InvocationState, # noqa: ARG002
|
|
88
|
+
worker: WorkerContext,
|
|
89
|
+
) -> TaskOutcome[CheckpointOutput]:
|
|
90
|
+
response: CheckpointOutput = self._processor.process_checkpoint(
|
|
91
|
+
self._checkpoint_token, self._updates, self._client_token
|
|
92
|
+
)
|
|
93
|
+
return TaskOutcome(_status_after(worker), response)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class GetStateTask(ExecutionTask[StateOutput]):
|
|
97
|
+
"""Reads the current state for one execution."""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
processor: CheckpointProcessor,
|
|
102
|
+
checkpoint_token: str,
|
|
103
|
+
next_marker: str,
|
|
104
|
+
max_items: int = 1000,
|
|
105
|
+
) -> None:
|
|
106
|
+
self._processor: CheckpointProcessor = processor
|
|
107
|
+
self._checkpoint_token: str = checkpoint_token
|
|
108
|
+
self._next_marker: str = next_marker
|
|
109
|
+
self._max_items: int = max_items
|
|
110
|
+
|
|
111
|
+
def execute(
|
|
112
|
+
self,
|
|
113
|
+
status: InvocationState, # noqa: ARG002
|
|
114
|
+
worker: WorkerContext,
|
|
115
|
+
) -> TaskOutcome[StateOutput]:
|
|
116
|
+
response: StateOutput = self._processor.get_execution_state(
|
|
117
|
+
self._checkpoint_token, self._next_marker, self._max_items
|
|
118
|
+
)
|
|
119
|
+
return TaskOutcome(_status_after(worker), response)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""The per-execution serial worker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
6
|
+
|
|
7
|
+
from aws_durable_execution_sdk_python_testing.worker.lane import SerialTaskLane
|
|
8
|
+
from aws_durable_execution_sdk_python_testing.worker.status import InvocationState
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from concurrent.futures import Future
|
|
12
|
+
|
|
13
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
14
|
+
from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
|
|
15
|
+
from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
|
|
16
|
+
from aws_durable_execution_sdk_python_testing.worker.registry import (
|
|
17
|
+
ExecutionRegistry,
|
|
18
|
+
)
|
|
19
|
+
from aws_durable_execution_sdk_python_testing.worker.task import (
|
|
20
|
+
ExecutionTask,
|
|
21
|
+
TaskOutcome,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
T = TypeVar("T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExecutionWorker:
|
|
28
|
+
"""Runs every operation for one execution, one at a time.
|
|
29
|
+
|
|
30
|
+
Operations are submitted as :class:`ExecutionTask` instances and run
|
|
31
|
+
on a single lane, so they never overlap and the execution's state
|
|
32
|
+
stays consistent. The worker carries the invocation state returned by
|
|
33
|
+
each task into the next, and once an execution completes it removes
|
|
34
|
+
itself from the registry and stops its lane.
|
|
35
|
+
|
|
36
|
+
Build instances with :meth:`create`, which starts the worker's lane;
|
|
37
|
+
the constructor only initializes fields.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
execution_arn: str,
|
|
43
|
+
store: ExecutionStore,
|
|
44
|
+
scheduler: Scheduler,
|
|
45
|
+
registry: ExecutionRegistry,
|
|
46
|
+
lane: SerialTaskLane,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._arn: str = execution_arn
|
|
49
|
+
self._store: ExecutionStore = store
|
|
50
|
+
self._scheduler: Scheduler = scheduler
|
|
51
|
+
self._registry: ExecutionRegistry = registry
|
|
52
|
+
self._lane: SerialTaskLane = lane
|
|
53
|
+
self._status: InvocationState = InvocationState.PRE_INVOKE
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def create(
|
|
57
|
+
cls,
|
|
58
|
+
execution_arn: str,
|
|
59
|
+
store: ExecutionStore,
|
|
60
|
+
scheduler: Scheduler,
|
|
61
|
+
registry: ExecutionRegistry,
|
|
62
|
+
) -> ExecutionWorker:
|
|
63
|
+
"""Build a worker and start its lane."""
|
|
64
|
+
lane: SerialTaskLane = SerialTaskLane.create(
|
|
65
|
+
name=f"execution-worker-{execution_arn}"
|
|
66
|
+
)
|
|
67
|
+
return cls(execution_arn, store, scheduler, registry, lane)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def status(self) -> InvocationState:
|
|
71
|
+
"""The current invocation state.
|
|
72
|
+
|
|
73
|
+
Authoritative only when read on the lane (inside a task); reads
|
|
74
|
+
from other threads may observe a value about to change.
|
|
75
|
+
"""
|
|
76
|
+
return self._status
|
|
77
|
+
|
|
78
|
+
def set_status(self, status: InvocationState) -> None:
|
|
79
|
+
"""Set the invocation state directly.
|
|
80
|
+
|
|
81
|
+
Used by the checkpoint gate before the invoke lifecycle runs on
|
|
82
|
+
the lane; the lane otherwise threads status through task outcomes.
|
|
83
|
+
"""
|
|
84
|
+
self._status = status
|
|
85
|
+
|
|
86
|
+
def submit(self, task: ExecutionTask[T]) -> Future[T]:
|
|
87
|
+
"""Enqueue ``task`` on this worker's lane and return its future."""
|
|
88
|
+
return self._lane.submit(lambda: self._run(task))
|
|
89
|
+
|
|
90
|
+
def reload(self) -> Execution:
|
|
91
|
+
"""Load this execution's current state from the store."""
|
|
92
|
+
return self._store.load(self._arn)
|
|
93
|
+
|
|
94
|
+
def stop(self) -> None:
|
|
95
|
+
"""Stop this worker's lane without touching the registry.
|
|
96
|
+
|
|
97
|
+
Used by :meth:`ExecutionRegistry.shutdown`, which has already
|
|
98
|
+
dropped the worker from its map.
|
|
99
|
+
"""
|
|
100
|
+
self._lane.stop(wait=False)
|
|
101
|
+
|
|
102
|
+
def _run(self, task: ExecutionTask[T]) -> T:
|
|
103
|
+
outcome: TaskOutcome[T] = task.execute(self._status, self)
|
|
104
|
+
self._status = outcome.next_status
|
|
105
|
+
if self._status is InvocationState.COMPLETED:
|
|
106
|
+
self._teardown()
|
|
107
|
+
return outcome.value
|
|
108
|
+
|
|
109
|
+
def _teardown(self) -> None:
|
|
110
|
+
# Runs on the lane thread. Stop the lane first (without joining,
|
|
111
|
+
# which would wait on the current thread) so the stop flag is
|
|
112
|
+
# set before the worker leaves the registry. A caller that was
|
|
113
|
+
# handed this worker then fails fast on submit and the registry
|
|
114
|
+
# hands it a fresh lane, rather than enqueueing onto a lane that
|
|
115
|
+
# is about to exit.
|
|
116
|
+
self._lane.stop(wait=False)
|
|
117
|
+
self._registry.remove(self._arn)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""A serial task lane.
|
|
2
|
+
|
|
3
|
+
One daemon consumer thread drains a FIFO queue, running submitted
|
|
4
|
+
callables one at a time. :meth:`SerialTaskLane.submit` returns a
|
|
5
|
+
:class:`concurrent.futures.Future` the caller may block on (such as a
|
|
6
|
+
handler or HTTP thread) or ignore (such as a timer callback). Routing
|
|
7
|
+
every operation for one execution through a single lane makes them run
|
|
8
|
+
in submit order with no overlap, which keeps that execution's state
|
|
9
|
+
consistent when several callers act concurrently. Independent lanes run
|
|
10
|
+
in parallel.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import queue
|
|
17
|
+
import threading
|
|
18
|
+
from concurrent.futures import Future
|
|
19
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
|
|
24
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
T = TypeVar("T")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _Stop:
|
|
30
|
+
"""Sentinel enqueued by :meth:`SerialTaskLane.stop` to end the loop."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SerialTaskLane:
|
|
34
|
+
"""A single-consumer FIFO lane that runs callables serially.
|
|
35
|
+
|
|
36
|
+
Tasks submitted from any number of producer threads run one at a
|
|
37
|
+
time on a dedicated daemon thread, in the order they were enqueued.
|
|
38
|
+
An exception raised by one task is captured on its future and does
|
|
39
|
+
not stop the lane.
|
|
40
|
+
|
|
41
|
+
Build instances with :meth:`create`, which starts the consumer
|
|
42
|
+
thread; the constructor only initializes fields.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, name: str | None = None) -> None:
|
|
46
|
+
self._queue: queue.Queue[tuple[Callable[[], object], Future] | _Stop] = (
|
|
47
|
+
queue.Queue()
|
|
48
|
+
)
|
|
49
|
+
self._stopped: bool = False
|
|
50
|
+
self._stop_lock: threading.Lock = threading.Lock()
|
|
51
|
+
self._thread: threading.Thread = threading.Thread(
|
|
52
|
+
target=self._run,
|
|
53
|
+
name=name or f"serial-task-lane-{id(self):x}",
|
|
54
|
+
daemon=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create(cls, name: str | None = None) -> SerialTaskLane:
|
|
59
|
+
"""Build a lane and start its consumer thread."""
|
|
60
|
+
lane: SerialTaskLane = cls(name)
|
|
61
|
+
lane._thread.start() # noqa: SLF001 — own private member
|
|
62
|
+
return lane
|
|
63
|
+
|
|
64
|
+
def submit(self, fn: Callable[[], T]) -> Future[T]:
|
|
65
|
+
"""Enqueue ``fn`` to run on the lane and return its future.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
RuntimeError: If the lane has been stopped.
|
|
69
|
+
"""
|
|
70
|
+
future: Future[T] = Future()
|
|
71
|
+
with self._stop_lock:
|
|
72
|
+
if self._stopped:
|
|
73
|
+
msg: str = "SerialTaskLane is stopped"
|
|
74
|
+
raise RuntimeError(msg)
|
|
75
|
+
self._queue.put((fn, future))
|
|
76
|
+
return future
|
|
77
|
+
|
|
78
|
+
def _run(self) -> None:
|
|
79
|
+
while True:
|
|
80
|
+
item: tuple[Callable[[], object], Future] | _Stop = self._queue.get()
|
|
81
|
+
try:
|
|
82
|
+
if isinstance(item, _Stop):
|
|
83
|
+
return
|
|
84
|
+
fn, future = item
|
|
85
|
+
if not future.set_running_or_notify_cancel():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
result: object = fn()
|
|
89
|
+
except BaseException as exc: # noqa: BLE001 — relayed to the future
|
|
90
|
+
future.set_exception(exc)
|
|
91
|
+
else:
|
|
92
|
+
future.set_result(result)
|
|
93
|
+
finally:
|
|
94
|
+
# Drop references before blocking on the next get so a
|
|
95
|
+
# task's inputs/outputs are not pinned between tasks.
|
|
96
|
+
del item
|
|
97
|
+
self._queue.task_done()
|
|
98
|
+
|
|
99
|
+
def stop(self, *, wait: bool = True) -> None:
|
|
100
|
+
"""Stop the lane after draining queued tasks.
|
|
101
|
+
|
|
102
|
+
Tasks already enqueued run to completion before the lane exits.
|
|
103
|
+
Submitting after ``stop`` raises ``RuntimeError``.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
wait: Join the consumer thread before returning.
|
|
107
|
+
"""
|
|
108
|
+
with self._stop_lock:
|
|
109
|
+
if self._stopped:
|
|
110
|
+
return
|
|
111
|
+
self._stopped = True
|
|
112
|
+
self._queue.put(_Stop())
|
|
113
|
+
if wait:
|
|
114
|
+
self._thread.join()
|
|
115
|
+
|
|
116
|
+
def is_running(self) -> bool:
|
|
117
|
+
"""Return True while the consumer thread is alive."""
|
|
118
|
+
return self._thread.is_alive()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The registry of per-execution workers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python_testing.worker.execution_worker import (
|
|
9
|
+
ExecutionWorker,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from concurrent.futures import Future
|
|
14
|
+
|
|
15
|
+
from aws_durable_execution_sdk_python_testing.scheduler import Scheduler
|
|
16
|
+
from aws_durable_execution_sdk_python_testing.stores.base import ExecutionStore
|
|
17
|
+
from aws_durable_execution_sdk_python_testing.worker.task import ExecutionTask
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExecutionRegistry:
|
|
23
|
+
"""Owns one :class:`ExecutionWorker` per execution ARN.
|
|
24
|
+
|
|
25
|
+
Creates a worker the first time an execution is acted on, hands the
|
|
26
|
+
same worker out for every later operation on that execution, and
|
|
27
|
+
drops it once the execution completes. Lookups and mutations are
|
|
28
|
+
guarded so concurrent callers always resolve to the same worker for
|
|
29
|
+
a given ARN.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, store: ExecutionStore, scheduler: Scheduler) -> None:
|
|
33
|
+
self._store = store
|
|
34
|
+
self._scheduler = scheduler
|
|
35
|
+
self._workers: dict[str, ExecutionWorker] = {}
|
|
36
|
+
self._lock = threading.Lock()
|
|
37
|
+
self._shut_down: bool = False
|
|
38
|
+
|
|
39
|
+
# Submitting can lose a race with a worker tearing its own lane
|
|
40
|
+
# down (it stops the lane, then leaves the registry), so a
|
|
41
|
+
# handed-out worker may reject the submit. Evicting the dead worker
|
|
42
|
+
# and retrying resolves to a fresh lane, which is never stopped, so
|
|
43
|
+
# a couple of passes always suffice; the bound only guards against a
|
|
44
|
+
# pathological storm of completions.
|
|
45
|
+
_MAX_SUBMIT_ATTEMPTS: int = 5
|
|
46
|
+
|
|
47
|
+
def get_or_create(self, execution_arn: str) -> ExecutionWorker:
|
|
48
|
+
"""Return the worker for ``execution_arn``, creating it if absent.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
RuntimeError: After ``shutdown``. A lane created then would
|
|
52
|
+
outlive the runner that owned it.
|
|
53
|
+
"""
|
|
54
|
+
with self._lock:
|
|
55
|
+
if self._shut_down:
|
|
56
|
+
msg: str = "execution registry is shut down"
|
|
57
|
+
raise RuntimeError(msg)
|
|
58
|
+
worker: ExecutionWorker | None = self._workers.get(execution_arn)
|
|
59
|
+
if worker is None:
|
|
60
|
+
worker = ExecutionWorker.create(
|
|
61
|
+
execution_arn, self._store, self._scheduler, self
|
|
62
|
+
)
|
|
63
|
+
self._workers[execution_arn] = worker
|
|
64
|
+
return worker
|
|
65
|
+
|
|
66
|
+
def submit(self, execution_arn: str, task: ExecutionTask[T]) -> Future[T]:
|
|
67
|
+
"""Submit ``task`` to ``execution_arn``'s worker and return its
|
|
68
|
+
future.
|
|
69
|
+
|
|
70
|
+
Resolves the race where the worker tears its own lane down
|
|
71
|
+
between hand-out and submit: if the handed-out worker rejects
|
|
72
|
+
the submit, evict it and retry on a fresh worker. The task is
|
|
73
|
+
enqueued at most once, so a retry never double-runs it.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
RuntimeError: If a live lane could not be obtained within
|
|
77
|
+
``_MAX_SUBMIT_ATTEMPTS`` (not expected in practice).
|
|
78
|
+
"""
|
|
79
|
+
last_error: RuntimeError | None = None
|
|
80
|
+
for _ in range(self._MAX_SUBMIT_ATTEMPTS):
|
|
81
|
+
worker: ExecutionWorker = self.get_or_create(execution_arn)
|
|
82
|
+
try:
|
|
83
|
+
return worker.submit(task)
|
|
84
|
+
except RuntimeError as err:
|
|
85
|
+
last_error = err
|
|
86
|
+
self._evict_if_current(execution_arn, worker)
|
|
87
|
+
msg: str = "could not obtain a live worker lane"
|
|
88
|
+
raise RuntimeError(msg) from last_error
|
|
89
|
+
|
|
90
|
+
def get(self, execution_arn: str) -> ExecutionWorker | None:
|
|
91
|
+
"""Return the worker for ``execution_arn`` if one exists."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
return self._workers.get(execution_arn)
|
|
94
|
+
|
|
95
|
+
def remove(self, execution_arn: str) -> None:
|
|
96
|
+
"""Drop the worker for ``execution_arn`` if present."""
|
|
97
|
+
with self._lock:
|
|
98
|
+
self._workers.pop(execution_arn, None)
|
|
99
|
+
|
|
100
|
+
def active_count(self) -> int:
|
|
101
|
+
"""Return the number of live workers."""
|
|
102
|
+
with self._lock:
|
|
103
|
+
return len(self._workers)
|
|
104
|
+
|
|
105
|
+
def shutdown(self) -> None:
|
|
106
|
+
"""Stop every worker's lane and drop all workers.
|
|
107
|
+
|
|
108
|
+
Called when the owning runner shuts down so per-execution lane
|
|
109
|
+
threads do not outlive it: an execution that never reached a
|
|
110
|
+
terminal status still has a live lane, which this stops.
|
|
111
|
+
"""
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._shut_down = True
|
|
114
|
+
workers: list[ExecutionWorker] = list(self._workers.values())
|
|
115
|
+
self._workers.clear()
|
|
116
|
+
for worker in workers:
|
|
117
|
+
worker.stop()
|
|
118
|
+
|
|
119
|
+
def _evict_if_current(self, execution_arn: str, worker: ExecutionWorker) -> None:
|
|
120
|
+
"""Drop ``worker`` only if it is still the mapped worker, so a
|
|
121
|
+
replacement created by another thread is not clobbered."""
|
|
122
|
+
with self._lock:
|
|
123
|
+
if self._workers.get(execution_arn) is worker:
|
|
124
|
+
del self._workers[execution_arn]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Per-execution handler-invocation state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvocationState(Enum):
|
|
9
|
+
"""Tracks whether a handler is currently running for one execution.
|
|
10
|
+
|
|
11
|
+
Held in memory per execution and never persisted: a persisted
|
|
12
|
+
``INVOKING`` state could be read back after a crash as "a handler is
|
|
13
|
+
running" when none is, stranding the execution behind the gate.
|
|
14
|
+
|
|
15
|
+
Transitions:
|
|
16
|
+
|
|
17
|
+
* ``PRE_INVOKE`` -> ``INVOKING`` when a handler call is dispatched.
|
|
18
|
+
* ``INVOKING`` -> ``PRE_INVOKE`` when a handler returns ``PENDING``
|
|
19
|
+
or fails; a retry re-enters ``PRE_INVOKE`` -> ``INVOKING``.
|
|
20
|
+
* ``INVOKING`` -> ``COMPLETED`` when the execution reaches a terminal
|
|
21
|
+
status (handler returned ``SUCCEEDED`` / ``FAILED``, a stop was
|
|
22
|
+
requested, or the invocation timed out).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
PRE_INVOKE = "PRE_INVOKE"
|
|
26
|
+
INVOKING = "INVOKING"
|
|
27
|
+
COMPLETED = "COMPLETED"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The execution-task contract and its outcome."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING, Generic, Protocol, TypeVar
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
11
|
+
from aws_durable_execution_sdk_python_testing.worker.status import InvocationState
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkerContext(Protocol):
|
|
17
|
+
"""The view of its worker that a task is given while it runs.
|
|
18
|
+
|
|
19
|
+
Kept minimal (and defined here, below the task it serves) so the
|
|
20
|
+
task module does not depend on the concrete worker, avoiding a
|
|
21
|
+
circular import.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def status(self) -> InvocationState: ... # pragma: no cover
|
|
26
|
+
|
|
27
|
+
def reload(self) -> Execution: ... # pragma: no cover
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class TaskOutcome(Generic[T]):
|
|
32
|
+
"""What a task produced: the invocation state to carry forward and
|
|
33
|
+
the value to return to the task's caller.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
next_status: InvocationState
|
|
37
|
+
value: T
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExecutionTask(ABC, Generic[T]):
|
|
41
|
+
"""A single operation against one execution, run on its worker.
|
|
42
|
+
|
|
43
|
+
A task runs with exclusive access to its execution's state for the
|
|
44
|
+
duration of :meth:`execute` (the worker runs one at a time), so it
|
|
45
|
+
may load, read, and mutate the execution without further
|
|
46
|
+
synchronization. It receives the current invocation state and returns
|
|
47
|
+
a :class:`TaskOutcome` carrying the next state and the value to
|
|
48
|
+
deliver to the caller.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def execute(
|
|
53
|
+
self,
|
|
54
|
+
status: InvocationState,
|
|
55
|
+
worker: WorkerContext,
|
|
56
|
+
) -> TaskOutcome[T]:
|
|
57
|
+
"""Run the operation and return its outcome."""
|