test(miroir-core): cover reshard executor source doc-count helper (bf-1s9od)

Add the executor-level seam missing from bf-14wgq: mockito-driven unit tests
for `ReshardExecutor::compute_source_document_count`, which delegates to the
shared `index_stats::aggregate_index_stats`. The pure reduction policy is
already covered directly; these exercise the real reqwest transport against
`/indexes/{uid}/stats` on mock nodes returning known per-node counts:

  (a)  multiple healthy nodes with differing counts → result is the max,
      not the sum (each address hosts a full replica);
  (b)  one node returning HTTP 5xx → logged and skipped, max of the rest;
  (b') a node 404-ing (absent replica) → counts as zero, not a failure;
  (c)  every node failing → denominator falls back to 0.

Adds `mockito = "1"` as a miroir-core dev-dependency, mirroring how the
miroir-proxy ILM acceptance tests drive the same endpoint. Test-only change;
no production code modified.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-07-09 09:16:59 -04:00
parent 3dd2b75e2b
commit 1523cae5de
3 changed files with 174 additions and 0 deletions

1
Cargo.lock generated
View file

@ -2464,6 +2464,7 @@ dependencies = [
"indexmap 2.14.0",
"meilisearch-sdk",
"mockall",
"mockito",
"proptest",
"rand 0.8.6",
"rdkafka",

View file

@ -122,5 +122,8 @@ tokio = { version = "1", features = ["rt", "macros", "time"] }
testcontainers = "0.23"
testcontainers-modules = { version = "0.11", features = ["redis"] }
mockall = "0.13"
# HTTP mock server for executor-level tests that drive the real reqwest
# transport against `/indexes/{uid}/stats` (mirrors miroir-proxy ILM tests).
mockito = "1"
meilisearch-sdk = { workspace = true }
arbitrary = { version = "1", features = ["derive"] }

View file

@ -966,3 +966,173 @@ pub enum Phase {
Cleanup,
Complete,
}
#[cfg(test)]
mod tests {
//! Executor-level coverage for `compute_source_document_count`.
//!
//! The pure reduction policy (`index_stats::reduce_document_counts`) is
//! already unit-tested directly. These tests cover the *other* seam: the
//! executor helper that drives the real reqwest transport against
//! `/indexes/{uid}/stats` via `index_stats::aggregate_index_stats`. Each
//! "node" is a mockito server returning a known `numberOfDocuments`, so we
//! assert the executor reduces them with `max`, tolerates a failing node,
//! and falls back to `0` when every node fails — matching how the
//! `miroir-proxy` ILM acceptance tests drive the same endpoint.
use super::*;
use crate::scatter::MockNodeClient;
/// Index uid every stats mock is mounted under.
const INDEX_UID: &str = "test-idx";
/// Stats endpoint path every mock is mounted on (`/indexes/{INDEX_UID}/stats`).
const STATS_PATH: &str = "/indexes/test-idx/stats";
/// Build an executor pointed at the given mockito node addresses.
///
/// Only `node_addresses` (and the reqwest `http_client`) matter for
/// `compute_source_document_count`; the `node_client`/`topology`/`config`/
/// `task_store` are required by the constructor but untouched by the helper,
/// so they get inert defaults.
fn executor_with_nodes(node_addresses: Vec<String>) -> ReshardExecutor<MockNodeClient> {
ReshardExecutor::new(
INDEX_UID.to_string(),
2,
4,
Arc::new(RwLock::new(Topology::new(2, 1, 1))),
ReshardConfig {
backfill_concurrency: 4,
backfill_batch_size: 100,
throttle_docs_per_sec: 0,
verify_before_swap: false,
retain_old_index_hours: 24,
},
Arc::new(MockNodeClient::default()),
None,
Arc::new(Client::new()),
Arc::new(node_addresses),
Arc::new("test-master-key".to_string()),
)
}
/// Mount one `GET /indexes/{uid}/stats` mock returning `body` at `status`,
/// expected to be hit exactly once.
async fn mock_stats(
server: &mut mockito::Server,
status: usize,
body: serde_json::Value,
) -> mockito::Mock {
server
.mock("GET", STATS_PATH)
.with_status(status)
.with_body(body.to_string())
.expect(1)
.create_async()
.await
}
// (a) Multiple healthy nodes returning different counts → result is the max.
#[tokio::test]
async fn compute_source_doc_count_returns_max_across_healthy_nodes() {
let mut server_a = mockito::Server::new_async().await;
let mut server_b = mockito::Server::new_async().await;
let mut server_c = mockito::Server::new_async().await;
let mocks: Vec<mockito::Mock> = vec![
mock_stats(&mut server_a, 200, json!({"numberOfDocuments": 100})).await,
mock_stats(&mut server_b, 200, json!({"numberOfDocuments": 250})).await,
mock_stats(&mut server_c, 200, json!({"numberOfDocuments": 180})).await,
];
let executor =
executor_with_nodes(vec![server_a.url(), server_b.url(), server_c.url()]);
let count = executor.compute_source_document_count(INDEX_UID).await.unwrap();
assert_eq!(count, 250, "denominator is the max responder, not the sum");
// Prove we drove the real HTTP path: every node's stats endpoint was hit.
for m in &mocks {
m.assert_async().await;
}
}
// (b) One node failing (HTTP 5xx) → logged and skipped; max of the rest wins.
#[tokio::test]
async fn compute_source_doc_count_ignores_failing_node() {
let mut server_a = mockito::Server::new_async().await;
let mut server_dead = mockito::Server::new_async().await;
let mut server_c = mockito::Server::new_async().await;
let mocks: Vec<mockito::Mock> = vec![
mock_stats(&mut server_a, 200, json!({"numberOfDocuments": 100})).await,
mock_stats(&mut server_dead, 500, json!({"message": "internal error"})).await,
mock_stats(&mut server_c, 200, json!({"numberOfDocuments": 250})).await,
];
let executor = executor_with_nodes(vec![
server_a.url(),
server_dead.url(),
server_c.url(),
]);
let count = executor.compute_source_document_count(INDEX_UID).await.unwrap();
assert_eq!(
count, 250,
"a single failing node must not block the denominator"
);
for m in &mocks {
m.assert_async().await;
}
}
// (b') A node that 404s (index absent on that replica) is treated as zero
// documents rather than a failure — the healthy max still wins.
#[tokio::test]
async fn compute_source_doc_count_tolerates_404_absent_index() {
let mut server_a = mockito::Server::new_async().await;
let mut server_missing = mockito::Server::new_async().await;
let mocks: Vec<mockito::Mock> = vec![
mock_stats(&mut server_a, 200, json!({"numberOfDocuments": 180})).await,
mock_stats(&mut server_missing, 404, json!({"message": "index not found"})).await,
];
let executor =
executor_with_nodes(vec![server_a.url(), server_missing.url()]);
let count = executor.compute_source_document_count(INDEX_UID).await.unwrap();
assert_eq!(
count, 180,
"a 404 (absent replica) counts as zero, not a failure"
);
for m in &mocks {
m.assert_async().await;
}
}
// (c) Every node failing → returns 0 (no responder → no denominator).
#[tokio::test]
async fn compute_source_doc_count_zero_when_all_nodes_fail() {
let mut server_a = mockito::Server::new_async().await;
let mut server_b = mockito::Server::new_async().await;
let mut server_c = mockito::Server::new_async().await;
let mocks: Vec<mockito::Mock> = vec![
mock_stats(&mut server_a, 500, json!({"message": "down"})).await,
mock_stats(&mut server_b, 503, json!({"message": "unavailable"})).await,
mock_stats(&mut server_c, 500, json!({"message": "down"})).await,
];
let executor =
executor_with_nodes(vec![server_a.url(), server_b.url(), server_c.url()]);
let count = executor.compute_source_document_count(INDEX_UID).await.unwrap();
assert_eq!(count, 0, "all nodes failing yields a zero denominator");
for m in &mocks {
m.assert_async().await;
}
}
}