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
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'component'
4
+ require_relative 'context'
5
+ require_relative 'job_logger'
6
+ require_relative 'job_retry'
7
+ require_relative 'keys'
8
+
9
+ module Wurk
10
+ # Inside each Manager, N Processors run in parallel. Each owns one thread,
11
+ # pulls a UnitOfWork from the capsule's fetcher, parses the payload, walks
12
+ # the server middleware chain, invokes `perform`, then ACKs (removes the
13
+ # payload from the per-process private list).
14
+ #
15
+ # Shutdown is two-stage:
16
+ # * `terminate` flips a flag; the run loop exits between jobs.
17
+ # * `kill` additionally raises `Wurk::Shutdown` into the thread so an
18
+ # in-flight perform unwinds. The current UoW is NOT acked, so the
19
+ # payload survives in the private list and is reclaimed on next boot.
20
+ #
21
+ # Spec: docs/target/sidekiq-free.md §14.
22
+ class Processor
23
+ include Component
24
+
25
+ attr_reader :thread, :job, :capsule
26
+
27
+ def initialize(capsule, &callback)
28
+ @capsule = capsule
29
+ @config = capsule
30
+ @callback = callback
31
+ @done = false
32
+ @job = nil
33
+ @thread = nil
34
+ @reloader = capsule.config[:reloader] || proc { |&b| b.call }
35
+ @job_logger = (capsule.config[:job_logger] || JobLogger).new(capsule.config)
36
+ @retrier = JobRetry.new(capsule)
37
+ end
38
+
39
+ # Sidekiq surface — positional boolean to match the drop-in contract.
40
+ def terminate(wait = false) # rubocop:disable Style/OptionalBooleanParameter
41
+ @done = true
42
+ return if @thread.nil?
43
+
44
+ @thread.value if wait
45
+ end
46
+
47
+ # Hard-stop: flips the flag *and* unwinds the in-flight job by raising
48
+ # Wurk::Shutdown into the worker thread. The UoW is intentionally not
49
+ # acked — the payload remains in the private list and is reclaimed on
50
+ # next boot via Reliable#bulk_requeue.
51
+ def kill(wait = false) # rubocop:disable Style/OptionalBooleanParameter
52
+ @done = true
53
+ return if @thread.nil?
54
+
55
+ @thread.raise ::Wurk::Shutdown
56
+ @thread.value if wait
57
+ end
58
+
59
+ def stopping?
60
+ @done
61
+ end
62
+
63
+ def start
64
+ @thread ||= safe_thread("#{@capsule.name}/processor", &method(:run)) # rubocop:disable Naming/MemoizedInstanceVariableName
65
+ end
66
+
67
+ # Capsule doesn't define handle_exception (it's a Configuration method);
68
+ # override Component's delegation so error handlers fire.
69
+ def handle_exception(ex, ctx = {})
70
+ @capsule.config.handle_exception(ex, ctx)
71
+ end
72
+
73
+ # Single iteration: fetch one UoW, process it. Public so tests can drive
74
+ # the loop step-by-step without spawning a thread.
75
+ def process_one
76
+ @job = fetch
77
+ process(@job) if @job
78
+ @job = nil
79
+ end
80
+
81
+ # Thread-safe global counter. Heartbeat reads + resets these every beat
82
+ # to publish per-process stats.
83
+ class Counter
84
+ def initialize
85
+ @value = 0
86
+ @lock = ::Mutex.new
87
+ end
88
+
89
+ def incr(amount = 1)
90
+ @lock.synchronize { @value += amount }
91
+ end
92
+
93
+ def reset
94
+ @lock.synchronize do
95
+ val = @value
96
+ @value = 0
97
+ val
98
+ end
99
+ end
100
+ end
101
+
102
+ # tid → { queue:, payload:, run_at: } for every Processor currently
103
+ # running a job. Read by Heartbeat each beat to publish into Redis.
104
+ class SharedWorkState
105
+ def initialize
106
+ @work = {}
107
+ @lock = ::Mutex.new
108
+ end
109
+
110
+ def set(tid, hash)
111
+ @lock.synchronize { @work[tid] = hash }
112
+ end
113
+
114
+ def delete(tid)
115
+ @lock.synchronize { @work.delete(tid) }
116
+ end
117
+
118
+ def dup
119
+ @lock.synchronize { @work.dup }
120
+ end
121
+
122
+ def size
123
+ @lock.synchronize { @work.size }
124
+ end
125
+
126
+ def clear
127
+ @lock.synchronize { @work.clear }
128
+ end
129
+ end
130
+
131
+ PROCESSED = Counter.new
132
+ FAILURE = Counter.new
133
+ EXPIRED = Counter.new
134
+ WORK_STATE = SharedWorkState.new
135
+
136
+ private
137
+
138
+ def run
139
+ process_one until @done
140
+ @callback&.call(self)
141
+ rescue Wurk::Shutdown
142
+ @callback&.call(self)
143
+ rescue Exception => e # rubocop:disable Lint/RescueException
144
+ handle_exception(e, { context: '!shutdown' })
145
+ @callback&.call(self)
146
+ raise
147
+ end
148
+
149
+ def fetch
150
+ @capsule.fetcher.retrieve_work
151
+ rescue Wurk::Shutdown
152
+ nil
153
+ rescue StandardError => e
154
+ handle_exception(e, { context: 'Error fetching job' })
155
+ sleep(1)
156
+ nil
157
+ end
158
+
159
+ def process(uow)
160
+ jobstr = uow.job
161
+ queue = uow.queue_name
162
+
163
+ job_hash = parse_or_kill(jobstr, uow)
164
+ return if job_hash.nil?
165
+
166
+ ack = false
167
+ begin
168
+ Thread.handle_interrupt(Wurk::Shutdown => :never) do
169
+ dispatch(job_hash, queue, jobstr) do |instance|
170
+ Thread.handle_interrupt(Wurk::Shutdown => :immediate) do
171
+ execute_job(instance, job_hash, queue)
172
+ end
173
+ end
174
+ ack = true
175
+ end
176
+ rescue Wurk::JobRetry::Handled
177
+ # JobRetry::Skip (subclass) or Handled — retry layer / middleware has
178
+ # already booked the outcome; safe to ack.
179
+ ack = true
180
+ rescue Wurk::Shutdown
181
+ # Don't ack — UoW stays in private list and is reclaimed on reboot.
182
+ ensure
183
+ uow.acknowledge if ack
184
+ end
185
+ end
186
+
187
+ # Parse JSON; on failure ZADD the raw payload to the dead set and ack.
188
+ # Returns nil to signal "no further processing".
189
+ def parse_or_kill(jobstr, uow)
190
+ Wurk.load_json(jobstr)
191
+ rescue ::JSON::ParserError => e
192
+ handle_exception(e, { context: 'Invalid JSON', jobstr: jobstr })
193
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
194
+ @capsule.redis do |conn|
195
+ conn.call('ZADD', Keys::DEAD, now.to_s, jobstr)
196
+ end
197
+ uow.acknowledge
198
+ nil
199
+ end
200
+
201
+ # Wraps the actual perform in the dispatch onion: logger.prepare →
202
+ # retrier.global → logger.call → stats → reloader → instantiate
203
+ # → retrier.local → (yield to caller for middleware + perform).
204
+ def dispatch(job_hash, queue, jobstr)
205
+ @job_logger.prepare(job_hash) do
206
+ @retrier.global(jobstr, queue) do
207
+ @job_logger.call(job_hash, queue) do
208
+ stats(jobstr, queue) do
209
+ @reloader.call do
210
+ klass = Object.const_get(job_hash['class'])
211
+ instance = klass.new
212
+ instance.jid = job_hash['jid']
213
+ instance.bid = job_hash['bid'] if instance.respond_to?(:bid=)
214
+ instance._context = self
215
+ @retrier.local(instance, jobstr, queue) do
216
+ yield instance
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+
225
+ def execute_job(instance, job_hash, queue)
226
+ @capsule.server_middleware.invoke(instance, job_hash, queue) do
227
+ instance.perform(*job_hash['args'])
228
+ end
229
+ end
230
+
231
+ # Bookkeeping wrapper. Publishes the in-flight job to WORK_STATE so the
232
+ # Heartbeat can mirror it into Redis (`<identity>:work`), and increments
233
+ # PROCESSED/FAILURE counters around the inner block.
234
+ def stats(jobstr, queue)
235
+ WORK_STATE.set(tid, queue: queue, payload: jobstr, run_at: ::Time.now.to_i)
236
+ begin
237
+ yield
238
+ rescue Exception # rubocop:disable Lint/RescueException
239
+ FAILURE.incr
240
+ raise
241
+ ensure
242
+ PROCESSED.incr
243
+ WORK_STATE.delete(tid)
244
+ end
245
+ end
246
+ end
247
+ end
data/lib/wurk/queue.rb ADDED
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_record'
4
+
5
+ module Wurk
6
+ # Inspects / iterates one named queue against the canonical Sidekiq
7
+ # schema: LIST at `queue:<name>`, membership in the `queues` SET.
8
+ # Wire-compat is sacred — every Redis call below matches OSS exactly.
9
+ #
10
+ # Pause/unpause is a Pro feature upstream; Wurk ships it free. Membership
11
+ # of the `paused` SET drives both `paused?` and the fetcher's queue filter
12
+ # (see `Wurk::Fetcher::Reliable#queues_cmd`). In-flight jobs continue to
13
+ # completion — pausing only stops new fetches.
14
+ #
15
+ # Spec: docs/target/sidekiq-free.md §19.2, sidekiq-pro.md §6.
16
+ class Queue
17
+ include Enumerable
18
+
19
+ # Page size for LRANGE traversal. Matches upstream so dashboards
20
+ # observing Redis traffic see the same pattern.
21
+ PAGE_SIZE = 50
22
+
23
+ attr_reader :name
24
+ alias id name
25
+
26
+ # @return [Array<Queue>] one per known queue, sorted by name.
27
+ def self.all
28
+ names = Wurk.redis { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
29
+ names.sort.map { |n| new(n) }
30
+ end
31
+
32
+ def initialize(name = 'default')
33
+ @name = name.to_s
34
+ @rname = Keys.queue(@name)
35
+ end
36
+
37
+ def size
38
+ Wurk.redis { |conn| conn.call('LLEN', @rname) }
39
+ end
40
+
41
+ # Seconds since the oldest job (tail of LIST) was enqueued. 0.0 when empty.
42
+ def latency
43
+ payload = Wurk.redis { |conn| conn.call('LRANGE', @rname, -1, -1).first }
44
+ return 0.0 if payload.nil?
45
+
46
+ JobRecord.latency_from(Wurk.load_json(payload)['enqueued_at'])
47
+ rescue ::JSON::ParserError
48
+ 0.0
49
+ end
50
+
51
+ # True iff this queue's name is a member of the `paused` SET. Wurk
52
+ # implements the Pro contract for free; fetchers consult the same set.
53
+ def paused?
54
+ Wurk.redis { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
55
+ end
56
+
57
+ # Pause new fetches against this queue. Idempotent — `SADD` returns
58
+ # 0 when the name was already present. In-flight jobs are untouched.
59
+ def pause! # rubocop:disable Naming/PredicateMethod
60
+ Wurk.redis { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
61
+ true
62
+ end
63
+
64
+ # Resume fetches. Idempotent.
65
+ def unpause! # rubocop:disable Naming/PredicateMethod
66
+ Wurk.redis { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
67
+ true
68
+ end
69
+
70
+ # Paged LRANGE traversal. Yields JobRecord per payload. Continues
71
+ # paging until Redis returns < PAGE_SIZE rows.
72
+ def each
73
+ page = 0
74
+ loop do
75
+ start = page * PAGE_SIZE
76
+ stop = start + PAGE_SIZE - 1
77
+ slice = Wurk.redis { |conn| conn.call('LRANGE', @rname, start, stop) }
78
+ slice.each { |value| yield JobRecord.new(value, @name) }
79
+ break if slice.size < PAGE_SIZE
80
+
81
+ page += 1
82
+ end
83
+ end
84
+
85
+ # O(n) scan. Returns nil if no job matches.
86
+ def find_job(jid)
87
+ each { |record| return record if record.jid == jid }
88
+ nil
89
+ end
90
+
91
+ # UNLINK the list + drop the queue from the `queues` set. Pipelined
92
+ # so a partial failure leaves at most one of the two ops applied.
93
+ # Method name is Sidekiq wire-compat — `clear?` would break the alias.
94
+ def clear # rubocop:disable Naming/PredicateMethod
95
+ Wurk.redis do |conn|
96
+ conn.pipelined do |pipe|
97
+ pipe.call('UNLINK', @rname)
98
+ pipe.call('SREM', Keys::QUEUES_SET, @name)
99
+ end
100
+ end
101
+ true
102
+ end
103
+
104
+ def as_json(_options = nil)
105
+ { name: @name }
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ # In-memory job store for `:fake` test mode (aliased to Sidekiq::Queues).
5
+ # Wurk::Client#raw_push routes payloads here instead of Redis when
6
+ # `Wurk::Testing.fake?`. Thread-safe so parallel test threads / a job that
7
+ # enqueues more jobs during `drain` don't corrupt the store.
8
+ #
9
+ # Spec: docs/target/sidekiq-free.md §24.2.
10
+ module Queues
11
+ @lock = ::Mutex.new
12
+ @by_queue = ::Hash.new { |h, k| h[k] = [] }
13
+
14
+ class << self
15
+ # Live array reference (not a copy), so the Sidekiq idiom
16
+ # `Sidekiq::Queues["q"].clear` mutates the underlying store like stock.
17
+ def [](queue)
18
+ @lock.synchronize { @by_queue[queue.to_s] }
19
+ end
20
+
21
+ def push(queue, _klass, job)
22
+ @lock.synchronize { @by_queue[queue.to_s] << job }
23
+ end
24
+
25
+ # Live hash of queue => [jobs], matching Sidekiq::Queues.jobs_by_queue.
26
+ def jobs_by_queue
27
+ @lock.synchronize { @by_queue }
28
+ end
29
+
30
+ def jobs_by_class
31
+ @lock.synchronize { @by_queue.values.flatten.group_by { |j| j['class'].to_s } }
32
+ end
33
+ alias jobs_by_worker jobs_by_class
34
+
35
+ # Every enqueued payload across all queues, flattened.
36
+ def jobs
37
+ @lock.synchronize { @by_queue.values.flatten }
38
+ end
39
+
40
+ def delete_for(jid, queue, _klass)
41
+ @lock.synchronize { @by_queue[queue.to_s].reject! { |j| j['jid'] == jid } }
42
+ end
43
+
44
+ def clear_for(queue, klass)
45
+ klass = klass.to_s
46
+ @lock.synchronize { @by_queue[queue.to_s].reject! { |j| j['class'].to_s == klass } }
47
+ end
48
+
49
+ def clear_all
50
+ @lock.synchronize { @by_queue.clear }
51
+ end
52
+
53
+ # --- drain helpers (lock released before the job runs, so a job that
54
+ # enqueues more work doesn't deadlock on the same Mutex) ---------------
55
+
56
+ def clear_class(klass)
57
+ klass = klass.to_s
58
+ @lock.synchronize { @by_queue.each_value { |jobs| jobs.reject! { |j| j['class'].to_s == klass } } }
59
+ end
60
+
61
+ def shift_class(klass)
62
+ klass = klass.to_s
63
+ @lock.synchronize do
64
+ @by_queue.each_value do |jobs|
65
+ idx = jobs.index { |j| j['class'].to_s == klass }
66
+ return jobs.delete_at(idx) if idx
67
+ end
68
+ nil
69
+ end
70
+ end
71
+
72
+ def shift_any
73
+ @lock.synchronize do
74
+ @by_queue.each_value { |jobs| return jobs.shift unless jobs.empty? }
75
+ nil
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
data/lib/wurk/rails.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One-line require for Rails hosts: `require "wurk/rails"`.
4
+ # Loads core Wurk, then defines the engine and railtie that mounts the
5
+ # dashboard and forks the swarm after `after_initialize`.
6
+
7
+ require_relative "../wurk"
8
+ require_relative "engine"
9
+ require_relative "railtie"
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Wurk
6
+ # Boot the swarm after the host app has fully initialized.
7
+ # Skip when: WURK_DISABLED=1, Rails console mode, or Rails test env.
8
+ # See docs/idea/03-process-model.md for the exact ordering.
9
+ class Railtie < ::Rails::Railtie
10
+ config.after_initialize do |app|
11
+ next if ENV["WURK_DISABLED"] == "1"
12
+ next if defined?(::Rails::Console)
13
+ next if ::Rails.env.test?
14
+
15
+ swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology)
16
+ swarm.boot
17
+ # Embedded mode keeps the Rails process serving HTTP; supervise must
18
+ # run somewhere or signal_queue never drains, crashed children never
19
+ # respawn, and memory pressure checks never fire. Background thread
20
+ # leaves the host's main thread free for Rails.
21
+ Thread.new do
22
+ swarm.supervise
23
+ rescue StandardError => e
24
+ Wurk.configuration.logger.error { "wurk supervisor thread died: #{e.class}: #{e.message}" }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+ require 'connection_pool'
5
+
6
+ module Wurk
7
+ # Per-process pool over redis-client + connection_pool. Never share a socket
8
+ # across forks: the parent closes the pool before fork, each child opens a
9
+ # fresh one (see docs/idea/03-process-model.md, steps 3 and 5).
10
+ #
11
+ # Retry policy on conn-level errors: close + retry once for messages
12
+ # prefixed READONLY / NOREPLICAS / UNBLOCKED. Spec: docs/target/sidekiq-free.md §26.
13
+ class RedisPool
14
+ DEFAULT_URL = ENV.fetch('REDIS_URL', 'redis://localhost:6379/0')
15
+ DEFAULT_TIMEOUT = 1.0
16
+ DEFAULT_NAME = 'default'
17
+
18
+ # Server-side messages where Sidekiq (and therefore Wurk) closes the
19
+ # connection and retries the block exactly once. Any other RedisClient::Error
20
+ # propagates immediately.
21
+ RETRYABLE_MSG = /\A(READONLY|NOREPLICAS|UNBLOCKED)/
22
+
23
+ attr_reader :size, :url, :timeout, :name
24
+
25
+ def initialize(size:, url: DEFAULT_URL, timeout: DEFAULT_TIMEOUT, name: DEFAULT_NAME)
26
+ @size = size
27
+ @url = url
28
+ @timeout = timeout
29
+ @name = name
30
+ @pool = ConnectionPool.new(size: size, timeout: timeout) { build_client }
31
+ end
32
+
33
+ def with
34
+ @pool.with do |conn|
35
+ attempts = 0
36
+ begin
37
+ yield conn
38
+ rescue RedisClient::Error => e
39
+ raise unless RETRYABLE_MSG.match?(e.message.to_s)
40
+ raise if attempts >= 1
41
+
42
+ attempts += 1
43
+ safe_close(conn)
44
+ retry
45
+ end
46
+ end
47
+ end
48
+
49
+ def disconnect!
50
+ @pool.shutdown { |conn| safe_close(conn) }
51
+ end
52
+
53
+ def info
54
+ with { |conn| parse_info(conn.call('INFO')) }
55
+ end
56
+
57
+ private
58
+
59
+ def build_client
60
+ RedisClient.config(url: @url, timeout: @timeout).new_client
61
+ end
62
+
63
+ def safe_close(conn)
64
+ conn.close
65
+ rescue StandardError
66
+ nil
67
+ end
68
+
69
+ def parse_info(raw)
70
+ raw.to_s.each_line.with_object({}) do |line, h|
71
+ line = line.strip
72
+ next if line.empty? || line.start_with?('#')
73
+
74
+ key, val = line.split(':', 2)
75
+ h[key] = val if key && val
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'job_set'
4
+
5
+ module Wurk
6
+ # ZSET keyed by next-retry timestamp (score = epoch seconds). Wire-compat
7
+ # with Sidekiq — the `retry` key, score format, and JSON payload all match.
8
+ #
9
+ # Spec: docs/target/sidekiq-free.md §19.5.
10
+ class RetrySet < JobSet
11
+ # Optional `name` allows tests to operate on a namespaced ZSET; production
12
+ # callers always use the default `'retry'` key (wire-compat with Sidekiq).
13
+ def initialize(name = 'retry')
14
+ super
15
+ end
16
+ end
17
+ end