asxpio/lib/bingo_monitor.rb
Sergei Poljanski 0cc3e1dece
All checks were successful
Build and Deploy to Production / test (push) Successful in 26s
Build and Deploy to Production / build (push) Successful in 1m11s
Build and Deploy to Production / deploy (push) Successful in 37s
bingo: add a reachability monitor for the status page
datacenterbingo gets a public page showing whether each datacenter's
network answers. The probing lives here, next to the schema and the
admin, since the public site only has a read-only role.

bin/monitor runs as a second container from the same image. Once a
minute it opens one TCP connection to every active target (a handshake
or a refusal both count as answered) and folds the results into
5-minute rows per datacenter, plus a per-bucket row for control hosts in
Russia, so a lost vantage point isn't mistaken for dark sites. Rows are
kept 30 days. Targets are managed at /admin/bingo/monitor; only hosts a
public source ties to that one site belong there.

Migration 017 also grants the new tables to the bingo role, so the
public site can read them without a storage redeploy.

Assisted-by: Claude:opus-5-5
2026-10-18 10:31:00 +04:00

122 lines
4.6 KiB
Ruby

require 'socket'
module Bingo
# Reachability monitor for datacenterbingo's status page. Every minute
# bin/monitor opens one TCP connection to each active target; a handshake
# or a refusal both prove the site's network answers. Results fold into
# 5-minute rows per datacenter (upserts, so a restart mid-bucket merges).
module Monitor
INTERVAL = 60 # seconds between rounds
BUCKET = 300 # seconds per sample row
TIMEOUT = 3 # connect and DNS timeout per probe
THREADS = 32
KEEP_DAYS = 30
CONTROL = '_control'.freeze # slug for control hosts in the bulk form
class Target < Sequel::Model(:bingo_monitor_targets)
many_to_one :datacenter, class: 'Bingo::Datacenter'
def before_create
self.created_at ||= Time.now.utc
super
end
def control? = datacenter_id.nil?
def endpoint = "#{host}:#{port}"
end
module_function
def bucket_of(time) = Time.at(time.to_i - (time.to_i % BUCKET)).utc
# Handshake time in ms, or nil when nothing answered (timeout, no route,
# DNS failure). A refusal is an answer: the host is up, the port closed.
def probe(host, port)
t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
begin
Socket.tcp(host, port, connect_timeout: TIMEOUT, resolv_timeout: TIMEOUT, &:close)
rescue Errno::ECONNREFUSED
nil # fall through to the elapsed time
end
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).round
rescue SystemCallError, SocketError, IOError
nil
end
# [[target, ms or nil], ...], probed concurrently.
def round(targets)
queue = Queue.new
targets.each { |t| queue << t }
results = Queue.new
Array.new([THREADS, targets.size].min) do
Thread.new do
while (t = (queue.pop(true) rescue nil))
results << [t, probe(t.host, t.port)]
end
end
end.each(&:join)
Array.new(results.size) { results.pop }
end
def record!(results, at: Time.now.utc)
db = DB.connection
bucket = bucket_of(at)
db.transaction do
sites, controls = results.partition { |t, _| t.datacenter_id }
sites.group_by { |t, _| t.datacenter_id }.each do |dc_id, rs|
answered = rs.filter_map { |_, ms| ms }
upsert(db[:bingo_monitor_samples], %i[datacenter_id bucket_at],
{ datacenter_id: dc_id, bucket_at: bucket },
checks: rs.size, ok: answered.size, rtt_ms_sum: answered.sum)
end
upsert(db[:bingo_monitor_buckets], %i[bucket_at], { bucket_at: bucket },
rounds: 1, control_checks: controls.size, control_ok: controls.count { |_, ms| ms })
results.each do |t, ms|
change = t.last_ok.nil? || t.last_ok != !ms.nil?
db[:bingo_monitor_targets].where(id: t.id).update(
{ last_ok: !ms.nil?, last_rtt_ms: ms, last_checked_at: at }.merge(change ? { last_change_at: at } : {})
)
end
end
end
# Insert key+counters, or add the counters to the existing row.
def upsert(ds, key_cols, key, counters)
t = ds.first_source_table
ds.insert_conflict(target: key_cols,
update: counters.keys.to_h { |c| [c, Sequel[t][c] + Sequel[:excluded][c]] })
.insert(key.merge(counters))
end
def prune!(now = Time.now.utc)
cutoff = now - (KEEP_DAYS * 86_400)
DB.connection[:bingo_monitor_samples].where { bucket_at < cutoff }.delete
DB.connection[:bingo_monitor_buckets].where { bucket_at < cutoff }.delete
end
# Bulk form, one target per line: "slug host[:port] source_url label…".
# slug "_control" adds a control host. Returns [targets_attrs, errors].
def parse(text)
slugs = Datacenter.select_hash(:slug, :id)
attrs, errors = [], []
text.to_s.each_line.with_index(1) do |line, n|
line = line.strip
next if line.empty? || line.start_with?('#')
slug, hostport, url, *label = line.split(/\s+/)
host, port = hostport.to_s.match(/\A\[?([^\]]+?)\]?(?::(\d+))?\z/)&.captures
dc_id = slug == CONTROL ? nil : slugs[slug]
err = if slug != CONTROL && !dc_id then "unknown slug #{slug.inspect}"
elsif host.to_s.empty? then 'no host'
elsif url && !url.match?(%r{\Ahttps?://}) then 'source must be an http(s) URL'
end
if err
errors << "line #{n}: #{err}"
else
attrs << { datacenter_id: dc_id, host: host, port: (port || 443).to_i, source_url: url,
label: (label.join(' ') unless label.empty?) }
end
end
[attrs, errors]
end
end
end