wurk 1.4.0 → 1.6.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 (190) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +41 -3
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/app/controllers/wurk/api/serializers.rb +51 -1
  5. data/app/controllers/wurk/api_controller.rb +42 -6
  6. data/app/controllers/wurk/dashboard_controller.rb +66 -10
  7. data/config/routes.rb +30 -3
  8. data/exe/wurk +6 -2
  9. data/lib/generators/wurk/install/install_generator.rb +3 -3
  10. data/lib/sidekiq/job_retry.rb +4 -0
  11. data/lib/sidekiq/manager.rb +4 -0
  12. data/lib/sidekiq/processor.rb +4 -0
  13. data/lib/wurk/api/app.rb +186 -0
  14. data/lib/wurk/api/auth.rb +167 -0
  15. data/lib/wurk/api/flows.rb +66 -0
  16. data/lib/wurk/api/idempotency.rb +168 -0
  17. data/lib/wurk/api/jobs.rb +180 -0
  18. data/lib/wurk/api/page.rb +98 -0
  19. data/lib/wurk/api/problem.rb +121 -0
  20. data/lib/wurk/api/queues.rb +126 -0
  21. data/lib/wurk/api/read_only.rb +68 -0
  22. data/lib/wurk/api/request.rb +46 -0
  23. data/lib/wurk/api/response.rb +28 -0
  24. data/lib/wurk/api/roll_up.rb +126 -0
  25. data/lib/wurk/api/router.rb +88 -0
  26. data/lib/wurk/api/serializers.rb +232 -0
  27. data/lib/wurk/api/swarm.rb +297 -0
  28. data/lib/wurk/api/throttle.rb +109 -0
  29. data/lib/wurk/api/validation.rb +306 -0
  30. data/lib/wurk/api.rb +96 -0
  31. data/lib/wurk/batch/server_middleware.rb +2 -2
  32. data/lib/wurk/batch.rb +12 -3
  33. data/lib/wurk/capsule.rb +48 -13
  34. data/lib/wurk/cli.rb +99 -4
  35. data/lib/wurk/client/buffered.rb +8 -1
  36. data/lib/wurk/client.rb +144 -35
  37. data/lib/wurk/collapse.rb +383 -0
  38. data/lib/wurk/compat.rb +19 -0
  39. data/lib/wurk/component.rb +48 -4
  40. data/lib/wurk/configuration.rb +348 -10
  41. data/lib/wurk/context.rb +1 -1
  42. data/lib/wurk/debounce.rb +125 -0
  43. data/lib/wurk/encryption.rb +6 -1
  44. data/lib/wurk/engine.rb +41 -1
  45. data/lib/wurk/errors.rb +15 -0
  46. data/lib/wurk/fetcher/capped.rb +218 -0
  47. data/lib/wurk/fetcher/reaper.rb +12 -2
  48. data/lib/wurk/fetcher/reliable.rb +307 -90
  49. data/lib/wurk/fetcher/unit_of_work.rb +100 -0
  50. data/lib/wurk/fetcher.rb +6 -0
  51. data/lib/wurk/flow/builder.rb +271 -0
  52. data/lib/wurk/flow/chain.rb +42 -0
  53. data/lib/wurk/flow/completion.rb +114 -0
  54. data/lib/wurk/flow/creation.rb +254 -0
  55. data/lib/wurk/flow/node.rb +124 -0
  56. data/lib/wurk/flow/status.rb +206 -0
  57. data/lib/wurk/flow.rb +255 -0
  58. data/lib/wurk/flow_set.rb +53 -0
  59. data/lib/wurk/health.rb +8 -7
  60. data/lib/wurk/heartbeat.rb +23 -5
  61. data/lib/wurk/job/options.rb +11 -1
  62. data/lib/wurk/job.rb +19 -0
  63. data/lib/wurk/job_logger.rb +16 -7
  64. data/lib/wurk/job_retry.rb +6 -1
  65. data/lib/wurk/job_set.rb +3 -2
  66. data/lib/wurk/job_util.rb +153 -32
  67. data/lib/wurk/keys.rb +125 -0
  68. data/lib/wurk/launcher.rb +117 -73
  69. data/lib/wurk/leader.rb +37 -7
  70. data/lib/wurk/limiter/bucket.rb +1 -1
  71. data/lib/wurk/limiter/points.rb +1 -1
  72. data/lib/wurk/logger.rb +1 -1
  73. data/lib/wurk/lua/debounce.lua +75 -0
  74. data/lib/wurk/lua/fetch_slot.lua +83 -0
  75. data/lib/wurk/lua/flow_abandon.lua +65 -0
  76. data/lib/wurk/lua/flow_advance.lua +177 -0
  77. data/lib/wurk/lua/flow_create.lua +141 -0
  78. data/lib/wurk/lua/flow_fail.lua +52 -0
  79. data/lib/wurk/lua/limiter_bucket_acquire.lua +26 -0
  80. data/lib/wurk/lua/limiter_concurrent_acquire.lua +33 -0
  81. data/lib/wurk/lua/limiter_concurrent_release.lua +7 -0
  82. data/lib/wurk/lua/limiter_leaky_acquire.lua +31 -0
  83. data/lib/wurk/lua/limiter_list_sweep.lua +30 -0
  84. data/lib/wurk/lua/limiter_points_acquire.lua +34 -0
  85. data/lib/wurk/lua/limiter_points_refund.lua +18 -0
  86. data/lib/wurk/lua/limiter_register.lua +27 -0
  87. data/lib/wurk/lua/limiter_window_acquire.lua +35 -0
  88. data/lib/wurk/lua/limiter_window_status.lua +22 -0
  89. data/lib/wurk/lua/loader.rb +32 -3
  90. data/lib/wurk/lua/queue_slot.lua +83 -0
  91. data/lib/wurk/lua/refresh_slots.lua +38 -0
  92. data/lib/wurk/lua/status_write.lua +29 -0
  93. data/lib/wurk/lua/throttle_slot.lua +71 -0
  94. data/lib/wurk/lua.rb +3 -3
  95. data/lib/wurk/manager.rb +12 -0
  96. data/lib/wurk/metrics/accumulator.rb +95 -0
  97. data/lib/wurk/metrics/flusher.rb +70 -0
  98. data/lib/wurk/metrics/history.rb +132 -31
  99. data/lib/wurk/metrics/query.rb +1 -1
  100. data/lib/wurk/metrics/statsd.rb +46 -18
  101. data/lib/wurk/middleware/chain.rb +31 -14
  102. data/lib/wurk/middleware/expiry.rb +71 -10
  103. data/lib/wurk/middleware/poison_pill.rb +23 -2
  104. data/lib/wurk/middleware/status.rb +274 -0
  105. data/lib/wurk/middleware/timeout.rb +137 -0
  106. data/lib/wurk/pool_checkout.rb +10 -0
  107. data/lib/wurk/processor.rb +141 -29
  108. data/lib/wurk/profiler.rb +6 -4
  109. data/lib/wurk/queue.rb +8 -0
  110. data/lib/wurk/queue_slot.rb +285 -0
  111. data/lib/wurk/rails.rb +3 -3
  112. data/lib/wurk/redis_client_adapter.rb +1 -1
  113. data/lib/wurk/redis_pool.rb +2 -1
  114. data/lib/wurk/shutdown_gate.rb +79 -0
  115. data/lib/wurk/stats.rb +5 -1
  116. data/lib/wurk/status/progress.rb +103 -0
  117. data/lib/wurk/status/record.rb +81 -0
  118. data/lib/wurk/status.rb +155 -0
  119. data/lib/wurk/swarm/child_boot.rb +24 -4
  120. data/lib/wurk/swarm.rb +88 -14
  121. data/lib/wurk/telemetry/client_middleware.rb +59 -0
  122. data/lib/wurk/telemetry/server_middleware.rb +152 -0
  123. data/lib/wurk/telemetry.rb +137 -0
  124. data/lib/wurk/throttle.rb +142 -0
  125. data/lib/wurk/unique.rb +10 -2
  126. data/lib/wurk/version.rb +1 -1
  127. data/lib/wurk/watchdog.rb +188 -0
  128. data/lib/wurk/web/config.rb +88 -17
  129. data/lib/wurk/web/extension.rb +2 -2
  130. data/lib/wurk/web/locale_negotiator.rb +67 -0
  131. data/lib/wurk/web.rb +1 -0
  132. data/lib/wurk/worker.rb +39 -4
  133. data/lib/wurk.rb +42 -9
  134. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +1 -0
  135. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +1 -0
  136. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +1 -0
  137. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +1 -0
  138. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +1 -0
  139. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +1 -0
  140. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +1 -0
  141. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +1 -0
  142. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +1 -0
  143. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +1 -0
  144. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +1 -0
  145. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +1 -0
  146. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +2 -0
  147. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +1 -0
  148. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +1 -0
  149. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +1 -0
  150. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +1 -0
  151. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +1 -0
  152. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +1 -0
  153. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +1 -0
  154. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +1 -0
  155. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +1 -0
  156. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +1 -0
  157. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +141 -0
  158. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +1 -0
  159. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +1 -0
  160. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +1 -0
  161. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +1 -0
  162. data/vendor/assets/dashboard/index.html +42 -11
  163. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  164. metadata +105 -34
  165. data/vendor/assets/dashboard/assets/ArgsValue-D74zX0MI.js +0 -1
  166. data/vendor/assets/dashboard/assets/BatchDetail-OmC5NPgw.js +0 -1
  167. data/vendor/assets/dashboard/assets/Batches-CIpai7St.js +0 -1
  168. data/vendor/assets/dashboard/assets/Busy-A_kwSR6Q.js +0 -1
  169. data/vendor/assets/dashboard/assets/Cron-BG7HTqlp.js +0 -1
  170. data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
  171. data/vendor/assets/dashboard/assets/Dead-8J21jMyK.js +0 -1
  172. data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +0 -1
  173. data/vendor/assets/dashboard/assets/FilterBox-Fh_Ae7UW.js +0 -1
  174. data/vendor/assets/dashboard/assets/JobDetailModal-Ceng0PMB.js +0 -2
  175. data/vendor/assets/dashboard/assets/Limiters-CruDWvNZ.js +0 -1
  176. data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
  177. data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +0 -1
  178. data/vendor/assets/dashboard/assets/PageHeader-C44KNMGm.js +0 -1
  179. data/vendor/assets/dashboard/assets/Profiles-xEVTyS2N.js +0 -1
  180. data/vendor/assets/dashboard/assets/Queues-D86FYohJ.js +0 -1
  181. data/vendor/assets/dashboard/assets/Retries-Bz1O1D-i.js +0 -1
  182. data/vendor/assets/dashboard/assets/Scheduled-B6h2akTu.js +0 -1
  183. data/vendor/assets/dashboard/assets/Search-OOu22e5s.js +0 -1
  184. data/vendor/assets/dashboard/assets/Skeleton-DzR7XNxz.js +0 -1
  185. data/vendor/assets/dashboard/assets/charts-BVHHGof7.js +0 -1
  186. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
  187. data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
  188. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-dVPGEWzn.js +0 -1
  189. data/vendor/assets/dashboard/assets/useSort-BeYbztkN.js +0 -1
  190. data/vendor/assets/dashboard/assets/utils-DDJC7tJV.js +0 -1
@@ -0,0 +1,297 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../web/enterprise'
4
+ require_relative 'page'
5
+ require_relative 'problem'
6
+ require_relative 'response'
7
+ require_relative 'roll_up'
8
+ require_relative 'serializers'
9
+ require_relative 'validation'
10
+
11
+ module Wurk
12
+ module API
13
+ # The swarm plane: how the fleet is laid out, what each process is doing
14
+ # right now, and the two signals an operator sends it.
15
+ #
16
+ # Read-only against Redis structures that already exist — the `processes`
17
+ # SET and its per-identity heartbeat HASHes, `<identity>:work`,
18
+ # `dear-leader`, the limiter and cron registries. No new key, no new field
19
+ # on the beat, and nothing here makes a process beat more often. A
20
+ # monitoring client polling `GET /swarm` every few seconds must cost the
21
+ # swarm nothing, so every route reads through the canonical inspectors
22
+ # ({Wurk::ProcessSet}, {Wurk::WorkSet}, {Wurk::Cron::LoopSet},
23
+ # Web::Enterprise::Limits) exactly as the dashboard does.
24
+ #
25
+ # One deliberate divergence from the dashboard: `ProcessSet.new(false)`
26
+ # everywhere. The default constructor also runs the SREM sweep for expired
27
+ # identities, which is a write, rate-limited by a `SET NX EX` that is
28
+ # itself a write. Every count reported here comes from `#each`, which
29
+ # already skips identities whose `info` has expired, so the sweep would buy
30
+ # no accuracy — only a Redis write on each poll of a plane that is supposed
31
+ # to be free to watch. The dashboard keeps the sweep; an operator has it
32
+ # open for minutes, not milliseconds.
33
+ module Swarm
34
+ # Signal targets. An identity is `<hostname>:<pid>:<nonce>`, so nothing
35
+ # live can be named `all` and the sentinel needs no escaping.
36
+ ALL = 'all'
37
+ QUIET_SIGNAL = 'TSTP'
38
+ STOP_SIGNAL = 'TERM'
39
+
40
+ MAX_FILTER_LENGTH = 255
41
+
42
+ # The two signals take :admin. Quieting the fleet stops it working
43
+ # without enqueueing or deleting anything — the same reasoning that puts
44
+ # queue pausing with the destructive routes rather than the listings.
45
+ TABLE = [
46
+ [:get, '/swarm', :read, :cluster],
47
+ [:get, '/processes', :read, :index],
48
+ [:get, '/processes/:identity', :read, :show],
49
+ [:post, '/processes/:identity/quiet', :admin, :quiet],
50
+ [:post, '/processes/:identity/stop', :admin, :stop],
51
+ [:get, '/busy', :read, :busy],
52
+ [:get, '/health', :read, :health],
53
+ [:get, '/limiters', :read, :limiters],
54
+ [:get, '/cron', :read, :cron]
55
+ ].freeze
56
+
57
+ module_function
58
+
59
+ def draw(router)
60
+ TABLE.each do |verb, pattern, scope, handler|
61
+ router.public_send(verb, pattern, scope: scope) do |request|
62
+ public_send(handler, request)
63
+ rescue Validation::Invalid => e
64
+ Problem.from(e, instance: request.path)
65
+ end
66
+ end
67
+ end
68
+
69
+ def cluster(_request)
70
+ set = ::Wurk::ProcessSet.new(false)
71
+ Response.json(200, RollUp.cluster(set.to_a, leader_identity: set.leader, now: ::Time.now.to_f))
72
+ end
73
+
74
+ # Materialized before paging so `total` counts live processes rather
75
+ # than `SCARD processes`, which still holds identities whose heartbeat
76
+ # lapsed. ProcessSet#each already costs one pipeline for the whole set,
77
+ # so walking it in full buys the accurate count for nothing.
78
+ def index(request)
79
+ window = Page.window!(request)
80
+ set = ::Wurk::ProcessSet.new(false)
81
+ live = set.to_a
82
+ leader = set.leader
83
+ now = ::Time.now.to_f
84
+ rows = Page.slice(live, window) do |process|
85
+ Serializers.process_row(process, leader: leader?(process, leader), now: now)
86
+ end
87
+ Response.json(200, total: live.size, page: window.page, count: window.count, processes: rows)
88
+ end
89
+
90
+ # The same row the listing emits, plus what this process is running, so
91
+ # a client that read a row out of `/processes` and then fetched it does
92
+ # not have to reshape anything.
93
+ def show(request)
94
+ identity = Validation.identity!(request.path_params[:identity].to_s)
95
+ process = ::Wurk::ProcessSet[identity]
96
+ return process_not_found(request, identity) unless process
97
+
98
+ now = ::Time.now.to_f
99
+ Response.json(
100
+ 200,
101
+ process: Serializers.process_row(process, leader: process.leader?, now: now),
102
+ work: work_for(identity, now)
103
+ )
104
+ end
105
+
106
+ def busy(request)
107
+ window = Page.window!(request)
108
+ now = ::Time.now.to_f
109
+ rows = ::Wurk::WorkSet.new.to_a
110
+ jobs = Page.slice(rows, window) { |row| Serializers.work_row(*row, now: now) }
111
+ Response.json(200, total: rows.size, page: window.page, count: window.count, jobs: jobs)
112
+ end
113
+
114
+ # Re-derived, not proxied. {Wurk::Health} is a raw TCPServer inside each
115
+ # worker process, answering about its own launcher and its own
116
+ # heartbeat; this runs in a process that has neither, so it answers the
117
+ # same two questions — is Redis reachable, is anything beating — about
118
+ # the cluster instead. `quiet` is reported but not folded into the
119
+ # verdict: a draining process is still finishing work, and Health does
120
+ # not fail readiness for it either.
121
+ #
122
+ # Not a Kubernetes probe replacement — a probe must not carry a bearer
123
+ # token. `config.health_check(port:)` is still that surface.
124
+ def health(_request)
125
+ redis = ping_redis
126
+ return unhealthy(redis, nil, 'redis unreachable') unless redis[:ok]
127
+
128
+ counts = process_health
129
+ return unhealthy(redis, counts, 'no live processes') if counts[:live].zero?
130
+
131
+ Response.json(200, health_body('ok', redis, counts))
132
+ end
133
+
134
+ def limiters(request)
135
+ query = Page.query!(request)
136
+ window = Page.window(query)
137
+ names = ::Wurk::Web::Enterprise::Limits.list(filter: filter!(query))
138
+ rows = Page.slice(names, window) { |name| limiter_row(name) }
139
+ Response.json(200, total: names.size, page: window.page, count: window.count, limiters: rows)
140
+ end
141
+
142
+ # Walked in full before serializing. LoopSet#each yields from inside its
143
+ # own Redis checkout and Loop#last_fired_at takes another, so serializing
144
+ # mid-iteration would hold a pool slot for the length of the listing.
145
+ # The registry is a deploy-sized list, not a job-sized one.
146
+ def cron(request)
147
+ window = Page.window!(request)
148
+ loops = ::Wurk::Cron::LoopSet.new.to_a
149
+ now = ::Time.now.to_i
150
+ rows = Page.slice(loops, window) { |loop_obj| Serializers.cron_loop(loop_obj, now: now) }
151
+ Response.json(200, total: loops.size, page: window.page, count: window.count, cron: rows)
152
+ end
153
+
154
+ def quiet(request) = signal(request, :quiet!, QUIET_SIGNAL)
155
+ def stop(request) = signal(request, :stop!, STOP_SIGNAL)
156
+
157
+ # Both signals go through {Wurk::Process#quiet!} / `#stop!` — the same
158
+ # `<identity>-signals` LPUSH the Busy page sends — rather than a second
159
+ # writer to that list. Asynchronous by construction: the target picks the
160
+ # entry up on its next beat, so 200 means queued, never applied.
161
+ def signal(request, method, name)
162
+ identity = request.path_params[:identity].to_s
163
+ return broadcast(method, name) if identity == ALL
164
+
165
+ Validation.identity!(identity)
166
+ process = ::Wurk::ProcessSet[identity]
167
+ return process_not_found(request, identity) unless process
168
+ return process_not_signalable(request, identity) if process.embedded?
169
+
170
+ process.public_send(method)
171
+ signal_result(name, [identity], [])
172
+ end
173
+
174
+ # One answer shape for both ways of addressing, so a client that
175
+ # broadcasts and a client that names one process parse the same body.
176
+ # An embedded process is skipped here rather than refused: a broadcast
177
+ # asks about the fleet, and one member that cannot be signalled is no
178
+ # reason to refuse the rest.
179
+ def broadcast(method, name)
180
+ sent = []
181
+ skipped = []
182
+ ::Wurk::ProcessSet.new(false).each do |process|
183
+ next skipped << process.identity if process.embedded?
184
+
185
+ process.public_send(method)
186
+ sent << process.identity
187
+ end
188
+ signal_result(name, sent, skipped)
189
+ end
190
+
191
+ def signal_result(name, sent, skipped)
192
+ Response.json(200, signal: name, signalled: sent, skipped: skipped)
193
+ end
194
+
195
+ def leader?(process, leader_identity)
196
+ !leader_identity.to_s.empty? && process.identity == leader_identity
197
+ end
198
+
199
+ # Filtered out of the canonical WorkSet rather than read straight off
200
+ # `<identity>:work`: a second reader of that hash is a second place for
201
+ # the run_at ordering and the JobRecord unwrapping to drift.
202
+ def work_for(identity, now)
203
+ ::Wurk::WorkSet.new.filter_map do |process_id, thread_id, work|
204
+ next unless process_id == identity
205
+
206
+ Serializers.work_row(process_id, thread_id, work, now: now)
207
+ end
208
+ end
209
+
210
+ def process_health
211
+ now = ::Time.now.to_f
212
+ live = ::Wurk::ProcessSet.new(false).to_a
213
+ stale = live.count { |entry| Serializers.stale?(Serializers.beat_age_seconds(entry['beat'], now)) }
214
+ { total: live.size, live: live.size - stale, stale: stale, quiet: live.count(&:stopping?) }
215
+ end
216
+
217
+ # A 503 whose body is the report itself, not a problem document: an
218
+ # unhealthy cluster is the answer this route was asked for, and Health's
219
+ # own `{"status":"down","reason":…}` is the shape operators already read.
220
+ def unhealthy(redis, processes, reason)
221
+ Response.json(503, health_body('down', redis, processes).merge(reason: reason))
222
+ end
223
+
224
+ def health_body(status, redis, processes)
225
+ { status: status, redis: redis, processes: processes,
226
+ stale_after_seconds: Serializers::STALE_AFTER_SECONDS }
227
+ end
228
+
229
+ # Its own PING rather than a byproduct of another read: "is Redis
230
+ # reachable" has to stay answerable when every other read would raise.
231
+ def ping_redis
232
+ started = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond)
233
+ pong = ::Wurk.redis(idempotent: true) { |conn| conn.call('PING') } == 'PONG'
234
+ elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC, :microsecond) - started
235
+ { ok: pong, rtt_ms: (elapsed / 1000.0).round(3) }
236
+ rescue ::StandardError
237
+ { ok: false, rtt_ms: nil }
238
+ end
239
+
240
+ # Two reads per row — the metadata HASH, then the limiter's own counters
241
+ # — which is what the page cap bounds. Limits owns the metadata and only
242
+ # the limiter type knows where its state lives, so there is no single key
243
+ # to pipeline both out of. Rebuilt from the metadata already in hand
244
+ # rather than through Limits.rebuild, which would re-read it.
245
+ def limiter_row(name)
246
+ meta = ::Wurk::Web::Enterprise::Limits.metadata(name)
247
+ Serializers.limiter_row(name, meta, limiter_status(name, meta))
248
+ end
249
+
250
+ # Best-effort in the two ways a persisted limiter can refuse to come
251
+ # back: `build` answers nil for a type this version no longer knows, and
252
+ # the constructor raises ArgumentError on options it cannot accept (a
253
+ # `bucket` whose `interval` is gone, a name written by a later release).
254
+ # A row with a null status is a better answer than a 500 for the whole
255
+ # page. Deliberately not `rescue StandardError` — a broader net would
256
+ # also swallow a bug in the rebuild and report it as a missing status.
257
+ def limiter_status(name, meta)
258
+ ::Wurk::Limiter.build(name, meta['type'], Serializers.parse_options(meta['options']))&.status
259
+ rescue ::ArgumentError, ::TypeError
260
+ nil
261
+ end
262
+
263
+ # A repeated `?filter=a&filter=b` parses to an Array, and an unbounded
264
+ # one is a substring scan over every limiter name run on the caller's
265
+ # behalf — both answered rather than coerced.
266
+ def filter!(query)
267
+ raw = query['filter']
268
+ return nil if raw.nil? || raw == ''
269
+ return raw if raw.is_a?(::String) && raw.length <= MAX_FILTER_LENGTH
270
+
271
+ raise Validation::Invalid, "The 'filter' parameter is a string of up to #{MAX_FILTER_LENGTH} characters."
272
+ end
273
+
274
+ def process_not_found(request, identity)
275
+ Problem.render(
276
+ Problem::PROCESS_NOT_FOUND,
277
+ status: 404,
278
+ detail: "No live process has identity #{identity}; it has exited, or its heartbeat expired.",
279
+ instance: request.path,
280
+ identity: identity
281
+ )
282
+ end
283
+
284
+ # 409, not 400: the request is well-formed and the caller is entitled to
285
+ # make it — the target is simply in a state that cannot accept it.
286
+ def process_not_signalable(request, identity)
287
+ Problem.render(
288
+ Problem::PROCESS_NOT_SIGNALABLE,
289
+ status: 409,
290
+ detail: "Process #{identity} is embedded in its host application; signalling it would signal the host.",
291
+ instance: request.path,
292
+ identity: identity
293
+ )
294
+ end
295
+ end
296
+ end
297
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../limiter'
4
+ require_relative 'problem'
5
+
6
+ module Wurk
7
+ module API
8
+ # Per-token request ceiling for the HTTP API.
9
+ #
10
+ # A {Wurk::Limiter::Window} per credential, not a second limiter: Wurk
11
+ # already owns a sliding window whose trim-count-add step is one atomic Lua
12
+ # call timed off the Redis clock, so a fleet of web processes shares one
13
+ # verdict and a producer's own clock cannot argue with it. Reusing it also
14
+ # means an API ceiling is a limiter like any other — it registers into
15
+ # `lmtr-list`, and `GET /v1/limiters` and the dashboard's Limiters page show
16
+ # it beside the ones the jobs use.
17
+ #
18
+ # The window is named for the token's fingerprint (a truncated,
19
+ # domain-separated digest — {Auth#fingerprint}), never the token: the name
20
+ # is a Redis key and shows up in the dashboard.
21
+ #
22
+ # Off unless `config.api_rate_limit` is set, and then it is exactly one
23
+ # extra round trip per request. Nothing here rescues a Redis failure: a
24
+ # window that cannot be read cannot limit, and an API that quietly drops
25
+ # its ceiling the moment Redis is in trouble sheds it precisely when the
26
+ # load matters. The error surfaces as App's 500, which a client retries.
27
+ module Throttle
28
+ NAME_PREFIX = 'api-token:'
29
+
30
+ # No waiting. `within_limit` sleeps until a slot frees, which is right for
31
+ # a worker thread and wrong for a socket a client is holding open — an
32
+ # over-limit request has an answer already (429 + Retry-After), and it is
33
+ # cheaper for both ends than a held connection.
34
+ NO_WAIT = 0
35
+
36
+ # Retry-After is a whole number of seconds (RFC 9110 §10.2.3) and must
37
+ # never be 0: a client that reads "wait 0" retries immediately, which is
38
+ # what got it throttled.
39
+ MIN_RETRY_AFTER = 1
40
+
41
+ LOCK = Mutex.new
42
+
43
+ module_function
44
+
45
+ # @return [Array, nil] a 429 problem triple, or nil when the request may
46
+ # proceed (including when no limit is configured).
47
+ def refuse(request)
48
+ limit = request.config.api_rate_limit
49
+ return nil unless limit
50
+
51
+ interval = request.config.api_rate_limit_interval
52
+ limiter = for_principal(request.principal, limit, interval)
53
+ limiter.within_limit { nil }
54
+ rescue ::Wurk::Limiter::OverLimit => e
55
+ over_limit(request, e.limiter, limit, interval)
56
+ end
57
+
58
+ # Limiters are stateless Ruby objects — every operation is a Lua call — so
59
+ # one per (credential, ceiling) is safe to keep and share across threads
60
+ # and across a fork. Kept because constructing one writes its metadata
61
+ # hash, and paying that round trip per request would double the cost of
62
+ # the feature. Keyed by the ceiling too, so a host that changes it gets a
63
+ # limiter that agrees with the config rather than the one built first.
64
+ def for_principal(principal, limit, interval)
65
+ key = [principal.id, limit, interval]
66
+ found = LOCK.synchronize { cache[key] }
67
+ return found if found
68
+
69
+ # Built outside the lock: registration is a Redis round trip, and a
70
+ # racing second build only rewrites the same metadata hash.
71
+ built = ::Wurk::Limiter.window("#{NAME_PREFIX}#{principal.id}", limit, interval, wait_timeout: NO_WAIT)
72
+ LOCK.synchronize { cache[key] ||= built }
73
+ end
74
+
75
+ def cache
76
+ @cache ||= {}
77
+ end
78
+
79
+ # `reset_at` is when the window's oldest entry slides out — the first
80
+ # moment a slot exists — read fresh rather than guessed from the interval,
81
+ # which would tell a client that trickled up to the ceiling to wait a full
82
+ # window for a slot that frees in a second. One extra read, on the refused
83
+ # path only.
84
+ def retry_after(limiter, interval)
85
+ reset_at = limiter.status[:reset_at]
86
+ seconds = reset_at ? (reset_at - ::Time.now.to_f).ceil : interval_seconds(interval)
87
+ [seconds, MIN_RETRY_AFTER].max
88
+ end
89
+
90
+ def interval_seconds(interval)
91
+ ::Wurk::Limiter.interval_seconds(interval, allow_integer: true)
92
+ end
93
+
94
+ def over_limit(request, limiter, limit, interval)
95
+ seconds = retry_after(limiter, interval)
96
+ Problem.render(
97
+ Problem::RATE_LIMITED,
98
+ status: 429,
99
+ detail: "This token is limited to #{limit} requests per #{interval}.",
100
+ instance: request.path,
101
+ headers: { 'retry-after' => seconds.to_s },
102
+ limit: limit,
103
+ interval_seconds: interval_seconds(interval),
104
+ retry_after: seconds
105
+ )
106
+ end
107
+ end
108
+ end
109
+ end