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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- 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
|
data/lib/wurk/queues.rb
ADDED
|
@@ -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"
|
data/lib/wurk/railtie.rb
ADDED
|
@@ -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
|