From d7e3d9246bdcc999b17d5fad8393dfb47c9203a7 Mon Sep 17 00:00:00 2001 From: 0xGlitch <92540908+bgauger@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:14:23 -0600 Subject: [PATCH] fix(downloads): improved handling for large file downloads and user-initiated cancellation (#632) * fix(downloads): increase retry attempts and backoff for large file downloads * fix download retry config and abort handling * use abort reason to detect user-initiated cancels --- admin/app/jobs/run_download_job.ts | 18 +++++++++++++----- admin/app/services/download_service.ts | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/admin/app/jobs/run_download_job.ts b/admin/app/jobs/run_download_job.ts index 4cb7dd7..12b3532 100644 --- a/admin/app/jobs/run_download_job.ts +++ b/admin/app/jobs/run_download_job.ts @@ -54,6 +54,11 @@ export class RunDownloadJob { totalBytes: 0, } + // Track whether cancellation was explicitly requested by the user (via Redis signal + // or in-process AbortController). BullMQ lock mismatches can also abort the download + // stream, but those should be retried — only user-initiated cancels are unrecoverable. + let userCancelled = false + // Poll Redis for cancel signal every 2s — independent of progress events so cancellation // works even when the stream is stalled and no onProgress ticks are firing. let cancelPollInterval: ReturnType | null = setInterval(async () => { @@ -61,7 +66,8 @@ export class RunDownloadJob { const val = await cancelRedis.get(RunDownloadJob.cancelKey(job.id!)) if (val) { await cancelRedis.del(RunDownloadJob.cancelKey(job.id!)) - abortController.abort() + userCancelled = true + abortController.abort('user-cancel') } } catch { // Redis errors are non-fatal; in-process AbortController covers same-process cancels @@ -176,8 +182,10 @@ export class RunDownloadJob { filepath, } } catch (error: any) { - // If this was a cancellation abort, don't let BullMQ retry - if (error?.message?.includes('aborted') || error?.message?.includes('cancelled')) { + // Only prevent retries for user-initiated cancellations. BullMQ lock mismatches + // can also abort the stream, and those should be retried with backoff. + // Check both the flag (Redis poll) and abort reason (in-process cancel). + if (userCancelled || abortController.signal.reason === 'user-cancel') { throw new UnrecoverableError(`Download cancelled: ${error.message}`) } throw error @@ -228,8 +236,8 @@ export class RunDownloadJob { try { const job = await queue.add(this.key, params, { jobId, - attempts: 3, - backoff: { type: 'exponential', delay: 2000 }, + attempts: 10, + backoff: { type: 'exponential', delay: 30000 }, removeOnComplete: true, }) diff --git a/admin/app/services/download_service.ts b/admin/app/services/download_service.ts index 40cdca3..ac9d02d 100644 --- a/admin/app/services/download_service.ts +++ b/admin/app/services/download_service.ts @@ -125,7 +125,7 @@ export class DownloadService { await RunDownloadJob.signalCancel(jobId) // Also try in-memory abort (works if worker is in same process) - RunDownloadJob.abortControllers.get(jobId)?.abort() + RunDownloadJob.abortControllers.get(jobId)?.abort('user-cancel') RunDownloadJob.abortControllers.delete(jobId) // Poll for terminal state (up to 4s at 250ms intervals) — cooperates with BullMQ's lifecycle