wurk 0.0.1

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
data/lib/wurk/batch.rb ADDED
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require_relative 'lua'
6
+ require_relative 'batch/buffer'
7
+
8
+ module Wurk
9
+ # Sidekiq Pro Batches. Group jobs, attach success/complete/death callbacks,
10
+ # track progress. Spec: docs/target/sidekiq-pro.md §2.
11
+ #
12
+ # Lifecycle:
13
+ # 1. `Batch.new` allocates a fresh BID; the batch is `mutable?` until the
14
+ # first `#jobs` block flushes — that flush HSETs the core hash, ZADDs
15
+ # to `batches`, and writes tag indexes.
16
+ # 2. `Batch.new(bid)` reopens an existing batch (legal from inside a job
17
+ # or callback only). `mutable?` is false because reopening implies the
18
+ # first flush already happened.
19
+ # 3. `#jobs { ... }` collects `Job.perform_async` calls via the client
20
+ # middleware (Thread.current[:wurk_current_batch] is the signal),
21
+ # atomically registering each via BATCH_PUSH.
22
+ # 4. Workers ack on success → BATCH_ACK_SUCCESS → pending--.
23
+ # Death handler acks on permanent failure → BATCH_ACK_COMPLETE.
24
+ # 5. When live jids hits zero → fire `:complete`. When pending also
25
+ # hits zero with zero deaths → fire `:success`.
26
+ #
27
+ # Nested batches: a job opening its OWN batch (`batch.jobs { ... }`)
28
+ # increments live counters on the existing batch. A callback opening its
29
+ # PARENT batch links via parent_bid and adds child BID to b-<bid>-kids.
30
+ class Batch
31
+ DEFAULT_EXPIRY_SECONDS = 30 * 24 * 60 * 60
32
+ POST_SUCCESS_EXPIRY_SECONDS = 24 * 60 * 60
33
+ CALLBACK_NOTIFY_TTL = 30 * 24 * 60 * 60
34
+
35
+ # Bid is URL-safe base64 of 10 random bytes — matches Sidekiq Pro's BID
36
+ # generator. Length matters: third-party gems that key off bid prefix
37
+ # (sharded batches in Pro 8) inspect the first character.
38
+ BID_BYTES = 10
39
+
40
+ VALID_EVENTS = %i[success complete death].freeze
41
+
42
+ # The 'live' set tracks jobs that have not yet reached a terminal state.
43
+ # When it's empty, every job has either succeeded or died → `:complete`
44
+ # is allowed to fire.
45
+ KEY_SUFFIXES = %w[jids failed died notify cbsucc kids pkids tags].freeze
46
+
47
+ THREAD_KEY = :wurk_current_batch
48
+
49
+ # Set on the current thread (to a Buffer) only inside an autoflush
50
+ # `#jobs` block. Client#raw_push reads it: when present, batched pushes
51
+ # accumulate here instead of round-tripping per job.
52
+ BUFFER_KEY = :wurk_batch_buffer
53
+
54
+ attr_reader :bid, :parent_bid, :linger
55
+ attr_accessor :description, :callback_queue, :callback_class, :autoflush
56
+
57
+ def self.keys_for(bid)
58
+ base = "b-#{bid}"
59
+ [base, *KEY_SUFFIXES.map { |s| "#{base}-#{s}" }]
60
+ end
61
+
62
+ def initialize(bid = nil)
63
+ @bid = bid || SecureRandom.urlsafe_base64(BID_BYTES)
64
+ @existing = !bid.nil?
65
+ @description = nil
66
+ @callback_queue = 'default'
67
+ @callback_class = nil
68
+ @tags = []
69
+ @autoflush = nil
70
+ @linger = nil
71
+ @parent_bid = nil
72
+ @callbacks = []
73
+ @expires_in = DEFAULT_EXPIRY_SECONDS
74
+ @mutable = !@existing
75
+ @flushed_once = @existing
76
+ load_existing! if @existing
77
+ end
78
+
79
+ # Hash assignment writes strings — Sidekiq's UI / third-party gems
80
+ # expect String tags. Array-coercion lets callers pass a String or Set.
81
+ def tags=(value)
82
+ @tags = Array(value).map(&:to_s)
83
+ end
84
+
85
+ def tags
86
+ @tags.dup
87
+ end
88
+
89
+ # Per-batch post-success retention override (seconds). nil falls back to
90
+ # POST_SUCCESS_EXPIRY_SECONDS when `:success` fires. See §2.8.
91
+ #
92
+ # After the first flush the value is persisted to `b-<bid>`; `apply_linger`
93
+ # reads from Redis, so a setter that only touched memory would silently
94
+ # ignore the override for any batch reopened by bid.
95
+ def linger=(duration)
96
+ @linger = duration&.to_i
97
+ return unless @flushed_once
98
+
99
+ Wurk.redis { |conn| conn.call('HSET', "b-#{@bid}", 'linger', @linger.to_s) }
100
+ end
101
+
102
+ def parent
103
+ return nil if @parent_bid.nil? || @parent_bid.empty?
104
+
105
+ Batch.new(@parent_bid)
106
+ end
107
+
108
+ def mutable?
109
+ @mutable
110
+ end
111
+
112
+ def include?(jid)
113
+ Wurk.redis { |conn| conn.call('SISMEMBER', "b-#{@bid}-jids", jid) }.to_i.positive?
114
+ end
115
+
116
+ # Remove jobs from the batch. Decrements pending/total by exactly the
117
+ # count of jids actually removed (idempotent for repeated calls).
118
+ def remove_jobs(*jids)
119
+ return 0 if jids.empty?
120
+
121
+ Wurk.redis do |conn|
122
+ removed = conn.call('SREM', "b-#{@bid}-jids", *jids).to_i
123
+ if removed.positive?
124
+ conn.call('HINCRBY', "b-#{@bid}", 'pending', -removed)
125
+ conn.call('HINCRBY', "b-#{@bid}", 'total', -removed)
126
+ end
127
+ removed
128
+ end
129
+ end
130
+
131
+ # Mark batch invalid. Pending jobs still exist in their queues; the
132
+ # server middleware short-circuits them when it observes the flag.
133
+ # Cascades to descendant batches via b-<bid>-kids.
134
+ def invalidate_all
135
+ cascade_invalidate(@bid)
136
+ nil
137
+ end
138
+
139
+ def valid?
140
+ Wurk.redis { |conn| conn.call('HGET', "b-#{@bid}", 'invalidated') } != '1'
141
+ end
142
+
143
+ def status
144
+ Status.new(@bid)
145
+ end
146
+
147
+ def expires_in(duration)
148
+ @expires_in = duration.to_i
149
+ self
150
+ end
151
+
152
+ # Register a callback. Multiple callbacks of the same event are allowed.
153
+ # The callback target may be a Class, "Foo#bar" string spec, or anything
154
+ # responding to `name`. `options` must be JSON-serializable.
155
+ def on(event, callback, options = {})
156
+ sym = event.to_sym
157
+ raise ArgumentError, "invalid event #{event.inspect}" unless VALID_EVENTS.include?(sym)
158
+ raise ArgumentError, 'callback options must be a Hash' unless options.is_a?(Hash)
159
+
160
+ @callbacks << [sym.to_s, callback_target(callback), options]
161
+ self
162
+ end
163
+
164
+ # Atomic enqueue block. Inside the block, `Job.perform_async` finds
165
+ # this batch via Thread.current[THREAD_KEY] and stamps `bid` onto the
166
+ # payload — the client middleware then uses BATCH_PUSH to register and
167
+ # push atomically. Empty blocks synthesise a Batch::Empty no-op so
168
+ # callbacks still fire.
169
+ def jobs(&block)
170
+ raise ArgumentError, 'jobs requires a block' unless block
171
+
172
+ ensure_first_flush!
173
+ pre_count = job_count
174
+ collect_jobs(&block)
175
+ # By the time we check, the buffer (if any) has flushed, so `total`
176
+ # reflects everything the block pushed — a flat count is reliable.
177
+ enqueue_empty_marker if job_count == pre_count
178
+ @mutable = false
179
+ self
180
+ end
181
+
182
+ private
183
+
184
+ # Runs the block with this batch active so the client middleware stamps
185
+ # the bid. With autoflush on, batched pushes accumulate in a Buffer and
186
+ # flush once at exit; the per-N flushing happens in Client#raw_push.
187
+ def collect_jobs
188
+ previous = Thread.current[THREAD_KEY]
189
+ prev_buffer = Thread.current[BUFFER_KEY]
190
+ buffer = new_buffer
191
+ Thread.current[THREAD_KEY] = self
192
+ Thread.current[BUFFER_KEY] = buffer
193
+ begin
194
+ yield
195
+ flush_buffer(buffer) if buffer
196
+ ensure
197
+ Thread.current[THREAD_KEY] = previous
198
+ Thread.current[BUFFER_KEY] = prev_buffer
199
+ end
200
+ end
201
+
202
+ # Buffering is opt-in via `autoflush`: `true` buffers the whole block and
203
+ # flushes once at exit; a positive Integer flushes every N jobs. Anything
204
+ # falsy keeps the default per-job immediate push.
205
+ def new_buffer
206
+ return nil unless @autoflush
207
+
208
+ Buffer.new([], autoflush_threshold)
209
+ end
210
+
211
+ # `true` → buffer the whole block (nil threshold, drained at exit);
212
+ # positive Integer → flush every N. Any other truthy value is a config
213
+ # typo (`0`, `-1`, `"5"`) — fail fast instead of silently degrading to
214
+ # "flush at block exit".
215
+ def autoflush_threshold
216
+ return nil if @autoflush == true
217
+ return @autoflush if @autoflush.is_a?(Integer) && @autoflush.positive?
218
+
219
+ raise ArgumentError, "autoflush must be true or a positive Integer, got #{@autoflush.inspect}"
220
+ end
221
+
222
+ def flush_buffer(buffer)
223
+ payloads = buffer.drain
224
+ Wurk::Client.new.flush_batched(payloads) unless payloads.empty?
225
+ end
226
+
227
+ # First flush writes the core hash, registers in the global `batches`
228
+ # zset, and links tag indexes. Subsequent #jobs invocations skip this
229
+ # — `total` is already there and BATCH_PUSH only increments deltas.
230
+ def ensure_first_flush!
231
+ return if @flushed_once
232
+
233
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
234
+ Wurk.redis { |conn| conn.pipelined { |pipe| pipelined_first_flush(pipe, now) } }
235
+ @parent_bid = current_parent_bid
236
+ @flushed_once = true
237
+ end
238
+
239
+ def pipelined_first_flush(pipe, now)
240
+ pipe.call('HSET', "b-#{@bid}", *first_flush_hash(now).flatten)
241
+ pipe.call('EXPIRE', "b-#{@bid}", @expires_in)
242
+ pipe.call('ZADD', 'batches', now.to_s, @bid)
243
+ @tags.each { |t| pipe.call('SADD', "tags:#{t}", @bid) }
244
+ link_to_parent(pipe) if current_parent_bid
245
+ end
246
+
247
+ def first_flush_hash(now)
248
+ {
249
+ 'created_at' => now.to_s,
250
+ 'description' => @description.to_s,
251
+ 'callback_queue' => @callback_queue.to_s,
252
+ 'callback_class' => @callback_class.to_s,
253
+ 'parent_bid' => current_parent_bid.to_s,
254
+ 'tags' => @tags.to_json,
255
+ 'linger' => @linger.to_s,
256
+ 'callbacks' => @callbacks.to_json,
257
+ 'total' => '0',
258
+ 'pending' => '0',
259
+ 'failures' => '0'
260
+ }
261
+ end
262
+
263
+ # If we're flushing from inside another batch's #jobs block, that
264
+ # parent batch's BID becomes our parent_bid and we get registered into
265
+ # its kids/pkids sets so cascade success/failure is correctly tracked.
266
+ def current_parent_bid
267
+ outer = Thread.current[THREAD_KEY]
268
+ return nil if outer.nil? || outer.bid == @bid
269
+
270
+ outer.bid
271
+ end
272
+
273
+ def link_to_parent(pipe)
274
+ pipe.call('SADD', "b-#{current_parent_bid}-kids", @bid)
275
+ pipe.call('SADD', "b-#{current_parent_bid}-pkids", @bid)
276
+ end
277
+
278
+ def job_count
279
+ Wurk.redis { |conn| conn.call('HGET', "b-#{@bid}", 'total') }.to_i
280
+ end
281
+
282
+ def enqueue_empty_marker
283
+ require_relative 'batch/empty'
284
+ previous = Thread.current[THREAD_KEY]
285
+ Thread.current[THREAD_KEY] = self
286
+ begin
287
+ Wurk::Batch::Empty.perform_async
288
+ ensure
289
+ Thread.current[THREAD_KEY] = previous
290
+ end
291
+ end
292
+
293
+ def cascade_invalidate(bid)
294
+ Wurk.redis do |conn|
295
+ Wurk::Lua::Loader.eval_cached(conn, :batch_invalidate, keys: ["b-#{bid}", "b-#{bid}-jids"], argv: [])
296
+ kids = conn.call('SMEMBERS', "b-#{bid}-kids") || []
297
+ kids.each { |child| cascade_invalidate(child) }
298
+ end
299
+ end
300
+
301
+ def load_existing!
302
+ data = fetch_hash
303
+ load_meta(data)
304
+ @tags = parse_json_array(data['tags'])
305
+ @linger = nil_if_empty(data['linger'])&.to_i
306
+ @callbacks = parse_json_callbacks(data['callbacks'])
307
+ end
308
+
309
+ def load_meta(data)
310
+ @description = nil_if_empty(data['description'])
311
+ @callback_queue = nil_if_empty(data['callback_queue']) || 'default'
312
+ @callback_class = nil_if_empty(data['callback_class'])
313
+ @parent_bid = nil_if_empty(data['parent_bid'])
314
+ end
315
+
316
+ def fetch_hash
317
+ raw = Wurk.redis { |conn| conn.call('HGETALL', "b-#{@bid}") }
318
+ raw.is_a?(Hash) ? raw : raw.each_slice(2).to_h
319
+ end
320
+
321
+ def callback_target(callback)
322
+ case callback
323
+ when Class then callback.name
324
+ when String, Symbol then callback.to_s
325
+ else
326
+ raise ArgumentError, "callback must be Class or String: #{callback.inspect}" unless callback.respond_to?(:name)
327
+
328
+ callback.name
329
+ end
330
+ end
331
+
332
+ def nil_if_empty(val)
333
+ val.nil? || val.to_s.empty? ? nil : val
334
+ end
335
+
336
+ def parse_json_array(raw)
337
+ return [] if raw.nil? || raw.empty?
338
+
339
+ JSON.parse(raw)
340
+ rescue JSON::ParserError
341
+ []
342
+ end
343
+
344
+ def parse_json_callbacks(raw)
345
+ arr = parse_json_array(raw)
346
+ arr.is_a?(Array) ? arr : []
347
+ end
348
+ end
349
+ end
350
+
351
+ require_relative 'batch/status'
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'batch'
4
+
5
+ module Wurk
6
+ # Discovery API over the global `batches` sorted set. `size`, `each` (yields
7
+ # Status), `scan_tags(tag)` for tag-indexed lookup.
8
+ #
9
+ # Spec: docs/target/sidekiq-pro.md §2.7.
10
+ class BatchSet
11
+ include Enumerable
12
+
13
+ PAGE_SIZE = 100
14
+
15
+ def initialize(key: 'batches')
16
+ @key = key
17
+ end
18
+
19
+ def size
20
+ Wurk.redis { |conn| conn.call('ZCARD', @key) }.to_i
21
+ end
22
+
23
+ # Newest-first iteration of every batch indexed in the set. Yields
24
+ # `Wurk::Batch::Status` instances — they HGETALL on construction so
25
+ # the caller pays one Redis round-trip per batch.
26
+ def each
27
+ return enum_for(:each) unless block_given?
28
+
29
+ page = 0
30
+ loop do
31
+ start = page * PAGE_SIZE
32
+ stop = start + PAGE_SIZE - 1
33
+ bids = Wurk.redis { |conn| conn.call('ZRANGE', @key, start, stop, 'REV') }
34
+ bids.each { |bid| yield Wurk::Batch::Status.new(bid) }
35
+ break if bids.size < PAGE_SIZE
36
+
37
+ page += 1
38
+ end
39
+ end
40
+
41
+ # Yields each BID indexed under the given tag (set at `tags:<tag>`).
42
+ # No JSON parsing; cheap iteration. Caller wraps in `Status.new(bid)`
43
+ # if it needs full state.
44
+ def scan_tags(tag, &block)
45
+ return enum_for(:scan_tags, tag) unless block_given?
46
+
47
+ cursor = '0'
48
+ Wurk.redis do |conn|
49
+ loop do
50
+ cursor, members = conn.call('SSCAN', "tags:#{tag}", cursor, 'COUNT', PAGE_SIZE)
51
+ members.each(&block)
52
+ break if cursor == '0'
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ class Batch
59
+ # Discovery view over batches that triggered `:death`. Iterates Status
60
+ # instances newest-first.
61
+ class DeadSet < ::Wurk::BatchSet
62
+ def initialize
63
+ super(key: 'dead-batches')
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'redis_pool'
4
+ require_relative 'middleware/chain'
5
+ require_relative 'fetcher/reliable'
6
+
7
+ module Wurk
8
+ # One processing unit: a set of threads + queues sharing a fetcher and a
9
+ # Redis pool. Configurations can hold many capsules; each maps to its own
10
+ # Manager and Processors.
11
+ #
12
+ # Spec: docs/target/sidekiq-free.md §5 (Sidekiq::Capsule).
13
+ class Capsule
14
+ MODES = %i[strict weighted random].freeze
15
+
16
+ attr_reader :name, :queues, :mode, :weights, :config
17
+ attr_accessor :concurrency, :fetcher
18
+
19
+ def initialize(name, config)
20
+ @name = name.to_s
21
+ @config = config
22
+ @concurrency = config[:concurrency] || 5
23
+ @queues = ['default']
24
+ @mode = :strict
25
+ @weights = { 'default' => 0 }
26
+ @fetcher = nil
27
+ @redis_pool = nil
28
+ @local_redis_pool = nil
29
+ @client_chain = nil
30
+ @server_chain = nil
31
+ end
32
+
33
+ def to_h
34
+ { concurrency: @concurrency, mode: @mode, weights: @weights }
35
+ end
36
+
37
+ # Parses queue specs Sidekiq-style:
38
+ # %w[high default low] → mode :strict, weights all 0
39
+ # %w[high,3 default,2 low,1] → mode :weighted, weights {q=>w}
40
+ # %w[a,1 b,1 c,1] → mode :random, weights all 1
41
+ # @queues is expanded by weight so a uniform shuffle gives weighted
42
+ # fairness (e.g. ["high","high","high","default","default","low"]).
43
+ def queues=(val)
44
+ parsed = Array(val).map { |entry| parse_queue_entry(entry) }
45
+ raise ArgumentError, 'queues cannot be empty' if parsed.empty?
46
+
47
+ @weights = parsed.to_h
48
+ @mode = detect_mode(parsed)
49
+ @queues = expand_by_weight(parsed, @mode)
50
+ end
51
+
52
+ # Lossless `name[,weight]` specs (unlike `queues`, which is the
53
+ # weight-expanded list). Lets Configuration#topology rebuild a slot that
54
+ # round-trips back through `queues=` without flattening weights.
55
+ def queue_specs
56
+ @weights.map { |q, w| w.positive? ? "#{q},#{w}" : q }
57
+ end
58
+
59
+ # Materialize everything that lazy-inits via `||=` and default the fetcher,
60
+ # BEFORE Configuration#freeze! freezes the capsule — otherwise the first
61
+ # post-freeze access (a fetch tick, a middleware call) hits a nil fetcher
62
+ # or FrozenErrors building a pool. The swarm's ChildBoot used to do this
63
+ # by hand; centralizing it here covers the standalone CLI and embedded
64
+ # paths too (the bug behind a nil `fetcher` in `exe/wurk`). Idempotent.
65
+ def prepare!
66
+ @fetcher ||= Wurk::Fetcher::Reliable.new(self)
67
+ redis_pool
68
+ local_redis_pool
69
+ client_middleware
70
+ server_middleware
71
+ self
72
+ end
73
+
74
+ def client_middleware
75
+ chain = (@client_chain ||= @config.client_middleware.copy_for(self))
76
+ yield chain if block_given?
77
+ chain
78
+ end
79
+
80
+ def server_middleware
81
+ # copy_for(self) — not dup — binds the chain's `@config` to this capsule,
82
+ # so middleware that reach for `redis_pool`/`redis`/`logger` resolve them
83
+ # instead of hitting `nil` (a plain dup leaves @config nil).
84
+ chain = (@server_chain ||= @config.server_middleware.copy_for(self))
85
+ yield chain if block_given?
86
+ chain
87
+ end
88
+
89
+ # Pool size = concurrency + POOL_OVERHEAD. Every processor thread can
90
+ # be parked in a blocking BLMOVE (reliable fetch), holding its slot
91
+ # for ~3s. POOL_OVERHEAD reserves connections for the Launcher's
92
+ # heartbeat thread + the Scheduled::Poller so they can never be
93
+ # starved by busy fetchers. Without this, concurrency=1 deadlocks
94
+ # immediately (worker holds the only slot, heartbeat times out).
95
+ POOL_OVERHEAD = 2
96
+
97
+ def redis_pool
98
+ @redis_pool ||= build_pool(size: @concurrency + POOL_OVERHEAD, name: "#{@name}-main")
99
+ end
100
+
101
+ def local_redis_pool
102
+ @local_redis_pool ||= build_pool(size: @concurrency, name: "#{@name}-local")
103
+ end
104
+
105
+ # Disconnect and drop cached pools. Called by Wurk::Swarm just before
106
+ # fork (parent side: close inherited sockets) and just after fork
107
+ # (child side: rebuild lazily). Connection_pool#shutdown is terminal,
108
+ # so dropping the reference is required — `redis_pool` will rebuild.
109
+ def reset_redis_pools!
110
+ @redis_pool&.disconnect!
111
+ @redis_pool = nil
112
+ @local_redis_pool&.disconnect!
113
+ @local_redis_pool = nil
114
+ end
115
+
116
+ def redis(&)
117
+ redis_pool.with(&)
118
+ end
119
+
120
+ def lookup(name)
121
+ @config.lookup(name)
122
+ end
123
+
124
+ def logger
125
+ @config.logger
126
+ end
127
+
128
+ # No-op in OSS. Reserved for Pro/Ent hooks that need to flush per-capsule
129
+ # state on shutdown. Manager#stop invokes this in `ensure` so the contract
130
+ # is honored regardless of how stop unwinds.
131
+ #
132
+ # Spec: docs/target/sidekiq-free.md §5.
133
+ def stop; end
134
+
135
+ private
136
+
137
+ def parse_queue_entry(entry)
138
+ qname, weight_str = entry.to_s.split(',', 2)
139
+ qname = qname.to_s.strip
140
+ raise ArgumentError, "queue name cannot be empty: `#{entry}`" if qname.empty?
141
+ return [qname, 0] if weight_str.nil?
142
+
143
+ weight = Integer(weight_str)
144
+ raise ArgumentError, "queue weight must be > 0: `#{entry}`" if weight <= 0
145
+
146
+ [qname, weight]
147
+ end
148
+
149
+ def detect_mode(parsed)
150
+ weights = parsed.map(&:last)
151
+ if weights.all?(&:zero?)
152
+ :strict
153
+ elsif weights.uniq.size == 1
154
+ :random
155
+ else
156
+ :weighted
157
+ end
158
+ end
159
+
160
+ def expand_by_weight(parsed, mode)
161
+ return parsed.map(&:first) if %i[strict random].include?(mode)
162
+
163
+ parsed.flat_map { |q, w| [q] * w }
164
+ end
165
+
166
+ def build_pool(size:, name:)
167
+ cfg = @config.redis_config
168
+ RedisPool.new(
169
+ size: size,
170
+ url: cfg[:url] || RedisPool::DEFAULT_URL,
171
+ timeout: cfg[:timeout] || RedisPool::DEFAULT_TIMEOUT,
172
+ name: name
173
+ )
174
+ end
175
+ end
176
+ end