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
@@ -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."""