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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'serializers'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module API
|
|
7
|
+
# `GET /swarm`: many heartbeats folded into one answer to "how is the
|
|
8
|
+
# swarm actually working". Serializers shapes one process; this shapes the
|
|
9
|
+
# cluster, and it is the only place the two differ in kind.
|
|
10
|
+
#
|
|
11
|
+
# A pure function of the {Wurk::Process} list it is handed — no Redis of
|
|
12
|
+
# its own — so the roll-up and the `/processes` listing beside it can only
|
|
13
|
+
# ever describe the same fleet.
|
|
14
|
+
#
|
|
15
|
+
# What it deliberately does not report: the swarm parent's rolling-restart
|
|
16
|
+
# state. `Wurk::Swarm::Restart` is a state machine in the parent's memory —
|
|
17
|
+
# which slot is being replaced, whether its replacement has beaten yet —
|
|
18
|
+
# and no part of it is ever written to Redis. The API usually runs in a
|
|
19
|
+
# different process entirely (a Rails web worker, or standalone `wurk
|
|
20
|
+
# api`), so the only way to serve it would be new heartbeat traffic, which
|
|
21
|
+
# is exactly what this plane may not add. The observable signature is here
|
|
22
|
+
# instead: `versions` holds more than one entry while a deploy is
|
|
23
|
+
# mid-flight, `processes.quiet` counts the children already told to drain,
|
|
24
|
+
# and `hosts` shows the per-host child count the replacements land in.
|
|
25
|
+
module RollUp
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @param processes [Array<Wurk::Process>] live heartbeats, already
|
|
29
|
+
# filtered of identities whose `info` has expired (ProcessSet#each).
|
|
30
|
+
# @param leader_identity [String] the `dear-leader` value, read once.
|
|
31
|
+
# @param now [Float] epoch seconds, taken once for the whole answer.
|
|
32
|
+
def cluster(processes, leader_identity:, now:)
|
|
33
|
+
ages = processes.map { |process| Serializers.beat_age_seconds(process['beat'], now) }
|
|
34
|
+
{
|
|
35
|
+
processes: process_counts(processes, ages),
|
|
36
|
+
**totals(processes),
|
|
37
|
+
queues: processes.flat_map(&:queues).compact.uniq.sort,
|
|
38
|
+
versions: processes.filter_map(&:version).uniq.sort,
|
|
39
|
+
leader: leader(processes, leader_identity),
|
|
40
|
+
hosts: hosts(processes),
|
|
41
|
+
slots: slots(processes),
|
|
42
|
+
beat: beat(ages)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def totals(processes)
|
|
47
|
+
concurrency = sum(processes, 'concurrency')
|
|
48
|
+
busy = sum(processes, 'busy')
|
|
49
|
+
{
|
|
50
|
+
concurrency: concurrency, busy: busy,
|
|
51
|
+
utilization: utilization(busy, concurrency), rss_kb: sum(processes, 'rss')
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sum(processes, field) = processes.sum { |process| process[field].to_i }
|
|
56
|
+
|
|
57
|
+
def process_counts(processes, ages)
|
|
58
|
+
{
|
|
59
|
+
total: processes.size,
|
|
60
|
+
quiet: processes.count(&:stopping?),
|
|
61
|
+
stale: ages.count { |age| Serializers.stale?(age) }
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Busy threads over total threads, as a fraction. Zero rather than a
|
|
66
|
+
# division by zero when nothing is running: an empty fleet is 0% busy,
|
|
67
|
+
# and a null here would only push the special case onto every client.
|
|
68
|
+
def utilization(busy, concurrency)
|
|
69
|
+
return 0.0 if concurrency.zero?
|
|
70
|
+
|
|
71
|
+
(busy.to_f / concurrency).round(4)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# `live` distinguishes a leader that is still beating from a lock left
|
|
75
|
+
# behind by a process that died holding it — the key outlives the
|
|
76
|
+
# heartbeat by up to its own TTL, and during that gap the cluster has a
|
|
77
|
+
# recorded leader and no leader.
|
|
78
|
+
def leader(processes, identity)
|
|
79
|
+
identity = identity.to_s
|
|
80
|
+
return { identity: nil, live: false } if identity.empty?
|
|
81
|
+
|
|
82
|
+
{ identity: identity, live: processes.any? { |process| process.identity == identity } }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Per-host roll-up, which is what "how many children is this box
|
|
86
|
+
# running" actually asks. Hardware facts ride here rather than on every
|
|
87
|
+
# process row: they describe the box, and repeating them per child would
|
|
88
|
+
# invite a client to average them.
|
|
89
|
+
def hosts(processes)
|
|
90
|
+
grouped = processes.group_by { |process| process['hostname'] }
|
|
91
|
+
grouped.map { |hostname, group| host(hostname, group) }.sort_by { |host| host[:hostname].to_s }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def host(hostname, group)
|
|
95
|
+
facts = group.first
|
|
96
|
+
{
|
|
97
|
+
hostname: hostname, processes: group.size,
|
|
98
|
+
concurrency: sum(group, 'concurrency'), busy: sum(group, 'busy'), rss_kb: sum(group, 'rss'),
|
|
99
|
+
cpu_model: facts['cpu_model'], cores: facts['cores'], memory_total_kb: facts['memory_total_kb']
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The topology as the heartbeats describe it, in {Wurk::Topology::Slot}'s
|
|
104
|
+
# own vocabulary: how many children of each (queues, concurrency) kind
|
|
105
|
+
# are live. Observed rather than read off `config.topology`, because the
|
|
106
|
+
# declaration lives in the swarm parent's configuration and the process
|
|
107
|
+
# answering this request may not share it — a standalone `wurk api` would
|
|
108
|
+
# otherwise report a slot table derived from its own CPU count, which
|
|
109
|
+
# describes nothing that is running.
|
|
110
|
+
def slots(processes)
|
|
111
|
+
grouped = processes.group_by { |process| [process.queues.to_a.sort, process['concurrency'].to_i] }
|
|
112
|
+
rows = grouped.map do |(queues, concurrency), group|
|
|
113
|
+
{ count: group.size, queues: queues, concurrency: concurrency }
|
|
114
|
+
end
|
|
115
|
+
rows.sort_by { |slot| [-slot[:count], slot[:queues]] }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The oldest beat is the one that says whether anything has gone quiet on
|
|
119
|
+
# the wire; the threshold ships with it so a client reads the verdict and
|
|
120
|
+
# the rule behind it in the same document.
|
|
121
|
+
def beat(ages)
|
|
122
|
+
{ oldest_age_seconds: ages.max, stale_after_seconds: Serializers::STALE_AFTER_SECONDS }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack'
|
|
4
|
+
require_relative 'auth'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
module API
|
|
8
|
+
# Path table for the HTTP API. Deliberately not ActionDispatch: the API has
|
|
9
|
+
# to route in standalone mode, where Rails is never loaded (CLAUDE.md —
|
|
10
|
+
# "standalone mode must run without loading the engine").
|
|
11
|
+
#
|
|
12
|
+
# Patterns are literal segments plus `:name` captures ('/jobs/:jid').
|
|
13
|
+
# A capture never spans '/', so a queue named "a/b" has to arrive
|
|
14
|
+
# percent-encoded — the same constraint `config/routes.rb` puts on the
|
|
15
|
+
# dashboard's `:name`.
|
|
16
|
+
#
|
|
17
|
+
# Every route names the Auth scope its caller must hold. The router only
|
|
18
|
+
# records it — App#dispatch enforces it — but the keyword is required, so
|
|
19
|
+
# adding an endpoint without deciding who may call it is impossible rather
|
|
20
|
+
# than merely discouraged. A new route can never default open.
|
|
21
|
+
class Router
|
|
22
|
+
Route = Struct.new(:verb, :segments, :scope, :handler, keyword_init: true)
|
|
23
|
+
# `handler` nil means nothing matched the path; `allowed` then carries the
|
|
24
|
+
# verbs registered for a path that *did* match, so the caller can answer
|
|
25
|
+
# 405 with an Allow header instead of a misleading 404.
|
|
26
|
+
Match = Struct.new(:handler, :params, :scope, :allowed, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
NO_PARAMS = {}.freeze
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@routes = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get(pattern, scope:, &handler) = add('GET', pattern, scope, handler)
|
|
35
|
+
def post(pattern, scope:, &handler) = add('POST', pattern, scope, handler)
|
|
36
|
+
def delete(pattern, scope:, &handler) = add('DELETE', pattern, scope, handler)
|
|
37
|
+
|
|
38
|
+
# HEAD is served by the GET route (RFC 9110 §9.3.2); the caller drops the
|
|
39
|
+
# body.
|
|
40
|
+
def match(verb, path)
|
|
41
|
+
verb = 'GET' if verb == 'HEAD'
|
|
42
|
+
segments = split(path).map { |seg| ::Rack::Utils.unescape_path(seg) }
|
|
43
|
+
allowed = []
|
|
44
|
+
@routes.each do |route|
|
|
45
|
+
params = bind(route.segments, segments)
|
|
46
|
+
next unless params
|
|
47
|
+
if route.verb == verb
|
|
48
|
+
return Match.new(handler: route.handler, params: params, scope: route.scope, allowed: nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
allowed << route.verb
|
|
52
|
+
end
|
|
53
|
+
Match.new(handler: nil, params: NO_PARAMS, scope: nil, allowed: allowed.uniq)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# A misspelled scope would 403 the route forever; reject it at draw time,
|
|
59
|
+
# which is boot, rather than letting it surface as a runtime denial.
|
|
60
|
+
def add(verb, pattern, scope, handler)
|
|
61
|
+
unless Auth::ROUTE_SCOPES.include?(scope)
|
|
62
|
+
raise ArgumentError, "unknown route scope #{scope.inspect}; valid scopes are #{Auth::ROUTE_SCOPES.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@routes << Route.new(verb: verb, segments: split(pattern), scope: scope, handler: handler)
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def split(path) = path.to_s.split('/').reject(&:empty?)
|
|
70
|
+
|
|
71
|
+
# nil (no match) is a different answer from {} (matched, no captures) —
|
|
72
|
+
# the caller branches on it, so don't collapse them.
|
|
73
|
+
def bind(pattern, segments)
|
|
74
|
+
return nil unless pattern.size == segments.size
|
|
75
|
+
|
|
76
|
+
params = {}
|
|
77
|
+
pattern.each_with_index do |seg, index|
|
|
78
|
+
if seg.start_with?(':')
|
|
79
|
+
params[seg[1..].to_sym] = segments[index]
|
|
80
|
+
elsif seg != segments[index]
|
|
81
|
+
return nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
params
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
module API
|
|
7
|
+
# Wire shapes for the observe plane.
|
|
8
|
+
#
|
|
9
|
+
# Every value below is read off a canonical inspector — Stats, Queue,
|
|
10
|
+
# JobRecord, SortedEntry — so the API and the dashboard cannot report
|
|
11
|
+
# different numbers for the same Redis. Only the shape differs, and it
|
|
12
|
+
# differs on purpose: the dashboard's serializers
|
|
13
|
+
# (app/controllers/wurk/api/serializers.rb) answer to the SPA and change
|
|
14
|
+
# whenever it does, and they reach into Wurk::Web for host-registered
|
|
15
|
+
# extension rows — an engine dependency the API cannot take, because it
|
|
16
|
+
# also runs standalone. Under /v1 these field names are a contract.
|
|
17
|
+
module Serializers
|
|
18
|
+
# Three missed beats at {Wurk::Heartbeat::BEAT_PAUSE} cadence, which is
|
|
19
|
+
# also the window Wurk::Health calls a heartbeat stale, and half the
|
|
20
|
+
# heartbeat key's TTL — so a process that stopped beating is reported
|
|
21
|
+
# stale for ~30s before Redis reaps the row out from under this API.
|
|
22
|
+
STALE_AFTER_SECONDS = ::Wurk::Heartbeat::BEAT_PAUSE * 3
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Seconds since this process last beat, measured against a `now` the
|
|
27
|
+
# caller took once for the whole listing so rows never disagree by a
|
|
28
|
+
# round trip. Floored at zero: `beat` is stamped by the beating process's
|
|
29
|
+
# clock and this is read on another host's, so a skew of a few
|
|
30
|
+
# milliseconds must not surface as a negative age.
|
|
31
|
+
def beat_age_seconds(beat, now)
|
|
32
|
+
[now - beat.to_f, 0.0].max.round(3)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def stale?(age) = age > STALE_AFTER_SECONDS
|
|
36
|
+
|
|
37
|
+
# One live process. `beat_age_seconds` and `stale` are derived here
|
|
38
|
+
# rather than left to the client: a client comparing `beat` against its
|
|
39
|
+
# own clock is comparing two clocks, which is the one comparison this
|
|
40
|
+
# roll-up exists to save it from.
|
|
41
|
+
#
|
|
42
|
+
# `leader` is passed in because the two callers learn it differently — a
|
|
43
|
+
# listing compares against one memoized `dear-leader` read, a single
|
|
44
|
+
# process asks itself — and neither should pay the other's round trips.
|
|
45
|
+
def process_row(process, leader:, now:)
|
|
46
|
+
declared(process, now).merge(measured(process, now)).merge(leader: leader)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The half a process wrote about itself in the `info` JSON: who it is and
|
|
50
|
+
# what it was configured to do. Split along the same seam the heartbeat
|
|
51
|
+
# itself uses (Heartbeat#info_hash versus #beat_hash_args), so a field
|
|
52
|
+
# that moves between the two halves upstream moves between these.
|
|
53
|
+
def declared(process, now)
|
|
54
|
+
{
|
|
55
|
+
identity: process.identity, hostname: process['hostname'], pid: process['pid'],
|
|
56
|
+
tag: process.tag, version: process.version, embedded: process.embedded?,
|
|
57
|
+
concurrency: process['concurrency'], queues: process.queues,
|
|
58
|
+
weights: process.weights, labels: process.labels,
|
|
59
|
+
started_at: process['started_at'], uptime_seconds: uptime_seconds(process['started_at'], now)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The half its last beat measured, plus the two values derived from it.
|
|
64
|
+
def measured(process, now)
|
|
65
|
+
age = beat_age_seconds(process['beat'], now)
|
|
66
|
+
{
|
|
67
|
+
busy: process['busy'], rss_kb: process['rss'], rtt_us: process['rtt_us'],
|
|
68
|
+
beat: process['beat'], beat_age_seconds: age, stale: stale?(age),
|
|
69
|
+
quiet: process.stopping?
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# One in-flight job. `class`/`args` are the display view for the reason
|
|
74
|
+
# {#job_record} gives, and `elapsed_seconds` is derived for the reason
|
|
75
|
+
# `beat_age_seconds` is — the client's clock is not the swarm's.
|
|
76
|
+
def work_row(process_id, thread_id, work, now:)
|
|
77
|
+
record = work.job
|
|
78
|
+
run_at = work.run_at.to_f
|
|
79
|
+
{
|
|
80
|
+
process_id: process_id, thread_id: thread_id, queue: work.queue,
|
|
81
|
+
jid: record.jid, class: record.display_class, args: record.display_args,
|
|
82
|
+
run_at: run_at, elapsed_seconds: [now - run_at, 0.0].max.round(3)
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# `available`, not the `available?` {Wurk::Limiter::Base#build_status}
|
|
87
|
+
# keys it with: a trailing `?` is Ruby's convention for a predicate, not
|
|
88
|
+
# JSON's for a Boolean field. `status` is null when the limiter's
|
|
89
|
+
# metadata expired between the listing that named it and this read.
|
|
90
|
+
def limiter_row(name, meta, status)
|
|
91
|
+
{
|
|
92
|
+
name: name,
|
|
93
|
+
type: meta['type'].to_s,
|
|
94
|
+
fingerprint: meta['fingerprint'].to_s,
|
|
95
|
+
options: parse_options(meta['options']),
|
|
96
|
+
status: status && {
|
|
97
|
+
used: status[:used], limit: status[:limit],
|
|
98
|
+
reset_at: status[:reset_at], available: status[:available?]
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# One registered cron loop. `next_fire_at` is evaluated against a `now`
|
|
104
|
+
# the caller took once, so every row in a listing answers "next after the
|
|
105
|
+
# same instant" rather than drifting a row at a time.
|
|
106
|
+
def cron_loop(loop_obj, now:)
|
|
107
|
+
{
|
|
108
|
+
lid: loop_obj.lid, schedule: loop_obj.schedule, class: loop_obj.klass,
|
|
109
|
+
queue: loop_obj.queue, args: loop_obj.args, tz: loop_obj.tz_name,
|
|
110
|
+
paused: loop_obj.paused?,
|
|
111
|
+
last_fired_at: loop_obj.last_fired_at,
|
|
112
|
+
next_fire_at: loop_obj.next_fire_at(now)
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_options(raw)
|
|
117
|
+
return {} if raw.nil? || raw.to_s.empty?
|
|
118
|
+
|
|
119
|
+
::JSON.parse(raw)
|
|
120
|
+
rescue ::JSON::ParserError
|
|
121
|
+
{}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# nil rather than 0 when the heartbeat carries no `started_at`: a process
|
|
125
|
+
# of unknown age is not one that just booted.
|
|
126
|
+
def uptime_seconds(started_at, now)
|
|
127
|
+
return nil if started_at.nil?
|
|
128
|
+
|
|
129
|
+
[now - started_at.to_f, 0.0].max.round(3)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# `default_queue_latency`, not the dashboard's `latency`: sitting beside
|
|
133
|
+
# a `queues` array, a bare `latency` reads as the whole fleet's rather
|
|
134
|
+
# than the `default` queue's, which is what Stats measures.
|
|
135
|
+
def stats(snapshot)
|
|
136
|
+
{
|
|
137
|
+
processed: snapshot.processed,
|
|
138
|
+
failed: snapshot.failed,
|
|
139
|
+
expired: snapshot.expired,
|
|
140
|
+
enqueued: snapshot.enqueued,
|
|
141
|
+
busy: snapshot.workers_size,
|
|
142
|
+
scheduled: snapshot.scheduled_size,
|
|
143
|
+
retries: snapshot.retry_size,
|
|
144
|
+
dead: snapshot.dead_size,
|
|
145
|
+
processes: snapshot.processes_size,
|
|
146
|
+
default_queue_latency: snapshot.default_queue_latency,
|
|
147
|
+
queues: snapshot.queue_summaries.map { |summary| queue_summary(summary) }
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def queue_summary(summary)
|
|
152
|
+
{ name: summary.name, size: summary.size, latency: summary.latency, paused: summary.paused? }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The same four gauges read off a Wurk::Queue instead of a pipelined
|
|
156
|
+
# Stats::QueueSummary — three round trips rather than a share of one,
|
|
157
|
+
# which is what asking about a single queue costs. Same field names on
|
|
158
|
+
# purpose: a client that read a queue out of the listing and then fetched
|
|
159
|
+
# it should not have to reshape anything.
|
|
160
|
+
def queue_gauges(queue)
|
|
161
|
+
{ name: queue.name, size: queue.size, latency: queue.latency, paused: queue.paused? }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `class` and `args` are the *display* view, the same one the dashboard
|
|
165
|
+
# renders: an ActiveJob wrapper is unwrapped to the job the host actually
|
|
166
|
+
# wrote, and an `encrypt: true` job's ciphertext argument is masked.
|
|
167
|
+
# Serving `record.args` here would publish the raw encrypted envelope of
|
|
168
|
+
# every such job over HTTP.
|
|
169
|
+
def job_record(record)
|
|
170
|
+
{
|
|
171
|
+
jid: record.jid,
|
|
172
|
+
class: record.display_class,
|
|
173
|
+
args: record.display_args,
|
|
174
|
+
queue: record.queue,
|
|
175
|
+
enqueued_at: record.enqueued_at&.to_f,
|
|
176
|
+
created_at: record.created_at&.to_f
|
|
177
|
+
}
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# One flow and its graph. `succeeded` is emitted alongside `pending`
|
|
181
|
+
# because a client rendering progress would otherwise have to know that
|
|
182
|
+
# the two are complements of `total` — a relation this contract should
|
|
183
|
+
# not require anyone to rediscover.
|
|
184
|
+
#
|
|
185
|
+
# `nodes` is empty for an abandoned flow: the kill switch releases the
|
|
186
|
+
# node records, and inventing rows for keys that are gone would report a
|
|
187
|
+
# graph nothing can still act on.
|
|
188
|
+
def flow(status)
|
|
189
|
+
{
|
|
190
|
+
fid: status.fid, state: status.state, terminal: status.terminal?,
|
|
191
|
+
total: status.total, pending: status.pending, succeeded: status.succeeded_count,
|
|
192
|
+
depth: status.depth, width: status.width,
|
|
193
|
+
created_at: status.created_at, finished_at: status.finished_at,
|
|
194
|
+
failed_at: status.failed_at, abandoned_at: status.abandoned_at,
|
|
195
|
+
dead_nodes: status.dead_indexes,
|
|
196
|
+
nodes: status.nodes.map { |node| flow_node(node) }
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# `depends_on` and `dependents` are node indexes, which is what a node is
|
|
201
|
+
# addressed by: `name` is optional, and two nodes of the same class are
|
|
202
|
+
# otherwise indistinguishable. `error` is set only on a `broken` node —
|
|
203
|
+
# one whose piped input never arrived, so no job ran to leave a failure
|
|
204
|
+
# anywhere else.
|
|
205
|
+
def flow_node(node)
|
|
206
|
+
{
|
|
207
|
+
index: node.index, name: node.name, class: node.klass, queue: node.queue,
|
|
208
|
+
jid: node.jid, bid: node.bid, state: node.state,
|
|
209
|
+
depends_on: node.dependencies, dependents: node.dependents,
|
|
210
|
+
remaining: node.remaining, piped: node.piped?, error: node.error
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# `at` is the member's ZSET score in epoch seconds — when it is due to
|
|
215
|
+
# retry, due to run, or when it died, depending on which set it came out
|
|
216
|
+
# of. Emitted once: the score and the timestamp are the same number, and
|
|
217
|
+
# a contract that ships both invites clients to disagree about which is
|
|
218
|
+
# authoritative.
|
|
219
|
+
def sorted_entry(entry)
|
|
220
|
+
job_record(entry).merge(
|
|
221
|
+
at: entry.at.to_f,
|
|
222
|
+
retry_count: entry['retry_count'],
|
|
223
|
+
error_class: entry['error_class'],
|
|
224
|
+
error_message: entry['error_message'],
|
|
225
|
+
failed_at: entry.failed_at&.to_f,
|
|
226
|
+
retried_at: entry.retried_at&.to_f,
|
|
227
|
+
error_backtrace: entry.error_backtrace
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|