wurk 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/capsule.rb +40 -12
  5. data/lib/wurk/client/buffered.rb +7 -0
  6. data/lib/wurk/client.rb +102 -32
  7. data/lib/wurk/component.rb +34 -2
  8. data/lib/wurk/configuration.rb +45 -6
  9. data/lib/wurk/context.rb +1 -1
  10. data/lib/wurk/engine.rb +41 -1
  11. data/lib/wurk/errors.rb +15 -0
  12. data/lib/wurk/fetcher/reaper.rb +12 -2
  13. data/lib/wurk/fetcher/reliable.rb +322 -60
  14. data/lib/wurk/fetcher.rb +6 -0
  15. data/lib/wurk/heartbeat.rb +2 -2
  16. data/lib/wurk/job_logger.rb +16 -7
  17. data/lib/wurk/job_set.rb +3 -2
  18. data/lib/wurk/job_util.rb +44 -24
  19. data/lib/wurk/launcher.rb +116 -72
  20. data/lib/wurk/leader.rb +36 -6
  21. data/lib/wurk/logger.rb +1 -1
  22. data/lib/wurk/lua/loader.rb +9 -3
  23. data/lib/wurk/metrics/accumulator.rb +95 -0
  24. data/lib/wurk/metrics/flusher.rb +70 -0
  25. data/lib/wurk/metrics/history.rb +98 -29
  26. data/lib/wurk/metrics/statsd.rb +32 -18
  27. data/lib/wurk/middleware/chain.rb +31 -14
  28. data/lib/wurk/middleware/poison_pill.rb +23 -2
  29. data/lib/wurk/pool_checkout.rb +10 -0
  30. data/lib/wurk/processor.rb +79 -11
  31. data/lib/wurk/profiler.rb +6 -2
  32. data/lib/wurk/queue.rb +8 -0
  33. data/lib/wurk/redis_client_adapter.rb +1 -1
  34. data/lib/wurk/redis_pool.rb +2 -1
  35. data/lib/wurk/shutdown_gate.rb +79 -0
  36. data/lib/wurk/stats.rb +5 -1
  37. data/lib/wurk/swarm/child_boot.rb +24 -4
  38. data/lib/wurk/swarm.rb +88 -14
  39. data/lib/wurk/version.rb +1 -1
  40. data/lib/wurk/web/config.rb +11 -7
  41. data/lib/wurk.rb +2 -8
  42. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  43. data/vendor/assets/dashboard/assets/{BatchDetail-OmC5NPgw.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  44. data/vendor/assets/dashboard/assets/{Batches-CIpai7St.js → Batches-Cxan6Ngw.js} +1 -1
  45. data/vendor/assets/dashboard/assets/{Busy-A_kwSR6Q.js → Busy-DC5EGM0g.js} +1 -1
  46. data/vendor/assets/dashboard/assets/{Cron-BG7HTqlp.js → Cron-Dlt8tXJA.js} +1 -1
  47. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  48. data/vendor/assets/dashboard/assets/{Dead-8J21jMyK.js → Dead-dZ7VGlKS.js} +1 -1
  49. data/vendor/assets/dashboard/assets/{Extension-B4Q9FIQu.js → Extension-DaFpEIJf.js} +1 -1
  50. data/vendor/assets/dashboard/assets/{FilterBox-Fh_Ae7UW.js → FilterBox-CO3aYWIq.js} +1 -1
  51. data/vendor/assets/dashboard/assets/{JobDetailModal-Ceng0PMB.js → JobDetailModal-DSWbT6G0.js} +1 -1
  52. data/vendor/assets/dashboard/assets/{Limiters-CruDWvNZ.js → Limiters-Cb4PKXNR.js} +1 -1
  53. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  54. data/vendor/assets/dashboard/assets/{Modal-CN3rdKA_.js → Modal-B86q6ruL.js} +1 -1
  55. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  56. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  57. data/vendor/assets/dashboard/assets/{Queues-D86FYohJ.js → Queues-CIyPevOy.js} +1 -1
  58. data/vendor/assets/dashboard/assets/{Retries-Bz1O1D-i.js → Retries-DopwXkXl.js} +1 -1
  59. data/vendor/assets/dashboard/assets/{Scheduled-B6h2akTu.js → Scheduled-1-Z7i1zE.js} +1 -1
  60. data/vendor/assets/dashboard/assets/{Search-OOu22e5s.js → Search-ByA6eTma.js} +1 -1
  61. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  62. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  63. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  64. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-dVPGEWzn.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  65. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  66. data/vendor/assets/dashboard/index.html +2 -2
  67. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  68. metadata +30 -26
  69. data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
  70. data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
  71. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative '../middleware'
4
4
  require_relative '../pool_checkout'
5
+ require_relative 'accumulator'
5
6
 
6
7
  module Wurk
7
8
  module Metrics
@@ -31,9 +32,10 @@ module Wurk
31
32
  # running we keep its bucket around for the retention window measured from
32
33
  # *last write*, not from first write. So we EXPIRE unconditionally.
33
34
  #
34
- # The middleware is hot-path — every successful job pays for it. Writes are
35
- # pipelined in a single round-trip per job (1 HINCRBY × 2 + 1 EXPIRE per
36
- # bucket × 2 buckets = 6 commands, batched).
35
+ # The middleware is hot-path — every job pays for it — so it does not touch
36
+ # Redis at all. Each execution folds into a process-wide Accumulator and
37
+ # Wurk::Metrics::Flusher writes the whole tree out every FLUSH_INTERVAL, in
38
+ # the same 6-commands-per-(class, minute) shape the per-job pipeline sent.
37
39
  class History
38
40
  include Wurk::Middleware::ServerMiddleware
39
41
 
@@ -44,6 +46,22 @@ module Wurk
44
46
  MINUTE_KEY_PREFIX = 'j|'
45
47
  DATE_FORMAT = '%Y%m%d' # YYYYMMDD — matches Sidekiq's j| key
46
48
 
49
+ # Ceiling on how stale an unflushed counter can be, in seconds. A
50
+ # constant, never a config knob: it exists to bound the dashboard's lag,
51
+ # not to be tuned. Sidekiq — the engine these numbers get compared
52
+ # against — flushes its own process stats on a 10s heartbeat and writes
53
+ # nothing per job, so this is the tighter of the two. Signed off in
54
+ # docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
55
+ FLUSH_INTERVAL = 5
56
+
57
+ # Process-wide, like Processor::PROCESSED — the counters belong to the
58
+ # process, not to a middleware instance (the chain builds one per capsule)
59
+ # and not to a job.
60
+ ACCUMULATOR = Accumulator.new
61
+
62
+ # Hourly buckets are already class-scoped, so their fields are bare.
63
+ HOUR_FIELDS = %w[p f ms].freeze
64
+
47
65
  def call(_worker, job, _queue)
48
66
  klass = job['class']
49
67
  started = monotonic_ms
@@ -54,8 +72,11 @@ module Wurk
54
72
  result
55
73
  ensure
56
74
  duration = (monotonic_ms - started).round
57
- # Best-effort: a metrics write failure must never propagate into
58
- # the job result. The processor already finalized the ack path.
75
+ # Best-effort: a metrics failure must never propagate into the job
76
+ # result. The processor already finalized the ack path. Recording is
77
+ # in-memory now, so what this catches is a bad payload (a `class` that
78
+ # is not a String) rather than a Redis blip — the Redis half moved to
79
+ # Flusher, which reports on its own thread.
59
80
  begin
60
81
  self.class.record(klass, duration, success: success, redis_pool: redis_pool)
61
82
  rescue StandardError => e
@@ -65,39 +86,46 @@ module Wurk
65
86
  end
66
87
 
67
88
  class << self
68
- # Single Redis round-trip per job. `success: true` → `<klass>|p`;
69
- # `success: false` → `<klass>|f`. `<klass>|ms` accumulates total
70
- # runtime in milliseconds for *both* outcomes (so an operator can
71
- # ask "how much wall-clock time has FooJob consumed?" without
72
- # branching on outcome).
73
- def record(klass, duration_ms, success:, redis_pool: nil, at: ::Time.now)
89
+ # No Redis. `success: true` → `<klass>|p`; `success: false` →
90
+ # `<klass>|f`. `<klass>|ms` accumulates total runtime in milliseconds
91
+ # for *both* outcomes (so an operator can ask "how much wall-clock time
92
+ # has FooJob consumed?" without branching on outcome).
93
+ #
94
+ # `at:` defaults to nil rather than `::Time.now`: this runs once per job
95
+ # and the bucket only needs whole UTC minutes, which #minute_bucket
96
+ # reads straight off the clock as an Integer.
97
+ def record(klass, duration_ms, success:, redis_pool: nil, at: nil)
74
98
  return if klass.nil? || klass.empty?
75
99
 
76
100
  ms = duration_ms.to_i
77
101
  ms = 0 if ms.negative?
78
- buckets = { minute: minute_key(at), hour: hour_key(klass, at) }
79
- with_pool(redis_pool) { |conn| pipeline_write(conn, klass, ms, success, buckets) }
102
+ ACCUMULATOR.add(redis_pool, klass, minute_bucket(at), ms, success)
80
103
  nil
81
104
  end
82
105
 
83
- # The minute bucket carries per-class `<klass>|p|f|ms` fields; the
84
- # hourly bucket is already class-scoped so its fields are bare
85
- # `p|f|ms`. Pipeline all 6 commands in one round-trip.
86
- def pipeline_write(conn, klass, ms, success, buckets)
87
- outcome = success ? 'p' : 'f'
88
- class_outcome = "#{klass}|#{outcome}"
89
- class_ms = "#{klass}|ms"
90
- conn.pipelined do |pipe|
91
- incr_bucket(pipe, buckets[:minute], [class_outcome, class_ms, ms, MID_TERM])
92
- incr_bucket(pipe, buckets[:hour], [outcome, 'ms', ms, MID_TERM])
106
+ # Drains an accumulator into Redis, one pipeline per pool. Raises the
107
+ # first pool's failure after every other pool has had its turn —
108
+ # Wurk::Metrics::Flusher owns turning that into an error-handler call.
109
+ #
110
+ # Only the *failed* pool's counts are merged back. Putting the whole
111
+ # drained tree back would re-send the writes that already landed, and
112
+ # HINCRBY would count them twice.
113
+ #
114
+ # The argument is a collaborator, not an option: a process has exactly
115
+ # one accumulator and nothing in wurk passes a second. Tests pass their
116
+ # own so a deliberately broken pool cannot leak into a parallel test's
117
+ # flush through the process-wide one.
118
+ def flush(accumulator = ACCUMULATOR)
119
+ error = nil
120
+ accumulator.drain.each do |pool, minutes|
121
+ write(pool, minutes)
122
+ rescue StandardError => e
123
+ accumulator.merge_back(pool, minutes)
124
+ error = e
93
125
  end
94
- end
126
+ raise error if error
95
127
 
96
- def incr_bucket(pipe, key, parts)
97
- outcome_field, ms_field, ms, ttl = parts
98
- pipe.call('HINCRBY', key, outcome_field, 1)
99
- pipe.call('HINCRBY', key, ms_field, ms)
100
- pipe.call('EXPIRE', key, ttl)
128
+ nil
101
129
  end
102
130
 
103
131
  # Public formatters — Wurk::Metrics::Query reuses these so the two
@@ -112,6 +140,47 @@ module Wurk
112
140
  t = time.utc
113
141
  "#{klass}-#{t.strftime(DATE_FORMAT)}-#{t.hour}"
114
142
  end
143
+
144
+ private
145
+
146
+ # Whole UTC minutes since the epoch. Integer arithmetic on a raw clock
147
+ # read, so nothing is allocated per job.
148
+ def minute_bucket(at)
149
+ return ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second) / 60 unless at
150
+
151
+ at.to_i / 60
152
+ end
153
+
154
+ def write(pool, minutes)
155
+ with_pool(pool) do |conn|
156
+ conn.pipelined do |pipe|
157
+ minutes.each do |minute, classes|
158
+ at = ::Time.at(minute * 60).utc
159
+ key = minute_key(at)
160
+ classes.each { |klass, counts| write_class(pipe, key, at, klass, counts) }
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def write_class(pipe, bucket_key, at, klass, counts)
167
+ incr_bucket(pipe, bucket_key, ["#{klass}|p", "#{klass}|f", "#{klass}|ms"], counts)
168
+ incr_bucket(pipe, hour_key(klass, at), HOUR_FIELDS, counts)
169
+ end
170
+
171
+ # `p`/`f` are emitted only when non-zero, so a bucket ends up with the
172
+ # exact field set the per-job path produced: `HINCRBY <field> 0` would
173
+ # materialize a counter no unbatched write ever created, and the read
174
+ # side would start reporting a `f: 0` series for classes that have never
175
+ # failed. `ms` is unconditional — the per-job path always incremented
176
+ # it, including by zero.
177
+ def incr_bucket(pipe, key, fields, counts)
178
+ processed, failed, ms = counts
179
+ pipe.call('HINCRBY', key, fields[0], processed) if processed.positive?
180
+ pipe.call('HINCRBY', key, fields[1], failed) if failed.positive?
181
+ pipe.call('HINCRBY', key, fields[2], ms)
182
+ pipe.call('EXPIRE', key, MID_TERM)
183
+ end
115
184
  end
116
185
 
117
186
  private
@@ -35,6 +35,15 @@ module Wurk
35
35
  METRIC_PREFIX = 'sidekiq.'
36
36
  DEFAULT_SAMPLE_RATE = 1.0
37
37
 
38
+ # Unresolved state of the {.client} memo, distinct from a resolved nil.
39
+ UNSET = :unset
40
+ private_constant :UNSET
41
+
42
+ # Seeded in the class body (not `class << self`, where `self` is the
43
+ # singleton class) so {.client} is a bare ivar read with no `defined?`
44
+ # guard on the hot path.
45
+ @client = UNSET
46
+
38
47
  class << self
39
48
  attr_accessor :options
40
49
 
@@ -91,19 +100,35 @@ module Wurk
91
100
  # Resolves the live client: invokes the configured `dogstatsd` proc
92
101
  # exactly once per process and memoizes. Returns nil when no proc
93
102
  # is configured, so callers get a clean no-op without raising.
103
+ #
104
+ # The *unconfigured* answer is memoized too, which is what makes the
105
+ # no-client path free: Client#emit_enqueued asks once per payload, so
106
+ # re-reading `Wurk.configuration` here would cost a config lookup per
107
+ # job on every bulk push. Both invalidation points call {.reset!}.
94
108
  def client
95
- return @client if defined?(@client) && !@client.nil?
109
+ memo = @client
110
+ return memo unless UNSET.equal?(memo)
96
111
 
97
112
  builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
98
- return nil if builder.nil?
99
-
100
113
  @client = builder.respond_to?(:call) ? builder.call : builder
101
114
  end
102
115
 
103
- # Test/lifecycle hook. Reset between specs and after fork so the
104
- # parent's socket doesn't bleed into children.
116
+ # {.client} without the raise: a builder proc that blows up is reported
117
+ # through the error handler and treated as "no client", so misconfigured
118
+ # metrics can never fail the work they were measuring. Callers on a
119
+ # hot path use this to skip building anything the emit would drop.
120
+ def safe_client
121
+ client
122
+ rescue StandardError => e
123
+ handle_error(e)
124
+ nil
125
+ end
126
+
127
+ # Test/lifecycle hook. Reset between specs, after fork so the parent's
128
+ # socket doesn't bleed into children, and on every `config.dogstatsd=`
129
+ # — a memoized nil would otherwise hide a client configured later.
105
130
  def reset!
106
- @client = nil
131
+ @client = UNSET
107
132
  end
108
133
 
109
134
  private
@@ -118,8 +143,7 @@ module Wurk
118
143
  end
119
144
 
120
145
  def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
121
- client = safe_client
122
- return yield if client.nil?
146
+ return yield if self.class.safe_client.nil?
123
147
 
124
148
  klass = job['class']
125
149
  opts = per_job_options(klass, job, queue)
@@ -146,16 +170,6 @@ module Wurk
146
170
 
147
171
  private
148
172
 
149
- # Wraps `self.class.client` so a misconfigured builder proc never turns
150
- # a metrics-init failure into a job failure. Returns nil on error (the
151
- # caller falls through to a plain `yield`).
152
- def safe_client
153
- self.class.client
154
- rescue StandardError => e
155
- self.class.send(:handle_error, e)
156
- nil
157
- end
158
-
159
173
  # Per-spec §9.2: caller-supplied proc may override tags / sample_rate
160
174
  # on a per-job basis. The job's own `dd_rate` field, when present,
161
175
  # always wins — it's the per-push override hinted in §8.
@@ -7,9 +7,10 @@ module Wurk
7
7
  # pushes to index 0, `insert_before`/`insert_after` anchor relative to an
8
8
  # existing entry, `invoke` walks the chain handing the inner block to each.
9
9
  #
10
- # `retrieve` instantiates a fresh instance per entry on every job — entries
11
- # store klass + ctor args, not the live object. This keeps middleware
12
- # thread-safe by construction and matches Sidekiq's lifecycle.
10
+ # Entries store klass + ctor args, not the live object: every job gets a
11
+ # fresh instance per entry (`invoke` builds each as it reaches it, `retrieve`
12
+ # builds them all up front). This keeps middleware thread-safe by
13
+ # construction and matches Sidekiq's lifecycle.
13
14
  #
14
15
  # Spec: docs/target/sidekiq-free.md §10.1.
15
16
  class Chain
@@ -83,23 +84,28 @@ module Wurk
83
84
  # Walks the chain inside-out. Each middleware receives a block that
84
85
  # advances to the next; the innermost block is the caller's `&block`,
85
86
  # whose return value is propagated back out. Empty chain: `yield`.
87
+ #
88
+ # Hot path — one job pays this per invocation, so instantiation is folded
89
+ # into an index walk over `@entries`: no instance array from `retrieve`,
90
+ # no traversal lambda, and no `shift` (which memmoves the array per entry).
91
+ # Cost is one allocation per entry (the middleware itself), zero to walk.
86
92
  def invoke(*args, &block)
87
93
  raise ArgumentError, 'middleware chain requires a block' unless block
88
94
  return yield if @entries.empty?
89
95
 
90
- chain = retrieve
91
- traverse = lambda do
92
- if chain.empty?
93
- block.call
94
- else
95
- chain.shift.call(*args, &traverse)
96
- end
97
- end
98
- traverse.call
96
+ traverse(@entries, 0, args, &block)
99
97
  end
100
98
 
101
99
  private
102
100
 
101
+ def traverse(entries, index, args, &block)
102
+ return yield if index == entries.size
103
+
104
+ entries[index].make_new(@config).call(*args) do
105
+ traverse(entries, index + 1, args, &block)
106
+ end
107
+ end
108
+
103
109
  # Custom dup semantics: deep-copy the entries array so a child chain's
104
110
  # mutations don't bleed into the parent (or sibling capsules).
105
111
  def initialize_copy(orig)
@@ -108,18 +114,29 @@ module Wurk
108
114
  end
109
115
 
110
116
  # Holds the klass + ctor args for a registered middleware. Defers
111
- # instantiation until `retrieve` runs so each job gets a fresh object.
117
+ # instantiation until `retrieve`/`invoke` runs so each job gets a fresh
118
+ # object.
119
+ #
120
+ # Whether the class declares `config=` is resolved once, at registration,
121
+ # so the common case (the setter comes from `include
122
+ # Wurk::Middleware::ServerMiddleware`) skips a `respond_to?` on every job
123
+ # × every entry. It is a fast path, not the rule: the contract is "set
124
+ # `config` if the instance is respondable" (spec §10.1), which also covers
125
+ # a setter defined on the singleton in `initialize`, one reached through
126
+ # `method_missing`, or one added to the class after it was registered — so
127
+ # a class that does not declare it still gets asked.
112
128
  class Entry
113
129
  attr_reader :klass
114
130
 
115
131
  def initialize(klass, *args)
116
132
  @klass = klass
117
133
  @args = args
134
+ @takes_config = klass.public_method_defined?(:config=)
118
135
  end
119
136
 
120
137
  def make_new(config = nil)
121
138
  instance = @klass.new(*@args)
122
- instance.config = config if config && instance.respond_to?(:config=)
139
+ instance.config = config if config && (@takes_config || instance.respond_to?(:config=))
123
140
  instance
124
141
  end
125
142
  end
@@ -129,11 +129,32 @@ module Wurk
129
129
  # Register a callback fired when a poison pill is detected. Callbacks
130
130
  # receive a single Hash `{jid:, klass:, count:, queue:}` — matches
131
131
  # Sidekiq Pro's documented shape so consumers can drop in unchanged.
132
+ #
133
+ # Dedups by callable identity — an initializer that re-runs (Rails
134
+ # code reloading, a re-required file) and hands back the *same* stored
135
+ # Proc each time won't accumulate duplicate closures (and the bindings
136
+ # they retain). A fresh block literal per call still registers
137
+ # separately, same as before. Returns a {Registration} the caller can
138
+ # `#remove!` to deregister explicitly.
132
139
  def on_poison(&block)
133
140
  raise ArgumentError, 'block required' unless block
134
141
 
135
- callbacks << block
136
- block
142
+ callbacks << block unless callbacks.any? { |cb| cb.equal?(block) }
143
+ Registration.new(callbacks, block)
144
+ end
145
+
146
+ # Handle returned by {on_poison}. Removing it is idempotent and safe
147
+ # even after a {reset!} discarded the underlying list.
148
+ class Registration
149
+ def initialize(list, callback)
150
+ @list = list
151
+ @callback = callback
152
+ end
153
+
154
+ def remove!
155
+ @list.delete(@callback)
156
+ nil
157
+ end
137
158
  end
138
159
 
139
160
  def callbacks
@@ -25,5 +25,15 @@ module Wurk
25
25
 
26
26
  pool.with(idempotent: true, &)
27
27
  end
28
+
29
+ # Same apply-safety forwarding as .with, minus the is_a? probe. Only for
30
+ # call sites whose pool is always this process's own {RedisPool} — never a
31
+ # host-supplied `pool:` kwarg — so the polymorphic check has nothing to
32
+ # decide.
33
+ def self.trusted(pool, idempotent, &)
34
+ return pool.with(&) unless idempotent
35
+
36
+ pool.with(idempotent: true, &)
37
+ end
28
38
  end
29
39
  end
@@ -10,8 +10,11 @@ require_relative 'profiler'
10
10
  module Wurk
11
11
  # Inside each Manager, N Processors run in parallel. Each owns one thread,
12
12
  # pulls a UnitOfWork from the capsule's fetcher, parses the payload, walks
13
- # the server middleware chain, invokes `perform`, then ACKs (removes the
14
- # payload from the per-process private list).
13
+ # the server middleware chain, invokes `perform`, then ACKs (retires the
14
+ # payload from the per-process private list). The ACK is handed to the
15
+ # fetcher, which pipelines it with the next fetch rather than spending a
16
+ # round trip on it — #flush_acks covers the case where there is no next
17
+ # fetch.
15
18
  #
16
19
  # Shutdown is two-stage:
17
20
  # * `terminate` flips a flag; the run loop exits between jobs.
@@ -23,6 +26,26 @@ module Wurk
23
26
  class Processor
24
27
  include Component
25
28
 
29
+ # Interrupt masks for the two `Thread.handle_interrupt` scopes every job
30
+ # runs inside. Frozen constants rather than the inline literals they
31
+ # replace: the mask never varies, and a literal allocated two Hashes per
32
+ # job. Sidekiq hoists the same pair (processor.rb:161-164).
33
+ IGNORE_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :never }.freeze
34
+ private_constant :IGNORE_SHUTDOWN_INTERRUPTS
35
+ ALLOW_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :immediate }.freeze
36
+ private_constant :ALLOW_SHUTDOWN_INTERRUPTS
37
+
38
+ # Stand-in for the default reloader, `proc { |&b| b.call }`. That default
39
+ # is an identity wrapper, but a block param (`|&b|`) forces MRI to reify
40
+ # the dispatch block into a Proc on every job just to call it straight
41
+ # back; `yield` does not. Same contract, one less allocation per job.
42
+ module IdentityReloader
43
+ def self.call
44
+ yield
45
+ end
46
+ end
47
+ private_constant :IdentityReloader
48
+
26
49
  attr_reader :thread, :job, :capsule
27
50
 
28
51
  def initialize(capsule, &callback)
@@ -32,7 +55,7 @@ module Wurk
32
55
  @done = false
33
56
  @job = nil
34
57
  @thread = nil
35
- @reloader = capsule.config[:reloader] || proc { |&b| b.call }
58
+ @reloader = resolve_reloader(capsule.config[:reloader])
36
59
  @job_logger = (capsule.config[:job_logger] || JobLogger).new(capsule.config)
37
60
  @retrier = JobRetry.new(capsule)
38
61
  end
@@ -116,6 +139,19 @@ module Wurk
116
139
  @lock.synchronize { @work.delete(tid) }
117
140
  end
118
141
 
142
+ # RAII: publish for the duration of the block, always retract. The write
143
+ # lives *inside* this method's ensure frame on purpose — with the `set`
144
+ # one line above a `begin`, an async raise landing in that gap (a host
145
+ # timeout's `Thread#raise`, say) escapes the ensure and strands the
146
+ # entry for the life of the process: the payload String stays reachable
147
+ # and every heartbeat reports the thread as busy.
148
+ def track(tid, hash)
149
+ set(tid, hash)
150
+ yield
151
+ ensure
152
+ delete(tid)
153
+ end
154
+
119
155
  def dup
120
156
  @lock.synchronize { @work.dup }
121
157
  end
@@ -136,8 +172,23 @@ module Wurk
136
172
 
137
173
  private
138
174
 
175
+ # Only the untouched framework default is swapped out — a host-supplied
176
+ # reloader (Rails wraps every job in `Rails.application.reloader`) is used
177
+ # exactly as given. `equal?` against DEFAULTS is sound because
178
+ # Configuration's deep-dup copies Hashes/Arrays only, so an unset
179
+ # `:reloader` is still the very Proc object DEFAULTS holds.
180
+ def resolve_reloader(configured)
181
+ return IdentityReloader if configured.nil? || configured.equal?(Configuration::DEFAULTS[:reloader])
182
+
183
+ configured
184
+ end
185
+
139
186
  def run
140
- process_one until @done
187
+ begin
188
+ process_one until @done
189
+ ensure
190
+ flush_acks
191
+ end
141
192
  @callback&.call(self)
142
193
  rescue Wurk::Shutdown
143
194
  @callback&.call(self)
@@ -147,6 +198,24 @@ module Wurk
147
198
  raise
148
199
  end
149
200
 
201
+ # The fetcher holds each finished job's LREM until a fetch can pipeline it
202
+ # (Fetcher::Reliable#defer_ack). This thread has stopped fetching, so
203
+ # nothing else will send the one it may still be holding — and a finished
204
+ # job left in the private list is invisible to Manager#hard_shutdown's
205
+ # in-flight list, so the next boot's reaper would run it a second time.
206
+ #
207
+ # Inside the loop's own ensure rather than the method's: the callback below
208
+ # drops this Processor from the Manager's pool, which is what lets
209
+ # Manager#stop return and close the capsule's Redis pool out from under us.
210
+ # `respond_to?` because a config[:fetch_class] fetcher need not defer, and
211
+ # the capsule has no fetcher at all if the launcher died before prepare!.
212
+ def flush_acks
213
+ fetcher = @capsule.fetcher
214
+ fetcher.flush_pending_acks if fetcher.respond_to?(:flush_pending_acks)
215
+ rescue StandardError => e
216
+ handle_exception(e, { context: 'Error flushing pending acks' })
217
+ end
218
+
150
219
  def fetch
151
220
  @capsule.fetcher.retrieve_work
152
221
  rescue Wurk::Shutdown
@@ -166,15 +235,15 @@ module Wurk
166
235
 
167
236
  # The fetcher never parses, so hand it the jid we just read: the ACK
168
237
  # retires this job's poison-pill recovery counter inside the round trip
169
- # it already makes. A fetcher plugged in via `config[:fetch_class]` has
170
- # no jid slot and simply ACKs — the counter then ages out on its 72h TTL.
238
+ # it rides. A fetcher plugged in via `config[:fetch_class]` has no jid
239
+ # slot and simply ACKs — the counter then ages out on its 72h TTL.
171
240
  uow.jid = job_hash['jid'] if uow.respond_to?(:jid=)
172
241
 
173
242
  ack = false
174
243
  begin
175
- Thread.handle_interrupt(Wurk::Shutdown => :never) do
244
+ Thread.handle_interrupt(IGNORE_SHUTDOWN_INTERRUPTS) do
176
245
  dispatch(job_hash, queue, jobstr) do |instance|
177
- Thread.handle_interrupt(Wurk::Shutdown => :immediate) do
246
+ Thread.handle_interrupt(ALLOW_SHUTDOWN_INTERRUPTS) do
178
247
  execute_job(instance, job_hash, queue)
179
248
  end
180
249
  end
@@ -247,15 +316,14 @@ module Wurk
247
316
  # Heartbeat can mirror it into Redis (`<identity>:work`), and increments
248
317
  # PROCESSED/FAILURE counters around the inner block.
249
318
  def stats(jobstr, queue)
250
- WORK_STATE.set(tid, queue: queue, payload: jobstr, run_at: ::Time.now.to_i)
251
- begin
319
+ run_at = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second)
320
+ WORK_STATE.track(tid, queue: queue, payload: jobstr, run_at: run_at) do
252
321
  yield
253
322
  rescue Exception # rubocop:disable Lint/RescueException
254
323
  FAILURE.incr
255
324
  raise
256
325
  ensure
257
326
  PROCESSED.incr
258
- WORK_STATE.delete(tid)
259
327
  end
260
328
  end
261
329
  end
data/lib/wurk/profiler.rb CHANGED
@@ -34,11 +34,15 @@ module Wurk
34
34
  # from the interrupt/expiry middleware) must propagate untouched so the
35
35
  # retry/skip flow works. Only the storage step is made failure-safe
36
36
  # (see #safe_store).
37
- def call(job_hash, &)
37
+ # No `&block` parameter: every job passes through here, and declaring one
38
+ # would reify the caller's block into a Proc even on the `yield`-straight-
39
+ # through path that opted-out jobs take. The capture path pays for its own
40
+ # block instead.
41
+ def call(job_hash)
38
42
  label = job_hash['profile']
39
43
  return yield unless label && defined?(::Vernier)
40
44
 
41
- capture(job_hash, label, &)
45
+ capture(job_hash, label) { yield } # rubocop:disable Style/ExplicitBlockArgument
42
46
  end
43
47
 
44
48
  # Persists a profile. Extracted from capture so it is unit-testable
data/lib/wurk/queue.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'fetcher/reliable'
3
4
  require_relative 'job_record'
4
5
 
5
6
  module Wurk
@@ -12,6 +13,11 @@ module Wurk
12
13
  # (see `Wurk::Fetcher::Reliable#queues_cmd`). In-flight jobs continue to
13
14
  # completion — pausing only stops new fetches.
14
15
  #
16
+ # Fetchers answer from a `PAUSED_TTL` cache rather than re-reading the SET
17
+ # every pass, so `pause!`/`unpause!` expire this process's copy inline and
18
+ # other processes pick the change up within the TTL. `paused?` is a reporting
19
+ # call and always reads Redis.
20
+ #
15
21
  # Spec: docs/target/sidekiq-free.md §19.2, sidekiq-pro.md §6.
16
22
  class Queue
17
23
  include Enumerable
@@ -58,12 +64,14 @@ module Wurk
58
64
  # 0 when the name was already present. In-flight jobs are untouched.
59
65
  def pause! # rubocop:disable Naming/PredicateMethod
60
66
  Wurk.redis(idempotent: true) { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
67
+ Fetcher::Reliable.invalidate_paused_cache!
61
68
  true
62
69
  end
63
70
 
64
71
  # Resume fetches. Idempotent.
65
72
  def unpause! # rubocop:disable Naming/PredicateMethod
66
73
  Wurk.redis(idempotent: true) { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
74
+ Fetcher::Reliable.invalidate_paused_cache!
67
75
  true
68
76
  end
69
77
 
@@ -37,7 +37,7 @@ module Wurk
37
37
 
38
38
  # The Redis commands Sidekiq itself uses — defined eagerly so the
39
39
  # common ones skip method_missing. Same list as upstream.
40
- USED_COMMANDS = %w[bitfield bitfield_ro del exists expire flushdb
40
+ USED_COMMANDS = %w[bitfield bitfield_ro blmove del exists expire flushdb
41
41
  get hdel hget hgetall hincrby hlen hmget hset hsetnx incr incrby
42
42
  lindex llen lmove lpop lpush lrange lrem mget mset ping pttl
43
43
  publish rpop rpush sadd scard script set sismember smembers
@@ -190,9 +190,9 @@ module Wurk
190
190
  # redials a closed socket lazily), so a busy fetcher can't leak checkouts.
191
191
  def run(conn, idempotent)
192
192
  attempts = 0
193
+ odometer = conn.round_trips
193
194
  begin
194
195
  attempts += 1
195
- odometer = conn.round_trips
196
196
  yield conn
197
197
  rescue RedisClient::Error => e
198
198
  plan = retry_plan(e, attempts, idempotent, conn.round_trips != odometer)
@@ -204,6 +204,7 @@ module Wurk
204
204
 
205
205
  safe_close(conn)
206
206
  sleep(backoff_delay(attempts)) if plan == :backoff
207
+ odometer = conn.round_trips
207
208
  retry
208
209
  end
209
210
  end