feat(miroir-core): register index_stats module (bf-2r01e)
Declare `pub mod index_stats;` in lib.rs so the already-written shared per-node stats-aggregation module compiles into the crate. It exposes aggregate_index_stats, fetch_node_stats, and the pure reduce_document_counts, with unit tests for the reduction policy. ilm.rs still defines its own NodeIndexStats/IndexStats; both coexist under separate module paths (neither is re-exported at the crate root). ILM caller migration is deferred to the next child. No behavior change to ILM/reshard. cargo build -p miroir-core: OK cargo test -p miroir-core index_stats::: 9 passed Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1e207ed6b5
commit
f10a67555f
2 changed files with 252 additions and 0 deletions
251
crates/miroir-core/src/index_stats.rs
Normal file
251
crates/miroir-core/src/index_stats.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
//! Shared per-node index-stats aggregation.
|
||||||
|
//!
|
||||||
|
//! Extracted from `ilm::fetch_index_stats` (plan §13.17) so that both ILM
|
||||||
|
//! rollover evaluation and the reshard backfill denominator (bf-2ynu5) compute
|
||||||
|
//! an index's real document count the *same* way: iterate the source node
|
||||||
|
//! addresses, `GET /indexes/{uid}/stats`, and reduce `numberOfDocuments`.
|
||||||
|
//!
|
||||||
|
//! # Why `max`, not `sum`
|
||||||
|
//!
|
||||||
|
//! Each address in a coordinator's `node_addresses` hosts a full replica of the
|
||||||
|
//! index (this is also why `reshard::advance_backfill` can read every shard of
|
||||||
|
//! the source index from a single healthy node via `filter=_miroir_shard`).
|
||||||
|
//! Reducing per-node counts with `max` therefore yields the true document count
|
||||||
|
//! and tolerates a replica that is mid-ingest; `sum` would over-count by the
|
||||||
|
//! replication factor. ILM relies on exactly this reduction for its `max_docs`
|
||||||
|
//! rollover trigger, so the reshard denominator must agree with it.
|
||||||
|
//!
|
||||||
|
//! # Testability
|
||||||
|
//!
|
||||||
|
//! The HTTP transport ([`fetch_node_stats`]) is exercised end-to-end by the
|
||||||
|
//! `miroir-proxy` ILM acceptance tests (mockito). The reduction policy itself is
|
||||||
|
//! a pure function — [`reduce_document_counts`] — so it is unit-tested directly
|
||||||
|
//! against known per-node counts (including failing nodes) without spinning up
|
||||||
|
//! an HTTP server.
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
/// `/indexes/{uid}/stats` response shape for a single node (Meilisearch).
|
||||||
|
///
|
||||||
|
/// Both fields default when absent so a minimal `{"numberOfDocuments": N}` body
|
||||||
|
/// parses cleanly, and a node that lacks the index entirely yields zeros.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub struct NodeIndexStats {
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "numberOfDocuments")]
|
||||||
|
pub number_of_documents: u64,
|
||||||
|
/// Size detail — may be absent on older Meilisearch versions.
|
||||||
|
#[serde(rename = "stats", default)]
|
||||||
|
pub stats: Option<NodeStatsDetail>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
pub struct NodeStatsDetail {
|
||||||
|
#[serde(rename = "databaseSize", default)]
|
||||||
|
pub database_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated index stats reduced across all source node addresses.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct IndexStats {
|
||||||
|
/// Document count, reduced with `max` across responding nodes.
|
||||||
|
pub total_documents: u64,
|
||||||
|
/// `databaseSize` summed across responding nodes.
|
||||||
|
pub total_size_bytes: u64,
|
||||||
|
/// Number of nodes that returned stats successfully (HTTP 2xx, parsable).
|
||||||
|
pub nodes_responded: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Failure encountered while fetching stats from one node.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum FetchStatsError {
|
||||||
|
#[error("request failed: {0}")]
|
||||||
|
Request(#[from] reqwest::Error),
|
||||||
|
#[error("failed to read response: {0}")]
|
||||||
|
Read(reqwest::Error),
|
||||||
|
#[error("HTTP {status}: {body}")]
|
||||||
|
Http { status: u16, body: String },
|
||||||
|
#[error("failed to parse stats: {0}")]
|
||||||
|
Parse(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch `/indexes/{uid}/stats` from a single node.
|
||||||
|
///
|
||||||
|
/// A 404 (index absent on that node) is reported as zero documents rather than
|
||||||
|
/// an error, matching the original `ilm::fetch_node_stats` behavior — a node
|
||||||
|
/// that has not yet received the index should not fail the aggregation.
|
||||||
|
pub async fn fetch_node_stats(
|
||||||
|
client: &Client,
|
||||||
|
address: &str,
|
||||||
|
master_key: &str,
|
||||||
|
index_uid: &str,
|
||||||
|
) -> Result<NodeIndexStats, FetchStatsError> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/indexes/{}/stats",
|
||||||
|
address.trim_end_matches('/'),
|
||||||
|
index_uid
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Authorization", format!("Bearer {}", master_key))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(FetchStatsError::Request)?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body_text = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(FetchStatsError::Read)?;
|
||||||
|
|
||||||
|
if status.as_u16() == 404 {
|
||||||
|
// Index doesn't exist on this node — count as zero, not an error.
|
||||||
|
return Ok(NodeIndexStats {
|
||||||
|
number_of_documents: 0,
|
||||||
|
stats: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(FetchStatsError::Http {
|
||||||
|
status: status.as_u16(),
|
||||||
|
body: body_text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
serde_json::from_str(&body_text).map_err(FetchStatsError::Parse)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate an index's stats across all source node addresses.
|
||||||
|
///
|
||||||
|
/// Iterates every address, GETs `/indexes/{uid}/stats`, and reduces the results
|
||||||
|
/// the same way `ilm::fetch_index_stats` did: document count via `max`, size via
|
||||||
|
/// `sum`. A node that errors (network / non-2xx / parse) is logged and skipped
|
||||||
|
/// — one failing node never blocks the count. Returns zeros only if every node
|
||||||
|
/// fails or reports zero.
|
||||||
|
pub async fn aggregate_index_stats(
|
||||||
|
client: &Client,
|
||||||
|
node_addresses: &[String],
|
||||||
|
master_key: &str,
|
||||||
|
index_uid: &str,
|
||||||
|
) -> IndexStats {
|
||||||
|
let mut total_documents = 0u64;
|
||||||
|
let mut total_size_bytes = 0u64;
|
||||||
|
let mut nodes_responded = 0usize;
|
||||||
|
|
||||||
|
for address in node_addresses {
|
||||||
|
match fetch_node_stats(client, address, master_key, index_uid).await {
|
||||||
|
Ok(node_stats) => {
|
||||||
|
total_documents = total_documents.max(node_stats.number_of_documents);
|
||||||
|
if let Some(ref stats) = node_stats.stats {
|
||||||
|
total_size_bytes += stats.database_size;
|
||||||
|
}
|
||||||
|
nodes_responded += 1;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// Log but continue - one node failing shouldn't block the count.
|
||||||
|
warn!("index_stats: failed to fetch stats from node {}: {}", address, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IndexStats {
|
||||||
|
total_documents,
|
||||||
|
total_size_bytes,
|
||||||
|
nodes_responded,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce per-node document counts into the index's real document count.
|
||||||
|
///
|
||||||
|
/// Pure (no I/O): takes each node's count as `Ok(n)` (or `Err(_)` if that node
|
||||||
|
/// failed to respond) and returns the `max` across the responders, or `0` when
|
||||||
|
/// no node responded. This is the policy shared by ILM rollover evaluation and
|
||||||
|
/// the reshard backfill denominator, factored out so it can be unit-tested
|
||||||
|
/// against known per-node counts without an HTTP server.
|
||||||
|
pub fn reduce_document_counts<I, E>(counts: I) -> u64
|
||||||
|
where
|
||||||
|
I: IntoIterator<Item = Result<u64, E>>,
|
||||||
|
{
|
||||||
|
counts
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Result::ok)
|
||||||
|
.max()
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_stats_full_body() {
|
||||||
|
let body = r#"{"numberOfDocuments": 4242, "stats": {"databaseSize": 9999}}"#;
|
||||||
|
let stats: NodeIndexStats = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(stats.number_of_documents, 4242);
|
||||||
|
let detail = stats.stats.expect("stats detail present");
|
||||||
|
assert_eq!(detail.database_size, 9999);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_stats_missing_fields_default_zero() {
|
||||||
|
// A bare object must still parse, defaulting every field.
|
||||||
|
let stats: NodeIndexStats = serde_json::from_str("{}").unwrap();
|
||||||
|
assert_eq!(stats.number_of_documents, 0);
|
||||||
|
assert!(stats.stats.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_node_stats_docs_without_size_detail() {
|
||||||
|
let stats: NodeIndexStats = serde_json::from_str(r#"{"numberOfDocuments": 7}"#).unwrap();
|
||||||
|
assert_eq!(stats.number_of_documents, 7);
|
||||||
|
assert!(stats.stats.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- reduce_document_counts: the "mock node client" aggregation seam ---
|
||||||
|
// Each `Ok(n)` is a node that returned `numberOfDocuments = n`; each `Err`
|
||||||
|
// is a node that failed to respond. The reduction must pick the maximum
|
||||||
|
// responder and tolerate any number of failures.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_picks_max_across_healthy_nodes() {
|
||||||
|
let counts: [Result<u64, ()>; 3] = [Ok(100), Ok(250), Ok(180)];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_ignores_failed_nodes() {
|
||||||
|
// Two of four nodes failed; the denominator is still the max responder.
|
||||||
|
let counts: [Result<u64, ()>; 4] = [Ok(100), Err(()), Ok(250), Err(())];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_zero_when_every_node_fails() {
|
||||||
|
let counts: [Result<u64, ()>; 3] = [Err(()), Err(()), Err(())];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_zero_when_no_nodes() {
|
||||||
|
let counts: [Result<u64, ()>; 0] = [];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_single_node() {
|
||||||
|
let counts: [Result<u64, ()>; 1] = [Ok(42)];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reduce_treats_zero_doc_index_as_zero() {
|
||||||
|
// An existing but empty index reports 0 on every node.
|
||||||
|
let counts: [Result<u64, ()>; 2] = [Ok(0), Ok(0)];
|
||||||
|
assert_eq!(reduce_document_counts(counts), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ pub mod group_sync_worker;
|
||||||
pub mod hedging;
|
pub mod hedging;
|
||||||
pub mod idempotency;
|
pub mod idempotency;
|
||||||
pub mod ilm;
|
pub mod ilm;
|
||||||
|
pub mod index_stats;
|
||||||
pub mod leader_election;
|
pub mod leader_election;
|
||||||
pub mod merger;
|
pub mod merger;
|
||||||
pub mod migration;
|
pub mod migration;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue