mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
## Summary
Relay pods can stall in the `mesh_boot` startup step, and the mesh peer
table grows without limit. One prod pod held 7,793 peer records. Most of
those records belong to pods that no longer exist.
**Cause.** Every boot gets a fresh runtime id, so a restarted pod leaves
its old record behind. Nothing ever removed records, and scuttlebutt
gossip kept spreading the dead ones around the fleet. Each reconcile
pass, including the first one inside `boot_mesh`, then dials every
record one at a time.
This PR makes dead records expire. It also adds the measurements we need
to see the next stall directly.
### Peer-table lifetime (`crates/buzz-relay-mesh/src/membership.rs`)
- A peer record expires 5 minutes after its last heartbeat
(`DEFAULT_PEER_RECORD_TTL`). For comparison, live runtimes refresh every
2s, and the Redis ready registry expires a silent runtime after 45s.
- What happens to expired records:
- They are **rejected on arrival**, so peers that haven't been upgraded
can't re-add them.
- They are **left out** of digests, deltas, and dial candidates.
- They are **pruned** at the start of each reconcile pass.
- Between prunes, they are not admitted (`has_peer`) and not routed
(`peers`, `peer_has_capability`).
- An expired entry that hasn't been pruned yet counts as absent, so any
fresh record replaces it whatever its version. That lets a live peer's
v1 Redis hint re-admit it before the next prune.
- **The TTL never prunes or hides a peer with a live connection**, even
if its heartbeat is stale. Its gossip can stop arriving while the
transport stays up: a dead control stream, or a clock behind by more
than the TTL. The connection proves the runtime is alive. Its stale
record is still kept out of digests and dials. (The existing phi filter
in `peers()` still drops a connected peer once its gossip stalls; this
PR doesn't change that.)
- **`Connected` tracks the transport map.** Because the TTL exemption
trusts that flag, `install_peer` and `remove_peer` write it while
holding the peers lock, so a connection that closes during install
always ends `Disconnected`. A dial's `Connecting`/`Disconnected` writes
are skipped when a connection is already installed, so a failed outbound
dial can't overwrite a live inbound connection. Each reconcile pass also
re-syncs the state from the transport map.
- **Gossip no longer sets connection state.** Before, any gossiped
record was marked `Connected`, even if this pod had never dialed it.
That inflated the `/_mesh` counts. Now a record learned from gossip or
the ready registry enters as `Disconnected`. Only the real connect and
disconnect paths change the state.
- Before building its candidate list, reconcile now skips peers that are
already connected. A pass dials serially, and a dial to a dead runtime
can take about a minute, so `dial_peer` re-reads the record before every
address attempt. It stops once the record has expired or started
draining, or once a connection has landed.
### Instrumentation
- **`mesh_boot` sub-steps** use the existing startup-step gauges:
`mesh_bind`, `mesh_publish`, and `mesh_initial_reconcile`. A pod stuck
in `mesh_boot` now also shows which await it is stuck on.
- **New metrics** (`crates/buzz-relay-mesh/src/telemetry.rs`):
| Metric | Shows |
|---|---|
| `buzz_mesh_dial_total{outcome}` / `buzz_mesh_dial_duration_seconds` |
Cost of each dial |
| `buzz_mesh_reconcile_pass_seconds` | Full pass time |
| `buzz_mesh_reconcile_candidates` | Dials planned for this pass (set
after the registry scan) |
| `buzz_mesh_peer_records{state,draining}` | Table size |
| `buzz_mesh_peer_record_max_age_seconds` | Oldest heartbeat |
| `buzz_mesh_peer_records_expired_total{reason=pruned\|rejected}` |
Expiry activity |
- The dial and pass histograms get buckets up to 600s. The default
`_seconds` buckets stop at 5s, which would hide exactly the stall these
metrics are meant to show.
### CI
The `buzz-relay-mesh` tests and the `buzz-relay` `mesh_boot::tests` did
not run in any CI lane. This PR adds both to `just test-unit`, and adds
the matching stanzas to `scripts/run-tests.sh`.
### What got simpler
- The peer table now has a defined size, about one record per live pod,
where before it grew forever.
- One wrong state write was deleted (gossip marking records
`Connected`).
- Two test suites that silently never ran now run.
### Rollout notes
- Rejecting expired records works one pod at a time. During a rollout,
pods that haven't been upgraded keep gossiping dead records. Upgraded
pods reject them, which shows up as `reason="rejected"` on the expired
counter.
- A pod whose clock runs more than 5 minutes behind has all of its
gossip rejected. It stays reachable through its Redis ready record and
any live connection. A `reason="rejected"` count that keeps climbing
outside a rollout points to that skew.
- Upgraded pods leave expired records out of their digests. Pods that
haven't been upgraded therefore resend the whole ghost set (about 7.8k
records, roughly 1 MB) on every 2s gossip tick, on each old↔new
connection. So during the rollout, `reason="rejected"` will climb by
about the ghost count per tick per connection. This is expected and
stops when the rollout finishes.
### Not in this PR
The boot dial itself is still unbounded: it runs serially, has no
per-dial timeout, and dials gossip-learned records. Bounding it changes
how a pod joins the mesh, so it goes in a separate PR, and these metrics
will measure it.
### Related issue
None found.
### Testing
- New tests:
- `membership::tests`: expired gossip is rejected; prune drops only
expired records; prune keeps a connected peer with a stale heartbeat;
expired unconnected records are neither admitted nor routed before a
prune; a fresh record replaces an expired, unpruned entry whatever its
version; expired records are neither dialed nor gossiped before a prune;
gossip never sets connection state; table stats.
- `runtime::tests::reconcile_pass_publishes_dial_and_peer_table_metrics`
- `runtime::tests::reconcile_pass_prunes_expired_peer_records` (binds
the prune to the reconcile pass)
-
`runtime::tests::dial_skips_candidate_that_expired_after_the_pass_snapshot`
-
`runtime::tests::finishing_dial_does_not_overwrite_a_connection_that_landed`
-
`runtime::tests::connection_closed_during_install_ends_disconnected_and_expires`
-
`runtime::tests::reconcile_pass_resyncs_connection_state_from_transport`
- `mesh_boot::tests::mesh_boot_sub_steps_name_the_step_that_failed`
- Blox at `e06f55a4` (tree `866add1c`): `just fmt-check`, `just clippy`,
`buzz-relay-mesh` nextest 3x, and the `buzz-relay`
`mesh_boot`/`startup_steps`/`metrics` tests all pass. Three of the new
tests fail against a mutant that removes the fix they pin (dial ignores
expiry; dial state writes ignore the transport map; no reconcile
resync).
---------
Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
150 lines
5.8 KiB
Rust
150 lines
5.8 KiB
Rust
//! Prometheus metrics for the mesh reconcile loop and peer table.
|
|
//!
|
|
//! These answer two operational questions without exec-ing into a pod:
|
|
//!
|
|
//! - **Where is a reconcile pass spending its time?** A pass first rescans the
|
|
//! ready registry, then dials every peer it is not yet connected to, one at
|
|
//! a time. `buzz_mesh_reconcile_candidates` is set once the scan finishes,
|
|
//! so a pass stuck before it is stuck in the scan; the dial counter and
|
|
//! histogram show progress and cost inside the dial loop.
|
|
//! - **How big and how stale is the peer table?** `buzz_mesh_peer_records`
|
|
//! and `buzz_mesh_peer_record_max_age_seconds` are refreshed every pass;
|
|
//! `buzz_mesh_peer_records_expired_total` counts records dropped for age.
|
|
//!
|
|
//! Label values are closed vocabularies: `outcome` ∈ {`ok`, `error`},
|
|
//! `state` ∈ [`ConnectionState`] in snake_case, `draining` ∈ {`true`,
|
|
//! `false`}, `reason` ∈ {`pruned`, `rejected`}.
|
|
|
|
use std::time::Duration;
|
|
|
|
use crate::status::ConnectionState;
|
|
|
|
/// Gauge: peers the current reconcile pass will dial.
|
|
pub const RECONCILE_CANDIDATES: &str = "buzz_mesh_reconcile_candidates";
|
|
/// Histogram: wall-clock duration of one full reconcile pass.
|
|
pub const RECONCILE_PASS_SECONDS: &str = "buzz_mesh_reconcile_pass_seconds";
|
|
/// Counter: outbound connect attempts by `outcome`.
|
|
pub const DIAL_TOTAL: &str = "buzz_mesh_dial_total";
|
|
/// Histogram: duration of one outbound connect attempt.
|
|
pub const DIAL_DURATION_SECONDS: &str = "buzz_mesh_dial_duration_seconds";
|
|
/// Gauge: peer-table records by effective `state` and `draining`.
|
|
pub const PEER_RECORDS: &str = "buzz_mesh_peer_records";
|
|
/// Gauge: age of the oldest peer-table heartbeat, in seconds.
|
|
pub const PEER_RECORD_MAX_AGE_SECONDS: &str = "buzz_mesh_peer_record_max_age_seconds";
|
|
/// Counter: records dropped for an expired heartbeat, by `reason`.
|
|
pub const PEER_RECORDS_EXPIRED_TOTAL: &str = "buzz_mesh_peer_records_expired_total";
|
|
|
|
/// Every effective connection state, in `state` label order.
|
|
pub(crate) const CONNECTION_STATES: [ConnectionState; 4] = [
|
|
ConnectionState::Disconnected,
|
|
ConnectionState::Connecting,
|
|
ConnectionState::Connected,
|
|
ConnectionState::Suspect,
|
|
];
|
|
|
|
/// Register metric descriptions. Call once after the global recorder exists.
|
|
pub fn describe_metrics() {
|
|
metrics::describe_gauge!(
|
|
RECONCILE_CANDIDATES,
|
|
"Mesh peers the current reconcile pass will dial (set after the registry scan)"
|
|
);
|
|
metrics::describe_histogram!(
|
|
RECONCILE_PASS_SECONDS,
|
|
metrics::Unit::Seconds,
|
|
"Wall-clock duration of one mesh reconcile pass (registry scan plus dials)"
|
|
);
|
|
metrics::describe_counter!(
|
|
DIAL_TOTAL,
|
|
"Outbound mesh connect attempts, by outcome (ok, error)"
|
|
);
|
|
metrics::describe_histogram!(
|
|
DIAL_DURATION_SECONDS,
|
|
metrics::Unit::Seconds,
|
|
"Duration of one outbound mesh connect attempt"
|
|
);
|
|
metrics::describe_gauge!(
|
|
PEER_RECORDS,
|
|
"Mesh peer-table records, by effective connection state and draining flag"
|
|
);
|
|
metrics::describe_gauge!(
|
|
PEER_RECORD_MAX_AGE_SECONDS,
|
|
metrics::Unit::Seconds,
|
|
"Age of the oldest heartbeat in the mesh peer table"
|
|
);
|
|
metrics::describe_counter!(
|
|
PEER_RECORDS_EXPIRED_TOTAL,
|
|
"Mesh peer records dropped for an expired heartbeat (pruned from the table, or rejected on arrival)"
|
|
);
|
|
}
|
|
|
|
pub(crate) const fn state_label(state: ConnectionState) -> &'static str {
|
|
match state {
|
|
ConnectionState::Disconnected => "disconnected",
|
|
ConnectionState::Connecting => "connecting",
|
|
ConnectionState::Connected => "connected",
|
|
ConnectionState::Suspect => "suspect",
|
|
}
|
|
}
|
|
|
|
/// Position of `state` in [`CONNECTION_STATES`].
|
|
const fn state_index(state: ConnectionState) -> usize {
|
|
match state {
|
|
ConnectionState::Disconnected => 0,
|
|
ConnectionState::Connecting => 1,
|
|
ConnectionState::Connected => 2,
|
|
ConnectionState::Suspect => 3,
|
|
}
|
|
}
|
|
|
|
const fn draining_label(draining: bool) -> &'static str {
|
|
if draining {
|
|
"true"
|
|
} else {
|
|
"false"
|
|
}
|
|
}
|
|
|
|
/// Peer-table shape at one instant, published as gauges.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct PeerTableStats {
|
|
/// `counts[state][draining as usize]`, indexed like [`CONNECTION_STATES`].
|
|
pub counts: [[u64; 2]; CONNECTION_STATES.len()],
|
|
/// Oldest heartbeat age; zero for an empty table.
|
|
pub max_age: Duration,
|
|
}
|
|
|
|
impl PeerTableStats {
|
|
pub(crate) fn record(&mut self, state: ConnectionState, draining: bool, age: Duration) {
|
|
self.counts[state_index(state)][usize::from(draining)] += 1;
|
|
self.max_age = self.max_age.max(age);
|
|
}
|
|
|
|
/// Set every `(state, draining)` series, zeros included, so a series
|
|
/// that empties reads 0 instead of holding its last non-zero value.
|
|
pub(crate) fn publish(&self) {
|
|
for (index, state) in CONNECTION_STATES.iter().enumerate() {
|
|
for draining in [false, true] {
|
|
metrics::gauge!(
|
|
PEER_RECORDS,
|
|
"state" => state_label(*state),
|
|
"draining" => draining_label(draining),
|
|
)
|
|
.set(self.counts[index][usize::from(draining)] as f64);
|
|
}
|
|
}
|
|
metrics::gauge!(PEER_RECORD_MAX_AGE_SECONDS).set(self.max_age.as_secs_f64());
|
|
}
|
|
}
|
|
|
|
pub(crate) fn record_dial(ok: bool, elapsed: Duration) {
|
|
let outcome = if ok { "ok" } else { "error" };
|
|
metrics::counter!(DIAL_TOTAL, "outcome" => outcome).increment(1);
|
|
metrics::histogram!(DIAL_DURATION_SECONDS).record(elapsed.as_secs_f64());
|
|
}
|
|
|
|
pub(crate) fn record_expired(reason: &'static str, count: u64) {
|
|
if count > 0 {
|
|
metrics::counter!(PEER_RECORDS_EXPIRED_TOTAL, "reason" => reason).increment(count);
|
|
}
|
|
}
|