Add scheduler_service and pipeline_runner
pipeline_runner: subprocess execution wrapper replacing flock -n with a single asyncio.Lock (deliberately one global lock, not per-resource -- matches the old flock's all-jobs-share-one-lock behavior rather than over-engineering it). Records one job_runs row per invocation (started/finished/status/exit_code/summary/log_path/triggered_by). If the lock is already held, records status='skipped_lock' immediately instead of silently dropping the run. Found and fixed a real concurrency bug via testing: the obvious asyncio.wait_for(lock.acquire(), timeout=0) idiom for a non-blocking try-acquire is broken in asyncio -- the wrapping Task's first iteration and the timeout-0 callback race with no guaranteed ordering, so it timed out on literally every call, including the very first uncontended one. Fixed using lock.locked() + acquire(), relying on acquire()'s fast path never suspending when uncontended. scheduler_service: AsyncIOScheduler with the default in-memory job store (NOT SQLAlchemyJobStore -- it pickles job functions to persist them, and the _lib()/_beet() factory closures here aren't picklable; MemoryJobStore avoids this since register_all_jobs() rebuilds the whole schedule from code + DB on every startup anyway). All 19 maintenance jobs ported 1:1 from /etc/cron.d/sldl-maintenance with their exact cron expressions; dedup deliberately has no --apply (dry-run only on schedule, per the false-negative-biased dedup preference). Playlist jobs are synced live from the playlists table (sync_playlist_jobs), so add/remove-in-the-UI takes effect with no redeploy -- wired into playlist_service's create/update/ delete. Maintenance job enable/disable persists to the scheduled_jobs table so a paused job stays paused across a restart despite the in-memory job store. trigger_now() supports "Run now" by invoking a job's function immediately with triggered_by='manual', bypassing its schedule. Wired into main.py's lifespan (start on boot, shutdown on exit). Verified via integration tests: 36 jobs register correctly (17 seeded playlists + 19 maintenance) with correct cron triggers; live playlist create/delete correctly adds/removes its scheduler job; maintenance enable/disable persists to the DB and takes effect live; trigger_now correctly bypasses the schedule; pipeline_runner correctly handles success/failure/timeout/concurrent-lock-contention with real subprocesses; and the full app boots with the scheduler running and shuts down cleanly.
This commit is contained in:
@@ -10,25 +10,27 @@ from app.settings import settings
|
||||
|
||||
# The 17-entry array this project is migrating off of (was hardcoded in
|
||||
# /opt/sldl/configs/regen.sh). Used once by seed_legacy() during Stage 1/2
|
||||
# of the migration; the DB is the source of truth from then on.
|
||||
# of the migration; the DB is the source of truth from then on. cron_expr
|
||||
# values are the exact staggered slots from /etc/cron.d/sldl-maintenance,
|
||||
# preserved so cutover doesn't change anyone's download schedule.
|
||||
LEGACY_PLAYLISTS = {
|
||||
"techno": "https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ",
|
||||
"house": "https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ",
|
||||
"lofi": "https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1",
|
||||
"y2k": "https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU",
|
||||
"weeb": "https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf",
|
||||
"shoegaze": "https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5",
|
||||
"bass": "https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2",
|
||||
"hiphop": "https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi",
|
||||
"modular": "https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb",
|
||||
"hotdog": "https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX",
|
||||
"kpop": "https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo",
|
||||
"jungle": "https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE",
|
||||
"goldenera": "https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR",
|
||||
"botanica": "https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU",
|
||||
"digicore": "https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4",
|
||||
"hardcore": "https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex",
|
||||
"liked": "https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo",
|
||||
"digicore": ("https://open.spotify.com/playlist/6tWHtPBECZTkZHPKFA3Fq4", "0 0 * * *", False),
|
||||
"techno": ("https://open.spotify.com/playlist/5GlAkczmLWJFIJO4FvkLEZ", "0 1 * * *", False),
|
||||
"house": ("https://open.spotify.com/playlist/2XF8ye6O5xqzT4NSfIXdfJ", "30 1 * * *", False),
|
||||
"lofi": ("https://open.spotify.com/playlist/0qnyF6TAmuT3U6hFH2fiF1", "0 2 * * *", False),
|
||||
"y2k": ("https://open.spotify.com/playlist/0iaA7ZJS00aRxhMwZn2GCU", "30 2 * * *", False),
|
||||
"weeb": ("https://open.spotify.com/playlist/0599AbpsKRjsp2rlJI8jhf", "0 3 * * *", False),
|
||||
"shoegaze": ("https://open.spotify.com/playlist/3R4S3tmbVKjXQ1RPAmryC5", "30 3 * * *", False),
|
||||
"bass": ("https://open.spotify.com/playlist/2zkF4S16efaMmmzdIOe5w2", "0 4 * * *", False),
|
||||
"hiphop": ("https://open.spotify.com/playlist/42JxTtOLJ7gG1ybfyy7Vmi", "30 4 * * *", False),
|
||||
"modular": ("https://open.spotify.com/playlist/2kOfP2EM8dzdMYC6fXvlLb", "0 5 * * *", False),
|
||||
"hotdog": ("https://open.spotify.com/playlist/53HrOL0qw47G9ywYZz3kgX", "30 5 * * *", False),
|
||||
"kpop": ("https://open.spotify.com/playlist/31e2V512TIqr5JIfgkyseo", "0 6 * * *", False),
|
||||
"jungle": ("https://open.spotify.com/playlist/4LjLeXg6ElneQiTaIcAHhE", "30 6 * * *", False),
|
||||
"goldenera": ("https://open.spotify.com/playlist/57vArhigysJfgwB6CY4VXR", "0 7 * * *", False),
|
||||
"botanica": ("https://open.spotify.com/playlist/5KgQT9YWz3EqhIN4Jh69IU", "30 7 * * *", False),
|
||||
"hardcore": ("https://open.spotify.com/playlist/1J1lbzlQKligzr2LWaq9Ex", "0 23 * * *", False),
|
||||
"liked": ("https://open.spotify.com/playlist/4Z3qCYuU1sjNeNYO3Amzeo", "30 23 * * *", True),
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +53,7 @@ def create(
|
||||
active: bool = True,
|
||||
no_m3u: bool = False,
|
||||
notes: str | None = None,
|
||||
cron_expr: str | None = None,
|
||||
) -> Playlist:
|
||||
now = time.time()
|
||||
playlist = Playlist(
|
||||
@@ -59,6 +62,7 @@ def create(
|
||||
active=active,
|
||||
no_m3u=no_m3u,
|
||||
notes=notes,
|
||||
cron_expr=cron_expr,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
@@ -96,16 +100,21 @@ def delete(db: Session, playlist_id: int) -> None:
|
||||
conf_path.unlink(missing_ok=True)
|
||||
_write_playlists_json(db)
|
||||
|
||||
from app.services import scheduler_service
|
||||
|
||||
scheduler = scheduler_service.get_scheduler()
|
||||
if scheduler is not None:
|
||||
scheduler_service.sync_playlist_jobs(scheduler)
|
||||
|
||||
|
||||
def seed_legacy(db: Session) -> int:
|
||||
"""One-time import of the legacy hardcoded array (migration Stage 1/2).
|
||||
Skips any name that already exists. Returns the number of rows created."""
|
||||
created = 0
|
||||
for name, url in LEGACY_PLAYLISTS.items():
|
||||
for name, (url, cron_expr, no_m3u) in LEGACY_PLAYLISTS.items():
|
||||
if get_by_name(db, name) is not None:
|
||||
continue
|
||||
no_m3u = name == "liked" # matches --no-m3u liked in the old crontab
|
||||
create(db, name=name, spotify_url=url, no_m3u=no_m3u)
|
||||
create(db, name=name, spotify_url=url, no_m3u=no_m3u, cron_expr=cron_expr)
|
||||
created += 1
|
||||
return created
|
||||
|
||||
@@ -152,14 +161,20 @@ def regenerate_confs(db: Session) -> subprocess.CompletedProcess:
|
||||
|
||||
|
||||
def _sync_to_disk(db: Session) -> None:
|
||||
"""Convenience wrapper used by create()/update(): regenerate confs and
|
||||
immediately re-patch credentials into any newly-rendered file."""
|
||||
"""Convenience wrapper used by create()/update(): regenerate confs,
|
||||
immediately re-patch credentials into any newly-rendered file, and push
|
||||
the change to the live scheduler if one is running -- this is what
|
||||
makes add/remove-in-the-UI take effect without a redeploy."""
|
||||
regenerate_confs(db)
|
||||
# Imported lazily to avoid a circular import (credential_service doesn't
|
||||
# depend on playlist_service, but importing at module load time would
|
||||
# still work here — done lazily anyway to keep the dependency direction
|
||||
# obvious as both services grow).
|
||||
from app.services import credential_service
|
||||
# Imported lazily to avoid a circular import (credential_service and
|
||||
# scheduler_service don't depend on playlist_service, but importing at
|
||||
# module load time would still work here — done lazily anyway to keep
|
||||
# the dependency direction obvious as all three services grow).
|
||||
from app.services import credential_service, scheduler_service
|
||||
|
||||
credential_service.render_scope(db, "soulseek")
|
||||
credential_service.render_scope(db, "spotify")
|
||||
|
||||
scheduler = scheduler_service.get_scheduler()
|
||||
if scheduler is not None:
|
||||
scheduler_service.sync_playlist_jobs(scheduler)
|
||||
|
||||
Reference in New Issue
Block a user