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.
Files changed (173) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/app/controllers/wurk/api/serializers.rb +51 -1
  4. data/app/controllers/wurk/api_controller.rb +42 -6
  5. data/app/controllers/wurk/dashboard_controller.rb +67 -10
  6. data/config/routes.rb +30 -3
  7. data/exe/wurk +6 -2
  8. data/lib/generators/wurk/install/install_generator.rb +3 -3
  9. data/lib/sidekiq/job_retry.rb +4 -0
  10. data/lib/sidekiq/manager.rb +4 -0
  11. data/lib/sidekiq/processor.rb +4 -0
  12. data/lib/wurk/api/app.rb +186 -0
  13. data/lib/wurk/api/auth.rb +167 -0
  14. data/lib/wurk/api/flows.rb +66 -0
  15. data/lib/wurk/api/idempotency.rb +168 -0
  16. data/lib/wurk/api/jobs.rb +180 -0
  17. data/lib/wurk/api/page.rb +98 -0
  18. data/lib/wurk/api/problem.rb +121 -0
  19. data/lib/wurk/api/queues.rb +126 -0
  20. data/lib/wurk/api/read_only.rb +68 -0
  21. data/lib/wurk/api/request.rb +46 -0
  22. data/lib/wurk/api/response.rb +28 -0
  23. data/lib/wurk/api/roll_up.rb +126 -0
  24. data/lib/wurk/api/router.rb +88 -0
  25. data/lib/wurk/api/serializers.rb +232 -0
  26. data/lib/wurk/api/swarm.rb +297 -0
  27. data/lib/wurk/api/throttle.rb +109 -0
  28. data/lib/wurk/api/validation.rb +306 -0
  29. data/lib/wurk/api.rb +96 -0
  30. data/lib/wurk/batch/server_middleware.rb +2 -2
  31. data/lib/wurk/batch.rb +12 -3
  32. data/lib/wurk/capsule.rb +8 -1
  33. data/lib/wurk/cli.rb +110 -8
  34. data/lib/wurk/client/buffered.rb +1 -1
  35. data/lib/wurk/client.rb +42 -3
  36. data/lib/wurk/collapse.rb +383 -0
  37. data/lib/wurk/command_builder.rb +49 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +16 -3
  40. data/lib/wurk/configuration.rb +303 -4
  41. data/lib/wurk/debounce.rb +125 -0
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/capped.rb +218 -0
  44. data/lib/wurk/fetcher/reliable.rb +22 -66
  45. data/lib/wurk/fetcher/unit_of_work.rb +107 -0
  46. data/lib/wurk/flow/builder.rb +271 -0
  47. data/lib/wurk/flow/chain.rb +42 -0
  48. data/lib/wurk/flow/completion.rb +114 -0
  49. data/lib/wurk/flow/creation.rb +254 -0
  50. data/lib/wurk/flow/node.rb +124 -0
  51. data/lib/wurk/flow/status.rb +206 -0
  52. data/lib/wurk/flow.rb +255 -0
  53. data/lib/wurk/flow_set.rb +53 -0
  54. data/lib/wurk/health.rb +8 -7
  55. data/lib/wurk/heartbeat.rb +21 -3
  56. data/lib/wurk/job/options.rb +11 -1
  57. data/lib/wurk/job.rb +19 -0
  58. data/lib/wurk/job_logger.rb +6 -2
  59. data/lib/wurk/job_retry.rb +6 -1
  60. data/lib/wurk/job_util.rb +110 -9
  61. data/lib/wurk/keys.rb +125 -0
  62. data/lib/wurk/launcher.rb +1 -1
  63. data/lib/wurk/leader.rb +1 -1
  64. data/lib/wurk/limiter/bucket.rb +1 -1
  65. data/lib/wurk/limiter/points.rb +1 -1
  66. data/lib/wurk/lua/debounce.lua +75 -0
  67. data/lib/wurk/lua/fetch_slot.lua +83 -0
  68. data/lib/wurk/lua/flow_abandon.lua +65 -0
  69. data/lib/wurk/lua/flow_advance.lua +177 -0
  70. data/lib/wurk/lua/flow_create.lua +141 -0
  71. data/lib/wurk/lua/flow_fail.lua +52 -0
  72. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  73. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  74. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  75. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  76. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  77. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  78. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  79. data/lib/wurk/lua/limiter_register.lua +27 -0
  80. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  81. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  82. data/lib/wurk/lua/loader.rb +23 -0
  83. data/lib/wurk/lua/queue_slot.lua +83 -0
  84. data/lib/wurk/lua/refresh_slots.lua +38 -0
  85. data/lib/wurk/lua/status_write.lua +29 -0
  86. data/lib/wurk/lua/throttle_slot.lua +71 -0
  87. data/lib/wurk/lua.rb +3 -3
  88. data/lib/wurk/manager.rb +12 -0
  89. data/lib/wurk/metrics/history.rb +34 -2
  90. data/lib/wurk/metrics/query.rb +1 -1
  91. data/lib/wurk/metrics/statsd.rb +14 -0
  92. data/lib/wurk/middleware/expiry.rb +71 -10
  93. data/lib/wurk/middleware/status.rb +274 -0
  94. data/lib/wurk/middleware/timeout.rb +137 -0
  95. data/lib/wurk/pid_cache.rb +43 -0
  96. data/lib/wurk/processor.rb +66 -22
  97. data/lib/wurk/profiler.rb +5 -3
  98. data/lib/wurk/queue_slot.rb +285 -0
  99. data/lib/wurk/rails.rb +3 -3
  100. data/lib/wurk/redis_pool.rb +5 -2
  101. data/lib/wurk/status/progress.rb +103 -0
  102. data/lib/wurk/status/record.rb +81 -0
  103. data/lib/wurk/status.rb +155 -0
  104. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  105. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  106. data/lib/wurk/telemetry.rb +137 -0
  107. data/lib/wurk/throttle.rb +142 -0
  108. data/lib/wurk/unique.rb +10 -2
  109. data/lib/wurk/version.rb +1 -1
  110. data/lib/wurk/watchdog.rb +188 -0
  111. data/lib/wurk/web/config.rb +77 -10
  112. data/lib/wurk/web/extension.rb +12 -4
  113. data/lib/wurk/web/locale_negotiator.rb +67 -0
  114. data/lib/wurk/web.rb +1 -0
  115. data/lib/wurk/worker.rb +39 -4
  116. data/lib/wurk.rb +42 -1
  117. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  118. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  119. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  120. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  121. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  122. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  123. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  124. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  125. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  126. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  127. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  128. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  129. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  130. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  131. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  132. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  133. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  134. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  135. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  136. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  137. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  138. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  139. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  140. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  141. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  142. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  143. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  144. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  145. data/vendor/assets/dashboard/index.html +42 -11
  146. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  147. metadata +102 -33
  148. data/vendor/assets/dashboard/assets/ArgsValue-CcR2ya6e.js +0 -1
  149. data/vendor/assets/dashboard/assets/BatchDetail-CUXJUQ3Q.js +0 -1
  150. data/vendor/assets/dashboard/assets/Batches-Cxan6Ngw.js +0 -1
  151. data/vendor/assets/dashboard/assets/Busy-DC5EGM0g.js +0 -1
  152. data/vendor/assets/dashboard/assets/Cron-Dlt8tXJA.js +0 -1
  153. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +0 -1
  154. data/vendor/assets/dashboard/assets/Dead-dZ7VGlKS.js +0 -1
  155. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +0 -1
  156. data/vendor/assets/dashboard/assets/FilterBox-CO3aYWIq.js +0 -1
  157. data/vendor/assets/dashboard/assets/JobDetailModal-DSWbT6G0.js +0 -2
  158. data/vendor/assets/dashboard/assets/Limiters-Cb4PKXNR.js +0 -1
  159. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +0 -1
  160. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +0 -1
  161. data/vendor/assets/dashboard/assets/PageHeader-fPrCcp_-.js +0 -1
  162. data/vendor/assets/dashboard/assets/Profiles-BnS82nR_.js +0 -1
  163. data/vendor/assets/dashboard/assets/Queues-CIyPevOy.js +0 -1
  164. data/vendor/assets/dashboard/assets/Retries-DopwXkXl.js +0 -1
  165. data/vendor/assets/dashboard/assets/Scheduled-1-Z7i1zE.js +0 -1
  166. data/vendor/assets/dashboard/assets/Search-ByA6eTma.js +0 -1
  167. data/vendor/assets/dashboard/assets/Skeleton-bC7HfQ9r.js +0 -1
  168. data/vendor/assets/dashboard/assets/charts-CLLzJ7vK.js +0 -1
  169. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +0 -141
  170. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  171. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-DpBjkf6_.js +0 -1
  172. data/vendor/assets/dashboard/assets/useSort-DvpwuNQE.js +0 -1
  173. 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 '../middleware/poison_pill'
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. Mirrors
340
- # Client#push_batched_pipelined: a pipelined EVALSHA surfaces NOSCRIPT
341
- # only at finalize (never to eval_cached's inline rescue). Every command
342
- # here is the same script, so a flushed cache fails all of them and
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 = ::Process.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
- def blmove(public_q)
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 = poll_interval
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