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,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