217 lines
10 KiB
Python
217 lines
10 KiB
Python
"""Small SQLite repository. Queue transitions are serialized transactions."""
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
import json
|
|
import sqlite3
|
|
import uuid
|
|
|
|
TERMINAL = {"succeeded", "failed", "cancelled", "timed_out", "interrupted"}
|
|
|
|
|
|
def now():
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
class Store:
|
|
def __init__(self, settings):
|
|
self.settings = settings
|
|
settings.prepare()
|
|
with self.connect() as db:
|
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
|
if version not in (0, 1):
|
|
raise RuntimeError("Unsupported database version")
|
|
db.executescript("""
|
|
PRAGMA journal_mode=WAL;
|
|
CREATE TABLE IF NOT EXISTS scripts(id TEXT PRIMARY KEY, data TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS versions(
|
|
script_id TEXT NOT NULL, version INTEGER NOT NULL, data TEXT NOT NULL,
|
|
PRIMARY KEY(script_id,version));
|
|
CREATE TABLE IF NOT EXISTS runs(
|
|
id TEXT PRIMARY KEY, request_id TEXT NOT NULL UNIQUE,
|
|
script_id TEXT NOT NULL, state TEXT NOT NULL,
|
|
cancel_requested INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS health(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
PRAGMA user_version=1;
|
|
""")
|
|
|
|
@contextmanager
|
|
def connect(self, write=False):
|
|
db = sqlite3.connect(self.settings.db, timeout=10)
|
|
db.row_factory = sqlite3.Row
|
|
try:
|
|
if write:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
yield db
|
|
db.commit()
|
|
except Exception:
|
|
db.rollback()
|
|
raise
|
|
finally:
|
|
db.close()
|
|
|
|
def scripts(self):
|
|
with self.connect() as db:
|
|
return [json.loads(r[0]) for r in db.execute("SELECT data FROM scripts ORDER BY id")]
|
|
|
|
def script(self, identity, versions=False):
|
|
with self.connect() as db:
|
|
row = db.execute("SELECT data FROM scripts WHERE id=?", (identity,)).fetchone()
|
|
if not row:
|
|
raise KeyError("脚本不存在")
|
|
item = json.loads(row[0])
|
|
if versions:
|
|
item["versions"] = [json.loads(r[0]) for r in db.execute(
|
|
"SELECT data FROM versions WHERE script_id=? ORDER BY version DESC", (identity,))]
|
|
return item
|
|
|
|
def save_script(self, item):
|
|
with self.connect(write=True) as db:
|
|
db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item)))
|
|
|
|
def version(self, identity, version):
|
|
with self.connect() as db:
|
|
row = db.execute("SELECT data FROM versions WHERE script_id=? AND version=?", (identity, version)).fetchone()
|
|
if not row:
|
|
raise KeyError("版本不存在")
|
|
return json.loads(row[0])
|
|
|
|
def upload_version(self, item, content, filename):
|
|
# A transaction prevents two simultaneous uploads from assigning the same version.
|
|
with self.connect(write=True) as db:
|
|
current = db.execute("SELECT data FROM scripts WHERE id=?", (item["id"],)).fetchone()
|
|
if current:
|
|
item = json.loads(current[0])
|
|
version = item["version"] + 1
|
|
target = self.settings.library / item["id"] / "versions" / str(version) / filename
|
|
target.parent.mkdir(parents=True, exist_ok=False)
|
|
try:
|
|
with target.open("xb") as handle:
|
|
handle.write(content)
|
|
import hashlib
|
|
value = {"version": version, "sha256": hashlib.sha256(content).hexdigest(),
|
|
"created_at": now(), "filename": filename, "path": str(target)}
|
|
item.update(version=version, sha256=value["sha256"], source_path=str(target), updated_at=now())
|
|
db.execute("INSERT INTO versions VALUES(?,?,?)", (item["id"], version, json.dumps(value)))
|
|
db.execute("INSERT OR REPLACE INTO scripts VALUES(?,?)", (item["id"], json.dumps(item)))
|
|
except Exception:
|
|
target.unlink(missing_ok=True)
|
|
target.parent.rmdir()
|
|
raise
|
|
return item
|
|
|
|
@staticmethod
|
|
def unpack_run(row):
|
|
data = json.loads(row["data"])
|
|
data.update(state=row["state"], cancel_requested=bool(row["cancel_requested"]))
|
|
return data
|
|
|
|
def runs(self, script_id=None, limit=100):
|
|
with self.connect() as db:
|
|
query = "SELECT * FROM runs"
|
|
values = []
|
|
if script_id:
|
|
query += " WHERE script_id=?"
|
|
values.append(script_id)
|
|
query += " ORDER BY rowid DESC LIMIT ?"
|
|
values.append(limit)
|
|
return [self.unpack_run(r) for r in db.execute(query, values)]
|
|
|
|
def run(self, identity):
|
|
with self.connect() as db:
|
|
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
|
if not row:
|
|
raise KeyError("运行记录不存在")
|
|
return self.unpack_run(row)
|
|
|
|
def run_for_request(self, request_id):
|
|
with self.connect() as db:
|
|
row = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone()
|
|
return self.unpack_run(row) if row else None
|
|
|
|
def queue(self, script, request_id, ip, execution=None):
|
|
with self.connect(write=True) as db:
|
|
old = db.execute("SELECT * FROM runs WHERE request_id=?", (request_id,)).fetchone()
|
|
if old:
|
|
if old["script_id"] != script["id"]:
|
|
raise ValueError("请求编号已用于其他脚本")
|
|
return self.unpack_run(old)
|
|
pending = db.execute("SELECT count(*) FROM runs WHERE state IN ('queued','running')").fetchone()[0]
|
|
if pending >= 20:
|
|
raise ValueError("队列已满,请等待已有测试结束")
|
|
item = {"id": uuid.uuid4().hex, "script_id": script["id"], "script_name": script["name"],
|
|
"version": script["version"], "sha256": script["sha256"], "state": "queued",
|
|
"created_at": now(), "started_at": None, "finished_at": None, "exit_code": None,
|
|
"message": "等待执行", "config": script["config"], "artifacts": [],
|
|
"synthetic": script.get("synthetic", False), "source_ip": ip,
|
|
"source_path": script["source_path"], "language": script["language"],
|
|
"kind": script["kind"], "source_group": script["source_group"]}
|
|
if execution:
|
|
item["execution"] = execution
|
|
db.execute("INSERT INTO runs(id,request_id,script_id,state,data) VALUES(?,?,?,?,?)",
|
|
(item["id"], request_id, script["id"], "queued", json.dumps(item)))
|
|
return item
|
|
|
|
def claim(self):
|
|
with self.connect(write=True) as db:
|
|
if db.execute("SELECT 1 FROM runs WHERE state='running'").fetchone():
|
|
return None
|
|
row = db.execute("SELECT * FROM runs WHERE state='queued' ORDER BY rowid LIMIT 1").fetchone()
|
|
if not row:
|
|
return None
|
|
item = self.unpack_run(row)
|
|
item.update(started_at=now(), state="running", message="正在启动测试")
|
|
db.execute("UPDATE runs SET state='running',data=? WHERE id=?", (json.dumps(item), item["id"]))
|
|
return item
|
|
|
|
def finish(self, identity, state, **values):
|
|
assert state in TERMINAL
|
|
with self.connect(write=True) as db:
|
|
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
|
item = self.unpack_run(row)
|
|
item.update(values, state=state, finished_at=now())
|
|
db.execute("UPDATE runs SET state=?,data=? WHERE id=?", (state, json.dumps(item), identity))
|
|
return item
|
|
|
|
def running_message(self, identity, message):
|
|
with self.connect(write=True) as db:
|
|
row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone()
|
|
if row:
|
|
item = self.unpack_run(row)
|
|
item["message"] = message
|
|
db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity))
|
|
|
|
def update_execution(self, identity, values):
|
|
with self.connect(write=True) as db:
|
|
row = db.execute("SELECT * FROM runs WHERE id=? AND state='running'", (identity,)).fetchone()
|
|
if not row:
|
|
raise ValueError("运行状态已变化")
|
|
item = self.unpack_run(row)
|
|
item["execution"] = {**item.get("execution", {}), **values}
|
|
db.execute("UPDATE runs SET data=? WHERE id=?", (json.dumps(item), identity))
|
|
return item
|
|
|
|
def cancel(self, identity):
|
|
with self.connect(write=True) as db:
|
|
row = db.execute("SELECT * FROM runs WHERE id=?", (identity,)).fetchone()
|
|
if not row:
|
|
raise KeyError("运行记录不存在")
|
|
item = self.unpack_run(row)
|
|
if row["state"] == "queued":
|
|
item.update(state="cancelled", finished_at=now(), message="已取消排队")
|
|
db.execute("UPDATE runs SET state='cancelled',cancel_requested=1,data=? WHERE id=?",
|
|
(json.dumps(item), identity))
|
|
elif row["state"] == "running":
|
|
db.execute("UPDATE runs SET cancel_requested=1 WHERE id=?", (identity,))
|
|
return self.run(identity)
|
|
|
|
def heartbeat(self):
|
|
with self.connect(write=True) as db:
|
|
db.execute("INSERT OR REPLACE INTO health VALUES('worker',?)", (now(),))
|
|
|
|
def health(self):
|
|
with self.connect() as db:
|
|
row = db.execute("SELECT value FROM health WHERE key='worker'").fetchone()
|
|
last = row[0] if row else None
|
|
online = bool(last and (datetime.now(timezone.utc) - datetime.fromisoformat(last)).total_seconds() < 15)
|
|
return {"online": online, "last_seen": last}
|