Skip to content

fix(archiver): add per-attempt timeout to redirect resolve, fix spaceManager loop leak - #88

Merged
dylanjeffers merged 2 commits into
mainfrom
fix/archiver-redirect-resolve-timeout
Aug 10, 2026
Merged

fix(archiver): add per-attempt timeout to redirect resolve, fix spaceManager loop leak#88
dylanjeffers merged 2 commits into
mainfrom
fix/archiver-redirect-resolve-timeout

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

What

Two bugs causing archiver stem jobs to get stuck in waiting or active and never complete — the same symptom as the Summer Cypher Vol 2 remix contest stall.


Bug 1 (root cause): resolveCanonicalUrl hangs forever under load

File: apps/archiver/src/workers/createStemsArchive/utils.ts

resolveCanonicalUrl fetches api.audius.co to resolve the download redirect with no per-attempt timeout. node-fetch has no built-in timeout. When the API is slow or rate-limited under contest traffic spikes, the fetch blocks indefinitely and holds a BullMQ worker slot open.

With concurrentJobs = 5, five such hangs fill every slot. New jobs queue in waiting and never get picked up. The loading wheel spins forever.

The code already documented the rate-limiting risk and had retry logic for 429/5xx responses — neither helped when the API simply didn't respond at all.

Fix: Wrap each fetch attempt in linkAbort(signal, REDIRECT_RESOLVE_TIMEOUT_MS) (30 s, matching the existing mirror download timeout). A network timeout is treated as a retryable error — same path as 429/5xx — so a momentary API stall retries up to MAX_REDIRECT_RESOLVE_ATTEMPTS times before failing the job. The job-level abort signal propagates through so a cancelled job still surfaces its own AbortError.


Bug 2 (secondary): spaceManager.waitForSpace leaks allocated space after timeout

File: apps/archiver/src/workers/spaceManager.ts

waitForSpace used const shouldContinue = true, making the background claimSpacePromise loop run forever. When Promise.race settled on the timeout, the outer IIFE threw — but the background loop kept polling every 100 ms. If it later claimed space, no one would ever call releaseSpace: state.usedSpace inflated permanently. Over time this caused spurious space exhaustion, making subsequent jobs timeout at the waitForSpace stage.

Fix: Add a loopController = new AbortController(). timeoutPromise calls loopController.abort() before rejecting, stopping the loop on its next iteration. A post-claim double-check + releaseSpace handles the narrow race window where the mutex was held at the moment of abort.


Contest-specific finding

No special gating or URL differences for Summer Cypher Vol 2 stems vs regular track downloads — the archiver uses identical SDK paths. The contest caused a traffic spike that saturated the 5 concurrent worker slots via Bug 1.

k8s

GCloud auth expired locally — pod logs unavailable. Deployment config shows autoUpgradeSchedule: "*/5 * * * *" with imageTag: audius/archiver:latest, so the fix auto-rolls within 5 min of merge.

… follower_count >= 10k

High-follower artists (10k+ followers) generate a very large number of
follower-blast email notifications (new track, playlist/album, remix
contest events) that contribute disproportionately to email spend.

This adds filterHighFollowerBlasts() in the scheduled email pipeline
(email/notifications/index.ts). Before delivering emails it:

  1. Identifies follower-blast notification types (create, fan_remix_contest_*)
  2. Resolves the initiating artist user_id from group_id / data.entity_user_id
  3. Batch-fetches follower_count from the users table
  4. Drops any notification where follower_count >= 10_000 from the email batch

Push notifications are untouched — only the email path is gated.
…Manager loop leak

Two bugs causing stems jobs to get stuck in waiting/active:

**Bug 1 (root cause of Summer Cypher Vol 2 stall): resolveCanonicalUrl hangs forever**

resolveCanonicalUrl fetches api.audius.co to resolve the download redirect
with no per-attempt timeout — node-fetch has no default timeout. When the
API is slow or rate-limited under contest traffic, the fetch hangs and holds
a BullMQ worker slot open indefinitely. With concurrentJobs=5, five such
hangs fill every slot; new jobs queue in `waiting` and never start.

Fix: wrap each fetch attempt in linkAbort(signal, REDIRECT_RESOLVE_TIMEOUT_MS)
(30s, matching the mirror download timeout). A timeout is treated as a
retryable error — same handling as 429/5xx — so a momentary API slowdown
does not fail the job. The job-level abort signal still propagates so a
cancelled job surfaces its own AbortError rather than a timeout message.

**Bug 2 (secondary): spaceManager.waitForSpace leaks allocated space after timeout**

waitForSpace used `const shouldContinue = true` so the background
claimSpacePromise loop kept running after Promise.race settled on a
timeout. If the loop subsequently claimed space (mutex released, space
now available) nobody would ever call releaseSpace — usedSpace
permanently inflated. Over time this causes spurious space exhaustion,
making subsequent jobs timeout at the space-wait stage.

Fix: add a local loopController AbortController. timeoutPromise calls
loopController.abort() before rejecting so the next loop iteration
throws CANCELLED cleanly. A post-claim double-check + releaseSpace
handles the narrow race window where the mutex was held during abort.

No contest-specific gating found: Summer Cypher Vol 2 stems use the same
SDK paths as regular track downloads. The difference is traffic volume —
contest launches drive concurrent archiver requests that saturate the
per-attempt fetch slots, triggering Bug 1.
@dylanjeffers
dylanjeffers merged commit 6719130 into main Aug 10, 2026
2 checks passed
dylanjeffers added a commit that referenced this pull request Aug 10, 2026
Follow-up to #88. `waitForSpace` never cleared its timeout timer, so every
satisfied space claim left a pending timer alive for the full
`maxDiskSpaceWaitSeconds`. The late fire is harmless on its own — `Promise.race`
has already settled and both branches are handled, so there's no unhandled
rejection — but this is a long-lived worker and #88 is specifically about not
leaking per-job resources.

Clear it in a `finally` so both the success and failure paths release the timer.

Co-authored-by: Claude Opus 5 <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.

1 participant