Module: Wurk::Middleware::PoisonPill
- Defined in:
- lib/wurk/fetcher/poison_pill.rb
Overview
Pro parity (§3.2): poison-pill detection for reliable-fetch orphans.
When a job is recovered out of a private list, a per-jid counter at
super_fetch:recovered:<jid> goes up by one with a 72h TTL. Recoveries up
to RECOVERY_THRESHOLD (3) requeue; the one that takes the counter past it
(the 4th orphaning) is the poison pill: the payload is moved to the dead
set, jobs.poison is emitted to statsd, and recovery callbacks fire so
operators can be paged.
Counter key TTL is wire-compat with Sidekiq Pro — third-party tooling
that watches super_fetch:recovered:* expects 72h.
Not a chain middleware, so the file lives with the fetcher; the constant
keeps its Middleware path because Pro initializers name it. No
server-middleware registration: the producer is Fetcher::OrphanReclaim,
whose script bumps the counter and takes the requeue-or-kill decision in
the same step as the move, then hands the outcome to PoisonPill.recovered! for the
notifications. The consumer is the ACK —
Fetcher::Reliable::UnitOfWork#acknowledge pipelines PoisonPill.clear_in next to
its LREM, so an attempt that finished starts the next one at zero
without costing a round trip of its own.
Defined Under Namespace
Classes: Pill, Poisoned, Registration
Constant Summary collapse
- RECOVERY_THRESHOLD =
3- RECOVERY_TTL =
72 * 60 * 60
- KEY_PREFIX =
'super_fetch:recovered:'
Class Method Summary collapse
- .callbacks ⇒ Object
-
.clear!(jid) ⇒ Object
Resets the counter for a jid.
-
.clear_in(pipe, jid) ⇒ Object
Queue the counter reset onto a pipeline the caller already has open.
- .counter_key(jid) ⇒ Object
- .emit(metric, klass, queue) ⇒ Object
- .fire_callbacks(pill) ⇒ Object
-
.fire_super_fetch(config, payload, pill) ⇒ Object
Invoke the Pro recovery callback registered via config.super_fetch! { }.
-
.mark_poison(payload, job, queue:, count:) ⇒ Object
The script already put
payloadin the dead set; DeadSet#kill writes the same member again, so the trim and the death handlers stay DeadSet's. -
.on_poison(&block) ⇒ Object
Register a callback fired when a poison pill is detected.
-
.parse(payload) ⇒ Object
The job Hash, or nil for anything that is not a JSON object.
- .poisoned_error(klass, count) ⇒ Object
-
.recovered!(payload, job, count:, queue: nil, config: Wurk.configuration) ⇒ Symbol
Called per recovered orphan job, once Fetcher::OrphanReclaim has moved it:
countis the recovery counter its script wrote (0 for a payload with no jid). -
.recovery_count(jid) ⇒ Object
Reads the current recovery counter without bumping it.
-
.reset! ⇒ Object
Test-only reset.
Class Method Details
.callbacks ⇒ Object
161 162 163 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 161 def callbacks @callbacks ||= [] end |
.clear!(jid) ⇒ Object
Resets the counter for a jid. The hot path uses clear_in instead — this is the standalone form for the API/dashboard and for callers with no pipeline of their own.
97 98 99 100 101 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 97 def clear!(jid) return if jid.nil? || jid.to_s.empty? Wurk.redis { |conn| conn.call('DEL', counter_key(jid)) } end |
.clear_in(pipe, jid) ⇒ Object
Queue the counter reset onto a pipeline the caller already has open.
The ACK is the reset point. An attempt that got as far as acking —
returned, or raised and booked its retry — is proof the job did not
take its worker down, and that is the only thing this counter
measures. Without the reset, three reclaims caused by three unrelated
crashes inside 72h dead-set a job that has been completing all along
(jids do come back: a UI/API retry re-pushes the same one, as does any
client that supplies its own). Riding a round trip the ACK already
makes is what keeps that free for the jobs — nearly all of them — that
were never reclaimed at all.
Guards the blank jid for the same reason clear! and recovery_count
do, rather than relying on the one caller to do it: counter_key(nil)
is the bare KEY_PREFIX, so an unguarded DEL here would delete a key
that is shared rather than per-job. The caller's own check stays --
it also skips queueing the command at all -- but the invariant belongs
on the method that builds the key.
120 121 122 123 124 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 120 def clear_in(pipe, jid) return if jid.nil? || jid.to_s.empty? pipe.call_v(['DEL', counter_key(jid)]) end |
.counter_key(jid) ⇒ Object
126 127 128 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 126 def counter_key(jid) "#{KEY_PREFIX}#{jid}" end |
.emit(metric, klass, queue) ⇒ Object
180 181 182 183 184 185 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 180 def emit(metric, klass, queue) = [] << "class:#{klass}" if klass << "queue:#{queue}" if queue Wurk::Metrics::Statsd.increment(metric, tags: .empty? ? nil : ) end |
.fire_callbacks(pill) ⇒ Object
208 209 210 211 212 213 214 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 208 def fire_callbacks(pill) callbacks.each do |cb| cb.call(pill) rescue StandardError => e Wurk.configuration.handle_exception(e, context: 'Wurk::Middleware::PoisonPill') end end |
.fire_super_fetch(config, payload, pill) ⇒ Object
Invoke the Pro recovery callback registered via config.super_fetch! { }.
No-op unless one is registered. jobstr is the raw job JSON so a Pro
|jobstr, pill| block sees exactly what Sidekiq Pro hands it.
219 220 221 222 223 224 225 226 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 219 def fire_super_fetch(config, payload, pill) cb = config.super_fetch_callback return unless cb cb.call(payload, pill) rescue StandardError => e config.handle_exception(e, context: 'Wurk::Middleware::PoisonPill') end |
.mark_poison(payload, job, queue:, count:) ⇒ Object
The script already put payload in the dead set; DeadSet#kill writes the
same member again, so the trim and the death handlers stay DeadSet's.
Death handlers fire (notify_failure defaults to true). A poison kill
is a death like any other exhaustion: suppressing it strands every
batch that owns the job — Batch::DeathHandler is a death handler, so a
silent kill leaves the batch's pending count stuck forever and neither
:death nor :complete ever runs. Pro's spec is silent here
(docs/target/sidekiq-pro.md §12); recorded in docs/idea/parity-divergences.md.
196 197 198 199 200 201 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 196 def mark_poison(payload, job, queue:, count:) emit('jobs.poison', job['class'], queue) Wurk::Metrics::ProcessCounters.increment(:poison_kills) Wurk::DeadSet.new.kill(payload, ex: poisoned_error(job['class'], count)) fire_callbacks(jid: job['jid'], klass: job['class'], count: count, queue: queue) end |
.on_poison(&block) ⇒ Object
Register a callback fired when a poison pill is detected. Callbacks
receive a single Hash {jid:, klass:, count:, queue:} — matches
Sidekiq Pro's documented shape so consumers can drop in unchanged.
Dedups by callable identity — an initializer that re-runs (Rails
code reloading, a re-required file) and hands back the same stored
Proc each time won't accumulate duplicate closures (and the bindings
they retain). A fresh block literal per call still registers
separately, same as before. Returns a Registration the caller can
#remove! to deregister explicitly.
140 141 142 143 144 145 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 140 def on_poison(&block) raise ArgumentError, 'block required' unless block callbacks << block unless callbacks.any? { |cb| cb.equal?(block) } Registration.new(callbacks, block) end |
.parse(payload) ⇒ Object
The job Hash, or nil for anything that is not a JSON object.
173 174 175 176 177 178 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 173 def parse(payload) job = Wurk.load_json(payload) job if job.is_a?(::Hash) rescue ::JSON::ParserError nil end |
.poisoned_error(klass, count) ⇒ Object
203 204 205 206 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 203 def poisoned_error(klass, count) = "#{klass || 'job'} was orphaned #{count} times without completing" Poisoned.new().tap { |e| e.set_backtrace(caller) } end |
.recovered!(payload, job, count:, queue: nil, config: Wurk.configuration) ⇒ Symbol
Called per recovered orphan job, once Fetcher::OrphanReclaim has moved
it: count is the recovery counter its script wrote (0 for a payload with
no jid). Returns :poison when the count is past the threshold — the
script already put the job in the dead set, never on its queue — and
:recovered otherwise. Emits jobs.recovered.fetch for every parseable
job, jobs.poison only on the kill path.
Fires the Pro super_fetch! recovery callback (config.super_fetch_callback)
exactly once per call: (jobstr, nil) on plain recovery, (jobstr, pill)
on the kill path. The poison-only on_poison Hash callbacks fire
independently inside #mark_poison.
69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 69 def recovered!(payload, job, count:, queue: nil, config: Wurk.configuration) unless job fire_super_fetch(config, payload, nil) return :recovered end emit('jobs.recovered.fetch', job['class'], queue) if count > RECOVERY_THRESHOLD mark_poison(payload, job, queue: queue, count: count) fire_super_fetch(config, payload, Pill.new(jid: job['jid'], klass: job['class'], count: count, queue: queue)) :poison else fire_super_fetch(config, payload, nil) :recovered end end |
.recovery_count(jid) ⇒ Object
Reads the current recovery counter without bumping it. Used by tests and dashboards; returns 0 for jobs that have never been recovered.
88 89 90 91 92 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 88 def recovery_count(jid) return 0 if jid.nil? || jid.to_s.empty? Wurk.redis { |conn| conn.call('GET', counter_key(jid)) }.to_i end |
.reset! ⇒ Object
Test-only reset.
166 167 168 |
# File 'lib/wurk/fetcher/poison_pill.rb', line 166 def reset! @callbacks = [] end |