What a job last did lived in the API process's memory and was gone at every restart; the audit log kept a run only when it was manual, failed or changed something. Now each run is a job_runs row written at its start and its end in its own session: who started it, its outcome as data, what it spent of the YouTube quota (quota_events.job_run_id) and the lines it logged (log_events.run_id, filterable in the log console). - a run a stopped process left open is marked interrupted at startup - "run now" is a one-shot job on the scheduler, which now always starts (interval jobs only with SCHEDULER_ENABLED); the manual actor is reset after the run, since pool threads are reused - the dashboard's last run comes from job_runs, so it survives a deploy - runs leave the audit log: migration 0109 moves the old job_run rows over (and back on downgrade); the audit page's run folding goes - job_run_retention_days (default 30) prunes old runs in audit_gc
99 lines
3.3 KiB
Python
99 lines
3.3 KiB
Python
"""Migration 0109's data step, DB-lane: the audit log's run rows move into `job_runs` with their
|
|
outcome intact, and leave the audit log; an admin action stays where it was. The downgrade moves
|
|
them back."""
|
|
|
|
import json
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
|
|
|
|
def _audit(conn, actor, action, target, after):
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO audit_log (actor_id, action, target_type, target_id, summary, after,"
|
|
" created_at) VALUES (:a, :act, 'job', :t, 's', CAST(:after AS json),"
|
|
" CAST('2026-10-01T03:00:00+00:00' AS timestamptz))"
|
|
),
|
|
{"a": actor, "act": action, "t": target, "after": json.dumps(after)},
|
|
)
|
|
|
|
|
|
def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user):
|
|
uid = make_user().id
|
|
cfg = Config("alembic.ini")
|
|
command.downgrade(cfg, "0108_folder_links")
|
|
try:
|
|
with engine.begin() as conn:
|
|
_audit(
|
|
conn,
|
|
None,
|
|
"job_run",
|
|
"rss_poll",
|
|
{"status": "ok", "summary": "new=3", "result": {"new": 3}},
|
|
)
|
|
_audit(
|
|
conn,
|
|
uid,
|
|
"job_run",
|
|
"rss_poll",
|
|
{"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}},
|
|
)
|
|
_audit(
|
|
conn, uid, "job_run", "backfill", {"status": "skipped", "summary": "sync paused"}
|
|
)
|
|
_audit(conn, uid, "config_set", None, {"key": "x"})
|
|
finally:
|
|
command.upgrade(cfg, "head")
|
|
|
|
with engine.connect() as conn:
|
|
runs = conn.execute(
|
|
text(
|
|
"SELECT job, trigger, actor_id, status, summary, result, error, failure,"
|
|
" to_char(started_at AT TIME ZONE 'UTC', 'MM-DD HH24'), finished_at = started_at"
|
|
" FROM job_runs ORDER BY id"
|
|
)
|
|
).all()
|
|
left = conn.execute(text("SELECT action FROM audit_log")).scalars().all()
|
|
assert [tuple(r) for r in runs] == [
|
|
("rss_poll", "scheduled", None, "ok", "new=3", {"new": 3}, None, None, "10-01 03", True),
|
|
(
|
|
"rss_poll",
|
|
"manual",
|
|
uid,
|
|
"error",
|
|
None,
|
|
None,
|
|
"boom",
|
|
{"failure": "rss_all_failed"},
|
|
"10-01 03",
|
|
True,
|
|
),
|
|
("backfill", "manual", uid, "skipped", "sync paused", None, None, None, "10-01 03", True),
|
|
]
|
|
assert left == ["config_set"]
|
|
|
|
command.downgrade(cfg, "0108_folder_links")
|
|
try:
|
|
with engine.connect() as conn:
|
|
back = conn.execute(
|
|
text(
|
|
"SELECT actor_id, target_id, after::jsonb FROM audit_log"
|
|
" WHERE action = 'job_run' ORDER BY id"
|
|
)
|
|
).all()
|
|
finally:
|
|
command.upgrade(cfg, "head")
|
|
assert [tuple(r) for r in back] == [
|
|
(None, "rss_poll", {"status": "ok", "summary": "new=3", "result": {"new": 3}}),
|
|
(
|
|
uid,
|
|
"rss_poll",
|
|
{"status": "error", "summary": "boom", "result": {"failure": "rss_all_failed"}},
|
|
),
|
|
(uid, "backfill", {"status": "skipped", "summary": "sync paused"}),
|
|
]
|