# retoor <retoor@molodetz.nl>
import json
import logging
from datetime import datetime, timedelta, timezone
from devplacepy.database import db, get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
PENDING = "pending"
RUNNING = "running"
DONE = "done"
FAILED = "failed"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _table():
return get_table("jobs")
def _decode(field: str) -> dict:
if not field:
return {}
try:
return json.loads(field)
except (ValueError, TypeError):
return {}
def _hydrate(row: dict) -> dict:
row = dict(row)
row["payload"] = _decode(row.get("payload"))
row["result"] = _decode(row.get("result"))
return row
def enqueue(
kind: str, payload: dict, owner_kind: str, owner_id: str, preferred_name: str = ""
) -> str:
uid = generate_uid()
now = _now()
_table().insert(
{
"uid": uid,
"kind": kind,
"status": PENDING,
"owner_kind": owner_kind,
"owner_id": owner_id,
"preferred_name": preferred_name,
"payload": json.dumps(payload),
"result": "",
"error": "",
"retry_count": 0,
"created_at": now,
"started_at": "",
"completed_at": "",
"updated_at": now,
"duration_ms": 0,
"last_accessed_at": "",
"expires_at": "",
"bytes_in": 0,
"bytes_out": 0,
"item_count": 0,
}
)
logger.info("Enqueued %s job %s for %s/%s", kind, uid, owner_kind, owner_id)
return uid
def get_job(uid: str) -> dict | None:
if "jobs" not in db.tables:
return None
row = _table().find_one(uid=uid)
return _hydrate(row) if row else None
def touch_job(uid: str, extend_seconds: int) -> None:
if "jobs" not in db.tables:
return
row = _table().find_one(uid=uid)
if not row:
return
now = datetime.now(timezone.utc)
_table().update(
{
"uid": uid,
"last_accessed_at": now.isoformat(),
"expires_at": (now + timedelta(seconds=extend_seconds)).isoformat(),
"updated_at": now.isoformat(),
},
["uid"],
)
def job_metrics(kind: str) -> dict:
empty = {
"total": 0,
"by_status": {PENDING: 0, RUNNING: 0, DONE: 0, FAILED: 0},
"bytes_in": 0,
"bytes_out": 0,
"items": 0,
"avg_ms": 0,
"recent": [],
}
if "jobs" not in db.tables:
return empty
metrics = dict(empty, by_status=dict(empty["by_status"]))
for row in db.query(
"SELECT status, COUNT(*) AS n, "
"COALESCE(SUM(bytes_in), 0) AS bytes_in, "
"COALESCE(SUM(bytes_out), 0) AS bytes_out, "
"COALESCE(SUM(item_count), 0) AS items, "
"COALESCE(AVG(CASE WHEN status = :done AND duration_ms > 0 "
"THEN duration_ms END), 0) AS avg_ms "
"FROM jobs WHERE kind = :kind GROUP BY status",
kind=kind,
done=DONE,
):
status = row["status"] or ""
count = int(row["n"] or 0)
metrics["total"] += count
metrics["by_status"][status] = metrics["by_status"].get(status, 0) + count
metrics["bytes_in"] += int(row["bytes_in"] or 0)
metrics["bytes_out"] += int(row["bytes_out"] or 0)
metrics["items"] += int(row["items"] or 0)
if status == DONE:
metrics["avg_ms"] = int(row["avg_ms"] or 0)
metrics["recent"] = list(
db.query(
"SELECT uid, status, preferred_name, bytes_out, item_count "
"FROM jobs WHERE kind = :kind ORDER BY id DESC LIMIT 10",
kind=kind,
)
)
return metrics
def list_jobs(kind: str = None, status: str = None, owner: tuple = None) -> list[dict]:
if "jobs" not in db.tables:
return []
filters = {}
if kind:
filters["kind"] = kind
if status:
filters["status"] = status
if owner:
filters["owner_kind"], filters["owner_id"] = owner
return [_hydrate(row) for row in _table().find(**filters)]