| """ |
| SQLite-backed persistence for jobs + results metadata. |
| |
| Schema: |
| jobs — one row per job |
| job_results — one row per completed job (JSON blob) |
| audit_events — mirrored from utils.audit for queryability |
| |
| This class is injectable: pass a `path` (or use :memory: for tests). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import sqlite3 |
| import threading |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any, Optional |
|
|
| from loguru import logger |
|
|
|
|
| _SCHEMA = """ |
| CREATE TABLE IF NOT EXISTS jobs ( |
| id TEXT PRIMARY KEY, |
| kind TEXT NOT NULL, |
| status TEXT NOT NULL, |
| created_at TEXT NOT NULL, |
| started_at TEXT, |
| completed_at TEXT, |
| request TEXT NOT NULL, |
| image_hash TEXT, |
| error TEXT |
| ); |
| |
| CREATE TABLE IF NOT EXISTS job_results ( |
| job_id TEXT PRIMARY KEY, |
| status TEXT NOT NULL, |
| report TEXT, |
| error TEXT, |
| elapsed_ms REAL, |
| created_at TEXT NOT NULL, |
| FOREIGN KEY (job_id) REFERENCES jobs(id) |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); |
| CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at); |
| """ |
|
|
|
|
| class Database: |
| """Thread-safe SQLite wrapper. One connection per instance.""" |
|
|
| def __init__(self, path: str = ":memory:") -> None: |
| self._path = path |
| self._lock = threading.Lock() |
| self._conn = sqlite3.connect(path, check_same_thread=False) |
| self._conn.row_factory = sqlite3.Row |
| self._conn.executescript(_SCHEMA) |
| self._conn.commit() |
| logger.info(f"Database initialized at {path}") |
|
|
| |
| |
| |
| def save_job(self, job: dict) -> None: |
| with self._lock: |
| self._conn.execute( |
| """INSERT OR REPLACE INTO jobs |
| (id, kind, status, created_at, started_at, completed_at, |
| request, image_hash, error) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", |
| ( |
| job["id"], |
| job["kind"], |
| job["status"], |
| job["created_at"], |
| job.get("started_at"), |
| job.get("completed_at"), |
| json.dumps(job.get("request", {}), default=str), |
| job.get("image_hash"), |
| job.get("error"), |
| ), |
| ) |
| self._conn.commit() |
|
|
| def get_job(self, job_id: str) -> Optional[dict]: |
| with self._lock: |
| row = self._conn.execute( |
| "SELECT * FROM jobs WHERE id = ?", (job_id,) |
| ).fetchone() |
| if not row: |
| return None |
| d = dict(row) |
| d["request"] = json.loads(d["request"] or "{}") |
| return d |
|
|
| def list_jobs(self, limit: int = 50, status: Optional[str] = None) -> list[dict]: |
| with self._lock: |
| if status: |
| cur = self._conn.execute( |
| "SELECT * FROM jobs WHERE status = ? ORDER BY created_at DESC LIMIT ?", |
| (status, limit), |
| ) |
| else: |
| cur = self._conn.execute( |
| "SELECT * FROM jobs ORDER BY created_at DESC LIMIT ?", |
| (limit,), |
| ) |
| rows = [dict(r) for r in cur.fetchall()] |
| for r in rows: |
| r["request"] = json.loads(r["request"] or "{}") |
| return rows |
|
|
| |
| |
| |
| def save_result(self, job_id: str, status: str, report: Any, |
| error: Optional[str], elapsed_ms: float) -> None: |
| with self._lock: |
| self._conn.execute( |
| """INSERT OR REPLACE INTO job_results |
| (job_id, status, report, error, elapsed_ms, created_at) |
| VALUES (?, ?, ?, ?, ?, ?)""", |
| ( |
| job_id, status, |
| json.dumps(report, default=str) if report is not None else None, |
| error, elapsed_ms, |
| datetime.now(timezone.utc).isoformat(), |
| ), |
| ) |
| self._conn.commit() |
|
|
| def get_result(self, job_id: str) -> Optional[dict]: |
| with self._lock: |
| row = self._conn.execute( |
| "SELECT * FROM job_results WHERE job_id = ?", (job_id,) |
| ).fetchone() |
| if not row: |
| return None |
| d = dict(row) |
| if d.get("report"): |
| try: |
| d["report"] = json.loads(d["report"]) |
| except json.JSONDecodeError: |
| pass |
| return d |
|
|
| |
| |
| |
| def cleanup_old_jobs(self, retention_days: int) -> int: |
| """Delete jobs older than retention_days. Returns count deleted.""" |
| cutoff = datetime.now(timezone.utc).timestamp() - (retention_days * 86400) |
| with self._lock: |
| cur = self._conn.execute( |
| "DELETE FROM jobs WHERE strftime('%s', created_at) < ?", |
| (cutoff,), |
| ) |
| self._conn.commit() |
| return cur.rowcount |
|
|
| def close(self) -> None: |
| with self._lock: |
| self._conn.close() |
|
|