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.
- checksums.yaml +4 -4
- data/README.md +41 -3
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- 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 +66 -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 +48 -13
- data/lib/wurk/cli.rb +99 -4
- data/lib/wurk/client/buffered.rb +8 -1
- data/lib/wurk/client.rb +144 -35
- data/lib/wurk/collapse.rb +383 -0
- data/lib/wurk/compat.rb +19 -0
- data/lib/wurk/component.rb +48 -4
- data/lib/wurk/configuration.rb +348 -10
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/debounce.rb +125 -0
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/capped.rb +218 -0
- data/lib/wurk/fetcher/reaper.rb +12 -2
- data/lib/wurk/fetcher/reliable.rb +307 -90
- data/lib/wurk/fetcher/unit_of_work.rb +100 -0
- data/lib/wurk/fetcher.rb +6 -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 +23 -5
- data/lib/wurk/job/options.rb +11 -1
- data/lib/wurk/job.rb +19 -0
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_retry.rb +6 -1
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +153 -32
- data/lib/wurk/keys.rb +125 -0
- data/lib/wurk/launcher.rb +117 -73
- data/lib/wurk/leader.rb +37 -7
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/points.rb +1 -1
- data/lib/wurk/logger.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 +32 -3
- 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/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +132 -31
- data/lib/wurk/metrics/query.rb +1 -1
- data/lib/wurk/metrics/statsd.rb +46 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/expiry.rb +71 -10
- data/lib/wurk/middleware/poison_pill.rb +23 -2
- data/lib/wurk/middleware/status.rb +274 -0
- data/lib/wurk/middleware/timeout.rb +137 -0
- data/lib/wurk/pool_checkout.rb +10 -0
- data/lib/wurk/processor.rb +141 -29
- data/lib/wurk/profiler.rb +6 -4
- data/lib/wurk/queue.rb +8 -0
- data/lib/wurk/queue_slot.rb +285 -0
- data/lib/wurk/rails.rb +3 -3
- data/lib/wurk/redis_client_adapter.rb +1 -1
- data/lib/wurk/redis_pool.rb +2 -1
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +5 -1
- 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/swarm/child_boot.rb +24 -4
- data/lib/wurk/swarm.rb +88 -14
- 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 +88 -17
- data/lib/wurk/web/extension.rb +2 -2
- 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 -9
- 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 +105 -34
- data/vendor/assets/dashboard/assets/ArgsValue-D74zX0MI.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-OmC5NPgw.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-CIpai7St.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-A_kwSR6Q.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-BG7HTqlp.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-A_ToqHoo.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-8J21jMyK.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-B4Q9FIQu.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-Fh_Ae7UW.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-Ceng0PMB.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-CruDWvNZ.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-CIT7VCoN.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-CN3rdKA_.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-C44KNMGm.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-xEVTyS2N.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-D86FYohJ.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-Bz1O1D-i.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-B6h2akTu.js +0 -1
- data/vendor/assets/dashboard/assets/Search-OOu22e5s.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-DzR7XNxz.js +0 -1
- data/vendor/assets/dashboard/assets/charts-BVHHGof7.js +0 -1
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +0 -1
- data/vendor/assets/dashboard/assets/index-D_lSDwKw.js +0 -141
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-dVPGEWzn.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-BeYbztkN.js +0 -1
- 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
|