refactor(jobs): derive job topology from one catalog, make next-run coherent
Groundwork for the Admin -> Jobs cleanup. Three sources of truth collapse into
app/job_catalog.py, which imports nothing from app so both the scheduler and
admin_service can import it at module level (admin_service otherwise has to
import the scheduler inside functions to dodge a cycle).
PIPELINE_MEMBERS is now DERIVED from the four pipeline step lists instead of
being a literal set in admin_service duplicating four lists in scheduler.py with
nothing asserting they agreed. A test pins that the derivation reproduces the
previous hand-maintained 9 names exactly, so this is behaviour-preserving.
Deletes the private _JOB_NAMES list, which held 16 of the 19 jobs:
benchmark_collector, outcome_evaluator and shadow_book had no runtime row, and
so no "last run" line in the panel, until their first run in a given process.
_job_runtime is now seeded from the catalog, and a test pins the invariant.
Next-run is decided by category rather than by reading a timestamp. A pipeline
step has no schedule of its own, so it reports its parent's ("next via Morning
Pipeline in 3h") instead of nothing; a manual job says manual_only rather than
rendering a date. This also fixes a real bug: triggering a paused job set
next_run_time=now, APScheduler re-armed the 520-week backstop behind it, and the
panel displayed "next run in ~87600h". Two independent guards -- the category
rule, plus _visible_next_run dropping anything past a year -- and an APScheduler
listener that re-pauses steps and manual jobs once their run finishes. The
listener is registered at module level because configure_scheduler is called
more than once and add_listener does not deduplicate.
Migrates backtest and ticker_universe_sync from interval to cron (Sun 03:00 ET
and 01:00 ET). configure_scheduler calls remove_all_jobs() on every startup, so
an interval countdown restarts each deploy -- a 168h backtest needed a week of
uninterrupted uptime to fire even once. The codebase already documented this
pitfall as the reason cron was adopted; these two were never migrated. Both are
now editable in Admin -> Schedule.
Also: list_jobs went from one settings query per job (19) to one for all of
them, and data_backfill is hidden from the listing while staying registered and
API-triggerable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from passlib.hash import bcrypt
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import job_catalog
|
||||
from app.exceptions import DuplicateError, NotFoundError, ValidationError
|
||||
from app.models.fundamental import FundamentalData
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
@@ -606,91 +607,108 @@ async def get_pipeline_readiness(db: AsyncSession) -> list[dict]:
|
||||
# Job control (placeholder — scheduler is Task 12.1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_JOB_NAMES = {
|
||||
"data_collector",
|
||||
"data_backfill",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"dolt_earnings_import",
|
||||
"sec_fundamentals_import",
|
||||
"rr_scanner",
|
||||
"ticker_universe_sync",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"event_study",
|
||||
"backtest",
|
||||
"daily_pipeline",
|
||||
"near_close_pipeline",
|
||||
"after_close_pipeline",
|
||||
"intraday_pipeline",
|
||||
"shadow_book",
|
||||
}
|
||||
# Job identity, labels and pipeline membership now live in app.job_catalog, which
|
||||
# derives PIPELINE_MEMBERS from the pipeline step lists instead of restating them.
|
||||
# Re-exported here because callers (routers, tests) import them from this module.
|
||||
VALID_JOB_NAMES = job_catalog.VALID_JOB_NAMES
|
||||
JOB_LABELS = job_catalog.JOB_LABELS
|
||||
PIPELINE_MEMBERS = job_catalog.PIPELINE_MEMBERS
|
||||
|
||||
JOB_LABELS = {
|
||||
"data_collector": "Data Collector (OHLCV)",
|
||||
"data_backfill": "Data Backfill (deep history)",
|
||||
"benchmark_collector": "Benchmark Collector",
|
||||
"sentiment_collector": "Sentiment Collector",
|
||||
"dolt_earnings_import": "Dolt Earnings Import",
|
||||
"sec_fundamentals_import": "SEC Fundamentals Import",
|
||||
"rr_scanner": "R:R Scanner",
|
||||
"ticker_universe_sync": "Ticker Universe Sync",
|
||||
"outcome_evaluator": "Outcome Evaluator",
|
||||
"alerts": "Alerts Dispatcher",
|
||||
# Keys are persisted job ids and must not change; these are display only.
|
||||
"market_regime": "Market Trend (SPY)",
|
||||
"regime_monitor": "AI/Tech Risk Monitor",
|
||||
"event_study": "Event Study",
|
||||
"backtest": "Backtest",
|
||||
"daily_pipeline": "Morning Pipeline",
|
||||
"near_close_pipeline": "Near-Close Pipeline (scan+alert)",
|
||||
"after_close_pipeline": "After-Close Pipeline (outcome)",
|
||||
"intraday_pipeline": "Intraday Pipeline",
|
||||
"shadow_book": "Shadow Book (auto-traded strategy)",
|
||||
}
|
||||
# Anything further out than this is a parked backstop, not a schedule: pipeline
|
||||
# steps and manual jobs are registered on a 520-week interval, and triggering one
|
||||
# re-arms it. Belt-and-braces behind the category rule in _next_run_fields.
|
||||
_NEXT_RUN_HORIZON_DAYS = 365
|
||||
|
||||
# Jobs driven by a pipeline (in order) rather than their own auto timer.
|
||||
PIPELINE_MEMBERS = {
|
||||
"data_collector",
|
||||
"benchmark_collector",
|
||||
"sentiment_collector",
|
||||
"rr_scanner",
|
||||
"outcome_evaluator",
|
||||
"alerts",
|
||||
"market_regime",
|
||||
"regime_monitor",
|
||||
"shadow_book",
|
||||
}
|
||||
|
||||
def _visible_next_run(next_run: datetime | None) -> datetime | None:
|
||||
"""Drop a next-run that is really the parked backstop."""
|
||||
if next_run is None:
|
||||
return None
|
||||
horizon = datetime.now(next_run.tzinfo) + timedelta(days=_NEXT_RUN_HORIZON_DAYS)
|
||||
return None if next_run > horizon else next_run
|
||||
|
||||
|
||||
def _own_next_run(scheduler, name: str) -> datetime | None:
|
||||
# getattr: APScheduler only sets next_run_time once the scheduler is running,
|
||||
# so a job registered but not yet started has no such attribute at all.
|
||||
job = scheduler.get_job(name)
|
||||
return _visible_next_run(getattr(job, "next_run_time", None)) if job else None
|
||||
|
||||
|
||||
def _next_run_fields(scheduler, name: str, enabled_map: dict[str, bool]) -> dict:
|
||||
"""Where this job's next run comes from, decided by category not by clock.
|
||||
|
||||
A pipeline step has no meaningful schedule of its own, so reporting one is
|
||||
the bug: its parent's timer is the answer. Manual jobs have no answer at all,
|
||||
and saying so beats rendering a parked backstop as a date.
|
||||
"""
|
||||
category = job_catalog.JOB_CATEGORY.get(name)
|
||||
if category == job_catalog.CATEGORY_STEP:
|
||||
parents = job_catalog.PIPELINES_BY_MEMBER.get(name, ())
|
||||
soonest: datetime | None = None
|
||||
via: str | None = None
|
||||
for parent in parents:
|
||||
if not enabled_map.get(parent, True):
|
||||
continue
|
||||
candidate = _own_next_run(scheduler, parent)
|
||||
if candidate is not None and (soonest is None or candidate < soonest):
|
||||
soonest, via = candidate, parent
|
||||
return {
|
||||
"next_run_at": None,
|
||||
"next_run_source": "via_pipeline",
|
||||
"via_next_run_at": soonest.isoformat() if soonest else None,
|
||||
"via_next_run_job": via,
|
||||
}
|
||||
if category == job_catalog.CATEGORY_MANUAL:
|
||||
return {
|
||||
"next_run_at": None,
|
||||
"next_run_source": "manual_only",
|
||||
"via_next_run_at": None,
|
||||
"via_next_run_job": None,
|
||||
}
|
||||
own = _own_next_run(scheduler, name)
|
||||
return {
|
||||
"next_run_at": own.isoformat() if own else None,
|
||||
"next_run_source": "own_schedule",
|
||||
"via_next_run_at": None,
|
||||
"via_next_run_job": None,
|
||||
}
|
||||
|
||||
|
||||
async def list_jobs(db: AsyncSession) -> list[dict]:
|
||||
"""Return status of all scheduled jobs."""
|
||||
"""Return status of all scheduled jobs, grouped and ordered by category."""
|
||||
from app.scheduler import get_job_runtime_snapshot, scheduler
|
||||
|
||||
visible = sorted(VALID_JOB_NAMES - job_catalog.HIDDEN_JOBS, key=job_catalog.sort_order)
|
||||
# One query for every flag instead of one per job. Parents are read too, since
|
||||
# a step reports its parent's next run only while that parent is enabled.
|
||||
flags = await settings_store.get_map(
|
||||
db, [f"job_{name}_enabled" for name in VALID_JOB_NAMES]
|
||||
)
|
||||
enabled_map = {
|
||||
name: flags.get(f"job_{name}_enabled", "true") == "true"
|
||||
for name in VALID_JOB_NAMES
|
||||
}
|
||||
|
||||
jobs_out = []
|
||||
for name in sorted(VALID_JOB_NAMES):
|
||||
# Check enabled setting
|
||||
setting = await settings_store.get_setting(db, f"job_{name}_enabled")
|
||||
enabled = setting.value == "true" if setting else True # default enabled
|
||||
|
||||
# Get scheduler job info
|
||||
for name in visible:
|
||||
job = scheduler.get_job(name)
|
||||
next_run = None
|
||||
if job and job.next_run_time:
|
||||
next_run = job.next_run_time.isoformat()
|
||||
|
||||
runtime = get_job_runtime_snapshot(name)
|
||||
|
||||
jobs_out.append({
|
||||
"name": name,
|
||||
"label": JOB_LABELS.get(name, name),
|
||||
"enabled": enabled,
|
||||
"next_run_at": next_run,
|
||||
"via_pipeline": name in PIPELINE_MEMBERS,
|
||||
"enabled": enabled_map.get(name, True),
|
||||
"category": job_catalog.JOB_CATEGORY.get(name),
|
||||
"sort_order": job_catalog.sort_order(name),
|
||||
# Parent pipelines for a step; the steps themselves for a pipeline.
|
||||
"pipelines": list(job_catalog.PIPELINES_BY_MEMBER.get(name, ())),
|
||||
"steps": [step for step, _ in job_catalog.PIPELINE_STEPS.get(name, ())],
|
||||
"registered": job is not None,
|
||||
"running": bool(runtime.get("running", False)),
|
||||
# runtime_* are strictly live in-memory state. Persisted history is
|
||||
# reported separately as last_run_*, so a stale error cannot pin the
|
||||
# status chip or the rate-limit banner.
|
||||
"runtime_status": runtime.get("status"),
|
||||
"runtime_processed": runtime.get("processed"),
|
||||
"runtime_total": runtime.get("total"),
|
||||
@@ -699,6 +717,7 @@ async def list_jobs(db: AsyncSession) -> list[dict]:
|
||||
"runtime_started_at": runtime.get("started_at"),
|
||||
"runtime_finished_at": runtime.get("finished_at"),
|
||||
"runtime_message": runtime.get("message"),
|
||||
**_next_run_fields(scheduler, name, enabled_map),
|
||||
})
|
||||
|
||||
return jobs_out
|
||||
|
||||
Reference in New Issue
Block a user