aws-durable-execution-sdk-python-testing 1.0.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 +4 -0
- aws_durable_execution_sdk_python_testing/__init__.py +20 -0
- aws_durable_execution_sdk_python_testing/checkpoint/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processor.py +101 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/base.py +199 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/callback.py +89 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/context.py +59 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/execution.py +52 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/step.py +124 -0
- aws_durable_execution_sdk_python_testing/checkpoint/processors/wait.py +95 -0
- aws_durable_execution_sdk_python_testing/checkpoint/transformer.py +104 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/checkpoint.py +242 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/callback.py +45 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/context.py +73 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/execution.py +47 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/invoke.py +56 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/step.py +106 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/operations/wait.py +54 -0
- aws_durable_execution_sdk_python_testing/checkpoint/validators/transitions.py +66 -0
- aws_durable_execution_sdk_python_testing/cli.py +501 -0
- aws_durable_execution_sdk_python_testing/client.py +50 -0
- aws_durable_execution_sdk_python_testing/exceptions.py +288 -0
- aws_durable_execution_sdk_python_testing/execution.py +440 -0
- aws_durable_execution_sdk_python_testing/executor.py +1214 -0
- aws_durable_execution_sdk_python_testing/invoker.py +327 -0
- aws_durable_execution_sdk_python_testing/model.py +3267 -0
- aws_durable_execution_sdk_python_testing/observer.py +144 -0
- aws_durable_execution_sdk_python_testing/py.typed +1 -0
- aws_durable_execution_sdk_python_testing/runner.py +1185 -0
- aws_durable_execution_sdk_python_testing/scheduler.py +246 -0
- aws_durable_execution_sdk_python_testing/stores/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/stores/base.py +147 -0
- aws_durable_execution_sdk_python_testing/stores/filesystem.py +104 -0
- aws_durable_execution_sdk_python_testing/stores/memory.py +38 -0
- aws_durable_execution_sdk_python_testing/stores/sqlite.py +283 -0
- aws_durable_execution_sdk_python_testing/token.py +49 -0
- aws_durable_execution_sdk_python_testing/web/__init__.py +1 -0
- aws_durable_execution_sdk_python_testing/web/errors.py +8 -0
- aws_durable_execution_sdk_python_testing/web/handlers.py +807 -0
- aws_durable_execution_sdk_python_testing/web/models.py +266 -0
- aws_durable_execution_sdk_python_testing/web/routes.py +684 -0
- aws_durable_execution_sdk_python_testing/web/serialization.py +245 -0
- aws_durable_execution_sdk_python_testing/web/server.py +243 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/METADATA +221 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/RECORD +52 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/WHEEL +4 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/entry_points.txt +2 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/LICENSE +175 -0
- aws_durable_execution_sdk_python_testing-1.0.0.dist-info/licenses/NOTICE +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""A Scheduler that can run awaitables or standard sync callables on a schedule once or repeatedly."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import itertools
|
|
7
|
+
import logging
|
|
8
|
+
import threading
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from concurrent.futures import Future
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Event:
|
|
20
|
+
"""An event created by Scheduler that will block on wait until it's set."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, scheduler: Scheduler, asyncio_event: asyncio.Event) -> None:
|
|
23
|
+
self._scheduler: Scheduler = scheduler
|
|
24
|
+
self._asyncio_event: asyncio.Event = asyncio_event
|
|
25
|
+
self._exception: Exception | None = None
|
|
26
|
+
|
|
27
|
+
def set(self):
|
|
28
|
+
"""Set the event with this to unblock wait."""
|
|
29
|
+
self._scheduler.set_event(self._asyncio_event)
|
|
30
|
+
|
|
31
|
+
def set_exception(self, exception: Exception):
|
|
32
|
+
"""Set exception and unblock waiters."""
|
|
33
|
+
self._exception = exception
|
|
34
|
+
self._scheduler.set_event(self._asyncio_event)
|
|
35
|
+
|
|
36
|
+
def wait(self, timeout: float | None = None, *, clear_on_set: bool = True) -> bool:
|
|
37
|
+
"""Wait until the event is set.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
timeout (int | float | None): Wait for event to set until this timeout.
|
|
41
|
+
clear_on_set (bool): Remove the event from the Scheduler on completion.
|
|
42
|
+
Use this if you won't re-use the event.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True when set. False if the event timed out without being set.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
Exception: If an exception was stored via set_exception().
|
|
49
|
+
"""
|
|
50
|
+
result = self._scheduler.wait_for_event(self._asyncio_event, timeout)
|
|
51
|
+
if clear_on_set:
|
|
52
|
+
self._scheduler.remove_event(self._asyncio_event)
|
|
53
|
+
if result and self._exception:
|
|
54
|
+
raise self._exception
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
def remove(self):
|
|
58
|
+
"""Remove the event from the Scheduler. Do this to avoid build-up of many events in the scheduler."""
|
|
59
|
+
self._scheduler.remove_event(self._asyncio_event)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Scheduler:
|
|
63
|
+
"""A Scheduler to run callables later, repeatedly or raise events."""
|
|
64
|
+
|
|
65
|
+
def __init__(self) -> None:
|
|
66
|
+
self._loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
|
|
67
|
+
self._ready_event: threading.Event = threading.Event()
|
|
68
|
+
self._thread: threading.Thread = threading.Thread(
|
|
69
|
+
target=self._start_loop, daemon=True
|
|
70
|
+
)
|
|
71
|
+
self._running: bool = False
|
|
72
|
+
self._events: set[asyncio.Event] = set()
|
|
73
|
+
|
|
74
|
+
# region context manager
|
|
75
|
+
def __enter__(self):
|
|
76
|
+
self.start()
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
80
|
+
self.stop()
|
|
81
|
+
|
|
82
|
+
# endregion context manager
|
|
83
|
+
|
|
84
|
+
# region event loop
|
|
85
|
+
def start(self):
|
|
86
|
+
"""Start the scheduler. Not thread-safe."""
|
|
87
|
+
if self._running:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
self._running = True
|
|
91
|
+
|
|
92
|
+
self._thread.start()
|
|
93
|
+
# Wait for inside of loop to notify it's ready (meaning _start_loop has completed)
|
|
94
|
+
self._ready_event.wait()
|
|
95
|
+
|
|
96
|
+
def stop(self):
|
|
97
|
+
"""Stop the scheduler, releasing resources. Not thread-safe."""
|
|
98
|
+
if not self._running:
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
self._running = False
|
|
102
|
+
self._loop.call_soon_threadsafe(self._cleanup_and_stop)
|
|
103
|
+
self._thread.join()
|
|
104
|
+
|
|
105
|
+
def is_started(self) -> bool:
|
|
106
|
+
"""Return True if the scheduler is started."""
|
|
107
|
+
return self._running
|
|
108
|
+
|
|
109
|
+
def event_count(self) -> int:
|
|
110
|
+
"""Return the number of events in the scheduler."""
|
|
111
|
+
return len(self._events)
|
|
112
|
+
|
|
113
|
+
def task_count(self) -> int:
|
|
114
|
+
"""Return the number of tasks in the scheduler."""
|
|
115
|
+
if not self._running:
|
|
116
|
+
return 0
|
|
117
|
+
return len(asyncio.all_tasks(self._loop))
|
|
118
|
+
|
|
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):
|
|
123
|
+
task.cancel()
|
|
124
|
+
|
|
125
|
+
# Clear events (don't set them)
|
|
126
|
+
self._events.clear()
|
|
127
|
+
|
|
128
|
+
self._loop.stop()
|
|
129
|
+
|
|
130
|
+
def _start_loop(self):
|
|
131
|
+
"""Initialize the event-loop. The ready event notifies that the loop is started."""
|
|
132
|
+
asyncio.set_event_loop(self._loop)
|
|
133
|
+
# signal that loop is ready from within the loop
|
|
134
|
+
self._loop.call_soon(self._ready_event.set)
|
|
135
|
+
# block indefinitely - call_soon with the read_event will run soon as the loop starts
|
|
136
|
+
self._loop.run_forever()
|
|
137
|
+
|
|
138
|
+
# endregion event loop
|
|
139
|
+
# region Tasks
|
|
140
|
+
def call_later(
|
|
141
|
+
self,
|
|
142
|
+
func: Callable[[], Any],
|
|
143
|
+
delay: float = 0,
|
|
144
|
+
count: int | None = 1,
|
|
145
|
+
completion_event: Event | None = None,
|
|
146
|
+
) -> Future[Any]:
|
|
147
|
+
"""Call func after the delay.
|
|
148
|
+
|
|
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.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
func (Callable[[], Any]): The function to call later. This can be an async or a standard
|
|
154
|
+
sync function.
|
|
155
|
+
delay (float | int): Delay in seconds before calling func.
|
|
156
|
+
count (int | None): Number of times to call func. Default is 1 (call once).
|
|
157
|
+
Use None for infinite repeats.
|
|
158
|
+
completion_event (Event | None): Event to notify on exception.
|
|
159
|
+
|
|
160
|
+
Returns: Future that completes when the scheduled work is done.
|
|
161
|
+
"""
|
|
162
|
+
# infinite counter if count = None, else it maxes out at count
|
|
163
|
+
loop_iter: itertools.count[int] | range = (
|
|
164
|
+
itertools.count() if count is None else range(count)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
async def delayed_func() -> Any:
|
|
168
|
+
try:
|
|
169
|
+
for _ in loop_iter:
|
|
170
|
+
await asyncio.sleep(delay)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
if asyncio.iscoroutinefunction(func):
|
|
174
|
+
result = await func()
|
|
175
|
+
else:
|
|
176
|
+
result = await asyncio.to_thread(func)
|
|
177
|
+
return result # noqa: TRY300
|
|
178
|
+
except Exception as err:
|
|
179
|
+
if completion_event:
|
|
180
|
+
completion_event.set_exception(err)
|
|
181
|
+
else:
|
|
182
|
+
msg: str = "error in scheduled task"
|
|
183
|
+
logger.exception(msg)
|
|
184
|
+
raise
|
|
185
|
+
except asyncio.CancelledError: # noqa: TRY302
|
|
186
|
+
# might want to handle more things here
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
future: Future[Any] = asyncio.run_coroutine_threadsafe(
|
|
190
|
+
delayed_func(), self._loop
|
|
191
|
+
)
|
|
192
|
+
return future
|
|
193
|
+
|
|
194
|
+
# endregion Tasks
|
|
195
|
+
|
|
196
|
+
# region Events
|
|
197
|
+
|
|
198
|
+
def create_event(self) -> Event:
|
|
199
|
+
"""Create an event controlled by the Scheduler to signal between threads and coroutines."""
|
|
200
|
+
# create event inside the Scheduler event-loop
|
|
201
|
+
future: Future[asyncio.Event] = asyncio.run_coroutine_threadsafe(
|
|
202
|
+
self._create_event(), self._loop
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Add timeout to prevent surprising "hangs" if for whatever reason event fails to create.
|
|
206
|
+
# result with block. Do NOT call anything in _create_event that calls back into scheduler
|
|
207
|
+
# methods because it could create a circular depdendency which will deadlock.
|
|
208
|
+
event = future.result(timeout=5.0)
|
|
209
|
+
return Event(self, event)
|
|
210
|
+
|
|
211
|
+
def wait_for_event(
|
|
212
|
+
self, event: asyncio.Event, timeout: float | None = None
|
|
213
|
+
) -> bool:
|
|
214
|
+
"""Run event's wait inside the Scheduler event-loop."""
|
|
215
|
+
if event not in self._events:
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
future: Future[bool] = asyncio.run_coroutine_threadsafe(
|
|
219
|
+
asyncio.wait_for(event.wait(), timeout), self._loop
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
return future.result()
|
|
224
|
+
except TimeoutError:
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def set_event(self, event: asyncio.Event):
|
|
228
|
+
"""Set event inside the Scheduler event-loop."""
|
|
229
|
+
if event in self._events:
|
|
230
|
+
self._loop.call_soon_threadsafe(event.set)
|
|
231
|
+
|
|
232
|
+
def remove_event(self, event: asyncio.Event):
|
|
233
|
+
"""Remove event from Scheduler in the Scheduler event-loop."""
|
|
234
|
+
|
|
235
|
+
def _remove():
|
|
236
|
+
self._events.discard(event)
|
|
237
|
+
|
|
238
|
+
self._loop.call_soon_threadsafe(_remove)
|
|
239
|
+
|
|
240
|
+
async def _create_event(self) -> asyncio.Event:
|
|
241
|
+
"""Create event and add it to the scheduler events list."""
|
|
242
|
+
event = asyncio.Event()
|
|
243
|
+
self._events.add(event)
|
|
244
|
+
return event
|
|
245
|
+
|
|
246
|
+
# endregion Events
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Base classes and protocols for execution stores."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import UTC
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import TYPE_CHECKING, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from aws_durable_execution_sdk_python.lambda_service import Operation
|
|
12
|
+
|
|
13
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StoreType(Enum):
|
|
17
|
+
"""Supported execution store types."""
|
|
18
|
+
|
|
19
|
+
MEMORY = "memory"
|
|
20
|
+
FILESYSTEM = "filesystem"
|
|
21
|
+
SQLITE = "sqlite"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExecutionStore(Protocol):
|
|
25
|
+
"""Protocol for execution storage implementations."""
|
|
26
|
+
|
|
27
|
+
# ignore cover because coverage doesn't understand elipses
|
|
28
|
+
def save(self, execution: Execution) -> None: ... # pragma: no cover
|
|
29
|
+
def load(self, execution_arn: str) -> Execution: ... # pragma: no cover
|
|
30
|
+
def update(self, execution: Execution) -> None: ... # pragma: no cover
|
|
31
|
+
def query(
|
|
32
|
+
self,
|
|
33
|
+
function_name: str | None = None,
|
|
34
|
+
execution_name: str | None = None,
|
|
35
|
+
status_filter: str | None = None,
|
|
36
|
+
started_after: str | None = None,
|
|
37
|
+
started_before: str | None = None,
|
|
38
|
+
limit: int | None = None,
|
|
39
|
+
offset: int = 0,
|
|
40
|
+
reverse_order: bool = False, # noqa: FBT001, FBT002
|
|
41
|
+
) -> tuple[list[Execution], str | None]: ... # pragma: no cover
|
|
42
|
+
def list_all(
|
|
43
|
+
self,
|
|
44
|
+
) -> list[Execution]: ... # pragma: no cover # Keep for backward compatibility
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseExecutionStore(ExecutionStore):
|
|
48
|
+
"""Base implementation for execution stores with shared query logic."""
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def process_query(
|
|
52
|
+
executions: list[Execution],
|
|
53
|
+
function_name: str | None = None,
|
|
54
|
+
execution_name: str | None = None,
|
|
55
|
+
status_filter: str | None = None,
|
|
56
|
+
started_after: str | None = None,
|
|
57
|
+
started_before: str | None = None,
|
|
58
|
+
limit: int | None = None,
|
|
59
|
+
offset: int = 0,
|
|
60
|
+
reverse_order: bool = False, # noqa: FBT001, FBT002
|
|
61
|
+
) -> tuple[list[Execution], str | None]:
|
|
62
|
+
"""Apply filtering, sorting, and pagination to executions."""
|
|
63
|
+
# Apply filters
|
|
64
|
+
filtered: list[Execution] = []
|
|
65
|
+
for execution in executions:
|
|
66
|
+
if function_name and execution.start_input.function_name != function_name:
|
|
67
|
+
continue
|
|
68
|
+
if (
|
|
69
|
+
execution_name
|
|
70
|
+
and execution.start_input.execution_name != execution_name
|
|
71
|
+
):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Status filtering
|
|
75
|
+
if status_filter and execution.current_status().value != status_filter:
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Time filtering
|
|
79
|
+
if started_after or started_before:
|
|
80
|
+
try:
|
|
81
|
+
operation: Operation = execution.get_operation_execution_started()
|
|
82
|
+
if operation.start_timestamp:
|
|
83
|
+
timestamp: float = (
|
|
84
|
+
operation.start_timestamp.timestamp()
|
|
85
|
+
if hasattr(operation.start_timestamp, "timestamp")
|
|
86
|
+
else operation.start_timestamp.replace(
|
|
87
|
+
tzinfo=UTC
|
|
88
|
+
).timestamp()
|
|
89
|
+
)
|
|
90
|
+
if started_after and timestamp < float(started_after):
|
|
91
|
+
continue
|
|
92
|
+
if started_before and timestamp > float(started_before):
|
|
93
|
+
continue
|
|
94
|
+
except (ValueError, AttributeError):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
filtered.append(execution)
|
|
98
|
+
|
|
99
|
+
# Sort by start timestamp
|
|
100
|
+
def get_sort_key(exe: Execution):
|
|
101
|
+
try:
|
|
102
|
+
op: Operation = exe.get_operation_execution_started()
|
|
103
|
+
if op.start_timestamp:
|
|
104
|
+
return (
|
|
105
|
+
op.start_timestamp.timestamp()
|
|
106
|
+
if hasattr(op.start_timestamp, "timestamp")
|
|
107
|
+
else op.start_timestamp.replace(tzinfo=UTC).timestamp()
|
|
108
|
+
)
|
|
109
|
+
except Exception: # noqa: BLE001, S110
|
|
110
|
+
pass
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
filtered.sort(key=get_sort_key, reverse=reverse_order)
|
|
114
|
+
|
|
115
|
+
# Apply pagination
|
|
116
|
+
if limit is not None and limit > 0:
|
|
117
|
+
end_idx: int = offset + limit
|
|
118
|
+
paginated: list[Execution] = filtered[offset:end_idx]
|
|
119
|
+
has_more: bool = end_idx < len(filtered)
|
|
120
|
+
next_marker: str | None = str(end_idx) if has_more else None
|
|
121
|
+
return paginated, next_marker
|
|
122
|
+
return filtered[offset:], None
|
|
123
|
+
|
|
124
|
+
def query(
|
|
125
|
+
self,
|
|
126
|
+
function_name: str | None = None,
|
|
127
|
+
execution_name: str | None = None,
|
|
128
|
+
status_filter: str | None = None,
|
|
129
|
+
started_after: str | None = None,
|
|
130
|
+
started_before: str | None = None,
|
|
131
|
+
limit: int | None = None,
|
|
132
|
+
offset: int = 0,
|
|
133
|
+
reverse_order: bool = False, # noqa: FBT001, FBT002
|
|
134
|
+
) -> tuple[list[Execution], str | None]:
|
|
135
|
+
"""Apply filtering, sorting, and pagination to executions."""
|
|
136
|
+
executions: list[Execution] = self.list_all()
|
|
137
|
+
return self.process_query(
|
|
138
|
+
executions,
|
|
139
|
+
function_name=function_name,
|
|
140
|
+
execution_name=execution_name,
|
|
141
|
+
status_filter=status_filter,
|
|
142
|
+
started_after=started_after,
|
|
143
|
+
started_before=started_before,
|
|
144
|
+
limit=limit,
|
|
145
|
+
offset=offset,
|
|
146
|
+
reverse_order=reverse_order,
|
|
147
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""File system-based execution store implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from aws_durable_execution_sdk_python_testing.exceptions import (
|
|
11
|
+
ResourceNotFoundException,
|
|
12
|
+
)
|
|
13
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
14
|
+
from aws_durable_execution_sdk_python_testing.stores.base import (
|
|
15
|
+
BaseExecutionStore,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DateTimeEncoder(json.JSONEncoder):
|
|
20
|
+
"""Custom JSON encoder that handles datetime objects."""
|
|
21
|
+
|
|
22
|
+
def default(self, obj):
|
|
23
|
+
if isinstance(obj, datetime):
|
|
24
|
+
return obj.timestamp()
|
|
25
|
+
return super().default(obj)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def datetime_object_hook(obj):
|
|
29
|
+
"""JSON object hook to convert unix timestamps back to datetime objects."""
|
|
30
|
+
if isinstance(obj, dict):
|
|
31
|
+
for key, value in obj.items():
|
|
32
|
+
if isinstance(value, int | float) and key.endswith(
|
|
33
|
+
("_timestamp", "_time", "Timestamp", "Time")
|
|
34
|
+
):
|
|
35
|
+
try: # noqa: SIM105
|
|
36
|
+
obj[key] = datetime.fromtimestamp(value, tz=UTC)
|
|
37
|
+
except (ValueError, OSError):
|
|
38
|
+
# Leave as number if not a valid timestamp
|
|
39
|
+
pass
|
|
40
|
+
return obj
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FileSystemExecutionStore(BaseExecutionStore):
|
|
44
|
+
"""File system-based execution store for persistence."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, storage_dir: Path) -> None:
|
|
47
|
+
self._storage_dir = storage_dir
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def create(cls, storage_dir: str | Path | None = None) -> FileSystemExecutionStore:
|
|
51
|
+
"""Create a FileSystemExecutionStore with directory creation.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
storage_dir: Directory path for storage. Defaults to '.durable_executions'
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
FileSystemExecutionStore instance with created directory
|
|
58
|
+
"""
|
|
59
|
+
path = Path(storage_dir) if storage_dir else Path(".durable_executions")
|
|
60
|
+
path.mkdir(exist_ok=True)
|
|
61
|
+
return cls(storage_dir=path)
|
|
62
|
+
|
|
63
|
+
def _get_file_path(self, execution_arn: str) -> Path:
|
|
64
|
+
"""Get file path for execution ARN."""
|
|
65
|
+
# Use ARN as filename with .json extension, replacing unsafe characters
|
|
66
|
+
safe_filename = execution_arn.replace(":", "_").replace("/", "_")
|
|
67
|
+
return self._storage_dir / f"{safe_filename}.json"
|
|
68
|
+
|
|
69
|
+
def save(self, execution: Execution) -> None:
|
|
70
|
+
"""Save execution to file system."""
|
|
71
|
+
file_path = self._get_file_path(execution.durable_execution_arn)
|
|
72
|
+
data = execution.to_dict()
|
|
73
|
+
|
|
74
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
75
|
+
json.dump(data, f, indent=2, cls=DateTimeEncoder)
|
|
76
|
+
|
|
77
|
+
def load(self, execution_arn: str) -> Execution:
|
|
78
|
+
"""Load execution from file system."""
|
|
79
|
+
file_path = self._get_file_path(execution_arn)
|
|
80
|
+
if not file_path.exists():
|
|
81
|
+
msg = f"Execution {execution_arn} not found"
|
|
82
|
+
raise ResourceNotFoundException(msg)
|
|
83
|
+
|
|
84
|
+
with open(file_path, encoding="utf-8") as f:
|
|
85
|
+
data = json.load(f, object_hook=datetime_object_hook)
|
|
86
|
+
|
|
87
|
+
return Execution.from_dict(data)
|
|
88
|
+
|
|
89
|
+
def update(self, execution: Execution) -> None:
|
|
90
|
+
"""Update execution in file system (same as save)."""
|
|
91
|
+
self.save(execution)
|
|
92
|
+
|
|
93
|
+
def list_all(self) -> list[Execution]:
|
|
94
|
+
"""List all executions from file system."""
|
|
95
|
+
executions = []
|
|
96
|
+
for file_path in self._storage_dir.glob("*.json"):
|
|
97
|
+
try:
|
|
98
|
+
with open(file_path, encoding="utf-8") as f:
|
|
99
|
+
data = json.load(f, object_hook=datetime_object_hook)
|
|
100
|
+
executions.append(Execution.from_dict(data))
|
|
101
|
+
except (json.JSONDecodeError, KeyError, OSError) as e:
|
|
102
|
+
logging.warning("Skipping corrupted file %s: %s", file_path, e)
|
|
103
|
+
continue
|
|
104
|
+
return executions
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""In-memory execution store implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from threading import Lock
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from aws_durable_execution_sdk_python_testing.stores.base import (
|
|
9
|
+
BaseExecutionStore,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from aws_durable_execution_sdk_python_testing.execution import Execution
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InMemoryExecutionStore(BaseExecutionStore):
|
|
18
|
+
"""Dict-based storage for testing."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._store: dict[str, Execution] = {}
|
|
22
|
+
self._lock: Lock = Lock()
|
|
23
|
+
|
|
24
|
+
def save(self, execution: Execution) -> None:
|
|
25
|
+
with self._lock:
|
|
26
|
+
self._store[execution.durable_execution_arn] = execution
|
|
27
|
+
|
|
28
|
+
def load(self, execution_arn: str) -> Execution:
|
|
29
|
+
with self._lock:
|
|
30
|
+
return self._store[execution_arn]
|
|
31
|
+
|
|
32
|
+
def update(self, execution: Execution) -> None:
|
|
33
|
+
with self._lock:
|
|
34
|
+
self._store[execution.durable_execution_arn] = execution
|
|
35
|
+
|
|
36
|
+
def list_all(self) -> list[Execution]:
|
|
37
|
+
with self._lock:
|
|
38
|
+
return list(self._store.values())
|