Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 KiB
Press controls + inter-job delay — design
Date: 2026-07-15
Problem
The monitor can enqueue a large backlog of acquisition jobs at once. The worker drains
them serially with no pacing (only a 2s idle poll when the queue is empty) and offers
no runtime control over the queue — the only per-job action on the Floor is Retry (on
needs_attention). The user wants:
- A configurable delay between downloads to spread sustained Qobuz load over time.
- Per-item controls on the Floor ("the Press"): pause and remove a queued item.
- Whole-press controls: pause/resume, clear the queue, and a force-stop of the currently-downloading album.
Constraints (from the existing code)
- The worker is a single, strictly-serial process (
worker/lyra_worker/main.py:256-298). One job runs to completion in a blockingrun_pipelinecall before the loop does anything else. - Config is read fresh every loop iteration (
main.py:262,get_config), so any Config change takes effect within ~2s with no restart. - An in-flight download cannot be cleanly interrupted —
run_pipelineand the adapter download are synchronous/blocking with only anon_progresscallback, no cancel token. The only interruption is process death, after whichreclaim_stuck_jobs(claim.py:8-24) requeues the in-flight job at startup andclear_staging_root(main.py:233) wipes the partial download. claim_next(claim.py:27-57) claims the oldeststate='requested'job.- Deleting a
Requestcascades to itsJobandCandidaterows (schema.prisma:51,66); the linkedMonitoredReleaseisSetNull, not cascaded. enqueue_due(monitor.py:115-147) re-enqueues a monitored release once it has no live (non-terminal) job and itslastSearchedAtis older thanretryIntervalHours(default 6). So a removed/cleared monitored item reappears later on the normal cadence unlessignored.- The web container runs
prisma migrate deployon startup (web/docker-entrypoint.sh), so a new migration auto-applies on deploy.
Decisions (confirmed with the user)
- Active item: add a global force-kill ("Stop now") that aborts the in-flight download via worker process-restart. Per-item controls act only on queued items.
- Remove / Clear semantics: cancel-for-now. Manual one-off requests are gone for good; monitored/wanted releases stay wanted and may reappear on the monitor's normal cadence. Permanent skip remains the existing per-release Ignore.
- Pause scope: downloads only. Pausing halts claiming/downloading; the monitor keeps finding and enqueueing releases (they pile up in the queue) and Resume drains them.
Data model
- New column
Job.paused Boolean @default(false)— a Prisma migration (add_job_paused). Auto-applies on deploy. - Config keys (key/value
Configrows, no migration):floor.paused—"true"/"false"; global press pause.floor.jobDelaySeconds— integer string, default"0"; delay between downloads.floor.killRequested—"true"/"false"; one-shot force-kill signal, cleared by the worker on consumption.
Worker changes
worker/lyra_worker/main.py and worker/lyra_worker/claim.py.
-
Inter-job delay. After
finish_job(main.py:297), readfloor.jobDelaySecondsfrom the already-freshconfigand sleep that many seconds before the next loop iteration. Sleep in small (~2s) chunks so the loop stays responsive; the force-kill watcher runs on a separate thread and works regardless. Parse with the existing_int-style guard (invalid → 0). A value of 0 preserves today's back-to-back behavior. -
Global pause. Immediately before
claim_next(main.py:286), iffloor.pausedis truthy (_TRUEidiom), skip claiming:time.sleep(IDLE_SLEEP); continue. Because the loop is serial, any in-flight job has already finished before this check — pause stops the next download. Monitor sweep / scan / discovery (above the claim in the loop body) continue to run, so the queue keeps filling while paused. -
Per-item pause.
claim_nextWHEREgainsAND NOT "paused". A paused queued job is skipped by the claim but remainsstate='requested', so it is not "lost" and resumes claiming as soon aspausedis cleared.reclaim_stuck_jobsis unaffected (it only touches in-flight states). -
Force-kill watcher. A daemon thread started in
run_forever(modeled on_liveness_heartbeat,main.py:216-226) pollsfloor.killRequestedon its own DB connection every ~1.5s. When set, it clears the flag (commit) and callsos._exit(0). The container restart policy brings the worker back; startupclear_staging_root+reclaim_stuck_jobswipe the partial download and requeue the aborted job. The "Stop now" action also setsfloor.paused=true, so the worker returns idle rather than instantly re-downloading the aborted album; the user resumes when ready.
Infrastructure (ops repo)
The prod worker's Docker restart policy is currently no (docker inspect →
RestartPolicy=no), so process death — from force-kill or any crash — leaves it down. The
force-kill design requires restart: unless-stopped on the worker service in the ops repo
(/opt/git/vm-download/docker/lyra/compose.yaml). The in-repo docker-compose.yml already has
this (docker-compose.yml:53); this change brings prod in line and is good hygiene
independent of this feature. This is a one-line change in a separate repo and must be applied
before the force-kill feature is usable in prod.
API (web) — follows existing retry / library DELETE patterns
GET /api/floor→{ paused: boolean, jobDelaySeconds: number }(reads the Config rows). Used by the Floor to render the paused banner and control states.POST /api/floorwith{ action }:"pause"/"resume"→ upsertfloor.paused."clear"→ delete everyRequestwhoseJob.state = 'requested'(queued, not yet claimed). Cascade removes the Jobs and Candidates. The in-flight and terminal jobs are untouched. Returns the number cleared."stop"→ upsertfloor.paused='true'andfloor.killRequested='true'.
POST /api/requests/[id]/pausewith{ paused: boolean }→ setJob.paused. Guard: the job must bestate='requested'(else400, matching the existing retry route), since pausing an in-flight job has no effect.DELETE /api/requests/[id]→ delete theRequest(cascade Job + Candidates). Guard: only whenJob.state = 'requested', so the active download is never touched (that is the job ofStop now). MirrorsDELETE /api/library/[id].
Web UI
web/src/app/queue.tsx and web/src/app/_ui/job-row.tsx; styling via existing .btn
variants in design.css.
- Press header (above the queue list):
Pause the press/Resumetoggle (fromGET /api/floor).Clear queue— confirm dialog showing the queued count; callsPOST /api/floor {action:"clear"}.Stop now ⚠— rendered only when a job is actively downloading (derivable from the existing/api/requestsdata: any job inmatching/matched/downloading/tagging). Confirm dialog ("aborts the current download and pauses the press"); callsPOST /api/floor {action:"stop"}.- A "Press paused — N queued" banner/chip when
paused.
- Per-item controls (queued rows only —
job.state === 'requested'), placed in the rownoteslot as.btn sm ghost:Pause/Resume→POST /api/requests/[id]/pause.Remove→DELETE /api/requests/[id].- A per-item paused row shows a subtle "paused" chip and the
Resumebutton.
- Active-download and
needs_attentionrows keep their current rendering (Retry unchanged). - Polling cadence stays 2s;
GET /api/flooris fetched alongside the existing three calls inrefresh().
Settings
Add a numeric field "Delay between downloads (seconds)" (default 0, min 0) to the settings
form (web/src/app/settings/settings-form.tsx), persisted to floor.jobDelaySeconds via the
existing settings config-write path (the same mechanism used for the other numeric/monitor
settings). Include short helptext explaining it paces sustained Qobuz load.
Testing
- Worker (pytest,
worker/tests/):claim_nextskipspaused=truequeued jobs and still claims the next unpaused one.- Global pause: the loop skips claiming when
floor.pausedis set (unit-test the claim-gate helper). - Delay: the delay value is read and applied (unit-test the parse/gate helper; keep the sleep injectable/mockable).
- Kill watcher: when
floor.killRequestedis set, the watcher clears the flag and triggers exit (mockos._exit, assert flag cleared + exit called).
- Web (route tests, mirroring
requests/[id]/retry/route.test.tsandlibrary/bulk/route.test.ts):POST /api/floorfor each action;cleardeletes onlyrequestedjobs;stopsets both flags.POST /api/requests/[id]/pausetogglesJob.paused; rejects non-requested.DELETE /api/requests/[id]removes a queued Request; refuses an in-flight/terminal job.GET /api/floorreturns the Config-derived state.
Edge cases
- Remove/Clear of monitored items reappear later by design (cancel-for-now). No extra handling; the per-release Ignore is the permanent path.
- Kill with nothing downloading still restarts the worker (brief blip). Minimized by only
showing
Stop nowwhile a download is active. - Delay during pause: the delay sleeps only between jobs; pause is checked before each claim, so a paused press never starts a delayed job.
- Race: clicking per-item Pause/Remove on a job that was just claimed — the guards
(
state='requested') reject it; the UI only renders those buttons on queued rows, and the 2s refresh reconciles.
Out of scope
- Resuming a partially-downloaded album (no download-resume support; force-killed jobs re-download from scratch).
- Reordering the queue / per-item priority.
- Any change to the per-release Ignore or the wanted list UI.