Skip to content

fix(admin): show a crashed refresh as interrupted at once - #69

Merged
plainprogrammer merged 14 commits into
mainfrom
060-catalog-running-after-crash
Oct 13, 2026
Merged

plainprogrammer merged 14 commits into
mainfrom
060-catalog-running-after-crash

Conversation

@plainprogrammer

Copy link
Copy Markdown
Owner

After a refresh's worker crashes, /admin/catalog now reads "Interrupted…" with "Refresh now" enabled and a link to the failed job, instead of "Running." with the button disabled for up to 15 minutes.

Closes #60

Cause

Catalog::Refresh#call closes its run only from inside the job. When the worker dies, Solid Queue fails the job itself (ProcessPrunedError, ProcessExitError, ProcessMissingError) and the run row stays running. Catalog::RefreshOperation#state asked the queue only once a run was 15 minutes without progress, so the page read "Running." and counted the run as in flight. A second defect had the same cause: a refresh started then was skipped as "already running" by Catalog::RefreshRun.start!.

What changed

  • Part A (the issue). A running run whose own job is in the queue's Failed list is interrupted: on the page, in the recent-runs list and in catalog:status (which now asks the operation, so the three agree). It is not in flight, so the button is enabled and the page stops polling, and the existing "See its failed job" link shows. A refresh started then closes that run and runs.
  • Part B (beyond the issue's text; yours to confirm). The same holds after the failed job is discarded: a running run that recorded a job id whose job is no longer an unfinished job in the queue is interrupted. Without this, Interrupted → Discard brings "Running." back. Runs with no job id keep the 15-minute rule.
  • 2-minute grace (Catalog::RefreshRun::JOB_ENDED_GRACE). A run counts as left behind by a failed or gone job only once its last progress is over 2 minutes old. This protects a refresh that is still alive after Solid Queue wrongly pruned its worker (host suspend, container pause). A real pruned crash is already 5 minutes stale, so it costs nothing there; after a crash the queue reports quickly (a killed forked worker, orphaned claims at boot) the page can read "Running." for up to 2 minutes.
  • Queued state. A left-behind run (crashed or stalled) reads "Queued." while another refresh job is in flight, instead of "Interrupted" with a disabled button and no reason.
  • The queue is read before the runs, so a job finishing normally between the two reads can't show as interrupted.
  • Spec 015 (version 1.2.1): glossary "Interrupted", AC-3.2, AC-3.3, a narrower AC-3.4, new AC-3.9, FR-1, Error Scenarios. README and CLAUDE.md match. No view, CSS, copy or migration change.

Decisions for you

  1. Keep part B? The reviewer recommends keeping it. Dropping it means reverting 0ec6839, abbb376 and 631d99d with a small manual resolution in start!, and keeping the queue.read calls in RefreshOperation#run and #recent_runs, which part A also needs.
  2. Two minutes for the grace.

Verification

  • bin/ci on 2d595d3: passed. RSpec 1262 examples, 0 failures, 1 pending (the art agreement spec, unrelated).
  • New examples failed before their code (part A 13, part B 12, review fixes 8 + 2 + 5).
  • Two independent read-only reviews (Opus): the first checked the state logic against Solid Queue 1.7.0's source for each ordering (claimed without a run, retry_on, admin retry, graceful release, finished jobs not preserved, the concurrency lock) and its findings were fixed; the second found no defects in the fixes.
  • Not verified: a live crash or suspend with a real worker in development, and the page in a browser. The evidence is specs and Solid Queue's source.

Known limits and follow-ups

  • Right after a suspended host resumes, a live run's last progress is as stale as its worker's heartbeat. Until the worker next records progress (normally 2 s, up to about 70 s in a hung request) the run can read interrupted, and a refresh started in that window runs beside it. Recorded in spec 015's Error Scenarios.
  • In that window, the first worker's later finish! would overwrite the closed run's status, because finish! doesn't check the run is still running. This predates the branch (the stall rule has the same exposure). Read in the code, not tested.
  • MTG::Art::Operation has the original flaw in its own code: "Building." with its button disabled for up to 10 minutes after a crash. Not changed here.
  • MTG::Scryfall::Client sleeps for Scryfall's Retry-After with no upper bound, which can stall a refresh without progress.
  • Catalog::RefreshJob.perform_now from a console records a job id with no queue row, so that run reads as interrupted after 2 minutes without progress. No shipped path does this.
  • Review suggestions left open: the queue_read_at guard is unreachable now that the grace exists (harmless defence in depth; two of its examples pass without it); the :running factory sits exactly on the grace boundary; facts can show "Retrying at" under a plain "Queued." summary; the glossary doesn't mention the queued exception.
  • Commits abbb376 and 631d99d say "the coordinator ruled", which is session wording for the agent that supervised this work.
  • This and A retried job shows "Attempts 0" #61's PR both edit spec 015's changelog; whichever merges second needs a small rebase. docs/roadmap.md still lists Catalog page reads "Running." after a crashed refresh #60.

🤖 Generated with Claude Code

plainprogrammer and others added 14 commits October 12, 2026 18:48
Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…tarts

A worker that dies never closes its run, and Solid Queue then fails the
job it held (ProcessPrunedError, ProcessExitError, ProcessMissingError).
Catalog::RefreshRun.start! now takes the failed jobs' ids and closes
their running runs as interrupted, as it does for a stalled run, and
status_label words such a run as interrupted.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Catalog::Refresh passes the queue's failed refresh jobs for its type to
Catalog::RefreshRun.start!, so a refresh started while a crashed run is
still marked running proceeds instead of recording "already running".

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After a crash /admin/catalog read "Running." with "Refresh now" disabled
until the run stalled (15 minutes), while its job already sat under
Failed. Catalog::RefreshOperation consulted the queue only for a stalled
run. A running run whose own job is in the failed list is now
interrupted: not in flight, so the page stops polling and the button is
enabled, and the panel links to the failed job.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The task asks Catalog::RefreshOperation for each status line, so a run
whose job the queue has failed reads interrupted there too.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spec 015 1.2.0: the glossary's Interrupted, AC-3.3, AC-3.4, a new AC-3.9
and the restart scenario. The README and CLAUDE.md say the same.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…iscarded

Discarding the failed job removed the only thing that said the run was
dead, so /admin/catalog went back to "Running." with "Refresh now"
disabled until the run stalled. A running run that recorded a job id is
now interrupted at once when the queue holds no unfinished job with that
id, on the page, in the recent-runs list and in catalog:status, and
Catalog::RefreshRun.start! closes it instead of skipping. A run that
recorded no job id keeps the 15-minute rule.

The queue is read before the runs (Catalog::RefreshOperation#run and
#recent_runs, Catalog::Refresh#call): Solid Queue finishes a job's row
only after perform returns, and the run is closed inside perform, so a
run still marked running after that read was left behind, never one
whose job ended between the two reads. A run that started after the
queue was read is not judged by that reading.

This goes beyond the issue's text: the coordinator ruled it in for
issue #60 and the maintainer confirms at PR review. It is its own commit
so it can be dropped without losing the failed-job fix.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Spec 015 1.2.1: the glossary's Interrupted, AC-3.3, AC-3.9 and a
narrower AC-3.4 (a fresh running run causes a skip only when it recorded
no job id or its job is still unfinished in the queue). Ruled by the
coordinator for issue #60; the maintainer confirms at PR review.

Refs #60.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`Queue#load` shadowed `Kernel#load`. No behaviour changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A refresh reads the queue, then the runs. A second refresh job queued,
claimed and recording its run between the two reads was not among the
jobs read, so the first refresh closed its live run as interrupted and
ran beside it. `RefreshRun.start!` now takes the time the queue was read
and only treats a run as left by a job that is gone when the run started
at or before that reading, as the page already does.

This belongs to part B only (the rule added in abbb376 for a job that is
no longer in the queue); revert it together with abbb376 and 631d99d.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Solid Queue fails a claimed job when its worker's heartbeat goes stale,
which also happens while the worker lives (a suspended host, a paused
container). The run of such a job was shown interrupted, "Refresh now"
was enabled, and the next refresh closed the live run and ran beside it.

A run whose job is failed, or no longer in the queue, is now judged as
left behind only once its own last progress is older than
Catalog::RefreshRun::JOB_ENDED_GRACE (2 minutes). Until then it is
running: in flight, the button disabled, the page polling, so the page
corrects itself. The same rule applies on the page, in status_label and
catalog:status, and in RefreshRun.start!. A job starting again still
closes its own run at once (AC-3.3).

The grace is above every gap between a working run's progress writes:
2 seconds while downloading and syncing, 0.9 and 4.5 seconds measured for
the retire and index stages on 110,000 entries, about 70 seconds for one
hung request to the source.

Also splits the refresh_run_spec example that tested two skip rules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
After "Refresh now" on a crashed run, the panel went on reading
"Interrupted" with the button disabled and nothing saying why, until a
worker claimed the new job. A run left behind (its job ended, or it
stalled with no worker holding its job) now reads "Queued." while a
refresh job for the type is in flight (AC-2.1, AC-2.2). The stalled path
did the same and now agrees. The recent-runs list still calls the old run
interrupted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The example called Solid Queue's own discard; it now posts to the discard
endpoint an admin uses.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…wording

Spec 015: a run whose job is failed or gone is interrupted once its last
progress is 2 minutes old, and running until then (glossary, AC-3.3,
AC-3.4, AC-3.9, FR-1); the button is enabled "unless another refresh job
for the type is in flight", and the refresh then shows as queued (AC-3.2,
AC-3.9); AC-3.3 names the own-job rule; a run that started after the
queue was read is not judged by that reading. Error Scenarios gain the
case of a job the queue failed while its worker still works, with what
remains of it right after a suspended host resumes.

README and CLAUDE.md say the same; CLAUDE.md also notes that
Catalog::Refresh#call reads Solid Queue's tables and how a perform_now
run is judged. The overlong README line is rewrapped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@plainprogrammer plainprogrammer self-assigned this Oct 12, 2026
@plainprogrammer
plainprogrammer marked this pull request as ready for review October 13, 2026 16:58
@plainprogrammer
plainprogrammer merged commit c046013 into main Oct 13, 2026
4 checks passed
@plainprogrammer
plainprogrammer deleted the 060-catalog-running-after-crash branch October 13, 2026 17:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Catalog page reads "Running." after a crashed refresh

1 participant