functualize-state-sqlite 0.1.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.
- functualize_state_sqlite/__init__.py +13 -0
- functualize_state_sqlite/_backend.py +160 -0
- functualize_state_sqlite/_execution_store.py +379 -0
- functualize_state_sqlite/_migrations.py +157 -0
- functualize_state_sqlite/_plugin.py +205 -0
- functualize_state_sqlite/plugin.py +345 -0
- functualize_state_sqlite/py.typed +0 -0
- functualize_state_sqlite/sqlite_backend.py +721 -0
- functualize_state_sqlite/state_store.py +178 -0
- functualize_state_sqlite/tracker.py +318 -0
- functualize_state_sqlite-0.1.0.dist-info/METADATA +88 -0
- functualize_state_sqlite-0.1.0.dist-info/RECORD +14 -0
- functualize_state_sqlite-0.1.0.dist-info/WHEEL +4 -0
- functualize_state_sqlite-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""SQLite backend for execution state persistence.
|
|
2
|
+
|
|
3
|
+
Manages the SQLite database connection with WAL mode for concurrent read
|
|
4
|
+
performance, initializes the schema, and provides query helpers for the
|
|
5
|
+
execution tracking plugin.
|
|
6
|
+
|
|
7
|
+
The database is stored at `.functualize/execution.db` relative to the
|
|
8
|
+
configured base path (defaults to CWD). The directory is created if it
|
|
9
|
+
does not exist.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import sqlite3
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
__all__ = ["SQLiteBackend"]
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Schema version for future migration support
|
|
26
|
+
_SCHEMA_VERSION = 1
|
|
27
|
+
|
|
28
|
+
_SCHEMA_SQL = """\
|
|
29
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
30
|
+
session_id TEXT PRIMARY KEY,
|
|
31
|
+
scope_id TEXT NOT NULL,
|
|
32
|
+
created_at REAL,
|
|
33
|
+
updated_at REAL,
|
|
34
|
+
workflow_name TEXT,
|
|
35
|
+
status TEXT DEFAULT 'running',
|
|
36
|
+
metadata_json TEXT DEFAULT '{}'
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS executions (
|
|
40
|
+
execution_uid TEXT PRIMARY KEY,
|
|
41
|
+
session_id TEXT REFERENCES sessions(session_id),
|
|
42
|
+
job_name TEXT NOT NULL,
|
|
43
|
+
started_at REAL,
|
|
44
|
+
ended_at REAL,
|
|
45
|
+
duration_ms REAL,
|
|
46
|
+
status TEXT DEFAULT 'running',
|
|
47
|
+
kwargs_json TEXT,
|
|
48
|
+
result_json TEXT,
|
|
49
|
+
error_message TEXT,
|
|
50
|
+
error_type TEXT,
|
|
51
|
+
parent_uid TEXT,
|
|
52
|
+
depth INTEGER DEFAULT 0
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
CREATE TABLE IF NOT EXISTS execution_steps (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
execution_uid TEXT REFERENCES executions(execution_uid),
|
|
58
|
+
step_name TEXT,
|
|
59
|
+
status TEXT,
|
|
60
|
+
message TEXT,
|
|
61
|
+
started_at REAL,
|
|
62
|
+
ended_at REAL,
|
|
63
|
+
duration_ms REAL
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
CREATE TABLE IF NOT EXISTS state (
|
|
67
|
+
scope_id TEXT,
|
|
68
|
+
job_namespace TEXT,
|
|
69
|
+
key TEXT,
|
|
70
|
+
value_json TEXT,
|
|
71
|
+
updated_at REAL,
|
|
72
|
+
PRIMARY KEY (scope_id, job_namespace, key)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_executions_session
|
|
76
|
+
ON executions(session_id);
|
|
77
|
+
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_executions_job_name
|
|
79
|
+
ON executions(job_name);
|
|
80
|
+
|
|
81
|
+
CREATE INDEX IF NOT EXISTS idx_execution_steps_uid
|
|
82
|
+
ON execution_steps(execution_uid);
|
|
83
|
+
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_state_scope
|
|
85
|
+
ON state(scope_id, job_namespace);
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SQLiteBackend:
|
|
90
|
+
"""SQLite connection manager with WAL mode and schema initialization.
|
|
91
|
+
|
|
92
|
+
Provides a single-connection approach suitable for the plugin's
|
|
93
|
+
synchronous execution model. WAL mode enables concurrent reads
|
|
94
|
+
without blocking writes.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
db_path: Path to the SQLite database file. If None, defaults to
|
|
98
|
+
`.functualize/execution.db` relative to cwd.
|
|
99
|
+
base_dir: Base directory for relative db_path resolution. Defaults
|
|
100
|
+
to the current working directory.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
db_path: str | Path | None = None,
|
|
106
|
+
base_dir: str | Path | None = None,
|
|
107
|
+
) -> None:
|
|
108
|
+
base_dir = Path.cwd() if base_dir is None else Path(base_dir)
|
|
109
|
+
|
|
110
|
+
if db_path is None:
|
|
111
|
+
self._db_path = base_dir / ".functualize" / "execution.db"
|
|
112
|
+
else:
|
|
113
|
+
db_path = Path(db_path)
|
|
114
|
+
if not db_path.is_absolute():
|
|
115
|
+
self._db_path = base_dir / db_path
|
|
116
|
+
else:
|
|
117
|
+
self._db_path = db_path
|
|
118
|
+
|
|
119
|
+
self._conn: sqlite3.Connection | None = None
|
|
120
|
+
self._initialized = False
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def db_path(self) -> Path:
|
|
124
|
+
"""The resolved path to the database file."""
|
|
125
|
+
return self._db_path
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_initialized(self) -> bool:
|
|
129
|
+
"""Whether the backend has been initialized (schema created)."""
|
|
130
|
+
return self._initialized
|
|
131
|
+
|
|
132
|
+
def initialize(self) -> None:
|
|
133
|
+
"""Create the database directory, open connection, enable WAL, and run schema.
|
|
134
|
+
|
|
135
|
+
This method is idempotent — calling it multiple times is safe.
|
|
136
|
+
"""
|
|
137
|
+
if self._initialized:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
# Ensure the directory exists
|
|
141
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
|
|
143
|
+
# Open connection and configure
|
|
144
|
+
self._conn = self._create_connection()
|
|
145
|
+
self._enable_wal()
|
|
146
|
+
self._create_schema()
|
|
147
|
+
self._initialized = True
|
|
148
|
+
logger.debug("SQLiteBackend initialized at %s", self._db_path)
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
"""Close the database connection."""
|
|
152
|
+
if self._conn is not None:
|
|
153
|
+
try:
|
|
154
|
+
self._conn.close()
|
|
155
|
+
except sqlite3.Error as e:
|
|
156
|
+
logger.warning("Error closing SQLite connection: %s", e)
|
|
157
|
+
finally:
|
|
158
|
+
self._conn = None
|
|
159
|
+
self._initialized = False
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def connection(self) -> sqlite3.Connection:
|
|
163
|
+
"""Get the active database connection.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
RuntimeError: If the backend has not been initialized.
|
|
167
|
+
"""
|
|
168
|
+
if self._conn is None:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"SQLiteBackend not initialized. Call initialize() first."
|
|
171
|
+
)
|
|
172
|
+
return self._conn
|
|
173
|
+
|
|
174
|
+
# ─── Query Helpers ────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def execute(
|
|
177
|
+
self,
|
|
178
|
+
sql: str,
|
|
179
|
+
params: tuple[Any, ...] | dict[str, Any] = (),
|
|
180
|
+
) -> sqlite3.Cursor:
|
|
181
|
+
"""Execute a SQL statement with error handling.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
sql: SQL statement to execute.
|
|
185
|
+
params: Parameters for the SQL statement.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The cursor after execution.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
RuntimeError: If the backend is not initialized.
|
|
192
|
+
sqlite3.Error: If the operation fails (callers should handle).
|
|
193
|
+
"""
|
|
194
|
+
return self.connection.execute(sql, params)
|
|
195
|
+
|
|
196
|
+
def execute_safe(
|
|
197
|
+
self,
|
|
198
|
+
sql: str,
|
|
199
|
+
params: tuple[Any, ...] | dict[str, Any] = (),
|
|
200
|
+
) -> sqlite3.Cursor | None:
|
|
201
|
+
"""Execute a SQL statement, logging errors instead of raising.
|
|
202
|
+
|
|
203
|
+
Per Requirement 23.11: database write failures should not crash
|
|
204
|
+
job execution.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
sql: SQL statement to execute.
|
|
208
|
+
params: Parameters for the SQL statement.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
The cursor after execution, or None if the operation failed.
|
|
212
|
+
"""
|
|
213
|
+
try:
|
|
214
|
+
cursor = self.connection.execute(sql, params)
|
|
215
|
+
self._conn.commit() # type: ignore[union-attr]
|
|
216
|
+
return cursor
|
|
217
|
+
except sqlite3.Error as e:
|
|
218
|
+
logger.error("SQLite operation failed: %s | SQL: %s", e, sql[:200])
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def executemany_safe(
|
|
222
|
+
self,
|
|
223
|
+
sql: str,
|
|
224
|
+
params_seq: list[tuple[Any, ...]],
|
|
225
|
+
) -> sqlite3.Cursor | None:
|
|
226
|
+
"""Execute a SQL statement against multiple parameter sets safely.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
sql: SQL statement to execute.
|
|
230
|
+
params_seq: Sequence of parameter tuples.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The cursor after execution, or None if the operation failed.
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
cursor = self.connection.executemany(sql, params_seq)
|
|
237
|
+
self._conn.commit() # type: ignore[union-attr]
|
|
238
|
+
return cursor
|
|
239
|
+
except sqlite3.Error as e:
|
|
240
|
+
logger.error("SQLite batch operation failed: %s | SQL: %s", e, sql[:200])
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def fetch_one(
|
|
244
|
+
self,
|
|
245
|
+
sql: str,
|
|
246
|
+
params: tuple[Any, ...] | dict[str, Any] = (),
|
|
247
|
+
) -> dict[str, Any] | None:
|
|
248
|
+
"""Execute a query and return the first row as a dict.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
sql: SELECT statement.
|
|
252
|
+
params: Query parameters.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
A dict mapping column names to values, or None if no rows.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
cursor = self.connection.execute(sql, params)
|
|
259
|
+
row = cursor.fetchone()
|
|
260
|
+
if row is None:
|
|
261
|
+
return None
|
|
262
|
+
columns = [desc[0] for desc in cursor.description]
|
|
263
|
+
return dict(zip(columns, row, strict=False))
|
|
264
|
+
except sqlite3.Error as e:
|
|
265
|
+
logger.error("SQLite fetch_one failed: %s | SQL: %s", e, sql[:200])
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def fetch_all(
|
|
269
|
+
self,
|
|
270
|
+
sql: str,
|
|
271
|
+
params: tuple[Any, ...] | dict[str, Any] = (),
|
|
272
|
+
) -> list[dict[str, Any]]:
|
|
273
|
+
"""Execute a query and return all rows as a list of dicts.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
sql: SELECT statement.
|
|
277
|
+
params: Query parameters.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
A list of dicts, each mapping column names to values.
|
|
281
|
+
Returns empty list on error.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
cursor = self.connection.execute(sql, params)
|
|
285
|
+
columns = [desc[0] for desc in cursor.description]
|
|
286
|
+
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
|
|
287
|
+
except sqlite3.Error as e:
|
|
288
|
+
logger.error("SQLite fetch_all failed: %s | SQL: %s", e, sql[:200])
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
# ─── Session Operations ───────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def insert_session(
|
|
294
|
+
self,
|
|
295
|
+
session_id: str,
|
|
296
|
+
scope_id: str,
|
|
297
|
+
*,
|
|
298
|
+
workflow_name: str | None = None,
|
|
299
|
+
metadata: dict[str, Any] | None = None,
|
|
300
|
+
) -> bool:
|
|
301
|
+
"""Insert a new session record.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
True if successful, False on failure.
|
|
305
|
+
"""
|
|
306
|
+
now = time.time()
|
|
307
|
+
metadata_json = json.dumps(metadata or {})
|
|
308
|
+
result = self.execute_safe(
|
|
309
|
+
"""INSERT INTO sessions
|
|
310
|
+
(session_id, scope_id, created_at, updated_at, workflow_name, status, metadata_json)
|
|
311
|
+
VALUES (?, ?, ?, ?, ?, 'running', ?)""",
|
|
312
|
+
(session_id, scope_id, now, now, workflow_name, metadata_json),
|
|
313
|
+
)
|
|
314
|
+
return result is not None
|
|
315
|
+
|
|
316
|
+
def update_session(
|
|
317
|
+
self,
|
|
318
|
+
session_id: str,
|
|
319
|
+
*,
|
|
320
|
+
status: str | None = None,
|
|
321
|
+
metadata: dict[str, Any] | None = None,
|
|
322
|
+
) -> bool:
|
|
323
|
+
"""Update an existing session record.
|
|
324
|
+
|
|
325
|
+
Only updates the fields that are provided (not None).
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
True if successful, False on failure.
|
|
329
|
+
"""
|
|
330
|
+
updates: list[str] = ["updated_at = ?"]
|
|
331
|
+
params: list[Any] = [time.time()]
|
|
332
|
+
|
|
333
|
+
if status is not None:
|
|
334
|
+
updates.append("status = ?")
|
|
335
|
+
params.append(status)
|
|
336
|
+
|
|
337
|
+
if metadata is not None:
|
|
338
|
+
updates.append("metadata_json = ?")
|
|
339
|
+
params.append(json.dumps(metadata))
|
|
340
|
+
|
|
341
|
+
params.append(session_id)
|
|
342
|
+
sql = f"UPDATE sessions SET {', '.join(updates)} WHERE session_id = ?"
|
|
343
|
+
result = self.execute_safe(sql, tuple(params))
|
|
344
|
+
return result is not None
|
|
345
|
+
|
|
346
|
+
def get_latest_session(self) -> dict[str, Any] | None:
|
|
347
|
+
"""Get the most recently updated session.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Session record as a dict, or None if no sessions exist.
|
|
351
|
+
"""
|
|
352
|
+
return self.fetch_one("SELECT * FROM sessions ORDER BY updated_at DESC LIMIT 1")
|
|
353
|
+
|
|
354
|
+
# ─── Execution Operations ─────────────────────────────────────────
|
|
355
|
+
|
|
356
|
+
def insert_execution(
|
|
357
|
+
self,
|
|
358
|
+
execution_uid: str,
|
|
359
|
+
session_id: str,
|
|
360
|
+
job_name: str,
|
|
361
|
+
*,
|
|
362
|
+
kwargs_json: str | None = None,
|
|
363
|
+
parent_uid: str | None = None,
|
|
364
|
+
depth: int = 0,
|
|
365
|
+
) -> bool:
|
|
366
|
+
"""Insert a new execution record.
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if successful, False on failure.
|
|
370
|
+
"""
|
|
371
|
+
now = time.time()
|
|
372
|
+
result = self.execute_safe(
|
|
373
|
+
"""INSERT INTO executions
|
|
374
|
+
(execution_uid, session_id, job_name, started_at, status,
|
|
375
|
+
kwargs_json, parent_uid, depth)
|
|
376
|
+
VALUES (?, ?, ?, ?, 'running', ?, ?, ?)""",
|
|
377
|
+
(execution_uid, session_id, job_name, now, kwargs_json, parent_uid, depth),
|
|
378
|
+
)
|
|
379
|
+
return result is not None
|
|
380
|
+
|
|
381
|
+
def update_execution(
|
|
382
|
+
self,
|
|
383
|
+
execution_uid: str,
|
|
384
|
+
*,
|
|
385
|
+
status: str | None = None,
|
|
386
|
+
duration_ms: float | None = None,
|
|
387
|
+
result_json: str | None = None,
|
|
388
|
+
error_message: str | None = None,
|
|
389
|
+
error_type: str | None = None,
|
|
390
|
+
) -> bool:
|
|
391
|
+
"""Update an existing execution record.
|
|
392
|
+
|
|
393
|
+
Only updates the fields that are provided (not None).
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
True if successful, False on failure.
|
|
397
|
+
"""
|
|
398
|
+
updates: list[str] = ["ended_at = ?"]
|
|
399
|
+
params: list[Any] = [time.time()]
|
|
400
|
+
|
|
401
|
+
if status is not None:
|
|
402
|
+
updates.append("status = ?")
|
|
403
|
+
params.append(status)
|
|
404
|
+
|
|
405
|
+
if duration_ms is not None:
|
|
406
|
+
updates.append("duration_ms = ?")
|
|
407
|
+
params.append(duration_ms)
|
|
408
|
+
|
|
409
|
+
if result_json is not None:
|
|
410
|
+
updates.append("result_json = ?")
|
|
411
|
+
params.append(result_json)
|
|
412
|
+
|
|
413
|
+
if error_message is not None:
|
|
414
|
+
updates.append("error_message = ?")
|
|
415
|
+
params.append(error_message)
|
|
416
|
+
|
|
417
|
+
if error_type is not None:
|
|
418
|
+
updates.append("error_type = ?")
|
|
419
|
+
params.append(error_type)
|
|
420
|
+
|
|
421
|
+
params.append(execution_uid)
|
|
422
|
+
sql = f"UPDATE executions SET {', '.join(updates)} WHERE execution_uid = ?"
|
|
423
|
+
result = self.execute_safe(sql, tuple(params))
|
|
424
|
+
return result is not None
|
|
425
|
+
|
|
426
|
+
def get_execution(self, execution_uid: str) -> dict[str, Any] | None:
|
|
427
|
+
"""Get a single execution record by UID."""
|
|
428
|
+
return self.fetch_one(
|
|
429
|
+
"SELECT * FROM executions WHERE execution_uid = ?",
|
|
430
|
+
(execution_uid,),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def get_session_executions(
|
|
434
|
+
self,
|
|
435
|
+
session_id: str,
|
|
436
|
+
*,
|
|
437
|
+
limit: int = 20,
|
|
438
|
+
) -> list[dict[str, Any]]:
|
|
439
|
+
"""Get executions for a session, ordered by start time descending.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
session_id: The session to query.
|
|
443
|
+
limit: Maximum number of results (default 20).
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
List of execution records as dicts.
|
|
447
|
+
"""
|
|
448
|
+
return self.fetch_all(
|
|
449
|
+
"""SELECT * FROM executions
|
|
450
|
+
WHERE session_id = ?
|
|
451
|
+
ORDER BY started_at DESC
|
|
452
|
+
LIMIT ?""",
|
|
453
|
+
(session_id, limit),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def get_recent_executions(self, *, limit: int = 20) -> list[dict[str, Any]]:
|
|
457
|
+
"""Get the most recent executions across all sessions.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
limit: Maximum number of results (default 20).
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
List of execution records as dicts.
|
|
464
|
+
"""
|
|
465
|
+
return self.fetch_all(
|
|
466
|
+
"SELECT * FROM executions ORDER BY started_at DESC LIMIT ?",
|
|
467
|
+
(limit,),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
# ─── Execution Steps Operations ──────────────────────────────────
|
|
471
|
+
|
|
472
|
+
def insert_step(
|
|
473
|
+
self,
|
|
474
|
+
execution_uid: str,
|
|
475
|
+
step_name: str,
|
|
476
|
+
*,
|
|
477
|
+
status: str = "running",
|
|
478
|
+
message: str | None = None,
|
|
479
|
+
) -> int | None:
|
|
480
|
+
"""Insert a new execution step record.
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
The auto-generated step ID, or None on failure.
|
|
484
|
+
"""
|
|
485
|
+
now = time.time()
|
|
486
|
+
result = self.execute_safe(
|
|
487
|
+
"""INSERT INTO execution_steps
|
|
488
|
+
(execution_uid, step_name, status, message, started_at)
|
|
489
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
490
|
+
(execution_uid, step_name, status, message, now),
|
|
491
|
+
)
|
|
492
|
+
if result is not None:
|
|
493
|
+
return result.lastrowid
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
def update_step(
|
|
497
|
+
self,
|
|
498
|
+
step_id: int,
|
|
499
|
+
*,
|
|
500
|
+
status: str | None = None,
|
|
501
|
+
message: str | None = None,
|
|
502
|
+
duration_ms: float | None = None,
|
|
503
|
+
) -> bool:
|
|
504
|
+
"""Update an execution step record.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
True if successful, False on failure.
|
|
508
|
+
"""
|
|
509
|
+
updates: list[str] = ["ended_at = ?"]
|
|
510
|
+
params: list[Any] = [time.time()]
|
|
511
|
+
|
|
512
|
+
if status is not None:
|
|
513
|
+
updates.append("status = ?")
|
|
514
|
+
params.append(status)
|
|
515
|
+
|
|
516
|
+
if message is not None:
|
|
517
|
+
updates.append("message = ?")
|
|
518
|
+
params.append(message)
|
|
519
|
+
|
|
520
|
+
if duration_ms is not None:
|
|
521
|
+
updates.append("duration_ms = ?")
|
|
522
|
+
params.append(duration_ms)
|
|
523
|
+
|
|
524
|
+
params.append(step_id)
|
|
525
|
+
sql = f"UPDATE execution_steps SET {', '.join(updates)} WHERE id = ?"
|
|
526
|
+
result = self.execute_safe(sql, tuple(params))
|
|
527
|
+
return result is not None
|
|
528
|
+
|
|
529
|
+
def get_execution_steps(self, execution_uid: str) -> list[dict[str, Any]]:
|
|
530
|
+
"""Get all steps for an execution, ordered by start time.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
List of step records as dicts.
|
|
534
|
+
"""
|
|
535
|
+
return self.fetch_all(
|
|
536
|
+
"""SELECT * FROM execution_steps
|
|
537
|
+
WHERE execution_uid = ?
|
|
538
|
+
ORDER BY started_at ASC""",
|
|
539
|
+
(execution_uid,),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# ─── State Operations ─────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
def upsert_state(
|
|
545
|
+
self,
|
|
546
|
+
scope_id: str,
|
|
547
|
+
job_namespace: str,
|
|
548
|
+
key: str,
|
|
549
|
+
value_json: str,
|
|
550
|
+
) -> bool:
|
|
551
|
+
"""Insert or update a state key-value pair.
|
|
552
|
+
|
|
553
|
+
Uses SQLite's INSERT OR REPLACE (upsert) on the composite primary key.
|
|
554
|
+
|
|
555
|
+
Returns:
|
|
556
|
+
True if successful, False on failure.
|
|
557
|
+
"""
|
|
558
|
+
now = time.time()
|
|
559
|
+
result = self.execute_safe(
|
|
560
|
+
"""INSERT OR REPLACE INTO state
|
|
561
|
+
(scope_id, job_namespace, key, value_json, updated_at)
|
|
562
|
+
VALUES (?, ?, ?, ?, ?)""",
|
|
563
|
+
(scope_id, job_namespace, key, value_json, now),
|
|
564
|
+
)
|
|
565
|
+
return result is not None
|
|
566
|
+
|
|
567
|
+
def get_state(
|
|
568
|
+
self,
|
|
569
|
+
scope_id: str,
|
|
570
|
+
job_namespace: str,
|
|
571
|
+
key: str,
|
|
572
|
+
) -> str | None:
|
|
573
|
+
"""Get a state value by composite key.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
The value_json string, or None if not found.
|
|
577
|
+
"""
|
|
578
|
+
row = self.fetch_one(
|
|
579
|
+
"""SELECT value_json FROM state
|
|
580
|
+
WHERE scope_id = ? AND job_namespace = ? AND key = ?""",
|
|
581
|
+
(scope_id, job_namespace, key),
|
|
582
|
+
)
|
|
583
|
+
if row is not None:
|
|
584
|
+
return row["value_json"]
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
def get_namespace_state(
|
|
588
|
+
self,
|
|
589
|
+
scope_id: str,
|
|
590
|
+
job_namespace: str,
|
|
591
|
+
) -> dict[str, str]:
|
|
592
|
+
"""Get all state key-value pairs for a namespace.
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
Dict mapping keys to their value_json strings.
|
|
596
|
+
"""
|
|
597
|
+
rows = self.fetch_all(
|
|
598
|
+
"SELECT key, value_json FROM state WHERE scope_id = ? AND job_namespace = ?",
|
|
599
|
+
(scope_id, job_namespace),
|
|
600
|
+
)
|
|
601
|
+
return {row["key"]: row["value_json"] for row in rows}
|
|
602
|
+
|
|
603
|
+
def get_all_state(self, scope_id: str) -> dict[str, dict[str, str]]:
|
|
604
|
+
"""Get all state for a scope, organized by namespace.
|
|
605
|
+
|
|
606
|
+
Returns:
|
|
607
|
+
Dict mapping job_namespace to {key: value_json}.
|
|
608
|
+
"""
|
|
609
|
+
rows = self.fetch_all(
|
|
610
|
+
"SELECT job_namespace, key, value_json FROM state WHERE scope_id = ?",
|
|
611
|
+
(scope_id,),
|
|
612
|
+
)
|
|
613
|
+
result: dict[str, dict[str, str]] = {}
|
|
614
|
+
for row in rows:
|
|
615
|
+
ns = row["job_namespace"]
|
|
616
|
+
if ns not in result:
|
|
617
|
+
result[ns] = {}
|
|
618
|
+
result[ns][row["key"]] = row["value_json"]
|
|
619
|
+
return result
|
|
620
|
+
|
|
621
|
+
def delete_state(
|
|
622
|
+
self,
|
|
623
|
+
scope_id: str,
|
|
624
|
+
job_namespace: str,
|
|
625
|
+
key: str,
|
|
626
|
+
) -> bool:
|
|
627
|
+
"""Delete a state key-value pair.
|
|
628
|
+
|
|
629
|
+
Returns:
|
|
630
|
+
True if successful, False on failure.
|
|
631
|
+
"""
|
|
632
|
+
result = self.execute_safe(
|
|
633
|
+
"DELETE FROM state WHERE scope_id = ? AND job_namespace = ? AND key = ?",
|
|
634
|
+
(scope_id, job_namespace, key),
|
|
635
|
+
)
|
|
636
|
+
return result is not None
|
|
637
|
+
|
|
638
|
+
def clear_namespace_state(
|
|
639
|
+
self,
|
|
640
|
+
scope_id: str,
|
|
641
|
+
job_namespace: str,
|
|
642
|
+
) -> bool:
|
|
643
|
+
"""Delete all state for a namespace within a scope.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
True if successful, False on failure.
|
|
647
|
+
"""
|
|
648
|
+
result = self.execute_safe(
|
|
649
|
+
"DELETE FROM state WHERE scope_id = ? AND job_namespace = ?",
|
|
650
|
+
(scope_id, job_namespace),
|
|
651
|
+
)
|
|
652
|
+
return result is not None
|
|
653
|
+
|
|
654
|
+
def list_namespaces(self, scope_id: str) -> list[str]:
|
|
655
|
+
"""List all job namespaces that have state for a scope.
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
List of namespace strings.
|
|
659
|
+
"""
|
|
660
|
+
rows = self.fetch_all(
|
|
661
|
+
"SELECT DISTINCT job_namespace FROM state WHERE scope_id = ?",
|
|
662
|
+
(scope_id,),
|
|
663
|
+
)
|
|
664
|
+
return [row["job_namespace"] for row in rows]
|
|
665
|
+
|
|
666
|
+
def get_namespace_keys(
|
|
667
|
+
self,
|
|
668
|
+
scope_id: str,
|
|
669
|
+
job_namespace: str,
|
|
670
|
+
) -> list[str]:
|
|
671
|
+
"""List all keys within a namespace for a scope.
|
|
672
|
+
|
|
673
|
+
Returns:
|
|
674
|
+
List of key strings.
|
|
675
|
+
"""
|
|
676
|
+
rows = self.fetch_all(
|
|
677
|
+
"SELECT key FROM state WHERE scope_id = ? AND job_namespace = ?",
|
|
678
|
+
(scope_id, job_namespace),
|
|
679
|
+
)
|
|
680
|
+
return [row["key"] for row in rows]
|
|
681
|
+
|
|
682
|
+
# ─── Internal Methods ─────────────────────────────────────────────
|
|
683
|
+
|
|
684
|
+
def _create_connection(self) -> sqlite3.Connection:
|
|
685
|
+
"""Create a new SQLite connection with appropriate settings."""
|
|
686
|
+
conn = sqlite3.connect(
|
|
687
|
+
str(self._db_path),
|
|
688
|
+
timeout=10.0,
|
|
689
|
+
check_same_thread=False,
|
|
690
|
+
)
|
|
691
|
+
# Enable foreign keys
|
|
692
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
693
|
+
return conn
|
|
694
|
+
|
|
695
|
+
def _enable_wal(self) -> None:
|
|
696
|
+
"""Enable WAL mode for better concurrent read performance."""
|
|
697
|
+
assert self._conn is not None
|
|
698
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
699
|
+
# Set synchronous to NORMAL for a good balance of safety and speed
|
|
700
|
+
self._conn.execute("PRAGMA synchronous = NORMAL")
|
|
701
|
+
self._conn.commit()
|
|
702
|
+
|
|
703
|
+
def _create_schema(self) -> None:
|
|
704
|
+
"""Create the database schema if it doesn't exist."""
|
|
705
|
+
assert self._conn is not None
|
|
706
|
+
self._conn.executescript(_SCHEMA_SQL)
|
|
707
|
+
self._conn.commit()
|
|
708
|
+
|
|
709
|
+
def __enter__(self) -> SQLiteBackend:
|
|
710
|
+
"""Context manager entry — initialize if needed."""
|
|
711
|
+
if not self._initialized:
|
|
712
|
+
self.initialize()
|
|
713
|
+
return self
|
|
714
|
+
|
|
715
|
+
def __exit__(self, *exc: Any) -> None:
|
|
716
|
+
"""Context manager exit — close connection."""
|
|
717
|
+
self.close()
|
|
718
|
+
|
|
719
|
+
def __del__(self) -> None:
|
|
720
|
+
"""Ensure connection is closed on garbage collection."""
|
|
721
|
+
self.close()
|