Skip to content

feat: cancel queued jobs with an AbortSignal - #32

Merged
sderrow merged 2 commits into
mainfrom
sean/abort-signal
Oct 16, 2026
Merged

sderrow merged 2 commits into
mainfrom
sean/abort-signal

Conversation

@sderrow

@sderrow sderrow commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

What

Jobs accept a new signal option. If the signal aborts before the job starts, the job leaves the queue, its task never runs, and its promise rejects with signal.reason. This follows the web-platform convention: callers get an AbortError, or a TimeoutError from AbortSignal.timeout(ms), and the job never settles silently. That holds even with rejectOnDrop: false, since the caller asked for the abort.

const controller = new AbortController();
limiter.schedule({ signal: controller.signal }, () => fetch(url, { signal: controller.signal }));
controller.abort();

Jobs that have already started are never touched. Bottleneck can't stop running code, and freeing the slot early would let the next job start while the abandoned work is still in flight, which breaks the rate limit. To cancel work in flight, pass the same signal into the task.

Design notes

  • Once the signal aborts, the job never starts another attempt. If an attempt fails after the abort, the job ends as a cancellation instead of an error: it rejects with signal.reason, the failed event doesn't fire, the job isn't retried, and the reason's stack isn't annotated with schedule locations (the caller owns the reason object and may share it across many jobs). If the abort lands during a retry delay, the retry is skipped without running the task. This covers chained limiters, where an abort in the inner queue rejects the outer job, and tasks that pass the signal to fetch and throw its reason. The trade-off: an unrelated error from an attempt that fails after the abort surfaces as signal.reason, matching how fetch behaves once aborted.
  • One listener per signal, however many jobs share it. Jobs register in a per-signal set instead of each adding its own abort listener. This avoids MaxListenersExceededWarning on runtimes that warn for AbortSignal, and avoids O(n²) listener churn when thousands of jobs share one controller.
  • A job watches its signal only while it holds its id, from when the limiter receives it until it starts or is dropped. An abort therefore can't reach a different job that reuses the id, e.g. after a duplicate-id rejection.
  • Queued jobs come out of the queue in O(1), and the queue counts stay accurate. empty now fires when an abort leaves the queue empty; before, it only fired on dispatch, so once("empty") could hang.
  • Race with dispatch: if the abort lands while the job's slot is being taken (a Redis round trip in cluster mode), the slot is freed and draining continues. That slot still counts against the reservoir and minTime.
  • Runtime support: the code avoids anything missing from Node 16 or the browser light build: no DOMException, AbortSignal.timeout, throwIfAborted or node:events. Where signal.reason doesn't exist (before Node 16.14), the job rejects with a BottleneckError with the new code ABORTED.

How do we know it works

test/abort.test.ts runs against the local, ioredis and node-redis datastores. It holds state with deferred signals and barriers, with no sleeps, and covers:

  • a signal that is already aborted
  • aborts during submit and while queued
  • AbortSignal.timeout
  • aborts after the job has started (ignored)
  • 100 jobs on one controller: exactly one listener is added, and it's removed once they've all left the queue
  • the dispatch race, triggered deterministically, then checking that running() is back to 0
  • chained limiters, and tasks that throw their own reason
  • aborts during a retry delay (the task isn't run again) and mid-attempt failures (no retry, failed not called)

The light-bundle smoke test also aborts a queued job. Coverage confirms every new branch runs.

sderrow commented Oct 15, 2026

Copy link
Copy Markdown
Owner Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@sderrow sderrow changed the title feat: abort signaling feat: cancel queued jobs with an AbortSignal Oct 15, 2026
@sderrow
sderrow marked this pull request as ready for review October 15, 2026 04:06
Once the signal aborts, a failed attempt ends the job as a cancellation
(rejects with signal.reason, no "failed" event, no retry), and a retry
whose delay elapses after the abort is skipped without running the task.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@sderrow
sderrow merged commit 87f2daa into main Oct 16, 2026
11 checks passed
@sderrow
sderrow deleted the sean/abort-signal branch October 16, 2026 06:45
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