Files
Artemis-Eapil/apps/admin_console/services/automation_store.py
T

270 lines
11 KiB
Python

"""Durable batch, execution and versioned workflow metadata; no device side effects."""
from contextlib import contextmanager
import json
import hashlib
from pathlib import Path
import re
import sqlite3
import time
import uuid
from artemis.config import DB_PATH
RECOVERY_ERRORS = {
"服务中断;未完成任务需手动重跑",
"服务中断,需手动重跑",
}
def identifier(value: str) -> str:
if not re.fullmatch(r"[A-Za-z0-9_-]{1,100}", value):
raise ValueError("无效的记录编号")
return value
def new_id() -> str:
return str(uuid.uuid4())
class AutomationStore:
def __init__(self, path: Path = DB_PATH):
self.path = Path(path)
@contextmanager
def connection(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(self.path, timeout=15)
try:
conn.row_factory = sqlite3.Row
conn.executescript("""
CREATE TABLE IF NOT EXISTS automation_records (
kind TEXT NOT NULL, id TEXT NOT NULL, payload TEXT NOT NULL,
updated_at REAL NOT NULL, PRIMARY KEY(kind,id));
CREATE TABLE IF NOT EXISTS automation_jobs (
session_id TEXT PRIMARY KEY, batch_id TEXT, position INTEGER NOT NULL,
status TEXT NOT NULL, payload TEXT NOT NULL, updated_at REAL NOT NULL);
CREATE INDEX IF NOT EXISTS automation_batch_jobs ON automation_jobs(batch_id,position);
""")
yield conn
finally:
conn.close()
def get(self, kind: str, key: str) -> dict | None:
with self.connection() as conn:
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind=? AND id=?", (kind, key)
).fetchone()
return json.loads(row[0]) if row else None
def put(self, kind: str, key: str, payload: dict) -> dict:
with self.connection() as conn:
if kind == "batch":
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?", (key,)
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除或已删除,不能继续更新")
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?) ON CONFLICT(kind,id) DO UPDATE SET payload=excluded.payload,updated_at=excluded.updated_at",
(kind, key, json.dumps(payload, ensure_ascii=False), time.time()),
)
conn.commit()
return payload
def records(self, kind: str, limit: int = 500) -> list[dict]:
with self.connection() as conn:
rows = conn.execute(
"SELECT payload FROM automation_records WHERE kind=? ORDER BY updated_at DESC LIMIT ?",
(kind, limit),
).fetchall()
return [json.loads(row[0]) for row in rows]
def job(self, session_id: str) -> dict | None:
with self.connection() as conn:
row = conn.execute(
"SELECT payload,status FROM automation_jobs WHERE session_id=?", (session_id,)
).fetchone()
return {**json.loads(row[0]), "status": row[1]} if row else None
def save_job(self, payload: dict) -> dict:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
if batch_id := payload.get("batch_id"):
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None or json.loads(row[0]).get("status") == "deleting":
raise ValueError("批次正在删除,不能继续派发任务")
conn.execute(
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?) ON CONFLICT(session_id) DO UPDATE SET payload=excluded.payload,status=excluded.status,updated_at=excluded.updated_at",
(
payload["session_id"],
payload.get("batch_id"),
payload.get("position", 0),
payload["status"],
json.dumps(payload, ensure_ascii=False),
time.time(),
),
)
conn.commit()
return payload
def jobs(self, batch_id: str | None = None) -> list[dict]:
with self.connection() as conn:
rows = (
conn.execute(
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
(batch_id,),
).fetchall()
if batch_id
else conn.execute(
"SELECT payload,status FROM automation_jobs ORDER BY updated_at DESC LIMIT 10000"
).fetchall()
)
return [{**json.loads(row[0]), "status": row[1]} for row in rows]
def jobs_for_sessions(self, session_ids: list[str]) -> dict[str, dict]:
"""Load only metadata belonging to the sessions shown in history."""
result = {}
with self.connection() as conn:
for offset in range(0, len(session_ids), 400):
chunk = session_ids[offset : offset + 400]
markers = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT session_id,payload,status FROM automation_jobs WHERE session_id IN ({markers})",
chunk,
).fetchall()
result.update(
{
row["session_id"]: {**json.loads(row["payload"]), "status": row["status"]}
for row in rows
}
)
return result
def preferences(self) -> dict:
return self.get("settings", "reports") or {"formats": ["docx", "xlsx"]}
def create_batch(self, batch: dict, jobs: list[dict], payload: dict) -> dict:
"""Commit a batch and all its jobs atomically; repeated request IDs never duplicate work."""
request_id = payload.get("request_id")
fingerprint = hashlib.sha256(
json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()
).hexdigest()
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
if request_id:
identifier(request_id)
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch_request' AND id=?",
(request_id,),
).fetchone()
if row:
previous = json.loads(row[0])
if previous["fingerprint"] != fingerprint:
raise ValueError("请求编号已用于不同内容,请使用新的 request_id")
existing = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(previous["batch_id"],),
).fetchone()
if not existing:
raise ValueError("该请求对应的历史批次已删除,不能自动重新执行")
return json.loads(existing[0])
now = time.time()
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?)",
("batch", batch["id"], json.dumps(batch, ensure_ascii=False), now),
)
for job in jobs:
conn.execute(
"INSERT INTO automation_jobs VALUES (?,?,?,?,?,?)",
(
job["session_id"],
batch["id"],
job["position"],
job["status"],
json.dumps(job, ensure_ascii=False),
now,
),
)
if request_id:
conn.execute(
"INSERT INTO automation_records VALUES (?,?,?,?)",
(
"batch_request",
request_id,
json.dumps({"batch_id": batch["id"], "fingerprint": fingerprint}),
now,
),
)
conn.commit()
return batch
def begin_delete_batch(self, batch_id: str) -> list[dict] | None:
"""Reserve an inactive batch for cleanup before any more jobs can start."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None:
return None
batch = json.loads(row[0])
active = conn.execute(
"SELECT 1 FROM automation_jobs WHERE batch_id=? AND status IN "
"('queued','running','task_paused','resuming','finalizing') LIMIT 1",
(batch_id,),
).fetchone()
if batch["status"] == "running" or active:
raise ValueError("批次仍有任务执行中或排队中,请等待完成后删除")
batch["status"] = "deleting"
conn.execute(
"UPDATE automation_records SET payload=?,updated_at=? WHERE kind='batch' AND id=?",
(json.dumps(batch, ensure_ascii=False), time.time(), batch_id),
)
jobs = conn.execute(
"SELECT payload,status FROM automation_jobs WHERE batch_id=? ORDER BY position",
(batch_id,),
).fetchall()
conn.commit()
return [{**json.loads(job[0]), "status": job[1]} for job in jobs]
def delete_batch(self, batch_id: str) -> bool:
"""Remove a batch record and all of its jobs after cleanup."""
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT payload FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
).fetchone()
if row is None:
return False
batch = json.loads(row[0])
if batch.get("status") != "deleting":
raise ValueError("批次尚未准备删除")
conn.execute("DELETE FROM automation_jobs WHERE batch_id=?", (batch_id,))
conn.execute(
"DELETE FROM automation_records WHERE kind='batch' AND id=?",
(batch_id,),
)
# Keep the request-id tombstone so deletion cannot replay an old batch.
conn.commit()
return True
def delete_history(self, session_id: str | None = None):
with self.connection() as conn:
if session_id:
conn.execute("DELETE FROM automation_jobs WHERE session_id=?", (session_id,))
else:
conn.execute("DELETE FROM automation_jobs")
conn.execute("DELETE FROM automation_records WHERE kind='batch'")
conn.commit()
store = AutomationStore()