Backend - A run's outcome write sits outside the job's own try: a database blip after a run that succeeded no longer records it as failed (the row waits for the next start's sweep). - An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does). - The Scheduler's ad-hoc row says running while any backfill runs, not only the newest. - recent_runs reads each job's last runs off its index instead of ranking the whole table. - An interval change is stored with its audit row in one transaction, then rescheduled. - One API instance per database is the stated contract (.env.example, jobruns). - Turning full history on for a channel already complete records no run. - Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are test-held to the enums; the account filter is one helper for the four activity reads. - An audit row says how many of its request's log lines are still kept. Frontend - An "All" period reaches every row still kept (runs and admin actions outlive 90 days). - Back/Forward is followed in place by the server log: a closing dialog no longer turns Live off; a pivot still opens a fresh log. - Runs moved over from the audit log read their one-liner in the reader's language. - Filter choices or accounts that failed to load say so beside the filters, with a retry. - An old server-log link keeps its account, text and range; a remembered retired page opens its tab. - A request whose lines are gone says so instead of linking to an empty list. - The log lines and the history rows share one row frame, and the server log one list frame with the other tabs; an empty list is announced; each day's quota is readable.
132 lines
4.7 KiB
Python
132 lines
4.7 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"}),
|
|
]
|
|
|
|
|
|
def test_a_down_and_up_round_trip_keeps_every_run(db, make_user):
|
|
"""A rollback then a redeploy: an interrupted run, a run caught mid-way and a user's run of one
|
|
channel come back as runs — not stranded in the audit log under an action no code knows."""
|
|
uid = make_user().id
|
|
with engine.begin() as conn:
|
|
conn.execute(
|
|
text(
|
|
"INSERT INTO job_runs (job, trigger, actor_id, target, status, started_at,"
|
|
" finished_at) VALUES"
|
|
" ('backfill', 'scheduled', NULL, NULL, 'interrupted', now(), now()),"
|
|
" ('maintenance', 'manual', :u, NULL, 'running', now(), NULL),"
|
|
" ('channel_backfill', 'ad_hoc', :u, 'UC_x', 'ok', now(), now())"
|
|
),
|
|
{"u": uid},
|
|
)
|
|
cfg = Config("alembic.ini")
|
|
command.downgrade(cfg, "0108_folder_links")
|
|
command.upgrade(cfg, "head")
|
|
with engine.connect() as conn:
|
|
runs = conn.execute(
|
|
text("SELECT job, trigger, actor_id, target, status FROM job_runs ORDER BY id")
|
|
).all()
|
|
stranded = conn.execute(
|
|
text("SELECT count(*) FROM audit_log WHERE action = 'job_run'")
|
|
).scalar()
|
|
assert [tuple(r) for r in runs] == [
|
|
("backfill", "scheduled", None, None, "interrupted"),
|
|
("maintenance", "manual", uid, None, "interrupted"), # the rollback stopped it
|
|
("channel_backfill", "ad_hoc", uid, "UC_x", "ok"),
|
|
]
|
|
assert stranded == 0
|