feat(scheduler): every background run is a durable row
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
This commit is contained in:
@@ -24,6 +24,7 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||
|
||||
from app.db import Base
|
||||
from app.runstatus import RunStatus, RunTrigger, sql_in
|
||||
from app.utils import canonical_email
|
||||
|
||||
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
|
||||
@@ -563,6 +564,11 @@ class QuotaEvent(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
# The background run that spent it (app/jobruns.py); NULL = a request's own spend. SET NULL: a
|
||||
# reaped run leaves the spend in the budget's history.
|
||||
job_run_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("job_runs.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
@@ -609,6 +615,8 @@ class LogEvent(Base):
|
||||
"debug_session_id",
|
||||
postgresql_where="debug_session_id IS NOT NULL",
|
||||
),
|
||||
# A run's lines are `WHERE run_id = …`; request lines carry none.
|
||||
Index("ix_log_events_run", "run_id", postgresql_where="run_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
@@ -624,7 +632,11 @@ class LogEvent(Base):
|
||||
request_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(Integer)
|
||||
debug_session_id: Mapped[str | None] = mapped_column(String(64))
|
||||
# A DOWNLOAD job's id (download_jobs) — not a background run, which is `run_id`.
|
||||
job_id: Mapped[int | None] = mapped_column(Integer)
|
||||
# The background run (job_runs) that logged it. No foreign key, as `user_id`: the writer runs
|
||||
# behind the event, and a run reaped meanwhile must not fail the batch its lines travel in.
|
||||
run_id: Mapped[int | None] = mapped_column(BigInteger)
|
||||
version: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
|
||||
@@ -700,6 +712,46 @@ class SchedulerSetting(Base):
|
||||
interval_minutes: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
|
||||
class JobRun(Base):
|
||||
"""One run of a background job — scheduled, an admin's "run now", or a user's ad-hoc work on one
|
||||
target — written when it starts and again when it ends (app/jobruns.py), each in its own
|
||||
committed session, so the history survives the job's own rollback and a restart. A row left
|
||||
`running` by a process that died is set `interrupted` at the next startup.
|
||||
|
||||
`quota_units` is what the run spent of the YouTube budget: the `quota_events` rows carrying its
|
||||
id, summed when it ends. `summary` is the English one-line fallback; `result` / `failure` are the
|
||||
same outcome as data, which the client names in the reader's language."""
|
||||
|
||||
__tablename__ = "job_runs"
|
||||
__table_args__ = (
|
||||
Index("ix_job_runs_job_started", "job", "started_at"),
|
||||
CheckConstraint(f"status IN ({sql_in(RunStatus)})", name="ck_job_runs_status"),
|
||||
CheckConstraint(f"trigger IN ({sql_in(RunTrigger)})", name="ck_job_runs_trigger"),
|
||||
# Only a run still going has no end.
|
||||
CheckConstraint(
|
||||
"(status = 'running') = (finished_at IS NULL)", name="ck_job_runs_finished"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
job: Mapped[str] = mapped_column(String(40))
|
||||
trigger: Mapped[str] = mapped_column(String(16))
|
||||
# Who started it: the admin of a manual run, the user of an ad-hoc one; NULL = the schedule.
|
||||
actor_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
# What an ad-hoc run worked on (a channel id); NULL for the recurring jobs.
|
||||
target: Mapped[str | None] = mapped_column(String(128))
|
||||
status: Mapped[str] = mapped_column(String(16))
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
result: Mapped[dict | None] = mapped_column(JSONB)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
failure: Mapped[dict | None] = mapped_column(JSONB)
|
||||
quota_units: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
|
||||
|
||||
class SystemConfig(Base):
|
||||
"""Admin-set overrides for operational config that otherwise comes from env/config
|
||||
defaults. Generic key/value store (one row per key); absent = use the env/config
|
||||
|
||||
Reference in New Issue
Block a user