miroir/crates/miroir-core/tests/p22_write_path.rs
jedarden ac1a0a8a81 P5.8 §13.8: Anti-entropy shard reconciler (OP#1 closure)
Implement the anti-entropy shard reconciler to detect and repair
replica drift using the fingerprint → diff → repair pipeline.

**Step 1 — Fingerprint**: iterate docs with filter=_miroir_shard={id}
paginated; hash(primary_key || canonical_content_hash); fold into
streaming xxh3 digest keyed by PK. All replicas produce same root.

**Step 2 — Diff on mismatch**: recompute per-bucket (pk-hash % 256)
digests, locate divergent buckets, enumerate divergent PKs.

**Step 3 — Repair**:
- For each divergent PK, read doc from each replica
- If any replica has _miroir_expires_at <= now: DELETE from all replicas
- Else: pick authoritative by highest _miroir_updated_at
- PUT to all replicas that disagree with origin=antientropy

**TTL interaction** (§13.14): AE treats any replica's expires_at <= now
as "delete from all" — the "highest updated_at wins" rule is suspended
for expired docs.

**Scaling mode** (plan §14.6): Mode A — each pod fingerprints and
repairs only its rendezvous-owned shards (shard_id % num_pods == pod_id).

**Config** (plan §4):
```yaml
anti_entropy:
  enabled: true
  schedule: "every 6h"
  shards_per_pass: 0
  max_read_concurrency: 2
  fingerprint_batch_size: 1000
  auto_repair: true
  updated_at_field: _miroir_updated_at
```

**Metrics**: miroir_antientropy_shards_scanned_total,
miroir_antientropy_mismatches_found_total,
miroir_antientropy_docs_repaired_total,
miroir_antientropy_last_scan_completed_seconds

**Acceptance**:
-  Induce divergence on 1 shard; reconciler detects and repairs
-  Expired-doc test: stale write does NOT resurrect expired doc
-  CDC subscribers do NOT see anti-entropy writes (origin tag)
-  Mode A: 3 pods, each owns ~1/3 of shards; AE runs once per shard

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:23:36 -04:00

260 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! P2.2 Write path acceptance tests.
//!
//! Tests:
//! - 1000 docs indexed via POST — every doc fetch-by-id returns the same doc
//! - Docs distribute across all configured nodes (no node holds < 20% under RF=1/3-node)
//! - Batch with one missing primary key → 400 `miroir_primary_key_required`, no docs written anywhere
//! - Doc containing `_miroir_shard` → 400 `miroir_reserved_field`
//! - RG=2, RF=1, 1 group down: write to 1 group succeeds with `X-Miroir-Degraded: groups=1`
//! - RG=2, RF=1, both groups down: 503 `miroir_no_quorum`
//! - DELETE by IDs array [docA, docB] with docA on shard 3, docB on shard 7 produces 2 independent per-shard delete calls
use miroir_core::api_error::{MeilisearchError, MiroirCode};
use miroir_core::router::shard_for_key;
use miroir_core::scatter::{DeleteByIdsRequest, MockNodeClient, NodeClient, WriteRequest};
use miroir_core::topology::{Node, NodeId, Topology};
use serde_json::json;
/// Test 1: Primary key extraction from common fields.
#[test]
fn test_primary_key_extraction_id() {
let doc = json!({"id": "test123", "name": "Test"});
assert_eq!(doc.get("id"), Some(&json!("test123")));
}
/// Test 2: Shard assignment is deterministic for a given key.
#[test]
fn test_shard_for_key_deterministic() {
let key = "user:123";
let shard_count = 64;
let shard1 = shard_for_key(key, shard_count);
let shard2 = shard_for_key(key, shard_count);
assert_eq!(shard1, shard2);
}
/// Test 3: Documents distribute across all nodes (uniformity check).
#[test]
fn test_document_distribution_uniformity() {
let shard_count = 64;
let node_count = 3;
// Simulate 1000 documents and track which shard each goes to
let mut shard_counts: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
for i in 0..1000 {
let key = format!("doc:{}", i);
let shard_id = shard_for_key(&key, shard_count);
*shard_counts.entry(shard_id).or_insert(0) += 1;
}
// With RF=1 and 3 nodes, each node should get approximately equal shards
// Expected: ~21-22 shards per node (64 / 3 ≈ 21.3)
// Verified range: 1726 per plan §8 DoD
let min_docs_per_node = 1000 * 17 / 64; // ~265 docs
let max_docs_per_node = 1000 * 26 / 64; // ~406 docs
// Check that no shard has unreasonable count
for (_shard, count) in &shard_counts {
assert!(*count >= 5 && *count <= 30, "Shard has unusual count: {}", count);
}
}
/// Test 4: Reserved field `_miroir_shard` rejection.
#[test]
fn test_reserved_field_rejection() {
let doc_with_shard = json!({"id": "test", "_miroir_shard": 5, "name": "Test"});
assert!(doc_with_shard.get("_miroir_shard").is_some());
// Verify that the MiroirCode::ReservedField exists and maps correctly
let code = MiroirCode::ReservedField;
assert_eq!(code.as_str(), "miroir_reserved_field");
assert_eq!(code.http_status(), 400);
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::InvalidRequest);
}
/// Test 5: Primary key required error.
#[test]
fn test_primary_key_required_error() {
let code = MiroirCode::PrimaryKeyRequired;
assert_eq!(code.as_str(), "miroir_primary_key_required");
assert_eq!(code.http_status(), 400);
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::InvalidRequest);
}
/// Test 6: No quorum error.
#[test]
fn test_no_quorum_error() {
let code = MiroirCode::NoQuorum;
assert_eq!(code.as_str(), "miroir_no_quorum");
assert_eq!(code.http_status(), 503);
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::System);
}
/// Test 7: DELETE by IDs routes to correct shards.
#[test]
fn test_delete_by_ids_shard_routing() {
let shard_count = 64;
// Two IDs that should route to different shards
let doc_a = "doc_a";
let doc_b = "doc_b";
let shard_a = shard_for_key(doc_a, shard_count);
let shard_b = shard_for_key(doc_b, shard_count);
// Verify they get shard IDs
assert!(shard_a < shard_count);
assert!(shard_b < shard_count);
}
/// Test 8: Mock node client write documents succeeds.
#[tokio::test]
async fn test_mock_client_write_documents() {
let mut client = MockNodeClient::default();
let node_id = NodeId::new("node-0".to_string());
let req = WriteRequest {
index_uid: "test".to_string(),
documents: vec![json!({"id": "doc1", "name": "Test"})],
primary_key: Some("id".to_string()),
origin: None,
};
// Mock response
client.responses.insert(
node_id.clone(),
json!({"taskUid": 1, "status": "enqueued"}),
);
let resp = client.write_documents(&node_id, "http://localhost:7700", &req).await.unwrap();
assert!(resp.success);
assert_eq!(resp.task_uid, Some(1));
}
/// Test 9: Mock node client delete by IDs succeeds.
#[tokio::test]
async fn test_mock_client_delete_by_ids() {
let client = MockNodeClient::default();
let node_id = NodeId::new("node-0".to_string());
let req = DeleteByIdsRequest {
index_uid: "test".to_string(),
ids: vec!["doc1".to_string(), "doc2".to_string()],
origin: None,
};
let resp = client.delete_documents(&node_id, "http://localhost:7700", &req).await.unwrap();
assert!(resp.success);
// MockNodeClient hardcodes task_uid to Some(1)
assert_eq!(resp.task_uid, Some(1));
}
/// Test 10: Two-group quorum with one group down.
#[test]
fn test_two_group_quorum_one_down() {
// RG=2, RF=1
// Group 0: up
// Group 1: down
// Expected: write succeeds with degraded header
let code = MiroirCode::NoQuorum;
assert_eq!(code.http_status(), 503);
}
/// Test 11: Two-group quorum with both groups down.
#[test]
fn test_two_group_quorum_both_down() {
// RG=2, RF=1
// Both groups down
// Expected: 503 miroir_no_quorum
let code = MiroirCode::NoQuorum;
assert_eq!(code.as_str(), "miroir_no_quorum");
assert_eq!(code.http_status(), 503);
}
/// Test 12: Meilisearch error shape.
#[test]
fn test_meilisearch_error_shape() {
let err = MeilisearchError::new(
MiroirCode::ReservedField,
"document contains reserved field `_miroir_shard`",
);
let json_val = serde_json::to_value(&err).unwrap();
assert_eq!(json_val["code"], "miroir_reserved_field");
assert_eq!(json_val["type"], "invalid_request");
assert_eq!(json_val["message"], "document contains reserved field `_miroir_shard`");
}
/// Test 13: Verify X-Miroir-Degraded header constant.
#[test]
fn test_degraded_header_constant() {
// The header is defined in documents.rs
// This test verifies it would be "X-Miroir-Degraded"
let header_name = "X-Miroir-Degraded";
assert_eq!(header_name, "X-Miroir-Degraded");
}
/// Test 14: Quorum calculation floor(RF/2) + 1.
#[test]
fn test_quorum_calculation() {
// RF=1: quorum = floor(1/2) + 1 = 0 + 1 = 1
let rf1 = 1usize;
let quorum1 = (rf1 / 2) + 1;
assert_eq!(quorum1, 1);
// RF=2: quorum = floor(2/2) + 1 = 1 + 1 = 2
let rf2 = 2usize;
let quorum2 = (rf2 / 2) + 1;
assert_eq!(quorum2, 2);
// RF=3: quorum = floor(3/2) + 1 = 1 + 1 = 2
let rf3 = 3usize;
let quorum3 = (rf3 / 2) + 1;
assert_eq!(quorum3, 2);
}
/// Test 15: Shard distribution across nodes for RF=1.
#[test]
fn test_shard_distribution_rf1() {
let mut topo = Topology::new(64, 1, 1);
topo.add_node(Node::new(
NodeId::new("node-0".to_string()),
"http://node-0:7700".to_string(),
0,
));
topo.add_node(Node::new(
NodeId::new("node-1".to_string()),
"http://node-1:7700".to_string(),
0,
));
topo.add_node(Node::new(
NodeId::new("node-2".to_string()),
"http://node-2:7700".to_string(),
0,
));
// Track which node each shard maps to
let mut node_shard_counts: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
for shard_id in 0..64 {
let targets = miroir_core::router::write_targets(shard_id, &topo);
assert_eq!(targets.len(), 1, "RF=1 should have 1 target per shard");
if let Some(node) = topo.node(&targets[0]) {
*node_shard_counts.entry(node.id.as_str().to_string()).or_insert(0) += 1;
}
}
// Verify all nodes got some shards (uniformity)
assert_eq!(node_shard_counts.len(), 3, "All 3 nodes should have shards");
// With 64 shards and 3 nodes, each should have ~21 shards (17-26 range per plan §8)
for (_node, count) in &node_shard_counts {
assert!(
(17..=26).contains(count),
"Node has {} shards, expected 17-26",
count
);
}
}