Files
Borealis-Github-Replica/Data/Engine/repositories/sqlite/job_repository.py

356 lines
13 KiB
Python

"""SQLite-backed persistence for Engine job scheduling."""
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass
from typing import Any, Iterable, Optional, Sequence
import sqlite3
from .connection import SQLiteConnectionFactory
__all__ = [
"ScheduledJobRecord",
"ScheduledJobRunRecord",
"SQLiteJobRepository",
]
def _now_ts() -> int:
return int(time.time())
def _json_dumps(value: Any) -> str:
try:
return json.dumps(value or [])
except Exception:
return "[]"
def _json_loads(value: Optional[str]) -> list[Any]:
if not value:
return []
try:
data = json.loads(value)
if isinstance(data, list):
return data
return []
except Exception:
return []
@dataclass(frozen=True, slots=True)
class ScheduledJobRecord:
id: int
name: str
components: list[dict[str, Any]]
targets: list[str]
schedule_type: str
start_ts: Optional[int]
duration_stop_enabled: bool
expiration: Optional[str]
execution_context: str
credential_id: Optional[int]
use_service_account: bool
enabled: bool
created_at: Optional[int]
updated_at: Optional[int]
@dataclass(frozen=True, slots=True)
class ScheduledJobRunRecord:
id: int
job_id: int
scheduled_ts: Optional[int]
started_ts: Optional[int]
finished_ts: Optional[int]
status: Optional[str]
error: Optional[str]
target_hostname: Optional[str]
created_at: Optional[int]
updated_at: Optional[int]
class SQLiteJobRepository:
"""Persistence adapter for Engine job scheduling."""
def __init__(
self,
factory: SQLiteConnectionFactory,
*,
logger: Optional[logging.Logger] = None,
) -> None:
self._factory = factory
self._log = logger or logging.getLogger("borealis.engine.repositories.jobs")
# ------------------------------------------------------------------
# Job CRUD
# ------------------------------------------------------------------
def list_jobs(self) -> list[ScheduledJobRecord]:
query = (
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, "
"duration_stop_enabled, expiration, execution_context, credential_id, "
"use_service_account, enabled, created_at, updated_at FROM scheduled_jobs "
"ORDER BY id ASC"
)
return [self._row_to_job(row) for row in self._fetchall(query)]
def list_enabled_jobs(self) -> list[ScheduledJobRecord]:
query = (
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, "
"duration_stop_enabled, expiration, execution_context, credential_id, "
"use_service_account, enabled, created_at, updated_at FROM scheduled_jobs "
"WHERE enabled=1 ORDER BY id ASC"
)
return [self._row_to_job(row) for row in self._fetchall(query)]
def fetch_job(self, job_id: int) -> Optional[ScheduledJobRecord]:
query = (
"SELECT id, name, components_json, targets_json, schedule_type, start_ts, "
"duration_stop_enabled, expiration, execution_context, credential_id, "
"use_service_account, enabled, created_at, updated_at FROM scheduled_jobs "
"WHERE id=?"
)
rows = self._fetchall(query, (job_id,))
return self._row_to_job(rows[0]) if rows else None
def create_job(
self,
*,
name: str,
components: Sequence[dict[str, Any]],
targets: Sequence[Any],
schedule_type: str,
start_ts: Optional[int],
duration_stop_enabled: bool,
expiration: Optional[str],
execution_context: str,
credential_id: Optional[int],
use_service_account: bool,
enabled: bool = True,
) -> ScheduledJobRecord:
now = _now_ts()
payload = (
name,
_json_dumps(list(components)),
_json_dumps(list(targets)),
schedule_type,
start_ts,
1 if duration_stop_enabled else 0,
expiration,
execution_context,
credential_id,
1 if use_service_account else 0,
1 if enabled else 0,
now,
now,
)
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO scheduled_jobs
(name, components_json, targets_json, schedule_type, start_ts,
duration_stop_enabled, expiration, execution_context, credential_id,
use_service_account, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
payload,
)
job_id = cur.lastrowid
conn.commit()
record = self.fetch_job(int(job_id))
if record is None:
raise RuntimeError("failed to create scheduled job")
return record
def update_job(
self,
job_id: int,
*,
name: str,
components: Sequence[dict[str, Any]],
targets: Sequence[Any],
schedule_type: str,
start_ts: Optional[int],
duration_stop_enabled: bool,
expiration: Optional[str],
execution_context: str,
credential_id: Optional[int],
use_service_account: bool,
) -> Optional[ScheduledJobRecord]:
now = _now_ts()
payload = (
name,
_json_dumps(list(components)),
_json_dumps(list(targets)),
schedule_type,
start_ts,
1 if duration_stop_enabled else 0,
expiration,
execution_context,
credential_id,
1 if use_service_account else 0,
now,
job_id,
)
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"""
UPDATE scheduled_jobs
SET name=?, components_json=?, targets_json=?, schedule_type=?,
start_ts=?, duration_stop_enabled=?, expiration=?, execution_context=?,
credential_id=?, use_service_account=?, updated_at=?
WHERE id=?
""",
payload,
)
conn.commit()
return self.fetch_job(job_id)
def set_enabled(self, job_id: int, enabled: bool) -> None:
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"UPDATE scheduled_jobs SET enabled=?, updated_at=? WHERE id=?",
(1 if enabled else 0, _now_ts(), job_id),
)
conn.commit()
def delete_job(self, job_id: int) -> None:
with self._connect() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM scheduled_jobs WHERE id=?", (job_id,))
conn.commit()
# ------------------------------------------------------------------
# Run history
# ------------------------------------------------------------------
def list_runs(self, job_id: int, *, days: Optional[int] = None) -> list[ScheduledJobRunRecord]:
params: list[Any] = [job_id]
where = "WHERE job_id=?"
if days is not None and days > 0:
cutoff = _now_ts() - (days * 86400)
where += " AND COALESCE(finished_ts, scheduled_ts, started_ts, 0) >= ?"
params.append(cutoff)
query = (
"SELECT id, job_id, scheduled_ts, started_ts, finished_ts, status, error, "
"target_hostname, created_at, updated_at FROM scheduled_job_runs "
f"{where} ORDER BY COALESCE(scheduled_ts, created_at, id) DESC"
)
return [self._row_to_run(row) for row in self._fetchall(query, tuple(params))]
def fetch_last_run(self, job_id: int) -> Optional[ScheduledJobRunRecord]:
query = (
"SELECT id, job_id, scheduled_ts, started_ts, finished_ts, status, error, "
"target_hostname, created_at, updated_at FROM scheduled_job_runs "
"WHERE job_id=? ORDER BY COALESCE(started_ts, scheduled_ts, created_at, id) DESC LIMIT 1"
)
rows = self._fetchall(query, (job_id,))
return self._row_to_run(rows[0]) if rows else None
def purge_runs(self, job_id: int) -> None:
with self._connect() as conn:
cur = conn.cursor()
cur.execute("DELETE FROM scheduled_job_run_activity WHERE run_id IN (SELECT id FROM scheduled_job_runs WHERE job_id=?)", (job_id,))
cur.execute("DELETE FROM scheduled_job_runs WHERE job_id=?", (job_id,))
conn.commit()
def create_run(self, job_id: int, scheduled_ts: int, *, target_hostname: Optional[str] = None) -> int:
now = _now_ts()
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO scheduled_job_runs
(job_id, scheduled_ts, created_at, updated_at, target_hostname, status)
VALUES (?, ?, ?, ?, ?, 'Pending')
""",
(job_id, scheduled_ts, now, now, target_hostname),
)
run_id = int(cur.lastrowid)
conn.commit()
return run_id
def mark_run_started(self, run_id: int, *, started_ts: Optional[int] = None) -> None:
started = started_ts or _now_ts()
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"UPDATE scheduled_job_runs SET started_ts=?, status='Running', updated_at=? WHERE id=?",
(started, _now_ts(), run_id),
)
conn.commit()
def mark_run_finished(
self,
run_id: int,
*,
status: str,
error: Optional[str] = None,
finished_ts: Optional[int] = None,
) -> None:
finished = finished_ts or _now_ts()
with self._connect() as conn:
cur = conn.cursor()
cur.execute(
"UPDATE scheduled_job_runs SET finished_ts=?, status=?, error=?, updated_at=? WHERE id=?",
(finished, status, error, _now_ts(), run_id),
)
conn.commit()
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _connect(self) -> sqlite3.Connection:
return self._factory()
def _fetchall(self, query: str, params: Optional[Iterable[Any]] = None) -> list[sqlite3.Row]:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute(query, tuple(params or ()))
rows = cur.fetchall()
return rows
def _row_to_job(self, row: sqlite3.Row) -> ScheduledJobRecord:
components = _json_loads(row["components_json"])
targets_raw = _json_loads(row["targets_json"])
targets = [str(t) for t in targets_raw if isinstance(t, (str, int))]
credential_id = row["credential_id"]
return ScheduledJobRecord(
id=int(row["id"]),
name=str(row["name"] or ""),
components=[c for c in components if isinstance(c, dict)],
targets=targets,
schedule_type=str(row["schedule_type"] or "immediately"),
start_ts=int(row["start_ts"]) if row["start_ts"] is not None else None,
duration_stop_enabled=bool(row["duration_stop_enabled"]),
expiration=str(row["expiration"]) if row["expiration"] else None,
execution_context=str(row["execution_context"] or "system"),
credential_id=int(credential_id) if credential_id is not None else None,
use_service_account=bool(row["use_service_account"]),
enabled=bool(row["enabled"]),
created_at=int(row["created_at"]) if row["created_at"] is not None else None,
updated_at=int(row["updated_at"]) if row["updated_at"] is not None else None,
)
def _row_to_run(self, row: sqlite3.Row) -> ScheduledJobRunRecord:
return ScheduledJobRunRecord(
id=int(row["id"]),
job_id=int(row["job_id"]),
scheduled_ts=int(row["scheduled_ts"]) if row["scheduled_ts"] is not None else None,
started_ts=int(row["started_ts"]) if row["started_ts"] is not None else None,
finished_ts=int(row["finished_ts"]) if row["finished_ts"] is not None else None,
status=str(row["status"]) if row["status"] else None,
error=str(row["error"]) if row["error"] else None,
target_hostname=str(row["target_hostname"]) if row["target_hostname"] else None,
created_at=int(row["created_at"]) if row["created_at"] is not None else None,
updated_at=int(row["updated_at"]) if row["updated_at"] is not None else None,
)