Module: Wurk::API::Serializers
- Defined in:
- lib/wurk/api/serializers.rb
Overview
Wire shapes for the observe plane.
Every value below is read off a canonical inspector — Stats, Queue, JobRecord, SortedEntry — so the API and the dashboard cannot report different numbers for the same Redis. Only the shape differs, and it differs on purpose: the dashboard's serializers (app/controllers/wurk/api/serializers.rb) answer to the SPA and change whenever it does, and they reach into Wurk::Web for host-registered extension rows — an engine dependency the API cannot take, because it also runs standalone. Under /v1 these field names are a contract.
Constant Summary collapse
- STALE_AFTER_SECONDS =
Three missed beats at Heartbeat::BEAT_PAUSE cadence, which is also the window Wurk::Health calls a heartbeat stale, and half the heartbeat key's TTL — so a process that stopped beating is reported stale for ~30s before Redis reaps the row out from under this API.
::Wurk::Heartbeat::BEAT_PAUSE * 3
Class Method Summary collapse
-
.beat_age_seconds(beat, now) ⇒ Object
Seconds since this process last beat, measured against a
nowthe caller took once for the whole listing so rows never disagree by a round trip. -
.cron_loop(loop_obj, now:) ⇒ Object
One registered cron loop.
-
.declared(process, now) ⇒ Object
The half a process wrote about itself in the
infoJSON: who it is and what it was configured to do. -
.flow(status) ⇒ Object
One flow and its graph.
-
.flow_node(node) ⇒ Object
depends_onanddependentsare node indexes, which is what a node is addressed by:nameis optional, and two nodes of the same class are otherwise indistinguishable. -
.job_record(record) ⇒ Object
classandargsare the display view, the same one the dashboard renders: an ActiveJob wrapper is unwrapped to the job the host actually wrote, and anencrypt: truejob's ciphertext argument is masked. -
.limiter_row(name, meta, status) ⇒ Object
available, not theavailable?Limiter::Base#build_status keys it with: a trailing?is Ruby's convention for a predicate, not JSON's for a Boolean field. -
.measured(process, now) ⇒ Object
The half its last beat measured, plus the two values derived from it.
- .parse_options(raw) ⇒ Object
-
.process_row(process, leader:, now:) ⇒ Object
One live process.
-
.queue_gauges(queue) ⇒ Object
The same four gauges read off a Wurk::Queue instead of a pipelined Stats::QueueSummary — three round trips rather than a share of one, which is what asking about a single queue costs.
- .queue_summary(summary) ⇒ Object
-
.sorted_entry(entry) ⇒ Object
atis the member's ZSET score in epoch seconds — when it is due to retry, due to run, or when it died, depending on which set it came out of. - .stale?(age) ⇒ Boolean
-
.stats(snapshot) ⇒ Object
default_queue_latency, not the dashboard'slatency: sitting beside aqueuesarray, a barelatencyreads as the whole fleet's rather than thedefaultqueue's, which is what Stats measures. -
.uptime_seconds(started_at, now) ⇒ Object
nil rather than 0 when the heartbeat carries no
started_at: a process of unknown age is not one that just booted. -
.work_row(process_id, thread_id, work, now:) ⇒ Object
One in-flight job.
Class Method Details
.beat_age_seconds(beat, now) ⇒ Object
Seconds since this process last beat, measured against a now the
caller took once for the whole listing so rows never disagree by a
round trip. Floored at zero: beat is stamped by the beating process's
clock and this is read on another host's, so a skew of a few
milliseconds must not surface as a negative age.
31 32 33 |
# File 'lib/wurk/api/serializers.rb', line 31 def beat_age_seconds(beat, now) [now - beat.to_f, 0.0].max.round(3) end |
.cron_loop(loop_obj, now:) ⇒ Object
One registered cron loop. next_fire_at is evaluated against a now
the caller took once, so every row in a listing answers "next after the
same instant" rather than drifting a row at a time.
106 107 108 109 110 111 112 113 114 |
# File 'lib/wurk/api/serializers.rb', line 106 def cron_loop(loop_obj, now:) { lid: loop_obj.lid, schedule: loop_obj.schedule, class: loop_obj.klass, queue: loop_obj.queue, args: loop_obj.args, tz: loop_obj.tz_name, paused: loop_obj.paused?, last_fired_at: loop_obj.last_fired_at, next_fire_at: loop_obj.next_fire_at(now) } end |
.declared(process, now) ⇒ Object
The half a process wrote about itself in the info JSON: who it is and
what it was configured to do. Split along the same seam the heartbeat
itself uses (Heartbeat#info_hash versus #beat_hash_args), so a field
that moves between the two halves upstream moves between these.
53 54 55 56 57 58 59 60 61 |
# File 'lib/wurk/api/serializers.rb', line 53 def declared(process, now) { identity: process.identity, hostname: process['hostname'], pid: process['pid'], tag: process.tag, version: process.version, embedded: process., concurrency: process['concurrency'], queues: process.queues, weights: process.weights, labels: process.labels, started_at: process['started_at'], uptime_seconds: uptime_seconds(process['started_at'], now) } end |
.flow(status) ⇒ Object
One flow and its graph. succeeded is emitted alongside pending
because a client rendering progress would otherwise have to know that
the two are complements of total — a relation this contract should
not require anyone to rediscover.
nodes is empty for an abandoned flow: the kill switch releases the
node records, and inventing rows for keys that are gone would report a
graph nothing can still act on.
188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/wurk/api/serializers.rb', line 188 def flow(status) { fid: status.fid, state: status.state, terminal: status.terminal?, total: status.total, pending: status.pending, succeeded: status.succeeded_count, depth: status.depth, width: status.width, created_at: status.created_at, finished_at: status.finished_at, failed_at: status.failed_at, abandoned_at: status.abandoned_at, dead_nodes: status.dead_indexes, nodes: status.nodes.map { |node| flow_node(node) } } end |
.flow_node(node) ⇒ Object
depends_on and dependents are node indexes, which is what a node is
addressed by: name is optional, and two nodes of the same class are
otherwise indistinguishable. error is set only on a broken node —
one whose piped input never arrived, so no job ran to leave a failure
anywhere else.
205 206 207 208 209 210 211 212 |
# File 'lib/wurk/api/serializers.rb', line 205 def flow_node(node) { index: node.index, name: node.name, class: node.klass, queue: node.queue, jid: node.jid, bid: node.bid, state: node.state, depends_on: node.dependencies, dependents: node.dependents, remaining: node.remaining, piped: node.piped?, error: node.error } end |
.job_record(record) ⇒ Object
class and args are the display view, the same one the dashboard
renders: an ActiveJob wrapper is unwrapped to the job the host actually
wrote, and an encrypt: true job's ciphertext argument is masked.
Serving record.args here would publish the raw encrypted envelope of
every such job over HTTP.
169 170 171 172 173 174 175 176 177 178 |
# File 'lib/wurk/api/serializers.rb', line 169 def job_record(record) { jid: record.jid, class: record.display_class, args: record.display_args, queue: record.queue, enqueued_at: record.enqueued_at&.to_f, created_at: record.created_at&.to_f } end |
.limiter_row(name, meta, status) ⇒ Object
available, not the available? Limiter::Base#build_status
keys it with: a trailing ? is Ruby's convention for a predicate, not
JSON's for a Boolean field. status is null when the limiter's
metadata expired between the listing that named it and this read.
90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/wurk/api/serializers.rb', line 90 def limiter_row(name, , status) { name: name, type: ['type'].to_s, fingerprint: ['fingerprint'].to_s, options: (['options']), status: status && { used: status[:used], limit: status[:limit], reset_at: status[:reset_at], available: status[:available?] } } end |
.measured(process, now) ⇒ Object
The half its last beat measured, plus the two values derived from it.
64 65 66 67 68 69 70 71 |
# File 'lib/wurk/api/serializers.rb', line 64 def measured(process, now) age = beat_age_seconds(process['beat'], now) { busy: process['busy'], rss_kb: process['rss'], rtt_us: process['rtt_us'], beat: process['beat'], beat_age_seconds: age, stale: stale?(age), quiet: process.stopping? } end |
.parse_options(raw) ⇒ Object
116 117 118 119 120 121 122 |
# File 'lib/wurk/api/serializers.rb', line 116 def (raw) return {} if raw.nil? || raw.to_s.empty? ::JSON.parse(raw) rescue ::JSON::ParserError {} end |
.process_row(process, leader:, now:) ⇒ Object
One live process. beat_age_seconds and stale are derived here
rather than left to the client: a client comparing beat against its
own clock is comparing two clocks, which is the one comparison this
roll-up exists to save it from.
leader is passed in because the two callers learn it differently — a
listing compares against one memoized dear-leader read, a single
process asks itself — and neither should pay the other's round trips.
45 46 47 |
# File 'lib/wurk/api/serializers.rb', line 45 def process_row(process, leader:, now:) declared(process, now).merge(measured(process, now)).merge(leader: leader) end |
.queue_gauges(queue) ⇒ Object
The same four gauges read off a Wurk::Queue instead of a pipelined Stats::QueueSummary — three round trips rather than a share of one, which is what asking about a single queue costs. Same field names on purpose: a client that read a queue out of the listing and then fetched it should not have to reshape anything.
160 161 162 |
# File 'lib/wurk/api/serializers.rb', line 160 def queue_gauges(queue) { name: queue.name, size: queue.size, latency: queue.latency, paused: queue.paused? } end |
.queue_summary(summary) ⇒ Object
151 152 153 |
# File 'lib/wurk/api/serializers.rb', line 151 def queue_summary(summary) { name: summary.name, size: summary.size, latency: summary.latency, paused: summary.paused? } end |
.sorted_entry(entry) ⇒ Object
at is the member's ZSET score in epoch seconds — when it is due to
retry, due to run, or when it died, depending on which set it came out
of. Emitted once: the score and the timestamp are the same number, and
a contract that ships both invites clients to disagree about which is
authoritative.
219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/wurk/api/serializers.rb', line 219 def sorted_entry(entry) job_record(entry).merge( at: entry.at.to_f, retry_count: entry['retry_count'], error_class: entry['error_class'], error_message: entry['error_message'], failed_at: entry.failed_at&.to_f, retried_at: entry.retried_at&.to_f, error_backtrace: entry.error_backtrace ) end |
.stale?(age) ⇒ Boolean
35 |
# File 'lib/wurk/api/serializers.rb', line 35 def stale?(age) = age > STALE_AFTER_SECONDS |
.stats(snapshot) ⇒ Object
default_queue_latency, not the dashboard's latency: sitting beside
a queues array, a bare latency reads as the whole fleet's rather
than the default queue's, which is what Stats measures.
135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/wurk/api/serializers.rb', line 135 def stats(snapshot) { processed: snapshot.processed, failed: snapshot.failed, expired: snapshot.expired, enqueued: snapshot.enqueued, busy: snapshot.workers_size, scheduled: snapshot.scheduled_size, retries: snapshot.retry_size, dead: snapshot.dead_size, processes: snapshot.processes_size, default_queue_latency: snapshot.default_queue_latency, queues: snapshot.queue_summaries.map { |summary| queue_summary(summary) } } end |
.uptime_seconds(started_at, now) ⇒ Object
nil rather than 0 when the heartbeat carries no started_at: a process
of unknown age is not one that just booted.
126 127 128 129 130 |
# File 'lib/wurk/api/serializers.rb', line 126 def uptime_seconds(started_at, now) return nil if started_at.nil? [now - started_at.to_f, 0.0].max.round(3) end |
.work_row(process_id, thread_id, work, now:) ⇒ Object
One in-flight job. class/args are the display view for the reason
#job_record gives, and elapsed_seconds is derived for the reason
beat_age_seconds is — the client's clock is not the swarm's.
76 77 78 79 80 81 82 83 84 |
# File 'lib/wurk/api/serializers.rb', line 76 def work_row(process_id, thread_id, work, now:) record = work.job run_at = work.run_at.to_f { process_id: process_id, thread_id: thread_id, queue: work.queue, jid: record.jid, class: record.display_class, args: record.display_args, run_at: run_at, elapsed_seconds: [now - run_at, 0.0].max.round(3) } end |