Module: Wurk::API::RollUp

Defined in:
lib/wurk/api/roll_up.rb

Overview

GET /swarm: many heartbeats folded into one answer to "how is the swarm actually working". Serializers shapes one process; this shapes the cluster, and it is the only place the two differ in kind.

A pure function of the Process list it is handed — no Redis of its own — so the roll-up and the /processes listing beside it can only ever describe the same fleet.

What it deliberately does not report: the swarm parent's rolling-restart state. Wurk::Swarm::Restart is a state machine in the parent's memory — which slot is being replaced, whether its replacement has beaten yet — and no part of it is ever written to Redis. The API usually runs in a different process entirely (a Rails web worker, or standalone wurk api), so the only way to serve it would be new heartbeat traffic, which is exactly what this plane may not add. The observable signature is here instead: versions holds more than one entry while a deploy is mid-flight, processes.quiet counts the children already told to drain, and hosts shows the per-host child count the replacements land in.

Class Method Summary collapse

Class Method Details

.beat(ages) ⇒ Object

The oldest beat is the one that says whether anything has gone quiet on the wire; the threshold ships with it so a client reads the verdict and the rule behind it in the same document.



121
122
123
# File 'lib/wurk/api/roll_up.rb', line 121

def beat(ages)
  { oldest_age_seconds: ages.max, stale_after_seconds: Serializers::STALE_AFTER_SECONDS }
end

.cluster(processes, leader_identity:, now:) ⇒ Object

Parameters:

  • processes (Array<Wurk::Process>)

    live heartbeats, already filtered of identities whose info has expired (ProcessSet#each).

  • leader_identity (String)

    the dear-leader value, read once.

  • now (Float)

    epoch seconds, taken once for the whole answer.



32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/wurk/api/roll_up.rb', line 32

def cluster(processes, leader_identity:, now:)
  ages = processes.map { |process| Serializers.beat_age_seconds(process['beat'], now) }
  {
    processes: process_counts(processes, ages),
    **totals(processes),
    queues: processes.flat_map(&:queues).compact.uniq.sort,
    versions: processes.filter_map(&:version).uniq.sort,
    leader: leader(processes, leader_identity),
    hosts: hosts(processes),
    slots: slots(processes),
    beat: beat(ages)
  }
end

.host(hostname, group) ⇒ Object



94
95
96
97
98
99
100
101
# File 'lib/wurk/api/roll_up.rb', line 94

def host(hostname, group)
  facts = group.first
  {
    hostname: hostname, processes: group.size,
    concurrency: sum(group, 'concurrency'), busy: sum(group, 'busy'), rss_kb: sum(group, 'rss'),
    cpu_model: facts['cpu_model'], cores: facts['cores'], memory_total_kb: facts['memory_total_kb']
  }
end

.hosts(processes) ⇒ Object

Per-host roll-up, which is what "how many children is this box running" actually asks. Hardware facts ride here rather than on every process row: they describe the box, and repeating them per child would invite a client to average them.



89
90
91
92
# File 'lib/wurk/api/roll_up.rb', line 89

def hosts(processes)
  grouped = processes.group_by { |process| process['hostname'] }
  grouped.map { |hostname, group| host(hostname, group) }.sort_by { |host| host[:hostname].to_s }
end

.leader(processes, identity) ⇒ Object

live distinguishes a leader that is still beating from a lock left behind by a process that died holding it — the key outlives the heartbeat by up to its own TTL, and during that gap the cluster has a recorded leader and no leader.



78
79
80
81
82
83
# File 'lib/wurk/api/roll_up.rb', line 78

def leader(processes, identity)
  identity = identity.to_s
  return { identity: nil, live: false } if identity.empty?

  { identity: identity, live: processes.any? { |process| process.identity == identity } }
end

.process_counts(processes, ages) ⇒ Object



57
58
59
60
61
62
63
# File 'lib/wurk/api/roll_up.rb', line 57

def process_counts(processes, ages)
  {
    total: processes.size,
    quiet: processes.count(&:stopping?),
    stale: ages.count { |age| Serializers.stale?(age) }
  }
end

.slots(processes) ⇒ Object

The topology as the heartbeats describe it, in Topology::Slot's own vocabulary: how many children of each (queues, concurrency) kind are live. Observed rather than read off config.topology, because the declaration lives in the swarm parent's configuration and the process answering this request may not share it — a standalone wurk api would otherwise report a slot table derived from its own CPU count, which describes nothing that is running.



110
111
112
113
114
115
116
# File 'lib/wurk/api/roll_up.rb', line 110

def slots(processes)
  grouped = processes.group_by { |process| [process.queues.to_a.sort, process['concurrency'].to_i] }
  rows = grouped.map do |(queues, concurrency), group|
    { count: group.size, queues: queues, concurrency: concurrency }
  end
  rows.sort_by { |slot| [-slot[:count], slot[:queues]] }
end

.sum(processes, field) ⇒ Object



55
# File 'lib/wurk/api/roll_up.rb', line 55

def sum(processes, field) = processes.sum { |process| process[field].to_i }

.totals(processes) ⇒ Object



46
47
48
49
50
51
52
53
# File 'lib/wurk/api/roll_up.rb', line 46

def totals(processes)
  concurrency = sum(processes, 'concurrency')
  busy = sum(processes, 'busy')
  {
    concurrency: concurrency, busy: busy,
    utilization: utilization(busy, concurrency), rss_kb: sum(processes, 'rss')
  }
end

.utilization(busy, concurrency) ⇒ Object

Busy threads over total threads, as a fraction. Zero rather than a division by zero when nothing is running: an empty fleet is 0% busy, and a null here would only push the special case onto every client.



68
69
70
71
72
# File 'lib/wurk/api/roll_up.rb', line 68

def utilization(busy, concurrency)
  return 0.0 if concurrency.zero?

  (busy.to_f / concurrency).round(4)
end