fix(activity): runs keep their true outcome and spend, the page reaches every kept row, a closing dialog leaves the server log alone
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.
This commit is contained in:
@@ -7,7 +7,10 @@ with rows that share a timestamp — the case an offset or a timestamp-only curs
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.auth import admin_user
|
||||
from app.main import app
|
||||
from app.models import AuditLog, Channel, JobRun, LogEvent, QuotaEvent
|
||||
from tests.test_body_limit import _route_objects
|
||||
|
||||
T0 = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
@@ -88,6 +91,32 @@ def test_an_admin_action_keeps_the_request_it_was_made_in(db, make_user, as_user
|
||||
assert row["request_id"] == r.headers["x-request-id"]
|
||||
|
||||
|
||||
def test_an_admin_action_says_how_many_of_its_requests_lines_are_still_kept(db, make_user, as_user):
|
||||
"""A request's lines are kept days, its audit row months: a link to lines gone would open an
|
||||
empty list, so the row says how many are left, as a run does."""
|
||||
admin = make_user(role="admin")
|
||||
kept = _audit(db, "config_set", actor=admin.id, request_id="req-kept-0001")
|
||||
gone = _audit(db, "config_set", actor=admin.id, request_id="req-gone-0001")
|
||||
db.add_all(
|
||||
[
|
||||
LogEvent(
|
||||
ts=T0,
|
||||
level="INFO",
|
||||
sev=20,
|
||||
source="api",
|
||||
logger="t",
|
||||
msg="m",
|
||||
request_id="req-kept-0001",
|
||||
)
|
||||
for _ in range(3)
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
items = as_user(admin).get("/api/admin/audit").json()["items"]
|
||||
lines = {r["id"]: r["log_lines"] for r in items}
|
||||
assert (lines[kept], lines[gone]) == (3, 0)
|
||||
|
||||
|
||||
def test_the_run_list_filters_pages_and_names_the_channel_and_the_log_lines(db, make_user, as_user):
|
||||
admin = make_user(role="admin")
|
||||
db.add(Channel(id="UCchan", title="Aileen Wang"))
|
||||
@@ -146,6 +175,8 @@ def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_
|
||||
|
||||
def totals(**params) -> dict:
|
||||
body = client.get("/api/quota/admin", params=params).json()
|
||||
# The chart is the same filter's: its days add up to the table's total.
|
||||
assert sum(d["total"] for d in body["daily"]) == sum(r["total"] for r in body["rows"])
|
||||
return {r["user_id"]: r["total"] for r in body["rows"]}
|
||||
|
||||
assert totals() == {admin.id: 150, None: 7} # the last 30 days by default
|
||||
@@ -153,3 +184,22 @@ def test_the_quota_breakdown_covers_its_range_and_one_account(db, make_user, as_
|
||||
assert totals(until=(now - timedelta(days=1)).isoformat()) == {admin.id: 50}
|
||||
assert totals(user="none") == {None: 7}
|
||||
assert totals(user=str(admin.id)) == {admin.id: 150}
|
||||
|
||||
|
||||
def test_every_admin_read_and_write_is_admin_only():
|
||||
"""The route table, not a list kept by hand: every /api/admin route and the quota breakdown
|
||||
resolve `admin_user`, so a dropped dependency cannot hand an account's runs, errors and e-mail
|
||||
addresses to any signed-in user."""
|
||||
routes = _route_objects(app)
|
||||
admin = {k: r for k, r in routes.items() if k[1].startswith(("/api/admin", "/api/quota/admin"))}
|
||||
assert ("GET", "/api/admin/runs") in admin and ("GET", "/api/quota/admin") in admin
|
||||
open_ = []
|
||||
for key, route in admin.items():
|
||||
stack, calls = [route.dependant], set()
|
||||
while stack:
|
||||
d = stack.pop()
|
||||
calls.add(d.call)
|
||||
stack.extend(d.dependencies)
|
||||
if admin_user not in calls:
|
||||
open_.append(key)
|
||||
assert open_ == []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""An admin action that counts something keeps the count as data in its audit row's `after`, under
|
||||
the key the audit log names in the reader's language (`scheduler.result.<key>`, frontend
|
||||
auditColumns.tsx) — not only in the English `summary`. A renamed key would fall back to the raw word.
|
||||
lib/jobResult.ts, called from lib/auditText.ts) — not only in the English `summary`. A renamed key would fall back to the raw word.
|
||||
DB lane (Postgres).
|
||||
"""
|
||||
|
||||
|
||||
@@ -136,6 +136,19 @@ def test_deep_request_starts_the_backfill_as_the_users_run_of_that_channel(
|
||||
assert calls == [(ch.id, me.id)]
|
||||
|
||||
|
||||
def test_turning_full_history_on_for_a_finished_channel_records_no_run(
|
||||
db, monkeypatch, override_dep
|
||||
):
|
||||
"""Its whole history is in already: a run would only say "nothing to do", and switching the flag
|
||||
off and on again would otherwise write one such row each time, kept for months."""
|
||||
ch, me = _setup_channel_and_sub(db, email="done@example.com")
|
||||
ch.backfill_done = True
|
||||
db.commit()
|
||||
resp, submitted, calls = _patch_deep_requested(db, monkeypatch, override_dep, me)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert (submitted, calls) == ([], [])
|
||||
|
||||
|
||||
class TestImmediateBackfillGuard:
|
||||
"""backfill_channel_now coordinates with the paced scheduler through _backfill_in_progress (per-
|
||||
channel mutual exclusion, both directions) + _immediate_running (a cap on interactive runs so a
|
||||
|
||||
@@ -96,3 +96,36 @@ def test_the_audit_logs_runs_move_into_the_run_table_and_back(db, make_user):
|
||||
),
|
||||
(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
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""The run words and the migrated database, DB-lane: the `job_runs` CHECKs are frozen in a migration,
|
||||
and the model's are built from the enums, so nothing else would notice the two drifting apart until a
|
||||
write of the new word failed in production."""
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.runstatus import RunStatus, RunTrigger
|
||||
|
||||
|
||||
def _words(definition: str) -> set[str]:
|
||||
return set(re.findall(r"'([a-z_]+)'", definition))
|
||||
|
||||
|
||||
def test_the_databases_checks_allow_exactly_the_run_words(db):
|
||||
defs = dict(
|
||||
db.execute(
|
||||
text(
|
||||
"SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint"
|
||||
" WHERE conname LIKE 'ck_job_runs_%'"
|
||||
)
|
||||
).all()
|
||||
)
|
||||
assert _words(defs["ck_job_runs_status"]) == set(RunStatus)
|
||||
assert _words(defs["ck_job_runs_trigger"]) == set(RunTrigger)
|
||||
assert _words(defs["ck_job_runs_finished"]) == {RunStatus.RUNNING}
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy import select
|
||||
|
||||
from app import jobruns, logs, logsink, quota, scheduler, state, sysconfig
|
||||
from app.models import AuditLog, Channel, JobRun, Notification, QuotaEvent
|
||||
from app.routes import scheduler as scheduler_routes
|
||||
from app.runstatus import RunStatus, RunTrigger
|
||||
|
||||
_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs
|
||||
@@ -256,6 +257,9 @@ def test_retention_reaps_finished_runs_only(db):
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
reaped = next(r for r in _runs(db) if r.status == "ok" and r.started_at < old + timedelta(1))
|
||||
with logs.bind(run_id=reaped.id):
|
||||
quota.record_usage(7)
|
||||
assert jobruns.gc(db, 0) == 0 # 0 = keep forever
|
||||
assert jobruns.gc(db, 30) == 1
|
||||
db.commit()
|
||||
@@ -263,6 +267,8 @@ def test_retention_reaps_finished_runs_only(db):
|
||||
("backfill", "running"),
|
||||
("rss_poll", "ok"),
|
||||
]
|
||||
# Its spend stays in the budget's history (kept on its own retention), no longer anyone's run.
|
||||
assert db.execute(select(QuotaEvent.units, QuotaEvent.job_run_id)).all() == [(7, None)]
|
||||
|
||||
|
||||
# ---- "run now" through the scheduler --------------------------------------------------------------
|
||||
@@ -413,6 +419,94 @@ def test_startup_closes_what_the_previous_process_left_running(db, monkeypatch):
|
||||
assert run.status == "interrupted"
|
||||
|
||||
|
||||
def test_an_interrupted_run_keeps_what_it_spent(db):
|
||||
"""Its spend is in the quota history, tagged with its id: the sweep sums it as `finish` would."""
|
||||
run_id = jobruns.start("backfill", RunTrigger.SCHEDULED, None)
|
||||
with logs.bind(run_id=run_id):
|
||||
quota.record_usage(40)
|
||||
quota.record_usage(2)
|
||||
assert jobruns.interrupt_leftovers() == 1
|
||||
(run,) = _runs(db, "backfill")
|
||||
assert (run.status, run.quota_units) == ("interrupted", 42)
|
||||
|
||||
|
||||
def _flaky_finish(monkeypatch) -> list:
|
||||
"""`jobruns.finish` whose first write fails (the database blinked); the statuses it was asked for."""
|
||||
calls: list = []
|
||||
real = jobruns.finish
|
||||
|
||||
def finish(run_id, status, **outcome):
|
||||
calls.append(status)
|
||||
if len(calls) == 1:
|
||||
raise RuntimeError("db blip")
|
||||
real(run_id, status, **outcome)
|
||||
|
||||
monkeypatch.setattr(jobruns, "finish", finish)
|
||||
return calls
|
||||
|
||||
|
||||
def test_a_run_whose_outcome_cannot_be_written_is_not_called_failed(db, make_user, monkeypatch):
|
||||
"""The job succeeded; only its bookkeeping did not. Writing ERROR then would say the work failed."""
|
||||
calls = _flaky_finish(monkeypatch)
|
||||
scheduler._job("autotag", lambda _db: {"tagged": 1})
|
||||
assert calls == ["ok"]
|
||||
assert not scheduler._is_running("autotag")
|
||||
(run,) = _runs(db, "autotag")
|
||||
assert run.status == "running" # left for the next start's sweep, not rewritten as a failure
|
||||
|
||||
user = make_user("blip@example.com")
|
||||
calls.clear()
|
||||
scheduler._ad_hoc("channel_backfill", lambda: {"videos_added": 1}, user.id, "UC_blip")
|
||||
assert calls == ["ok"]
|
||||
|
||||
|
||||
def test_a_run_that_cannot_be_recorded_does_not_wedge_its_job(db, monkeypatch):
|
||||
def broken(*_a, **_k):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(jobruns, "start", broken)
|
||||
scheduler._job("autotag", lambda _db: pytest.fail("no row, no run"))
|
||||
assert not scheduler._is_running("autotag")
|
||||
monkeypatch.undo()
|
||||
scheduler._job("autotag", lambda _db: {"tagged": 1})
|
||||
assert [r.status for r in _runs(db, "autotag")] == ["ok"]
|
||||
|
||||
|
||||
def test_the_page_says_where_the_interval_jobs_do_not_run(db, live_scheduler, monkeypatch):
|
||||
"""The scheduler starts on every install now; only SCHEDULER_ENABLED says the jobs run here."""
|
||||
assert scheduler.scheduler_snapshot(db)["running_here"] is False
|
||||
monkeypatch.setattr(scheduler.settings, "scheduler_enabled", True)
|
||||
assert scheduler.scheduler_snapshot(db)["running_here"] is True
|
||||
|
||||
|
||||
def test_an_ad_hoc_run_still_going_shows_although_a_newer_one_ended(db, make_user):
|
||||
"""Two channels can backfill at once; a later run that ended at once must not hide the first."""
|
||||
user = make_user("two@example.com")
|
||||
db.add(Channel(id="UC_long", title="Long one"))
|
||||
db.commit()
|
||||
jobruns.start("channel_backfill", RunTrigger.AD_HOC, user.id, "UC_long")
|
||||
scheduler._ad_hoc("channel_backfill", lambda: {"skipped": "at capacity"}, user.id, "UC_short")
|
||||
(row,) = scheduler.scheduler_snapshot(db)["ad_hoc"]
|
||||
assert (row["running"], row["target"], row["target_title"]) == (True, "UC_long", "Long one")
|
||||
|
||||
|
||||
def test_an_interval_change_lands_with_its_audit_row_or_not_at_all(
|
||||
db, make_user, as_user, live_scheduler, monkeypatch
|
||||
):
|
||||
live_scheduler.add_job(lambda: None, "interval", minutes=10, id="backfill")
|
||||
admin = as_user(make_user(role="admin"))
|
||||
|
||||
def broken(*_a, **_k):
|
||||
raise RuntimeError("audit write failed")
|
||||
|
||||
monkeypatch.setattr(scheduler_routes.audit, "record", broken)
|
||||
with pytest.raises(RuntimeError): # the handler raises: there is no response to check
|
||||
_ = admin.patch("/api/admin/scheduler/jobs/backfill", json={"interval_minutes": 45})
|
||||
db.expire_all()
|
||||
assert sysconfig.get_int(db, "job_interval_backfill") == scheduler.JOB_INTERVALS["backfill"]
|
||||
assert live_scheduler.get_job("backfill").trigger.interval == timedelta(minutes=10)
|
||||
|
||||
|
||||
def test_every_job_has_an_interval_and_every_interval_a_job():
|
||||
assert set(scheduler.JOB_FUNCS) == set(scheduler.JOB_INTERVALS)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user