Phase 5j: Prometheus /metrics endpoint

Adds a tiny axum-served HTTP endpoint that exposes the same
CacheMetrics counters that back GetMetrics + gossip, in Prometheus
text exposition format (v0.0.4). Enable per node by setting
`cluster.prom_bind` in the daemon config.

* metrics.rs: MetricsReply::to_prometheus() emits one HELP + TYPE +
  sample line per counter. started_unix is a gauge; everything else is
  a counter. Preallocates ~1 KiB so no reallocs mid-format.
* prom.rs (new): PromServer::bind spins up axum on a TcpListener,
  serves GET /metrics, returns 404 elsewhere. Graceful shutdown via
  oneshot channel; abort() variant for the sync-drop path in
  ClusterServices. Snapshots on every scrape (no cache) — Relaxed
  atomic loads are cheap enough that even 1 Hz is sub-microsecond.
* config.rs: new optional `cluster.prom_bind: SocketAddr` field.
  Default None means no server; typical value is 127.0.0.1:7702.
* services.rs: wires PromServer into ClusterServices when both a
  router and prom_bind exist. Warns (doesn't fail) if prom_bind is set
  without RPC — nothing would ever change on a scrape.

+8 tests:
- metrics: to_prometheus emits every counter with correct type; zeros
  still produce valid exposition (fresh daemon scrape)
- prom: content-type is text/plain; version=0.0.4; live updates
  between requests (no cache); unknown paths 404; shutdown stops
  serving
- services: end-to-end scrape returns router-driven counters;
  prom_addr() is None when unconfigured (no accidentally-leaked port)

Raw-TCP HTTP client in tests instead of pulling in reqwest — 30 lines
of tokio::net + string split for GET / read-to-close is small enough
to justify not adding a dep.

249 tests pass (+8 from Phase 5i). Pre-existing macOS `du -sb` failure
unchanged.
This commit is contained in:
Omar Sobh
2026-07-12 04:36:34 -07:00
parent 29be728089
commit 523b22f148
6 changed files with 665 additions and 108 deletions
+5 -8
View File
@@ -19,6 +19,7 @@ pub mod build_cache;
pub mod client_config; pub mod client_config;
pub mod gossip; pub mod gossip;
pub mod metrics; pub mod metrics;
pub mod prom;
pub mod refs; pub mod refs;
pub mod rpc; pub mod rpc;
pub mod services; pub mod services;
@@ -198,10 +199,8 @@ mod tests {
let ts = spawn_accepting_listener().await; let ts = spawn_accepting_listener().await;
// Short LAN timeout so the test runs fast even if the OS holds the // Short LAN timeout so the test runs fast even if the OS holds the
// failed connect in a queue rather than refusing immediately. // failed connect in a queue rather than refusing immediately.
let probe = LanFirstProbe::with_timeouts( let probe =
Duration::from_millis(50), LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(500));
Duration::from_millis(500),
);
let win = probe let win = probe
.probe(&peer_with(Some(lan_dead), Some(ts))) .probe(&peer_with(Some(lan_dead), Some(ts)))
.await .await
@@ -214,10 +213,8 @@ mod tests {
async fn probe_errors_when_both_routes_fail() { async fn probe_errors_when_both_routes_fail() {
let lan_dead = free_but_unbound_addr().await; let lan_dead = free_but_unbound_addr().await;
let ts_dead = free_but_unbound_addr().await; let ts_dead = free_but_unbound_addr().await;
let probe = LanFirstProbe::with_timeouts( let probe =
Duration::from_millis(50), LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(50));
Duration::from_millis(50),
);
let err = probe let err = probe
.probe(&peer_with(Some(lan_dead), Some(ts_dead))) .probe(&peer_with(Some(lan_dead), Some(ts_dead)))
.await .await
+48 -51
View File
@@ -22,8 +22,7 @@ use crate::config::ClusterConfig;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use chitchat::transport::UdpTransport; use chitchat::transport::UdpTransport;
use chitchat::{ use chitchat::{
spawn_chitchat, Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId, spawn_chitchat, Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId, FailureDetectorConfig,
FailureDetectorConfig,
}; };
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
@@ -182,10 +181,7 @@ impl ClusterGossip {
/// `ClusterGossip` MUST be held for the daemon's lifetime; dropping /// `ClusterGossip` MUST be held for the daemon's lifetime; dropping
/// it aborts the gossip task and the node effectively leaves the /// it aborts the gossip task and the node effectively leaves the
/// cluster (peers observe it as dead within `dead_node_grace_period`). /// cluster (peers observe it as dead within `dead_node_grace_period`).
pub async fn bootstrap( pub async fn bootstrap(cluster: &ClusterConfig, local_name: impl Into<String>) -> Result<Self> {
cluster: &ClusterConfig,
local_name: impl Into<String>,
) -> Result<Self> {
let local_name = local_name.into(); let local_name = local_name.into();
if local_name.is_empty() { if local_name.is_empty() {
bail!("cluster gossip requires a non-empty local node name"); bail!("cluster gossip requires a non-empty local node name");
@@ -354,11 +350,7 @@ fn get_socket(state: &chitchat::NodeState, key: &str) -> Option<SocketAddr> {
state.get(key).and_then(|s| s.parse().ok()) state.get(key).and_then(|s| s.parse().ok())
} }
fn peer_view_from_state( fn peer_view_from_state(id: &ChitchatId, state: &chitchat::NodeState, alive: bool) -> PeerView {
id: &ChitchatId,
state: &chitchat::NodeState,
alive: bool,
) -> PeerView {
// Fall back to the chitchat node_id when the peer hasn't published a // Fall back to the chitchat node_id when the peer hasn't published a
// separate NODE_NAME yet (should be almost never, but keeps startup // separate NODE_NAME yet (should be almost never, but keeps startup
// races graceful). // races graceful).
@@ -369,7 +361,11 @@ fn peer_view_from_state(
s.split(',') s.split(',')
.filter_map(|p| { .filter_map(|p| {
let t = p.trim(); let t = p.trim();
if t.is_empty() { None } else { Some(t.to_string()) } if t.is_empty() {
None
} else {
Some(t.to_string())
}
}) })
.collect() .collect()
}) })
@@ -415,11 +411,7 @@ mod tests {
/// the failure detector has classified it as alive. Phi-accrual needs /// the failure detector has classified it as alive. Phi-accrual needs
/// a handful of heartbeat samples before a newly-discovered node /// a handful of heartbeat samples before a newly-discovered node
/// flips to live — this wait covers that ramp-up. /// flips to live — this wait covers that ramp-up.
async fn wait_until_peer_alive( async fn wait_until_peer_alive(gossip: &ClusterGossip, name: &str, deadline: Duration) -> bool {
gossip: &ClusterGossip,
name: &str,
deadline: Duration,
) -> bool {
let start = Instant::now(); let start = Instant::now();
loop { loop {
if let Some(v) = gossip.peer(name).await { if let Some(v) = gossip.peer(name).await {
@@ -442,10 +434,11 @@ mod tests {
bind_lan: Some(loopback(port)), bind_lan: Some(loopback(port)),
bind_tailscale: None, bind_tailscale: None,
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap(); let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
let id = g.self_chitchat_id().await; let id = g.self_chitchat_id().await;
@@ -461,10 +454,11 @@ mod tests {
bind_lan: Some(loopback(next_lan_port())), bind_lan: Some(loopback(next_lan_port())),
bind_tailscale: None, bind_tailscale: None,
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let err = ClusterGossip::bootstrap(&cfg, "") let err = ClusterGossip::bootstrap(&cfg, "")
.await .await
@@ -480,14 +474,18 @@ mod tests {
bind_lan: None, bind_lan: None,
bind_tailscale: None, bind_tailscale: None,
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
// ClusterConfig::validate rejects this first — that's what we want: // ClusterConfig::validate rejects this first — that's what we want:
// the daemon should refuse to bootstrap gossip on a malformed config. // the daemon should refuse to bootstrap gossip on a malformed config.
let err_chain = format!("{:#}", ClusterGossip::bootstrap(&cfg, "solo").await.unwrap_err()); let err_chain = format!(
"{:#}",
ClusterGossip::bootstrap(&cfg, "solo").await.unwrap_err()
);
assert!( assert!(
err_chain.contains("no bind address"), err_chain.contains("no bind address"),
"expected 'no bind address' in error chain, got: {err_chain}" "expected 'no bind address' in error chain, got: {err_chain}"
@@ -507,10 +505,11 @@ mod tests {
bind_lan: Some(addr_a), bind_lan: Some(addr_a),
bind_tailscale: None, bind_tailscale: None,
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
// Node B: uses A as seed. // Node B: uses A as seed.
let cfg_b = ClusterConfig { let cfg_b = ClusterConfig {
@@ -523,10 +522,11 @@ mod tests {
lan_addr: Some(addr_a), lan_addr: Some(addr_a),
tailscale_addr: None, tailscale_addr: None,
}], }],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap(); let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
@@ -543,11 +543,9 @@ mod tests {
// peers as live. Allow a generous 10s since phi-accrual needs a // peers as live. Allow a generous 10s since phi-accrual needs a
// handful of heartbeat samples (500ms gossip interval → typically // handful of heartbeat samples (500ms gossip interval → typically
// 2-4s to flip to alive on first sight). // 2-4s to flip to alive on first sight).
let a_sees_b_alive = let a_sees_b_alive = wait_until_peer_alive(&gossip_a, "b", Duration::from_secs(10)).await;
wait_until_peer_alive(&gossip_a, "b", Duration::from_secs(10)).await;
assert!(a_sees_b_alive, "A should see B alive within 10s"); assert!(a_sees_b_alive, "A should see B alive within 10s");
let b_sees_a_alive = let b_sees_a_alive = wait_until_peer_alive(&gossip_b, "a", Duration::from_secs(10)).await;
wait_until_peer_alive(&gossip_b, "a", Duration::from_secs(10)).await;
assert!(b_sees_a_alive, "B should see A alive within 10s"); assert!(b_sees_a_alive, "B should see A alive within 10s");
// Verify the state B sees for A matches what A published. RPC // Verify the state B sees for A matches what A published. RPC
@@ -564,10 +562,7 @@ mod tests {
assert_eq!(b_view_of_a.hot_max_bytes, Some(1_000_000)); assert_eq!(b_view_of_a.hot_max_bytes, Some(1_000_000));
assert_eq!( assert_eq!(
b_view_of_a.warm_projects, b_view_of_a.warm_projects,
vec![ vec!["osobh/clawverse".to_string(), "osobh/clawmates".to_string(),]
"osobh/clawverse".to_string(),
"osobh/clawmates".to_string(),
]
); );
assert_eq!(b_view_of_a.hot_fill_ratio(), Some(1024.0 / 1_000_000.0)); assert_eq!(b_view_of_a.hot_fill_ratio(), Some(1024.0 / 1_000_000.0));
@@ -595,10 +590,11 @@ mod tests {
bind_lan: Some(loopback(port)), bind_lan: Some(loopback(port)),
bind_tailscale: None, bind_tailscale: None,
peers: vec![], peers: vec![],
bind_rpc_lan: None, bind_rpc_lan: None,
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap(); let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
// Solo cluster — peers() must never include self. // Solo cluster — peers() must never include self.
@@ -626,6 +622,7 @@ mod tests {
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let cfg_b = ClusterConfig { let cfg_b = ClusterConfig {
zone: "lan-1g".into(), zone: "lan-1g".into(),
@@ -641,6 +638,7 @@ mod tests {
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let g_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap(); let g_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
let g_b = ClusterGossip::bootstrap(&cfg_b, "b").await.unwrap(); let g_b = ClusterGossip::bootstrap(&cfg_b, "b").await.unwrap();
@@ -767,5 +765,4 @@ mod tests {
}; };
assert_eq!(half_full.hot_fill_ratio(), Some(0.5)); assert_eq!(half_full.hot_fill_ratio(), Some(0.5));
} }
} }
+168 -4
View File
@@ -147,6 +147,115 @@ impl MetricsReply {
} }
} }
/// Phase 5j: encode this snapshot in the Prometheus text exposition
/// format (v0.0.4 — the same one every scraper understands). One
/// `# HELP`, one `# TYPE`, one sample line per metric.
///
/// All counters are exposed as `counter` type (monotonically
/// increasing since `started_unix`); the timestamp itself is a
/// `gauge`. Line ordering is stable so text-diff comparisons on a
/// scrape work — useful for debugging without a Prometheus server.
pub fn to_prometheus(&self) -> String {
// Preallocate — the output is always small (~1 KiB) but avoids
// reallocs mid-format.
let mut out = String::with_capacity(1024);
fn counter(out: &mut String, name: &str, help: &str, value: u64) {
out.push_str("# HELP ");
out.push_str(name);
out.push(' ');
out.push_str(help);
out.push('\n');
out.push_str("# TYPE ");
out.push_str(name);
out.push_str(" counter\n");
out.push_str(name);
out.push(' ');
out.push_str(&value.to_string());
out.push('\n');
}
fn gauge(out: &mut String, name: &str, help: &str, value: u64) {
out.push_str("# HELP ");
out.push_str(name);
out.push(' ');
out.push_str(help);
out.push('\n');
out.push_str("# TYPE ");
out.push_str(name);
out.push_str(" gauge\n");
out.push_str(name);
out.push(' ');
out.push_str(&value.to_string());
out.push('\n');
}
gauge(
&mut out,
"clawstor_counters_started_unix",
"Unix seconds when this counter set started (reset on daemon restart).",
self.started_unix,
);
counter(
&mut out,
"clawstor_cache_get_ref_hits_total",
"GetRef lookups that returned a cached blob id.",
self.get_ref_hits,
);
counter(
&mut out,
"clawstor_cache_get_ref_misses_total",
"GetRef lookups that missed.",
self.get_ref_misses,
);
counter(
&mut out,
"clawstor_cache_get_tag_hits_total",
"GetTag lookups that returned a value.",
self.get_tag_hits,
);
counter(
&mut out,
"clawstor_cache_get_tag_misses_total",
"GetTag lookups that missed.",
self.get_tag_misses,
);
counter(
&mut out,
"clawstor_cache_blob_get_bytes_total",
"Bytes served from the local blob store via BlobGet/GetChunk.",
self.blob_get_bytes,
);
counter(
&mut out,
"clawstor_cache_blob_put_bytes_total",
"Bytes ingested into the local blob store via BlobPut.",
self.blob_put_bytes,
);
counter(
&mut out,
"clawstor_cache_get_chunk_hits_total",
"GetChunk requests that returned bytes.",
self.get_chunk_hits,
);
counter(
&mut out,
"clawstor_cache_get_chunk_misses_total",
"GetChunk requests for unknown chunks.",
self.get_chunk_misses,
);
counter(
&mut out,
"clawstor_cache_has_chunk_hits_total",
"HasChunk probes that found the chunk locally (dedup save).",
self.has_chunk_hits,
);
counter(
&mut out,
"clawstor_cache_has_chunk_misses_total",
"HasChunk probes for chunks not present locally.",
self.has_chunk_misses,
);
out
}
/// Hit rate for `HasChunk` probes — the dominant signal for how /// Hit rate for `HasChunk` probes — the dominant signal for how
/// much dedup we're saving in partial-sync operations. /// much dedup we're saving in partial-sync operations.
pub fn has_chunk_hit_rate(&self) -> Option<f64> { pub fn has_chunk_hit_rate(&self) -> Option<f64> {
@@ -226,10 +335,7 @@ mod tests {
} }
m.record_get_ref_miss(); m.record_get_ref_miss();
let rate = m.snapshot().get_ref_hit_rate().unwrap(); let rate = m.snapshot().get_ref_hit_rate().unwrap();
assert!( assert!((rate - 0.75).abs() < 1e-9, "expected 0.75, got {rate}");
(rate - 0.75).abs() < 1e-9,
"expected 0.75, got {rate}"
);
} }
#[test] #[test]
@@ -243,6 +349,64 @@ mod tests {
assert_eq!(round, original); assert_eq!(round, original);
} }
#[test]
fn to_prometheus_emits_every_counter_with_correct_type() {
let m = CacheMetrics::new();
for _ in 0..7 {
m.record_get_ref_hit();
}
m.record_get_ref_miss();
m.record_get_tag_hit();
m.record_blob_get_bytes(4096);
m.record_blob_put_bytes(8192);
m.record_get_chunk_hit();
m.record_get_chunk_miss();
m.record_has_chunk_hit();
m.record_has_chunk_miss();
let text = m.snapshot().to_prometheus();
// Every metric name must appear as a `# TYPE` line so scrapers
// classify it correctly. Missing a TYPE line breaks Grafana.
for (name, kind) in [
("clawstor_counters_started_unix", "gauge"),
("clawstor_cache_get_ref_hits_total", "counter"),
("clawstor_cache_get_ref_misses_total", "counter"),
("clawstor_cache_get_tag_hits_total", "counter"),
("clawstor_cache_get_tag_misses_total", "counter"),
("clawstor_cache_blob_get_bytes_total", "counter"),
("clawstor_cache_blob_put_bytes_total", "counter"),
("clawstor_cache_get_chunk_hits_total", "counter"),
("clawstor_cache_get_chunk_misses_total", "counter"),
("clawstor_cache_has_chunk_hits_total", "counter"),
("clawstor_cache_has_chunk_misses_total", "counter"),
] {
let type_line = format!("# TYPE {name} {kind}");
assert!(
text.contains(&type_line),
"missing type line {type_line} in:\n{text}"
);
}
// Sample values land verbatim on their own line.
assert!(text.contains("clawstor_cache_get_ref_hits_total 7\n"));
assert!(text.contains("clawstor_cache_get_ref_misses_total 1\n"));
assert!(text.contains("clawstor_cache_blob_get_bytes_total 4096\n"));
assert!(text.contains("clawstor_cache_blob_put_bytes_total 8192\n"));
}
#[test]
fn to_prometheus_valid_when_all_counters_zero() {
// A fresh daemon should still expose a valid scrape — zero
// counters are meaningful (they let alerts fire on absence of
// change, not just presence of misses).
let text = CacheMetrics::new().snapshot().to_prometheus();
assert!(text.contains("clawstor_cache_get_ref_hits_total 0\n"));
assert!(text.contains("clawstor_cache_blob_get_bytes_total 0\n"));
// No trailing garbage or partial lines.
assert!(text.ends_with('\n'));
}
#[test] #[test]
fn snapshots_across_threads_are_consistent_up_to_relaxed_ordering() { fn snapshots_across_threads_are_consistent_up_to_relaxed_ordering() {
// Fire many increments from N threads and verify the final // Fire many increments from N threads and verify the final
+272
View File
@@ -0,0 +1,272 @@
//! Prometheus `/metrics` endpoint (Phase 5j).
//!
//! Exposes the same [`CacheMetrics`] counters that back `GetMetrics`
//! and gossip, but in the Prometheus text exposition format so any
//! existing scraper can consume them without knowing anything about
//! QUIC or chitchat.
//!
//! # Wire shape
//!
//! One GET route:
//!
//! ```text
//! GET /metrics
//! → 200 OK, content-type text/plain; version=0.0.4
//! <the counters>
//! ```
//!
//! Every other path returns 404. The scrape is stateless — no auth,
//! no session — because the endpoint is intended for `127.0.0.1`
//! binding by default, or a Tailscale-only bind at operator choice.
//! A reverse proxy in front is the recommended pattern for
//! internet-exposed scrapers.
use crate::cluster::metrics::CacheMetrics;
use anyhow::{Context, Result};
use axum::{extract::State, http::header, response::IntoResponse, routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
/// Prometheus text exposition v0.0.4 content-type. Every scraper in
/// the wild parses this MIME correctly — a plain `text/plain` also
/// works but the versioned string is the canonical value.
const PROM_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
/// Running Prometheus HTTP server. Drop / [`PromServer::shutdown`] cancels
/// the underlying accept loop and closes the listening socket.
pub struct PromServer {
/// Address we actually bound. Copies of `SocketAddr` are cheap;
/// exposing it lets callers log or tests connect without a race.
bind_addr: SocketAddr,
task: JoinHandle<()>,
shutdown_tx: Option<oneshot::Sender<()>>,
}
impl std::fmt::Debug for PromServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromServer")
.field("bind_addr", &self.bind_addr)
.finish_non_exhaustive()
}
}
impl PromServer {
/// Bind the HTTP server, spawn the accept loop, return. The
/// returned handle is the sole owner of the background task; drop
/// it (or call [`Self::shutdown`]) to stop serving.
///
/// `metrics` is shared with the RPC router so both endpoints
/// observe the same counters — no risk of drift between the
/// `GetMetrics` RPC and a scrape landing at the same instant.
pub async fn bind(addr: SocketAddr, metrics: Arc<CacheMetrics>) -> Result<Self> {
let listener = TcpListener::bind(addr)
.await
.with_context(|| format!("binding prometheus listener at {addr}"))?;
let bind_addr = listener
.local_addr()
.context("reading prometheus listener bound address")?;
let router = Router::new()
.route("/metrics", get(metrics_handler))
.with_state(metrics);
let (tx, rx) = oneshot::channel::<()>();
let task = tokio::spawn(async move {
let shutdown = async move {
let _ = rx.await;
};
if let Err(e) = axum::serve(listener, router)
.with_graceful_shutdown(shutdown)
.await
{
tracing::warn!(error = %e, "prometheus server exited with error");
}
});
Ok(Self {
bind_addr,
task,
shutdown_tx: Some(tx),
})
}
/// The address the server actually bound to. Differs from the
/// requested `addr` only when the caller passed a `0` port
/// (OS-assigned) — useful in tests.
pub fn local_addr(&self) -> SocketAddr {
self.bind_addr
}
/// Fire the graceful-shutdown signal and await the accept task.
/// Idempotent — a second call after the receiver's already been
/// dropped is a no-op.
pub async fn shutdown(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
// Best-effort await. If it panicked we've already logged.
let _ = self.task.await;
}
/// Abort the accept task without waiting. Used by `ClusterServices`
/// during a synchronous drop where awaiting isn't an option.
pub fn abort(self) {
if let Some(tx) = self.shutdown_tx {
let _ = tx.send(());
}
self.task.abort();
}
}
/// `GET /metrics` handler — snapshot the counters, format, return.
///
/// Snapshotting on every scrape (rather than caching for N ms) keeps
/// counters as fresh as possible; the underlying `Ordering::Relaxed`
/// atomic loads are cheap enough that even a 1 Hz scrape cadence is
/// well under a microsecond of CPU per request.
async fn metrics_handler(State(metrics): State<Arc<CacheMetrics>>) -> impl IntoResponse {
let body = metrics.snapshot().to_prometheus();
([(header::CONTENT_TYPE, PROM_CONTENT_TYPE)], body)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
/// Raw HTTP/1.1 client: writes one GET, reads the entire response
/// until connection close, and splits headers from body on the
/// double CRLF. Returns `(status, content_type, body)`.
///
/// The server closes each response's TCP connection (default for a
/// non-keep-alive request), so a bounded read-to-end is safe here.
async fn scrape(addr: SocketAddr, path: &str) -> (u16, String, String) {
let mut sock = TcpStream::connect(addr).await.unwrap();
let req = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
sock.write_all(req.as_bytes()).await.unwrap();
let mut buf = Vec::with_capacity(4096);
sock.read_to_end(&mut buf).await.unwrap();
let raw = String::from_utf8_lossy(&buf).to_string();
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
let status = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let ct = head
.lines()
.find_map(|l| {
let (k, v) = l.split_once(':')?;
if k.eq_ignore_ascii_case("content-type") {
Some(v.trim().to_string())
} else {
None
}
})
.unwrap_or_default();
(status, ct, body.to_string())
}
#[tokio::test]
async fn scrape_returns_prometheus_text_with_correct_content_type() {
let m = Arc::new(CacheMetrics::new());
for _ in 0..3 {
m.record_get_ref_hit();
}
m.record_get_ref_miss();
m.record_blob_get_bytes(1024);
let bind = "127.0.0.1:0".parse().unwrap();
let server = PromServer::bind(bind, m.clone()).await.unwrap();
let addr = server.local_addr();
assert_ne!(addr.port(), 0, "kernel must assign a real port");
let (status, ct, body) = scrape(addr, "/metrics").await;
assert_eq!(status, 200);
assert!(ct.starts_with("text/plain"), "unexpected content-type {ct}");
// Exposition body carries the counters at the values we set.
// Body may be chunked (transfer-encoding: chunked) — check via
// substring rather than exact equality.
assert!(
body.contains("clawstor_cache_get_ref_hits_total 3"),
"body missing get_ref_hits=3:\n{body}"
);
assert!(
body.contains("clawstor_cache_get_ref_misses_total 1"),
"body missing get_ref_misses=1:\n{body}"
);
assert!(
body.contains("clawstor_cache_blob_get_bytes_total 1024"),
"body missing blob_get_bytes=1024:\n{body}"
);
server.shutdown().await;
}
#[tokio::test]
async fn scrape_shows_live_updates_between_requests() {
// Prove there's no cache — a second scrape after an increment
// must show the new value.
let m = Arc::new(CacheMetrics::new());
let bind = "127.0.0.1:0".parse().unwrap();
let server = PromServer::bind(bind, m.clone()).await.unwrap();
let addr = server.local_addr();
let (_, _, first) = scrape(addr, "/metrics").await;
assert!(first.contains("clawstor_cache_get_ref_hits_total 0"));
for _ in 0..5 {
m.record_get_ref_hit();
}
let (_, _, second) = scrape(addr, "/metrics").await;
assert!(
second.contains("clawstor_cache_get_ref_hits_total 5"),
"expected 5 after increments:\n{second}"
);
server.shutdown().await;
}
#[tokio::test]
async fn unknown_paths_return_404() {
// Only /metrics is a valid path; anything else is a 404 (no
// catch-all router that would tempt someone into treating this
// as a general HTTP server).
let m = Arc::new(CacheMetrics::new());
let bind = "127.0.0.1:0".parse().unwrap();
let server = PromServer::bind(bind, m.clone()).await.unwrap();
let addr = server.local_addr();
let (status, _, _) = scrape(addr, "/not-a-thing").await;
assert_eq!(status, 404);
server.shutdown().await;
}
#[tokio::test]
async fn shutdown_stops_serving() {
// After shutdown() the port must refuse new connections.
let m = Arc::new(CacheMetrics::new());
let bind = "127.0.0.1:0".parse().unwrap();
let server = PromServer::bind(bind, m.clone()).await.unwrap();
let addr = server.local_addr();
server.shutdown().await;
// Give the OS a beat to release the socket.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
// Either the connect fails or the connection is closed with 0
// bytes read — both count as "not serving".
let res = tokio::time::timeout(
std::time::Duration::from_millis(500),
TcpStream::connect(addr),
)
.await;
match res {
Err(_) => { /* timeout — good */ }
Ok(Err(_)) => { /* connect refused — good */ }
Ok(Ok(mut sock)) => {
// Connected — but a read must return 0 bytes because
// the accept loop is gone.
let mut buf = [0u8; 16];
let n = sock.read(&mut buf).await.unwrap_or(0);
assert_eq!(n, 0, "server accepted a connection after shutdown");
}
}
}
}
+154 -35
View File
@@ -14,6 +14,7 @@
use crate::cluster::blob::BlobStore; use crate::cluster::blob::BlobStore;
use crate::cluster::gossip::ClusterGossip; use crate::cluster::gossip::ClusterGossip;
use crate::cluster::prom::PromServer;
use crate::cluster::refs::RefStore; use crate::cluster::refs::RefStore;
use crate::cluster::rpc::{serve_connection, RpcRouter}; use crate::cluster::rpc::{serve_connection, RpcRouter};
use crate::cluster::tags::TagStore; use crate::cluster::tags::TagStore;
@@ -75,6 +76,10 @@ pub struct ClusterServices {
/// Phase 5i: periodic cache-metric publisher. `None` when RPC is /// Phase 5i: periodic cache-metric publisher. `None` when RPC is
/// not running (no router → no counters to publish). /// not running (no router → no counters to publish).
cache_metric_task: Option<JoinHandle<()>>, cache_metric_task: Option<JoinHandle<()>>,
/// Phase 5j: Prometheus `/metrics` HTTP server. `None` when
/// `cluster.prom_bind` was absent or no router exists (nothing to
/// scrape).
prom_server: Option<PromServer>,
} }
impl std::fmt::Debug for ClusterServices { impl std::fmt::Debug for ClusterServices {
@@ -161,8 +166,7 @@ impl ClusterServices {
// present, so an in-process caller (dashboard, tests) can hold // present, so an in-process caller (dashboard, tests) can hold
// it. But we only spawn the accept loop when TLS is up. // it. But we only spawn the accept loop when TLS is up.
let router: Option<Arc<RpcRouter>> = if cluster.tls.is_some() { let router: Option<Arc<RpcRouter>> = if cluster.tls.is_some() {
let mut r = let mut r = RpcRouter::new(gossip.clone(), local_name.clone(), cluster.zone.clone());
RpcRouter::new(gossip.clone(), local_name.clone(), cluster.zone.clone());
if let Some(store) = &blob_store { if let Some(store) = &blob_store {
r = r.with_blob_store(store.clone()); r = r.with_blob_store(store.clone());
} }
@@ -186,8 +190,7 @@ impl ClusterServices {
.rpc_lan() .rpc_lan()
.or_else(|| cluster.rpc_tailscale()) .or_else(|| cluster.rpc_tailscale())
.context("no RPC bind address (need bind_lan or bind_tailscale)")?; .context("no RPC bind address (need bind_lan or bind_tailscale)")?;
let server = QuicServer::bind(bind, identity) let server = QuicServer::bind(bind, identity).context("binding QUIC RPC server")?;
.context("binding QUIC RPC server")?;
let router = router.clone(); let router = router.clone();
tracing::info!("cluster RPC server listening on {}", bind); tracing::info!("cluster RPC server listening on {}", bind);
Some(tokio::spawn(async move { Some(tokio::spawn(async move {
@@ -237,6 +240,28 @@ impl ClusterServices {
None None
}; };
// Phase 5j: Prometheus `/metrics` server. Only meaningful when
// we have a router — otherwise there are no counters to expose.
// `prom_bind` unset => no server, no port opened.
let prom_server = match (&router, cluster.prom_bind) {
(Some(router), Some(addr)) => {
let server = PromServer::bind(addr, router.metrics().clone())
.await
.with_context(|| format!("binding Prometheus /metrics at {addr}"))?;
tracing::info!("Prometheus /metrics listening on {}", server.local_addr());
Some(server)
}
(Some(_), None) => {
tracing::info!("cluster: prom_bind unset; Prometheus endpoint disabled");
None
}
(None, Some(_)) => {
tracing::warn!("cluster: prom_bind set but RPC not running; nothing to scrape");
None
}
(None, None) => None,
};
Ok(Self { Ok(Self {
gossip, gossip,
blob_store, blob_store,
@@ -246,9 +271,16 @@ impl ClusterServices {
accept_task, accept_task,
metric_task, metric_task,
cache_metric_task, cache_metric_task,
prom_server,
}) })
} }
/// Address the Prometheus `/metrics` server actually bound to.
/// `None` when the server was never started.
pub fn prom_addr(&self) -> Option<std::net::SocketAddr> {
self.prom_server.as_ref().map(|s| s.local_addr())
}
/// Whether a local blob store was configured at start time. /// Whether a local blob store was configured at start time.
pub fn blob_store_enabled(&self) -> bool { pub fn blob_store_enabled(&self) -> bool {
self.blob_store.is_some() self.blob_store.is_some()
@@ -276,6 +308,9 @@ impl ClusterServices {
if let Some(task) = self.cache_metric_task { if let Some(task) = self.cache_metric_task {
task.abort(); task.abort();
} }
if let Some(server) = self.prom_server {
server.abort();
}
} }
} }
@@ -405,7 +440,10 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert!(!svc.rpc_enabled(), "no [cluster.tls] → RPC disabled"); assert!(!svc.rpc_enabled(), "no [cluster.tls] → RPC disabled");
assert!(!svc.blob_store_enabled(), "no blob root → blob store disabled"); assert!(
!svc.blob_store_enabled(),
"no blob root → blob store disabled"
);
// Gossip must still be functional. // Gossip must still be functional.
let self_id = svc.gossip.self_chitchat_id().await; let self_id = svc.gossip.self_chitchat_id().await;
assert_eq!(self_id.node_id.as_ref(), "test-node"); assert_eq!(self_id.node_id.as_ref(), "test-node");
@@ -444,10 +482,9 @@ mod tests {
std::fs::create_dir_all(&hot_dir).unwrap(); std::fs::create_dir_all(&hot_dir).unwrap();
std::fs::write(hot_dir.join("blob"), vec![0u8; 4096]).unwrap(); std::fs::write(hot_dir.join("blob"), vec![0u8; 4096]).unwrap();
let svc = let svc = ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None)
ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None) .await
.await .unwrap();
.unwrap();
assert!(svc.rpc_enabled()); assert!(svc.rpc_enabled());
// Dial A from B — use the persisted B identity. // Dial A from B — use the persisted B identity.
@@ -532,14 +569,24 @@ mod tests {
..Default::default() ..Default::default()
}; };
let tmp = tempfile::TempDir::new().unwrap(); let tmp = tempfile::TempDir::new().unwrap();
let svc_a = let svc_a = ClusterServices::start(
ClusterServices::start(&cfg_a, "a".into(), tmp.path().to_path_buf(), 1_000_000, None) &cfg_a,
.await "a".into(),
.unwrap(); tmp.path().to_path_buf(),
let svc_b = 1_000_000,
ClusterServices::start(&cfg_b, "b".into(), tmp.path().to_path_buf(), 1_000_000, None) None,
.await )
.unwrap(); .await
.unwrap();
let svc_b = ClusterServices::start(
&cfg_b,
"b".into(),
tmp.path().to_path_buf(),
1_000_000,
None,
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10); let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop { loop {
@@ -558,6 +605,90 @@ mod tests {
svc_b.shutdown(); svc_b.shutdown();
} }
#[tokio::test]
async fn services_expose_prometheus_endpoint_when_configured() {
// Phase 5j: cluster.prom_bind is honoured, the endpoint serves
// live counters that reflect in-process router activity.
use crate::cluster::transport::FleetCa;
use crate::config::ClusterTlsConfig;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let tmp = tempfile::TempDir::new().unwrap();
let ca_dir = tmp.path().join("ca");
let a_tls_dir = tmp.path().join("a-tls");
let ca = FleetCa::generate("test CA").unwrap();
ca.save(&ca_dir).unwrap();
ca.sign_leaf_to_pem("a", &a_tls_dir).unwrap();
let cfg = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some(loopback(next_port())),
tls: Some(ClusterTlsConfig {
ca_cert: a_tls_dir.join("ca.crt"),
node_cert: a_tls_dir.join("node.crt"),
node_key: a_tls_dir.join("node.key"),
}),
prom_bind: Some("127.0.0.1:0".parse().unwrap()),
..Default::default()
};
let hot_dir = tmp.path().join("hot");
std::fs::create_dir_all(&hot_dir).unwrap();
let svc = ClusterServices::start(&cfg, "a".into(), hot_dir, 1_000_000, None)
.await
.unwrap();
let prom = svc.prom_addr().expect("prom server bound");
assert_ne!(prom.port(), 0, "kernel must assign a real port");
// Drive some counters directly through the router.
let router = svc.router.as_ref().unwrap();
for _ in 0..4 {
router.metrics().record_get_ref_hit();
}
router.metrics().record_blob_get_bytes(2048);
// Scrape.
let mut sock = TcpStream::connect(prom).await.unwrap();
let req = format!("GET /metrics HTTP/1.1\r\nHost: {prom}\r\nConnection: close\r\n\r\n");
sock.write_all(req.as_bytes()).await.unwrap();
let mut buf = Vec::new();
sock.read_to_end(&mut buf).await.unwrap();
let body = String::from_utf8_lossy(&buf);
assert!(
body.contains("clawstor_cache_get_ref_hits_total 4"),
"expected 4 hits in scrape: {body}"
);
assert!(
body.contains("clawstor_cache_blob_get_bytes_total 2048"),
"expected 2048 blob GET bytes: {body}"
);
svc.shutdown();
}
#[tokio::test]
async fn services_leave_prom_server_off_when_unconfigured() {
// Absence of prom_bind means no HTTP server; prom_addr() is
// None. Guards against accidentally leaking a metrics port.
let cfg = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some(loopback(next_port())),
..Default::default()
};
let tmp = tempfile::TempDir::new().unwrap();
let svc = ClusterServices::start(
&cfg,
"quiet".into(),
tmp.path().to_path_buf(),
1_000_000,
None,
)
.await
.unwrap();
assert!(svc.prom_addr().is_none(), "prom server must not start");
svc.shutdown();
}
#[tokio::test] #[tokio::test]
async fn services_publish_cache_metrics_to_gossip_on_start() { async fn services_publish_cache_metrics_to_gossip_on_start() {
// Phase 5i: when RPC is up, the initial cache-metric publish // Phase 5i: when RPC is up, the initial cache-metric publish
@@ -601,28 +732,16 @@ mod tests {
let hot_dir = tmp.path().join("hot"); let hot_dir = tmp.path().join("hot");
std::fs::create_dir_all(&hot_dir).unwrap(); std::fs::create_dir_all(&hot_dir).unwrap();
let svc_a = ClusterServices::start( let svc_a = ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None)
&cfg_a, .await
"a".into(), .unwrap();
hot_dir.clone(),
1_000_000,
None,
)
.await
.unwrap();
// Drive counters BEFORE start() would be a chicken-and-egg — // Drive counters BEFORE start() would be a chicken-and-egg —
// instead, drive them AFTER start and check the periodic // instead, drive them AFTER start and check the periodic
// republish. First, verify the initial-publish (all zeros) has // republish. First, verify the initial-publish (all zeros) has
// reached B: the four keys should be Some(0), not None. // reached B: the four keys should be Some(0), not None.
let svc_b = ClusterServices::start( let svc_b = ClusterServices::start(&cfg_b, "b".into(), hot_dir, 1_000_000, None)
&cfg_b, .await
"b".into(), .unwrap();
hot_dir,
1_000_000,
None,
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10); let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop { loop {
+18 -10
View File
@@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum NodeRole { Primary, Secondary } pub enum NodeRole {
Primary,
Secondary,
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct NodeConfig { pub struct NodeConfig {
@@ -143,6 +146,14 @@ pub struct ClusterConfig {
/// gossip + peer-status but returns `NotConfigured` for Blob RPCs. /// gossip + peer-status but returns `NotConfigured` for Blob RPCs.
#[serde(default)] #[serde(default)]
pub blob_store_root: Option<PathBuf>, pub blob_store_root: Option<PathBuf>,
/// Phase 5j: optional bind address for the Prometheus `/metrics`
/// endpoint. When set, the daemon spins up a tiny HTTP server on
/// this address serving the same counters exposed via `GetMetrics`
/// and gossip. Typical value is `127.0.0.1:7702` (Prometheus scrapes
/// via the LAN listener the node advertises to its scrape target
/// group). Absent means "no scrape endpoint".
#[serde(default)]
pub prom_bind: Option<SocketAddr>,
} }
/// Compute the default RPC address for a gossip address: same IP, port + 1. /// Compute the default RPC address for a gossip address: same IP, port + 1.
@@ -182,7 +193,8 @@ impl ClusterConfig {
/// explicit `bind_rpc_lan` override; otherwise derives from `bind_lan` /// explicit `bind_rpc_lan` override; otherwise derives from `bind_lan`
/// with port + 1. /// with port + 1.
pub fn rpc_lan(&self) -> Option<SocketAddr> { pub fn rpc_lan(&self) -> Option<SocketAddr> {
self.bind_rpc_lan.or_else(|| self.bind_lan.and_then(default_rpc_addr)) self.bind_rpc_lan
.or_else(|| self.bind_lan.and_then(default_rpc_addr))
} }
/// The Tailscale address this node listens on for RPC (QUIC). /// The Tailscale address this node listens on for RPC (QUIC).
@@ -367,10 +379,7 @@ tailscale_addr = "100.64.1.5:7701"
); );
let laptop = cluster.peer("laptop").expect("laptop peer"); let laptop = cluster.peer("laptop").expect("laptop peer");
assert!( assert!(laptop.lan_addr.is_none(), "roaming peer has no LAN address");
laptop.lan_addr.is_none(),
"roaming peer has no LAN address"
);
assert!(laptop.tailscale_addr.is_some()); assert!(laptop.tailscale_addr.is_some());
cluster.validate().expect("valid cluster config"); cluster.validate().expect("valid cluster config");
@@ -402,12 +411,10 @@ tailscale_addr = "100.64.1.5:7701"
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let err = cluster.validate().unwrap_err().to_string(); let err = cluster.validate().unwrap_err().to_string();
assert!( assert!(err.contains("no bind address"), "unexpected error: {err}");
err.contains("no bind address"),
"unexpected error: {err}"
);
} }
#[test] #[test]
@@ -434,6 +441,7 @@ tailscale_addr = "100.64.1.5:7701"
bind_rpc_tailscale: None, bind_rpc_tailscale: None,
tls: None, tls: None,
blob_store_root: None, blob_store_root: None,
prom_bind: None,
}; };
let err = cluster.validate().unwrap_err().to_string(); let err = cluster.validate().unwrap_err().to_string();
assert!( assert!(