Class: Wurk::Batch
- Inherits:
-
Object
- Object
- Wurk::Batch
- Defined in:
- lib/wurk/batch.rb,
lib/wurk/batch_set.rb,
lib/wurk/batch/empty.rb,
lib/wurk/batch/buffer.rb,
lib/wurk/batch/status.rb,
lib/wurk/batch/callbacks.rb,
lib/wurk/batch/callback_job.rb,
lib/wurk/batch/death_handler.rb,
lib/wurk/batch/client_middleware.rb,
lib/wurk/batch/server_middleware.rb
Overview
Sidekiq Pro Batches. Group jobs, attach success/complete/death callbacks, track progress. Spec: docs/target/sidekiq-pro.md §2.
Lifecycle:
1. `Batch.new` allocates a fresh BID; the batch is `mutable?` until the
first `#jobs` block flushes — that flush HSETs the core hash, ZADDs
to `batches`, and writes tag indexes.
2. `Batch.new(bid)` reopens an existing batch (legal from inside a job
or callback only). `mutable?` is false because reopening implies the
first flush already happened.
3. `#jobs { ... }` collects `Job.perform_async` calls via the client
middleware (Thread.current[:wurk_current_batch] is the signal),
atomically registering each via BATCH_PUSH.
4. Workers ack on success → BATCH_ACK_SUCCESS → pending--.
Death handler acks on permanent failure → BATCH_ACK_COMPLETE.
5. When live jids hits zero → fire `:complete`. When pending also
hits zero with zero deaths → fire `:success`.
Nested batches: a job opening its OWN batch (batch.jobs { ... })
increments live counters on the existing batch. A callback opening its
PARENT batch links via parent_bid and adds child BID to b-
Defined Under Namespace
Modules: Callbacks Classes: Buffer, CallbackJob, ClientMiddleware, DeadSet, DeathHandler, Empty, ServerMiddleware, Status
Constant Summary collapse
- DEFAULT_EXPIRY_SECONDS =
30 * 24 * 60 * 60
- POST_SUCCESS_EXPIRY_SECONDS =
24 * 60 * 60
- CALLBACK_NOTIFY_TTL =
30 * 24 * 60 * 60
- INDEX_MAX =
Member ceiling for the two batch index ZSETs (
batches,dead-batches). The score axis in.trim_indexretires entries in step with the batch data itself, so this is only the backstop for a workload creating batches faster than that window retires them. Deliberately generous: a cap that bites drops batches whose data is still live out ofBatchSet, and at this scale the per-batch hashes dwarf the index anyway. 1_000_000- CALLBACKS_MAX =
Ceiling on the
callbacksarray of one batch hash, enforced by BATCH_APPEND_CALLBACK. Every registration re-encodes the whole array, and every entry becomes a callback job when the event fires, so an unbounded array is both a hot-path cost and a fan-out. Far above any legitimate batch — real ones register a handful — so hitting it means a loop is registering callbacks it should have registered once. 1_000- BID_BYTES =
Bid is URL-safe base64 of 10 random bytes — matches Sidekiq Pro's BID generator. Length matters: third-party gems that key off bid prefix (sharded batches in Pro 8) inspect the first character.
10- VALID_EVENTS =
%i[success complete death].freeze
- KEY_SUFFIXES =
Every key a batch owns, for the sweep paths:
Status#delete(UNLINK),Callbacks#apply_lingerandDeathHandler.restamp_ttls(EXPIRE).The 'live' set tracks jobs that have not yet reached a terminal state. When it's empty, every job has either succeeded or died →
:completeis allowed to fire.complete/success/deathare the callback dedup markers written byCallbacks#dedup_set; they belong to the batch and must die with it.notify/cbsucc/tagsare Sidekiq Pro's own key layout (spec §2.8) that Wurk never writes — Wurk dedups on the three markers above and indexes tags attags:<tag>. They stay listed so a Redis dataset carried over from Sidekiq Pro on the gem swap gets swept too; EXPIRE/UNLINK of a missing key is a no-op for batches Wurk created itself. %w[jids failed died complete success death notify cbsucc kids pkids tags].freeze
- THREAD_KEY =
:wurk_current_batch- BUFFER_KEY =
Set on the current thread (to a Buffer) only inside an autoflush
#jobsblock. Client#raw_push reads it: when present, batched pushes accumulate here instead of round-tripping per job. :wurk_batch_buffer
Instance Attribute Summary collapse
-
#autoflush ⇒ Object
Returns the value of attribute autoflush.
-
#bid ⇒ Object
readonly
Returns the value of attribute bid.
-
#callback_class ⇒ Object
Returns the value of attribute callback_class.
-
#callback_queue ⇒ Object
Returns the value of attribute callback_queue.
-
#description ⇒ Object
Returns the value of attribute description.
-
#linger ⇒ Object
Returns the value of attribute linger.
-
#parent_bid ⇒ Object
readonly
Returns the value of attribute parent_bid.
Class Method Summary collapse
- .keys_for(bid) ⇒ Object
-
.trim_index(pipe, key, max: nil, timeout: nil) ⇒ Object
Two-axis trim of a batch index ZSET (
batches,dead-batches), in the shape of the morgue trim (DeadSet#trim):ZREMRANGEBYSCOREevicts entries older thantimeout,ZREMRANGEBYRANK 0 -maxcaps the member count — and, like the morgue, that bound keepsmax - 1of a full set.
Instance Method Summary collapse
- #expires_in(duration) ⇒ Object
- #include?(jid) ⇒ Boolean
-
#initialize(bid = nil) ⇒ Batch
constructor
A new instance of Batch.
-
#invalidate_all ⇒ Object
Mark batch invalid.
-
#jobs(&block) ⇒ Object
Atomic enqueue block.
- #mutable? ⇒ Boolean
-
#on(event, callback, options = {}) ⇒ Object
Register a callback.
- #parent ⇒ Object
-
#remove_jobs(*jids) ⇒ Object
Remove jobs from the batch.
- #status ⇒ Object
- #tags ⇒ Object
-
#tags=(value) ⇒ Object
Hash assignment writes strings — Sidekiq's UI / third-party gems expect String tags.
- #valid? ⇒ Boolean
Constructor Details
#initialize(bid = nil) ⇒ Batch
Returns a new instance of Batch.
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
# File 'lib/wurk/batch.rb', line 125 def initialize(bid = nil) @bid = bid || SecureRandom.urlsafe_base64(BID_BYTES) @existing = !bid.nil? @description = nil @callback_queue = 'default' @callback_class = nil @tags = [] @autoflush = nil @linger = nil @parent_bid = nil @callbacks = [] # Dedup index over `@callbacks`, keyed on the encoded entry. Only the # pre-flush staging path feeds it — once flushed, Redis holds the array # and BATCH_APPEND_CALLBACK does the deduping — so a batch reopened by # bid never pays to build it. @callback_index = Set.new @expires_in = DEFAULT_EXPIRY_SECONDS @mutable = !@existing @flushed_once = @existing load_existing! if @existing end |
Instance Attribute Details
#autoflush ⇒ Object
Returns the value of attribute autoflush.
91 92 93 |
# File 'lib/wurk/batch.rb', line 91 def autoflush @autoflush end |
#bid ⇒ Object (readonly)
Returns the value of attribute bid.
90 91 92 |
# File 'lib/wurk/batch.rb', line 90 def bid @bid end |
#callback_class ⇒ Object
Returns the value of attribute callback_class.
90 91 92 |
# File 'lib/wurk/batch.rb', line 90 def callback_class @callback_class end |
#callback_queue ⇒ Object
Returns the value of attribute callback_queue.
91 92 93 |
# File 'lib/wurk/batch.rb', line 91 def callback_queue @callback_queue end |
#description ⇒ Object
Returns the value of attribute description.
91 92 93 |
# File 'lib/wurk/batch.rb', line 91 def description @description end |
#linger ⇒ Object
Returns the value of attribute linger.
90 91 92 |
# File 'lib/wurk/batch.rb', line 90 def linger @linger end |
#parent_bid ⇒ Object (readonly)
Returns the value of attribute parent_bid.
90 91 92 |
# File 'lib/wurk/batch.rb', line 90 def parent_bid @parent_bid end |
Class Method Details
.keys_for(bid) ⇒ Object
93 94 95 96 |
# File 'lib/wurk/batch.rb', line 93 def self.keys_for(bid) base = "b-#{bid}" [base, *KEY_SUFFIXES.map { |s| "#{base}-#{s}" }] end |
.trim_index(pipe, key, max: nil, timeout: nil) ⇒ Object
Two-axis trim of a batch index ZSET (batches, dead-batches), in the
shape of the morgue trim (DeadSet#trim): ZREMRANGEBYSCORE evicts
entries older than timeout, ZREMRANGEBYRANK 0 -max caps the member
count — and, like the morgue, that bound keeps max - 1 of a full set.
Appended to the caller's pipeline so bounding the index costs neither
writer an extra round trip.
Nothing else ever shrinks either set: Status#delete and the
death-recovery ZREM are manual, so an index entry outlives the batch it
points at and both sets grow for the life of the Redis without this.
Both index in epoch seconds — batches from CLOCK_REALTIME,
dead-batches from Time.now.to_f — so one cutoff serves both. The
default window is the batch hash TTL: past it b-<bid> is gone and the
entry only yields an empty Status. A batch that overrode expires_in
beyond that window outlives its index entry — still reachable by bid,
just no longer enumerated by BatchSet.
max: / timeout: override the defaults for one call, so parallel tests
can drive the trim on isolated limits without mutating the process-global
Wurk.configuration.
119 120 121 122 123 |
# File 'lib/wurk/batch.rb', line 119 def self.trim_index(pipe, key, max: nil, timeout: nil) cutoff = ::Process.clock_gettime(::Process::CLOCK_REALTIME) - (timeout || DEFAULT_EXPIRY_SECONDS) pipe.call('ZREMRANGEBYSCORE', key, '-inf', "(#{cutoff}") pipe.call('ZREMRANGEBYRANK', key, 0, -(max || INDEX_MAX)) end |
Instance Method Details
#expires_in(duration) ⇒ Object
223 224 225 226 |
# File 'lib/wurk/batch.rb', line 223 def expires_in(duration) @expires_in = duration.to_i self end |
#include?(jid) ⇒ Boolean
188 189 190 |
# File 'lib/wurk/batch.rb', line 188 def include?(jid) Wurk.redis { |conn| conn.call('SISMEMBER', "b-#{@bid}-jids", jid) }.to_i.positive? end |
#invalidate_all ⇒ Object
Mark batch invalid. Pending jobs still exist in their queues; the
server middleware short-circuits them when it observes the flag.
Cascades to descendant batches via b-
210 211 212 213 |
# File 'lib/wurk/batch.rb', line 210 def invalidate_all cascade_invalidate(@bid) nil end |
#jobs(&block) ⇒ Object
Atomic enqueue block. Inside the block, Job.perform_async finds
this batch via Thread.current and stamps bid onto the
payload — the client middleware then uses BATCH_PUSH to register and
push atomically. Empty blocks synthesise a Batch::Empty no-op so
callbacks still fire.
252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/wurk/batch.rb', line 252 def jobs(&block) raise ArgumentError, 'jobs requires a block' unless block ensure_first_flush! pre_count = job_count collect_jobs(&block) # By the time we check, the buffer (if any) has flushed, so `total` # reflects everything the block pushed — a flat count is reliable. # Scheduled (`perform_in`) jobs count here too: BATCH_SCHEDULE moves # `total` at creation, so a scheduled-only block does not misfire the # marker as if it were empty. enqueue_empty_marker if job_count == pre_count @mutable = false self end |
#mutable? ⇒ Boolean
184 185 186 |
# File 'lib/wurk/batch.rb', line 184 def mutable? @mutable end |
#on(event, callback, options = {}) ⇒ Object
Register a callback. Any number of distinct callbacks may be attached
to one event; re-registering an identical [event, target, options]
triple is a no-op, and past CALLBACKS_MAX entries the registration is
dropped with a warning. The callback target may be a Class, "Foo#bar"
string spec, or anything responding to name. options must be
JSON-serializable.
234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/wurk/batch.rb', line 234 def on(event, callback, = {}) sym = event.to_sym raise ArgumentError, "invalid event #{event.inspect}" unless VALID_EVENTS.include?(sym) raise ArgumentError, 'callback options must be a Hash' unless .is_a?(Hash) entry = [sym.to_s, callback_target(callback), ] # Before the first flush the array lives only in memory; after it, Redis # is authoritative and `@callbacks` is a stale mirror nothing reads — # appending to it there would just leak one entry per registration. @flushed_once ? persist_callback!(entry) : stage_callback(entry) self end |
#parent ⇒ Object
178 179 180 181 182 |
# File 'lib/wurk/batch.rb', line 178 def parent return nil if @parent_bid.nil? || @parent_bid.empty? Batch.new(@parent_bid) end |
#remove_jobs(*jids) ⇒ Object
Remove jobs from the batch. Decrements pending/total by exactly the count of jids actually removed (idempotent for repeated calls).
194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/wurk/batch.rb', line 194 def remove_jobs(*jids) return 0 if jids.empty? Wurk.redis do |conn| removed = conn.call('SREM', "b-#{@bid}-jids", *jids).to_i if removed.positive? conn.call('HINCRBY', "b-#{@bid}", 'pending', -removed) conn.call('HINCRBY', "b-#{@bid}", 'total', -removed) end removed end end |
#tags ⇒ Object
153 154 155 |
# File 'lib/wurk/batch.rb', line 153 def @tags.dup end |
#tags=(value) ⇒ Object
Hash assignment writes strings — Sidekiq's UI / third-party gems expect String tags. Array-coercion lets callers pass a String or Set.
149 150 151 |
# File 'lib/wurk/batch.rb', line 149 def (value) @tags = Array(value).map(&:to_s) end |