Skip to main content

Cancellation & timeouts

The Ruby worker uses cooperative cancellation. It never force-kills a job thread — interrupting a thread mid-call (an in-flight HTTP request, a database query) can leave a connection in a corrupt state. Instead, every job is handed a cancellation handle on its ctx, and a well-behaved handler watches it.

Cancellation can fire for three reasons:

  1. Server cancel — the job was cancelled (e.g. from the dashboard or the cancel API). The worker learns this from a heartbeat response and trips the handle.
  2. Timeout — the job ran past its configured timeout.
  3. Host shutdown — the process received SIGTERM/SIGINT and is draining.

In all three cases the job is ultimately acked failed once it returns.

Honoring cancellation

Check the handle at safe points and return (or raise) early:

def perform(payload, ctx)
payload["ids"].each do |id|
return if ctx.cancelled? # stop between units of work
process(id)
end
end

For a loop where you'd rather abort with an exception:

def perform(payload, ctx)
loop do
ctx.check_cancellation! # raises if cancellation was requested
do_one_chunk
end
end

When you need to wait, wait on the handle instead of sleep so the wait wakes the instant cancellation is requested:

def perform(payload, ctx)
# returns true immediately if cancelled, else after the delay
woke_to_cancel = ctx.cancellation.wait(5.0)
return if woke_to_cancel
# ...
end

The limit of cooperative cancellation

A handler that never checks the handle — a tight CPU loop, or a single blocking call with no timeout — cannot be stopped by the SDK. It runs until it finishes on its own. The server still protects you: if a worker stops heartbeating a job (because it's wedged, or the process died), the server eventually reclaims the job and re-delivers it. But that's a coarse backstop measured in the job's timeout window, not an instant abort.

Two practical rules:

  • Set socket/read timeouts on your own I/O at or below the job's timeout, so a stuck network call returns instead of hanging forever.
  • Check ctx.cancelled? at the boundaries of long loops.

Liveness: heartbeat immediately, then on a cadence

The worker sends one heartbeat the moment it claims a job, then continues on a fixed cadence for as long as the job runs. The immediate first beat matters: it tells the server the job is genuinely in progress, which puts a long-running job in the more forgiving reclaim window. If heartbeats stop arriving — a wedged or dead worker — the server reclaims the job and re-delivers it elsewhere. This is exactly why handlers must be idempotent (dedupe on ctx.job_id).

Graceful drain

On SIGTERM/SIGINT, worker.run stops claiming new jobs, trips cancellation on any freshly-claimed job, waits up to the shutdown grace for in-flight jobs to finish, and then exits. Keep the grace under your platform's termination window so jobs drain before the process is hard-killed; anything still running at the hard kill is re-delivered by the server.

See also