Skip to content

fix(archiver): replace stale jobs so retry can escape a wedged download - #84

Open
dylanjeffers wants to merge 1 commit into
mainfrom
fix/archiver-replace-stale-jobs
Open

fix(archiver): replace stale jobs so retry can escape a wedged download#84
dylanjeffers wants to merge 1 commit into
mainfrom
fix/archiver-replace-stale-jobs

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Closes #81.

Problem

Job ids are deterministic — base64url("{userId}-{trackId}") — and getOrCreateStemsArchiveJob only replaced a job whose state was failed:

const state = await existingJob.getState()
if (state !== 'failed') {
  return getJobStatus(jobId, existingJob)   // same stuck job, every time
}

So any job parked in a non-terminal state is handed back to every subsequent retry from that user for that track.

This is what made the 2026-07-28 outage (#77) unrecoverable from the client. When archiver-redis was recreated the worker lost its locks, kept all five concurrency slots, and jobs sat in waiting — never failed. The "Try again" link in DownloadTrackArchiveModal is the only recovery affordance the UI offers, and it was a no-op: POST returned the same dead job id, the client re-polled it, and timed out again 15 minutes later. 24 requests across 15 tracks were stuck this way for ~7 hours.

Cancel-then-retry from the client doesn't work around it either — cancelStemsArchiveJob removes a job from the queue only when it's completed; otherwise it aborts via an in-memory AbortController, and a job stranded in waiting has none, so it falls through to 'Stems archive job not found' and removes nothing.

Change

A job that has been non-terminal past staleJobSeconds is now removed and recreated. Behavior by state:

state before after
completed returned returned (unchanged — download the ready archive, don't rebuild)
failed replaced replaced (unchanged)
non-terminal, fresh returned returned (unchanged — this is what dedupes double-clicks / multiple tabs)
non-terminal, stale returned replaced

Staleness is measured from processedOn ?? timestamp, so a job that never started — the case that actually broke — is covered, not just one that stalled mid-run.

New config staleJobSeconds (ARCHIVER_STALE_JOB_SECONDS, default 900s). Chosen to match STEMS_ARCHIVE_POLL_TIMEOUT_MS in the client's useDownloadTrackStems: past that point the client has already given up on the job, so its output would never be collected anyway. The coupling is called out in a comment on both sides.

Notes for review

Testing

New src/jobs/createStemsArchive.test.ts, 11 cases: isStaleJob boundaries (processedOn preferred over timestamp, fallback for never-started jobs, missing timestamps treated as fresh) and each getOrCreateStemsArchiveJob branch, including the exact wedged-in-waiting case from the outage.

Test Files  3 passed (3)
     Tests  31 passed (31)

tsc --noEmit clean. ESLint could not be run from a git worktree — eslint-plugin-turbo throws Cannot convert undefined or null to object outside a turbo workspace root, identically on unmodified main, so it's environmental and left to CI.

🤖 Generated with Claude Code

Job ids are deterministic (`base64url("{userId}-{trackId}")`), and
getOrCreateStemsArchiveJob only replaced a job whose state was `failed`.
Any job parked in a non-terminal state was therefore handed straight back
to every subsequent retry from that user for that track.

That is what made the 2026-07-28 outage unrecoverable from the client. The
worker lost its Redis lock when archiver-redis was recreated, kept all five
concurrency slots, and jobs sat in `waiting` — never `failed`. The "Try
again" link in DownloadTrackArchiveModal is the only recovery affordance
the UI offers, and it was a no-op: POST returned the same dead job id, the
client re-polled it, and timed out again 15 minutes later.

Cancel-then-retry could not work around it either. cancelStemsArchiveJob
removes a job from the queue only when it is completed; otherwise it aborts
via an in-memory AbortController, and a job stranded in `waiting` has none,
so it falls through to 'Stems archive job not found' and removes nothing.

Now a job that has been non-terminal past `staleJobSeconds` (default 15m,
`ARCHIVER_STALE_JOB_SECONDS`) is removed and recreated. Completed jobs are
still returned as-is so a ready archive is downloaded rather than rebuilt,
and jobs that are still progressing are still rejoined, which is what
dedupes double-clicks and multiple tabs.

The threshold is kept in step with STEMS_ARCHIVE_POLL_TIMEOUT_MS in the
client's useDownloadTrackStems: past that point the client has already
given up, so the old job's output would never be collected anyway.

Staleness is measured from `processedOn ?? timestamp`, so a job that never
started — the case that actually broke — is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

archiver: retry can never escape a stuck job — getOrCreate only replaces failed jobs

1 participant