wurk 1.5.0 → 1.7.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.
- checksums.yaml +4 -4
- data/README.md +40 -2
- data/app/controllers/wurk/api/serializers.rb +51 -1
- data/app/controllers/wurk/api_controller.rb +42 -6
- data/app/controllers/wurk/dashboard_controller.rb +67 -10
- data/config/routes.rb +30 -3
- data/exe/wurk +6 -2
- data/lib/generators/wurk/install/install_generator.rb +3 -3
- data/lib/sidekiq/job_retry.rb +4 -0
- data/lib/sidekiq/manager.rb +4 -0
- data/lib/sidekiq/processor.rb +4 -0
- data/lib/wurk/api/app.rb +186 -0
- data/lib/wurk/api/auth.rb +167 -0
- data/lib/wurk/api/flows.rb +66 -0
- data/lib/wurk/api/idempotency.rb +168 -0
- data/lib/wurk/api/jobs.rb +180 -0
- data/lib/wurk/api/page.rb +98 -0
- data/lib/wurk/api/problem.rb +121 -0
- data/lib/wurk/api/queues.rb +126 -0
- data/lib/wurk/api/read_only.rb +68 -0
- data/lib/wurk/api/request.rb +46 -0
- data/lib/wurk/api/response.rb +28 -0
- data/lib/wurk/api/roll_up.rb +126 -0
- data/lib/wurk/api/router.rb +88 -0
- data/lib/wurk/api/serializers.rb +232 -0
- data/lib/wurk/api/swarm.rb +297 -0
- data/lib/wurk/api/throttle.rb +109 -0
- data/lib/wurk/api/validation.rb +306 -0
- data/lib/wurk/api.rb +96 -0
- data/lib/wurk/batch/server_middleware.rb +2 -2
- data/lib/wurk/batch.rb +12 -3
- data/lib/wurk/capsule.rb +8 -1
- data/lib/wurk/cli.rb +110 -8
- data/lib/wurk/client/buffered.rb +1 -1
- data/lib/wurk/client.rb +42 -3
- data/lib/wurk/collapse.rb +383 -0
- data/lib/wurk/command_builder.rb +49 -0
- data/lib/wurk/compat.rb +19 -0
- data/lib/wurk/component.rb +16 -3
- data/lib/wurk/configuration.rb +303 -4
- data/lib/wurk/debounce.rb +125 -0
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/capped.rb +218 -0
- data/lib/wurk/fetcher/reliable.rb +22 -66
- data/lib/wurk/fetcher/unit_of_work.rb +107 -0
- data/lib/wurk/flow/builder.rb +271 -0
- data/lib/wurk/flow/chain.rb +42 -0
- data/lib/wurk/flow/completion.rb +114 -0
- data/lib/wurk/flow/creation.rb +254 -0
- data/lib/wurk/flow/node.rb +124 -0
- data/lib/wurk/flow/status.rb +206 -0
- data/lib/wurk/flow.rb +255 -0
- data/lib/wurk/flow_set.rb +53 -0
- data/lib/wurk/health.rb +8 -7
- data/lib/wurk/heartbeat.rb +21 -3
- data/lib/wurk/job/options.rb +11 -1
- data/lib/wurk/job.rb +19 -0
- data/lib/wurk/job_logger.rb +6 -2
- data/lib/wurk/job_retry.rb +6 -1
- data/lib/wurk/job_util.rb +110 -9
- data/lib/wurk/keys.rb +125 -0
- data/lib/wurk/launcher.rb +1 -1
- data/lib/wurk/leader.rb +1 -1
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/points.rb +1 -1
- data/lib/wurk/lua/debounce.lua +75 -0
- data/lib/wurk/lua/fetch_slot.lua +83 -0
- data/lib/wurk/lua/flow_abandon.lua +65 -0
- data/lib/wurk/lua/flow_advance.lua +177 -0
- data/lib/wurk/lua/flow_create.lua +141 -0
- data/lib/wurk/lua/flow_fail.lua +52 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
- data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
- data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
- data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
- data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
- data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
- data/lib/wurk/lua/limiter_points_refund.lua +18 -0
- data/lib/wurk/lua/limiter_register.lua +27 -0
- data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
- data/lib/wurk/lua/limiter_window_status.lua +22 -0
- data/lib/wurk/lua/loader.rb +23 -0
- data/lib/wurk/lua/queue_slot.lua +83 -0
- data/lib/wurk/lua/refresh_slots.lua +38 -0
- data/lib/wurk/lua/status_write.lua +29 -0
- data/lib/wurk/lua/throttle_slot.lua +71 -0
- data/lib/wurk/lua.rb +3 -3
- data/lib/wurk/manager.rb +12 -0
- data/lib/wurk/metrics/history.rb +34 -2
- data/lib/wurk/metrics/query.rb +1 -1
- data/lib/wurk/metrics/statsd.rb +14 -0
- data/lib/wurk/middleware/expiry.rb +71 -10
- data/lib/wurk/middleware/status.rb +274 -0
- data/lib/wurk/middleware/timeout.rb +137 -0
- data/lib/wurk/pid_cache.rb +43 -0
- data/lib/wurk/processor.rb +66 -22
- data/lib/wurk/profiler.rb +5 -3
- data/lib/wurk/queue_slot.rb +285 -0
- data/lib/wurk/rails.rb +3 -3
- data/lib/wurk/redis_pool.rb +5 -2
- data/lib/wurk/status/progress.rb +103 -0
- data/lib/wurk/status/record.rb +81 -0
- data/lib/wurk/status.rb +155 -0
- data/lib/wurk/telemetry/client_middleware.rb +59 -0
- data/lib/wurk/telemetry/server_middleware.rb +152 -0
- data/lib/wurk/telemetry.rb +137 -0
- data/lib/wurk/throttle.rb +142 -0
- data/lib/wurk/unique.rb +10 -2
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +188 -0
- data/lib/wurk/web/config.rb +77 -10
- data/lib/wurk/web/extension.rb +12 -4
- data/lib/wurk/web/locale_negotiator.rb +67 -0
- data/lib/wurk/web.rb +1 -0
- data/lib/wurk/worker.rb +39 -4
- data/lib/wurk.rb +42 -1
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
- data/vendor/assets/dashboard/index.html +42 -11
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +102 -33
- data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
- data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
- data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
- data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../keys'
|
|
4
|
+
require_relative '../lua'
|
|
5
|
+
require_relative '../queue_slot'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
class Fetcher
|
|
9
|
+
# Everything the reliable fetcher does differently when one of its queues
|
|
10
|
+
# carries a global concurrency cap (`config.global_concurrency`).
|
|
11
|
+
#
|
|
12
|
+
# Prepended onto {Fetcher::Reliable}, so the separation is structural rather
|
|
13
|
+
# than a claim in a comment: an install that caps nothing resolves one
|
|
14
|
+
# boolean at boot and every fetch pass hands straight back to `super` — the
|
|
15
|
+
# unchanged loop — without touching another line in this file. The cost of
|
|
16
|
+
# the whole feature to everyone else is that one test and the frame it sits
|
|
17
|
+
# in, which is why `rake bench` unconfigured and `bench/command_count.rb` are
|
|
18
|
+
# the gate on this slice rather than an argument about it.
|
|
19
|
+
#
|
|
20
|
+
# The cap itself — the ledger of who is running what — is {Wurk::QueueSlot}
|
|
21
|
+
# and `lib/wurk/lua/queue_slot.lua`. This is only about *fetching* under one:
|
|
22
|
+
# asking the question in the same round trip that claims the job, and
|
|
23
|
+
# deciding what a worker does with a queue that says no.
|
|
24
|
+
module Capped
|
|
25
|
+
# How long a queue that answered "the cluster is at capacity" is skipped
|
|
26
|
+
# locally before this fetcher asks again.
|
|
27
|
+
#
|
|
28
|
+
# Without it a capped queue sitting at capacity costs a round trip per
|
|
29
|
+
# processor thread per pass, forever — the fetcher spins on a `no` that
|
|
30
|
+
# cannot change faster than a job finishes somewhere in the cluster. With
|
|
31
|
+
# it the cost is bounded at four questions a second per capped queue for
|
|
32
|
+
# the whole capsule, and the price is that capacity freed on another host
|
|
33
|
+
# is picked up up to a quarter second late. Much shorter and the round
|
|
34
|
+
# trips come back; much longer and a cap starts to behave like a throttle.
|
|
35
|
+
# Not a config knob — a cap is a ceiling on how much runs, never a promise
|
|
36
|
+
# about latency.
|
|
37
|
+
CAPPED_BACKOFF = 0.25
|
|
38
|
+
|
|
39
|
+
# The two of fetch_slot.lua's three answers a caller acts on. Its third,
|
|
40
|
+
# `1`, means the queue was empty — which costs and means exactly what an
|
|
41
|
+
# empty uncapped queue does, so nothing compares against it.
|
|
42
|
+
AT_CAPACITY = 0
|
|
43
|
+
CLAIMED = 2
|
|
44
|
+
|
|
45
|
+
# One capped queue's ceiling, its slot key, and the local clock that keeps
|
|
46
|
+
# a queue at capacity from costing a round trip per pass. Built once per
|
|
47
|
+
# capped queue at boot: the fetch path never reads a config Hash, and a cap
|
|
48
|
+
# cannot change under a running fetcher anyway (`global_concurrency=`
|
|
49
|
+
# refuses to run once the configuration is frozen).
|
|
50
|
+
#
|
|
51
|
+
# One per queue per fetcher, so a capsule's processor threads share it: a
|
|
52
|
+
# refusal is a statement about the cluster, not about the thread that heard
|
|
53
|
+
# it, and suppressing the retry for all of them is the whole saving. They
|
|
54
|
+
# race on `blocked_until` — the loser writes an equally fresh deadline, and
|
|
55
|
+
# the worst a torn read can cost is one extra round trip. A gate inherited
|
|
56
|
+
# across a fork can carry a stale deadline for the same reason it is
|
|
57
|
+
# harmless: at worst the child's first pass on that queue waits out a
|
|
58
|
+
# backoff it did not earn.
|
|
59
|
+
Gate = Struct.new(:capacity, :slot_key, :blocked_until) do
|
|
60
|
+
def blocked?(now) = now < blocked_until
|
|
61
|
+
|
|
62
|
+
def block!(now)
|
|
63
|
+
self.blocked_until = now + CAPPED_BACKOFF
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolved here and never again. The fetch path must not read a config
|
|
68
|
+
# Hash per pass to discover that this install caps nothing, so the answer
|
|
69
|
+
# is a boolean and a Hash of prebuilt gates, both settled while the
|
|
70
|
+
# capsule is materializing its fetcher (`Capsule#prepare!`).
|
|
71
|
+
def initialize(capsule)
|
|
72
|
+
super
|
|
73
|
+
@gates = capsule.config.global_concurrency.to_h do |name, capacity|
|
|
74
|
+
[name, Gate.new(capacity, Keys.queue_slot(name).freeze, 0.0)]
|
|
75
|
+
end.freeze
|
|
76
|
+
@capped = !@gates.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# #walk for a capsule that caps at least one of its queues; every other
|
|
82
|
+
# install falls straight through to the loop this decorates.
|
|
83
|
+
#
|
|
84
|
+
# A capped queue at capacity is skipped *locally* — no round trip, and the
|
|
85
|
+
# pass carries on to the queues behind it. That is the fairness
|
|
86
|
+
# requirement: one full queue must never stop this worker serving the
|
|
87
|
+
# others, and must not be able to spin the fetcher either. Skipping is also
|
|
88
|
+
# what keeps the held ACK, which is only ever handed to a queue we are
|
|
89
|
+
# about to talk to.
|
|
90
|
+
def walk(queues)
|
|
91
|
+
return super unless @capped
|
|
92
|
+
|
|
93
|
+
ack = take_pending_ack
|
|
94
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
95
|
+
queues.each do |public_q|
|
|
96
|
+
# `[private list, unprefixed name]`, the tuple #queue_keys caches; the
|
|
97
|
+
# name is what a cap is declared against.
|
|
98
|
+
keys = queue_keys(public_q)
|
|
99
|
+
gate = @gates[keys[1]]
|
|
100
|
+
next if gate&.blocked?(now)
|
|
101
|
+
|
|
102
|
+
uow = gate ? claim(public_q, keys, gate, ack, now) : lmove(public_q, ack)
|
|
103
|
+
ack = nil
|
|
104
|
+
return uow if uow
|
|
105
|
+
end
|
|
106
|
+
wait_capped(queues, ack)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The gated twin of #lmove: fetch_slot.lua answers the cap and performs the
|
|
110
|
+
# LMOVE in one call, so a capped queue costs the same single round trip an
|
|
111
|
+
# uncapped one does and the held ACK still rides in front of it. Bracketing
|
|
112
|
+
# the existing fetch with a separate acquire and release instead measured
|
|
113
|
+
# 2.2x-2.6x the uncapped cost (the slice's measurement doc); that shape is
|
|
114
|
+
# what this method exists to avoid.
|
|
115
|
+
#
|
|
116
|
+
# Claims the same apply-safety #lmove does, for the same reason: a claim
|
|
117
|
+
# whose reply was lost leaves the job in *this* process's private list
|
|
118
|
+
# un-ACKed, which the next boot's Reaper already reclaims, and the script's
|
|
119
|
+
# own replay arm refreshes this token's hold rather than counting it twice
|
|
120
|
+
# — the token is built here, before the retried block, so a replay carries
|
|
121
|
+
# the same one.
|
|
122
|
+
#
|
|
123
|
+
# A token per claim rather than per thread, because the matching release
|
|
124
|
+
# is deferred: an ACK handed back by a failed flush must not be able to
|
|
125
|
+
# take the hold of whatever this thread is running by then. See
|
|
126
|
+
# QueueSlot.claim_token.
|
|
127
|
+
def claim(public_q, keys, gate, ack, now)
|
|
128
|
+
priv, name = keys
|
|
129
|
+
token = QueueSlot.claim_token
|
|
130
|
+
script_keys = [gate.slot_key, public_q, priv]
|
|
131
|
+
status, payload = config.redis(idempotent: true) do |conn|
|
|
132
|
+
fetch_slot(conn, script_keys, gate.capacity, token, ack)
|
|
133
|
+
end
|
|
134
|
+
return hold(unit_of_work(public_q, priv, name, payload), gate.slot_key, token) if status == CLAIMED
|
|
135
|
+
|
|
136
|
+
# The refusal is the only answer worth remembering: it cannot change
|
|
137
|
+
# faster than a job finishes somewhere in the cluster, so re-asking on
|
|
138
|
+
# every pass is how a full queue turns into a spin. An empty queue is
|
|
139
|
+
# remembered by nobody, exactly as on the uncapped path.
|
|
140
|
+
gate.block!(now) if status == AT_CAPACITY
|
|
141
|
+
nil
|
|
142
|
+
rescue StandardError
|
|
143
|
+
# The window #lmove guards: the ACK left its slot but never reached
|
|
144
|
+
# Redis, so hand it back or a finished job runs a second time.
|
|
145
|
+
defer_ack(ack) if ack
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The claim's other half — the bookkeeping the script's ZADD implies.
|
|
150
|
+
#
|
|
151
|
+
# The unit carries the slot it was admitted under so its release can name
|
|
152
|
+
# that slot without re-deriving anything on the way out
|
|
153
|
+
# (UnitOfWork#write_ack), and the process ledger carries it so the
|
|
154
|
+
# heartbeat keeps the hold alive under a job that outlives the TTL.
|
|
155
|
+
def hold(uow, slot_key, token)
|
|
156
|
+
QueueSlot::HELD.hold(token, slot_key)
|
|
157
|
+
uow.slot_key = slot_key
|
|
158
|
+
uow.slot_token = token
|
|
159
|
+
uow
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# One EVALSHA, or that same EVALSHA behind the held ACK in one pipeline —
|
|
163
|
+
# the shape #lmove already uses, so the gate costs no round trip of its
|
|
164
|
+
# own either way.
|
|
165
|
+
def fetch_slot(conn, keys, capacity, token, ack)
|
|
166
|
+
argv = [capacity, token, QueueSlot::TTL_SECONDS]
|
|
167
|
+
return Wurk::Lua::Loader.eval_cached(conn, :fetch_slot, keys: keys, argv: argv) unless ack
|
|
168
|
+
|
|
169
|
+
Wurk::Lua::Loader.pipelined_eval(conn) do |pipe, eval_method|
|
|
170
|
+
ack.write_ack(pipe)
|
|
171
|
+
Wurk::Lua::Loader.public_send(eval_method, pipe, :fetch_slot, keys: keys, argv: argv)
|
|
172
|
+
end.last
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# #walk's blocking fall-through, for a capsule that caps a queue. BLMOVE
|
|
176
|
+
# has no gated form — a script cannot block — so a capped queue is never
|
|
177
|
+
# the one we park on: the block goes to the first *uncapped* queue in fetch
|
|
178
|
+
# order, and when every fetchable queue is capped there is nothing to park
|
|
179
|
+
# on at all, so we idle the wall clock a timed-out block would have spent.
|
|
180
|
+
# Sidekiq's BasicFetch idles on the same terms.
|
|
181
|
+
#
|
|
182
|
+
# While a capped queue is sitting at capacity the block is shortened to the
|
|
183
|
+
# gate's own re-check, so capacity freed on another host is picked up in a
|
|
184
|
+
# backoff instead of a poll interval. An idle capsule keeps today's
|
|
185
|
+
# cadence: nothing is blocked, so nothing is shortened.
|
|
186
|
+
def wait_capped(queues, ack)
|
|
187
|
+
# A pass that skipped every capped queue never handed its ACK to
|
|
188
|
+
# anything. A blocking call cannot carry one, so give it back rather than
|
|
189
|
+
# drop it — an LREM that is never sent leaves a finished job in the
|
|
190
|
+
# private list for the next boot's reaper to run again.
|
|
191
|
+
defer_ack(ack) if ack
|
|
192
|
+
wait = capped_wait(queues)
|
|
193
|
+
open_q = queues.find { |public_q| gate_for(public_q).nil? }
|
|
194
|
+
return blmove(open_q, wait) if open_q
|
|
195
|
+
|
|
196
|
+
# Which also hands back the slot this thread's last job ran under: a
|
|
197
|
+
# held ACK carries that ZREM, and a thread idling out a backoff with no
|
|
198
|
+
# job in hand must not keep capacity somebody else could be using. The
|
|
199
|
+
# two cases are the same one — an ACK survives the loop above only when
|
|
200
|
+
# every queue was gate-blocked, and a gate-blocked queue is a capped
|
|
201
|
+
# queue, so there was never an uncapped one to park on either.
|
|
202
|
+
flush_pending_acks
|
|
203
|
+
sleep wait
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def capped_wait(queues)
|
|
208
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
209
|
+
blocked = queues.any? { |public_q| gate_for(public_q)&.blocked?(now) }
|
|
210
|
+
blocked ? [poll_interval, CAPPED_BACKOFF].min : poll_interval
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def gate_for(public_q)
|
|
214
|
+
@gates[queue_keys(public_q)[1]]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require 'socket'
|
|
4
4
|
require_relative '../component'
|
|
5
|
+
require_relative '../pid_cache'
|
|
5
6
|
require_relative '../keys'
|
|
6
7
|
require_relative '../lua'
|
|
7
8
|
require_relative '../fetcher'
|
|
8
|
-
require_relative '
|
|
9
|
+
require_relative 'capped'
|
|
9
10
|
|
|
10
11
|
module Wurk
|
|
11
12
|
class Fetcher
|
|
@@ -33,6 +34,12 @@ module Wurk
|
|
|
33
34
|
# docs/target/sidekiq-free.md §15 (TIMEOUT=2).
|
|
34
35
|
class Reliable < Fetcher
|
|
35
36
|
include Component
|
|
37
|
+
# Global per-queue concurrency, as a decorator over #walk. Prepended
|
|
38
|
+
# rather than mixed in so there stays exactly one copy of the uncapped
|
|
39
|
+
# fetch loop — Capped#walk hands straight back to `super` for an install
|
|
40
|
+
# that caps nothing, which is a stronger guarantee that the unconfigured
|
|
41
|
+
# path is unchanged than a second loop kept in step by hand.
|
|
42
|
+
prepend Capped
|
|
36
43
|
|
|
37
44
|
# Default BLMOVE block timeout; overridable via config.fetch_poll_interval.
|
|
38
45
|
TIMEOUT = 2
|
|
@@ -53,55 +60,6 @@ module Wurk
|
|
|
53
60
|
# immediately after #quiet, so this pause adds no drain latency.
|
|
54
61
|
QUIET_PAUSE = 0.05
|
|
55
62
|
|
|
56
|
-
# Carries the public queue key, the raw (still-JSON) job payload, the
|
|
57
|
-
# capsule we use to reach Redis, and the fetcher that holds this unit's
|
|
58
|
-
# ACK until it can ride a pipeline. ACK removes from the private list;
|
|
59
|
-
# requeue pushes back to the public queue head so the job is next pulled.
|
|
60
|
-
# LREM count=1 is idempotent for our payloads since each job's JSON
|
|
61
|
-
# contains a unique `jid`.
|
|
62
|
-
#
|
|
63
|
-
# `queue_name` (the queue without its `queue:` prefix) and
|
|
64
|
-
# `private_queue` come off the fetcher's per-queue cache at build time:
|
|
65
|
-
# both are pure functions of the queue this unit came from, so deriving
|
|
66
|
-
# them here would be a per-job cost for a per-queue fact.
|
|
67
|
-
#
|
|
68
|
-
# `jid` is filled in by the Processor once it has parsed the payload —
|
|
69
|
-
# the fetcher never parses. It is only used to retire the job's
|
|
70
|
-
# poison-pill recovery counter, so an ACK without one is still a
|
|
71
|
-
# complete ACK.
|
|
72
|
-
UnitOfWork = Struct.new(:queue, :queue_name, :private_queue, :job, :config, :jid, :fetcher,
|
|
73
|
-
keyword_init: true) do
|
|
74
|
-
# Deferred, never skipped: the LREM goes back to the fetcher, which
|
|
75
|
-
# pipelines it in front of the next fetch's LMOVE instead of spending a
|
|
76
|
-
# round trip of its own. Ordering against the job is unchanged — the
|
|
77
|
-
# LREM still happens only after success or retry handling (Pro §3.2) —
|
|
78
|
-
# so all that moves is the wall clock. Every path that stops fetching
|
|
79
|
-
# flushes first; see #flush_pending_acks.
|
|
80
|
-
def acknowledge
|
|
81
|
-
fetcher.defer_ack(self)
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Queue this unit's ACK into an already-open pipeline. The counter DEL
|
|
85
|
-
# rides the same round trip rather than taking one of its own: a
|
|
86
|
-
# per-job call would be a fetch+execute regression for the sake of a
|
|
87
|
-
# key that exists for roughly no jobs. See Middleware::PoisonPill.
|
|
88
|
-
#
|
|
89
|
-
# Sending it only for the jobs that own one is not available to us:
|
|
90
|
-
# whether a job was reclaimed lives in the counter, and a reclaimed
|
|
91
|
-
# payload is byte-identical to a first-attempt one (the job JSON is
|
|
92
|
-
# wire-frozen, so the reaper cannot flag it). Reading the counter to
|
|
93
|
-
# decide would spend the very round trip the DEL is riding for free.
|
|
94
|
-
def write_ack(pipe)
|
|
95
|
-
pipe.call('LREM', private_queue, 1, job)
|
|
96
|
-
job_jid = jid.to_s
|
|
97
|
-
Middleware::PoisonPill.clear_in(pipe, job_jid) unless job_jid.empty?
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
def requeue
|
|
101
|
-
config.redis { |conn| conn.call('RPUSH', queue, job) }
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
|
|
105
63
|
# Class-level: the name is a pure function of the public queue and this
|
|
106
64
|
# process's identity, and both the fetcher and the Reaper need it (the
|
|
107
65
|
# fetcher's units carry the string #queue_keys built for them, so nothing
|
|
@@ -336,14 +294,10 @@ module Wurk
|
|
|
336
294
|
end
|
|
337
295
|
end
|
|
338
296
|
|
|
339
|
-
# One pipelined RELIABLE_REQUEUE EVALSHA per UoW
|
|
340
|
-
#
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
# applies none — recover by reloading once and replaying the whole
|
|
344
|
-
# pipeline via source-embedded EVAL.
|
|
345
|
-
def requeue_pipelined(conn, in_progress, eval_method: :eval_cached)
|
|
346
|
-
conn.pipelined do |pipe|
|
|
297
|
+
# One pipelined RELIABLE_REQUEUE EVALSHA per UoW; the flushed-script-cache
|
|
298
|
+
# replay is Lua::Loader.pipelined_eval's.
|
|
299
|
+
def requeue_pipelined(conn, in_progress)
|
|
300
|
+
Wurk::Lua::Loader.pipelined_eval(conn) do |pipe, eval_method|
|
|
347
301
|
in_progress.each do |uow|
|
|
348
302
|
Wurk::Lua::Loader.public_send(
|
|
349
303
|
eval_method, pipe, :reliable_requeue,
|
|
@@ -352,11 +306,6 @@ module Wurk
|
|
|
352
306
|
)
|
|
353
307
|
end
|
|
354
308
|
end
|
|
355
|
-
rescue RedisClient::CommandError => e
|
|
356
|
-
raise unless e.message.to_s.start_with?('NOSCRIPT')
|
|
357
|
-
|
|
358
|
-
Wurk::Lua::Loader.script_load_all(conn)
|
|
359
|
-
requeue_pipelined(conn, in_progress, eval_method: :eval_with_source)
|
|
360
309
|
end
|
|
361
310
|
|
|
362
311
|
# SMEMBERS of the `paused` SET, at most once per PAUSED_TTL per fetcher
|
|
@@ -418,7 +367,7 @@ module Wurk
|
|
|
418
367
|
# fetcher and read this without a lock. A racing writer's entry can be
|
|
419
368
|
# lost, and the next fetch rebuilds it.
|
|
420
369
|
def queue_keys(public_q)
|
|
421
|
-
pid =
|
|
370
|
+
pid = PidCache.pid
|
|
422
371
|
if @queue_keys_pid != pid
|
|
423
372
|
@queue_keys = {}
|
|
424
373
|
@queue_keys_pid = pid
|
|
@@ -464,9 +413,12 @@ module Wurk
|
|
|
464
413
|
raise
|
|
465
414
|
end
|
|
466
415
|
|
|
467
|
-
|
|
416
|
+
# `timeout` overrides the block window for a caller that has a reason to
|
|
417
|
+
# come back sooner than a poll interval; Capped#wait_capped shortens it
|
|
418
|
+
# while a capped queue is sitting at capacity. nil keeps today's default.
|
|
419
|
+
def blmove(public_q, timeout = nil)
|
|
468
420
|
priv, name = queue_keys(public_q)
|
|
469
|
-
timeout
|
|
421
|
+
timeout ||= poll_interval
|
|
470
422
|
# Dedicated fetch pool, not the main one: a parked BLMOVE holds its slot
|
|
471
423
|
# for the whole block window, so routing it here keeps idle fetchers from
|
|
472
424
|
# starving the main pool's background loops (#101). redis-client's
|
|
@@ -494,3 +446,7 @@ module Wurk
|
|
|
494
446
|
end
|
|
495
447
|
end
|
|
496
448
|
end
|
|
449
|
+
|
|
450
|
+
# At the bottom, not the top: the file reopens the class defined above, so a
|
|
451
|
+
# require before that definition would create it with the wrong superclass.
|
|
452
|
+
require_relative 'unit_of_work'
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../queue_slot'
|
|
4
|
+
require_relative '../middleware/poison_pill'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
class Fetcher
|
|
8
|
+
# `Fetcher::Reliable` reopened, not a second definition: the fetch loop owns
|
|
9
|
+
# which job to claim, and this owns what a claimed job still has to write on
|
|
10
|
+
# its way out — the ACK, the poison-pill counter and the global-concurrency
|
|
11
|
+
# slot. Required from the bottom of `reliable.rb`, once that class exists,
|
|
12
|
+
# the way `lua.rb` requires its loader.
|
|
13
|
+
class Reliable < Fetcher
|
|
14
|
+
# Carries the public queue key, the raw (still-JSON) job payload, the
|
|
15
|
+
# capsule we use to reach Redis, and the fetcher that holds this unit's
|
|
16
|
+
# ACK until it can ride a pipeline. ACK removes from the private list;
|
|
17
|
+
# requeue pushes back to the public queue head so the job is next pulled.
|
|
18
|
+
# LREM count=1 is idempotent for our payloads since each job's JSON
|
|
19
|
+
# contains a unique `jid`.
|
|
20
|
+
#
|
|
21
|
+
# `queue_name` (the queue without its `queue:` prefix) and
|
|
22
|
+
# `private_queue` come off the fetcher's per-queue cache at build time:
|
|
23
|
+
# both are pure functions of the queue this unit came from, so deriving
|
|
24
|
+
# them here would be a per-job cost for a per-queue fact.
|
|
25
|
+
#
|
|
26
|
+
# `jid` is filled in by the Processor once it has parsed the payload —
|
|
27
|
+
# the fetcher never parses. It is only used to retire the job's
|
|
28
|
+
# poison-pill recovery counter, so an ACK without one is still a
|
|
29
|
+
# complete ACK.
|
|
30
|
+
#
|
|
31
|
+
# `slot_key`/`slot_token` name the global-concurrency slot this unit was
|
|
32
|
+
# admitted under, and are set only by Fetcher::Capped — nil is the whole
|
|
33
|
+
# uncapped path, which is why the release below is a field test rather
|
|
34
|
+
# than a question asked of Redis.
|
|
35
|
+
# LREM's count, pre-stringified. Every argument of every command on the
|
|
36
|
+
# ACK pipeline is then a String, which is what keeps it on
|
|
37
|
+
# {Wurk::CommandBuilder}'s allocation-free fast path; an Integer here
|
|
38
|
+
# would send the whole command back through redis-client's normalizer.
|
|
39
|
+
LREM_COUNT = '1'
|
|
40
|
+
private_constant :LREM_COUNT
|
|
41
|
+
|
|
42
|
+
UnitOfWork = Struct.new(:queue, :queue_name, :private_queue, :job, :config, :jid, :fetcher,
|
|
43
|
+
:slot_key, :slot_token, keyword_init: true) do
|
|
44
|
+
# Deferred, never skipped: the LREM goes back to the fetcher, which
|
|
45
|
+
# pipelines it in front of the next fetch's LMOVE instead of spending a
|
|
46
|
+
# round trip of its own. Ordering against the job is unchanged — the
|
|
47
|
+
# LREM still happens only after success or retry handling (Pro §3.2) —
|
|
48
|
+
# so all that moves is the wall clock. Every path that stops fetching
|
|
49
|
+
# flushes first; see #flush_pending_acks.
|
|
50
|
+
def acknowledge
|
|
51
|
+
fetcher.defer_ack(self)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Queue this unit's ACK into an already-open pipeline. The counter DEL
|
|
55
|
+
# rides the same round trip rather than taking one of its own: a
|
|
56
|
+
# per-job call would be a fetch+execute regression for the sake of a
|
|
57
|
+
# key that exists for roughly no jobs. See Middleware::PoisonPill.
|
|
58
|
+
#
|
|
59
|
+
# Sending it only for the jobs that own one is not available to us:
|
|
60
|
+
# whether a job was reclaimed lives in the counter, and a reclaimed
|
|
61
|
+
# payload is byte-identical to a first-attempt one (the job JSON is
|
|
62
|
+
# wire-frozen, so the reaper cannot flag it). Reading the counter to
|
|
63
|
+
# decide would spend the very round trip the DEL is riding for free.
|
|
64
|
+
def write_ack(pipe)
|
|
65
|
+
pipe.call('LREM', private_queue, LREM_COUNT, job)
|
|
66
|
+
job_jid = jid.to_s
|
|
67
|
+
Middleware::PoisonPill.clear_in(pipe, job_jid) unless job_jid.empty?
|
|
68
|
+
release_slot_in(pipe) if slot_key
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Give a global-concurrency slot back inside a pipeline the caller
|
|
72
|
+
# already has open — the ACK's, so a capped job costs no round trip more
|
|
73
|
+
# on the way out than an uncapped one does on the way in. The ZREM lands
|
|
74
|
+
# ahead of the fetch it is pipelined with, so a thread re-competing for
|
|
75
|
+
# the queue it just ran releases before it asks, never after.
|
|
76
|
+
#
|
|
77
|
+
# Idempotent by construction (QueueSlot#release): it names one member,
|
|
78
|
+
# so a replayed ACK frees nothing that has since been handed on. The
|
|
79
|
+
# member names this *claim* rather than this thread, which is what makes
|
|
80
|
+
# that true of a stale ACK too — one handed back by a failed flush
|
|
81
|
+
# (Fetcher::Reliable#restore_pending_acks) after the thread has already
|
|
82
|
+
# claimed its next slot on the same queue. See QueueSlot.claim_token.
|
|
83
|
+
def release_slot_in(pipe)
|
|
84
|
+
QueueSlot::HELD.drop(slot_token, slot_key)
|
|
85
|
+
pipe.call('ZREM', slot_key, slot_token)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# The same release for the one path that deliberately does not ACK:
|
|
89
|
+
# Wurk::Shutdown, where the payload stays in the private list to be
|
|
90
|
+
# requeued and re-run somewhere else. Whoever re-runs it needs the
|
|
91
|
+
# capacity, so this cannot wait out the TTL — and there is no ACK left
|
|
92
|
+
# to ride, hence the round trip. Bounded to the jobs a hard shutdown
|
|
93
|
+
# kills mid-flight.
|
|
94
|
+
def release_slot
|
|
95
|
+
return unless slot_key
|
|
96
|
+
|
|
97
|
+
QueueSlot::HELD.drop(slot_token, slot_key)
|
|
98
|
+
config.redis(idempotent: true) { |conn| conn.call('ZREM', slot_key, slot_token) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def requeue
|
|
102
|
+
config.redis { |conn| conn.call('RPUSH', queue, job) }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|