Class: Wurk::Middleware::Timeout

Inherits:
Object
  • Object
show all
Includes:
ServerMiddleware
Defined in:
lib/wurk/middleware/timeout.rb

Overview

Arms the capsule's Watchdog with whichever wall-clock bounds a job declared. A class that declares neither — the default — costs two Hash lookups and a yield.

sidekiq_options timeout: <seconds> bounds one attempt. Past it the watchdog raises Job::TimedOut into the thread running perform, and the job takes the ordinary failure path from there: booked as a failure, retried on the class's own policy, dead set once the retries run out. Per attempt, not per job — every attempt re-arms its own bound, so a retry of a job that timed out gets the full budget again.

sidekiq_options deadline: <seconds> bounds the job from enqueue onward. The client resolves it to an absolute deadline_at once, at push (JobUtil#stamp_deadline), and what this middleware arms is whatever is left of it — so retries and IterableJob resumes eat into one budget rather than each getting a fresh one. Past it the watchdog raises Job::DeadlineExceeded, which is not a failure to retry: Expiry catches it one frame out and books the job expired, the same terminal state as one whose deadline had already passed before it started. That check, immediately before perform, is the other half of the bound — a job whose window closed while it queued never starts at all.

Soft only. Thread#kill would cut the job between any two instructions, stranding whatever it was holding — a checked-out connection, a half written file, a lock nobody is left to release. That is the class of bug docs/plans/2026/07/31/101-leak-logic-perf-fixes/ closed, and the reason a hard kill belongs to the process supervisor rather than to a thread. What soft costs is that a perform which swallows StandardError swallows this too: the same bargain Celery's soft time limit and stdlib Timeout make.

Chain position (lib/wurk.rb), both halves load-bearing:

* OUTSIDE Statsd / History / Status, so the raise unwinds *through* all
three and the timeout is measured and booked as the failure it is. A
`DeadlineExceeded` crosses the same three, which is why the two metric
emitters name it: it is booked `expired`, and booking it failed as
well would count one abandoned job twice.
* INSIDE Batch, Expiry and Limiter. Limiter is the sharp one: its rescue
re-enqueues an over-limit job and only then raises `Rescheduled`, so a
bound reaching around it could fire between those two steps and leave
the job both rescheduled and retried. Ending the bound before that
frame puts the bookkeeping out of the watchdog's reach. Expiry has to
be outside for a second reason: it is what catches the deadline raise.

Against shutdown_timeout: both bounds run and the shorter one wins. A timeout under the drain budget fires first and the job retries; over it, shutdown unwinds the job with Wurk::Shutdown instead and the payload is reclaimed from the private list on the next boot.

Instance Attribute Summary

Attributes included from ServerMiddleware

#config

Instance Method Summary collapse

Methods included from ServerMiddleware

#logger, #redis, #redis_pool

Instance Method Details

#call(_worker, job, _queue) ⇒ Object

The block is passed on rather than taken as &block: reifying it would allocate a Proc for every job in the process, bounded or not.



61
62
63
64
65
66
67
68
69
70
# File 'lib/wurk/middleware/timeout.rb', line 61

def call(_worker, job, _queue)
  attempt = bound_for(job)
  remaining = deadline_for(job)
  timer = (attempt || remaining) && watchdog
  return yield unless timer

  within_deadline(timer, job, remaining) do
    within_attempt(timer, job, attempt) { yield } # rubocop:disable Style/ExplicitBlockArgument
  end
end