Skip to content

feat(monitoring): replicate the metrics pipeline, and stop health repairs causing outages - #1430

Merged
github-actions[bot] merged 14 commits into
developfrom
jim.lin/feat/replace-monasca-1
Sep 8, 2026
Merged

github-actions[bot] merged 14 commits into
developfrom
jim.lin/feat/replace-monasca-1

Conversation

@Eandalf-Bigstack

Copy link
Copy Markdown
Collaborator

What type of PR is this?

  • bug
  • feature

What this PR does / why we need it

The next slice of the telemetry replacement: make the metrics pipeline survive a control node going away, and stop the health machinery from turning transient faults into outages.

  • Kafka topics replicate across the control nodes. Every topic was created at RF 1, so any broker loss took its partitions offline and the monitoring pipeline with them. TargetRF() is min(control nodes, 3) — not a flat 3, which would fail topic creation on a 2-control HA cluster — and it now backs RecreateTopic, UpdateCfg, and the three broker defaults (default.replication.factor, offsets.topic.replication.factor, transaction.state.log.replication.factor). kafka_topic_rf_reconcile raises existing topics, which is what carries v3.1.10-and-older clusters across the upgrade: it prunes, places by least-loaded broker, is idempotent, and defers entirely while any broker is offline
  • Thanos, so the Prometheus replicas stop being islands. Each control node scraped and stored on its own, so a node that was down answered its own gap forever and a query hit whichever replica haproxy chose. Bare-metal tarball install (v0.42.4, verified against sha256sums.txt), systemd units running as prometheus, installed disabled and turned on by hex_config like everything else. config_prometheus.cpp writes external_labels: replica: <hostname>, the sidecar/querier defaults and /etc/thanos/endpoints.yml; the querier deduplicates on replica. Thanos is HA-only — thanosEnabled = enabled && s_ha — and haproxy routes /prometheus at the three queriers on HA
  • Stop health_mysql_repair causing the outage it was sent to fix. Four defects, one shape. It fired on a galera cluster that was merely still forming, restarted mariadb on every control node to repair one, copied the whole datadir once per retry, and never pruned the copies. On accept-3cc that filled / twice — once taking every ceph mon down with ENOSPC for 8h, once blowing OpenSearch's 85% watermark and stalling log ingestion behind it
  • InfluxDB starts again past ~25G. influxd-systemd-start.sh runs influx_inspect buildtsi -compact-series-file before every start — not once, every boot and every restart — and inherits DefaultTimeoutStartSec=300s. Past the ceiling the unit never starts again: Type=forking waits, KillMode=control-group kills the compaction at the deadline, Restart=on-failure restarts it from zero, and health_influxdb_repair kills it again. A drop-in raises the ceiling to 1800s (~120G)
  • Prometheus and Thanos get health coverage, under a new MetricsDb service. Neither had any: sdk_health.sh contained no mention of either, so a dead Prometheus or a querier that had lost its peers was invisible to cluster check and to auto-repair
  • cube_remote_cluster_check compares a real hostname. There is no hostname() in the sdk, so remote_run $N "$HEX_SDK hostname" returned 1081 lines of usage into the substitution and the comparison could never match — the moderator/edge remote-check path was dead. Reported by a peer session on 3.1.10

Which issue(s) this PR fixes

Special notes for your reviewer

This PR stacks on jim.lin/feat/replace-monasca (#1428). The base here is develop, so until that merges this PR's commit list also shows #1428's five commits. Only the thirteen below belong to this PR:

  • fix(kafka): replicate topics across the control nodes, capped at three
  • feat(kafka): raise existing topics to the target replication factor
  • feat(thanos): package the thanos sidecar and querier
  • feat(prometheus): configure and run thanos on the control nodes
  • fix(haproxy): route /prometheus at the thanos queriers on HA
  • fix(health): bound the mysql datadir copy the repair takes
  • fix(health): only repair galera when a node is truly down, not merely absent
  • fix(health): restart only the mariadb nodes that need it, not the whole tier
  • fix(health): take the mysql datadir safety copy once per repair, not per attempt
  • fix(cluster): compare the real hostname in cube_remote_cluster_check
  • fix(influxdb): raise TimeoutStartSec past the per-start series rebuild
  • feat(health): check and repair prometheus and thanos
  • feat(cluster): add the MetricsDb service for prometheus and thanos

Review it after #1428, or diff against jim.lin/feat/replace-monasca.

Why the four galera fixes are one story. health_mysql_check is strict on purpose — wsrep_cluster_size equal to the control-node count, every node Synced, every unit active — and that strictness is what makes it the right gate for a roll to advance on (handbook rolling-upgrade-no-galera-health-gate.md, #651). So the check is deliberately not weakened here. The fix is in the repair: "the cluster is short a member" and "the cluster is broken" are different statements, and only the second is repairable. _health_mysql_node_state classifies each control node reachability first — asking galera whether a node is in the cluster means nothing on its own, because a node the network cannot reach was evicted correctly, and bootstrapping cannot bring it back, only destroy the cluster the survivors still have.

Why this class of bug keeps recurring. Four of the fixes here are the same failure mode: a repair fires at a system that is recovering normally, and the repair is what breaks it. Galera still forming; Synced members restarted to fix a peer; a datadir copied per attempt; a TSI rebuild killed mid-flight. The house precedent for the right answer already existed in 741f4093 (pacemaker_settled(), "the fix is to wait for quorum, not repair"). The three questions worth asking of any health_*_repair: can the check tell mid-recovery from failed, does the repair touch only the faulty member, and is its work bounded per invocation.

Why the operator is never gated. An earlier revision of this branch deferred essential-tier repair during a roll. That was backwards — cluster_check_repair_essential has no in-tree caller, so its only caller is a human typing the command, and the gate would only ever have blocked the person who asked for it. It was dropped. The roll is protected by health_mysql_repair itself declining to act on a joining node, which covers every caller.

Thanos ports and prefixes are not uniform, and the checks had to measure rather than assume. Prometheus derives --web.route-prefix from --web.external-url, so :9091/-/healthy is a 404 on a healthy server and :9091/prometheus/-/healthy is the live endpoint. Thanos keeps /-/healthy and /-/ready at the root even though the querier runs --web.route-prefix=/prometheus — only its API moves under the prefix, which is why haproxy's option httpchk GET /-/ready works and :10904/api/v1/stores is a 404 while :10904/prometheus/api/v1/stores is not.

Two auto-repairs deliberately decline. prometheus ERR 3 (up, scraping nothing) is a config or service-discovery problem a restart cannot fix, so repairing it would be a restart loop against a server answering fine. thanos ERR 4 (a peer's sidecar unreachable) is usually a node that is down; restarting the local querier does not bring a peer back, and during a roll it would restart a querier on every pass.

Companion PRs, same metricsDb service: bigstack-oss/cube-cos-openapi#111 (base), bigstack-oss/cube-cos-api#650, bigstack-oss/cube-cos-ui#867.

Additional documentation

Verified on accept-3cc (cc1/cc2/cc3, control-converged, HA) unless stated otherwise.

For the requirement "Kafka survives a control node going away",

[cc1 ~]# hex_sdk kafka_topic_rf_reconcile cc1:9095
[cc1 ~]# /opt/kafka/bin/kafka-topics.sh --bootstrap-server cc1:9095 --describe | grep -c "ReplicationFactor: 3"
111
[cc1 ~]# # one broker down, then two
[cc1 ~]# /opt/kafka/bin/kafka-topics.sh --bootstrap-server cc1:9095 --describe --unavailable-partitions | wc -l
0
[cc1 ~]# # partition balance across the three brokers
111 111 111

At RF 1 the same probe showed 2 leaderless partitions per topic with a single broker down. An auto-created topic lands at RF 3 with ISR [2,1,0], and a rolling restart of all three brokers keeps unavailable partitions at 0 throughout.

For the requirement "queries reach more than one replica",

[cc3 ~]# systemctl stop prometheus          # 4 minutes
[cc3 ~]# # samples for the same series over the gap:
cc3 prometheus (raw)            9
cc3 thanos-query                16
via cc3 haproxy /prometheus     16

The querier fills its own node's hole from its peers, which is the whole point — before Thanos, cc3 answered 9 and there was no way to get 16 out of it.

For the requirement "a galera repair only fires when a node is truly down",

[cc1 ~]# # classifier across all four states
A. healthy            NO-OP   (cc1=synced cc2=synced cc3=synced)
B. mariadb stopped    REPAIR  -> cc3 truly down (cc1=synced cc2=synced cc3=down)
C. mid-rejoin         NO-OP   (cc1=synced cc2=synced cc3=joining)
D. unknown host       -> unreachable

B is the control: a genuinely dead mariadbd on a reachable node still repairs, so #155's stale-mariadbd case is preserved. C is the fix — during the rejoin the unit sits in activating and answers no query at all for the whole state transfer, which is the longest phase of joining and the only window the old code could not see.

For the requirement "a repair never takes the healthy members with it",

[cc1 ~]# # which nodes the repair would restart
all Synced            would restart: (none)   [cc3=synced cc2=synced cc1=synced]
cc3 mariadb stopped   would restart: cc3      [cc3=down   cc2=synced cc1=synced]
after rejoin          would restart: (none)   [cc3=synced cc2=synced cc1=synced]
[cc1 ~]# # cluster intact afterwards
cc1 size=3  cc2 size=3  cc3 size=3

And the datadir safety copy is now once per repair rather than once per attempt — 1 copy whether the repair succeeds on attempt 1 or 3, re-armed on the next call, and still skipped entirely on the live-primary rejoin path (#1328).

For the requirement "prometheus and thanos are checked and repaired",

[cc1 ~]# hex_sdk health_prometheus_check ; echo rc=$?
rc=0
[cc1 ~]# hex_sdk health_thanos_check ; echo rc=$?
rc=0
[cc1 ~]# hex_sdk health_errcode_lookup prometheus 3 ; hex_sdk health_errcode_lookup thanos 4
no scrape target up
querier missing a replica
[cc3 ~]# systemctl stop thanos-sidecar
[cc1 ~]# hex_sdk -v health_thanos_check ; echo rc=$?
thanos-sidecar on cc3 is not running
rc=1
[cc1 ~]# hex_sdk health_thanos_repair ; hex_sdk health_thanos_check ; echo rc=$?
rc=0
[cc3 ~]# systemctl stop prometheus
[cc1 ~]# hex_sdk -v health_prometheus_check ; echo rc=$?
prometheus on cc3 is not running
rc=1
[cc1 ~]# hex_sdk health_prometheus_repair ; hex_sdk health_prometheus_check ; echo rc=$?
rc=0

The endpoint behaviour the checks are built on, measured rather than assumed:

[cc1 ~]# for u in 9091/-/healthy 9091/prometheus/-/healthy 10902/-/ready 10904/-/ready ; do
>   printf "%-28s %s\n" $u "$(curl -s -o /dev/null -w '%{http_code}' http://cc1:$u)" ; done
9091/-/healthy               404
9091/prometheus/-/healthy    200
10902/-/ready                200
10904/-/ready                200
[cc1 ~]# curl -s http://cc1:10904/prometheus/api/v1/stores | jq -r '.data.sidecar[] | .name'
10.1.0.1:10901
10.1.0.2:10901
10.1.0.3:10901

For the requirement "the MetricsDb service is reported",

$ g++ -fsyntax-only -Wall -Werror -std=c++17 ... core/modules/cli_cluster.cpp   # rc=0
$ ./xm    # standalone X-macro expansion after the edit
TOP=29  S[Metrics]=Metrics  S[MetricsDb]=MetricsDb  S[Node]=Node

For the requirement "influxdb starts past the 300s ceiling" — verified on cube4510, not accept-3cc, because that is where the large datadirs are:

[cube451 ~]# systemctl show influxdb -p TimeoutStartUSec
TimeoutStartUSec=30min
[cube451 ~]# systemd-analyze verify influxdb.service     # clean
[cube451 ~]# # rebuild time by datadir size
21G  ->  3m26s   (cube451)
25G  ->  6m12s   (cube452)

cube452 had been restart-looping indefinitely after its 3.1.0 → 3.1.10 upgrade, taking Notifications and Metrics down with it; with the drop-in it completed in 6m12s and the bootstrap ran through to 83/83 modules committed.

For the requirement "the remote cluster check compares a real hostname",

[cc1 ~]# # old form vs fixed form, per node
cc1  old=[Usage: hex_sdk <option> <fun]  match=no   |  new=[cc1]  match=YES
cc2  old=[Usage: hex_sdk <option> <fun]  match=no   |  new=[cc2]  match=YES
cc3  old=[Usage: hex_sdk <option> <fun]  match=no   |  new=[cc3]  match=YES
[cc1 ~]# hex_sdk remote_run cc2 "hex_sdk hostname" | wc -lc
1081   23652

Audited every $HEX_SDK <word> invocation in the tree against the 1079 defined sdk function names; this was the only genuine miss. Note the usage listing goes to stdout, so inside $( ) it is captured rather than printed — a control node returns at the is_control_node guard above it and shows nothing, which is why cluster check there stays at 28 clean lines.

@Eandalf-Bigstack
Eandalf-Bigstack requested a review from a team as a code owner September 5, 2026 10:08
Comment thread core/modules/config_prometheus.cpp Fixed
Comment thread core/modules/config_prometheus.cpp Fixed
Comment thread core/modules/config_prometheus.cpp Fixed

@SekiXu SekiXu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

Checked the things that would only surface at build or run time, since CI here is CodeQL/DCO with no build gate:

  • config_prometheus.cpp uses hex_string_util::split without including <hex/string_util.h>. It resolves transitively through hex/config_tuning.h, the same way config_apache2.cpp and config_cinder.cpp already do, so this compiles.
  • TargetRF(): GetClusterSize() returns 1 when !ha (core/cube_sdk_library/src/cluster.cpp:95), so non-HA keeps RF 1 and matches the old ha ? 2 : 1; HA gets min(ctrlAddrs, 3).
  • kafka_target_rf()s cubectl node list -r control | wc -l matches the four existing uses of that idiom in sdk_health.sh / sdk_ceph.sh, so no off-by-one from a header row.
  • The Grafana datasource move to http://localhost/prometheus works: both cube_cos_http and cube_cos_https already route path_beg /prometheus at prometheus_backend.
  • option httpchk GET /-/ready is the same form ceph_prometheus_ep already uses in the srvlist table.

The _health_mysql_repairable gate is the part I most wanted to see here. Deciding reachable/joining/synced/down before anything gets restarted, and testing reachability first so a correctly evicted node cannot trigger a bootstrap, is the right way round.

@Eandalf-Bigstack Eandalf-Bigstack added the done Merge the pull request label Sep 8, 2026
Eandalf-Bigstack and others added 14 commits September 8, 2026 17:53
Upstream's sample server.properties ships default.replication.factor,
offsets.topic.replication.factor and transaction.state.log.replication.factor at
1 and config_kafka never overrode them, so every topic Kafka created for itself
was born unreplicated -- __consumer_offsets above all, which is why losing one
broker of three also lost consumer offsets and made kafka-consumer-groups
--describe time out instead of reporting the outage. Topic creation asked for
2 where it asked at all.

TargetRF is one replica per control node, capped at 3, and both the broker
defaults and --create now use it. Three rather than two because kafka's data
directory is not carried across the A/B partition switch -- there is no
CONFIG_MIGRATE here -- so an upgraded node rejoins with an empty log dir and
re-replicates every partition it holds. At RF 2 the partitions that node led sit
on a single good copy for the length of that catch-up, once per node of a
rolling upgrade; at RF 3 two copies remain throughout.

Capped rather than fixed at 3 because cubesys.control.addrs decides the size and
a 2-control-node HA is expressible -- the zookeeper block already guards for
csize < 3 -- and asking for more replicas than brokers fails outright.

Two settings are deliberately left alone. auto.create.topics.enable stays true:
oslo.messaging names notification topics by priority, so notifications.warn and
notifications.error appear the first time a service emits at that level and are
not in the managed list. notifications.warn is present on accept-3cc today;
turning auto-create off would silently drop it, and default.replication.factor
is what makes it arrive replicated instead. min.insync.replicas stays 1: this is
a 5-minute, 64MB transport buffer, so availability beats durability, and at
min.insync 2 a two-broker loss would fail every produce.

Verified on accept-3cc: an auto-created topic lands at RF 3 with ISR [2,1,0],
and a rolling restart of all three brokers reports 0 unavailable partitions
throughout. On jim-1cc TargetRF is 1 and nothing changes.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The broker settings bind only when a topic is created, so on their own they fix
a fresh install and nothing else. kafka-topics.sh --create --if-not-exists is a
no-op on a topic that already exists, and the --alter beside it moves partitions,
never replicas -- so every cluster that has already run keeps whatever RF its
topics were born with.

That covers more than 3.1.10-and-older upgrades. A rolling upgrade hits it too:
the node being upgraded loses its zookeeper database with the partition switch,
rejoins the ensemble, and resyncs the old RF-1 topic definitions back from the
two nodes that have not rolled yet. So the topics are already there, at the wrong
RF, on exactly the path that is supposed to be introducing the right one.

Raising RF in place is a partition reassignment, so kafka_topic_rf_reconcile
builds one. It keeps the existing replicas -- the leader stays first, so no
leadership churn -- and fills the rest from the least-loaded broker not already
holding the partition, which is what keeps the new followers spread. On
accept-3cc it moved every topic from RF 1 to RF 2 in 37s and later to RF 3 in
16s, ending at 111/111/111 replicas per broker.

Three guards make it safe to call from UpdateTopics, which every control node
runs: it returns immediately when the target is 1, defers with a warning unless
every broker is online -- a reassignment naming a dead broker never completes and
blocks the next one -- and skips partitions already at the target, so the second
and third node find nothing to do. Bounded at 600s from the config module and
non-fatal either way.

Verified on accept-3cc at RF 3: with two of three brokers stopped there are 0
unavailable partitions and every topic still produces and consumes, which RF 2
cannot do; kafka-consumer-groups --describe answers during the outage instead of
timing out as it did while __consumer_offsets was RF 1. Second run is a 7s no-op.
On jim-1cc the target is 1, the call returns in under a second and all 11 topics
stay at RF 1.

Note this does not buy two-node fault tolerance: zookeeper is a 3-node ensemble,
so losing two nodes stops kafka whatever the RF. The two-broker test above kept
zookeeper running on all three.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Prometheus runs as three independent replicas that never exchange data --
prometheus.yml has no remote_write, remote_read, federation or rule_files at all.
While a node is down that is honest enough, the target reads up=0. The problem is
the node that comes back: its own TSDB keeps a permanent hole for the outage, and
whoever queries it serves that hole with no indication anything is missing.

Thanos closes it without a second storage system. The sidecar exports each node's
Prometheus over the Store API; the querier fans out to all of them and
deduplicates by replica, so a gap in one copy is answered from the others.

Sidecar and querier only. Object storage -- store gateway, compactor -- buys
retention beyond what Prometheus already keeps locally, which is a different
problem from replicas being islands, so it is left out.

Installed from the upstream release tarball. There is no rpm anywhere: thanos
publishes tarballs only, and the packagecloud repo that carried a 0.37.2 build
was deleted from the tree with the prometheus move for being stale. Checksum
verified against the release's own sha256sums.txt -- weaker than kafka's
cross-host KEYS, but it is what upstream offers, and the download still lands on
.part until the digest matches.

Both units run as prometheus rather than adding a system user, and --tsdb.path
already points at that user's data directory, which is what an object-storage
upload would need if one is ever added. Installed disabled; config_prometheus
enables them per role.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generates everything the two units read and starts them per role, so the
querier answers from all three Prometheus replicas instead of whichever one the
caller happened to reach.

prometheus.yml gains an external_labels block carrying the node's hostname as
the replica label. That is not decoration: the sidecar refuses to start against
a Prometheus with no external labels at all, and replica is the label the
querier strips when it deduplicates -- which is why a query through thanos
returns exactly the series a raw prometheus did, with no extra label leaking
into the result. It is written unconditionally, so enabling HA later needs no
prometheus config change, and on a single node it is one label nobody matches.

The sidecar endpoint list comes from cubesys.control.addrs, which this module
already observes, so a control-membership change re-commits and rewrites it --
no cron, unlike the lachesis compute list, whose membership re-commits nothing.
The file is thanos's own EndpointConfig schema, not prometheus file_sd: a bare
list of targets parses cleanly and then discovers nothing at all, which is a
silent way to end up with a querier that answers from no stores.

Two more shapes worth recording in the generated flags. The sidecar reaches
prometheus at /prometheus because --web.external-url puts every endpoint behind
that prefix. The querier serves under the same prefix so haproxy's route and
grafana's datasource keep working when the backend moves to it -- but thanos
keeps /-/ready at the root regardless, which is what a health check has to use.
The querier is also moved off 10901/10902 because the sidecar already has them.

Both stay off unless the node is a control node and HA is on: with one replica
the querier would fan out to a single sidecar and dedupe nothing.

Verified on accept-3cc: each querier discovers all three sidecars with distinct
replica labels, and after stopping cc3's prometheus for four minutes its own
TSDB holds 9 samples of a series over the window where its peers hold 15 -- while
its querier answers 16. On jim-1cc nothing starts and prometheus is unchanged
apart from the label.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
prometheus_backend had exactly one server, 127.0.0.1:9091, and grafana's
datasource named the same port directly. So every query was answered by one
node's Prometheus -- whichever node the caller reached -- with no load balancing
across the replicas and no deduplication. The queriers the previous commit
starts would have sat there unasked.

On HA the route now goes to all three queriers on 10904. Each one fans out to
every control node's sidecar, so the answer no longer depends on which node
holds the VIP, and listing all three means losing a querier fails the route over
rather than blanking it. Health-checked on /-/ready, which thanos keeps at the
root even though it serves the UI and API under /prometheus. Non-HA is
unchanged and still points straight at the local Prometheus.

Grafana's datasource moves to the node's own haproxy rather than a fixed port,
so one provisioning file covers both shapes and follows whatever /prometheus is
wired to. It also means a dead local querier fails over with the route instead
of blanking the datasource.

This is the part that closes the silent-degradation hole. Measured on accept-3cc
after stopping cc3's prometheus for four minutes: counting stored samples of a
series over the following fifteen, cc3's own Prometheus holds 9 where its peers
hold 15 -- and through cc3's haproxy, the exact case of the VIP landing on the
node that was down, /prometheus now answers 16. All three queriers report UP in
the backend and grafana provisions with no errors.

Worth knowing when checking this by hand: query_range hides it. Its five-minute
lookback fills a four-minute gap and reports the same count everywhere;
count_over_time on stored samples is what shows the hole.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
_health_mysql_repair copies the whole datadir before bootstrapping a fresh
galera cluster, health_mysql_repair calls it in a for i in 1 2 3 loop, the health
machinery can call that loop again, and nothing ever pruned the copies. So the
repair's cost is unbounded in the one resource whose exhaustion it cannot survive.

It has now filled accept-3cc twice. Both times six copies per node with timestamps
a minute apart -- about 100GB across the cluster -- and / at 100%. In August the
ceph mons died with ENOSPC and stayed down eight hours. This week the mons held on
but ceph went HEALTH_ERR, opensearch blew its 85% disk watermark and turned red
with an unassigned primary, and logstash's output stalled behind it: log_transformer
lost every consumer, sat at 2.9M lag, and no logs-<today>-* index was created at
all. The visible symptom was two components away from the cause. It also cost the
database: ENOSPC truncated ib_logfile0 on two of three nodes, so they needed a
full SST to come back.

Two bounds now. Prune first, so at most one copy exists; and skip the copy when
free space is under twice the datadir, which is exactly the state the loop creates
for its own later attempts. When it skips, the repair still runs -- the copy is a
safety net, and refusing to fix a database because the disk is full would be its
own kind of wrong.

Done as a per-node loop rather than one cmd -c, so each node is judged against its
own disk, and so the skip can be reported with log_error: cmd ships a bare string
over ssh, where the sdk's log helpers are not defined.

Checked both branches on the live cluster: a 4364MB datadir with 661MB free is
correctly identified as no-room and skipped, where the unbounded version would have
taken a seventh copy; with 24GB free it copies as before.

Refs #672

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… absent

health_mysql_check is strict on purpose -- wsrep_cluster_size equal to the
control-node count, every node Synced, every mariadb unit active -- and it must
stay that way: that strictness is what makes it the right gate for a roll to
advance on (handbook rolling-upgrade-no-galera-health-gate.md, #651). But 'the
cluster is short a member' and 'the cluster is broken' are different statements,
and only the second is repairable. The repair did not distinguish them, so on
every boot -- PostBootRecovery -> cluster_check_repair_async -> the mysql entry
of the full check_repair suite, whose only readiness gate is cube_cluster_ready,
three marker files that say nothing about wsrep -- it killed mariadb
cluster-wide, copied every datadir and bootstrapped a new cluster against a
galera cluster that was simply still assembling itself.

Decide in the repair, not the check. _health_mysql_node_state classifies each
control node reachability-first: unreachable | joining | synced | down. Testing
whether a node is 'in the cluster' means nothing on its own -- a node the
network cannot reach was evicted correctly, and bootstrapping cannot bring it
back, only destroy the cluster the survivors still have. 'joining' covers
Joining/Joined/Donor/Desynced and a unit still in 'activating', since a joiner
receiving an SST answers no query for the whole transfer. The repair runs only
when some node is down: reachable, but neither in the cluster nor joining.

Preserves #155 (a stale mariadbd on a reachable node still repairs) and #1328
(the live-primary check is untouched).

Verified on accept-3cc: healthy -> no-op; mariadb stopped on a reachable cc3 ->
down, repair fires; mid-rejoin -> joining, no-op; unknown host -> unreachable.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…le tier

health_mysql_repair opened with a blanket 'cmd -cor systemctl restart mariadb'
across every control node. So a single failed member took the entire galera
cluster down with it: the survivors were Synced and serving, and got killed to
repair a peer. During a roll that is the difference between a cluster short one
node and no cluster at all -- and it fires even when the repair is otherwise
warranted, so the gate added in the previous commit does not cover it.

Pick the nodes from _health_mysql_node_state: skip any node already Synced, and
any the network cannot reach (it cannot be restarted anyway, and its absence is
a power or network fault that galera has already handled correctly). Same
command, same reverse order, one at a time. This matches what the repair's own
live-primary path already does -- it rejoins only the non-Synced nodes.

Verified on accept-3cc: all Synced selects no node; mariadb stopped on cc3
selects cc3 alone, leaving cc1 and cc2 untouched; after rejoin the selection is
empty again and all three report wsrep_cluster_size 3.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…per attempt

health_mysql_repair calls _health_mysql_repair up to three times and every
attempt bootstraps from the same datadir, so the second and third copies capture
state the first already has. They cost another full datadir of disk and several
GB of I/O against a database that is trying to start, and buy nothing. 29e3279
capped what was kept on disk; this stops making them in the first place.

_health_mysql_repair takes the attempt number as its first argument and copies
only on the first, so the decision travels with the call rather than through a
flag two functions have to keep in step. It defaults to 1, so a direct
'hex_sdk _health_mysql_repair' still takes its copy. The live-primary rejoin
path returns before the copy as it always has, so #1328's path is unchanged.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
There is no hostname() in the sdk, so 'remote_run $N "$HEX_SDK hostname"'
returned 1081 lines of hex_sdk usage -- 23652 bytes -- into the command
substitution, and the comparison against $N could never match. The elif branch
was dead, and with it the whole moderator/edge path that hands the cluster check
to a real control node. Control nodes never noticed: they return at the
is_control_node guard above it.

Use plain hostname. Audited every $HEX_SDK invocation in the tree against the
1079 defined sdk functions; this was the only genuine miss.

Verified on accept-3cc: old form matches on none of cc1/cc2/cc3, fixed form
matches on all three; cube_remote_cluster_check still returns 1 with no output
on a control node and 'cluster check' stays at 28 clean lines.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
influxd-systemd-start.sh runs `influx_inspect buildtsi -compact-series-file`
before it launches influxd, unconditionally, on every boot and every restart --
not as a one-off migration. The compaction is O(data on disk), and
core/heavyfs/Makefile sets DefaultTimeoutStartSec=300s, which influxdb inherits,
so the ceiling is only ~20-25G of retained metrics.

Past that ceiling the unit does not just start slowly, it never starts again:
Type=forking keeps systemd waiting on the wrapper, KillMode=control-group tears
influx_inspect down mid-compaction at the deadline, and Restart=on-failure then
restarts the rebuild from the beginning. Nothing in the loop makes progress and
nothing bounds it. health_influxdb_repair compounds it, since its
remote_systemd_restart kills a rebuild that was partway through.

1800s covers ~120G at the slower measured rate. Erring large is the safe
direction: config_influxdb's SystemdCommitService runs `systemctl start`
synchronously with retry=false, so a wedged influxd stalls that one commit until
the timeout and is then reported -- bounded and visible, unlike a silent
unbounded restart loop.

Verified on cube4510: 21G of /var/lib/influxdb rebuilds in 3m26s (cube451),
25G in 6m12s (cube452). cube452 restart-looped indefinitely after its
3.1.0 -> 3.1.10 upgrade, taking Notifications and Metrics down; with the drop-in
it completed in 6m12s and the bootstrap ran through to 83/83 modules committed.
systemd parses the drop-in to TimeoutStartUSec=30min and `systemd-analyze verify
influxdb.service` is clean.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither had any health coverage -- sdk_health.sh contained no mention of
either -- so a dead prometheus or a thanos querier that had lost its peers was
invisible to 'cluster check' and to auto-repair.

prometheus is probed under its route prefix: config_prometheus sets
--web.external-url=http://localhost/prometheus/, so :9091/-/healthy is a 404 on
a healthy server and :9091/prometheus/-/healthy is the live endpoint. Beyond
liveness it checks that at least one scrape target is up, since a server that is
up and scraping nothing reports healthy while monitoring nothing.

thanos is HA-only (config_prometheus gates both units on enabled && s_ha), so
the check reports non-HA and passes on a single-control cluster. Unlike
prometheus, thanos keeps /-/healthy and /-/ready at the root and moves only its
API under the prefix. The last check asks the querier how many sidecars it can
see: a querier that has lost its peers passes every other probe while serving
one node's view of the cluster, which defeats the point of running thanos.

Auto-repair declines the two faults a restart cannot fix: prometheus ERR 3
(up, scraping nothing -- config or service discovery) and thanos ERR 4 (a peer's
sidecar unreachable -- usually a node that is down, and during a roll it would
restart a querier on every pass).

Verified on accept-3cc: healthy rc=0 for both; thanos-sidecar stopped on cc3 ->
rc=1 'thanos-sidecar on cc3 is not running' -> repair -> rc=0; prometheus
stopped on cc3 -> rc=1 -> repair -> rc=0; errcode lookups resolve.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Surfaces the new health checks in 'cluster check' and 'check_repair' as their
own service rather than folding them into Metrics, which is collection and
visualisation. influxdb and kapacitor stay under Notifications for now:
kapacitor is a write proxy and alerting engine here, not only storage, so
moving them is a separate decision with its own API and UI blast radius.

Added to both SRVS and EDGE_SRVS, with prometheus and thanos as repairable
comps. Matches the metricsDb service now landed in cube-cos-openapi,
cube-cos-api and cube-cos-ui.

Verified by compiling cli_cluster.cpp under -Wall -Werror, and by expanding the
X-macro standalone: TOP=29 with S[MetricsDb] resolving to MetricsDb.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL flagged all three fopen calls in WriteThanosConf as
cpp/world-writable-file-creation (high) -- the mode is left to the process
umask rather than stated. Same finding, same fix as 61d0ed8 did for
config_apache2: WriteFile() from filesystem.hpp opens with an explicit
S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH, which is the only shape of this alert the
repo has ever cleared.

Output is byte-identical: the old fprintf path and the new vector<string> path
were compiled side by side and their output diffed, matching exactly for the
endpoints list and both ARGS lines. Existing files keep their current mode
either way, since O_CREAT only applies the mode on creation -- so an upgrade
sees no permission change.

Verified in the build jail per the hex_config gate: clean CXX
config_prometheus.o under -Wall -Werror and a clean LD hex_config, with the
three generated literals still present in the object.

Signed-off-by: Jim Lin <jim.lin@bigstack.co>
Co-authored-by: Eandalf <clinah@connect.ust.hk>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Eandalf-Bigstack
Eandalf-Bigstack force-pushed the jim.lin/feat/replace-monasca-1 branch from b9416d3 to 7ecd4ac Compare September 8, 2026 09:54
@Eandalf-Bigstack Eandalf-Bigstack added done Merge the pull request and removed done Merge the pull request labels Sep 8, 2026
@github-actions
github-actions Bot merged commit 7ecd4ac into develop Sep 8, 2026
9 checks passed
@github-actions
github-actions Bot deleted the jim.lin/feat/replace-monasca-1 branch September 8, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

done Merge the pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants