wurk 1.3.1 → 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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/batch/callbacks.rb +82 -12
  5. data/lib/wurk/batch/death_handler.rb +7 -4
  6. data/lib/wurk/batch/server_middleware.rb +1 -1
  7. data/lib/wurk/batch.rb +121 -15
  8. data/lib/wurk/capsule.rb +45 -16
  9. data/lib/wurk/cli.rb +48 -14
  10. data/lib/wurk/client/buffered.rb +200 -43
  11. data/lib/wurk/client.rb +181 -38
  12. data/lib/wurk/compat.rb +1 -1
  13. data/lib/wurk/component.rb +36 -4
  14. data/lib/wurk/configuration.rb +47 -7
  15. data/lib/wurk/context.rb +1 -1
  16. data/lib/wurk/cron.rb +94 -37
  17. data/lib/wurk/deploy.rb +5 -3
  18. data/lib/wurk/embedded.rb +13 -0
  19. data/lib/wurk/engine.rb +41 -1
  20. data/lib/wurk/errors.rb +15 -0
  21. data/lib/wurk/fetcher/reaper.rb +125 -58
  22. data/lib/wurk/fetcher/reliable.rb +366 -51
  23. data/lib/wurk/fetcher.rb +6 -0
  24. data/lib/wurk/heartbeat.rb +24 -12
  25. data/lib/wurk/history.rb +13 -1
  26. data/lib/wurk/job_logger.rb +16 -7
  27. data/lib/wurk/job_set.rb +3 -2
  28. data/lib/wurk/job_util.rb +44 -24
  29. data/lib/wurk/launcher.rb +230 -119
  30. data/lib/wurk/leader.rb +63 -14
  31. data/lib/wurk/limiter/base.rb +8 -10
  32. data/lib/wurk/limiter/bucket.rb +1 -1
  33. data/lib/wurk/limiter/concurrent.rb +27 -22
  34. data/lib/wurk/limiter/window.rb +13 -11
  35. data/lib/wurk/limiter.rb +7 -4
  36. data/lib/wurk/logger.rb +1 -1
  37. data/lib/wurk/lua/loader.rb +9 -3
  38. data/lib/wurk/lua.rb +97 -14
  39. data/lib/wurk/manager.rb +29 -13
  40. data/lib/wurk/metrics/accumulator.rb +95 -0
  41. data/lib/wurk/metrics/flusher.rb +70 -0
  42. data/lib/wurk/metrics/history.rb +102 -32
  43. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  44. data/lib/wurk/metrics/rollup.rb +13 -1
  45. data/lib/wurk/metrics/statsd.rb +32 -18
  46. data/lib/wurk/middleware/chain.rb +31 -14
  47. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  48. data/lib/wurk/middleware/poison_pill.rb +93 -31
  49. data/lib/wurk/middleware.rb +2 -2
  50. data/lib/wurk/pool_checkout.rb +39 -0
  51. data/lib/wurk/process_set.rb +10 -5
  52. data/lib/wurk/processor.rb +83 -9
  53. data/lib/wurk/profiler.rb +9 -4
  54. data/lib/wurk/queue.rb +18 -7
  55. data/lib/wurk/rails_boot.rb +38 -7
  56. data/lib/wurk/redis_client_adapter.rb +49 -5
  57. data/lib/wurk/redis_pool.rb +71 -25
  58. data/lib/wurk/scheduled.rb +30 -2
  59. data/lib/wurk/shutdown_gate.rb +79 -0
  60. data/lib/wurk/stats.rb +19 -10
  61. data/lib/wurk/swarm/child_boot.rb +36 -4
  62. data/lib/wurk/swarm.rb +258 -43
  63. data/lib/wurk/timer_loop.rb +14 -0
  64. data/lib/wurk/version.rb +1 -1
  65. data/lib/wurk/web/config.rb +11 -7
  66. data/lib/wurk/web/enterprise.rb +58 -6
  67. data/lib/wurk/web/extension.rb +1 -1
  68. data/lib/wurk/web/search.rb +5 -3
  69. data/lib/wurk.rb +12 -10
  70. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
  75. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  76. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
  77. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
  78. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
  81. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  82. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
  83. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  84. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  85. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
  89. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  91. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  92. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  93. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +32 -27
  98. data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
  99. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  100. data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
  101. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  102. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  103. data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
@@ -41,13 +41,7 @@ module Wurk
41
41
  # under a per-job log level. ActiveJob-wrapped jobs expose the real
42
42
  # class via the "wrapped" key — log that, not the wrapper.
43
43
  def prepare(job_hash, &block)
44
- h = {
45
- jid: job_hash['jid'],
46
- class: job_hash['wrapped'] || job_hash['class']
47
- }
48
- @config[:logged_job_attributes].each do |attr|
49
- h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
50
- end
44
+ h = context_hash(job_hash)
51
45
 
52
46
  level = job_hash['log_level']
53
47
  Wurk::Context.with(h) do
@@ -64,5 +58,20 @@ module Wurk
64
58
  def elapsed(start)
65
59
  (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(3)
66
60
  end
61
+
62
+ # The attribute list is a host setting (defaults to %w[bid tags]), so the
63
+ # walk cannot be short-circuited on the default keys: a host that adds
64
+ # `tenant_id` logs jobs that carry neither `bid` nor `tags`.
65
+ def context_hash(job_hash)
66
+ h = {
67
+ jid: job_hash['jid'],
68
+ class: job_hash['wrapped'] || job_hash['class']
69
+ }
70
+
71
+ @config[:logged_job_attributes].each do |attr|
72
+ h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
73
+ end
74
+ h
75
+ end
67
76
  end
68
77
  end
data/lib/wurk/job_set.rb CHANGED
@@ -65,8 +65,9 @@ module Wurk
65
65
  #
66
66
  # Spec: docs/target/sidekiq-free.md §19.5.
67
67
  class JobSet < SortedSet
68
- # ZADD with NX so re-scheduling the same payload doesn't reset its score.
69
- # Mirrors Sidekiq::JobSet#schedule exactly.
68
+ # Plain ZADD, no NX: re-scheduling an identical payload *moves* it to the
69
+ # new timestamp rather than keeping the first one. Mirrors
70
+ # Sidekiq::JobSet#schedule exactly — the last schedule call wins.
70
71
  def schedule(timestamp, message)
71
72
  Wurk.redis { |conn| conn.call('ZADD', @name, timestamp.to_f.to_s, Wurk.dump_json(message)) }
72
73
  end
data/lib/wurk/job_util.rb CHANGED
@@ -140,32 +140,52 @@ module Wurk
140
140
  end
141
141
  end
142
142
 
143
+ JSON_NATIVE = ->(_val) {}
144
+
145
+ # Dispatch table for the args walk, ported from Sidekiq (job_util.rb:80-111).
146
+ # Keyed by `val.class` under `compare_by_identity`, so every node costs one
147
+ # identity lookup + one call instead of the ladder of `Module#===` checks a
148
+ # `case/when` compiles to — the entry node is always the args Array, which
149
+ # sat behind six failing `===` probes.
150
+ #
151
+ # Identity lookup means a *subclass* of a JSON-native type misses the table
152
+ # and falls to the default lambda, i.e. is reported unsafe: an
153
+ # ActiveSupport::SafeBuffer arg raises where a bare String does not. That is
154
+ # Sidekiq's behavior and the drop-in contract, not an oversight. Hash keys
155
+ # are the one exception — they are checked with `is_a?`, so a String
156
+ # subclass key is accepted, again matching Sidekiq.
157
+ RECURSIVE_JSON_UNSAFE = { # rubocop:disable Style/MutableConstant -- frozen below, after the two setup calls
158
+ Integer => JSON_NATIVE,
159
+ Float => JSON_NATIVE,
160
+ TrueClass => JSON_NATIVE,
161
+ FalseClass => JSON_NATIVE,
162
+ NilClass => JSON_NATIVE,
163
+ String => JSON_NATIVE,
164
+ Array => lambda { |val|
165
+ val.each do |e|
166
+ bad = RECURSIVE_JSON_UNSAFE[e.class].call(e)
167
+ return bad unless bad.nil?
168
+ end
169
+ nil
170
+ },
171
+ Hash => lambda { |val|
172
+ val.each do |k, v|
173
+ return k unless k.is_a?(String)
174
+
175
+ bad = RECURSIVE_JSON_UNSAFE[v.class].call(v)
176
+ return bad unless bad.nil?
177
+ end
178
+ nil
179
+ }
180
+ }
181
+ RECURSIVE_JSON_UNSAFE.default = ->(val) { val }
182
+ RECURSIVE_JSON_UNSAFE.compare_by_identity
183
+ RECURSIVE_JSON_UNSAFE.freeze
184
+ private_constant :JSON_NATIVE, :RECURSIVE_JSON_UNSAFE
185
+
143
186
  # Returns the first offending value, or nil when the tree is JSON-native.
144
187
  def json_unsafe(obj)
145
- case obj
146
- when String, Integer, Float, TrueClass, FalseClass, NilClass then nil
147
- when Array then json_unsafe_array(obj)
148
- when Hash then json_unsafe_hash(obj)
149
- else obj
150
- end
151
- end
152
-
153
- def json_unsafe_array(arr)
154
- arr.each do |v|
155
- bad = json_unsafe(v)
156
- return bad unless bad.nil?
157
- end
158
- nil
159
- end
160
-
161
- def json_unsafe_hash(hash)
162
- hash.each do |k, v|
163
- return k unless k.is_a?(String)
164
-
165
- bad = json_unsafe(v)
166
- return bad unless bad.nil?
167
- end
168
- nil
188
+ RECURSIVE_JSON_UNSAFE[obj.class].call(obj)
169
189
  end
170
190
  end
171
191
  end
data/lib/wurk/launcher.rb CHANGED
@@ -11,8 +11,11 @@ require_relative 'leader'
11
11
  require_relative 'cron'
12
12
  require_relative 'metrics/rollup'
13
13
  require_relative 'metrics/queue_rollup'
14
+ require_relative 'metrics/flusher'
14
15
  require_relative 'history'
15
16
  require_relative 'fetcher/reaper'
17
+ require_relative 'timer_loop'
18
+ require_relative 'shutdown_gate'
16
19
 
17
20
  module Wurk
18
21
  # Top-level supervisor inside each worker process. Owns the Manager pool
@@ -43,41 +46,52 @@ module Wurk
43
46
  # (Sidekiq's drop-in surface). The single source of truth is Heartbeat.
44
47
  BEAT_PAUSE = Heartbeat::BEAT_PAUSE
45
48
 
46
- attr_accessor :managers, :poller, :cron_poller, :metrics_rollup, :queue_rollup, :history
49
+ # Bound on how long #stop waits for the boot-time reclaim sweep before
50
+ # moving on — it can still be scanning a large keyspace when a fast
51
+ # shutdown lands right after boot; teardown must not hang on it.
52
+ BOOT_RECLAIM_JOIN_TIMEOUT = 5
53
+
54
+ attr_accessor :managers, :poller, :cron_poller, :metrics_rollup, :queue_rollup, :metrics_flusher, :history
47
55
 
48
56
  def initialize(config, embedded: false)
49
57
  @config = config
50
58
  @embedded = embedded
51
- # Two separate flags, deliberately. @done = "quieted" (stop fetching, stay
52
- # alive, report quiet=true). @stopped = "shutting down" (terminate the
53
- # heartbeat loop). Quiet must NOT stop the heartbeat — otherwise a quieted
54
- # process never publishes quiet=true and expires out of the live set (#236).
59
+ # @done is "quieted": stop fetching, stay alive, report quiet=true. It
60
+ # deliberately does NOT stop the heartbeat — a quieted process that stopped
61
+ # beating would never publish quiet=true and would expire out of the live
62
+ # set (#236). Only #stop ends the beat, by terminating @beat_timer.
55
63
  @done = false
56
- @stopped = false
57
- @managers = config.capsules.values.map { |cap| Manager.new(cap) }
58
- @poller = build_poller
59
- @cron_poller = build_cron_poller
60
- @metrics_rollup = build_metrics_rollup
61
- @queue_rollup = build_queue_rollup
62
- @history = build_history
64
+ @shutdown_gate = ShutdownGate.new
65
+ @beat_timer = TimerLoop.new(BEAT_PAUSE)
66
+ @managers = build_managers
67
+ build_loops
63
68
  @leader = build_leader
64
69
  @reaper = build_reaper
65
- @started_at = nil
66
- @heartbeat = nil
67
- @heartbeat_thread = nil
68
- @boot_reclaim_thread = nil
69
70
  @health_server = build_health_server
71
+ reset_thread_state
70
72
  end
71
73
 
72
74
  # Boot order matters:
73
75
  # 1. freeze! the config so mutations after fork are visible mistakes.
74
- # 2. spawn the heartbeat thread BEFORE the managers so the dashboard
75
- # sees the process the moment it can pick up jobs.
76
- # 3. start the scheduler poller + the cron poller (both leader-gated for
77
- # what they enqueue; safe to start before leadership is settled since
78
- # a non-leader tick just returns early).
79
- # 4. start the managers (which start their processors).
80
- # 5. start the health probe server LAST so the listener doesn't
76
+ # A swarm child finds the slot-independent half already frozen by its
77
+ # parent (Configuration#prepare_for_fork!); what closes here is the
78
+ # capsules, which stayed open for this child's slot and pools.
79
+ # 2. start the managers FIRST. They are the only thing here on the
80
+ # time-to-first-job path (the number bench/vs_sidekiq.rb measures);
81
+ # everything below is periodic background work whose first tick is
82
+ # seconds away, so it has nothing to gain from going ahead of them.
83
+ # 3. spawn the heartbeat thread, so the dashboard sees the process
84
+ # right after it can pick up jobs.
85
+ # 4. start the reaper and the boot-time reclaim sweep — kill-9 recovery,
86
+ # not decoration: a SIGKILLed sibling's in-flight jobs wait on this,
87
+ # so it stays ahead of the periodic loops.
88
+ # 5. start the periodic loops. None of them ticks at zero — TimerLoop#run
89
+ # waits an interval before its first yield, Scheduled::Poller waits
90
+ # INITIAL_WAIT, and Leader waits DEFAULT_INITIAL_WAIT — so starting
91
+ # them last costs a few thread spawns and nothing else. Both leader-
92
+ # gated pollers are also safe to start before leadership settles: a
93
+ # non-leader tick just returns early.
94
+ # 6. start the health probe server LAST so the listener doesn't
81
95
  # accept k8s probes until the rest of the launcher is up.
82
96
  def run(async_beat: true)
83
97
  @started_at = Time.now.to_f
@@ -86,19 +100,22 @@ module Wurk
86
100
  # CLI, embedded) runs through here, so none boots with a nil fetcher.
87
101
  @config.capsules.each_value(&:prepare!)
88
102
  @config.freeze!
89
- @heartbeat_thread = safe_thread('heartbeat', &method(:start_heartbeat)) if async_beat
90
- @poller&.start
91
- @leader&.start
92
- @cron_poller&.start
93
- @metrics_rollup&.start
94
- @queue_rollup&.start
95
- @history&.start
96
103
  @managers.each(&:start)
104
+ @heartbeat_thread = safe_thread('heartbeat', &method(:start_heartbeat)) if async_beat
97
105
  @reaper.start
98
106
  # Run on a background thread so /ready probe isn't delayed by a large
99
107
  # orphan sweep (reaper.reclaim! is atomic, but can scan many entries).
100
108
  @boot_reclaim_thread = safe_thread('boot-reclaim', &method(:boot_reclaim))
109
+ start_periodic_components
101
110
  @health_server&.start
111
+ rescue StandardError
112
+ # Boot is not atomic: whatever raised (a health-check port already bound,
113
+ # ThreadError at the OS thread limit) leaves the steps before it holding
114
+ # threads, sockets and a leader campaign — and the caller is about to drop
115
+ # its only reference to us, so nothing else can ever release them. Guarded,
116
+ # because the caller must see the boot failure, not a rollback failure.
117
+ teardown_step('boot-rollback') { stop }
118
+ raise
102
119
  end
103
120
 
104
121
  # Idempotent. Flips `stopping?` true, halts fetching across every
@@ -116,28 +133,35 @@ module Wurk
116
133
  fire_event(:quiet, reverse: true)
117
134
  end
118
135
 
119
- # Graceful shutdown. Deadline is monotonic so wall-clock skew can't
120
- # extend it. Managers stop in parallel threads so a slow capsule
121
- # doesn't block its siblings.
136
+ # Graceful shutdown, single-shot: several requests can be in flight at once
137
+ # (a dashboard-queued TERM, the embedded host's own `Embedded#stop`, a
138
+ # Manager that can no longer hold its concurrency), so the first caller
139
+ # drains and the rest wait it out — see ShutdownGate. Deadline is monotonic
140
+ # so wall-clock skew can't extend it. Managers stop in parallel threads so a
141
+ # slow capsule doesn't block its siblings — and each drain is guarded,
142
+ # because a capsule that blows up mid-drain (Redis down during bulk_requeue)
143
+ # used to surface out of `join` and skip the whole teardown tail, leaving the
144
+ # leader lock held, the process listed as live, and its port open.
145
+ #
146
+ # The joins get one shared budget of `deadline + TimerLoop::JOIN_TIMEOUT`,
147
+ # not an open-ended wait. Manager#stop is meant to run a little past the
148
+ # deadline — on expiry it bulk_requeues the in-flight UnitsOfWork, kills the
149
+ # threads and gives them a ~3s window to run their `ensure` blocks — but
150
+ # past that it is wedged (a `fetcher.terminate` / `bulk_requeue` parked on a
151
+ # Redis that stopped answering), and every further second spent waiting
152
+ # comes out of the teardown tail below, which the swarm parent only allows
153
+ # `shutdown_timeout + Swarm::SHUTDOWN_GRACE` for before it SIGKILLs us
154
+ # mid-drain.
122
155
  def stop
123
- deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + (@config[:timeout] || 25)
124
- quiet
125
- stoppers = @managers.map { |m| Thread.new { m.stop(deadline) } }
126
- fire_event(:shutdown, reverse: true)
127
- stoppers.each(&:join)
128
- # Full shutdown stops periodic firing (it survived #quiet); do this before
129
- # releasing the lock so no tick races a follower's promotion.
130
- @cron_poller&.terminate
131
- @metrics_rollup&.terminate
132
- @queue_rollup&.terminate
133
- @history&.terminate
134
- @reaper&.stop
135
- # CAS-release the cluster lock now (planned shutdown) so a follower can
136
- # take over immediately instead of waiting out the TTL.
137
- @leader&.stop
138
- stop_heartbeat
139
- clear_heartbeat
140
- fire_event(:exit, reverse: true)
156
+ @shutdown_gate.run do
157
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + (@config[:timeout] || 25)
158
+ quiet
159
+ stoppers = @managers.map { |m| Thread.new { teardown_step('manager') { m.stop(deadline) } } }
160
+ fire_event(:shutdown, reverse: true)
161
+ @shutdown_gate.join_within(stoppers, deadline + TimerLoop::JOIN_TIMEOUT)
162
+ ensure
163
+ release_components
164
+ end
141
165
  end
142
166
 
143
167
  def stopping?
@@ -162,9 +186,12 @@ module Wurk
162
186
 
163
187
  write_stats(processed, failed, expired)
164
188
  rescue StandardError => e
165
- # Replay-safety: counters were reset above, so a Redis blip would
166
- # otherwise drop stats. We log and accept — the per-job at-least-once
167
- # semantics don't apply to *counters*, and the next beat resets again.
189
+ # The counters were reset above, so a raise here drops this window's stats
190
+ # for good: #write_stats cannot claim apply-safety and the pool no longer
191
+ # replays it through a blip. Accepted deliberately — adding them back
192
+ # would double-count the case where the INCRBYs did land and only the
193
+ # reply was lost, and the per-job at-least-once semantics don't apply to
194
+ # *counters*. The next beat resets again.
168
195
  handle_exception(e, { context: 'flush_stats' })
169
196
  end
170
197
 
@@ -174,6 +201,77 @@ module Wurk
174
201
 
175
202
  private
176
203
 
204
+ # Teardown tail, driven from #stop's ensure. Every release is guarded on
205
+ # its own because they are independent: a Redis blip in one (leader CAS
206
+ # release, heartbeat clear) must not strand the ones after it — the process
207
+ # is exiting either way, so the only thing worse than a failed release is a
208
+ # skipped one. Order matters twice over: full shutdown stops the periodic
209
+ # loops (they survived #quiet) before the cluster lock is CAS-released, so
210
+ # no tick races a follower's promotion, and the release itself happens on a
211
+ # planned shutdown rather than waiting out the lock TTL.
212
+ def release_components
213
+ # Joined first, before anything below tears down the Redis pool it's
214
+ # still reading from: it started as a fire-and-forget thread in #run
215
+ # and a shutdown landing right after boot could otherwise race a
216
+ # pool disconnect mid-scan. Bounded: a full-keyspace scan can outlast
217
+ # the timeout, in which case the thread is left running rather than
218
+ # blocking shutdown on it — #boot_reclaim already rescues and logs on
219
+ # its own, so a straggler racing #reset_redis_pools! below just means
220
+ # a noisy log line, not a hang.
221
+ teardown_step('boot-reclaim-join') { @boot_reclaim_thread&.join(BOOT_RECLAIM_JOIN_TIMEOUT) }
222
+ stop_periodic_components
223
+ %i[stop_heartbeat clear_heartbeat].each { |step| teardown_step(step) { send(step) } }
224
+ # Unguarded: fire_event already reports and skips past a raising hook.
225
+ fire_event(:exit, reverse: true)
226
+ # Unconditional. A swarm child or standalone process exits right after
227
+ # #stop, so for those it only closes sockets the kernel was about to close
228
+ # anyway — but every other driver survives us: an embedded host (Puma, a
229
+ # rake task) that boots a fresh Launcher later, and anything driving
230
+ # Launcher directly. Skipping those leaked one main + one fetch pool per
231
+ # capsule, live, for the life of the host. Idempotent: `reset_redis_pools!`
232
+ # drops the memo, so the next checkout rebuilds.
233
+ teardown_step('redis-pools') { @config.reset_redis_pools! }
234
+ ensure
235
+ # In an ensure of its own, not merely a guarded step: a leaked TCPServer
236
+ # FD survives even a non-StandardError unwind (a second TERM landing
237
+ # mid-teardown), and kubelet would keep getting 200s from a process that
238
+ # is already gone.
239
+ teardown_step('health-server') { @health_server&.stop }
240
+ end
241
+
242
+ # Mirror of #stop_periodic_components: every loop this process runs on a
243
+ # timer, started in one place at the tail of #run. `compact` because the
244
+ # history snapshotter is opt-in and an embedded boot may have stripped the
245
+ # rest.
246
+ def start_periodic_components
247
+ [@poller, @leader, @cron_poller, @metrics_rollup,
248
+ @queue_rollup, @metrics_flusher, @history].compact.each(&:start)
249
+ end
250
+
251
+ # Split out of #release_components to keep it under the AbcSize/
252
+ # CyclomaticComplexity ceilings — these are the "periodic loop" releases,
253
+ # independently guarded like everything else in the tail.
254
+ #
255
+ # The metrics flusher goes first: its #terminate drains the last window of
256
+ # per-job counters into the `j|…` minute buckets, and Metrics::Rollup reads
257
+ # exactly those buckets — stopping the rollup first would give its final
258
+ # tick nothing to roll.
259
+ def stop_periodic_components
260
+ [@metrics_flusher, @cron_poller, @metrics_rollup, @queue_rollup,
261
+ @history].each { |t| teardown_step(t.class) { t&.terminate } }
262
+ teardown_step('reaper') { @reaper&.stop }
263
+ teardown_step('leader') { @leader&.stop }
264
+ end
265
+
266
+ def teardown_step(label)
267
+ yield
268
+ rescue StandardError => e
269
+ handle_exception(e, { context: "launcher-stop-#{label}" })
270
+ end
271
+
272
+ # INCRBY is additive, so a pipeline replayed after a lost reply counts this
273
+ # window twice on every `stat:` key it touches — no apply-safety claim. (The
274
+ # EXPIREs riding along are harmless to repeat; the INCRBYs pin the block.)
177
275
  def write_stats(processed, failed, expired)
178
276
  day = Time.now.utc.strftime('%F')
179
277
  @config.redis do |conn|
@@ -217,117 +315,130 @@ module Wurk
217
315
 
218
316
  # Erase the live-process footprint. flush_stats first so we don't drop
219
317
  # the final batch of counters; then Heartbeat#stop! removes us from the
220
- # `processes` SET and UNLINK-s the identity + work hashes. The probe
221
- # server is closed alongside so kubelet stops getting 200s after the
222
- # process is no longer healthy.
318
+ # `processes` SET and UNLINK-s the identity + work hashes.
223
319
  def clear_heartbeat
224
320
  flush_stats
225
321
  @heartbeat&.stop!
226
- @health_server&.stop
227
322
  end
228
323
 
229
324
  # Terminate the heartbeat loop and wait for it to exit before clear_heartbeat
230
325
  # removes us from the `processes` SET — otherwise a final in-flight beat could
231
- # SADD us back right after the SREM. Wakes the thread out of its BEAT_PAUSE
232
- # sleep so shutdown isn't delayed up to a full interval.
326
+ # SADD us back right after the SREM and the identity would linger for a full
327
+ # 60s TTL.
328
+ #
329
+ # The join is unbounded on purpose. This used to be `wakeup` + `join(BEAT_PAUSE)`,
330
+ # which lost the race whenever the beat was mid-Redis-call: `wakeup` does nothing
331
+ # to a thread that isn't sleeping, so the loop then slept a full interval and the
332
+ # bounded join returned with the thread still live — exactly the resurrect-after-
333
+ # SREM this ordering exists to prevent. A condvar can't be missed (terminate flips
334
+ # the flag inside the critical section the loop re-checks it in), so all that is
335
+ # left to wait out is one in-flight beat, whose Redis calls are timeout-bounded.
233
336
  def stop_heartbeat
234
- @stopped = true
337
+ @beat_timer.terminate
235
338
  thread = @heartbeat_thread
236
339
  return unless thread
237
- # Embedded dashboard-TERM runs `stop` from the beat itself; a self-join
238
- # raises ThreadError. @stopped is set, so the loop exits after this beat.
340
+ # Embedded dashboard-TERM can run `stop` from the beat itself; a self-join
341
+ # raises ThreadError. The timer is terminated, so the loop exits after this beat.
239
342
  return if thread == Thread.current
240
343
 
241
- begin
242
- thread.wakeup
243
- rescue ThreadError
244
- nil
245
- end
246
- thread.join(BEAT_PAUSE)
344
+ thread.join
247
345
  end
248
346
 
249
- # Heartbeat thread loop. `safe_thread` already wraps exceptions. Loops on
250
- # `@stopped` — NOT `@done` — so a *quieted* process keeps beating and publishes
251
- # `quiet=true` instead of vanishing from the live set (#236). Only `#stop`
252
- # flips `@stopped`; its `Thread#wakeup` breaks the sleep so the loop re-checks
253
- # `@stopped` and exits without waiting out the interval.
347
+ # Heartbeat thread loop. `safe_thread` already wraps exceptions. Beats once up
348
+ # front — TimerLoop#run waits before its first yield, and the dashboard has to
349
+ # see this process the moment it can pick up jobs — then ticks until #stop
350
+ # terminates the timer. Note it does NOT stop on `@done`: a *quieted* process
351
+ # keeps beating so it publishes `quiet=true` instead of vanishing (#236).
254
352
  def start_heartbeat
255
- until @stopped
256
- heartbeat
257
- sleep BEAT_PAUSE
258
- end
353
+ heartbeat
354
+ @beat_timer.run { heartbeat }
259
355
  logger.info('Heartbeat stopping...')
260
356
  end
261
357
 
262
358
  # Dashboard-queued signals must behave exactly like OS signals, so a
263
- # standalone process re-delivers to itself and lets the installed trap
264
- # run — that wakes the main thread (CLI self-pipe / child dispatcher)
265
- # so the process actually exits instead of stopping its managers and
266
- # then parking forever. Embedded mode owns no traps (and self-TERM
267
- # would kill the host app), so it calls quiet/stop directly — stop on
268
- # its own thread because `stop` joins the heartbeat thread we're on.
359
+ # standalone process re-delivers to itself and lets the installed trap run —
360
+ # that wakes the main thread (CLI self-pipe / child dispatcher) so the
361
+ # process actually exits instead of stopping its managers and then parking
362
+ # forever. Embedded mode owns no traps (and self-TERM would kill the host
363
+ # app), so quiet applies directly.
269
364
  def dispatch_signal(sig)
270
365
  case sig
271
- when 'TSTP', 'TERM'
272
- if @embedded
273
- sig == 'TSTP' ? quiet : Thread.new { stop }
274
- else
275
- redeliver(sig)
276
- end
366
+ when 'TSTP' then @embedded ? quiet : redeliver(sig)
367
+ when 'TERM' then request_shutdown
277
368
  else
278
369
  logger.warn { "Unknown signal in #{identity}-signals: #{sig.inspect}" }
279
370
  end
280
371
  end
281
372
 
373
+ # The one way anything inside this process asks it to shut down gracefully:
374
+ # dashboard-queued TERM, or a Manager that can no longer hold its
375
+ # concurrency. Standalone hands off to the installed TERM trap; embedded
376
+ # owns no traps, so it drains in place — on its own thread, because callers
377
+ # may be a thread `stop` itself joins (the heartbeat) or kills (a Processor).
378
+ #
379
+ # That thread is spawned once and retained by the gate: every later request
380
+ # rides the same drain instead of leaving another unreferenced `stop`
381
+ # running behind it. Returned so a caller that can afford to wait (a test,
382
+ # a host teardown) has something to join.
383
+ def request_shutdown
384
+ return redeliver('TERM') unless @embedded
385
+
386
+ @shutdown_gate.runner { safe_thread('launcher-stop') { stop } }
387
+ end
388
+
282
389
  # Separate method so tests can stub it — really sending TERM/TSTP would
283
390
  # kill or suspend the test process.
284
391
  def redeliver(sig)
285
392
  ::Process.kill(sig, ::Process.pid)
286
393
  end
287
394
 
288
- def build_poller
289
- Wurk::Scheduled::Poller.new(@config)
290
- end
291
-
292
- # Periodic (cron) tick loop. Like the scheduler poller, every process runs
293
- # one, but only the elected leader enqueues — the single-leader invariant is
294
- # what guarantees exactly one enqueue per (loop, tick) across the cluster.
295
- def build_cron_poller
296
- Wurk::Cron::Poller.new(@config)
297
- end
298
-
299
- # Leader-only metrics rollup. Every process runs one, but only the elected
300
- # leader writes the cluster-total time-series buckets the dashboard charts
301
- # read — a non-leader tick returns early. Tune the cadence (tests shrink it)
302
- # with `config.metrics_rollup_interval`.
303
- def build_metrics_rollup
304
- Wurk::Metrics::Rollup.new(@config)
395
+ # One Manager per capsule, each holding our shutdown request: a Manager
396
+ # that can no longer replace a dead Processor has to take the process
397
+ # down, and only the Launcher knows how this process exits.
398
+ def build_managers
399
+ @config.capsules.values.map { |cap| Manager.new(cap, shutdown: method(:request_shutdown)) }
305
400
  end
306
401
 
307
- # Leader-only per-queue gauge sampler. Like the metrics rollup, every
308
- # process runs one but only the leader writes the `qm|…` size/latency
309
- # buckets the Historical tab's per-queue charts read.
310
- def build_queue_rollup
311
- Wurk::Metrics::QueueRollup.new(@config)
402
+ # The periodic loops, built in one place because they are started together
403
+ # (#run) and released together (#stop_periodic_components). Every process
404
+ # runs every one of them; what differs is who does the work inside a tick:
405
+ #
406
+ # poller / reaper every process, work shared through Redis
407
+ # cron_poller leader only enqueues — that is the exactly-once guard
408
+ # metrics_rollup leader only writes the cluster-total chart buckets
409
+ # (cadence: `config.metrics_rollup_interval`)
410
+ # queue_rollup leader only writes the `qm|…` per-queue gauges
411
+ # metrics_flusher NOT leader-gated: the counters it drains exist only in
412
+ # this process's memory, so every process flushes its own
413
+ # history Ent §5 snapshotter, leader-gated, opt-in via
414
+ # `config.retain_history`
415
+ def build_loops
416
+ @poller = Wurk::Scheduled::Poller.new(@config)
417
+ @cron_poller = Wurk::Cron::Poller.new(@config)
418
+ @metrics_rollup = Wurk::Metrics::Rollup.new(@config)
419
+ @queue_rollup = Wurk::Metrics::QueueRollup.new(@config)
420
+ @metrics_flusher = Wurk::Metrics::Flusher.new(@config)
421
+ @history = Wurk::History.new(@config) if @config.history_enabled?
312
422
  end
313
423
 
314
- # Ent §5 Historical Metrics snapshotter — only when the host opted in via
315
- # `config.retain_history`. Leader-gated like the rollups, so just one
316
- # process emits the cluster-wide snapshot per interval.
317
- def build_history
318
- Wurk::History.new(@config) if @config.history_enabled?
424
+ # Filled in by #run. Declared up front so #initialize reads as the full
425
+ # inventory of what a Launcher owns.
426
+ def reset_thread_state
427
+ @started_at = @heartbeat = @heartbeat_thread = @boot_reclaim_thread = nil
319
428
  end
320
429
 
321
430
  # Every worker process campaigns for the single cluster lock (`dear-leader`);
322
431
  # one wins and renews it, the rest follow and promote on its death. Cadence
323
432
  # falls back to the spec defaults (TTL 30 / renew 15 / follower 60) unless
324
- # the host tunes it. `Leader#start` no-ops under `WURK_LEADER=false`.
433
+ # the host tunes it, plus the short pre-campaign delay that keeps the first
434
+ # CAS off the boot path. `Leader#start` no-ops under `WURK_LEADER=false`.
325
435
  def build_leader
326
436
  Wurk::Leader.new(
327
437
  config: @config,
328
438
  ttl: @config[:leader_ttl] || Wurk::Leader::DEFAULT_TTL,
329
439
  renew_interval: @config[:leader_renew_interval] || Wurk::Leader::DEFAULT_RENEW_INTERVAL,
330
- follower_interval: @config[:leader_follower_interval] || Wurk::Leader::DEFAULT_FOLLOWER_INTERVAL
440
+ follower_interval: @config[:leader_follower_interval] || Wurk::Leader::DEFAULT_FOLLOWER_INTERVAL,
441
+ initial_wait: @config[:leader_initial_wait] || Wurk::Leader::DEFAULT_INITIAL_WAIT
331
442
  )
332
443
  end
333
444