diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 1b7a0d7..2275096 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,5 +1,5 @@ {"id":"bf-10qf","title":"plan-gap: fix p4_topology_chaos test compilation errors - topology API changed","description":"Plan: §4 Implementation, §8 Testing (integration tests).\n\nGap evidence: cargo test fails with compilation errors in crates/miroir-core/tests/p4_topology_chaos.rs:\n- topo.groups() method not found (line 539, 566)\n- topo.node_mut() method not found (line 716)\n- topo.node() method not found (line 722, 732)\n\nThe Topology API has changed but the integration tests haven't been updated to match.\n\nAcceptance: All cargo tests pass without compilation errors. The p4_topology_chaos tests should use the correct Topology API methods.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"marathon","created_at":"2026-05-25T11:31:07.530364082Z","updated_at":"2026-05-25T11:38:36.614522573Z","closed_at":"2026-05-25T11:38:36.614522573Z","close_reason":"Fixed p4_topology_chaos test compilation errors. Updated RwLock usage patterns (topology.read().await/topology.write().await) and marked nodes as Active after creation to match is_healthy() expectations. All 12 tests now pass. Commit: 3955d03","source_repo":".","compaction_level":0} -{"id":"bf-13ip4","title":"Repo hygiene: remove committed build artifacts and stale config.bak from git tracking","description":"Git tracks generated build artifacts and a dead backup module, violating the plan section 12 repository structure: coverage/ (17 tracked HTML and lcov files), lcov.info, librust_out.rlib, a stray file literally named 1 at the repo root, and crates/miroir-core/src/config.bak/ (advanced.rs and mod.rs, an unreferenced backup copy of the config module; the dot in the directory name makes it impossible to import as a Rust module). Remove all of these from git tracking and from the worktree with git rm -r, then add .gitignore entries for coverage/, lcov.info, and *.rlib so they cannot be re-committed. Verify with grep that nothing references config.bak or librust_out, and that cargo check --workspace still compiles. Do NOT touch notes/, .beads/, tests/, dashboards/, or tarpaulin-report.json handling (already gitignored). Acceptance: git ls-files shows none of the listed paths, .gitignore covers the removed artifact patterns, workspace builds unchanged.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":3,"issue_type":"task","created_at":"2026-07-02T11:30:53.905772068Z","updated_at":"2026-07-02T11:30:53.905772068Z","source_repo":".","compaction_level":0} +{"id":"bf-13ip4","title":"Repo hygiene: remove committed build artifacts and stale config.bak from git tracking","description":"Git tracks generated build artifacts and a dead backup module, violating the plan section 12 repository structure: coverage/ (17 tracked HTML and lcov files), lcov.info, librust_out.rlib, a stray file literally named 1 at the repo root, and crates/miroir-core/src/config.bak/ (advanced.rs and mod.rs, an unreferenced backup copy of the config module; the dot in the directory name makes it impossible to import as a Rust module). Remove all of these from git tracking and from the worktree with git rm -r, then add .gitignore entries for coverage/, lcov.info, and *.rlib so they cannot be re-committed. Verify with grep that nothing references config.bak or librust_out, and that cargo check --workspace still compiles. Do NOT touch notes/, .beads/, tests/, dashboards/, or tarpaulin-report.json handling (already gitignored). Acceptance: git ls-files shows none of the listed paths, .gitignore covers the removed artifact patterns, workspace builds unchanged.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":3,"issue_type":"task","created_at":"2026-07-02T11:30:53.905772068Z","updated_at":"2026-07-02T11:43:16.970603515Z","source_repo":".","compaction_level":0} {"id":"bf-14xmh","title":"Canary Traffic Capture","description":"Plan: §13.18 Synthetic canary queries\n\nGap evidence: Core canary system implemented; `POST /_miroir/canaries/capture` endpoint may be stubbed or incomplete.\n\nAcceptance: Implement traffic capture for golden pair recording:\n1. Implement `POST /_miroir/canaries/capture` endpoint\n2. Record next M production queries + responses as golden pairs\n3. Support body: `{\"index\": \"...\", \"count\": M, \"name_prefix\": \"...\"}`\n4. Store captured queries as canary definitions\n5. Verify captured queries can be replayed and asserted against\n6. Add tests for capture workflow\n7. Document capture procedure and usage\n\nThis is a convenience feature - manual canary definition currently required.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","created_at":"2026-05-26T21:15:42.239940690Z","updated_at":"2026-05-27T01:04:33.557438005Z","closed_at":"2026-05-27T01:04:33.557438005Z","close_reason":"Implemented in commit 73a29e1: feat(canary): implement traffic capture for golden pair recording. POST /_miroir/canaries/capture endpoint records production queries as canary definitions.","source_repo":".","compaction_level":0} {"id":"bf-1976","title":"P6.8 Multi-pod Kubernetes acceptance tests (plan §14 DoD)","description":"Plan §14 Definition of Done requires multi-pod Kubernetes acceptance tests.\n\n## Acceptance Criteria (from Phase 6 epic DoD)\n\n1. **Multi-pod deployment**: replicas=3 — every pod independently serves requests with identical routing\n2. **Chaos test**: Kill one of three pods mid-traffic — zero client-visible errors beyond retry budget (plan §8 chaos)\n3. **Mode A test**: Spin up 3 pods, anti-entropy runs exactly once per shard per interval cluster-wide\n4. **Mode B test**: Start 3 pods, exactly one holds the reshard lease at any given instant; killing it promotes another within `lease_ttl_s`\n5. **Mode C test**: Submit a 10GB dump; chunks distribute across 3 pods and HPA reacts to `miroir_background_queue_depth`\n6. **Memory validation**: All §14.2 memory rows fit within 3584 MiB under realistic steady-state load\n7. **Alerts**: All §14.9 alerts present in PrometheusRule manifest and trip under induced fault\n\n## Current State\n\nPhase 6 components are implemented and have unit/acceptance tests:\n- P6.2 Peer discovery: verified\n- P6.3 Mode A coordinator: implemented\n- P6.4 Mode B coordinator: 21 leader election tests pass\n- P6.5 Mode C coordinator: 22 acceptance tests pass\n- P6.7 Resource-pressure metrics: tests pass (with 2 known bugs noted)\n\nWhat's missing are **end-to-end multi-pod Kubernetes tests** that verify:\n- Pods discover each other via headless Service\n- Mode A partitioning works across 3 pods\n- Mode B leader failover works within TTL\n- Mode C job distribution and HPA reaction\n- Chaos resiliency (pod kill mid-traffic)\n\n## Implementation Approach\n\nCreate `tests/p6_8_multi_pod_acceptance.sh` that:\n1. Uses `kind` or `minikube` to spin up a 3-pod Miroir deployment\n2. Runs client traffic in the background\n3. Verifies each acceptance criterion above\n4. Tears down the cluster\n\nThis blocks closing the Phase 6 epic (miroir-m9q).","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"marathon","created_at":"2026-05-25T07:49:53.993439004Z","updated_at":"2026-05-25T07:58:59.434106522Z","closed_at":"2026-05-25T07:58:59.434106522Z","close_reason":"Implemented P6.8 multi-pod Kubernetes acceptance tests (plan §14 DoD)\n\nAdded 4 files:\n- tests/p6_8_multi_pod_acceptance.sh - Full end-to-end test using kind\n- tests/verify_p6_8_templates_direct.sh - Template verification without kind\n- tests/verify_p6_8_helm_templates.sh - Helm-based template verification\n- tests/p6_8_README.md - Documentation\n\nTest coverage (all verified by template verification):\n1. Multi-pod deployment (3 replicas)\n2. Peer discovery (headless Service + Downward API)\n3. Mode B leader election (exactly one leader, failover)\n4. Resource-pressure metrics (all §14.9 metrics)\n5. PrometheusRule alerts (all §14.9 alerts)\n6. HPA configuration (correct metric types: Pods/External)\n7. Resource limits (2 vCPU / 3.75 GB envelope)\n\nCommits: 1222e8f\n\nTemplate verification script passes all tests locally.\nFull end-to-end test requires kind (not available in current environment).","source_repo":".","compaction_level":0} {"id":"bf-1aesk","title":"Fix README quick-start compose snippet: nonexistent image ronaldraygun/miroir:latest","description":"The Quick Start section of README.md inlines an examples/docker-compose-dev.yml snippet whose miroir service uses image ronaldraygun/miroir:latest, but that image does not exist anywhere: the actual examples/docker-compose-dev.yml uses a locally built miroir-dev:latest image, and plan section 12 plus charts/miroir/values.yaml and k8s/argo-workflows both define the canonical registry as ghcr.io/jedarden/miroir. A user following the README verbatim gets an image pull failure. Fix: make the README snippet match the real examples/docker-compose-dev.yml (local build) or reference ghcr.io/jedarden/miroir with a pinned version tag once a release exists; do not use a floating latest tag for the published registry image. Also check examples/README.md for the same inconsistency. Acceptance: README compose snippet is consistent with examples/docker-compose-dev.yml, no ronaldraygun/miroir reference remains in the repo, and any registry image reference is version-pinned.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":3,"issue_type":"task","created_at":"2026-07-02T11:31:07.005928699Z","updated_at":"2026-07-02T11:31:07.005928699Z","source_repo":".","compaction_level":0} @@ -13,7 +13,7 @@ {"id":"bf-1mpcp","title":"Phase 10: Admin & Search UIs","description":"## Phase 10 Epic: Admin & Search UIs\n\nPlan reference: §13.19 Admin UI, §13.21 Search UI\n\n### Overview\nEmbedded single-page applications for administration and end-user search.\n\n### Deliverables\n- Admin UI at /_miroir/admin (topology, indexes, aliases, tasks, canaries, shadow diff, CDC, metrics)\n- Search UI at /ui/search/{index} (search bar, results, facets, pagination)\n- JWT session management\n- CSRF protection\n- Scoped key rotation for search UI\n- Admin session management with Redis backing\n- Rate limiting for login and search UI\n\n### Acceptance Criteria\n- UIs render correctly on desktop and mobile\n- Admin UI requires authentication\n- Search UI sessions are short-lived JWTs\n- All UI actions use existing admin API\n- Static assets embedded via rust-embed\n\n### Blocks\nGenesis bead (bf-3waw)","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-05-26T16:51:15.970217651Z","updated_at":"2026-05-26T20:20:36.238615283Z","closed_at":"2026-05-26T20:20:36.238615283Z","close_reason":"Phase 10 Admin and Search UIs COMPLETE. Admin Web UI embedded via rust-embed at crates/miroir-proxy/admin-ui/dist/. Search UI embedded at static/search/. Widget JS at static/widget.js. Both UIs fully functional with authentication. See admin_ui.rs and search_ui_serve.rs.","source_repo":".","compaction_level":0} {"id":"bf-1p4v","title":"Fix compile error: borrow of moved value `state` in miroir-proxy/src/main.rs:64","description":"miroir-proxy fails to compile with E0382: borrow of moved value.\n\nError:\n error[E0382]: borrow of moved value: `state`\n --> crates/miroir-proxy/src/main.rs:64:9\n\nThe `state` value is moved into .with_state(state) on line ~61, then borrowed on line 64 via state.config.server.bind.parse().\n\nFix: Change .with_state(state) to .with_state(state.clone()). If the state type does not already derive Clone, add #[derive(Clone)] to it.\n\nAcceptance: cargo build in repo root succeeds with no errors.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"claude-code-glm-4.7-delta","created_at":"2026-05-16T20:15:11.894483429Z","updated_at":"2026-05-20T11:17:13.590794984Z","closed_at":"2026-05-20T11:17:13.590794984Z","close_reason":"Compile error verified as already fixed - see notes/bf-1p4v.md for details","source_repo":".","compaction_level":0} {"id":"bf-1p9a3","title":"plan-gap: §13 advanced features - un-ignore header_contract tests","description":"Plan: §13 Advanced Capabilities, §5 Custom HTTP headers. Gap evidence: header_contract.rs tests still have #[ignore] attributes for features that are supposedly implemented (miroir-uhj.6 X-Miroir-Session, miroir-uhj.10 Idempotency-Key, miroir-uhj.12 X-Miroir-Over-Fetch, miroir-uhj.15 X-Miroir-Tenant). All these beads are closed but the test #[ignore] attributes remain. Acceptance: Remove #[ignore] from all tests for implemented features, ensure they pass, update header_contract.rs comment listing \"Headers not yet implemented\".","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":"marathon","created_at":"2026-05-26T19:12:13.895507721Z","updated_at":"2026-05-26T19:16:19.373074421Z","closed_at":"2026-05-26T19:16:19.373074421Z","close_reason":"Un-ignored 4 tests in header_contract.rs for implemented §13 features (X-Miroir-Min-Settings-Version, Idempotency-Key, X-Miroir-Over-Fetch). Updated test expectations to match actual lenient parsing behavior (invalid values ignored, not 400). Updated implementation status comment to document all headers are implemented. All 1781 tests pass. Commit: c1dbe3d","source_repo":".","compaction_level":0} -{"id":"bf-1qbie","title":"Cut first tagged release v0.1.0 (plan section 12 delivered artifacts)","description":"Plan section 12 promises per-release artifacts (static miroir-proxy-linux-amd64 and miroir-ctl-linux-amd64 binaries plus sha256 checksums on GitHub Releases, ghcr.io/jedarden/miroir Docker image, Helm chart on gh-pages and OCI) but origin has ZERO git tags and jedarden/miroir on GitHub has zero releases, even though CHANGELOG.md already contains a released 0.1.0 section dated 2026-04-19. Release machinery exists and is deployed: k8s/argo-workflows/miroir-release.yaml in this repo, and WorkflowTemplates miroir-release / miroir-release-ready / miroir-ci-smoke live on the iad-ci cluster. Steps: reconcile the Unreleased section of CHANGELOG.md into the correct release section, confirm workspace version in Cargo.toml matches the tag being cut, create annotated tag v0.1.0 on main, push the tag to origin (Forgejo primary; GitHub mirror syncs automatically), then verify the miroir-release workflow ran on iad-ci or submit it manually per the release checklist in docs. Never force-push. Acceptance: tag v0.1.0 visible on origin, GitHub Release exists with both binaries and checksums, image ghcr.io/jedarden/miroir:0.1.0 exists, Helm chart published to oci://ghcr.io/jedarden/charts/miroir.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-07-02T11:30:42.298892292Z","updated_at":"2026-07-02T11:30:42.298892292Z","source_repo":".","compaction_level":0} +{"id":"bf-1qbie","title":"Cut first tagged release v0.1.0 (plan section 12 delivered artifacts)","description":"Plan section 12 promises per-release artifacts (static miroir-proxy-linux-amd64 and miroir-ctl-linux-amd64 binaries plus sha256 checksums on GitHub Releases, ghcr.io/jedarden/miroir Docker image, Helm chart on gh-pages and OCI) but origin has ZERO git tags and jedarden/miroir on GitHub has zero releases, even though CHANGELOG.md already contains a released 0.1.0 section dated 2026-04-19. Release machinery exists and is deployed: k8s/argo-workflows/miroir-release.yaml in this repo, and WorkflowTemplates miroir-release / miroir-release-ready / miroir-ci-smoke live on the iad-ci cluster. Steps: reconcile the Unreleased section of CHANGELOG.md into the correct release section, confirm workspace version in Cargo.toml matches the tag being cut, create annotated tag v0.1.0 on main, push the tag to origin (Forgejo primary; GitHub mirror syncs automatically), then verify the miroir-release workflow ran on iad-ci or submit it manually per the release checklist in docs. Never force-push. Acceptance: tag v0.1.0 visible on origin, GitHub Release exists with both binaries and checksums, image ghcr.io/jedarden/miroir:0.1.0 exists, Helm chart published to oci://ghcr.io/jedarden/charts/miroir.","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-alpha","created_at":"2026-07-02T11:30:42.298892292Z","updated_at":"2026-07-02T11:43:16.970603515Z","source_repo":".","compaction_level":0} {"id":"bf-1y7r","title":"P8.8 Helm chart tests/ directory with connection-test.yaml","description":"Plan §6 Helm chart structure specifies tests/connection-test.yaml for Helm chart testing. Acceptance: tests/ directory exists with connection-test.yaml that validates Miroir can connect to Meilisearch.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":1,"issue_type":"task","assignee":"marathon","created_at":"2026-05-25T12:23:13.737335523Z","updated_at":"2026-05-25T12:27:55.742863579Z","closed_at":"2026-05-25T12:27:55.742863579Z","close_reason":"Implemented Helm connection test at charts/miroir/tests/connection-test.yaml. The test validates Miroir can connect to Meilisearch by checking /health, /_miroir/ready, /version, and /_miroir/config endpoints. Committed as 3a4c599.","source_repo":".","compaction_level":0} {"id":"bf-21zmc","title":"Phase 3: Advanced Capabilities (§13)","description":"## Phase 3 Epic: Advanced Capabilities\n\nPlan reference: §13 Advanced Capabilities (13.1-13.21)\n\n### Overview\nImplement the 21 advanced features that differentiate Miroir from basic sharding.\n\n### Deliverables\n- §13.1: Online resharding via shadow index\n- §13.2: Hedged requests for tail-latency mitigation\n- §13.3: Adaptive replica selection (EWMA)\n- §13.4: Shard-aware query planner\n- §13.5: Two-phase settings broadcast\n- §13.6: Read-your-writes session pinning\n- §13.7: Atomic index aliases\n- §13.8: Anti-entropy reconciler\n- §13.9: Streaming dump import\n- §13.10: Idempotency keys\n- §13.11: Multi-search API\n- §13.12: Vector search sharding\n- §13.13: CDC stream\n- §13.14: Document TTL\n- §13.15: Tenant affinity\n- §13.16: Traffic shadow\n- §13.17: ILM (time-series indexes)\n- §13.18: Canary queries\n- §13.19: Admin UI\n- §13.20: Query explain API\n- §13.21: Search UI\n\n### Acceptance Criteria\n- Each feature is togglable via config\n- All features use only Meilisearch CE public API\n- Unit and integration tests for each feature\n- Metrics emitted for each feature\n\n### Blocks\nGenesis bead (bf-3waw)","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-05-26T16:51:02.945924425Z","updated_at":"2026-05-26T20:19:51.346523625Z","closed_at":"2026-05-26T20:19:51.346523625Z","close_reason":"Phase 3 Advanced Capabilities (plan §13) COMPLETE. All 21 capabilities implemented: reshard.rs, hedging.rs, replica_selection.rs, query_planner.rs, settings.rs, session_pinning.rs, alias/, anti_entropy.rs, dump_import.rs, idempotency.rs, multi_search.rs, vector.rs, cdc.rs, ttl.rs, tenant.rs, shadow.rs, ilm.rs, canary.rs, admin_ui.rs, explainer.rs, search_ui/. All acceptance tests pass. See crates/miroir-core/src/ and crates/miroir-proxy/src/.","source_repo":".","compaction_level":0} {"id":"bf-2czfj","title":"Phase 8: Security","description":"## Phase 8 Epic: Security\n\nPlan reference: §9 Secrets Handling\n\n### Overview\nSecrets management, authentication, TLS, and JWT signing.\n\n### Deliverables\n- Secret handling via ESO or K8s Secrets\n- Master key and admin key authentication\n- JWT signing for admin sessions\n- TLS support for external communication\n- Scoped key rotation for search UI\n\n### Acceptance Criteria\n- Master key required for write operations\n- Admin key required for admin API\n- JWT sessions for admin UI\n- Scoped keys for search UI with time-based expiry\n- External Secret Operator integration example\n\n### Blocks\nGenesis bead (bf-3waw)","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-05-26T18:48:23.440091774Z","updated_at":"2026-05-26T20:20:23.449028510Z","closed_at":"2026-05-26T20:20:23.449028510Z","close_reason":"Phase 8 Security COMPLETE. JWT signing for admin and search UI sessions. CSRF protection. Scoped key rotation for search UI. Admin API key authentication. Rate limiting. Secrets handling via env vars. TLS termination via Kubernetes Service. See auth.rs and scoped_key_rotation.rs.","source_repo":".","compaction_level":0} diff --git a/.gitignore b/.gitignore index daaad65..b321297 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ tests/benches/score-comparability/results/*.jsonl miroir-proxy-linux-amd64 miroir-proxy-linux-amd64.sha256 .beads/.br_recovery/ +coverage/ +lcov.info +*.rlib diff --git a/.needle-predispatch-sha b/.needle-predispatch-sha index fae3e3a..47e75a3 100644 --- a/.needle-predispatch-sha +++ b/.needle-predispatch-sha @@ -1 +1 @@ -6ff3687ebac267346304b53eb7421ba20910d868 +57a8009b3894ecfe7951933cee0158f84a4fd567 diff --git a/1 b/1 deleted file mode 100644 index e69de29..0000000 diff --git a/coverage/html/control.js b/coverage/html/control.js deleted file mode 100644 index 5897b00..0000000 --- a/coverage/html/control.js +++ /dev/null @@ -1,99 +0,0 @@ - -function next_uncovered(selector, reverse, scroll_selector) { - function visit_element(element) { - element.classList.add("seen"); - element.classList.add("selected"); - - if (!scroll_selector) { - scroll_selector = "tr:has(.selected) td.line-number" - } - - const scroll_to = document.querySelector(scroll_selector); - if (scroll_to) { - scroll_to.scrollIntoView({behavior: "smooth", block: "center", inline: "end"}); - } - } - - function select_one() { - if (!reverse) { - const previously_selected = document.querySelector(".selected"); - - if (previously_selected) { - previously_selected.classList.remove("selected"); - } - - return document.querySelector(selector + ":not(.seen)"); - } else { - const previously_selected = document.querySelector(".selected"); - - if (previously_selected) { - previously_selected.classList.remove("selected"); - previously_selected.classList.remove("seen"); - } - - const nodes = document.querySelectorAll(selector + ".seen"); - if (nodes) { - const last = nodes[nodes.length - 1]; // last - return last; - } else { - return undefined; - } - } - } - - function reset_all() { - if (!reverse) { - const all_seen = document.querySelectorAll(selector + ".seen"); - - if (all_seen) { - all_seen.forEach(e => e.classList.remove("seen")); - } - } else { - const all_seen = document.querySelectorAll(selector + ":not(.seen)"); - - if (all_seen) { - all_seen.forEach(e => e.classList.add("seen")); - } - } - - } - - const uncovered = select_one(); - - if (uncovered) { - visit_element(uncovered); - } else { - reset_all(); - - const uncovered = select_one(); - - if (uncovered) { - visit_element(uncovered); - } - } -} - -function next_line(reverse) { - next_uncovered("td.uncovered-line", reverse) -} - -function next_region(reverse) { - next_uncovered("span.red.region", reverse); -} - -function next_branch(reverse) { - next_uncovered("span.red.branch", reverse); -} - -document.addEventListener("keypress", function(event) { - const reverse = event.shiftKey; - if (event.code == "KeyL") { - next_line(reverse); - } - if (event.code == "KeyB") { - next_branch(reverse); - } - if (event.code == "KeyR") { - next_region(reverse); - } -}); diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/anti_entropy.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/anti_entropy.rs.html deleted file mode 100644 index f646096..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/anti_entropy.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/anti_entropy.rs
Line
Count
Source
1
//! Anti-entropy reconciler module.
2
//!
3
//! Stub for plan §13.8 anti-entropy shard reconciler.
4
//! Full implementation will follow the fingerprint → diff → repair pipeline.
5
6
use serde::{Deserialize, Serialize};
7
8
use crate::migration::{MigrationConfig, MigrationError};
9
10
/// Anti-entropy configuration (plan §13.8).
11
#[derive(Debug, Clone, Serialize, Deserialize)]
12
pub struct AntiEntropyConfig {
13
    pub enabled: bool,
14
    pub schedule_cron: String,
15
    pub shards_per_pass: u32,
16
    pub max_read_concurrency: u32,
17
    pub fingerprint_batch_size: u32,
18
    pub auto_repair: bool,
19
    pub updated_at_field: String,
20
}
21
22
impl Default for AntiEntropyConfig {
23
6
    fn default() -> Self {
24
6
        Self {
25
6
            enabled: true,
26
6
            schedule_cron: "0 */6 * * *".to_string(),
27
6
            shards_per_pass: 0,
28
6
            max_read_concurrency: 2,
29
6
            fingerprint_batch_size: 1000,
30
6
            auto_repair: true,
31
6
            updated_at_field: "_miroir_updated_at".to_string(),
32
6
        }
33
6
    }
34
}
35
36
/// Validates that migration is safe given the anti-entropy configuration.
37
/// Returns Ok(()) if safe, Err with a descriptive message if not.
38
///
39
/// Hard refusal policy (plan §15 OP#1): skipping the delta pass while
40
/// anti-entropy is disabled provides zero recovery path for documents
41
/// written at the cutover boundary. Measured loss rate: ~2% of writes.
42
/// This is a hard-coded policy, not a warning.
43
6
pub fn validate_migration_safety(
44
6
    ae_config: &AntiEntropyConfig,
45
6
    migration_config: &MigrationConfig,
46
6
) -> Result<(), MigrationError> {
47
6
    if migration_config.skip_delta_pass && 
!ae_config.enabled4
{
48
2
        return Err(MigrationError::UnsafeCutoverNoAntiEntropy);
49
4
    }
50
4
    Ok(())
51
6
}
52
53
/// Generates a warning if anti-entropy is disabled during active migration.
54
/// The caller should log this at warn level.
55
///
56
/// Even with the delta pass enabled (which provides 0-loss cutover on its own),
57
/// disabling anti-entropy means the delta pass is the sole safety mechanism.
58
/// Operators should be aware of this reduced redundancy.
59
4
pub fn migration_warning_if_ae_disabled(ae_enabled: bool) -> Option<String> {
60
4
    if ae_enabled {
61
2
        return None;
62
2
    }
63
2
    Some(
64
2
        "Anti-entropy is disabled. Shard migration cutover relies on the delta pass \
65
2
         as the sole safety mechanism. Any bugs in the delta pass could lead to \
66
2
         data loss at the cutover boundary. Re-enable anti-entropy for defense-in-depth."
67
2
            .to_string(),
68
2
    )
69
4
}
70
71
#[cfg(test)]
72
mod tests {
73
    use super::*;
74
75
    #[test]
76
2
    fn test_validate_safe_with_delta_pass() {
77
2
        let ae = AntiEntropyConfig {
78
2
            enabled: false,
79
2
            ..Default::default()
80
2
        };
81
2
        let mc = MigrationConfig {
82
2
            skip_delta_pass: false,
83
2
            ..Default::default()
84
2
        };
85
2
        assert!(validate_migration_safety(&ae, &mc).is_ok());
86
2
    }
87
88
    #[test]
89
2
    fn test_validate_unsafe_without_anti_entropy() {
90
2
        let ae = AntiEntropyConfig {
91
2
            enabled: false,
92
2
            ..Default::default()
93
2
        };
94
2
        let mc = MigrationConfig {
95
2
            skip_delta_pass: true,
96
2
            anti_entropy_enabled: false,
97
2
            ..Default::default()
98
2
        };
99
2
        assert!(validate_migration_safety(&ae, &mc).is_err());
100
2
    }
101
102
    #[test]
103
2
    fn test_validate_safe_with_anti_entropy_safety_net() {
104
2
        let ae = AntiEntropyConfig {
105
2
            enabled: true,
106
2
            ..Default::default()
107
2
        };
108
2
        let mc = MigrationConfig {
109
2
            skip_delta_pass: true,
110
2
            anti_entropy_enabled: true,
111
2
            ..Default::default()
112
2
        };
113
2
        assert!(validate_migration_safety(&ae, &mc).is_ok());
114
2
    }
115
116
    #[test]
117
2
    fn test_warning_when_ae_disabled() {
118
2
        assert!(migration_warning_if_ae_disabled(false).is_some());
119
2
        assert!(migration_warning_if_ae_disabled(true).is_none());
120
2
    }
121
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config.rs.html deleted file mode 100644 index 2d28e57..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/config.rs
Line
Count
Source
1
//! Miroir configuration — plan §4 YAML schema with §13 advanced capabilities.
2
3
mod advanced;
4
mod error;
5
mod load;
6
mod validate;
7
8
pub use error::ConfigError;
9
10
use serde::{Deserialize, Serialize};
11
12
/// Top-level configuration matching plan §4 YAML schema under `miroir:`.
13
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14
#[serde(default)]
15
pub struct MiroirConfig {
16
    // --- Secrets (env-var overrides) ---
17
    /// Client-facing API key. Env override: `MIROIR_MASTER_KEY`.
18
    pub master_key: String,
19
    /// Key Miroir uses on Meilisearch nodes. Env override: `MIROIR_NODE_MASTER_KEY`.
20
    pub node_master_key: String,
21
22
    // --- Core topology ---
23
    /// Total number of logical shards.
24
    pub shards: u32,
25
    /// Replication factor (intra-group replicas per shard). Production: 2.
26
    pub replication_factor: u32,
27
    /// Number of independent query pools. Default 1; production: 2.
28
    pub replica_groups: u32,
29
30
    // --- Sub-structs ---
31
    pub nodes: Vec<NodeConfig>,
32
    pub task_store: TaskStoreConfig,
33
    pub admin: AdminConfig,
34
    pub health: HealthConfig,
35
    pub scatter: ScatterConfig,
36
    pub rebalancer: RebalancerConfig,
37
    pub server: ServerConfig,
38
    pub connection_pool_per_node: ConnectionPoolConfig,
39
    pub task_registry: TaskRegistryConfig,
40
41
    // --- §13 advanced capabilities ---
42
    pub resharding: advanced::ReshardingConfig,
43
    pub hedging: advanced::HedgingConfig,
44
    pub replica_selection: advanced::ReplicaSelectionConfig,
45
    pub query_planner: advanced::QueryPlannerConfig,
46
    pub settings_broadcast: advanced::SettingsBroadcastConfig,
47
    pub settings_drift_check: advanced::SettingsDriftCheckConfig,
48
    pub session_pinning: advanced::SessionPinningConfig,
49
    pub aliases: advanced::AliasesConfig,
50
    pub anti_entropy: advanced::AntiEntropyConfig,
51
    pub dump_import: advanced::DumpImportConfig,
52
    pub idempotency: advanced::IdempotencyConfig,
53
    pub query_coalescing: advanced::QueryCoalescingConfig,
54
    pub multi_search: advanced::MultiSearchConfig,
55
    pub vector_search: advanced::VectorSearchConfig,
56
    pub cdc: advanced::CdcConfig,
57
    pub ttl: advanced::TtlConfig,
58
    pub tenant_affinity: advanced::TenantAffinityConfig,
59
    pub shadow: advanced::ShadowConfig,
60
    pub ilm: advanced::IlmConfig,
61
    pub canary_runner: advanced::CanaryRunnerConfig,
62
    pub explain: advanced::ExplainConfig,
63
    pub admin_ui: advanced::AdminUiConfig,
64
    pub search_ui: advanced::SearchUiConfig,
65
66
    // --- §14 horizontal scaling ---
67
    pub peer_discovery: PeerDiscoveryConfig,
68
    pub leader_election: LeaderElectionConfig,
69
    pub hpa: HpaConfig,
70
}
71
72
/// Convenience alias.
73
pub type Config = MiroirConfig;
74
75
impl Default for MiroirConfig {
76
32
    fn default() -> Self {
77
32
        Self {
78
32
            master_key: String::new(),
79
32
            node_master_key: String::new(),
80
32
            shards: 64,
81
32
            replication_factor: 2,
82
32
            replica_groups: 1,
83
32
            nodes: Vec::new(),
84
32
            task_store: TaskStoreConfig::default(),
85
32
            admin: AdminConfig::default(),
86
32
            health: HealthConfig::default(),
87
32
            scatter: ScatterConfig::default(),
88
32
            rebalancer: RebalancerConfig::default(),
89
32
            server: ServerConfig::default(),
90
32
            connection_pool_per_node: ConnectionPoolConfig::default(),
91
32
            task_registry: TaskRegistryConfig::default(),
92
32
            resharding: advanced::ReshardingConfig::default(),
93
32
            hedging: advanced::HedgingConfig::default(),
94
32
            replica_selection: advanced::ReplicaSelectionConfig::default(),
95
32
            query_planner: advanced::QueryPlannerConfig::default(),
96
32
            settings_broadcast: advanced::SettingsBroadcastConfig::default(),
97
32
            settings_drift_check: advanced::SettingsDriftCheckConfig::default(),
98
32
            session_pinning: advanced::SessionPinningConfig::default(),
99
32
            aliases: advanced::AliasesConfig::default(),
100
32
            anti_entropy: advanced::AntiEntropyConfig::default(),
101
32
            dump_import: advanced::DumpImportConfig::default(),
102
32
            idempotency: advanced::IdempotencyConfig::default(),
103
32
            query_coalescing: advanced::QueryCoalescingConfig::default(),
104
32
            multi_search: advanced::MultiSearchConfig::default(),
105
32
            vector_search: advanced::VectorSearchConfig::default(),
106
32
            cdc: advanced::CdcConfig::default(),
107
32
            ttl: advanced::TtlConfig::default(),
108
32
            tenant_affinity: advanced::TenantAffinityConfig::default(),
109
32
            shadow: advanced::ShadowConfig::default(),
110
32
            ilm: advanced::IlmConfig::default(),
111
32
            canary_runner: advanced::CanaryRunnerConfig::default(),
112
32
            explain: advanced::ExplainConfig::default(),
113
32
            admin_ui: advanced::AdminUiConfig::default(),
114
32
            search_ui: advanced::SearchUiConfig::default(),
115
32
            peer_discovery: PeerDiscoveryConfig::default(),
116
32
            leader_election: LeaderElectionConfig::default(),
117
32
            hpa: HpaConfig::default(),
118
32
        }
119
32
    }
120
}
121
122
impl MiroirConfig {
123
    /// Validate cross-field constraints. Returns `Ok(())` or a `ConfigError`.
124
22
    pub fn validate(&self) -> Result<(), ConfigError> {
125
22
        validate::validate(self)
126
22
    }
127
128
    /// Layered loading: file → env overrides → CLI overrides.
129
0
    pub fn load() -> Result<Self, ConfigError> {
130
0
        load::load()
131
0
    }
132
133
    /// Load from a specific file path with env-var overrides applied.
134
0
    pub fn load_from(path: &std::path::Path) -> Result<Self, ConfigError> {
135
0
        load::load_from(path)
136
0
    }
137
138
    /// Load from a YAML string (useful for testing).
139
0
    pub fn from_yaml(yaml: &str) -> Result<Self, ConfigError> {
140
0
        load::from_yaml(yaml)
141
0
    }
142
}
143
144
// ---------------------------------------------------------------------------
145
// Core sub-structs (§4)
146
// ---------------------------------------------------------------------------
147
148
/// A single Meilisearch node in the cluster topology.
149
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150
pub struct NodeConfig {
151
    pub id: String,
152
    pub address: String,
153
    pub replica_group: u32,
154
}
155
156
/// Task store backend configuration.
157
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158
#[serde(default)]
159
pub struct TaskStoreConfig {
160
    /// `sqlite` or `redis`.
161
    pub backend: String,
162
    /// Path to SQLite database file (sqlite backend).
163
    pub path: String,
164
    /// Redis URL (redis backend), e.g. `redis://host:6379`.
165
    pub url: String,
166
}
167
168
impl Default for TaskStoreConfig {
169
50
    fn default() -> Self {
170
50
        Self {
171
50
            backend: "sqlite".into(),
172
50
            path: "/data/miroir-tasks.db".into(),
173
50
            url: String::new(),
174
50
        }
175
50
    }
176
}
177
178
/// Admin API configuration.
179
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180
#[serde(default)]
181
pub struct AdminConfig {
182
    pub enabled: bool,
183
    /// Env override: `MIROIR_ADMIN_API_KEY`.
184
    pub api_key: String,
185
}
186
187
impl Default for AdminConfig {
188
38
    fn default() -> Self {
189
38
        Self {
190
38
            enabled: true,
191
38
            api_key: String::new(),
192
38
        }
193
38
    }
194
}
195
196
/// Health check configuration.
197
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198
#[serde(default)]
199
pub struct HealthConfig {
200
    pub interval_ms: u64,
201
    pub timeout_ms: u64,
202
    pub unhealthy_threshold: u32,
203
    pub recovery_threshold: u32,
204
}
205
206
impl Default for HealthConfig {
207
38
    fn default() -> Self {
208
38
        Self {
209
38
            interval_ms: 5000,
210
38
            timeout_ms: 2000,
211
38
            unhealthy_threshold: 3,
212
38
            recovery_threshold: 2,
213
38
        }
214
38
    }
215
}
216
217
/// Scatter-gather query configuration.
218
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219
#[serde(default)]
220
pub struct ScatterConfig {
221
    pub node_timeout_ms: u64,
222
    pub retry_on_timeout: bool,
223
    /// `partial` or `error`.
224
    pub unavailable_shard_policy: String,
225
}
226
227
impl Default for ScatterConfig {
228
38
    fn default() -> Self {
229
38
        Self {
230
38
            node_timeout_ms: 5000,
231
38
            retry_on_timeout: true,
232
38
            unavailable_shard_policy: "partial".into(),
233
38
        }
234
38
    }
235
}
236
237
/// Rebalancer configuration.
238
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
239
#[serde(default)]
240
pub struct RebalancerConfig {
241
    pub auto_rebalance_on_recovery: bool,
242
    pub max_concurrent_migrations: u32,
243
    pub migration_timeout_s: u64,
244
}
245
246
impl Default for RebalancerConfig {
247
38
    fn default() -> Self {
248
38
        Self {
249
38
            auto_rebalance_on_recovery: true,
250
38
            max_concurrent_migrations: 4,
251
38
            migration_timeout_s: 3600,
252
38
        }
253
38
    }
254
}
255
256
/// Server (HTTP listener) configuration.
257
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258
#[serde(default)]
259
pub struct ServerConfig {
260
    pub port: u16,
261
    pub bind: String,
262
    pub max_body_bytes: u64,
263
    #[serde(default = "default_max_concurrent_requests")]
264
    pub max_concurrent_requests: u32,
265
    #[serde(default = "default_request_timeout_ms")]
266
    pub request_timeout_ms: u64,
267
}
268
269
42
fn default_max_concurrent_requests() -> u32 {
270
42
    500
271
42
}
272
42
fn default_request_timeout_ms() -> u64 {
273
42
    30000
274
42
}
275
276
impl Default for ServerConfig {
277
38
    fn default() -> Self {
278
38
        Self {
279
38
            port: 7700,
280
38
            bind: "0.0.0.0".into(),
281
38
            max_body_bytes: 104_857_600, // 100 MiB
282
38
            max_concurrent_requests: default_max_concurrent_requests(),
283
38
            request_timeout_ms: default_request_timeout_ms(),
284
38
        }
285
38
    }
286
}
287
288
/// HTTP/2 connection pool per-node settings (§14.8).
289
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290
#[serde(default)]
291
pub struct ConnectionPoolConfig {
292
    pub max_idle: u32,
293
    pub max_total: u32,
294
    pub idle_timeout_s: u64,
295
}
296
297
impl Default for ConnectionPoolConfig {
298
34
    fn default() -> Self {
299
34
        Self {
300
34
            max_idle: 32,
301
34
            max_total: 128,
302
34
            idle_timeout_s: 60,
303
34
        }
304
34
    }
305
}
306
307
/// Task registry cache settings (§14.8).
308
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309
#[serde(default)]
310
pub struct TaskRegistryConfig {
311
    pub cache_size: u32,
312
    pub redis_pool_max: u32,
313
}
314
315
impl Default for TaskRegistryConfig {
316
34
    fn default() -> Self {
317
34
        Self {
318
34
            cache_size: 10000,
319
34
            redis_pool_max: 50,
320
34
        }
321
34
    }
322
}
323
324
/// Peer discovery via Kubernetes headless Service (§14.5).
325
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326
#[serde(default)]
327
pub struct PeerDiscoveryConfig {
328
    pub service_name: String,
329
    pub refresh_interval_s: u64,
330
}
331
332
impl Default for PeerDiscoveryConfig {
333
34
    fn default() -> Self {
334
34
        Self {
335
34
            service_name: "miroir-headless".into(),
336
34
            refresh_interval_s: 15,
337
34
        }
338
34
    }
339
}
340
341
/// Leader election for Mode B background jobs (§14.5).
342
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
343
#[serde(default)]
344
pub struct LeaderElectionConfig {
345
    pub enabled: bool,
346
    pub lease_ttl_s: u64,
347
    pub renew_interval_s: u64,
348
}
349
350
impl Default for LeaderElectionConfig {
351
38
    fn default() -> Self {
352
38
        Self {
353
38
            enabled: true,
354
38
            lease_ttl_s: 10,
355
38
            renew_interval_s: 3,
356
38
        }
357
38
    }
358
}
359
360
/// Horizontal Pod Autoscaler settings (Helm-only, informational in config).
361
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
362
#[serde(default)]
363
pub struct HpaConfig {
364
    #[serde(default)]
365
    pub enabled: bool,
366
}
367
368
/// Policy for handling unavailable shards during scatter.
369
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
370
#[serde(rename_all = "snake_case")]
371
pub enum UnavailableShardPolicy {
372
    /// Return partial results from available nodes.
373
    Partial,
374
    /// Fail the request if any shard is unavailable.
375
    Error,
376
    /// Fall back to another replica group for unavailable shards.
377
    Fallback,
378
}
379
380
impl Default for UnavailableShardPolicy {
381
0
    fn default() -> Self {
382
0
        Self::Partial
383
0
    }
384
}
385
386
impl std::fmt::Display for UnavailableShardPolicy {
387
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388
0
        match self {
389
0
            Self::Partial => write!(f, "partial"),
390
0
            Self::Error => write!(f, "error"),
391
0
            Self::Fallback => write!(f, "fallback"),
392
        }
393
0
    }
394
}
395
396
#[cfg(test)]
397
mod tests {
398
    use super::*;
399
400
    /// Returns a minimal valid dev config (single-node, sqlite, RF=1).
401
10
    fn dev_config() -> MiroirConfig {
402
10
        MiroirConfig {
403
10
            replication_factor: 1,
404
10
            task_store: TaskStoreConfig {
405
10
                backend: "sqlite".into(),
406
10
                ..Default::default()
407
10
            },
408
10
            cdc: advanced::CdcConfig {
409
10
                buffer: advanced::CdcBufferConfig {
410
10
                    overflow: "drop".into(),
411
10
                    ..Default::default()
412
10
                },
413
10
                ..Default::default()
414
10
            },
415
10
            search_ui: advanced::SearchUiConfig {
416
10
                rate_limit: advanced::SearchUiRateLimitConfig {
417
10
                    backend: "local".into(),
418
10
                    ..Default::default()
419
10
                },
420
10
                ..Default::default()
421
10
            },
422
10
            ..Default::default()
423
10
        }
424
10
    }
425
426
    #[test]
427
2
    fn default_config_is_valid() {
428
2
        let cfg = MiroirConfig::default();
429
        // Default has replication_factor=2 with sqlite, which should fail
430
        // validation — but the struct itself should construct fine.
431
2
        assert_eq!(cfg.shards, 64);
432
2
        assert_eq!(cfg.replication_factor, 2);
433
2
        assert_eq!(cfg.replica_groups, 1);
434
2
        assert_eq!(cfg.task_store.backend, "sqlite");
435
2
    }
436
437
    #[test]
438
2
    fn minimal_yaml_deserializes() {
439
2
        let yaml = r#"
440
2
shards: 32
441
2
replication_factor: 1
442
2
nodes: []
443
2
"#;
444
2
        let cfg: MiroirConfig = serde_yaml::from_str(yaml).expect("deserialize");
445
2
        assert_eq!(cfg.shards, 32);
446
2
        assert_eq!(cfg.replication_factor, 1);
447
        // All §13 blocks should get defaults
448
2
        assert!(cfg.resharding.enabled);
449
2
        assert!(cfg.hedging.enabled);
450
2
        assert!(cfg.anti_entropy.enabled);
451
2
    }
452
453
    #[test]
454
2
    fn full_plan_example_deserializes() {
455
2
        let yaml = r#"
456
2
master_key: "test-key"
457
2
node_master_key: "node-key"
458
2
shards: 64
459
2
replication_factor: 2
460
2
replica_groups: 2
461
2
task_store:
462
2
  backend: redis
463
2
  url: "redis://redis:6379"
464
2
admin:
465
2
  enabled: true
466
2
nodes:
467
2
  - id: "meili-0"
468
2
    address: "http://meili-0.search.svc:7700"
469
2
    replica_group: 0
470
2
  - id: "meili-1"
471
2
    address: "http://meili-1.search.svc:7700"
472
2
    replica_group: 0
473
2
health:
474
2
  interval_ms: 5000
475
2
  timeout_ms: 2000
476
2
  unhealthy_threshold: 3
477
2
  recovery_threshold: 2
478
2
scatter:
479
2
  node_timeout_ms: 5000
480
2
  retry_on_timeout: true
481
2
  unavailable_shard_policy: partial
482
2
rebalancer:
483
2
  auto_rebalance_on_recovery: true
484
2
  max_concurrent_migrations: 4
485
2
  migration_timeout_s: 3600
486
2
server:
487
2
  port: 7700
488
2
  bind: "0.0.0.0"
489
2
  max_body_bytes: 104857600
490
2
leader_election:
491
2
  enabled: true
492
2
"#;
493
2
        let cfg: MiroirConfig = serde_yaml::from_str(yaml).expect("deserialize");
494
2
        assert_eq!(cfg.master_key, "test-key");
495
2
        assert_eq!(cfg.nodes.len(), 2);
496
2
        assert_eq!(cfg.replica_groups, 2);
497
2
        cfg.validate().expect("valid production config");
498
2
    }
499
500
    #[test]
501
2
    fn round_trip_yaml() {
502
2
        let original = MiroirConfig::default();
503
2
        let yaml = serde_yaml::to_string(&original).expect("serialize");
504
2
        let round_tripped: MiroirConfig = serde_yaml::from_str(&yaml).expect("deserialize");
505
2
        assert_eq!(original, round_tripped);
506
2
    }
507
508
    #[test]
509
2
    fn validation_rejects_ha_with_sqlite() {
510
2
        let mut cfg = dev_config();
511
2
        cfg.replication_factor = 2;
512
2
        let err = cfg.validate().unwrap_err();
513
2
        assert!(err.to_string().contains("redis"));
514
2
    }
515
516
    #[test]
517
2
    fn validation_rejects_zero_shards() {
518
2
        let mut cfg = dev_config();
519
2
        cfg.shards = 0;
520
2
        let err = cfg.validate().unwrap_err();
521
2
        assert!(err.to_string().contains("shards"));
522
2
    }
523
524
    #[test]
525
2
    fn validation_rejects_duplicate_node_ids() {
526
2
        let mut cfg = dev_config();
527
2
        cfg.nodes = vec![
528
2
            NodeConfig {
529
2
                id: "n0".into(),
530
2
                address: "http://n0".into(),
531
2
                replica_group: 0,
532
2
            },
533
2
            NodeConfig {
534
2
                id: "n0".into(),
535
2
                address: "http://n0b".into(),
536
2
                replica_group: 0,
537
2
            },
538
        ];
539
2
        let err = cfg.validate().unwrap_err();
540
2
        assert!(err.to_string().contains("duplicate"));
541
2
    }
542
543
    #[test]
544
2
    fn validation_rejects_node_outside_replica_groups() {
545
2
        let mut cfg = dev_config();
546
2
        cfg.nodes = vec![NodeConfig {
547
2
            id: "n0".into(),
548
2
            address: "http://n0".into(),
549
2
            replica_group: 5,
550
2
        }];
551
2
        let err = cfg.validate().unwrap_err();
552
2
        assert!(err.to_string().contains("replica_group"));
553
2
    }
554
555
    #[test]
556
2
    fn validation_rejects_scoped_key_timing_inversion() {
557
2
        let mut cfg = dev_config();
558
2
        cfg.search_ui.scoped_key_max_age_days = 10;
559
2
        cfg.search_ui.scoped_key_rotate_before_expiry_days = 10;
560
2
        let err = cfg.validate().unwrap_err();
561
2
        assert!(err.to_string().contains("scoped_key"));
562
2
    }
563
564
    #[test]
565
2
    fn advanced_defaults_all_enabled() {
566
2
        let cfg = MiroirConfig::default();
567
2
        assert!(cfg.resharding.enabled);
568
2
        assert!(cfg.hedging.enabled);
569
2
        assert!(cfg.replica_selection.strategy == "adaptive");
570
2
        assert!(cfg.query_planner.enabled);
571
2
        assert!(cfg.settings_broadcast.strategy == "two_phase");
572
2
        assert!(cfg.session_pinning.enabled);
573
2
        assert!(cfg.aliases.enabled);
574
2
        assert!(cfg.anti_entropy.enabled);
575
2
        assert!(cfg.dump_import.mode == "streaming");
576
2
        assert!(cfg.idempotency.enabled);
577
2
        assert!(cfg.query_coalescing.enabled);
578
2
        assert!(cfg.multi_search.enabled);
579
2
        assert!(cfg.vector_search.enabled);
580
2
        assert!(cfg.cdc.enabled);
581
2
        assert!(cfg.ttl.enabled);
582
2
        assert!(cfg.tenant_affinity.enabled);
583
2
        assert!(cfg.shadow.enabled);
584
2
        assert!(cfg.ilm.enabled);
585
2
        assert!(cfg.canary_runner.enabled);
586
2
        assert!(cfg.explain.enabled);
587
2
        assert!(cfg.admin_ui.enabled);
588
2
        assert!(cfg.search_ui.enabled);
589
2
    }
590
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/advanced.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/advanced.rs.html deleted file mode 100644 index f5ec689..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/advanced.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/config/advanced.rs
Line
Count
Source
1
//! §13 Advanced capabilities configuration structs.
2
3
use serde::{Deserialize, Serialize};
4
use std::collections::HashMap;
5
6
// ---------------------------------------------------------------------------
7
// 13.1  Online resharding
8
// ---------------------------------------------------------------------------
9
10
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11
#[serde(default)]
12
pub struct ReshardingConfig {
13
    pub enabled: bool,
14
    pub backfill_concurrency: u32,
15
    pub backfill_batch_size: u32,
16
    pub throttle_docs_per_sec: u32,
17
    pub verify_before_swap: bool,
18
    pub retain_old_index_hours: u32,
19
}
20
21
impl Default for ReshardingConfig {
22
34
    fn default() -> Self {
23
34
        Self {
24
34
            enabled: true,
25
34
            backfill_concurrency: 4,
26
34
            backfill_batch_size: 1000,
27
34
            throttle_docs_per_sec: 0,
28
34
            verify_before_swap: true,
29
34
            retain_old_index_hours: 48,
30
34
        }
31
34
    }
32
}
33
34
// ---------------------------------------------------------------------------
35
// 13.2  Hedged requests
36
// ---------------------------------------------------------------------------
37
38
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39
#[serde(default)]
40
pub struct HedgingConfig {
41
    pub enabled: bool,
42
    pub p95_trigger_multiplier: f64,
43
    pub min_trigger_ms: u64,
44
    pub max_hedges_per_query: u32,
45
    pub cross_group_fallback: bool,
46
}
47
48
impl Default for HedgingConfig {
49
34
    fn default() -> Self {
50
34
        Self {
51
34
            enabled: true,
52
34
            p95_trigger_multiplier: 1.2,
53
34
            min_trigger_ms: 15,
54
34
            max_hedges_per_query: 2,
55
34
            cross_group_fallback: true,
56
34
        }
57
34
    }
58
}
59
60
// ---------------------------------------------------------------------------
61
// 13.3  Adaptive replica selection (EWMA)
62
// ---------------------------------------------------------------------------
63
64
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65
#[serde(default)]
66
pub struct ReplicaSelectionConfig {
67
    /// `adaptive`, `round_robin`, or `random`.
68
    pub strategy: String,
69
    pub latency_weight: f64,
70
    pub inflight_weight: f64,
71
    pub error_weight: f64,
72
    pub ewma_half_life_ms: u64,
73
    pub exploration_epsilon: f64,
74
}
75
76
impl Default for ReplicaSelectionConfig {
77
34
    fn default() -> Self {
78
34
        Self {
79
34
            strategy: "adaptive".into(),
80
34
            latency_weight: 1.0,
81
34
            inflight_weight: 2.0,
82
34
            error_weight: 10.0,
83
34
            ewma_half_life_ms: 5000,
84
34
            exploration_epsilon: 0.05,
85
34
        }
86
34
    }
87
}
88
89
// ---------------------------------------------------------------------------
90
// 13.4  Shard-aware query planner
91
// ---------------------------------------------------------------------------
92
93
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94
#[serde(default)]
95
pub struct QueryPlannerConfig {
96
    pub enabled: bool,
97
    pub max_pk_literals_narrowable: u32,
98
    pub log_plans: bool,
99
}
100
101
impl Default for QueryPlannerConfig {
102
34
    fn default() -> Self {
103
34
        Self {
104
34
            enabled: true,
105
34
            max_pk_literals_narrowable: 128,
106
34
            log_plans: false,
107
34
        }
108
34
    }
109
}
110
111
// ---------------------------------------------------------------------------
112
// 13.5  Two-phase settings broadcast
113
// ---------------------------------------------------------------------------
114
115
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116
#[serde(default)]
117
pub struct SettingsBroadcastConfig {
118
    /// `two_phase` or `sequential` (legacy).
119
    pub strategy: String,
120
    pub verify_timeout_s: u64,
121
    pub max_repair_retries: u32,
122
    pub freeze_writes_on_unrepairable: bool,
123
}
124
125
impl Default for SettingsBroadcastConfig {
126
34
    fn default() -> Self {
127
34
        Self {
128
34
            strategy: "two_phase".into(),
129
34
            verify_timeout_s: 60,
130
34
            max_repair_retries: 3,
131
34
            freeze_writes_on_unrepairable: true,
132
34
        }
133
34
    }
134
}
135
136
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137
#[serde(default)]
138
pub struct SettingsDriftCheckConfig {
139
    pub interval_s: u64,
140
    pub auto_repair: bool,
141
}
142
143
impl Default for SettingsDriftCheckConfig {
144
34
    fn default() -> Self {
145
34
        Self {
146
34
            interval_s: 300,
147
34
            auto_repair: true,
148
34
        }
149
34
    }
150
}
151
152
// ---------------------------------------------------------------------------
153
// 13.6  Session pinning (read-your-writes)
154
// ---------------------------------------------------------------------------
155
156
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
157
#[serde(default)]
158
pub struct SessionPinningConfig {
159
    pub enabled: bool,
160
    pub ttl_seconds: u64,
161
    pub max_sessions: u32,
162
    /// `block` or `route_pin`.
163
    pub wait_strategy: String,
164
    pub max_wait_ms: u64,
165
}
166
167
impl Default for SessionPinningConfig {
168
34
    fn default() -> Self {
169
34
        Self {
170
34
            enabled: true,
171
34
            ttl_seconds: 900,
172
34
            max_sessions: 100_000,
173
34
            wait_strategy: "block".into(),
174
34
            max_wait_ms: 5000,
175
34
        }
176
34
    }
177
}
178
179
// ---------------------------------------------------------------------------
180
// 13.7  Index aliases
181
// ---------------------------------------------------------------------------
182
183
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
184
#[serde(default)]
185
pub struct AliasesConfig {
186
    pub enabled: bool,
187
    pub history_retention: u32,
188
    pub require_target_exists: bool,
189
}
190
191
impl Default for AliasesConfig {
192
34
    fn default() -> Self {
193
34
        Self {
194
34
            enabled: true,
195
34
            history_retention: 10,
196
34
            require_target_exists: true,
197
34
        }
198
34
    }
199
}
200
201
// ---------------------------------------------------------------------------
202
// 13.8  Anti-entropy shard reconciler
203
// ---------------------------------------------------------------------------
204
205
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206
#[serde(default)]
207
pub struct AntiEntropyConfig {
208
    pub enabled: bool,
209
    pub schedule: String,
210
    pub shards_per_pass: u32,
211
    pub max_read_concurrency: u32,
212
    pub fingerprint_batch_size: u32,
213
    pub auto_repair: bool,
214
    pub updated_at_field: String,
215
}
216
217
impl Default for AntiEntropyConfig {
218
34
    fn default() -> Self {
219
34
        Self {
220
34
            enabled: true,
221
34
            schedule: "every 6h".into(),
222
34
            shards_per_pass: 0,
223
34
            max_read_concurrency: 2,
224
34
            fingerprint_batch_size: 1000,
225
34
            auto_repair: true,
226
34
            updated_at_field: "_miroir_updated_at".into(),
227
34
        }
228
34
    }
229
}
230
231
// ---------------------------------------------------------------------------
232
// 13.9  Streaming dump import
233
// ---------------------------------------------------------------------------
234
235
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
236
#[serde(default)]
237
pub struct DumpImportConfig {
238
    /// `streaming` or `broadcast` (legacy).
239
    pub mode: String,
240
    pub batch_size: u32,
241
    pub parallel_target_writes: u32,
242
    pub memory_buffer_bytes: u64,
243
    pub chunk_size_bytes: u64,
244
}
245
246
impl Default for DumpImportConfig {
247
34
    fn default() -> Self {
248
34
        Self {
249
34
            mode: "streaming".into(),
250
34
            batch_size: 1000,
251
34
            parallel_target_writes: 8,
252
34
            memory_buffer_bytes: 134_217_728, // 128 MiB
253
34
            chunk_size_bytes: 268_435_456,    // 256 MiB
254
34
        }
255
34
    }
256
}
257
258
// ---------------------------------------------------------------------------
259
// 13.10  Idempotency keys
260
// ---------------------------------------------------------------------------
261
262
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263
#[serde(default)]
264
pub struct IdempotencyConfig {
265
    pub enabled: bool,
266
    pub ttl_seconds: u64,
267
    pub max_cached_keys: u32,
268
}
269
270
impl Default for IdempotencyConfig {
271
34
    fn default() -> Self {
272
34
        Self {
273
34
            enabled: true,
274
34
            ttl_seconds: 86400,
275
34
            max_cached_keys: 1_000_000,
276
34
        }
277
34
    }
278
}
279
280
// ---------------------------------------------------------------------------
281
// 13.10  Query coalescing (paired with idempotency)
282
// ---------------------------------------------------------------------------
283
284
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285
#[serde(default)]
286
pub struct QueryCoalescingConfig {
287
    pub enabled: bool,
288
    pub window_ms: u64,
289
    pub max_subscribers: u32,
290
    pub max_pending_queries: u32,
291
}
292
293
impl Default for QueryCoalescingConfig {
294
34
    fn default() -> Self {
295
34
        Self {
296
34
            enabled: true,
297
34
            window_ms: 50,
298
34
            max_subscribers: 1000,
299
34
            max_pending_queries: 10000,
300
34
        }
301
34
    }
302
}
303
304
// ---------------------------------------------------------------------------
305
// 13.11  Multi-search batch API
306
// ---------------------------------------------------------------------------
307
308
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309
#[serde(default)]
310
pub struct MultiSearchConfig {
311
    pub enabled: bool,
312
    pub max_queries_per_batch: u32,
313
    pub total_timeout_ms: u64,
314
    pub per_query_timeout_ms: u64,
315
}
316
317
impl Default for MultiSearchConfig {
318
34
    fn default() -> Self {
319
34
        Self {
320
34
            enabled: true,
321
34
            max_queries_per_batch: 100,
322
34
            total_timeout_ms: 30000,
323
34
            per_query_timeout_ms: 30000,
324
34
        }
325
34
    }
326
}
327
328
// ---------------------------------------------------------------------------
329
// 13.12  Vector / hybrid search
330
// ---------------------------------------------------------------------------
331
332
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
333
#[serde(default)]
334
pub struct VectorSearchConfig {
335
    pub enabled: bool,
336
    pub over_fetch_factor: u32,
337
    /// `convex` or `rrf`.
338
    pub merge_strategy: String,
339
    pub hybrid_alpha_default: f64,
340
    pub rrf_k: u32,
341
}
342
343
impl Default for VectorSearchConfig {
344
34
    fn default() -> Self {
345
34
        Self {
346
34
            enabled: true,
347
34
            over_fetch_factor: 3,
348
34
            merge_strategy: "convex".into(),
349
34
            hybrid_alpha_default: 0.5,
350
34
            rrf_k: 60,
351
34
        }
352
34
    }
353
}
354
355
// ---------------------------------------------------------------------------
356
// 13.13  Change data capture (CDC)
357
// ---------------------------------------------------------------------------
358
359
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360
#[serde(default)]
361
pub struct CdcConfig {
362
    pub enabled: bool,
363
    pub emit_ttl_deletes: bool,
364
    pub emit_internal_writes: bool,
365
    pub sinks: Vec<CdcSinkConfig>,
366
    pub buffer: CdcBufferConfig,
367
}
368
369
impl Default for CdcConfig {
370
46
    fn default() -> Self {
371
46
        Self {
372
46
            enabled: true,
373
46
            emit_ttl_deletes: false,
374
46
            emit_internal_writes: false,
375
46
            sinks: Vec::new(),
376
46
            buffer: CdcBufferConfig::default(),
377
46
        }
378
46
    }
379
}
380
381
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
382
#[serde(default)]
383
pub struct CdcSinkConfig {
384
    /// `webhook`, `nats`, `kafka`, or `internal`.
385
    #[serde(rename = "type")]
386
    pub sink_type: String,
387
    pub url: String,
388
    pub batch_size: u32,
389
    pub batch_flush_ms: u64,
390
    pub include_body: bool,
391
    pub retry_max_s: u64,
392
    /// NATS-specific.
393
    pub subject_prefix: Option<String>,
394
}
395
396
impl Default for CdcSinkConfig {
397
0
    fn default() -> Self {
398
0
        Self {
399
0
            sink_type: "webhook".into(),
400
0
            url: String::new(),
401
0
            batch_size: 100,
402
0
            batch_flush_ms: 1000,
403
0
            include_body: false,
404
0
            retry_max_s: 3600,
405
0
            subject_prefix: None,
406
0
        }
407
0
    }
408
}
409
410
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411
#[serde(default)]
412
pub struct CdcBufferConfig {
413
    /// `memory`, `redis`, or `pvc`.
414
    pub primary: String,
415
    pub memory_bytes: u64,
416
    /// `redis`, `pvc`, or `drop`.
417
    pub overflow: String,
418
    pub redis_bytes: u64,
419
}
420
421
impl Default for CdcBufferConfig {
422
58
    fn default() -> Self {
423
58
        Self {
424
58
            primary: "memory".into(),
425
58
            memory_bytes: 67_108_864, // 64 MiB
426
58
            overflow: "redis".into(),
427
58
            redis_bytes: 1_073_741_824, // 1 GiB
428
58
        }
429
58
    }
430
}
431
432
// ---------------------------------------------------------------------------
433
// 13.14  Document TTL
434
// ---------------------------------------------------------------------------
435
436
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437
#[serde(default)]
438
pub struct TtlConfig {
439
    pub enabled: bool,
440
    pub sweep_interval_s: u64,
441
    pub max_deletes_per_sweep: u32,
442
    pub expires_at_field: String,
443
    pub per_index_overrides: HashMap<String, TtlOverride>,
444
}
445
446
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447
pub struct TtlOverride {
448
    pub sweep_interval_s: u64,
449
    pub max_deletes_per_sweep: u32,
450
}
451
452
impl Default for TtlConfig {
453
34
    fn default() -> Self {
454
34
        Self {
455
34
            enabled: true,
456
34
            sweep_interval_s: 300,
457
34
            max_deletes_per_sweep: 10000,
458
34
            expires_at_field: "_miroir_expires_at".into(),
459
34
            per_index_overrides: HashMap::new(),
460
34
        }
461
34
    }
462
}
463
464
// ---------------------------------------------------------------------------
465
// 13.15  Tenant-to-replica-group affinity
466
// ---------------------------------------------------------------------------
467
468
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
469
#[serde(default)]
470
pub struct TenantAffinityConfig {
471
    pub enabled: bool,
472
    /// `header`, `api_key`, or `explicit`.
473
    pub mode: String,
474
    pub header_name: String,
475
    /// `hash`, `random`, or `reject`.
476
    pub fallback: String,
477
    pub static_map: HashMap<String, u32>,
478
    pub dedicated_groups: Vec<u32>,
479
}
480
481
impl Default for TenantAffinityConfig {
482
34
    fn default() -> Self {
483
34
        Self {
484
34
            enabled: true,
485
34
            mode: "header".into(),
486
34
            header_name: "X-Miroir-Tenant".into(),
487
34
            fallback: "hash".into(),
488
34
            static_map: HashMap::new(),
489
34
            dedicated_groups: Vec::new(),
490
34
        }
491
34
    }
492
}
493
494
// ---------------------------------------------------------------------------
495
// 13.16  Traffic shadow
496
// ---------------------------------------------------------------------------
497
498
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499
#[serde(default)]
500
pub struct ShadowConfig {
501
    pub enabled: bool,
502
    pub targets: Vec<ShadowTargetConfig>,
503
    pub diff_buffer_size: u32,
504
    pub max_shadow_latency_ms: u64,
505
}
506
507
impl Default for ShadowConfig {
508
34
    fn default() -> Self {
509
34
        Self {
510
34
            enabled: true,
511
34
            targets: Vec::new(),
512
34
            diff_buffer_size: 10000,
513
34
            max_shadow_latency_ms: 5000,
514
34
        }
515
34
    }
516
}
517
518
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
519
#[serde(default)]
520
pub struct ShadowTargetConfig {
521
    pub name: String,
522
    pub url: String,
523
    pub api_key_env: String,
524
    pub sample_rate: f64,
525
    pub operations: Vec<String>,
526
}
527
528
impl Default for ShadowTargetConfig {
529
0
    fn default() -> Self {
530
0
        Self {
531
0
            name: String::new(),
532
0
            url: String::new(),
533
0
            api_key_env: String::new(),
534
0
            sample_rate: 0.05,
535
0
            operations: vec!["search".into(), "multi_search".into(), "explain".into()],
536
0
        }
537
0
    }
538
}
539
540
// ---------------------------------------------------------------------------
541
// 13.17  Index lifecycle management (ILM)
542
// ---------------------------------------------------------------------------
543
544
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
545
#[serde(default)]
546
pub struct IlmConfig {
547
    pub enabled: bool,
548
    pub check_interval_s: u64,
549
    pub safety_lock_older_than_days: u32,
550
    pub max_rollovers_per_check: u32,
551
}
552
553
impl Default for IlmConfig {
554
34
    fn default() -> Self {
555
34
        Self {
556
34
            enabled: true,
557
34
            check_interval_s: 3600,
558
34
            safety_lock_older_than_days: 7,
559
34
            max_rollovers_per_check: 10,
560
34
        }
561
34
    }
562
}
563
564
// ---------------------------------------------------------------------------
565
// 13.18  Synthetic canary queries
566
// ---------------------------------------------------------------------------
567
568
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
569
#[serde(default)]
570
pub struct CanaryRunnerConfig {
571
    pub enabled: bool,
572
    pub max_concurrent_canaries: u32,
573
    pub run_history_per_canary: u32,
574
    pub emit_results_to_cdc: bool,
575
}
576
577
impl Default for CanaryRunnerConfig {
578
34
    fn default() -> Self {
579
34
        Self {
580
34
            enabled: true,
581
34
            max_concurrent_canaries: 10,
582
34
            run_history_per_canary: 100,
583
34
            emit_results_to_cdc: true,
584
34
        }
585
34
    }
586
}
587
588
// ---------------------------------------------------------------------------
589
// 13.19  Admin Web UI
590
// ---------------------------------------------------------------------------
591
592
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593
#[serde(default)]
594
pub struct AdminUiConfig {
595
    pub enabled: bool,
596
    pub path: String,
597
    /// `key`, `oauth` (future), or `none` (dev only).
598
    pub auth: String,
599
    pub session_ttl_s: u64,
600
    pub read_only_mode: bool,
601
    pub allowed_origins: Vec<String>,
602
    pub cors_allowed_origins: Vec<String>,
603
    pub csp_overrides: CspOverridesConfig,
604
    pub theme: AdminUiThemeConfig,
605
    pub features: AdminUiFeaturesConfig,
606
}
607
608
impl Default for AdminUiConfig {
609
34
    fn default() -> Self {
610
34
        Self {
611
34
            enabled: true,
612
34
            path: "/_miroir/admin".into(),
613
34
            auth: "key".into(),
614
34
            session_ttl_s: 3600,
615
34
            read_only_mode: false,
616
34
            allowed_origins: vec!["same-origin".into()],
617
34
            cors_allowed_origins: Vec::new(),
618
34
            csp_overrides: CspOverridesConfig::default(),
619
34
            theme: AdminUiThemeConfig::default(),
620
34
            features: AdminUiFeaturesConfig::default(),
621
34
        }
622
34
    }
623
}
624
625
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
626
#[serde(default)]
627
pub struct CspOverridesConfig {
628
    pub script_src: Vec<String>,
629
    pub img_src: Vec<String>,
630
    pub connect_src: Vec<String>,
631
}
632
633
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
634
#[serde(default)]
635
pub struct AdminUiThemeConfig {
636
    pub accent_color: String,
637
    /// `auto`, `light`, or `dark`.
638
    pub default_mode: String,
639
}
640
641
impl Default for AdminUiThemeConfig {
642
36
    fn default() -> Self {
643
36
        Self {
644
36
            accent_color: "#2563eb".into(),
645
36
            default_mode: "auto".into(),
646
36
        }
647
36
    }
648
}
649
650
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
651
#[serde(default)]
652
pub struct AdminUiFeaturesConfig {
653
    pub sandbox: bool,
654
    pub shadow_viewer: bool,
655
    pub cdc_inspector: bool,
656
}
657
658
impl Default for AdminUiFeaturesConfig {
659
36
    fn default() -> Self {
660
36
        Self {
661
36
            sandbox: true,
662
36
            shadow_viewer: true,
663
36
            cdc_inspector: true,
664
36
        }
665
36
    }
666
}
667
668
// ---------------------------------------------------------------------------
669
// 13.20  Query explain API
670
// ---------------------------------------------------------------------------
671
672
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
673
#[serde(default)]
674
pub struct ExplainConfig {
675
    pub enabled: bool,
676
    pub max_warnings: u32,
677
    pub allow_execute_parameter: bool,
678
}
679
680
impl Default for ExplainConfig {
681
34
    fn default() -> Self {
682
34
        Self {
683
34
            enabled: true,
684
34
            max_warnings: 20,
685
34
            allow_execute_parameter: true,
686
34
        }
687
34
    }
688
}
689
690
// ---------------------------------------------------------------------------
691
// 13.21  Search UI (end-user)
692
// ---------------------------------------------------------------------------
693
694
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
695
#[serde(default)]
696
pub struct SearchUiConfig {
697
    pub enabled: bool,
698
    pub path: String,
699
    pub widget_script_enabled: bool,
700
    pub embeddable: bool,
701
    pub auth: SearchUiAuthConfig,
702
    pub allowed_origins: Vec<String>,
703
    pub scoped_key_max_age_days: u32,
704
    pub scoped_key_rotate_before_expiry_days: u32,
705
    pub scoped_key_rotation_drain_s: u64,
706
    pub rate_limit: SearchUiRateLimitConfig,
707
    pub cors_allowed_origins: Vec<String>,
708
    pub csp_overrides: CspOverridesConfig,
709
    pub csp: String,
710
    pub analytics: SearchUiAnalyticsConfig,
711
}
712
713
impl Default for SearchUiConfig {
714
46
    fn default() -> Self {
715
46
        Self {
716
46
            enabled: true,
717
46
            path: "/ui/search".into(),
718
46
            widget_script_enabled: true,
719
46
            embeddable: true,
720
46
            auth: SearchUiAuthConfig::default(),
721
46
            allowed_origins: vec!["*".into()],
722
46
            scoped_key_max_age_days: 60,
723
46
            scoped_key_rotate_before_expiry_days: 30,
724
46
            scoped_key_rotation_drain_s: 120,
725
46
            rate_limit: SearchUiRateLimitConfig::default(),
726
46
            cors_allowed_origins: Vec::new(),
727
46
            csp_overrides: CspOverridesConfig::default(),
728
46
            csp: "default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'"
729
46
                .into(),
730
46
            analytics: SearchUiAnalyticsConfig::default(),
731
46
        }
732
46
    }
733
}
734
735
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
736
#[serde(default)]
737
pub struct SearchUiAuthConfig {
738
    /// `public`, `shared_key`, or `oauth_proxy`.
739
    pub mode: String,
740
    pub shared_key_env: String,
741
    pub session_ttl_s: u64,
742
    pub session_rate_limit: String,
743
    pub jwt_secret_env: String,
744
    pub oauth_proxy: OAuthProxyConfig,
745
}
746
747
impl Default for SearchUiAuthConfig {
748
48
    fn default() -> Self {
749
48
        Self {
750
48
            mode: "public".into(),
751
48
            shared_key_env: String::new(),
752
48
            session_ttl_s: 900,
753
48
            session_rate_limit: "10/minute".into(),
754
48
            jwt_secret_env: "SEARCH_UI_JWT_SECRET".into(),
755
48
            oauth_proxy: OAuthProxyConfig::default(),
756
48
        }
757
48
    }
758
}
759
760
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
761
#[serde(default)]
762
pub struct OAuthProxyConfig {
763
    pub user_header: String,
764
    pub groups_header: String,
765
    pub filter_template: Option<String>,
766
    pub attribute_map: HashMap<String, String>,
767
}
768
769
impl Default for OAuthProxyConfig {
770
50
    fn default() -> Self {
771
50
        Self {
772
50
            user_header: "X-Forwarded-User".into(),
773
50
            groups_header: "X-Forwarded-Groups".into(),
774
50
            filter_template: Some("tenant IN [{groups}]".into()),
775
50
            attribute_map: {
776
50
                let mut m = HashMap::new();
777
50
                m.insert("groups".into(), "groups_array".into());
778
50
                m.insert("user".into(), "user_id_string".into());
779
50
                m
780
50
            },
781
50
        }
782
50
    }
783
}
784
785
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
786
#[serde(default)]
787
pub struct SearchUiRateLimitConfig {
788
    pub per_ip: String,
789
    /// `redis` or `local`.
790
    pub backend: String,
791
    pub redis_key_prefix: String,
792
    pub redis_ttl_s: u64,
793
}
794
795
impl Default for SearchUiRateLimitConfig {
796
60
    fn default() -> Self {
797
60
        Self {
798
60
            per_ip: "60/minute".into(),
799
60
            backend: "redis".into(),
800
60
            redis_key_prefix: "miroir:ratelimit:searchui:".into(),
801
60
            redis_ttl_s: 60,
802
60
        }
803
60
    }
804
}
805
806
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
807
#[serde(default)]
808
pub struct SearchUiAnalyticsConfig {
809
    pub enabled: bool,
810
    /// `cdc` (publishes click-throughs as CDC events).
811
    pub sink: String,
812
}
813
814
impl Default for SearchUiAnalyticsConfig {
815
48
    fn default() -> Self {
816
48
        Self {
817
48
            enabled: false,
818
48
            sink: "cdc".into(),
819
48
        }
820
48
    }
821
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/load.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/load.rs.html deleted file mode 100644 index ae72a29..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/load.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/config/load.rs
Line
Count
Source
1
//! Layered configuration loading: file → env-var overrides → CLI overrides.
2
3
use super::{ConfigError, MiroirConfig};
4
5
// The local `config` module shadows the external `config` crate.
6
// Use a crate-qualified path to reach the external config crate.
7
use ::config as ext_config;
8
use serde_yaml;
9
10
/// Default config file paths to search (in order).
11
const CONFIG_SEARCH_PATHS: &[&str] = &[
12
    "miroir.yaml",
13
    "miroir.yml",
14
    "config/miroir.yaml",
15
    "/etc/miroir/config.yaml",
16
];
17
18
/// Environment variable prefix for overrides.
19
const ENV_PREFIX: &str = "MIROIR";
20
21
/// Load configuration using layered approach:
22
/// 1. Search for config file in default paths
23
/// 2. Apply environment variable overrides (`MIROIR_*`)
24
/// 3. Returns validated config
25
0
pub fn load() -> Result<MiroirConfig, ConfigError> {
26
0
    let mut builder = ext_config::Config::builder();
27
28
0
    builder = builder.add_source(ext_config::Config::try_from(&MiroirConfig::default())?);
29
30
0
    for path in CONFIG_SEARCH_PATHS {
31
0
        if std::path::Path::new(path).exists() {
32
0
            builder = builder.add_source(ext_config::File::with_name(path));
33
0
            break;
34
0
        }
35
    }
36
37
0
    builder = builder.add_source(
38
0
        ext_config::Environment::with_prefix(ENV_PREFIX)
39
0
            .separator("_")
40
0
            .try_parsing(true),
41
    );
42
43
0
    let cfg: MiroirConfig = builder.build()?.try_deserialize()?;
44
0
    cfg.validate()?;
45
0
    Ok(cfg)
46
0
}
47
48
/// Load from a specific file path with env-var overrides applied.
49
0
pub fn load_from(path: &std::path::Path) -> Result<MiroirConfig, ConfigError> {
50
0
    let mut builder = ext_config::Config::builder();
51
52
0
    builder = builder.add_source(ext_config::Config::try_from(&MiroirConfig::default())?);
53
0
    builder = builder.add_source(ext_config::File::with_name(path.to_string_lossy().as_ref()));
54
55
0
    builder = builder.add_source(
56
0
        ext_config::Environment::with_prefix(ENV_PREFIX)
57
0
            .separator("_")
58
0
            .try_parsing(true),
59
    );
60
61
0
    let cfg: MiroirConfig = builder.build()?.try_deserialize()?;
62
0
    cfg.validate()?;
63
0
    Ok(cfg)
64
0
}
65
66
/// Load from a YAML string (useful for testing).
67
12
pub fn from_yaml(yaml: &str) -> Result<MiroirConfig, ConfigError> {
68
12
    let 
cfg10
:
MiroirConfig10
= serde_yaml::from_str(yaml)
?2
;
69
10
    cfg.validate()
?4
;
70
6
    Ok(cfg)
71
12
}
72
73
#[cfg(test)]
74
mod tests {
75
    use super::*;
76
77
    #[test]
78
2
    fn test_from_yaml_valid_config() {
79
2
        let yaml = r#"
80
2
shards: 32
81
2
replication_factor: 1
82
2
cdc:
83
2
  enabled: false
84
2
search_ui:
85
2
  rate_limit:
86
2
    backend: local
87
2
nodes: []
88
2
"#;
89
2
        let cfg = from_yaml(yaml).expect("should parse valid config");
90
2
        assert_eq!(cfg.shards, 32);
91
2
        assert_eq!(cfg.replication_factor, 1);
92
2
    }
93
94
    #[test]
95
2
    fn test_from_yaml_with_nodes() {
96
2
        let yaml = r#"
97
2
shards: 64
98
2
replication_factor: 1
99
2
replica_groups: 2
100
2
task_store:
101
2
  backend: redis
102
2
  url: "redis://localhost:6379"
103
2
nodes:
104
2
  - id: "node1"
105
2
    address: "http://node1:7700"
106
2
    replica_group: 0
107
2
  - id: "node2"
108
2
    address: "http://node2:7700"
109
2
    replica_group: 1
110
2
"#;
111
2
        let cfg = from_yaml(yaml).expect("should parse config with nodes");
112
2
        assert_eq!(cfg.nodes.len(), 2);
113
2
        assert_eq!(cfg.nodes[0].id, "node1");
114
2
        assert_eq!(cfg.nodes[1].replica_group, 1);
115
2
    }
116
117
    #[test]
118
2
    fn test_from_yaml_invalid_yaml_fails() {
119
2
        let yaml = r#"
120
2
shards: 32
121
2
replication_factor: invalid
122
2
nodes: []
123
2
"#;
124
2
        let result = from_yaml(yaml);
125
2
        assert!(result.is_err(), 
"should fail on invalid YAML"0
);
126
2
    }
127
128
    #[test]
129
2
    fn test_from_yaml_validation_fails_on_ha_with_sqlite() {
130
2
        let yaml = r#"
131
2
shards: 64
132
2
replication_factor: 2
133
2
nodes: []
134
2
"#;
135
2
        let result = from_yaml(yaml);
136
2
        assert!(result.is_err(), 
"should fail validation: RF=2 requires redis"0
);
137
2
    }
138
139
    #[test]
140
2
    fn test_from_yaml_validation_fails_on_zero_shards() {
141
2
        let yaml = r#"
142
2
shards: 0
143
2
replication_factor: 1
144
2
nodes: []
145
2
"#;
146
2
        let result = from_yaml(yaml);
147
2
        assert!(result.is_err(), 
"should fail validation: zero shards"0
);
148
2
    }
149
150
    #[test]
151
2
    fn test_from_yaml_with_all_sections() {
152
2
        let yaml = r#"
153
2
shards: 64
154
2
replication_factor: 1
155
2
replica_groups: 2
156
2
master_key: "test-key"
157
2
node_master_key: "node-key"
158
2
nodes:
159
2
  - id: "node1"
160
2
    address: "http://node1:7700"
161
2
    replica_group: 0
162
2
task_store:
163
2
  backend: redis
164
2
  url: "redis://localhost:6379"
165
2
admin:
166
2
  enabled: true
167
2
  api_key: "admin-key"
168
2
health:
169
2
  interval_ms: 5000
170
2
  timeout_ms: 2000
171
2
  unhealthy_threshold: 3
172
2
  recovery_threshold: 2
173
2
scatter:
174
2
  node_timeout_ms: 5000
175
2
  retry_on_timeout: true
176
2
  unavailable_shard_policy: partial
177
2
rebalancer:
178
2
  auto_rebalance_on_recovery: true
179
2
  max_concurrent_migrations: 4
180
2
  migration_timeout_s: 3600
181
2
server:
182
2
  port: 7700
183
2
  bind: "0.0.0.0"
184
2
  max_body_bytes: 104857600
185
2
leader_election:
186
2
  enabled: true
187
2
"#;
188
2
        let cfg = from_yaml(yaml).expect("should parse full config");
189
2
        assert_eq!(cfg.shards, 64);
190
2
        assert_eq!(cfg.master_key, "test-key");
191
2
        assert_eq!(cfg.admin.api_key, "admin-key");
192
2
        assert_eq!(cfg.health.interval_ms, 5000);
193
2
        assert_eq!(cfg.scatter.node_timeout_ms, 5000);
194
2
    }
195
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/validate.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/validate.rs.html deleted file mode 100644 index fd09b15..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/config/validate.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/config/validate.rs
Line
Count
Source
1
use crate::config::{ConfigError, MiroirConfig};
2
3
22
pub fn validate(cfg: &MiroirConfig) -> Result<(), ConfigError> {
4
    // replication_factor > 1 requires redis backend for HA
5
22
    if cfg.replication_factor > 1 && 
cfg.task_store.backend == "sqlite"6
{
6
4
        return Err(ConfigError::Validation(
7
4
            "replication_factor > 1 requires task_store.backend = 'redis' (SQLite is single-writer)".into(),
8
4
        ));
9
18
    }
10
11
    // replica_groups > 1 requires redis backend
12
18
    if cfg.replica_groups > 1 && 
cfg.task_store.backend == "sqlite"6
{
13
0
        return Err(ConfigError::Validation(
14
0
            "replica_groups > 1 requires task_store.backend = 'redis' (SQLite is single-writer)"
15
0
                .into(),
16
0
        ));
17
18
    }
18
19
    // Nodes must belong to a valid replica group
20
18
    if cfg.replica_groups > 0 {
21
32
        for 
node16
in &cfg.nodes {
22
16
            if node.replica_group >= cfg.replica_groups {
23
2
                return Err(ConfigError::Validation(format!(
24
2
                    "node '{}' has replica_group={} but only {} groups exist (0..{})",
25
2
                    node.id,
26
2
                    node.replica_group,
27
2
                    cfg.replica_groups,
28
2
                    cfg.replica_groups - 1
29
2
                )));
30
14
            }
31
        }
32
0
    }
33
34
    // Node IDs must be unique
35
16
    let mut seen_ids = std::collections::HashSet::new();
36
28
    for 
node14
in &cfg.nodes {
37
14
        if !seen_ids.insert(&node.id) {
38
2
            return Err(ConfigError::Validation(format!(
39
2
                "duplicate node id: '{}'",
40
2
                node.id
41
2
            )));
42
12
        }
43
    }
44
45
    // HPA enabled requires redis backend
46
14
    if cfg.hpa.enabled && 
cfg.task_store.backend == "sqlite"0
{
47
0
        return Err(ConfigError::Validation(
48
0
            "hpa.enabled = true requires task_store.backend = 'redis'".into(),
49
0
        ));
50
14
    }
51
52
    // Search UI scoped_key timing validation
53
14
    if cfg.search_ui.enabled {
54
14
        let max_age = cfg.search_ui.scoped_key_max_age_days;
55
14
        let rotate_before = cfg.search_ui.scoped_key_rotate_before_expiry_days;
56
14
        if rotate_before >= max_age {
57
2
            return Err(ConfigError::Validation(format!(
58
2
                "search_ui.scoped_key_rotate_before_expiry_days ({rotate_before}) must be strictly less than scoped_key_max_age_days ({max_age})"
59
2
            )));
60
12
        }
61
0
    }
62
63
    // CDC overflow = redis requires redis backend
64
12
    if cfg.cdc.enabled && 
cfg.cdc.buffer.overflow == "redis"10
&&
cfg.task_store.backend != "redis"8
{
65
2
        return Err(ConfigError::Validation(
66
2
            "cdc.buffer.overflow = 'redis' requires task_store.backend = 'redis'".into(),
67
2
        ));
68
10
    }
69
70
    // Search UI rate_limit.backend = redis requires redis task store (when multi-pod)
71
10
    if cfg.search_ui.enabled
72
10
        && cfg.search_ui.rate_limit.backend == "redis"
73
6
        && cfg.task_store.backend != "redis"
74
    {
75
0
        return Err(ConfigError::Validation(
76
0
            "search_ui.rate_limit.backend = 'redis' requires task_store.backend = 'redis'".into(),
77
0
        ));
78
10
    }
79
80
    // Leader election should be enabled when replica_groups > 1
81
10
    if cfg.replica_groups > 1 && 
!cfg.leader_election.enabled6
{
82
0
        return Err(ConfigError::Validation(
83
0
            "leader_election.enabled must be true when replica_groups > 1".into(),
84
0
        ));
85
10
    }
86
87
    // Tenant affinity dedicated_groups must be within valid range
88
10
    if cfg.tenant_affinity.enabled {
89
10
        for 
g0
in &cfg.tenant_affinity.dedicated_groups {
90
0
            if *g >= cfg.replica_groups {
91
0
                return Err(ConfigError::Validation(format!(
92
0
                    "tenant_affinity.dedicated_groups contains {} but only {} groups (0..{})",
93
0
                    g,
94
0
                    cfg.replica_groups,
95
0
                    cfg.replica_groups - 1
96
0
                )));
97
0
            }
98
        }
99
10
        for (
tenant0
,
group0
) in &cfg.tenant_affinity.static_map {
100
0
            if *group >= cfg.replica_groups {
101
0
                return Err(ConfigError::Validation(format!(
102
0
                    "tenant_affinity.static_map: tenant '{}' maps to group {} but only {} groups (0..{})",
103
0
                    tenant,
104
0
                    group,
105
0
                    cfg.replica_groups,
106
0
                    cfg.replica_groups - 1
107
0
                )));
108
0
            }
109
        }
110
0
    }
111
112
    // Shadow targets must have valid sample_rate
113
10
    if cfg.shadow.enabled {
114
10
        for 
target0
in &cfg.shadow.targets {
115
0
            if target.sample_rate <= 0.0 || target.sample_rate > 1.0 {
116
0
                return Err(ConfigError::Validation(format!(
117
0
                    "shadow target '{}' has invalid sample_rate={} (must be 0 < rate <= 1)",
118
0
                    target.name, target.sample_rate
119
0
                )));
120
0
            }
121
        }
122
0
    }
123
124
    // Server port must be non-zero
125
10
    if cfg.server.port == 0 {
126
0
        return Err(ConfigError::Validation(
127
0
            "server.port must be non-zero".into(),
128
0
        ));
129
10
    }
130
131
    // shards must be non-zero
132
10
    if cfg.shards == 0 {
133
2
        return Err(ConfigError::Validation("shards must be non-zero".into()));
134
8
    }
135
136
    // replication_factor must be > 0
137
8
    if cfg.replication_factor == 0 {
138
0
        return Err(ConfigError::Validation(
139
0
            "replication_factor must be > 0".into(),
140
0
        ));
141
8
    }
142
143
8
    Ok(())
144
22
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/merger.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/merger.rs.html deleted file mode 100644 index fc8554b..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/merger.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/merger.rs
Line
Count
Source
1
//! Result merger: combines shard results into a single response.
2
3
use crate::Result;
4
use serde::{Deserialize, Serialize};
5
use serde_json::Value;
6
use std::collections::{BTreeMap, BinaryHeap};
7
8
/// Input to the merge function.
9
#[derive(Debug, Clone, Serialize, Deserialize)]
10
pub struct MergeInput {
11
    /// One page of hits per node in the covering set.
12
    pub shard_hits: Vec<ShardHitPage>,
13
14
    /// Offset to apply after merge.
15
    pub offset: usize,
16
17
    /// Limit to apply after merge.
18
    pub limit: usize,
19
20
    /// Whether the client requested the ranking score in the response.
21
    pub client_requested_score: bool,
22
23
    /// Facet names to include (if None, include all).
24
    pub facets: Option<Vec<String>>,
25
}
26
27
/// A page of hits from a single shard.
28
#[derive(Debug, Clone, Serialize, Deserialize)]
29
pub struct ShardHitPage {
30
    /// Raw JSON response from the node.
31
    pub body: Value,
32
33
    /// Whether this shard succeeded.
34
    pub success: bool,
35
}
36
37
/// Merged search result.
38
#[derive(Debug, Clone, Serialize, Deserialize)]
39
pub struct MergedSearchResult {
40
    /// Merged hits (globally sorted, offset/limit applied).
41
    pub hits: Vec<Value>,
42
43
    /// Aggregated facets.
44
    pub facets: Value,
45
46
    /// Estimated total hits (sum of shard totals).
47
    pub estimated_total_hits: u64,
48
49
    /// Processing time in milliseconds.
50
    pub processing_time_ms: u64,
51
52
    /// Whether the response is degraded (some shards failed).
53
    pub degraded: bool,
54
}
55
56
/// Merge search results from multiple shards.
57
///
58
/// This is the primary entry point for result merging. It:
59
/// 1. Collects all hits with scores from all shards
60
/// 2. Sorts globally by `_rankingScore` descending
61
/// 3. Applies offset + limit after merge
62
/// 4. Strips `_rankingScore` if client didn't request it
63
/// 5. Always strips `_miroir_*` reserved fields
64
/// 6. Sums facet counts across shards
65
/// 7. Sums `estimatedTotalHits` across shards
66
/// 8. Takes max `processingTimeMs` across shards
67
///
68
/// Uses a binary min-heap to avoid keeping all hits in RAM when fan-out is large.
69
/// Uses BTreeMap for stable facet serialization (deterministic JSON output).
70
54
pub fn merge(input: MergeInput) -> MergedSearchResult {
71
    // Filter to only successful responses
72
54
    let successful_shards: Vec<_> = input
73
54
        .shard_hits
74
54
        .iter()
75
54
        .filter(|s| s.success)
76
54
        .collect();
77
78
    // Check if any shards failed (degraded mode)
79
54
    let degraded = successful_shards.len() < input.shard_hits.len();
80
81
    // Collect all hits with their ranking scores and primary keys
82
54
    let mut all_hits: Vec<HitWithScore> = Vec::new();
83
84
128
    for 
shard74
in &successful_shards {
85
74
        if let Some(hits) = shard.body.get("hits").and_then(|h| h.as_array()) {
86
924
            for 
hit850
in hits {
87
850
                let score = hit
88
850
                    .get("_rankingScore")
89
850
                    .and_then(|s| s.as_f64())
90
850
                    .unwrap_or(0.0);
91
92
850
                let primary_key = hit
93
850
                    .get("id")
94
850
                    .and_then(|id| id.as_str())
95
850
                    .unwrap_or("")
96
850
                    .to_string();
97
98
850
                all_hits.push(HitWithScore {
99
850
                    score,
100
850
                    primary_key,
101
850
                    hit: hit.clone(),
102
850
                });
103
            }
104
0
        }
105
    }
106
107
    // Use a min-heap of size offset + limit to avoid keeping all hits in RAM
108
54
    let heap_size = input.offset + input.limit;
109
54
    let top_hits = if all_hits.len() > heap_size * 2 {
110
        // Only use heap optimization if we have significantly more hits than needed
111
        // Use BinaryHeap with Reverse to get min-heap behavior
112
        // Reverse<T> makes the "smallest" T become the "largest" Reverse<T>
113
        // So in BinaryHeap (max-heap), the smallest T ends up at the top
114
        use std::cmp::Reverse;
115
116
        // First, define a wrapper with natural ascending order by score
117
        #[derive(Debug, Clone, PartialEq, Eq)]
118
        struct AscendingHit(HitWithScore);
119
120
        impl PartialOrd for AscendingHit {
121
968
            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
122
968
                Some(self.cmp(other))
123
968
            }
124
        }
125
126
        impl Ord for AscendingHit {
127
968
            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
128
                // Ascending order: lower score first, then primary key ascending
129
968
                match self.0.score.partial_cmp(&other.0.score) {
130
                    Some(std::cmp::Ordering::Equal) | None => {
131
0
                        self.0.primary_key.cmp(&other.0.primary_key)
132
                    }
133
968
                    Some(ord) => ord,
134
                }
135
968
            }
136
        }
137
138
        // Reverse<AscendingHit> in BinaryHeap gives us a min-heap by score
139
        // The lowest score (worst) will be at the top for easy replacement
140
6
        let mut heap: BinaryHeap<Reverse<AscendingHit>> = BinaryHeap::new();
141
142
406
        for 
hit400
in all_hits {
143
400
            if heap.len() < heap_size {
144
80
                heap.push(Reverse(AscendingHit(hit)));
145
80
            } else {
146
                // Peek at the smallest (worst) hit in our top-k
147
320
                if let Some(Reverse(worst)) = heap.peek() {
148
                    // If current hit is better than our worst, replace it
149
                    // Compare using the score directly
150
320
                    if hit.score > worst.0.score ||
151
180
                       (
hit.score == worst.0.score140
&&
hit.primary_key < worst.0.primary_key0
) {
152
180
                        heap.pop();
153
180
                        heap.push(Reverse(AscendingHit(hit)));
154
180
                    
}140
155
0
                }
156
            }
157
        }
158
159
        // Convert to sorted vector (descending by score)
160
6
        let mut result: Vec<_> = heap.into_iter().map(|r| (r.0).0).collect();
161
500
        
result6
.
sort_by6
(|a, b| a.descending_cmp(b));
162
6
        result
163
    } else {
164
        // For smaller result sets, just sort directly
165
436
        
all_hits48
.
sort_by48
(|a, b| a.descending_cmp(b));
166
48
        all_hits
167
    };
168
169
    // Apply offset and limit
170
54
    let page: Vec<Value> = top_hits
171
54
        .into_iter()
172
54
        .skip(input.offset)
173
54
        .take(input.limit)
174
260
        .
map54
(|mut hit_with_score| {
175
260
            let hit = &mut hit_with_score.hit;
176
177
            // Strip all _miroir_* fields (always removed)
178
260
            if let Some(obj) = hit.as_object_mut() {
179
784
                
obj260
.
retain260
(|k, _| !k.starts_with("_miroir_"));
180
0
            }
181
182
            // Strip _rankingScore if client didn't request it
183
260
            if !input.client_requested_score {
184
252
                if let Some(obj) = hit.as_object_mut() {
185
252
                    obj.remove("_rankingScore");
186
252
                
}0
187
8
            }
188
189
260
            hit.clone()
190
260
        })
191
54
        .collect();
192
193
    // Aggregate facets across all shards
194
54
    let facets = merge_facets(&successful_shards, input.facets.as_deref());
195
196
    // Sum estimated total hits
197
54
    let estimated_total_hits: u64 = successful_shards
198
54
        .iter()
199
74
        .
filter_map54
(|s| s.body.get("estimatedTotalHits").and_then(|v| v.as_u64()))
200
54
        .sum();
201
202
    // Max processing time across all shards
203
54
    let processing_time_ms: u64 = successful_shards
204
54
        .iter()
205
74
        .
filter_map54
(|s| s.body.get("processingTimeMs").and_then(|v| v.as_u64()))
206
54
        .max()
207
54
        .unwrap_or(0);
208
209
54
    MergedSearchResult {
210
54
        hits: page,
211
54
        facets,
212
54
        estimated_total_hits,
213
54
        processing_time_ms,
214
54
        degraded,
215
54
    }
216
54
}
217
218
/// A hit with its ranking score for sorting.
219
#[derive(Debug, Clone)]
220
struct HitWithScore {
221
    score: f64,
222
    primary_key: String,
223
    hit: Value,
224
}
225
226
impl PartialEq for HitWithScore {
227
0
    fn eq(&self, other: &Self) -> bool {
228
0
        self.score == other.score && self.primary_key == other.primary_key
229
0
    }
230
}
231
232
impl Eq for HitWithScore {}
233
234
impl PartialOrd for HitWithScore {
235
0
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
236
0
        Some(self.cmp(other))
237
0
    }
238
}
239
240
impl HitWithScore {
241
    /// Compare for descending order (score desc, primary key asc).
242
936
    fn descending_cmp(&self, other: &Self) -> std::cmp::Ordering {
243
        // Sort by score descending, then by primary key ascending for tie-breaking
244
936
        match other.score.partial_cmp(&self.score) {
245
            Some(std::cmp::Ordering::Equal) | None => {
246
                // On equal scores (or NaN), fall back to primary key ascending
247
6
                self.primary_key.cmp(&other.primary_key)
248
            }
249
930
            Some(ord) => ord,
250
        }
251
936
    }
252
}
253
254
impl Ord for HitWithScore {
255
0
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
256
        // Sort by score descending, then by primary key ascending for tie-breaking
257
0
        match self.score.partial_cmp(&other.score) {
258
            Some(std::cmp::Ordering::Equal) | None => {
259
                // On equal scores (or NaN), fall back to primary key
260
                // Reversed for descending order
261
0
                other.primary_key.cmp(&self.primary_key)
262
            }
263
0
            Some(ord) => ord.reverse(),
264
        }
265
0
    }
266
}
267
268
/// Result merger: combines responses from multiple shards.
269
pub trait Merger: Send + Sync {
270
    /// Merge search results from multiple shards.
271
    ///
272
    /// Takes the raw JSON responses from each shard and produces
273
    /// a merged result with global sorting, offset/limit applied,
274
    /// and facet aggregation.
275
    fn merge(
276
        &self,
277
        shard_responses: Vec<ShardResponse>,
278
        offset: usize,
279
        limit: usize,
280
        client_requested_score: bool,
281
    ) -> Result<MergedResult>;
282
}
283
284
/// Response from a single shard.
285
#[derive(Debug, Clone)]
286
pub struct ShardResponse {
287
    /// Shard identifier.
288
    pub shard_id: u32,
289
290
    /// Raw JSON response from the node.
291
    pub body: Value,
292
293
    /// Whether this shard succeeded.
294
    pub success: bool,
295
}
296
297
/// Merged search result (legacy, use MergedSearchResult instead).
298
#[derive(Debug, Clone)]
299
pub struct MergedResult {
300
    /// Merged hits (globally sorted, offset/limit applied).
301
    pub hits: Vec<Value>,
302
303
    /// Aggregated facets.
304
    pub facets: Value,
305
306
    /// Estimated total hits (sum of shard totals).
307
    pub total_hits: u64,
308
309
    /// Processing time in milliseconds.
310
    pub processing_time_ms: u64,
311
312
    /// Whether the response is degraded (some shards failed).
313
    pub degraded: bool,
314
}
315
316
/// Default implementation of Merger.
317
#[derive(Debug, Clone, Default)]
318
pub struct MergerImpl;
319
320
impl Merger for MergerImpl {
321
48
    fn merge(
322
48
        &self,
323
48
        shard_responses: Vec<ShardResponse>,
324
48
        offset: usize,
325
48
        limit: usize,
326
48
        client_requested_score: bool,
327
48
    ) -> Result<MergedResult> {
328
        // Convert ShardResponse to ShardHitPage
329
48
        let shard_hits: Vec<ShardHitPage> = shard_responses
330
48
            .into_iter()
331
48
            .map(|sr| ShardHitPage {
332
66
                body: sr.body,
333
66
                success: sr.success,
334
66
            })
335
48
            .collect();
336
337
        // Call the new merge function
338
48
        let input = MergeInput {
339
48
            shard_hits,
340
48
            offset,
341
48
            limit,
342
48
            client_requested_score,
343
48
            facets: None,
344
48
        };
345
346
48
        let result = merge(input);
347
348
48
        Ok(MergedResult {
349
48
            hits: result.hits,
350
48
            facets: result.facets,
351
48
            total_hits: result.estimated_total_hits,
352
48
            processing_time_ms: result.processing_time_ms,
353
48
            degraded: result.degraded,
354
48
        })
355
48
    }
356
}
357
358
/// Merge facet distributions from all shards.
359
///
360
/// Facets are nested objects like `{"color": {"red": 10, "blue": 5}}`.
361
/// We sum counts for each facet value across all shards.
362
///
363
/// Uses BTreeMap for stable, deterministic serialization.
364
54
fn merge_facets(shards: &[&ShardHitPage], facet_filter: Option<&[String]>) -> Value {
365
54
    let mut merged_facets: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
366
367
128
    for 
shard74
in shards {
368
74
        if let Some(
facets66
) = shard
369
74
            .body
370
74
            .get("facetDistribution")
371
74
            .and_then(|f| 
f66
.
as_object66
())
372
        {
373
100
            for (
facet_name34
,
facet_values34
) in facets {
374
                // Apply facet filter if provided
375
34
                if let Some(
filter4
) = facet_filter {
376
4
                    if !filter.contains(&facet_name) {
377
2
                        continue;
378
2
                    }
379
30
                }
380
381
32
                if let Some(values_obj) = facet_values.as_object() {
382
32
                    let entry = merged_facets.entry(facet_name.clone()).or_default();
383
384
88
                    for (
value56
,
count56
) in values_obj {
385
56
                        let count_val = count.as_u64().unwrap_or(0);
386
56
                        *entry.entry(value.clone()).or_insert(0) += count_val;
387
56
                    }
388
0
                }
389
            }
390
8
        }
391
    }
392
393
    // Convert back to JSON structure (BTreeMap ensures stable key order)
394
54
    let result: serde_json::Map<String, Value> = merged_facets
395
54
        .into_iter()
396
54
        .map(|(facet_name, values)| 
{18
397
18
            let values_obj: serde_json::Map<String, Value> = values
398
18
                .into_iter()
399
44
                .
map18
(|(k, v)| (k, Value::Number(v.into())))
400
18
                .collect();
401
18
            (facet_name, Value::Object(values_obj))
402
18
        })
403
54
        .collect();
404
405
54
    Value::Object(result)
406
54
}
407
408
/// Stub implementation that returns empty results.
409
#[derive(Debug, Clone, Default)]
410
pub struct StubMerger;
411
412
impl Merger for StubMerger {
413
0
    fn merge(
414
0
        &self,
415
0
        _shard_responses: Vec<ShardResponse>,
416
0
        _offset: usize,
417
0
        _limit: usize,
418
0
        _client_requested_score: bool,
419
0
    ) -> Result<MergedResult> {
420
0
        Ok(MergedResult {
421
0
            hits: Vec::new(),
422
0
            facets: serde_json::json!({}),
423
0
            total_hits: 0,
424
0
            processing_time_ms: 0,
425
0
            degraded: false,
426
0
        })
427
0
    }
428
}
429
430
#[cfg(test)]
431
mod tests {
432
    use super::*;
433
434
348
    fn create_hit(score: f64, id: &str) -> Value {
435
348
        serde_json::json!({
436
348
            "id": id,
437
348
            "_rankingScore": score,
438
348
            "_miroir_shard": 0,
439
        })
440
348
    }
441
442
46
    fn create_shard_response(shard_id: u32, hits: Vec<Value>, total: u64) -> ShardResponse {
443
46
        ShardResponse {
444
46
            shard_id,
445
46
            body: serde_json::json!({
446
46
                "hits": hits,
447
46
                "estimatedTotalHits": total,
448
46
                "processingTimeMs": 10,
449
46
                "facetDistribution": {},
450
46
            }),
451
46
            success: true,
452
46
        }
453
46
    }
454
455
    #[test]
456
2
    fn test_global_sort_by_ranking_score() {
457
2
        let merger = MergerImpl;
458
459
2
        let hits1 = vec![
460
2
            create_hit(0.5, "doc1"),
461
2
            create_hit(0.9, "doc2"),
462
2
            create_hit(0.3, "doc3"),
463
        ];
464
2
        let hits2 = vec![
465
2
            create_hit(0.7, "doc4"),
466
2
            create_hit(0.1, "doc5"),
467
2
            create_hit(0.8, "doc6"),
468
        ];
469
470
2
        let shards = vec![
471
2
            create_shard_response(0, hits1, 3),
472
2
            create_shard_response(1, hits2, 3),
473
        ];
474
475
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
476
477
        // Check global ordering: should be doc2(0.9), doc6(0.8), doc4(0.7), doc1(0.5), doc3(0.3), doc5(0.1)
478
2
        assert_eq!(result.hits.len(), 6);
479
2
        assert_eq!(result.hits[0]["id"], "doc2");
480
2
        assert_eq!(result.hits[1]["id"], "doc6");
481
2
        assert_eq!(result.hits[2]["id"], "doc4");
482
2
        assert_eq!(result.hits[3]["id"], "doc1");
483
2
        assert_eq!(result.hits[4]["id"], "doc3");
484
2
        assert_eq!(result.hits[5]["id"], "doc5");
485
2
    }
486
487
    #[test]
488
2
    fn test_offset_and_limit_applied_after_merge() {
489
2
        let merger = MergerImpl;
490
491
2
        let hits = vec![
492
2
            create_hit(0.9, "doc1"),
493
2
            create_hit(0.8, "doc2"),
494
2
            create_hit(0.7, "doc3"),
495
2
            create_hit(0.6, "doc4"),
496
2
            create_hit(0.5, "doc5"),
497
        ];
498
499
2
        let shards = vec![create_shard_response(0, hits, 5)];
500
501
2
        let result = merger.merge(shards, 2, 2, false).unwrap();
502
503
        // Should skip first 2, take next 2
504
2
        assert_eq!(result.hits.len(), 2);
505
2
        assert_eq!(result.hits[0]["id"], "doc3");
506
2
        assert_eq!(result.hits[1]["id"], "doc4");
507
2
    }
508
509
    #[test]
510
2
    fn test_ranking_score_stripped_when_not_requested() {
511
2
        let merger = MergerImpl;
512
513
2
        let hits = vec![create_hit(0.9, "doc1"), create_hit(0.8, "doc2")];
514
515
2
        let shards = vec![create_shard_response(0, hits, 2)];
516
517
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
518
519
        // _rankingScore should be stripped
520
2
        assert!(result.hits[0].get("_rankingScore").is_none());
521
2
        assert!(result.hits[1].get("_rankingScore").is_none());
522
2
    }
523
524
    #[test]
525
2
    fn test_ranking_score_included_when_requested() {
526
2
        let merger = MergerImpl;
527
528
2
        let hits = vec![create_hit(0.9, "doc1"), create_hit(0.8, "doc2")];
529
530
2
        let shards = vec![create_shard_response(0, hits, 2)];
531
532
2
        let result = merger.merge(shards, 0, 10, true).unwrap();
533
534
        // _rankingScore should be present
535
2
        assert_eq!(result.hits[0]["_rankingScore"], 0.9);
536
2
        assert_eq!(result.hits[1]["_rankingScore"], 0.8);
537
2
    }
538
539
    #[test]
540
2
    fn test_miroir_shard_always_stripped() {
541
2
        let merger = MergerImpl;
542
543
2
        let hits = vec![create_hit(0.9, "doc1")];
544
545
2
        let shards = vec![create_shard_response(0, hits, 1)];
546
547
2
        let result = merger.merge(shards, 0, 10, true).unwrap();
548
549
        // _miroir_shard should always be stripped
550
2
        assert!(result.hits[0].get("_miroir_shard").is_none());
551
2
    }
552
553
    #[test]
554
2
    fn test_facet_counts_summed_across_shards() {
555
2
        let merger = MergerImpl;
556
557
2
        let shard1 = serde_json::json!({
558
2
            "hits": [],
559
2
            "estimatedTotalHits": 0,
560
2
            "processingTimeMs": 10,
561
2
            "facetDistribution": {
562
2
                "color": {
563
2
                    "red": 10,
564
2
                    "blue": 5,
565
                },
566
2
                "size": {
567
2
                    "large": 8,
568
                }
569
            }
570
        });
571
572
2
        let shard2 = serde_json::json!({
573
2
            "hits": [],
574
2
            "estimatedTotalHits": 0,
575
2
            "processingTimeMs": 15,
576
2
            "facetDistribution": {
577
2
                "color": {
578
2
                    "red": 7,
579
2
                    "green": 3,
580
                },
581
2
                "size": {
582
2
                    "large": 4,
583
2
                    "small": 6,
584
                }
585
            }
586
        });
587
588
2
        let shards = vec![
589
2
            ShardResponse {
590
2
                shard_id: 0,
591
2
                body: shard1,
592
2
                success: true,
593
2
            },
594
2
            ShardResponse {
595
2
                shard_id: 1,
596
2
                body: shard2,
597
2
                success: true,
598
2
            },
599
        ];
600
601
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
602
603
2
        let facets = result.facets;
604
2
        let color = facets.get("color").unwrap().as_object().unwrap();
605
2
        assert_eq!(color.get("red").unwrap().as_u64().unwrap(), 17); // 10 + 7
606
2
        assert_eq!(color.get("blue").unwrap().as_u64().unwrap(), 5); // only in shard1
607
2
        assert_eq!(color.get("green").unwrap().as_u64().unwrap(), 3); // only in shard2
608
609
2
        let size = facets.get("size").unwrap().as_object().unwrap();
610
2
        assert_eq!(size.get("large").unwrap().as_u64().unwrap(), 12); // 8 + 4
611
2
        assert_eq!(size.get("small").unwrap().as_u64().unwrap(), 6); // only in shard2
612
2
    }
613
614
    #[test]
615
2
    fn test_estimated_total_hits_summed() {
616
2
        let merger = MergerImpl;
617
618
2
        let shards = vec![
619
2
            create_shard_response(0, vec![], 100),
620
2
            create_shard_response(1, vec![], 150),
621
2
            create_shard_response(2, vec![], 75),
622
        ];
623
624
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
625
626
2
        assert_eq!(result.total_hits, 325); // 100 + 150 + 75
627
2
    }
628
629
    #[test]
630
2
    fn test_processing_time_max_across_shards() {
631
2
        let merger = MergerImpl;
632
633
2
        let shard1 = serde_json::json!({
634
2
            "hits": [],
635
2
            "estimatedTotalHits": 0,
636
2
            "processingTimeMs": 10,
637
        });
638
639
2
        let shard2 = serde_json::json!({
640
2
            "hits": [],
641
2
            "estimatedTotalHits": 0,
642
2
            "processingTimeMs": 25,
643
        });
644
645
2
        let shard3 = serde_json::json!({
646
2
            "hits": [],
647
2
            "estimatedTotalHits": 0,
648
2
            "processingTimeMs": 15,
649
        });
650
651
2
        let shards = vec![
652
2
            ShardResponse {
653
2
                shard_id: 0,
654
2
                body: shard1,
655
2
                success: true,
656
2
            },
657
2
            ShardResponse {
658
2
                shard_id: 1,
659
2
                body: shard2,
660
2
                success: true,
661
2
            },
662
2
            ShardResponse {
663
2
                shard_id: 2,
664
2
                body: shard3,
665
2
                success: true,
666
2
            },
667
        ];
668
669
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
670
671
2
        assert_eq!(result.processing_time_ms, 25); // max(10, 25, 15)
672
2
    }
673
674
    #[test]
675
2
    fn test_degraded_flag_when_shard_fails() {
676
2
        let merger = MergerImpl;
677
678
2
        let hits = vec![create_hit(0.9, "doc1")];
679
680
2
        let shards = vec![
681
2
            create_shard_response(0, hits.clone(), 1),
682
2
            ShardResponse {
683
2
                shard_id: 1,
684
2
                body: serde_json::json!({}),
685
2
                success: false,
686
2
            },
687
        ];
688
689
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
690
691
2
        assert!(result.degraded);
692
2
        assert_eq!(result.hits.len(), 1);
693
2
    }
694
695
    #[test]
696
2
    fn test_not_degraded_when_all_succeed() {
697
2
        let merger = MergerImpl;
698
699
2
        let shards = vec![
700
2
            create_shard_response(0, vec![], 0),
701
2
            create_shard_response(1, vec![], 0),
702
        ];
703
704
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
705
706
2
        assert!(!result.degraded);
707
2
    }
708
709
    #[test]
710
2
    fn test_empty_shards_returns_empty_result() {
711
2
        let merger = MergerImpl;
712
713
2
        let result = merger.merge(vec![], 0, 10, false).unwrap();
714
715
2
        assert!(result.hits.is_empty());
716
2
        assert_eq!(result.total_hits, 0);
717
2
        assert_eq!(result.facets.as_object().unwrap().len(), 0);
718
2
        assert!(!result.degraded);
719
2
    }
720
721
    #[test]
722
2
    fn test_facet_keys_unique_to_one_shard_preserved() {
723
2
        let merger = MergerImpl;
724
725
2
        let shard1 = serde_json::json!({
726
2
            "hits": [],
727
2
            "estimatedTotalHits": 0,
728
2
            "processingTimeMs": 10,
729
2
            "facetDistribution": {
730
2
                "category": {
731
2
                    "electronics": 20,
732
2
                    "books": 15,
733
                }
734
            }
735
        });
736
737
2
        let shard2 = serde_json::json!({
738
2
            "hits": [],
739
2
            "estimatedTotalHits": 0,
740
2
            "processingTimeMs": 10,
741
2
            "facetDistribution": {
742
2
                "category": {
743
2
                    "clothing": 30,
744
2
                    "food": 10,
745
                }
746
            }
747
        });
748
749
2
        let shards = vec![
750
2
            ShardResponse {
751
2
                shard_id: 0,
752
2
                body: shard1,
753
2
                success: true,
754
2
            },
755
2
            ShardResponse {
756
2
                shard_id: 1,
757
2
                body: shard2,
758
2
                success: true,
759
2
            },
760
        ];
761
762
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
763
764
2
        let category = result.facets.get("category").unwrap().as_object().unwrap();
765
2
        assert_eq!(category.get("electronics").unwrap().as_u64().unwrap(), 20);
766
2
        assert_eq!(category.get("books").unwrap().as_u64().unwrap(), 15);
767
2
        assert_eq!(category.get("clothing").unwrap().as_u64().unwrap(), 30);
768
2
        assert_eq!(category.get("food").unwrap().as_u64().unwrap(), 10);
769
2
    }
770
771
    #[test]
772
2
    fn test_missing_facet_distribution_handled_gracefully() {
773
2
        let merger = MergerImpl;
774
775
2
        let shard_with_facets = serde_json::json!({
776
2
            "hits": [],
777
2
            "estimatedTotalHits": 0,
778
2
            "processingTimeMs": 10,
779
2
            "facetDistribution": {
780
2
                "color": {"red": 5}
781
            }
782
        });
783
784
2
        let shard_without_facets = serde_json::json!({
785
2
            "hits": [],
786
2
            "estimatedTotalHits": 0,
787
2
            "processingTimeMs": 10,
788
        });
789
790
2
        let shards = vec![
791
2
            ShardResponse {
792
2
                shard_id: 0,
793
2
                body: shard_with_facets,
794
2
                success: true,
795
2
            },
796
2
            ShardResponse {
797
2
                shard_id: 1,
798
2
                body: shard_without_facets,
799
2
                success: true,
800
2
            },
801
        ];
802
803
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
804
805
        // Should still have facets from the shard that provided them
806
2
        let color = result.facets.get("color").unwrap().as_object().unwrap();
807
2
        assert_eq!(color.get("red").unwrap().as_u64().unwrap(), 5);
808
2
    }
809
810
    #[test]
811
2
    fn test_offset_exceeds_total_hits() {
812
2
        let merger = MergerImpl;
813
814
2
        let hits = vec![create_hit(0.9, "doc1"), create_hit(0.8, "doc2")];
815
816
2
        let shards = vec![create_shard_response(0, hits, 2)];
817
818
2
        let result = merger.merge(shards, 10, 10, false).unwrap();
819
820
2
        assert!(result.hits.is_empty());
821
2
    }
822
823
    #[test]
824
2
    fn test_limit_exceeds_available_hits() {
825
2
        let merger = MergerImpl;
826
827
2
        let hits = vec![create_hit(0.9, "doc1"), create_hit(0.8, "doc2")];
828
829
2
        let shards = vec![create_shard_response(0, hits, 2)];
830
831
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
832
833
2
        assert_eq!(result.hits.len(), 2);
834
2
    }
835
836
    #[test]
837
2
    fn test_tie_breaking_by_primary_key() {
838
2
        let merger = MergerImpl;
839
840
2
        let hits = vec![
841
2
            create_hit(0.5, "zebra"),
842
2
            create_hit(0.5, "apple"),
843
2
            create_hit(0.5, "banana"),
844
        ];
845
846
2
        let shards = vec![create_shard_response(0, hits, 3)];
847
848
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
849
850
        // All have score 0.5, so should be sorted by primary_key ascending
851
2
        assert_eq!(result.hits[0]["id"], "apple");
852
2
        assert_eq!(result.hits[1]["id"], "banana");
853
2
        assert_eq!(result.hits[2]["id"], "zebra");
854
2
    }
855
856
    #[test]
857
2
    fn test_stable_serialization_same_input_same_json() {
858
2
        let shard1 = serde_json::json!({
859
2
            "hits": [],
860
2
            "estimatedTotalHits": 0,
861
2
            "processingTimeMs": 10,
862
2
            "facetDistribution": {
863
2
                "color": {"red": 10, "blue": 5},
864
2
                "size": {"large": 8}
865
            }
866
        });
867
868
2
        let shard2 = serde_json::json!({
869
2
            "hits": [],
870
2
            "estimatedTotalHits": 0,
871
2
            "processingTimeMs": 15,
872
2
            "facetDistribution": {
873
2
                "color": {"red": 7, "green": 3},
874
2
                "size": {"large": 4, "small": 6}
875
            }
876
        });
877
878
2
        let input = MergeInput {
879
2
            shard_hits: vec![
880
2
                ShardHitPage { body: shard1, success: true },
881
2
                ShardHitPage { body: shard2, success: true },
882
2
            ],
883
2
            offset: 0,
884
2
            limit: 10,
885
2
            client_requested_score: false,
886
2
            facets: None,
887
2
        };
888
889
2
        let result1 = merge(input.clone());
890
2
        let result2 = merge(input);
891
892
        // Serialize both results to JSON
893
2
        let json1 = serde_json::to_string(&result1).unwrap();
894
2
        let json2 = serde_json::to_string(&result2).unwrap();
895
896
        // Must be byte-identical
897
2
        assert_eq!(json1, json2);
898
2
    }
899
900
    #[test]
901
2
    fn test_binary_heap_efficiency_large_fan_out() {
902
2
        let merger = MergerImpl;
903
904
        // Test that the heap correctly maintains top-k when we have more hits than heap_size
905
2
        let mut hits = vec![];
906
202
        for 
i200
in 0..100 {
907
200
            hits.push(create_hit(i as f64 / 100.0, &format!("doc{}", i)));
908
200
        }
909
910
2
        let shards = vec![create_shard_response(0, hits, 100)];
911
912
2
        let result = merger.merge(shards, 0, 10, false).unwrap();
913
914
        // Should get the top 10 scores (0.90-0.99)
915
2
        assert_eq!(result.hits.len(), 10);
916
2
        assert_eq!(result.hits[0]["id"], "doc99"); // score 0.99
917
2
        assert_eq!(result.hits[9]["id"], "doc90"); // score 0.90
918
2
    }
919
920
    #[test]
921
2
    fn test_offset_limit_pagination_reconstruction() {
922
2
        let merger = MergerImpl;
923
924
        // Create 50 docs with known scores
925
2
        let mut hits = vec![];
926
102
        for 
i100
in 0..50 {
927
100
            hits.push(create_hit((50 - i) as f64 / 100.0, &format!("doc{:02}", i)));
928
100
        }
929
930
        // Get all 50 in one go
931
2
        let shards_all = vec![create_shard_response(0, hits.clone(), 50)];
932
2
        let result_all = merger.merge(shards_all, 0, 50, false).unwrap();
933
934
        // Get 5 pages of 10
935
2
        let mut paged_ids = vec![];
936
12
        for 
page10
in 0..5 {
937
10
            let shards = vec![create_shard_response(0, hits.clone(), 50)];
938
10
            let result = merger.merge(shards, page * 10, 10, false).unwrap();
939
110
            for 
hit100
in result.hits {
940
100
                paged_ids.push(hit["id"].as_str().unwrap().to_string());
941
100
            }
942
        }
943
944
        // Concatenated pages should match the single limit=50 query
945
2
        let all_ids: Vec<_> = result_all
946
2
            .hits
947
2
            .iter()
948
100
            .
map2
(|h| h["id"].as_str().unwrap().to_string())
949
2
            .collect();
950
951
2
        assert_eq!(paged_ids, all_ids);
952
2
    }
953
954
    #[test]
955
2
    fn test_strip_all_miroir_reserved_fields() {
956
2
        let merger = MergerImpl;
957
958
2
        let hit = serde_json::json!({
959
2
            "id": "doc1",
960
2
            "_rankingScore": 0.9,
961
2
            "_miroir_shard": 0,
962
2
            "_miroir_internal": "some_value",
963
2
            "title": "Test",
964
        });
965
966
2
        let shards = vec![create_shard_response(0, vec![hit], 1)];
967
968
2
        let result = merger.merge(shards, 0, 10, true).unwrap();
969
970
        // _rankingScore should be present (requested)
971
2
        assert_eq!(result.hits[0]["_rankingScore"], 0.9);
972
        // All _miroir_* fields should be stripped
973
2
        assert!(result.hits[0].get("_miroir_shard").is_none());
974
2
        assert!(result.hits[0].get("_miroir_internal").is_none());
975
        // Other fields should be present
976
2
        assert_eq!(result.hits[0]["title"], "Test");
977
2
    }
978
979
    #[test]
980
2
    fn test_facet_filter_only_merges_requested_facets() {
981
2
        let facets = serde_json::json!({
982
2
            "color": {"red": 10, "blue": 5},
983
2
            "size": {"large": 8}
984
        });
985
986
2
        let input = MergeInput {
987
2
            shard_hits: vec![ShardHitPage {
988
2
                body: serde_json::json!({
989
2
                    "hits": [],
990
2
                    "estimatedTotalHits": 0,
991
2
                    "processingTimeMs": 10,
992
2
                    "facetDistribution": facets,
993
2
                }),
994
2
                success: true,
995
2
            }],
996
2
            offset: 0,
997
2
            limit: 10,
998
2
            client_requested_score: false,
999
2
            facets: Some(vec!["color".to_string()]),
1000
2
        };
1001
1002
2
        let result = merge(input);
1003
1004
        // Only "color" should be present
1005
2
        assert!(result.facets.get("color").is_some());
1006
2
        assert!(result.facets.get("size").is_none());
1007
2
    }
1008
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/migration.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/migration.rs.html deleted file mode 100644 index d56a2e3..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/migration.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/migration.rs
Line
Count
Source
1
//! Shard migration cutover state machine.
2
//!
3
//! Implements the node-addition migration flow from plan §4 with explicit state
4
//! transitions and a race-window-safe cutover sequence.
5
//!
6
//! ## Race window analysis (plan §15 OP#1)
7
//!
8
//! The dangerous window is between "mark node active" (routing changes to new-node-only)
9
//! and "delete migrated shard from old node." A document written during dual-write that
10
//! succeeded on OLD but failed on NEW — and arrived after the last migration page —
11
//! would be deleted from OLD without ever reaching NEW.
12
//!
13
//! ## Solution: quiesce-then-verify cutover
14
//!
15
//! Instead of the naïve sequence (mark active → stop dual-write → delete old), we use:
16
//!
17
//! 1. Stop dual-write (no new writes go to either node for affected shards)
18
//! 2. Drain: wait for all in-flight writes to both OLD and NEW to complete
19
//! 3. Delta migration: re-read affected shards from OLD (catches anything written since
20
//!    the last migration page) and write deltas to NEW
21
//! 4. Mark node active (routing switches to NEW-only)
22
//! 5. Delete migrated shard from OLD
23
//!
24
//! Step 3 is the key: it closes the race window by ensuring NEW has a complete picture
25
//! before we commit the routing change. The cost is one extra pagination pass over each
26
//! migrated shard — bounded by the number of docs written during the migration window.
27
28
use std::collections::{HashMap, HashSet};
29
use std::fmt;
30
use std::time::{Duration, Instant};
31
32
use serde::{Deserialize, Serialize};
33
34
/// Unique identifier for a shard migration operation.
35
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36
pub struct MigrationId(pub u64);
37
38
impl fmt::Display for MigrationId {
39
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40
0
        write!(f, "{}", self.0)
41
0
    }
42
}
43
44
/// Identifier for a physical node in the cluster.
45
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46
pub struct NodeId(pub String);
47
48
impl fmt::Display for NodeId {
49
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50
0
        write!(f, "{}", self.0)
51
0
    }
52
}
53
54
/// Identifier for a logical shard.
55
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56
pub struct ShardId(pub u32);
57
58
impl fmt::Display for ShardId {
59
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60
0
        write!(f, "s{}", self.0)
61
0
    }
62
}
63
64
/// Per-shard migration state within a node-addition migration.
65
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66
pub enum ShardMigrationState {
67
    /// Waiting for background migration to begin.
68
    Pending,
69
    /// Background pagination is reading docs from source and writing to target.
70
    Migrating {
71
        docs_copied: u64,
72
        pages_remaining: u32,
73
    },
74
    /// Background migration complete, awaiting cutover.
75
    MigrationComplete { docs_copied: u64 },
76
    /// Dual-write stopped, in-flight writes draining.
77
    Draining {
78
        in_flight_count: u32,
79
        docs_copied: u64,
80
    },
81
    /// Delta pass: re-reading source to catch stragglers written during migration.
82
    DeltaPass {
83
        docs_copied: u64,
84
        delta_docs_copied: u64,
85
    },
86
    /// Node is active for this shard; old replica data deleted.
87
    Active,
88
    /// Migration failed at this phase.
89
    Failed { phase: String, reason: String },
90
}
91
92
impl fmt::Display for ShardMigrationState {
93
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94
0
        match self {
95
0
            Self::Pending => write!(f, "pending"),
96
            Self::Migrating {
97
0
                docs_copied,
98
0
                pages_remaining,
99
            } => {
100
0
                write!(
101
0
                    f,
102
0
                    "migrating({docs_copied} copied, {pages_remaining} pages left)"
103
                )
104
            }
105
0
            Self::MigrationComplete { docs_copied } => {
106
0
                write!(f, "migration_complete({docs_copied} copied)")
107
            }
108
            Self::Draining {
109
0
                in_flight_count,
110
0
                docs_copied,
111
            } => {
112
0
                write!(
113
0
                    f,
114
0
                    "draining({in_flight_count} in-flight, {docs_copied} copied)"
115
                )
116
            }
117
            Self::DeltaPass {
118
0
                docs_copied,
119
0
                delta_docs_copied,
120
            } => {
121
0
                write!(f, "delta_pass({docs_copied} + {delta_docs_copied} copied)")
122
            }
123
0
            Self::Active => write!(f, "active"),
124
0
            Self::Failed { phase, reason } => write!(f, "failed({phase}: {reason})"),
125
        }
126
0
    }
127
}
128
129
/// Overall migration phase for a node addition.
130
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131
pub enum MigrationPhase {
132
    /// Computing which shards move to the new node.
133
    ComputingAssignments,
134
    /// Dual-write active; background migration in progress.
135
    DualWriteMigrating,
136
    /// Background migration done; beginning cutover.
137
    CutoverBegin,
138
    /// Stopping dual-write; waiting for in-flight writes to settle.
139
    CutoverDraining,
140
    /// Re-reading source to catch docs written during migration.
141
    CutoverDeltaPass,
142
    /// Marking new node active; switching routing.
143
    CutoverActivate,
144
    /// Deleting migrated shard data from old nodes.
145
    CutoverCleanup,
146
    /// All shards migrated; migration complete.
147
    Complete,
148
    /// Migration failed.
149
    Failed(String),
150
}
151
152
impl fmt::Display for MigrationPhase {
153
0
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154
0
        match self {
155
0
            Self::ComputingAssignments => write!(f, "computing_assignments"),
156
0
            Self::DualWriteMigrating => write!(f, "dual_write_migrating"),
157
0
            Self::CutoverBegin => write!(f, "cutover_begin"),
158
0
            Self::CutoverDraining => write!(f, "cutover_draining"),
159
0
            Self::CutoverDeltaPass => write!(f, "cutover_delta_pass"),
160
0
            Self::CutoverActivate => write!(f, "cutover_activate"),
161
0
            Self::CutoverCleanup => write!(f, "cutover_cleanup"),
162
0
            Self::Complete => write!(f, "complete"),
163
0
            Self::Failed(msg) => write!(f, "failed({msg})"),
164
        }
165
0
    }
166
}
167
168
/// A single document write targeting a shard during migration.
169
#[derive(Debug, Clone)]
170
pub struct InFlightWrite {
171
    pub doc_id: String,
172
    pub shard: ShardId,
173
    pub target_nodes: Vec<NodeId>,
174
    pub completed_nodes: HashSet<NodeId>,
175
    pub failed_nodes: HashMap<NodeId, String>,
176
    pub submitted_at: Instant,
177
}
178
179
// Serialize Instant as a placeholder bool (present/absent).
180
// Instant is monotonic and not meaningfully serializable across processes;
181
// on deserialize, reconstruct as Instant::now().
182
mod instant_serde {
183
    use serde::{Deserialize, Deserializer, Serialize, Serializer};
184
    use std::time::Instant;
185
186
0
    pub fn serialize<S>(instant: &Option<Instant>, serializer: S) -> Result<S::Ok, S::Error>
187
0
    where
188
0
        S: Serializer,
189
    {
190
0
        instant.is_some().serialize(serializer)
191
0
    }
192
193
0
    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Instant>, D::Error>
194
0
    where
195
0
        D: Deserializer<'de>,
196
    {
197
0
        let present = bool::deserialize(deserializer)?;
198
0
        Ok(if present { Some(Instant::now()) } else { None })
199
0
    }
200
}
201
202
/// Configuration for migration cutover behavior.
203
#[derive(Debug, Clone, Serialize, Deserialize)]
204
pub struct MigrationConfig {
205
    /// Maximum time to wait for in-flight writes to drain during cutover.
206
    pub drain_timeout: Duration,
207
    /// Whether to perform the delta pass (re-read source after stopping dual-write).
208
    /// Disabling this saves a pagination pass but opens the race window — only safe
209
    /// when anti-entropy is enabled as a safety net.
210
    pub skip_delta_pass: bool,
211
    /// Whether anti-entropy is enabled — used to determine if skip_delta_pass is safe.
212
    pub anti_entropy_enabled: bool,
213
}
214
215
impl Default for MigrationConfig {
216
16
    fn default() -> Self {
217
16
        Self {
218
16
            drain_timeout: Duration::from_secs(30),
219
16
            skip_delta_pass: false,
220
16
            anti_entropy_enabled: true,
221
16
        }
222
16
    }
223
}
224
225
/// Error type for migration operations.
226
#[derive(Debug, thiserror::Error)]
227
pub enum MigrationError {
228
    #[error(
229
        "anti-entropy is disabled and delta pass is skipped — documents may be lost at cutover"
230
    )]
231
    UnsafeCutoverNoAntiEntropy,
232
    #[error("drain timeout exceeded: {0} in-flight writes still pending")]
233
    DrainTimeout(u32),
234
    #[error("shard {0} is not in a valid state for this transition (current: {1})")]
235
    InvalidTransition(ShardId, String),
236
    #[error("migration {0} not found")]
237
    NotFound(MigrationId),
238
    #[error("delta pass failed for shard {0}: {1}")]
239
    DeltaPassFailed(ShardId, String),
240
}
241
242
/// Tracks the state of a node-addition migration.
243
#[derive(Debug, Clone, Serialize, Deserialize)]
244
pub struct MigrationState {
245
    pub id: MigrationId,
246
    pub new_node: NodeId,
247
    pub replica_group: u32,
248
    pub phase: MigrationPhase,
249
    pub affected_shards: HashMap<ShardId, ShardMigrationState>,
250
    /// Maps shard → old node that currently owns it.
251
    pub old_owners: HashMap<ShardId, NodeId>,
252
    #[serde(with = "instant_serde")]
253
    pub started_at: Option<Instant>,
254
    #[serde(with = "instant_serde")]
255
    pub completed_at: Option<Instant>,
256
}
257
258
/// The migration coordinator manages shard migration state transitions.
259
pub struct MigrationCoordinator {
260
    config: MigrationConfig,
261
    migrations: HashMap<MigrationId, MigrationState>,
262
    next_id: u64,
263
    /// In-flight writes being tracked for drain during cutover.
264
    in_flight: Vec<InFlightWrite>,
265
}
266
267
impl MigrationCoordinator {
268
10
    pub fn new(config: MigrationConfig) -> Self {
269
10
        Self {
270
10
            config,
271
10
            migrations: HashMap::new(),
272
10
            next_id: 0,
273
10
            in_flight: Vec::new(),
274
10
        }
275
10
    }
276
277
    /// Validate migration safety before starting. Returns an error if the configuration
278
    /// would allow data loss at the cutover boundary.
279
10
    pub fn validate_safety(&self) -> Result<(), MigrationError> {
280
10
        if self.config.skip_delta_pass && 
!self.config.anti_entropy_enabled6
{
281
2
            return Err(MigrationError::UnsafeCutoverNoAntiEntropy);
282
8
        }
283
8
        Ok(())
284
10
    }
285
286
    /// Begin a new node-addition migration.
287
10
    pub fn begin_migration(
288
10
        &mut self,
289
10
        new_node: NodeId,
290
10
        replica_group: u32,
291
10
        affected_shards: HashMap<ShardId, NodeId>,
292
10
    ) -> Result<MigrationId, MigrationError> {
293
10
        self.validate_safety()
?2
;
294
295
8
        let id = MigrationId(self.next_id);
296
8
        self.next_id += 1;
297
298
8
        let shard_states: HashMap<ShardId, ShardMigrationState> = affected_shards
299
8
            .keys()
300
10
            .
map8
(|&shard| (shard, ShardMigrationState::Pending))
301
8
            .collect();
302
303
8
        let state = MigrationState {
304
8
            id,
305
8
            new_node,
306
8
            replica_group,
307
8
            phase: MigrationPhase::ComputingAssignments,
308
8
            affected_shards: shard_states,
309
8
            old_owners: affected_shards,
310
8
            started_at: Some(Instant::now()),
311
8
            completed_at: None,
312
8
        };
313
314
8
        self.migrations.insert(id, state);
315
8
        Ok(id)
316
10
    }
317
318
    /// Transition to dual-write + background migration phase.
319
8
    pub fn begin_dual_write(&mut self, id: MigrationId) -> Result<(), MigrationError> {
320
8
        let state = self
321
8
            .migrations
322
8
            .get_mut(&id)
323
8
            .ok_or(MigrationError::NotFound(id))
?0
;
324
8
        state.phase = MigrationPhase::DualWriteMigrating;
325
10
        for shard_state in 
state.affected_shards8
.
values_mut8
() {
326
10
            if *shard_state == ShardMigrationState::Pending {
327
10
                *shard_state = ShardMigrationState::Migrating {
328
10
                    docs_copied: 0,
329
10
                    pages_remaining: 0,
330
10
                };
331
10
            
}0
332
        }
333
8
        Ok(())
334
8
    }
335
336
    /// Record that a shard's background migration completed.
337
10
    pub fn shard_migration_complete(
338
10
        &mut self,
339
10
        id: MigrationId,
340
10
        shard: ShardId,
341
10
        docs_copied: u64,
342
10
    ) -> Result<(), MigrationError> {
343
10
        let state = self
344
10
            .migrations
345
10
            .get_mut(&id)
346
10
            .ok_or(MigrationError::NotFound(id))
?0
;
347
10
        let shard_state = state.affected_shards.get_mut(&shard).ok_or_else(|| 
{0
348
0
            MigrationError::InvalidTransition(shard, "shard not in migration".into())
349
0
        })?;
350
351
10
        match shard_state {
352
10
            ShardMigrationState::Migrating { .. } => {
353
10
                *shard_state = ShardMigrationState::MigrationComplete { docs_copied };
354
10
            }
355
            _ => {
356
0
                return Err(MigrationError::InvalidTransition(
357
0
                    shard,
358
0
                    shard_state.to_string(),
359
0
                ));
360
            }
361
        }
362
363
        // Check if all shards are done migrating
364
10
        let all_complete = state
365
10
            .affected_shards
366
10
            .values()
367
13
            .
all10
(|s| matches!(s, ShardMigrationState::MigrationComplete { .. }));
368
369
10
        if all_complete {
370
8
            state.phase = MigrationPhase::CutoverBegin;
371
8
        
}2
372
373
10
        Ok(())
374
10
    }
375
376
    /// Begin the cutover sequence: stop dual-write and drain in-flight writes.
377
6
    pub fn begin_cutover(&mut self, id: MigrationId) -> Result<MigrationPhase, MigrationError> {
378
6
        let state = self
379
6
            .migrations
380
6
            .get_mut(&id)
381
6
            .ok_or(MigrationError::NotFound(id))
?0
;
382
383
6
        if !
matches!0
(state.phase, MigrationPhase::CutoverBegin) {
384
0
            return Err(MigrationError::InvalidTransition(
385
0
                ShardId(0),
386
0
                format!("expected CutoverBegin, got {}", state.phase),
387
0
            ));
388
6
        }
389
390
        // Transition all shards to Draining
391
6
        let total_in_flight = self.in_flight.len() as u32;
392
8
        for (shard, shard_state) in 
state.affected_shards6
.
iter_mut6
() {
393
8
            match shard_state {
394
8
                ShardMigrationState::MigrationComplete { docs_copied } => {
395
8
                    *shard_state = ShardMigrationState::Draining {
396
8
                        in_flight_count: total_in_flight,
397
8
                        docs_copied: *docs_copied,
398
8
                    };
399
8
                }
400
                _ => {
401
0
                    return Err(MigrationError::InvalidTransition(
402
0
                        *shard,
403
0
                        shard_state.to_string(),
404
0
                    ));
405
                }
406
            }
407
        }
408
409
6
        state.phase = MigrationPhase::CutoverDraining;
410
6
        Ok(state.phase.clone())
411
6
    }
412
413
    /// Register an in-flight write for tracking during drain.
414
4
    pub fn register_in_flight(&mut self, write: InFlightWrite) {
415
4
        self.in_flight.push(write);
416
4
    }
417
418
    /// Acknowledge completion of a write to a specific node.
419
0
    pub fn ack_write(&mut self, doc_id: &str, node: &NodeId) {
420
0
        for write in &mut self.in_flight {
421
0
            if write.doc_id == doc_id {
422
0
                write.completed_nodes.insert(node.clone());
423
0
            }
424
        }
425
0
    }
426
427
    /// Mark a write as failed on a specific node.
428
0
    pub fn fail_write(&mut self, doc_id: &str, node: &NodeId, reason: String) {
429
0
        for write in &mut self.in_flight {
430
0
            if write.doc_id == doc_id {
431
0
                write.failed_nodes.insert(node.clone(), reason.clone());
432
0
            }
433
        }
434
0
    }
435
436
    /// Check if all in-flight writes have completed (drained).
437
6
    pub fn is_drained(&self) -> bool {
438
6
        self.in_flight
439
6
            .iter()
440
6
            .all(|w| 
w.completed_nodes4
.
len4
() + w.failed_nodes.len() ==
w.target_nodes4
.
len4
())
441
6
    }
442
443
    /// Complete the drain and move to delta pass or activation.
444
6
    pub fn complete_drain(&mut self, id: MigrationId) -> Result<MigrationPhase, MigrationError> {
445
        // First check phase exists without holding mutable borrow
446
6
        let phase = self
447
6
            .migrations
448
6
            .get(&id)
449
6
            .ok_or(MigrationError::NotFound(id))
?0
450
            .phase
451
6
            .clone();
452
453
6
        if !
matches!0
(phase, MigrationPhase::CutoverDraining) {
454
0
            return Err(MigrationError::InvalidTransition(
455
0
                ShardId(0),
456
0
                format!("expected CutoverDraining, got {phase}"),
457
0
            ));
458
6
        }
459
460
        // Check drain status
461
6
        if !self.is_drained() {
462
2
            let remaining = self
463
2
                .in_flight
464
2
                .iter()
465
2
                .filter(|w| w.completed_nodes.len() + w.failed_nodes.len() < w.target_nodes.len())
466
2
                .count() as u32;
467
2
            return Err(MigrationError::DrainTimeout(remaining));
468
4
        }
469
470
        // Collect docs that need delta pass
471
4
        let needs_delta = self.collect_delta_candidates(id)
?0
;
472
4
        let skip_delta = self.config.skip_delta_pass;
473
474
        // Now get mutable borrow to update state
475
4
        let state = self
476
4
            .migrations
477
4
            .get_mut(&id)
478
4
            .ok_or(MigrationError::NotFound(id))
?0
;
479
480
4
        if skip_delta {
481
2
            // Skip delta pass — safe only if anti-entropy is enabled
482
2
            state.phase = MigrationPhase::CutoverActivate;
483
2
        } else if needs_delta.is_empty() {
484
0
            state.phase = MigrationPhase::CutoverActivate;
485
0
        } else {
486
2
            state.phase = MigrationPhase::CutoverDeltaPass;
487
4
            for (_shard, shard_state) in 
state.affected_shards2
.
iter_mut2
() {
488
4
                if let ShardMigrationState::Draining { docs_copied, .. } = shard_state {
489
4
                    *shard_state = ShardMigrationState::DeltaPass {
490
4
                        docs_copied: *docs_copied,
491
4
                        delta_docs_copied: 0,
492
4
                    };
493
4
                
}0
494
            }
495
        }
496
497
        // Clear only the in-flight writes for this migration
498
4
        let affected_shards = state
499
4
            .affected_shards
500
4
            .keys()
501
4
            .cloned()
502
4
            .collect::<HashSet<_>>();
503
4
        self.in_flight
504
4
            .retain(|w| !
affected_shards2
.
contains2
(
&w.shard2
));
505
506
        // If going to activate, do that now (drop mutable borrow first)
507
4
        let next_phase = state.phase.clone();
508
4
        if 
matches!2
(next_phase, MigrationPhase::CutoverActivate) {
509
2
            let _ = state;
510
2
            self.activate_shards(id)
?0
;
511
            // Return the new phase after activation
512
2
            return Ok(self
513
2
                .migrations
514
2
                .get(&id)
515
2
                .map(|s| s.phase.clone())
516
2
                .unwrap_or(MigrationPhase::CutoverCleanup));
517
2
        }
518
519
2
        Ok(next_phase)
520
6
    }
521
522
    /// Identify writes that need the delta pass — those that succeeded on OLD but
523
    /// failed (or never reached) NEW.
524
4
    fn collect_delta_candidates(
525
4
        &self,
526
4
        id: MigrationId,
527
4
    ) -> Result<HashMap<ShardId, Vec<String>>, MigrationError> {
528
4
        let state = self
529
4
            .migrations
530
4
            .get(&id)
531
4
            .ok_or(MigrationError::NotFound(id))
?0
;
532
4
        let mut candidates: HashMap<ShardId, Vec<String>> = HashMap::new();
533
534
6
        for 
write2
in &self.in_flight {
535
2
            let old_owner = match state.old_owners.get(&write.shard) {
536
2
                Some(owner) => owner,
537
0
                None => continue,
538
            };
539
540
2
            let succeeded_on_old = write.completed_nodes.contains(old_owner);
541
2
            let succeeded_on_new = write.completed_nodes.contains(&state.new_node);
542
543
            // Doc is on OLD but not on NEW — delta pass must catch it
544
2
            if succeeded_on_old && !succeeded_on_new {
545
2
                candidates
546
2
                    .entry(write.shard)
547
2
                    .or_default()
548
2
                    .push(write.doc_id.clone());
549
2
            
}0
550
        }
551
552
4
        Ok(candidates)
553
4
    }
554
555
    /// Record that the delta pass completed for a shard.
556
4
    pub fn shard_delta_complete(
557
4
        &mut self,
558
4
        id: MigrationId,
559
4
        shard: ShardId,
560
4
        delta_docs: u64,
561
4
    ) -> Result<(), MigrationError> {
562
4
        let state = self
563
4
            .migrations
564
4
            .get_mut(&id)
565
4
            .ok_or(MigrationError::NotFound(id))
?0
;
566
4
        let shard_state = state.affected_shards.get_mut(&shard).ok_or_else(|| 
{0
567
0
            MigrationError::InvalidTransition(shard, "shard not in migration".into())
568
0
        })?;
569
570
4
        match shard_state {
571
4
            ShardMigrationState::DeltaPass { docs_copied, .. } => {
572
4
                *shard_state = ShardMigrationState::MigrationComplete {
573
4
                    docs_copied: *docs_copied + delta_docs,
574
4
                };
575
4
            }
576
            _ => {
577
0
                return Err(MigrationError::InvalidTransition(
578
0
                    shard,
579
0
                    shard_state.to_string(),
580
0
                ));
581
            }
582
        }
583
584
        // Check if all shards done with delta
585
4
        let all_complete = state
586
4
            .affected_shards
587
4
            .values()
588
7
            .
all4
(|s| matches!(s, ShardMigrationState::MigrationComplete { .. }));
589
590
4
        if all_complete {
591
2
            state.phase = MigrationPhase::CutoverActivate;
592
2
            self.activate_shards(id)
?0
;
593
2
        }
594
595
4
        Ok(())
596
4
    }
597
598
    /// Mark all affected shards as active on the new node.
599
4
    fn activate_shards(&mut self, id: MigrationId) -> Result<(), MigrationError> {
600
4
        let state = self
601
4
            .migrations
602
4
            .get_mut(&id)
603
4
            .ok_or(MigrationError::NotFound(id))
?0
;
604
605
6
        for shard_state in 
state.affected_shards4
.
values_mut4
() {
606
6
            match shard_state {
607
                ShardMigrationState::MigrationComplete { .. }
608
6
                | ShardMigrationState::Draining { .. } => {
609
6
                    *shard_state = ShardMigrationState::Active;
610
6
                }
611
0
                _ => {}
612
            }
613
        }
614
615
4
        if 
matches!0
(state.phase, MigrationPhase::CutoverActivate) {
616
4
            state.phase = MigrationPhase::CutoverCleanup;
617
4
        
}0
618
619
4
        Ok(())
620
4
    }
621
622
    /// Complete the migration by deleting migrated shard data from old nodes.
623
4
    pub fn complete_cleanup(&mut self, id: MigrationId) -> Result<(), MigrationError> {
624
4
        let state = self
625
4
            .migrations
626
4
            .get_mut(&id)
627
4
            .ok_or(MigrationError::NotFound(id))
?0
;
628
629
4
        if !
matches!0
(state.phase, MigrationPhase::CutoverCleanup) {
630
0
            return Err(MigrationError::InvalidTransition(
631
0
                ShardId(0),
632
0
                format!("expected CutoverCleanup, got {}", state.phase),
633
0
            ));
634
4
        }
635
636
4
        state.phase = MigrationPhase::Complete;
637
4
        state.completed_at = Some(Instant::now());
638
4
        Ok(())
639
4
    }
640
641
    /// Get the current state of a migration.
642
6
    pub fn get_state(&self, id: MigrationId) -> Option<&MigrationState> {
643
6
        self.migrations.get(&id)
644
6
    }
645
646
    /// Check if a write should go to both old and new node (dual-write phase).
647
6
    pub fn is_dual_write_active(&self, shard: ShardId) -> bool {
648
6
        self.migrations.values().any(|m| {
649
6
            
matches!2
(m.phase, MigrationPhase::DualWriteMigrating)
650
2
                && matches!(
651
4
                    m.affected_shards.get(&shard),
652
                    Some(ShardMigrationState::Migrating { .. })
653
                )
654
6
        })
655
6
    }
656
657
    /// Get the migration config.
658
0
    pub fn config(&self) -> &MigrationConfig {
659
0
        &self.config
660
0
    }
661
}
662
663
#[cfg(test)]
664
mod tests {
665
    use super::*;
666
667
34
    fn node(s: &str) -> NodeId {
668
34
        NodeId(s.to_string())
669
34
    }
670
671
36
    fn shard(id: u32) -> ShardId {
672
36
        ShardId(id)
673
36
    }
674
675
    #[test]
676
2
    fn test_safe_cutover_with_delta_pass() {
677
2
        let config = MigrationConfig {
678
2
            anti_entropy_enabled: false,
679
2
            skip_delta_pass: false,
680
2
            ..Default::default()
681
2
        };
682
2
        let mut coord = MigrationCoordinator::new(config);
683
684
2
        let affected = HashMap::from([(shard(0), node("old-0")), (shard(1), node("old-0"))]);
685
686
2
        let mid = coord.begin_migration(node("new-0"), 0, affected).unwrap();
687
2
        coord.begin_dual_write(mid).unwrap();
688
689
        // Simulate background migration completing
690
2
        coord.shard_migration_complete(mid, shard(0), 500).unwrap();
691
2
        coord.shard_migration_complete(mid, shard(1), 300).unwrap();
692
693
        // Register an in-flight write that succeeded on OLD but not NEW.
694
        // The write must be marked as failed on NEW so is_drained() sees
695
        // completed + failed == target count.
696
2
        coord.register_in_flight(InFlightWrite {
697
2
            doc_id: "doc-at-boundary".into(),
698
2
            shard: shard(0),
699
2
            target_nodes: vec![node("old-0"), node("new-0")],
700
2
            completed_nodes: HashSet::from([node("old-0")]),
701
2
            failed_nodes: HashMap::from([(node("new-0"), "write failed".into())]),
702
2
            submitted_at: Instant::now(),
703
2
        });
704
705
        // Cutover
706
2
        coord.begin_cutover(mid).unwrap();
707
708
        // The drain sees the in-flight write completed (on old, not on new)
709
        // Delta pass should be triggered
710
2
        let phase = coord.complete_drain(mid).unwrap();
711
2
        assert_eq!(phase, MigrationPhase::CutoverDeltaPass);
712
713
        // Delta pass catches the straggler
714
2
        coord.shard_delta_complete(mid, shard(0), 1).unwrap();
715
        // Shard 1 had no stragglers, but needs delta complete too
716
2
        coord.shard_delta_complete(mid, shard(1), 0).unwrap();
717
718
        // Now activation and cleanup
719
2
        let state = coord.get_state(mid).unwrap();
720
2
        assert_eq!(state.phase, MigrationPhase::CutoverCleanup);
721
722
2
        coord.complete_cleanup(mid).unwrap();
723
2
        let state = coord.get_state(mid).unwrap();
724
2
        assert_eq!(state.phase, MigrationPhase::Complete);
725
2
    }
726
727
    #[test]
728
2
    fn test_unsafe_cutover_refused_without_anti_entropy() {
729
2
        let config = MigrationConfig {
730
2
            anti_entropy_enabled: false,
731
2
            skip_delta_pass: true,
732
2
            ..Default::default()
733
2
        };
734
2
        let mut coord = MigrationCoordinator::new(config);
735
736
2
        let affected = HashMap::from([(shard(0), node("old-0"))]);
737
2
        let result = coord.begin_migration(node("new-0"), 0, affected);
738
739
2
        assert!(result.is_err());
740
2
        let err = result.unwrap_err();
741
2
        assert!(
matches!0
(err, MigrationError::UnsafeCutoverNoAntiEntropy));
742
2
    }
743
744
    #[test]
745
2
    fn test_skip_delta_pass_allowed_with_anti_entropy() {
746
2
        let config = MigrationConfig {
747
2
            anti_entropy_enabled: true,
748
2
            skip_delta_pass: true,
749
2
            ..Default::default()
750
2
        };
751
2
        let mut coord = MigrationCoordinator::new(config);
752
753
2
        let affected = HashMap::from([(shard(0), node("old-0"))]);
754
2
        let mid = coord.begin_migration(node("new-0"), 0, affected).unwrap();
755
2
        coord.begin_dual_write(mid).unwrap();
756
2
        coord.shard_migration_complete(mid, shard(0), 100).unwrap();
757
758
2
        coord.begin_cutover(mid).unwrap();
759
760
        // With skip_delta_pass=true and AE enabled, drain goes straight to activate
761
2
        let phase = coord.complete_drain(mid).unwrap();
762
2
        assert_eq!(phase, MigrationPhase::CutoverCleanup);
763
764
2
        coord.complete_cleanup(mid).unwrap();
765
2
        assert_eq!(
766
2
            coord.get_state(mid).unwrap().phase,
767
            MigrationPhase::Complete
768
        );
769
2
    }
770
771
    #[test]
772
2
    fn test_drain_timeout_blocks_cutover() {
773
2
        let config = MigrationConfig {
774
2
            anti_entropy_enabled: true,
775
2
            skip_delta_pass: true,
776
2
            ..Default::default()
777
2
        };
778
2
        let mut coord = MigrationCoordinator::new(config);
779
780
2
        let affected = HashMap::from([(shard(0), node("old-0"))]);
781
2
        let mid = coord.begin_migration(node("new-0"), 0, affected).unwrap();
782
2
        coord.begin_dual_write(mid).unwrap();
783
2
        coord.shard_migration_complete(mid, shard(0), 100).unwrap();
784
2
        coord.begin_cutover(mid).unwrap();
785
786
        // Register an in-flight write that hasn't completed on either node
787
2
        coord.register_in_flight(InFlightWrite {
788
2
            doc_id: "stuck-doc".into(),
789
2
            shard: shard(0),
790
2
            target_nodes: vec![node("old-0"), node("new-0")],
791
2
            completed_nodes: HashSet::new(),
792
2
            failed_nodes: HashMap::new(),
793
2
            submitted_at: Instant::now(),
794
2
        });
795
796
        // Drain should fail — write still in flight
797
2
        let result = coord.complete_drain(mid);
798
2
        assert!(result.is_err());
799
2
        assert!(
matches!0
(
800
2
            result.unwrap_err(),
801
            MigrationError::DrainTimeout(1)
802
        ));
803
2
    }
804
805
    #[test]
806
2
    fn test_dual_write_tracking() {
807
2
        let config = MigrationConfig::default();
808
2
        let mut coord = MigrationCoordinator::new(config);
809
810
2
        let affected = HashMap::from([(shard(5), node("old-0"))]);
811
2
        let mid = coord.begin_migration(node("new-0"), 0, affected).unwrap();
812
2
        coord.begin_dual_write(mid).unwrap();
813
814
        // Shard 5 is in dual-write
815
2
        assert!(coord.is_dual_write_active(shard(5)));
816
        // Shard 99 is not being migrated
817
2
        assert!(!coord.is_dual_write_active(shard(99)));
818
819
        // After migration completes, shard 5 is no longer dual-write
820
2
        coord.shard_migration_complete(mid, shard(5), 100).unwrap();
821
2
        assert!(!coord.is_dual_write_active(shard(5)));
822
2
    }
823
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/reshard.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/reshard.rs.html deleted file mode 100644 index 202438a..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/reshard.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/reshard.rs
Line
Count
Source
1
//! Online resharding: window guard, simulation model, and load estimation.
2
//!
3
//! Implements the plan §13.1 shadow-index resharding mechanics and §15 OP#3
4
//! empirical validation of the 2× transient load caveat.
5
6
use crate::router::{assign_shard_in_group, shard_for_key};
7
use crate::topology::{Group, NodeId};
8
use serde::{Deserialize, Serialize};
9
use std::time::SystemTime;
10
11
// ---------------------------------------------------------------------------
12
// Schedule window guard
13
// ---------------------------------------------------------------------------
14
15
/// A UTC time window like `"02:00-06:00"`.
16
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17
pub struct TimeWindow {
18
    /// Start hour+minute in minutes since midnight UTC.
19
    pub start_mins: u16,
20
    /// End hour+minute in minutes since midnight UTC.
21
    pub end_mins: u16,
22
}
23
24
impl std::fmt::Display for TimeWindow {
25
0
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
0
        write!(
27
0
            f,
28
0
            "{:02}:{:02}-{:02}:{:02}",
29
0
            self.start_mins / 60,
30
0
            self.start_mins % 60,
31
0
            self.end_mins / 60,
32
0
            self.end_mins % 60
33
        )
34
0
    }
35
}
36
37
impl TimeWindow {
38
    /// Parse a `"HH:MM-HH:MM"` string (UTC).
39
32
    pub fn parse(s: &str) -> Result<Self, String> {
40
32
        let (start, end) = s
41
32
            .split_once('-')
42
32
            .ok_or_else(|| format!(
"expected HH:MM-HH:MM, got {s}"0
))
?0
;
43
        Ok(TimeWindow {
44
32
            start_mins: Self::parse_hm(start)
?6
,
45
26
            end_mins: Self::parse_hm(end)
?0
,
46
        })
47
32
    }
48
49
58
    fn parse_hm(hm: &str) -> Result<u16, String> {
50
58
        let (
h56
,
m56
) = hm
51
58
            .split_once(':')
52
58
            .ok_or_else(|| format!(
"expected HH:MM, got {hm}"2
))
?2
;
53
56
        let h: u16 = h.parse().map_err(|_| format!(
"invalid hour: {h}"0
))
?0
;
54
56
        let m: u16 = m.parse().map_err(|_| format!(
"invalid minute: {m}"0
))
?0
;
55
56
        if h >= 24 || 
m >= 6054
{
56
4
            return Err(format!("time out of range: {hm}"));
57
52
        }
58
52
        Ok(h * 60 + m)
59
58
    }
60
61
    /// Does `utc_minutes` (minutes since midnight UTC) fall inside this window?
62
30
    pub fn contains(&self, utc_minutes: u16) -> bool {
63
30
        if self.start_mins <= self.end_mins {
64
24
            utc_minutes >= self.start_mins && 
utc_minutes < self.end_mins20
65
        } else {
66
            // Wraps midnight, e.g. 22:00-06:00
67
6
            utc_minutes >= self.start_mins || 
utc_minutes < self.end_mins4
68
        }
69
30
    }
70
}
71
72
/// Resharding configuration (plan §13.1 + schedule window guard).
73
#[derive(Debug, Clone, Serialize, Deserialize)]
74
pub struct ReshardingConfig {
75
    #[serde(default = "default_true")]
76
    pub enabled: bool,
77
    #[serde(default = "default_backfill_concurrency")]
78
    pub backfill_concurrency: usize,
79
    #[serde(default = "default_backfill_batch_size")]
80
    pub backfill_batch_size: usize,
81
    #[serde(default)]
82
    pub throttle_docs_per_sec: u64,
83
    #[serde(default = "default_true")]
84
    pub verify_before_swap: bool,
85
    #[serde(default = "default_retain_hours")]
86
    pub retain_old_index_hours: u64,
87
    /// Allowed schedule windows in `"HH:MM-HH:MM UTC"` format.
88
    /// Empty means any time is allowed (no restriction).
89
    #[serde(default)]
90
    pub allowed_windows: Vec<String>,
91
}
92
93
8
fn default_backfill_concurrency() -> usize {
94
8
    4
95
8
}
96
8
fn default_backfill_batch_size() -> usize {
97
8
    1000
98
8
}
99
0
fn default_true() -> bool {
100
0
    true
101
0
}
102
8
fn default_retain_hours() -> u64 {
103
8
    48
104
8
}
105
106
impl Default for ReshardingConfig {
107
8
    fn default() -> Self {
108
8
        Self {
109
8
            enabled: true,
110
8
            backfill_concurrency: default_backfill_concurrency(),
111
8
            backfill_batch_size: default_backfill_batch_size(),
112
8
            throttle_docs_per_sec: 0,
113
8
            verify_before_swap: true,
114
8
            retain_old_index_hours: default_retain_hours(),
115
8
            allowed_windows: Vec::new(),
116
8
        }
117
8
    }
118
}
119
120
/// Result of the schedule window guard check.
121
#[derive(Debug, Clone, PartialEq, Eq)]
122
pub enum WindowGuardResult {
123
    /// Current time is inside an allowed window.
124
    Allowed { window: String },
125
    /// No windows configured — always allowed.
126
    NoRestriction,
127
    /// Current time is outside all allowed windows.
128
    Denied {
129
        utc_now: String,
130
        allowed: Vec<String>,
131
    },
132
}
133
134
/// Check whether resharding is allowed at the given UTC minute-of-day.
135
///
136
/// Returns `Allowed` if `utc_minute` falls inside any configured window,
137
/// `NoRestriction` if no windows are configured, or `Denied` otherwise.
138
12
pub fn check_window(utc_minute: u16, config: &ReshardingConfig) -> WindowGuardResult {
139
12
    if config.allowed_windows.is_empty() {
140
2
        return WindowGuardResult::NoRestriction;
141
10
    }
142
143
18
    for 
raw14
in &config.allowed_windows {
144
14
        let window = match TimeWindow::parse(raw) {
145
14
            Ok(w) => w,
146
0
            Err(_) => continue,
147
        };
148
14
        if window.contains(utc_minute) {
149
6
            return WindowGuardResult::Allowed {
150
6
                window: raw.clone(),
151
6
            };
152
8
        }
153
    }
154
155
4
    WindowGuardResult::Denied {
156
4
        utc_now: format!("{:02}:{:02} UTC", utc_minute / 60, utc_minute % 60),
157
4
        allowed: config.allowed_windows.clone(),
158
4
    }
159
12
}
160
161
/// Check the schedule window against the system clock.
162
0
pub fn check_window_now(config: &ReshardingConfig) -> WindowGuardResult {
163
0
    let utc_minute = current_utc_minute();
164
0
    check_window(utc_minute, config)
165
0
}
166
167
0
fn current_utc_minute() -> u16 {
168
0
    let duration = SystemTime::now()
169
0
        .duration_since(SystemTime::UNIX_EPOCH)
170
0
        .unwrap_or_default();
171
0
    ((duration.as_secs() / 60) % (24 * 60)) as u16
172
0
}
173
174
// ---------------------------------------------------------------------------
175
// Resharding load simulation
176
// ---------------------------------------------------------------------------
177
178
/// Parameters for a single simulation run.
179
#[derive(Debug, Clone)]
180
pub struct SimParams {
181
    /// Document size in bytes.
182
    pub doc_size_bytes: u64,
183
    /// Total corpus size in bytes.
184
    pub corpus_size_bytes: u64,
185
    /// Incoming write rate in documents per second.
186
    pub write_rate_dps: u64,
187
    /// Number of replica groups.
188
    pub replica_groups: u32,
189
    /// Replication factor (intra-group copies per shard).
190
    pub replication_factor: usize,
191
    /// Old shard count (before reshard).
192
    pub old_shards: u32,
193
    /// New shard count (after reshard).
194
    pub new_shards: u32,
195
    /// Number of nodes per replica group.
196
    pub nodes_per_group: usize,
197
    /// Backfill throttle in documents per second (0 = unlimited).
198
    pub backfill_throttle_dps: u64,
199
}
200
201
/// Results from a single simulation run.
202
#[derive(Debug, Clone, Serialize, Deserialize)]
203
pub struct SimResult {
204
    pub label: String,
205
    pub doc_size_bytes: u64,
206
    pub corpus_size_bytes: u64,
207
    pub total_docs: u64,
208
    pub replica_groups: u32,
209
    pub replication_factor: usize,
210
    pub old_shards: u32,
211
    pub new_shards: u32,
212
    pub nodes_per_group: usize,
213
    pub write_rate_dps: u64,
214
215
    /// Normal steady-state storage across entire cluster (bytes).
216
    pub normal_storage_bytes: u64,
217
    /// Peak storage during resharding (live + shadow full, bytes).
218
    pub peak_storage_bytes: u64,
219
    /// Storage amplification factor (peak / normal).
220
    pub storage_amplification: f64,
221
222
    /// Normal steady-state write rate (actual node writes/sec).
223
    pub normal_write_rate: u64,
224
    /// Dual-write rate (phase 2 only, no backfill).
225
    pub dual_write_rate: u64,
226
    /// Peak write rate during backfill + dual-write.
227
    pub peak_write_rate: u64,
228
    /// Write amplification factor during dual-write only.
229
    pub dual_write_amplification: f64,
230
    /// Write amplification factor during peak (backfill + dual-write).
231
    pub peak_write_amplification: f64,
232
233
    /// Backfill duration in seconds (at configured throttle).
234
    pub backfill_duration_secs: f64,
235
    /// Total bytes written during full reshard operation.
236
    pub total_bytes_written: u64,
237
238
    /// Per-node peak storage (bytes).
239
    pub per_node_peak_storage_bytes: u64,
240
    /// Per-node normal storage (bytes).
241
    pub per_node_normal_storage_bytes: u64,
242
243
    /// Hash distribution stats for old shards.
244
    pub old_shard_cv: f64,
245
    /// Hash distribution stats for new shards.
246
    pub new_shard_cv: f64,
247
}
248
249
/// Run a resharding load simulation with the given parameters.
250
///
251
/// This models the six-phase resharding process from plan §13.1 using
252
/// the actual routing code to compute shard assignments and estimate
253
/// storage/write load. Document keys are synthesized for the corpus size
254
/// and routed through the real hash function to measure distribution.
255
6
pub fn simulate(params: &SimParams) -> SimResult {
256
6
    let total_docs = params.corpus_size_bytes / params.doc_size_bytes;
257
6
    let rf = params.replication_factor;
258
6
    let rg = params.replica_groups;
259
6
    let nodes_per_group = params.nodes_per_group;
260
261
    // Build a synthetic topology for the simulation.
262
6
    let groups: Vec<Group> = (0..rg)
263
10
        .
map6
(|g| {
264
10
            let mut group = Group::new(g);
265
32
            for n in 0..
nodes_per_group10
{
266
32
                group.add_node(NodeId::new(format!("node-g{g}-n{n}")));
267
32
            }
268
10
            group
269
10
        })
270
6
        .collect();
271
272
    // Simulate document distribution across old and new shard counts.
273
    // Use the actual router hash to get realistic distribution.
274
6
    let mut old_shard_counts: Vec<u64> = vec![0; params.old_shards as usize];
275
6
    let mut new_shard_counts: Vec<u64> = vec![0; params.new_shards as usize];
276
277
    // Track per-node storage for old and new shard assignments.
278
    // Each group stores the full corpus; each node in a group stores its
279
    // rendezvous-assigned fraction.
280
6
    let total_nodes = (rg as usize) * nodes_per_group;
281
6
    let mut node_storage_old: Vec<u64> = vec![0; total_nodes];
282
6
    let mut node_storage_new: Vec<u64> = vec![0; total_nodes];
283
284
22.9M
    for i in 0..
total_docs6
{
285
22.9M
        let key = format!("doc-{i}");
286
22.9M
        let old_shard = shard_for_key(&key, params.old_shards);
287
22.9M
        let new_shard = shard_for_key(&key, params.new_shards);
288
289
22.9M
        old_shard_counts[old_shard as usize] += 1;
290
22.9M
        new_shard_counts[new_shard as usize] += 1;
291
292
        // For each replica group, assign shard to RF nodes.
293
43.9M
        for (g_idx, group) in 
groups.iter()22.9M
.
enumerate22.9M
() {
294
43.9M
            let old_targets = assign_shard_in_group(old_shard, group.nodes(), rf);
295
43.9M
            let new_targets = assign_shard_in_group(new_shard, group.nodes(), rf);
296
297
87.8M
            for 
node_id43.9M
in &old_targets {
298
43.9M
                let node_idx = g_idx * nodes_per_group
299
91.5M
                    + 
group.nodes().iter()43.9M
.
position43.9M
(|n| n == node_id).
unwrap_or43.9M
(0);
300
43.9M
                node_storage_old[node_idx] += params.doc_size_bytes;
301
            }
302
87.8M
            for 
node_id43.9M
in &new_targets {
303
43.9M
                let node_idx = g_idx * nodes_per_group
304
94.4M
                    + 
group.nodes().iter()43.9M
.
position43.9M
(|n| n == node_id).
unwrap_or43.9M
(0);
305
43.9M
                node_storage_new[node_idx] += params.doc_size_bytes;
306
            }
307
        }
308
    }
309
310
    // Compute distribution coefficients of variation.
311
6
    let old_cv = cv(&old_shard_counts);
312
6
    let new_cv = cv(&new_shard_counts);
313
314
    // Normal storage: corpus replicated across RG groups.
315
6
    let normal_storage_bytes = params.corpus_size_bytes * rg as u64;
316
    // Peak storage: live + shadow (both fully populated).
317
6
    let peak_storage_bytes = normal_storage_bytes * 2;
318
319
    // Per-node storage (max across all nodes).
320
6
    let per_node_normal = node_storage_old.iter().copied().max().unwrap_or(0);
321
6
    let per_node_peak = per_node_normal + node_storage_new.iter().copied().max().unwrap_or(0);
322
323
    // Write rates.
324
    // Normal: each incoming doc → RF × RG actual node writes.
325
6
    let normal_write_rate = params.write_rate_dps * rf as u64 * rg as u64;
326
    // Dual-write: each incoming doc → 2 × (RF × RG) writes (old + new assignment).
327
6
    let dual_write_rate = normal_write_rate * 2;
328
    // Backfill: reads all docs, writes each to new assignment → RF × RG writes/doc.
329
    // Plus ongoing dual-writes for new incoming docs.
330
6
    let backfill_write_rate = params.backfill_throttle_dps * rf as u64 * rg as u64;
331
6
    let peak_write_rate = dual_write_rate + backfill_write_rate;
332
333
6
    let dual_write_amplification = 2.0;
334
6
    let peak_write_amplification = peak_write_rate as f64 / normal_write_rate as f64;
335
336
    // Backfill duration: total docs / throttle rate.
337
6
    let backfill_duration_secs = if params.backfill_throttle_dps > 0 {
338
6
        total_docs as f64 / params.backfill_throttle_dps as f64
339
    } else {
340
0
        f64::INFINITY
341
    };
342
343
    // Total bytes written during reshard:
344
    // 1. Dual-write ongoing for the full reshard duration.
345
    // 2. Backfill writes of entire corpus.
346
    // Approximate: backfill_duration × dual_write_rate + corpus × RF × RG.
347
6
    let total_reshard_write_bytes = if params.backfill_throttle_dps > 0 {
348
6
        let dual_write_bytes =
349
6
            backfill_duration_secs * dual_write_rate as f64 * params.doc_size_bytes as f64;
350
6
        let backfill_bytes = total_docs * rf as u64 * rg as u64 * params.doc_size_bytes;
351
6
        (dual_write_bytes as u64) + backfill_bytes
352
    } else {
353
0
        0
354
    };
355
356
6
    let storage_amplification = peak_storage_bytes as f64 / normal_storage_bytes as f64;
357
358
6
    SimResult {
359
6
        label: format!(
360
6
            "{}KB/{}GB/RG{}/RF{}",
361
6
            params.doc_size_bytes / 1024,
362
6
            params.corpus_size_bytes / (1024 * 1024 * 1024),
363
6
            rg,
364
6
            rf
365
6
        ),
366
6
        doc_size_bytes: params.doc_size_bytes,
367
6
        corpus_size_bytes: params.corpus_size_bytes,
368
6
        total_docs,
369
6
        replica_groups: rg,
370
6
        replication_factor: rf,
371
6
        old_shards: params.old_shards,
372
6
        new_shards: params.new_shards,
373
6
        nodes_per_group,
374
6
        write_rate_dps: params.write_rate_dps,
375
6
376
6
        normal_storage_bytes,
377
6
        peak_storage_bytes,
378
6
        storage_amplification,
379
6
380
6
        normal_write_rate,
381
6
        dual_write_rate,
382
6
        peak_write_rate,
383
6
        dual_write_amplification,
384
6
        peak_write_amplification,
385
6
386
6
        backfill_duration_secs,
387
6
        total_bytes_written: total_reshard_write_bytes,
388
6
389
6
        per_node_peak_storage_bytes: per_node_peak,
390
6
        per_node_normal_storage_bytes: per_node_normal,
391
6
392
6
        old_shard_cv: old_cv,
393
6
        new_shard_cv: new_cv,
394
6
    }
395
6
}
396
397
/// Coefficient of variation for a distribution.
398
12
fn cv(values: &[u64]) -> f64 {
399
12
    if values.is_empty() {
400
0
        return 0.0;
401
12
    }
402
12
    let n = values.len() as f64;
403
12
    let mean = values.iter().sum::<u64>() as f64 / n;
404
12
    if mean == 0.0 {
405
0
        return 0.0;
406
12
    }
407
12
    let variance = values
408
12
        .iter()
409
640
        .
map12
(|v| (*v as f64 - mean).powi(2))
410
12
        .sum::<f64>()
411
12
        / n;
412
12
    variance.sqrt() / mean
413
12
}
414
415
// ---------------------------------------------------------------------------
416
// Tests
417
// ---------------------------------------------------------------------------
418
419
#[cfg(test)]
420
mod tests {
421
    use super::*;
422
423
    // ---- TimeWindow parsing and containment ----
424
425
    #[test]
426
2
    fn time_window_parse_simple() {
427
2
        let w = TimeWindow::parse("02:00-06:00").unwrap();
428
2
        assert_eq!(w.start_mins, 120);
429
2
        assert_eq!(w.end_mins, 360);
430
2
    }
431
432
    #[test]
433
2
    fn time_window_parse_wrap_midnight() {
434
2
        let w = TimeWindow::parse("22:00-06:00").unwrap();
435
2
        assert_eq!(w.start_mins, 1320);
436
2
        assert_eq!(w.end_mins, 360);
437
2
    }
438
439
    #[test]
440
2
    fn time_window_contains_normal() {
441
2
        let w = TimeWindow::parse("02:00-06:00").unwrap();
442
2
        assert!(w.contains(180)); // 03:00
443
2
        assert!(!w.contains(100)); // 01:40
444
2
        assert!(!w.contains(400)); // 06:40
445
2
    }
446
447
    #[test]
448
2
    fn time_window_contains_wrap() {
449
2
        let w = TimeWindow::parse("22:00-06:00").unwrap();
450
2
        assert!(w.contains(1350)); // 22:30
451
2
        assert!(w.contains(300)); // 05:00
452
2
        assert!(!w.contains(700)); // 11:40
453
2
    }
454
455
    #[test]
456
2
    fn time_window_boundary_start() {
457
2
        let w = TimeWindow::parse("02:00-06:00").unwrap();
458
2
        assert!(w.contains(120)); // exactly 02:00
459
2
    }
460
461
    #[test]
462
2
    fn time_window_boundary_end_exclusive() {
463
2
        let w = TimeWindow::parse("02:00-06:00").unwrap();
464
2
        assert!(!w.contains(360)); // exactly 06:00 is excluded
465
2
    }
466
467
    #[test]
468
2
    fn time_window_invalid_format() {
469
2
        assert!(TimeWindow::parse("not-a-window").is_err());
470
2
        assert!(TimeWindow::parse("25:00-06:00").is_err());
471
2
        assert!(TimeWindow::parse("02:60-06:00").is_err());
472
2
    }
473
474
    // ---- Window guard ----
475
476
    #[test]
477
2
    fn window_guard_no_restriction() {
478
2
        let config = ReshardingConfig::default();
479
2
        assert_eq!(check_window(0, &config), WindowGuardResult::NoRestriction);
480
2
    }
481
482
    #[test]
483
2
    fn window_guard_allowed() {
484
2
        let config = ReshardingConfig {
485
2
            allowed_windows: vec!["02:00-06:00".into()],
486
2
            ..Default::default()
487
2
        };
488
2
        let result = check_window(180, &config); // 03:00
489
2
        assert!(
matches!0
(result, WindowGuardResult::Allowed { .. }));
490
2
    }
491
492
    #[test]
493
2
    fn window_guard_denied() {
494
2
        let config = ReshardingConfig {
495
2
            allowed_windows: vec!["02:00-06:00".into()],
496
2
            ..Default::default()
497
2
        };
498
2
        let result = check_window(720, &config); // 12:00
499
2
        assert!(
matches!0
(result, WindowGuardResult::Denied { .. }));
500
2
    }
501
502
    #[test]
503
2
    fn window_guard_multiple_windows() {
504
2
        let config = ReshardingConfig {
505
2
            allowed_windows: vec!["02:00-04:00".into(), "22:00-23:30".into()],
506
2
            ..Default::default()
507
2
        };
508
        // In first window.
509
2
        assert!(
matches!0
(
510
2
            check_window(150, &config),
511
            WindowGuardResult::Allowed { .. }
512
        ));
513
        // In second window.
514
2
        assert!(
matches!0
(
515
2
            check_window(1350, &config),
516
            WindowGuardResult::Allowed { .. }
517
        ));
518
        // Outside both.
519
2
        assert!(
matches!0
(
520
2
            check_window(720, &config),
521
            WindowGuardResult::Denied { .. }
522
        ));
523
2
    }
524
525
    // ---- Simulation ----
526
527
    #[test]
528
2
    fn simulation_storage_always_2x() {
529
        // Regardless of parameters, peak storage should be exactly 2× normal.
530
2
        let params = SimParams {
531
2
            doc_size_bytes: 1024,
532
2
            corpus_size_bytes: 10 * 1024 * 1024 * 1024, // 10 GB
533
2
            write_rate_dps: 100,
534
2
            replica_groups: 2,
535
2
            replication_factor: 1,
536
2
            old_shards: 64,
537
2
            new_shards: 128,
538
2
            nodes_per_group: 3,
539
2
            backfill_throttle_dps: 10_000,
540
2
        };
541
2
        let result = simulate(&params);
542
2
        assert!(
543
2
            (result.storage_amplification - 2.0).abs() < 0.01,
544
0
            "expected ~2.0, got {}",
545
            result.storage_amplification
546
        );
547
2
    }
548
549
    #[test]
550
2
    fn simulation_dual_write_is_2x() {
551
2
        let params = SimParams {
552
2
            doc_size_bytes: 1024,
553
2
            corpus_size_bytes: 1024 * 1024,
554
2
            write_rate_dps: 100,
555
2
            replica_groups: 2,
556
2
            replication_factor: 1,
557
2
            old_shards: 16,
558
2
            new_shards: 32,
559
2
            nodes_per_group: 3,
560
2
            backfill_throttle_dps: 1000,
561
2
        };
562
2
        let result = simulate(&params);
563
2
        assert!(
564
2
            (result.dual_write_amplification - 2.0).abs() < 0.01,
565
0
            "expected 2.0, got {}",
566
            result.dual_write_amplification
567
        );
568
2
    }
569
570
    #[test]
571
2
    fn simulation_low_cv_with_many_docs() {
572
        // With enough docs, hash distribution CV should be very low (< 5%).
573
2
        let params = SimParams {
574
2
            doc_size_bytes: 1024,
575
2
            corpus_size_bytes: 1_000_000 * 1024, // 1M docs × 1KB
576
2
            write_rate_dps: 100,
577
2
            replica_groups: 1,
578
2
            replication_factor: 1,
579
2
            old_shards: 16,
580
2
            new_shards: 64,
581
2
            nodes_per_group: 4,
582
2
            backfill_throttle_dps: 1000,
583
2
        };
584
2
        let result = simulate(&params);
585
2
        assert!(
586
2
            result.old_shard_cv < 0.05,
587
0
            "old shard CV too high: {}",
588
            result.old_shard_cv
589
        );
590
2
        assert!(
591
2
            result.new_shard_cv < 0.05,
592
0
            "new shard CV too high: {}",
593
            result.new_shard_cv
594
        );
595
2
    }
596
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/router.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/router.rs.html deleted file mode 100644 index 59dafbe..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/router.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/router.rs
Line
Count
Source
1
//! Rendezvous hash-based routing and shard assignment.
2
3
use crate::topology::{Group, NodeId, Topology};
4
use std::hash::{Hash, Hasher};
5
use twox_hash::XxHash64;
6
7
/// Compute a rendezvous score for a shard+node pair.
8
///
9
/// Higher scores win; used for deterministic shard assignment.
10
///
11
/// CRITICAL: Uses seed 0 to match Meilisearch Enterprise's hash function.
12
/// Any deviation (different seed, different ordering, endianness) forks
13
/// routing across any two Miroir instances and silently corrupts writes.
14
267M
pub fn score(shard_id: u32, node_id: &str) -> u64 {
15
267M
    let mut h = XxHash64::with_seed(0);
16
267M
    shard_id.hash(&mut h);
17
267M
    node_id.hash(&mut h);
18
267M
    h.finish()
19
267M
}
20
21
/// Assign a shard to `rf` nodes within a single replica group.
22
///
23
/// `group_nodes` is the subset of nodes belonging to that group.
24
///
25
/// Nodes are sorted by score descending, with ties broken lexicographically
26
/// by node_id to ensure deterministic assignment even when hash scores collide.
27
87.9M
pub fn assign_shard_in_group(shard_id: u32, group_nodes: &[NodeId], rf: usize) -> Vec<NodeId> {
28
87.9M
    let mut scored: Vec<(u64, &NodeId)> = group_nodes
29
87.9M
        .iter()
30
267M
        .
map87.9M
(|n| (score(shard_id, n.as_str()), n))
31
87.9M
        .collect();
32
244M
    
scored87.9M
.
sort_unstable_by87.9M
(|a, b| {
33
244M
        b.0.cmp(&a.0)
34
244M
            .then_with(|| 
a.1.as_str()0
.
cmp0
(
b.1.as_str()0
))
35
244M
    });
36
87.9M
    scored
37
87.9M
        .into_iter()
38
87.9M
        .take(rf)
39
87.9M
        .
map87.9M
(|(_, n)| n.clone())
40
87.9M
        .collect()
41
87.9M
}
42
43
/// All write targets for a document: the RF nodes in EACH replica group.
44
6
pub fn write_targets(shard_id: u32, topology: &Topology) -> Vec<NodeId> {
45
6
    topology
46
6
        .groups()
47
10
        .
flat_map6
(|group| assign_shard_in_group(shard_id, group.nodes(), topology.rf()))
48
6
        .collect()
49
6
}
50
51
/// Select the replica group for a query (round-robin by query counter).
52
2.00k
pub fn query_group(query_seq: u64, replica_groups: u32) -> u32 {
53
2.00k
    (query_seq % replica_groups as u64) as u32
54
2.00k
}
55
56
/// The covering set for a search: one node per shard within the chosen group.
57
///
58
/// Returns a Vec where index i contains the node to query for shard i.
59
/// The length is always exactly shard_count, even if multiple shards
60
/// map to the same node.
61
6
pub fn covering_set(shard_count: u32, group: &Group, rf: usize, query_seq: u64) -> Vec<NodeId> {
62
6
    (0..shard_count)
63
168
        .
map6
(|shard_id| {
64
168
            let replicas = assign_shard_in_group(shard_id, group.nodes(), rf);
65
            // rotate through replicas for intra-group load balancing
66
168
            replicas[(query_seq as usize) % replicas.len()].clone()
67
168
        })
68
6
        .collect()
69
6
}
70
71
/// Compute the shard ID for a document's primary key.
72
45.9M
pub fn shard_for_key(primary_key: &str, shard_count: u32) -> u32 {
73
45.9M
    let mut h = XxHash64::with_seed(0);
74
45.9M
    primary_key.hash(&mut h);
75
45.9M
    (h.finish() % shard_count as u64) as u32
76
45.9M
}
77
78
#[cfg(test)]
79
mod tests {
80
    use super::*;
81
    use crate::topology::{Node, NodeId};
82
83
    // Test 1: Rendezvous assignment is deterministic given fixed node list
84
    #[test]
85
2
    fn test_rendezvous_determinism() {
86
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
87
2
            .into_iter()
88
6
            .
map2
(|s| NodeId::new(s.to_string()))
89
2
            .collect();
90
2
        let shard_id = 42;
91
92
2
        let assignment1 = assign_shard_in_group(shard_id, &nodes, 1);
93
2
        let assignment2 = assign_shard_in_group(shard_id, &nodes, 1);
94
95
2
        assert_eq!(assignment1, assignment2);
96
2
    }
97
98
    // Test 2: Score is stable across calls
99
    #[test]
100
2
    fn test_score_stability() {
101
2
        let score1 = score(123, "node1");
102
2
        let score2 = score(123, "node1");
103
2
        assert_eq!(score1, score2);
104
2
    }
105
106
    // Test 3: Different shard+node pairs produce different scores
107
    #[test]
108
2
    fn test_score_uniqueness() {
109
2
        let score1 = score(1, "node1");
110
2
        let score2 = score(1, "node2");
111
2
        let score3 = score(2, "node1");
112
113
2
        assert_ne!(score1, score2);
114
2
        assert_ne!(score1, score3);
115
2
        assert_ne!(score2, score3);
116
2
    }
117
118
    // Test 4: Adding a 4th node moves at most ~2 × (1/4) of shards
119
    #[test]
120
2
    fn test_minimal_reshuffling_on_add() {
121
2
        let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
122
2
            .into_iter()
123
6
            .
map2
(|s| NodeId::new(s.to_string()))
124
2
            .collect();
125
2
        let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
126
2
            .into_iter()
127
8
            .
map2
(|s| NodeId::new(s.to_string()))
128
2
            .collect();
129
130
2
        let shard_count = 100;
131
2
        let rf = 1;
132
133
2
        let mut moved_count = 0;
134
200
        for shard_id in 0..
shard_count2
{
135
200
            let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
136
200
            let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
137
138
            // Shard moved if its primary owner changed
139
200
            if assign_3.first() != assign_4.first() {
140
46
                moved_count += 1;
141
154
            }
142
        }
143
144
        // Expected: at most ~2 × (1/4) = 50% of shards
145
2
        let max_expected = (shard_count as f64 * 0.5).ceil() as usize;
146
2
        assert!(
147
2
            moved_count <= max_expected,
148
0
            "Expected ≤ {max_expected} shards to move, but {moved_count} moved"
149
        );
150
2
    }
151
152
    // Test 5: 64 shards / 3 nodes / RF=1 → each node holds 18–26 shards
153
    #[test]
154
2
    fn test_shard_distribution_64_3_rf1() {
155
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
156
2
            .into_iter()
157
6
            .
map2
(|s| NodeId::new(s.to_string()))
158
2
            .collect();
159
2
        let shard_count = 64;
160
2
        let rf = 1;
161
162
2
        let mut node_shard_counts: std::collections::HashMap<String, usize> =
163
2
            std::collections::HashMap::new();
164
165
128
        for shard_id in 0..
shard_count2
{
166
128
            let assignment = assign_shard_in_group(shard_id, &nodes, rf);
167
128
            if let Some(node) = assignment.first() {
168
128
                *node_shard_counts
169
128
                    .entry(node.as_str().to_string())
170
128
                    .or_insert(0) += 1;
171
128
            
}0
172
        }
173
174
        // Debug: print actual distribution
175
2
        eprintln!("Actual shard distribution: {node_shard_counts:?}");
176
177
        // DoD requirement: each node holds 15–27 shards
178
        // This accommodates natural variance in hash-based distribution
179
8
        for (
node6
,
count6
) in &node_shard_counts {
180
6
            assert!(
181
6
                *count >= 15 && *count <= 27,
182
0
                "Node {node} has {count} shards, expected 15–27"
183
            );
184
        }
185
186
        // Total should equal shard_count
187
2
        let total: usize = node_shard_counts.values().sum();
188
2
        assert_eq!(total, shard_count as usize);
189
2
    }
190
191
    // Test 6: Top-RF placement changes minimally on add/remove
192
    #[test]
193
2
    fn test_top_rf_stability() {
194
2
        let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
195
2
            .into_iter()
196
6
            .
map2
(|s| NodeId::new(s.to_string()))
197
2
            .collect();
198
2
        let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
199
2
            .into_iter()
200
8
            .
map2
(|s| NodeId::new(s.to_string()))
201
2
            .collect();
202
2
        let rf = 2;
203
2
        let shard_count = 100;
204
205
2
        let mut changed_count = 0;
206
200
        for shard_id in 0..
shard_count2
{
207
200
            let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
208
200
            let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
209
210
            // Count how many of the top-RF nodes changed
211
200
            let set_3: std::collections::HashSet<_> = assign_3.iter().collect();
212
200
            let set_4: std::collections::HashSet<_> = assign_4.iter().collect();
213
214
            // A change is if the intersection is less than RF
215
200
            let intersection = set_3.intersection(&set_4).count();
216
200
            if intersection < rf {
217
98
                changed_count += 1;
218
102
            }
219
        }
220
221
        // Adding a 4th node affects approximately 1/4 of assignments
222
        // But with RF=2, we need to account for overlap
223
        // Expected: roughly 50-60% might have some change
224
2
        let max_expected = (shard_count as f64 * 0.6).ceil() as usize;
225
2
        assert!(
226
2
            changed_count <= max_expected,
227
0
            "Expected ≤ {max_expected} shards to change, but {changed_count} changed"
228
        );
229
230
        // Also verify that not everything changed
231
2
        let min_expected = (shard_count as f64 * 0.2).ceil() as usize;
232
2
        assert!(
233
2
            changed_count >= min_expected,
234
0
            "Expected at least {min_expected} shards to change, but only {changed_count} changed"
235
        );
236
2
    }
237
238
    // Test 7: write_targets returns exactly RG × RF nodes
239
    #[test]
240
2
    fn test_write_targets_count() {
241
2
        let mut topology = Topology::new(64, 2); // 64 shards, RF=2
242
243
        // 3 replica groups, 2 nodes each
244
8
        for 
group_id6
in 0..3 {
245
18
            for 
node_idx12
in 0..2 {
246
12
                let node = Node::new(
247
12
                    NodeId::new(format!("node-g{group_id}-{node_idx}")),
248
12
                    format!("http://example.com/{group_id}"),
249
12
                    group_id,
250
12
                );
251
12
                topology.add_node(node);
252
12
            }
253
        }
254
255
2
        let shard_id = 42;
256
2
        let targets = write_targets(shard_id, &topology);
257
258
        // Should be RG (3) × RF (2) = 6 nodes
259
2
        assert_eq!(targets.len(), 6);
260
261
        // All targets should be unique
262
2
        let unique: std::collections::HashSet<_> = targets.iter().collect();
263
2
        assert_eq!(unique.len(), 6);
264
265
        // Each replica group should contribute exactly RF nodes
266
6
        for group in 
topology2
.
groups2
() {
267
6
            let group_targets: Vec<_> = targets
268
6
                .iter()
269
36
                .
filter6
(|t| group.nodes().contains(t))
270
6
                .collect();
271
6
            assert_eq!(
272
6
                group_targets.len(),
273
6
                topology.rf(),
274
0
                "Group {} should contribute exactly RF nodes",
275
                group.id
276
            );
277
        }
278
2
    }
279
280
    // Test 8: query_group distributes evenly
281
    #[test]
282
2
    fn test_query_group_distribution() {
283
2
        let replica_groups = 3u32;
284
2
        let queries = 1000u64;
285
286
2
        let mut counts = vec![0; replica_groups as usize];
287
2.00k
        for seq in 0..
queries2
{
288
2.00k
            let group = query_group(seq, replica_groups);
289
2.00k
            counts[group as usize] += 1;
290
2.00k
        }
291
292
        // Each group should get roughly the same number of queries
293
2
        let expected = (queries / replica_groups as u64) as usize;
294
8
        for 
count6
in counts {
295
6
            assert!(
296
6
                count >= expected - 1 && count <= expected + 1,
297
0
                "Group query count {} outside expected range [{}, {}]",
298
                count,
299
0
                expected - 1,
300
0
                expected + 1
301
            );
302
        }
303
2
    }
304
305
    // Test 9: covering_set returns exactly one node per shard
306
    #[test]
307
2
    fn test_covering_set_one_per_shard() {
308
2
        let mut topology = Topology::new(64, 2); // 64 shards, RF=2
309
2
        let group_id = 0;
310
2
        let num_nodes = 5;
311
312
        // Add nodes to a single group
313
10
        for node_idx in 0..
num_nodes2
{
314
10
            let node = Node::new(
315
10
                NodeId::new(format!("node-{node_idx}")),
316
10
                format!("http://example.com/{node_idx}"),
317
10
                group_id,
318
10
            );
319
10
            topology.add_node(node);
320
10
        }
321
322
2
        let group = topology.group(group_id).unwrap();
323
2
        let shard_count = 64;
324
2
        let rf = 2;
325
2
        let query_seq = 0;
326
327
2
        let covering = covering_set(shard_count, group, rf, query_seq);
328
329
        // Should have exactly one node per shard
330
2
        assert_eq!(covering.len(), shard_count as usize);
331
332
        // All nodes should be from the group
333
130
        for 
node128
in &covering {
334
128
            assert!(group.nodes().contains(node));
335
        }
336
2
    }
337
338
    // Test 10: covering_set handles intra-group replica rotation
339
    #[test]
340
2
    fn test_covering_set_replica_rotation() {
341
2
        let mut topology = Topology::new(64, 2); // 64 shards, RF=2
342
2
        let group_id = 0;
343
344
        // Add 3 nodes to a single group
345
8
        for 
node_idx6
in 0..3 {
346
6
            let node = Node::new(
347
6
                NodeId::new(format!("node-{node_idx}")),
348
6
                format!("http://example.com/{node_idx}"),
349
6
                group_id,
350
6
            );
351
6
            topology.add_node(node);
352
6
        }
353
354
2
        let group = topology.group(group_id).unwrap();
355
2
        let shard_count = 10;
356
2
        let rf = 2;
357
358
2
        let covering_0 = covering_set(shard_count, group, rf, 0);
359
2
        let covering_1 = covering_set(shard_count, group, rf, 1);
360
361
        // With RF=2, the covering set should rotate between the two replicas
362
        // For each shard, the node should be different between query_seq 0 and 1
363
        // Note: This is true for most shards but not all, since assignment is deterministic
364
2
        let mut rotated_count = 0;
365
20
        for (n0, n1) in 
covering_0.iter()2
.
zip2
(
covering_1.iter()2
) {
366
20
            if n0 != n1 {
367
20
                rotated_count += 1;
368
20
            
}0
369
        }
370
371
        // At least some shards should rotate (ideally most/all)
372
2
        assert!(
373
2
            rotated_count >= shard_count as usize / 2,
374
0
            "Expected at least half of shards to rotate, but only {rotated_count} did"
375
        );
376
2
    }
377
378
    // Test 11: shard_for_key is deterministic
379
    #[test]
380
2
    fn test_shard_for_key_determinism() {
381
2
        let key = "user:12345";
382
2
        let shard_count = 64;
383
384
2
        let shard1 = shard_for_key(key, shard_count);
385
2
        let shard2 = shard_for_key(key, shard_count);
386
387
2
        assert_eq!(shard1, shard2);
388
2
        assert!(shard1 < shard_count);
389
2
    }
390
391
    // Test 12: shard_for_key distributes keys evenly
392
    #[test]
393
2
    fn test_shard_for_key_distribution() {
394
2
        let shard_count = 64;
395
2
        let keys = 1000;
396
397
2
        let mut counts = vec![0; shard_count as usize];
398
2.00k
        for i in 0..
keys2
{
399
2.00k
            let key = format!("user:{i}");
400
2.00k
            let shard = shard_for_key(&key, shard_count);
401
2.00k
            counts[shard as usize] += 1;
402
2.00k
        }
403
404
        // Each shard should get roughly keys / shard_count entries
405
2
        let expected = keys / shard_count as usize;
406
130
        for 
count128
in counts {
407
            // Allow some variance due to hash distribution
408
128
            assert!(
409
128
                count >= expected / 2 && count <= expected * 2,
410
0
                "Shard count {count} outside reasonable range"
411
            );
412
        }
413
2
    }
414
415
    // Test 13: assign_shard_in_group respects RF limit
416
    #[test]
417
2
    fn test_assign_shard_respects_rf() {
418
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3", "node4", "node5"]
419
2
            .into_iter()
420
10
            .
map2
(|s| NodeId::new(s.to_string()))
421
2
            .collect();
422
2
        let shard_id = 42;
423
424
12
        for 
rf10
in 1..=5 {
425
10
            let assignment = assign_shard_in_group(shard_id, &nodes, rf);
426
10
            assert_eq!(
427
10
                assignment.len(),
428
                rf,
429
0
                "Assignment should return exactly RF nodes"
430
            );
431
        }
432
2
    }
433
434
    // Test 14: assign_shard_in_group handles RF larger than node count
435
    #[test]
436
2
    fn test_assign_shard_rf_larger_than_nodes() {
437
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
438
2
            .into_iter()
439
6
            .
map2
(|s| NodeId::new(s.to_string()))
440
2
            .collect();
441
2
        let shard_id = 42;
442
2
        let rf = 5;
443
444
2
        let assignment = assign_shard_in_group(shard_id, &nodes, rf);
445
446
        // Should return all available nodes when RF > node count
447
2
        assert_eq!(assignment.len(), nodes.len());
448
2
    }
449
450
    // Test 15: Empty node list returns empty assignment
451
    #[test]
452
2
    fn test_assign_shard_empty_nodes() {
453
2
        let nodes: Vec<NodeId> = vec![];
454
2
        let shard_id = 42;
455
2
        let rf = 2;
456
457
2
        let assignment = assign_shard_in_group(shard_id, &nodes, rf);
458
459
2
        assert!(assignment.is_empty());
460
2
    }
461
462
    // Test 16: write_targets with empty topology
463
    #[test]
464
2
    fn test_write_targets_empty_topology() {
465
2
        let topology = Topology::new(64, 2);
466
2
        let shard_id = 42;
467
468
2
        let targets = write_targets(shard_id, &topology);
469
470
2
        assert!(targets.is_empty());
471
2
    }
472
473
    // Test 17: shard_for_key with zero shard_count handles edge case
474
    #[test]
475
    #[should_panic(expected = "attempt to calculate the remainder with a divisor of zero")]
476
2
    fn test_shard_for_key_zero_shard_count() {
477
        // This test verifies the panic behavior - in production this should be validated
478
        // at the API boundary
479
2
        shard_for_key("test", 0);
480
2
    }
481
482
    // Test 18: Group-scoped assignment prevents same-group replica placement
483
    #[test]
484
2
    fn test_group_scoped_assignment() {
485
        // Create topology with 2 groups, 2 nodes each
486
2
        let mut topology = Topology::new(64, 1); // 64 shards, RF=1
487
2
        let shard_id = 42;
488
489
        // Group 0
490
2
        topology.add_node(Node::new(
491
2
            NodeId::new("g0n0".to_string()),
492
2
            "http://g0n0".to_string(),
493
            0,
494
        ));
495
2
        topology.add_node(Node::new(
496
2
            NodeId::new("g0n1".to_string()),
497
2
            "http://g0n1".to_string(),
498
            0,
499
        ));
500
501
        // Group 1
502
2
        topology.add_node(Node::new(
503
2
            NodeId::new("g1n0".to_string()),
504
2
            "http://g1n0".to_string(),
505
            1,
506
        ));
507
2
        topology.add_node(Node::new(
508
2
            NodeId::new("g1n1".to_string()),
509
2
            "http://g1n1".to_string(),
510
            1,
511
        ));
512
513
2
        let targets = write_targets(shard_id, &topology);
514
515
        // With RG=2, RF=1, should get 2 targets (one from each group)
516
2
        assert_eq!(targets.len(), 2);
517
518
        // Verify one from each group
519
2
        let g0_target = targets.iter().any(|t| {
520
2
            topology
521
2
                .node(t)
522
2
                .map(|n| n.replica_group == 0)
523
2
                .unwrap_or(false)
524
2
        });
525
4
        let 
g1_target2
=
targets.iter()2
.
any2
(|t| {
526
4
            topology
527
4
                .node(t)
528
4
                .map(|n| n.replica_group == 1)
529
4
                .unwrap_or(false)
530
4
        });
531
532
2
        assert!(g0_target, 
"Should have one target from group 0"0
);
533
2
        assert!(g1_target, 
"Should have one target from group 1"0
);
534
2
    }
535
536
    // === Acceptance Tests (plan §8 "Router correctness") ===
537
538
    // AT-1: Determinism: same (shard_id, nodes) → identical Vec<NodeId> across 1000 randomized runs
539
    #[test]
540
2
    fn acceptance_determinism_1000_runs() {
541
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
542
2
            .into_iter()
543
8
            .
map2
(|s| NodeId::new(s.to_string()))
544
2
            .collect();
545
546
2.00k
        for 
run2.00k
in 0..1000 {
547
2.00k
            let shard_id = (run % 100) as u32; // Test different shard IDs
548
2.00k
            let rf = ((run % 3) + 1) as usize; // Test different RF values
549
550
2.00k
            let assignment1 = assign_shard_in_group(shard_id, &nodes, rf);
551
2.00k
            let assignment2 = assign_shard_in_group(shard_id, &nodes, rf);
552
553
2.00k
            assert_eq!(
554
                assignment1, assignment2,
555
0
                "Assignments differ on run {}: shard_id={}, rf={}",
556
                run, shard_id, rf
557
            );
558
        }
559
2
    }
560
561
    // AT-2: Reshuffle bound on add: 64 shards, 3→4 nodes → at most 2 × (1/4) × 64 edges differ
562
    #[test]
563
2
    fn acceptance_reshuffle_bound_on_add() {
564
2
        let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
565
2
            .into_iter()
566
6
            .
map2
(|s| NodeId::new(s.to_string()))
567
2
            .collect();
568
2
        let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
569
2
            .into_iter()
570
8
            .
map2
(|s| NodeId::new(s.to_string()))
571
2
            .collect();
572
573
2
        let shard_count = 64;
574
2
        let rf = 1;
575
576
2
        let mut moved_count = 0;
577
128
        for shard_id in 0..
shard_count2
{
578
128
            let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
579
128
            let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
580
581
            // Shard moved if its primary owner changed
582
128
            if assign_3.first() != assign_4.first() {
583
30
                moved_count += 1;
584
98
            }
585
        }
586
587
        // Expected: at most 2 × (1/4) × 64 = 32 edges differ
588
2
        let max_expected = (2.0 * (1.0 / 4.0) * shard_count as f64).ceil() as usize;
589
2
        assert!(
590
2
            moved_count <= max_expected,
591
0
            "Expected ≤ {max_expected} shard-node edges to differ, but {moved_count} differed"
592
        );
593
2
    }
594
595
    // AT-3: Reshuffle bound on remove: 64 shards, 4→3 nodes → ~RF × S / Ng edges differ
596
    #[test]
597
2
    fn acceptance_reshuffle_bound_on_remove() {
598
2
        let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
599
2
            .into_iter()
600
8
            .
map2
(|s| NodeId::new(s.to_string()))
601
2
            .collect();
602
2
        let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
603
2
            .into_iter()
604
6
            .
map2
(|s| NodeId::new(s.to_string()))
605
2
            .collect();
606
607
2
        let shard_count = 64;
608
2
        let rf = 2;
609
610
2
        let mut moved_count = 0;
611
128
        for shard_id in 0..
shard_count2
{
612
128
            let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
613
128
            let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
614
615
            // Count edges that differ
616
128
            let set_4: std::collections::HashSet<_> = assign_4.iter().collect();
617
128
            let set_3: std::collections::HashSet<_> = assign_3.iter().collect();
618
619
            // An edge differs if it's not in both sets
620
128
            let diff = set_4.symmetric_difference(&set_3).count();
621
128
            if diff > 0 {
622
58
                moved_count += diff;
623
70
            }
624
        }
625
626
        // Expected: ~RF × S / Ng = 2 × 64 / 4 = 32 edges differ
627
        // Allow some variance due to hash distribution
628
2
        let expected = (rf * shard_count as usize) / 4;
629
2
        let tolerance = (expected as f64 * 0.9).ceil() as usize; // ±90%
630
2
        assert!(
631
2
            moved_count >= expected - tolerance && moved_count <= expected + tolerance,
632
0
            "Expected ~{expected} shard-node edges to differ (±{tolerance}), but {moved_count} differed"
633
        );
634
2
    }
635
636
    // AT-4: Uniformity: 64 shards, 3 nodes, RF=1 → each node holds 18–26 shards
637
    #[test]
638
2
    fn acceptance_uniformity_64_shards_3_nodes_rf1() {
639
2
        let nodes: Vec<NodeId> = vec!["node1", "node2", "node3"]
640
2
            .into_iter()
641
6
            .
map2
(|s| NodeId::new(s.to_string()))
642
2
            .collect();
643
2
        let shard_count = 64;
644
2
        let rf = 1;
645
646
2
        let mut node_shard_counts: std::collections::HashMap<String, usize> =
647
2
            std::collections::HashMap::new();
648
649
128
        for shard_id in 0..
shard_count2
{
650
128
            let assignment = assign_shard_in_group(shard_id, &nodes, rf);
651
128
            if let Some(node) = assignment.first() {
652
128
                *node_shard_counts
653
128
                    .entry(node.as_str().to_string())
654
128
                    .or_insert(0) += 1;
655
128
            
}0
656
        }
657
658
        // DoD requirement: each node holds 15–27 shards
659
8
        for (
node6
,
count6
) in &node_shard_counts {
660
6
            assert!(
661
6
                *count >= 15 && *count <= 27,
662
0
                "Node {node} has {count} shards, expected 15–27"
663
            );
664
        }
665
666
        // Total should equal shard_count
667
2
        let total: usize = node_shard_counts.values().sum();
668
2
        assert_eq!(total, shard_count as usize);
669
2
    }
670
671
    // AT-5: RF=2 placement: top-2 nodes change minimally when a node is added or removed
672
    #[test]
673
2
    fn acceptance_rf2_placement_stability() {
674
2
        let nodes_3: Vec<NodeId> = vec!["node1", "node2", "node3"]
675
2
            .into_iter()
676
6
            .
map2
(|s| NodeId::new(s.to_string()))
677
2
            .collect();
678
2
        let nodes_4: Vec<NodeId> = vec!["node1", "node2", "node3", "node4"]
679
2
            .into_iter()
680
8
            .
map2
(|s| NodeId::new(s.to_string()))
681
2
            .collect();
682
683
2
        let shard_count = 64;
684
2
        let rf = 2;
685
686
2
        let mut changed_count = 0;
687
128
        for shard_id in 0..
shard_count2
{
688
128
            let assign_3 = assign_shard_in_group(shard_id, &nodes_3, rf);
689
128
            let assign_4 = assign_shard_in_group(shard_id, &nodes_4, rf);
690
691
            // Count how many of the top-RF nodes changed
692
128
            let set_3: std::collections::HashSet<_> = assign_3.iter().collect();
693
128
            let set_4: std::collections::HashSet<_> = assign_4.iter().collect();
694
695
            // A change is if the intersection is less than RF
696
128
            let intersection = set_3.intersection(&set_4).count();
697
128
            if intersection < rf {
698
58
                changed_count += 1;
699
70
            }
700
        }
701
702
        // Adding a 4th node should affect minimally
703
        // Expected: roughly 1/4 of assignments might have some change
704
2
        let max_expected = (shard_count as f64 * 0.5).ceil() as usize;
705
2
        assert!(
706
2
            changed_count <= max_expected,
707
0
            "Expected ≤ {max_expected} shards to change, but {changed_count} changed"
708
        );
709
2
    }
710
711
    // AT-6: shard_for_key uses seed 0 and matches known fixture
712
    #[test]
713
2
    fn acceptance_shard_for_key_fixture() {
714
        // Known fixture values computed with XxHash64::with_seed(0)
715
        // These are verified against the actual twox-hash implementation
716
2
        let fixtures = [
717
2
            ("user:12345", 64, 15),
718
2
            ("product:abc", 64, 24),
719
2
            ("order:99999", 64, 4),
720
2
            ("test", 16, 10),
721
2
            ("hello", 32, 6),
722
2
        ];
723
724
12
        for (
key10
,
shard_count10
,
expected_shard10
) in fixtures {
725
10
            let shard = shard_for_key(key, shard_count);
726
10
            assert_eq!(
727
                shard, expected_shard,
728
0
                "shard_for_key(\"{}\", {}) should be {}, got {}",
729
                key, shard_count, expected_shard, shard
730
            );
731
        }
732
2
    }
733
734
    // AT-7: Tie-breaking on node_id for identical scores
735
    #[test]
736
2
    fn acceptance_tie_breaking_node_id() {
737
        // Create nodes that will have deterministic assignment
738
2
        let nodes: Vec<NodeId> = vec!["node-a", "node-b", "node-c"]
739
2
            .into_iter()
740
6
            .
map2
(|s| NodeId::new(s.to_string()))
741
2
            .collect();
742
743
2
        let rf = 3; // Request all nodes
744
2
        let shard_id = 42;
745
746
2
        let assignment = assign_shard_in_group(shard_id, &nodes, rf);
747
748
        // Should return all nodes in a deterministic order
749
2
        assert_eq!(assignment.len(), 3);
750
751
        // The order should be stable across calls
752
2
        let assignment2 = assign_shard_in_group(shard_id, &nodes, rf);
753
2
        assert_eq!(assignment, assignment2);
754
2
    }
755
756
    // AT-8: Canonical concatenation order (shard_id, node_id)
757
    #[test]
758
2
    fn acceptance_canonical_concatenation_order() {
759
        // Verify that score(shard_id, node_id) != score(node_id, shard_id)
760
        // by checking that different orders produce different results
761
2
        let shard_id = 42u32;
762
2
        let node_id = "node1";
763
764
2
        let score_correct = score(shard_id, node_id);
765
766
        // Compute score with reversed order (manually)
767
        use std::hash::{Hash, Hasher};
768
2
        let mut h_rev = twox_hash::XxHash64::with_seed(0);
769
2
        node_id.hash(&mut h_rev);
770
2
        shard_id.hash(&mut h_rev);
771
2
        let score_reversed = h_rev.finish();
772
773
        // These should almost certainly be different
774
2
        assert_ne!(
775
            score_correct, score_reversed,
776
0
            "Canonical order (shard_id, node_id) must differ from reversed order"
777
        );
778
2
    }
779
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/scatter.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/scatter.rs.html deleted file mode 100644 index 45190b9..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/scatter.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/scatter.rs
Line
Count
Source
1
//! Scatter orchestration: fan-out logic and covering set builder.
2
3
use async_trait::async_trait;
4
use crate::config::UnavailableShardPolicy;
5
use crate::topology::{NodeId, Topology};
6
use crate::Result;
7
8
/// Scatter orchestrator: fans out requests to the covering set.
9
#[async_trait]
10
pub trait Scatter: Send + Sync {
11
    /// Execute a scatter request to multiple nodes.
12
    ///
13
    /// Returns a map of node ID to response. Failed nodes are omitted
14
    /// based on the unavailable shard policy.
15
    async fn scatter(
16
        &self,
17
        topology: &Topology,
18
        nodes: Vec<NodeId>,
19
        request: ScatterRequest,
20
        policy: UnavailableShardPolicy,
21
    ) -> Result<ScatterResponse>;
22
}
23
24
/// A scatter request to be sent to each node.
25
#[derive(Debug, Clone)]
26
pub struct ScatterRequest {
27
    /// Request body (JSON or raw bytes).
28
    pub body: Vec<u8>,
29
30
    /// Request headers.
31
    pub headers: Vec<(String, String)>,
32
33
    /// HTTP method.
34
    pub method: String,
35
36
    /// Request path.
37
    pub path: String,
38
}
39
40
/// Response from a scatter operation.
41
#[derive(Debug, Clone)]
42
pub struct ScatterResponse {
43
    /// Responses from successful nodes.
44
    pub responses: Vec<NodeResponse>,
45
46
    /// Nodes that failed or timed out.
47
    pub failed: Vec<NodeId>,
48
}
49
50
/// Response from a single node.
51
#[derive(Debug, Clone)]
52
pub struct NodeResponse {
53
    /// Node that responded.
54
    pub node_id: NodeId,
55
56
    /// Response body.
57
    pub body: Vec<u8>,
58
59
    /// HTTP status code.
60
    pub status: u16,
61
62
    /// Response headers.
63
    pub headers: Vec<(String, String)>,
64
}
65
66
/// Default stub implementation of Scatter.
67
#[derive(Debug, Clone, Default)]
68
pub struct StubScatter;
69
70
#[async_trait]
71
impl Scatter for StubScatter {
72
    async fn scatter(
73
        &self,
74
        _topology: &Topology,
75
        _nodes: Vec<NodeId>,
76
        _request: ScatterRequest,
77
        _policy: UnavailableShardPolicy,
78
6
    ) -> Result<ScatterResponse> {
79
        Ok(ScatterResponse {
80
            responses: Vec::new(),
81
            failed: Vec::new(),
82
        })
83
6
    }
84
}
85
86
#[cfg(test)]
87
mod tests {
88
    use super::*;
89
    use crate::config::UnavailableShardPolicy;
90
91
    #[test]
92
2
    fn test_scatter_request_creation() {
93
2
        let request = ScatterRequest {
94
2
            body: b"test body".to_vec(),
95
2
            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
96
2
            method: "POST".to_string(),
97
2
            path: "/search".to_string(),
98
2
        };
99
100
2
        assert_eq!(request.body, b"test body");
101
2
        assert_eq!(request.headers.len(), 1);
102
2
        assert_eq!(request.method, "POST");
103
2
        assert_eq!(request.path, "/search");
104
2
    }
105
106
    #[tokio::test]
107
2
    async fn test_stub_scatter_returns_empty_response() {
108
2
        let scatter = StubScatter;
109
2
        let topology = Topology::new(64, 1); // 64 shards, RF=1
110
2
        let nodes = vec![NodeId::new("node1".to_string())];
111
2
        let request = ScatterRequest {
112
2
            body: Vec::new(),
113
2
            headers: Vec::new(),
114
2
            method: "GET".to_string(),
115
2
            path: "/".to_string(),
116
2
        };
117
118
2
        let result = scatter
119
2
            .scatter(&topology, nodes, request, UnavailableShardPolicy::Partial)
120
2
            .await
121
2
            .unwrap();
122
123
2
        assert!(result.responses.is_empty());
124
2
        assert!(result.failed.is_empty());
125
2
    }
126
127
    #[test]
128
2
    fn test_scatter_response_empty() {
129
2
        let response = ScatterResponse {
130
2
            responses: Vec::new(),
131
2
            failed: Vec::new(),
132
2
        };
133
134
2
        assert!(response.responses.is_empty());
135
2
        assert!(response.failed.is_empty());
136
2
    }
137
138
    #[test]
139
2
    fn test_scatter_response_with_data() {
140
2
        let node_response = NodeResponse {
141
2
            node_id: NodeId::new("node1".to_string()),
142
2
            body: b"response".to_vec(),
143
2
            status: 200,
144
2
            headers: vec![("Content-Type".to_string(), "application/json".to_string())],
145
2
        };
146
147
2
        let response = ScatterResponse {
148
2
            responses: vec![node_response],
149
2
            failed: vec![NodeId::new("node2".to_string())],
150
2
        };
151
152
2
        assert_eq!(response.responses.len(), 1);
153
2
        assert_eq!(response.failed.len(), 1);
154
2
        assert_eq!(response.responses[0].node_id.as_str(), "node1");
155
2
        assert_eq!(response.responses[0].status, 200);
156
2
        assert_eq!(response.failed[0].as_str(), "node2");
157
2
    }
158
159
    #[test]
160
2
    fn test_node_response_creation() {
161
2
        let response = NodeResponse {
162
2
            node_id: NodeId::new("test-node".to_string()),
163
2
            body: b"test body".to_vec(),
164
2
            status: 200,
165
2
            headers: vec![("X-Custom".to_string(), "value".to_string())],
166
2
        };
167
168
2
        assert_eq!(response.node_id.as_str(), "test-node");
169
2
        assert_eq!(response.body, b"test body");
170
2
        assert_eq!(response.status, 200);
171
2
        assert_eq!(response.headers.len(), 1);
172
2
        assert_eq!(response.headers[0].0, "X-Custom");
173
2
    }
174
175
    #[tokio::test]
176
2
    async fn test_stub_scatter_with_empty_nodes() {
177
2
        let scatter = StubScatter;
178
2
        let topology = Topology::new(64, 1); // 64 shards, RF=1
179
2
        let nodes: Vec<NodeId> = Vec::new();
180
2
        let request = ScatterRequest {
181
2
            body: Vec::new(),
182
2
            headers: Vec::new(),
183
2
            method: "GET".to_string(),
184
2
            path: "/".to_string(),
185
2
        };
186
187
2
        let result = scatter
188
2
            .scatter(&topology, nodes, request, UnavailableShardPolicy::Partial)
189
2
            .await
190
2
            .unwrap();
191
192
2
        assert!(result.responses.is_empty());
193
2
        assert!(result.failed.is_empty());
194
2
    }
195
196
    #[tokio::test]
197
2
    async fn test_stub_scatter_with_multiple_nodes() {
198
2
        let scatter = StubScatter;
199
2
        let mut topology = Topology::new(64, 1); // 64 shards, RF=1
200
201
2
        let node1 = NodeId::new("node1".to_string());
202
2
        let node2 = NodeId::new("node2".to_string());
203
2
        let node3 = NodeId::new("node3".to_string());
204
205
2
        topology.add_node(crate::topology::Node::new(
206
2
            node1.clone(),
207
2
            "http://node1".to_string(),
208
            0,
209
        ));
210
2
        topology.add_node(crate::topology::Node::new(
211
2
            node2.clone(),
212
2
            "http://node2".to_string(),
213
            0,
214
        ));
215
2
        topology.add_node(crate::topology::Node::new(
216
2
            node3.clone(),
217
2
            "http://node3".to_string(),
218
            0,
219
        ));
220
221
2
        let nodes = vec![node1, node2, node3];
222
2
        let request = ScatterRequest {
223
2
            body: Vec::new(),
224
2
            headers: Vec::new(),
225
2
            method: "POST".to_string(),
226
2
            path: "/search".to_string(),
227
2
        };
228
229
2
        let result = scatter
230
2
            .scatter(&topology, nodes, request, UnavailableShardPolicy::Partial)
231
2
            .await
232
2
            .unwrap();
233
234
2
        assert!(result.responses.is_empty());
235
2
        assert!(result.failed.is_empty());
236
2
    }
237
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/score_comparability.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/score_comparability.rs.html deleted file mode 100644 index 8f73bc0..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/score_comparability.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/score_comparability.rs
Line
Count
Source
1
//! Score comparability analysis: validates cross-shard ranking consistency.
2
//!
3
//! Implements Plan §15 OP#4: statistical validation that `_rankingScore`
4
//! values remain comparable across shards with very different document-count
5
//! distributions. Uses Kendall Tau correlation to measure ranking similarity
6
//! between sharded and ground-truth (single-index) result orderings.
7
8
use serde::{Deserialize, Serialize};
9
10
/// Parameters for a score comparability simulation run.
11
#[derive(Debug, Clone, Serialize, Deserialize)]
12
pub struct SimParams {
13
    /// Total number of documents in the corpus.
14
    pub total_docs: u64,
15
16
    /// Number of shards to distribute documents across.
17
    pub shard_count: u32,
18
19
    /// Skew factor: multiplier for document distribution imbalance.
20
    /// 1.0 = uniform distribution. Higher values create more extreme skew.
21
    /// Example: 100.0 means one shard gets 100× the median count.
22
    pub skew_factor: f64,
23
24
    /// Number of queries to run against the corpus.
25
    pub num_queries: usize,
26
27
    /// Number of top results to compare per query (K).
28
    pub top_k: usize,
29
30
    /// Random seed for reproducibility.
31
    pub seed: u64,
32
}
33
34
/// Result of a single query comparison.
35
#[derive(Debug, Clone, Serialize, Deserialize)]
36
pub struct QueryResult {
37
    /// Query identifier.
38
    pub query_id: usize,
39
40
    /// Number of hits returned by ground truth (single-index).
41
    pub ground_truth_hits: usize,
42
43
    /// Number of hits returned by sharded execution.
44
    pub sharded_hits: usize,
45
46
    /// Kendall Tau correlation between result orderings.
47
    pub kendall_tau: f64,
48
49
    /// Jaccard similarity of the top-K result sets (ignoring order).
50
    pub jaccard_similarity: f64,
51
52
    /// Position of the first divergent result (0 if identical, top_k+1 if completely different).
53
    pub first_divergence_position: usize,
54
55
    /// Score statistics per shard.
56
    pub shard_score_stats: Vec<ShardScoreStats>,
57
}
58
59
/// Score statistics for a single shard on a single query.
60
#[derive(Debug, Clone, Serialize, Deserialize)]
61
pub struct ShardScoreStats {
62
    /// Shard identifier.
63
    pub shard_id: u32,
64
65
    /// Number of documents on this shard.
66
    pub doc_count: u64,
67
68
    /// Number of hits returned by this shard.
69
    pub hit_count: usize,
70
71
    /// Minimum score returned.
72
    pub min_score: f64,
73
74
    /// Maximum score returned.
75
    pub max_score: f64,
76
77
    /// Mean score of returned hits.
78
    pub mean_score: f64,
79
80
    /// Score range (max - min).
81
    pub score_range: f64,
82
}
83
84
/// Aggregate results across all queries.
85
#[derive(Debug, Clone, Serialize, Deserialize)]
86
pub struct AggregateResult {
87
    /// Mean Kendall Tau across all queries.
88
    pub mean_kendall_tau: f64,
89
90
    /// Standard deviation of Kendall Tau.
91
    pub std_kendall_tau: f64,
92
93
    /// Minimum Kendall Tau observed.
94
    pub min_kendall_tau: f64,
95
96
    /// Maximum Kendall Tau observed.
97
    pub max_kendall_tau: f64,
98
99
    /// Percentage of queries with τ ≥ 0.95.
100
    pub percent_above_threshold: f64,
101
102
    /// Mean Jaccard similarity.
103
    pub mean_jaccard: f64,
104
105
    /// Mean first divergence position.
106
    pub mean_first_divergence: usize,
107
108
    /// Shard population statistics (CV of document counts).
109
    pub shard_pop_cv: f64,
110
111
    /// Shard population ratio (max/median).
112
    pub shard_pop_ratio: f64,
113
}
114
115
/// Simulated score comparability test result.
116
#[derive(Debug, Clone, Serialize, Deserialize)]
117
pub struct SimResult {
118
    pub params: SimParams,
119
    pub aggregate: AggregateResult,
120
    pub query_results: Vec<QueryResult>,
121
    pub shard_doc_counts: Vec<u64>,
122
}
123
124
/// Run a score comparability simulation.
125
///
126
/// This models a simplified ranking system where:
127
/// - Documents have relevance scores based on term frequency
128
/// - Shards compute scores locally using their own document statistics
129
/// - A ground-truth single-index uses global statistics
130
///
131
/// The simulation measures how well the sharded ordering matches the
132
/// ground-truth ordering using Kendall Tau correlation.
133
2
pub fn simulate(params: &SimParams) -> SimResult {
134
    use rand::rngs::StdRng;
135
    use rand::SeedableRng;
136
137
2
    let mut rng = StdRng::seed_from_u64(params.seed);
138
139
    // Generate document distribution across shards with specified skew.
140
2
    let shard_doc_counts = generate_skewed_distribution(
141
2
        params.total_docs,
142
2
        params.shard_count,
143
2
        params.skew_factor,
144
2
        &mut rng,
145
    );
146
147
    // Compute shard population statistics.
148
2
    let total_docs = shard_doc_counts.iter().sum::<u64>() as f64;
149
2
    let mean_docs = total_docs / params.shard_count as f64;
150
2
    let variance = shard_doc_counts
151
2
        .iter()
152
8
        .
map2
(|&c| (c as f64 - mean_docs).powi(2))
153
2
        .sum::<f64>()
154
2
        / params.shard_count as f64;
155
2
    let cv = variance.sqrt() / mean_docs;
156
2
    let median_docs = median(&shard_doc_counts);
157
2
    let max_docs = *shard_doc_counts.iter().max().unwrap_or(&1) as f64;
158
2
    let pop_ratio = max_docs / median_docs;
159
160
    // Run queries and collect results.
161
2
    let mut query_results = Vec::with_capacity(params.num_queries);
162
163
20
    for qid in 0..
params.num_queries2
{
164
20
        let result = run_query(qid, params, &shard_doc_counts, &mut rng);
165
20
        query_results.push(result);
166
20
    }
167
168
    // Compute aggregate statistics.
169
2
    let taus: Vec<f64> = query_results.iter().map(|r| r.kendall_tau).collect();
170
2
    let mean_tau = mean(&taus);
171
2
    let std_tau = std_dev(&taus, mean_tau);
172
2
    let min_tau = taus.iter().cloned().reduce(f64::min).unwrap_or(0.0);
173
2
    let max_tau = taus.iter().cloned().reduce(f64::max).unwrap_or(0.0);
174
2
    let above_threshold =
175
20
        
taus.iter()2
.
filter2
(|&&t| t >= 0.95).
count2
() as f64 /
taus.len() as f642
* 100.0;
176
177
2
    let jaccards: Vec<f64> = query_results.iter().map(|r| r.jaccard_similarity).collect();
178
2
    let mean_jaccard = mean(&jaccards);
179
180
2
    let divergences: Vec<usize> = query_results
181
2
        .iter()
182
2
        .map(|r| r.first_divergence_position)
183
2
        .collect();
184
2
    let mean_divergence = mean_usize(&divergences);
185
186
2
    let aggregate = AggregateResult {
187
2
        mean_kendall_tau: mean_tau,
188
2
        std_kendall_tau: std_tau,
189
2
        min_kendall_tau: min_tau,
190
2
        max_kendall_tau: max_tau,
191
2
        percent_above_threshold: above_threshold,
192
2
        mean_jaccard,
193
2
        mean_first_divergence: mean_divergence,
194
2
        shard_pop_cv: cv,
195
2
        shard_pop_ratio: pop_ratio,
196
2
    };
197
198
2
    SimResult {
199
2
        params: params.clone(),
200
2
        aggregate,
201
2
        query_results,
202
2
        shard_doc_counts,
203
2
    }
204
2
}
205
206
/// Generate a skewed document distribution across shards.
207
///
208
/// Uses a Pareto-like distribution to create realistic skew where
209
/// a few shards have many more documents than others.
210
6
fn generate_skewed_distribution(
211
6
    total_docs: u64,
212
6
    shard_count: u32,
213
6
    skew_factor: f64,
214
6
    rng: &mut impl rand::Rng,
215
6
) -> Vec<u64> {
216
    // Start with uniform weights.
217
6
    let mut weights: Vec<f64> = (0..shard_count).map(|_| 1.0).collect();
218
219
    // Apply skew: scale a few shards by the skew factor.
220
    // For skew_factor = 100, scale the top 5% of shards.
221
6
    let num_skewed = (shard_count as f64 / 20.0).ceil() as usize; // Top 5%
222
6
    for i in 0..num_skewed.min(weights.len()) {
223
6
        weights[i] *= skew_factor;
224
6
    }
225
226
    // Add some randomness to make it more realistic.
227
54
    for 
w48
in &mut weights {
228
48
        *w *= rng.gen_range(0.5..1.5);
229
48
    }
230
231
    // Normalize weights to sum to total_docs.
232
6
    let weight_sum: f64 = weights.iter().sum();
233
6
    let scale = total_docs as f64 / weight_sum;
234
235
6
    weights
236
6
        .into_iter()
237
48
        .
map6
(|w| (w * scale).max(1.0) as u64) // Ensure at least 1 doc per shard
238
6
        .collect()
239
6
}
240
241
/// Run a single query and compare sharded vs ground-truth results.
242
20
fn run_query(
243
20
    query_id: usize,
244
20
    params: &SimParams,
245
20
    shard_doc_counts: &[u64],
246
20
    rng: &mut impl rand::Rng,
247
20
) -> QueryResult {
248
    // Simulate a query by generating random relevance scores for documents.
249
    // In a real system, scores would be based on term frequency, IDF, etc.
250
    // Here we simulate the key effect: smaller shards tend to produce higher
251
    // scores for the same documents because their local statistics are different.
252
253
20
    let total_docs = params.total_docs as usize;
254
255
    // Generate ground-truth scores (global IDF).
256
    // Each document gets a base relevance score + some noise.
257
20
    let mut ground_truth_scores: Vec<(usize, f64)> = (0..total_docs)
258
20.0k
        .
map20
(|doc_id| {
259
20.0k
            let base_relevance = rng.gen::<f64>(); // Simulated term matching
260
20.0k
            let global_idf = 1.0; // Global IDF is uniform
261
20.0k
            let score = base_relevance * global_idf;
262
20.0k
            (doc_id, score)
263
20.0k
        })
264
20
        .collect();
265
266
    // Sort by score descending and take top-K.
267
213k
    
ground_truth_scores20
.
sort_by20
(|a, b| b.1.partial_cmp(&a.1).unwrap());
268
269
20
    let ground_truth_top: Vec<usize> = ground_truth_scores
270
20
        .iter()
271
20
        .take(params.top_k)
272
20
        .map(|(id, _)| *id)
273
20
        .collect();
274
275
    // Generate sharded scores (local IDF per shard).
276
    // Key insight: local IDF is computed from shard-specific document counts.
277
    // Smaller shards have fewer docs, so their IDF tends to be higher for
278
    // the same term, leading to score inflation.
279
280
20
    let mut sharded_scores: Vec<(usize, f64)> = Vec::new();
281
282
80
    for (shard_id, &doc_count) in 
shard_doc_counts20
.
iter20
().
enumerate20
() {
283
80
        let shard_id = shard_id as u32;
284
80
        let doc_count_f64 = doc_count as f64;
285
80
        let total_docs_f64 = params.total_docs as f64;
286
287
        // Simulate local IDF effect: smaller shards → higher local IDF.
288
        // In real BM25, IDF = log((N - df + 0.5) / (df + 0.5)) where N is doc count.
289
        // We model this as: local_idf / global_idf ≈ log(N_global) / log(N_shard).
290
80
        let global_idf_factor = (total_docs_f64 + 1.0).ln();
291
80
        let local_idf_factor = (doc_count_f64 + 1.0).ln();
292
80
        let idf_inflation = global_idf_factor / local_idf_factor.max(0.1);
293
294
        // Documents on this shard get scores inflated by the local IDF factor.
295
80
        let start_doc = shard_doc_counts[..shard_id as usize].iter().sum::<u64>() as usize;
296
80
        let end_doc = start_doc + doc_count as usize;
297
298
19.9k
        for (doc_id, base_relevance) in ground_truth_scores
299
80
            .iter()
300
80
            .enumerate()
301
80
            .skip(start_doc)
302
80
            .take(end_doc.min(total_docs) - start_doc)
303
19.9k
        {
304
19.9k
            let score = base_relevance.1 * idf_inflation;
305
19.9k
            sharded_scores.push((doc_id, score));
306
19.9k
        }
307
    }
308
309
    // Sort sharded scores and take top-K.
310
28.4k
    
sharded_scores20
.
sort_by20
(|a, b| b.1.partial_cmp(&a.1).unwrap());
311
312
20
    let sharded_top: Vec<usize> = sharded_scores
313
20
        .iter()
314
20
        .take(params.top_k)
315
20
        .map(|(id, _)| *id)
316
20
        .collect();
317
318
    // Compute Kendall Tau between the two orderings.
319
20
    let kendall_tau = kendall_tau(&ground_truth_top, &sharded_top);
320
321
    // Compute Jaccard similarity (set overlap, ignoring order).
322
20
    let jaccard = jaccard_similarity(&ground_truth_top, &sharded_top);
323
324
    // Find first divergence position.
325
20
    let first_divergence = ground_truth_top
326
20
        .iter()
327
20
        .zip(sharded_top.iter())
328
20
        .position(|(a, b)| a != b)
329
20
        .unwrap_or(params.top_k);
330
331
    // Collect per-shard score statistics.
332
20
    let shard_stats =
333
20
        collect_shard_stats(query_id, shard_doc_counts, &sharded_scores, params.top_k);
334
335
20
    QueryResult {
336
20
        query_id,
337
20
        ground_truth_hits: ground_truth_top.len(),
338
20
        sharded_hits: sharded_top.len(),
339
20
        kendall_tau,
340
20
        jaccard_similarity: jaccard,
341
20
        first_divergence_position: first_divergence,
342
20
        shard_score_stats: shard_stats,
343
20
    }
344
20
}
345
346
/// Collect score statistics for each shard.
347
20
fn collect_shard_stats(
348
20
    _query_id: usize,
349
20
    shard_doc_counts: &[u64],
350
20
    sharded_scores: &[(usize, f64)],
351
20
    top_k: usize,
352
20
) -> Vec<ShardScoreStats> {
353
20
    let mut stats = Vec::new();
354
355
    // Map doc_id back to its shard.
356
80
    for (shard_idx, &doc_count) in 
shard_doc_counts20
.
iter20
().
enumerate20
() {
357
80
        let shard_id = shard_idx as u32;
358
80
        let start_doc = shard_doc_counts[..shard_idx].iter().sum::<u64>() as usize;
359
80
        let end_doc = start_doc + doc_count as usize;
360
361
        // Find scores from this shard in the top-K results.
362
80
        let shard_hits: Vec<f64> = sharded_scores
363
80
            .iter()
364
80
            .take(top_k)
365
800
            .
filter80
(|(doc_id, _)| *doc_id >= start_doc &&
*doc_id < end_doc200
)
366
80
            .map(|(_, score)| *score)
367
80
            .collect();
368
369
80
        if shard_hits.is_empty() {
370
60
            continue;
371
20
        }
372
373
200
        let 
min_score20
=
shard_hits.iter()20
.
fold20
(f64::INFINITY, |a, &b| a.min(b));
374
200
        let 
max_score20
=
shard_hits.iter()20
.
fold20
(f64::NEG_INFINITY, |a, &b| a.max(b));
375
20
        let mean_score = shard_hits.iter().sum::<f64>() / shard_hits.len() as f64;
376
377
20
        stats.push(ShardScoreStats {
378
20
            shard_id,
379
20
            doc_count,
380
20
            hit_count: shard_hits.len(),
381
20
            min_score,
382
20
            max_score,
383
20
            mean_score,
384
20
            score_range: max_score - min_score,
385
20
        });
386
    }
387
388
20
    stats
389
20
}
390
391
/// Compute Kendall Tau correlation between two ranked lists.
392
///
393
/// Returns a value in [-1, 1] where:
394
/// - 1.0 = identical rankings
395
/// - 0.0 = no correlation
396
/// - -1.0 = completely inverted rankings
397
///
398
/// Uses the O(n²) algorithm which is fine for small K (typically ≤ 100).
399
26
fn kendall_tau<T: Eq + std::hash::Hash + std::fmt::Debug>(rank1: &[T], rank2: &[T]) -> f64 {
400
26
    if rank1.is_empty() || rank2.is_empty() {
401
0
        return 1.0;
402
26
    }
403
404
    // Create position maps.
405
26
    let pos1: std::collections::HashMap<&T, usize> = rank1
406
26
        .iter()
407
26
        .enumerate()
408
230
        .
map26
(|(i, item)| (item, i))
409
26
        .collect();
410
26
    let pos2: std::collections::HashMap<&T, usize> = rank2
411
26
        .iter()
412
26
        .enumerate()
413
230
        .
map26
(|(i, item)| (item, i))
414
26
        .collect();
415
416
    // Collect all unique items from both lists.
417
26
    let all_items: std::collections::HashSet<&T> = rank1.iter().chain(rank2.iter()).collect();
418
419
    // Count concordant and discordant pairs.
420
26
    let mut concordant = 0;
421
26
    let mut discordant = 0;
422
423
26
    let items: Vec<&T> = all_items.into_iter().collect();
424
426
    for i in 0..
items26
.
len26
() {
425
3.78k
        for j in 
(i + 1)426
..
items426
.
len426
() {
426
3.78k
            let a = items[i];
427
3.78k
            let b = items[j];
428
429
3.78k
            let pos1_a = pos1.get(a);
430
3.78k
            let pos1_b = pos1.get(b);
431
3.78k
            let pos2_a = pos2.get(a);
432
3.78k
            let pos2_b = pos2.get(b);
433
434
            // A pair is only counted if both items appear in both lists.
435
3.78k
            let (
p1a60
,
p1b60
,
p2a60
,
p2b60
) = match (pos1_a, pos1_b, pos2_a, pos2_b) {
436
60
                (Some(&x), Some(&y), Some(&u), Some(&v)) => (x, y, u, v),
437
3.72k
                _ => continue,
438
            };
439
440
60
            if (p1a < p1b && 
p2a < p2b27
) || (
p1a > p1b42
&&
p2a > p2b33
) {
441
38
                concordant += 1;
442
38
            } else if (
p1a < p1b22
&&
p2a > p2b9
) || (
p1a > p1b13
&&
p2a < p2b13
) {
443
22
                discordant += 1;
444
22
            
}0
445
        }
446
    }
447
448
26
    let total = concordant + discordant;
449
26
    if total == 0 {
450
20
        return 1.0;
451
6
    }
452
453
6
    (concordant - discordant) as f64 / total as f64
454
26
}
455
456
/// Compute Jaccard similarity between two sets (ignoring order).
457
26
fn jaccard_similarity<T: Eq + std::hash::Hash>(set1: &[T], set2: &[T]) -> f64 {
458
26
    let set1_hash: std::collections::HashSet<&T> = set1.iter().collect();
459
26
    let set2_hash: std::collections::HashSet<&T> = set2.iter().collect();
460
461
26
    let intersection = set1_hash.intersection(&set2_hash).count();
462
26
    let union = set1_hash.union(&set2_hash).count();
463
464
26
    if union == 0 {
465
0
        return 1.0;
466
26
    }
467
468
26
    intersection as f64 / union as f64
469
26
}
470
471
/// Compute mean of a slice of floats.
472
4
fn mean(values: &[f64]) -> f64 {
473
4
    if values.is_empty() {
474
0
        return 0.0;
475
4
    }
476
4
    values.iter().sum::<f64>() / values.len() as f64
477
4
}
478
479
/// Compute standard deviation of a slice of floats.
480
2
fn std_dev(values: &[f64], mean_val: f64) -> f64 {
481
2
    if values.len() <= 1 {
482
0
        return 0.0;
483
2
    }
484
2
    let variance =
485
20
        
values2
.
iter2
().
map2
(|v| (v - mean_val).powi(2)).
sum2
::<f64>() /
(values.len() - 1) as f642
;
486
2
    variance.sqrt()
487
2
}
488
489
/// Compute mean of a slice of usize.
490
2
fn mean_usize(values: &[usize]) -> usize {
491
2
    if values.is_empty() {
492
0
        return 0;
493
2
    }
494
2
    values.iter().sum::<usize>() / values.len()
495
2
}
496
497
/// Compute median of a slice of u64.
498
2
fn median(values: &[u64]) -> f64 {
499
2
    if values.is_empty() {
500
0
        return 0.0;
501
2
    }
502
2
    let mut sorted = values.to_vec();
503
2
    sorted.sort();
504
2
    let len = sorted.len();
505
2
    if len % 2 == 0 {
506
2
        (sorted[len / 2 - 1] + sorted[len / 2]) as f64 / 2.0
507
    } else {
508
0
        sorted[len / 2] as f64
509
    }
510
2
}
511
512
// ---------------------------------------------------------------------------
513
// Tests
514
// ---------------------------------------------------------------------------
515
516
#[cfg(test)]
517
mod tests {
518
    use super::*;
519
520
    #[test]
521
2
    fn test_kendall_tau_identical() {
522
2
        let a = vec![1, 2, 3, 4, 5];
523
2
        let b = vec![1, 2, 3, 4, 5];
524
2
        assert!((kendall_tau(&a, &b) - 1.0).abs() < 0.001);
525
2
    }
526
527
    #[test]
528
2
    fn test_kendall_tau_reversed() {
529
2
        let a = vec![1, 2, 3, 4, 5];
530
2
        let b = vec![5, 4, 3, 2, 1];
531
2
        assert!((kendall_tau(&a, &b) - (-1.0)).abs() < 0.001);
532
2
    }
533
534
    #[test]
535
2
    fn test_kendall_tau_partial_overlap() {
536
2
        let a = vec![1, 2, 3, 4, 5];
537
2
        let b = vec![1, 3, 2, 4, 5]; // Only 2 and 3 are swapped
538
                                     // One discordant pair (2,3) out of 10 total: tau = (9 - 1) / 10 = 0.8
539
2
        let tau = kendall_tau(&a, &b);
540
2
        assert!((tau - 0.8).abs() < 0.01);
541
2
    }
542
543
    #[test]
544
2
    fn test_jaccard_identical() {
545
2
        let a = vec![1, 2, 3, 4, 5];
546
2
        let b = vec![1, 2, 3, 4, 5];
547
2
        assert!((jaccard_similarity(&a, &b) - 1.0).abs() < 0.001);
548
2
    }
549
550
    #[test]
551
2
    fn test_jaccard_no_overlap() {
552
2
        let a = vec![1, 2, 3];
553
2
        let b = vec![4, 5, 6];
554
2
        assert!((jaccard_similarity(&a, &b) - 0.0).abs() < 0.001);
555
2
    }
556
557
    #[test]
558
2
    fn test_jaccard_half_overlap() {
559
2
        let a = vec![1, 2, 3, 4];
560
2
        let b = vec![3, 4, 5, 6];
561
        // Intersection: {3, 4}, Union: {1, 2, 3, 4, 5, 6}
562
        // Jaccard = 2/6 = 1/3
563
2
        assert!((jaccard_similarity(&a, &b) - 1.0 / 3.0).abs() < 0.001);
564
2
    }
565
566
    #[test]
567
2
    fn test_skewed_distribution_sum() {
568
2
        let counts = generate_skewed_distribution(10000, 10, 10.0, &mut rand::thread_rng());
569
2
        let total: u64 = counts.iter().sum();
570
2
        assert!((total as i64 - 10000i64).abs() < 100); // Allow small rounding error
571
2
    }
572
573
    #[test]
574
2
    fn test_skewed_distribution_has_skew() {
575
2
        let counts = generate_skewed_distribution(10000, 10, 100.0, &mut rand::thread_rng());
576
2
        let max = *counts.iter().max().unwrap();
577
2
        let min = *counts.iter().min().unwrap();
578
        // With skew factor 100, we expect significant imbalance.
579
2
        assert!(max > min * 10);
580
2
    }
581
582
    #[test]
583
2
    fn test_simulation_runs() {
584
2
        let params = SimParams {
585
2
            total_docs: 1000,
586
2
            shard_count: 4,
587
2
            skew_factor: 10.0,
588
2
            num_queries: 10,
589
2
            top_k: 10,
590
2
            seed: 42,
591
2
        };
592
2
        let result = simulate(&params);
593
2
        assert_eq!(result.query_results.len(), 10);
594
2
        assert!(result.aggregate.mean_kendall_tau >= -1.0);
595
2
        assert!(result.aggregate.mean_kendall_tau <= 1.0);
596
2
    }
597
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/task.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/task.rs.html deleted file mode 100644 index 086f12e..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/task.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/task.rs
Line
Count
Source
1
//! Task registry: unified task namespace across all Meilisearch nodes.
2
3
use crate::Result;
4
use serde::{Deserialize, Serialize};
5
use std::collections::HashMap;
6
use uuid::Uuid;
7
8
/// Task registry: manages the unified task namespace.
9
pub trait TaskRegistry: Send + Sync {
10
    /// Register a new Miroir task that fans out to multiple nodes.
11
    fn register(&self, node_tasks: HashMap<String, u64>) -> Result<MiroirTask>;
12
13
    /// Get a task by its Miroir ID.
14
    fn get(&self, miroir_id: &str) -> Result<Option<MiroirTask>>;
15
16
    /// Update the status of a Miroir task.
17
    fn update_status(&self, miroir_id: &str, status: TaskStatus) -> Result<()>;
18
19
    /// Update node task status.
20
    fn update_node_task(
21
        &self,
22
        miroir_id: &str,
23
        node_id: &str,
24
        node_status: NodeTaskStatus,
25
    ) -> Result<()>;
26
27
    /// List tasks with optional filtering.
28
    fn list(&self, filter: TaskFilter) -> Result<Vec<MiroirTask>>;
29
}
30
31
/// A Miroir task: unified view of a fan-out write operation.
32
#[derive(Debug, Clone, Serialize, Deserialize)]
33
pub struct MiroirTask {
34
    /// Unique Miroir task ID (UUID).
35
    pub miroir_id: String,
36
37
    /// Creation timestamp (Unix millis).
38
    pub created_at: u64,
39
40
    /// Current task status.
41
    pub status: TaskStatus,
42
43
    /// Map of node ID to local Meilisearch task UID.
44
    pub node_tasks: HashMap<String, NodeTask>,
45
46
    /// Error message if the task failed.
47
    pub error: Option<String>,
48
}
49
50
/// Status of a Miroir task.
51
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52
pub enum TaskStatus {
53
    /// Task is enqueued.
54
    Enqueued,
55
56
    /// Task is being processed.
57
    Processing,
58
59
    /// Task completed successfully.
60
    Succeeded,
61
62
    /// Task failed.
63
    Failed,
64
65
    /// Task was canceled.
66
    Canceled,
67
}
68
69
/// A node task: local Meilisearch task reference.
70
#[derive(Debug, Clone, Serialize, Deserialize)]
71
pub struct NodeTask {
72
    /// Local Meilisearch task UID.
73
    pub task_uid: u64,
74
75
    /// Current status of this node task.
76
    pub status: NodeTaskStatus,
77
}
78
79
/// Status of a node task.
80
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
81
pub enum NodeTaskStatus {
82
    /// Task is enqueued on the node.
83
    Enqueued,
84
85
    /// Task is processing on the node.
86
    Processing,
87
88
    /// Task succeeded on the node.
89
    Succeeded,
90
91
    /// Task failed on the node.
92
    Failed,
93
}
94
95
/// Filter for listing tasks.
96
#[derive(Debug, Clone, Default)]
97
pub struct TaskFilter {
98
    /// Filter by status.
99
    pub status: Option<TaskStatus>,
100
101
    /// Filter by node ID.
102
    pub node_id: Option<String>,
103
104
    /// Maximum number of results.
105
    pub limit: Option<usize>,
106
107
    /// Offset for pagination.
108
    pub offset: Option<usize>,
109
}
110
111
/// Default stub implementation of TaskRegistry.
112
#[derive(Debug, Clone, Default)]
113
pub struct StubTaskRegistry;
114
115
impl TaskRegistry for StubTaskRegistry {
116
2
    fn register(&self, _node_tasks: HashMap<String, u64>) -> Result<MiroirTask> {
117
2
        Ok(MiroirTask {
118
2
            miroir_id: Uuid::new_v4().to_string(),
119
2
            created_at: 0,
120
2
            status: TaskStatus::Enqueued,
121
2
            node_tasks: HashMap::new(),
122
2
            error: None,
123
2
        })
124
2
    }
125
126
2
    fn get(&self, _miroir_id: &str) -> Result<Option<MiroirTask>> {
127
2
        Ok(None)
128
2
    }
129
130
2
    fn update_status(&self, _miroir_id: &str, _status: TaskStatus) -> Result<()> {
131
2
        Ok(())
132
2
    }
133
134
2
    fn update_node_task(
135
2
        &self,
136
2
        _miroir_id: &str,
137
2
        _node_id: &str,
138
2
        _node_status: NodeTaskStatus,
139
2
    ) -> Result<()> {
140
2
        Ok(())
141
2
    }
142
143
2
    fn list(&self, _filter: TaskFilter) -> Result<Vec<MiroirTask>> {
144
2
        Ok(Vec::new())
145
2
    }
146
}
147
148
#[cfg(test)]
149
mod tests {
150
    use super::*;
151
152
    #[test]
153
2
    fn test_stub_task_registry_register() {
154
2
        let registry = StubTaskRegistry;
155
2
        let mut node_tasks = HashMap::new();
156
2
        node_tasks.insert("node1".to_string(), 123);
157
158
2
        let task = registry.register(node_tasks).unwrap();
159
2
        assert!(!task.miroir_id.is_empty());
160
2
        assert_eq!(task.status, TaskStatus::Enqueued);
161
2
        assert!(task.node_tasks.is_empty());
162
2
        assert!(task.error.is_none());
163
2
    }
164
165
    #[test]
166
2
    fn test_stub_task_registry_get() {
167
2
        let registry = StubTaskRegistry;
168
2
        let result = registry.get("test-id").unwrap();
169
2
        assert!(result.is_none());
170
2
    }
171
172
    #[test]
173
2
    fn test_stub_task_registry_update_status() {
174
2
        let registry = StubTaskRegistry;
175
2
        let result = registry.update_status("test-id", TaskStatus::Succeeded);
176
2
        assert!(result.is_ok());
177
2
    }
178
179
    #[test]
180
2
    fn test_stub_task_registry_update_node_task() {
181
2
        let registry = StubTaskRegistry;
182
2
        let result = registry.update_node_task("test-id", "node1", NodeTaskStatus::Succeeded);
183
2
        assert!(result.is_ok());
184
2
    }
185
186
    #[test]
187
2
    fn test_stub_task_registry_list() {
188
2
        let registry = StubTaskRegistry;
189
2
        let filter = TaskFilter::default();
190
2
        let result = registry.list(filter).unwrap();
191
2
        assert!(result.is_empty());
192
2
    }
193
194
    #[test]
195
2
    fn test_task_status_equality() {
196
2
        assert_eq!(TaskStatus::Enqueued, TaskStatus::Enqueued);
197
2
        assert_ne!(TaskStatus::Enqueued, TaskStatus::Processing);
198
2
        assert_ne!(TaskStatus::Succeeded, TaskStatus::Failed);
199
2
    }
200
201
    #[test]
202
2
    fn test_node_task_status_equality() {
203
2
        assert_eq!(NodeTaskStatus::Enqueued, NodeTaskStatus::Enqueued);
204
2
        assert_ne!(NodeTaskStatus::Processing, NodeTaskStatus::Succeeded);
205
2
        assert_ne!(NodeTaskStatus::Failed, NodeTaskStatus::Succeeded);
206
2
    }
207
208
    #[test]
209
2
    fn test_task_filter_default() {
210
2
        let filter = TaskFilter::default();
211
2
        assert!(filter.status.is_none());
212
2
        assert!(filter.node_id.is_none());
213
2
        assert!(filter.limit.is_none());
214
2
        assert!(filter.offset.is_none());
215
2
    }
216
217
    #[test]
218
2
    fn test_task_filter_with_fields() {
219
2
        let filter = TaskFilter {
220
2
            status: Some(TaskStatus::Processing),
221
2
            node_id: Some("node1".to_string()),
222
2
            limit: Some(10),
223
2
            offset: Some(5),
224
2
        };
225
2
        assert_eq!(filter.status, Some(TaskStatus::Processing));
226
2
        assert_eq!(filter.node_id, Some("node1".to_string()));
227
2
        assert_eq!(filter.limit, Some(10));
228
2
        assert_eq!(filter.offset, Some(5));
229
2
    }
230
231
    #[test]
232
2
    fn test_miroir_task_creation() {
233
2
        let mut node_tasks = HashMap::new();
234
2
        node_tasks.insert(
235
2
            "node1".to_string(),
236
2
            NodeTask {
237
2
                task_uid: 123,
238
2
                status: NodeTaskStatus::Enqueued,
239
2
            },
240
        );
241
242
2
        let task = MiroirTask {
243
2
            miroir_id: "test-id".to_string(),
244
2
            created_at: 1234567890,
245
2
            status: TaskStatus::Processing,
246
2
            node_tasks,
247
2
            error: None,
248
2
        };
249
250
2
        assert_eq!(task.miroir_id, "test-id");
251
2
        assert_eq!(task.created_at, 1234567890);
252
2
        assert_eq!(task.status, TaskStatus::Processing);
253
2
        assert_eq!(task.node_tasks.len(), 1);
254
2
        assert!(task.error.is_none());
255
2
    }
256
257
    #[test]
258
2
    fn test_miroir_task_with_error() {
259
2
        let task = MiroirTask {
260
2
            miroir_id: "failed-task".to_string(),
261
2
            created_at: 0,
262
2
            status: TaskStatus::Failed,
263
2
            node_tasks: HashMap::new(),
264
2
            error: Some("Something went wrong".to_string()),
265
2
        };
266
267
2
        assert_eq!(task.status, TaskStatus::Failed);
268
2
        assert_eq!(task.error, Some("Something went wrong".to_string()));
269
2
    }
270
}
\ No newline at end of file diff --git a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/topology.rs.html b/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/topology.rs.html deleted file mode 100644 index fb9c9d9..0000000 --- a/coverage/html/coverage/home/coding/miroir/crates/miroir-core/src/topology.rs.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/coding/miroir/crates/miroir-core/src/topology.rs
Line
Count
Source
1
//! Topology management: node registry, groups, and health state.
2
3
use serde::{Deserialize, Serialize};
4
use std::collections::HashMap;
5
use std::fmt;
6
7
/// Unique identifier for a node.
8
#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
9
pub struct NodeId(String);
10
11
impl NodeId {
12
    /// Create a new NodeId.
13
246
    pub fn new(id: String) -> Self {
14
246
        Self(id)
15
246
    }
16
17
    /// Get the node ID as a string slice.
18
267M
    pub fn as_str(&self) -> &str {
19
267M
        &self.0
20
267M
    }
21
}
22
23
impl From<String> for NodeId {
24
2
    fn from(s: String) -> Self {
25
2
        Self(s)
26
2
    }
27
}
28
29
impl AsRef<str> for NodeId {
30
2
    fn as_ref(&self) -> &str {
31
2
        &self.0
32
2
    }
33
}
34
35
/// Health status of a node.
36
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37
pub enum NodeStatus {
38
    /// Node is healthy and serving traffic.
39
    Healthy,
40
    /// Node is degraded (intermittent failures, still serving traffic).
41
    Degraded,
42
    /// Node is active and fully operational (synonym for Healthy).
43
    Active,
44
    /// Node is joining the cluster (being provisioned).
45
    Joining,
46
    /// Node is draining (graceful shutdown, not accepting new writes).
47
    Draining,
48
    /// Node has failed (unplanned outage).
49
    Failed,
50
    /// Node has been removed from the cluster (tracked for migration).
51
    Removed,
52
}
53
54
impl NodeStatus {
55
    /// Check if a transition from `self` to `new_status` is valid.
56
    ///
57
    /// # State Transition Rules
58
    ///
59
    /// | From | To | Triggered by |
60
    /// |------|-----|-------------|
61
    /// | (new) | Joining | `POST /_miroir/nodes` |
62
    /// | Joining | Active | Migration complete |
63
    /// | Active | Draining | `POST /_miroir/nodes/{id}/drain` |
64
    /// | Draining | Removed | Migration complete |
65
    /// | Active/Draining | Failed | Health check detects |
66
    /// | Failed | Active | Health check recovery |
67
    /// | Active/Failed | Degraded | Partial health |
68
    /// | Degraded | Active | Health restored |
69
50
    pub fn can_transition_to(self, new_status: NodeStatus) -> bool {
70
50
        match (self, new_status) {
71
            // Initial state
72
6
            (NodeStatus::Joining, NodeStatus::Active) => true,
73
74
            // Normal operations
75
2
            (NodeStatus::Active, NodeStatus::Draining) => true,
76
2
            (NodeStatus::Draining, NodeStatus::Removed) => true,
77
78
            // Failure and recovery
79
2
            (NodeStatus::Active, NodeStatus::Failed) => true,
80
2
            (NodeStatus::Draining, NodeStatus::Failed) => true,
81
2
            (NodeStatus::Failed, NodeStatus::Active) => true,
82
83
            // Degradation
84
2
            (NodeStatus::Active, NodeStatus::Degraded) => true,
85
2
            (NodeStatus::Failed, NodeStatus::Degraded) => true,
86
2
            (NodeStatus::Degraded, NodeStatus::Active) => true,
87
88
            // Healthy <-> Active are bidirectional (synonyms)
89
2
            (NodeStatus::Healthy, NodeStatus::Active) => true,
90
2
            (NodeStatus::Active, NodeStatus::Healthy) => true,
91
92
            // Same state is always valid
93
24
            (
s14
,
t14
) if s ==
t14
=>
true14
,
94
95
            // All other transitions are invalid
96
10
            _ => false,
97
        }
98
50
    }
99
100
    /// Returns `true` if the node can accept writes for the given shard.
101
    ///
102
    /// # Write Eligibility Rules
103
    ///
104
    /// A node is write-eligible for a shard based on its status:
105
    ///
106
    /// | Status | Write Eligible | Notes |
107
    /// |--------|----------------|-------|
108
    /// | Healthy/Active | Yes | Normal operation |
109
    /// | Degraded | Yes | Partial failures, still accepting writes |
110
    /// | Joining | No | Being provisioned, not yet ready |
111
    /// | Draining | Conditional | Only for shards it still owns during migration |
112
    /// | Failed | No | Unavailable |
113
    /// | Removed | No | No longer in cluster |
114
    ///
115
    /// The `draining_shard` parameter should be `Some(shard_id)` if the node
116
    /// is in `Draining` status and the shard IS being actively migrated off this node
117
    /// (use `None` if the shard is not being drained or no shard is being checked).
118
    /// When `Some(...)`, the node is NOT eligible for writes.
119
32
    pub fn is_write_eligible_for(self, draining_shard: Option<u32>) -> bool {
120
32
        match self {
121
14
            NodeStatus::Healthy | NodeStatus::Active | NodeStatus::Degraded => true,
122
12
            NodeStatus::Joining | NodeStatus::Failed | NodeStatus::Removed => false,
123
6
            NodeStatus::Draining => !draining_shard.is_some(),
124
        }
125
32
    }
126
}
127
128
impl fmt::Display for NodeStatus {
129
18
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130
18
        match self {
131
2
            NodeStatus::Healthy => write!(f, "healthy"),
132
2
            NodeStatus::Degraded => write!(f, "degraded"),
133
2
            NodeStatus::Active => write!(f, "active"),
134
4
            NodeStatus::Joining => write!(f, "joining"),
135
4
            NodeStatus::Draining => write!(f, "draining"),
136
2
            NodeStatus::Failed => write!(f, "failed"),
137
2
            NodeStatus::Removed => write!(f, "removed"),
138
        }
139
18
    }
140
}
141
142
/// A single Meilisearch node in the topology.
143
#[derive(Debug, Clone, Serialize, Deserialize)]
144
pub struct Node {
145
    /// Unique node identifier.
146
    pub id: NodeId,
147
148
    /// Node base URL / address.
149
    pub address: String,
150
151
    /// Current health status.
152
    pub status: NodeStatus,
153
154
    /// Replica group assignment (0-based).
155
    pub replica_group: u32,
156
}
157
158
impl Node {
159
    /// Create a new node.
160
62
    pub fn new(id: NodeId, address: String, replica_group: u32) -> Self {
161
62
        Self {
162
62
            id,
163
62
            address,
164
62
            status: NodeStatus::Joining,
165
62
            replica_group,
166
62
        }
167
62
    }
168
169
    /// Create a new node with a specific status.
170
16
    pub fn with_status(id: NodeId, address: String, replica_group: u32, status: NodeStatus) -> Self {
171
16
        Self {
172
16
            id,
173
16
            address,
174
16
            status,
175
16
            replica_group,
176
16
        }
177
16
    }
178
179
    /// Check if the node is healthy (can serve traffic).
180
26
    pub fn is_healthy(&self) -> bool {
181
26
        
matches!16
(self.status, NodeStatus::Healthy | NodeStatus::Active)
182
26
    }
183
184
    /// Transition the node to a new status, validating the transition.
185
    ///
186
    /// Returns `Ok(())` if the transition is valid, `Err` otherwise.
187
6
    pub fn set_status(&mut self, new_status: NodeStatus) -> Result<(), TransitionError> {
188
6
        if self.status.can_transition_to(new_status) {
189
4
            self.status = new_status;
190
4
            Ok(())
191
        } else {
192
2
            Err(TransitionError {
193
2
                from: self.status,
194
2
                to: new_status,
195
2
            })
196
        }
197
6
    }
198
199
    /// Check if the node is eligible to receive writes for a specific shard.
200
    ///
201
    /// For nodes in `Draining` status, this depends on whether the shard is
202
    /// being actively migrated off this node. The caller should pass
203
    /// `Some(shard_id)` if the shard is being drained from this node.
204
2
    pub fn is_write_eligible_for(&self, shard_id: Option<u32>) -> bool {
205
2
        self.status.is_write_eligible_for(shard_id)
206
2
    }
207
}
208
209
/// Error returned when an invalid state transition is attempted.
210
#[derive(Debug, Clone, PartialEq, Eq)]
211
pub struct TransitionError {
212
    pub from: NodeStatus,
213
    pub to: NodeStatus,
214
}
215
216
impl fmt::Display for TransitionError {
217
2
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218
2
        write!(
219
2
            f,
220
2
            "invalid state transition from {} to {}",
221
            self.from, self.to
222
        )
223
2
    }
224
}
225
226
impl std::error::Error for TransitionError {}
227
228
/// A replica group: an independent query pool.
229
///
230
/// Each group holds all S shards, distributed across its nodes.
231
/// Reads are routed to a single group per query.
232
#[derive(Debug, Clone, Serialize, Deserialize)]
233
pub struct Group {
234
    /// Group identifier (0-based).
235
    pub id: u32,
236
237
    /// Nodes in this group.
238
    nodes: Vec<NodeId>,
239
}
240
241
impl Group {
242
    /// Create a new group.
243
48
    pub fn new(id: u32) -> Self {
244
48
        Self {
245
48
            id,
246
48
            nodes: Vec::new(),
247
48
        }
248
48
    }
249
250
    /// Add a node to this group.
251
108
    pub fn add_node(&mut self, node_id: NodeId) {
252
108
        if !self.nodes.contains(&node_id) {
253
106
            self.nodes.push(node_id);
254
106
        
}2
255
108
    }
256
257
    /// Get the node IDs in this group.
258
175M
    pub fn nodes(&self) -> &[NodeId] {
259
175M
        &self.nodes
260
175M
    }
261
262
    /// Get the number of nodes in this group.
263
8
    pub fn node_count(&self) -> usize {
264
8
        self.nodes.len()
265
8
    }
266
267
    /// Get all healthy nodes in this group, looking them up from the topology.
268
    ///
269
    /// This requires access to the topology's node map to resolve NodeIds to Nodes.
270
6
    pub fn healthy_nodes<'a>(&'a self, all_nodes: &'a HashMap<NodeId, Node>) -> Vec<&'a Node> {
271
6
        self.nodes
272
6
            .iter()
273
12
            .
filter_map6
(|node_id| all_nodes.get(node_id))
274
12
            .
filter6
(|node| node.is_healthy())
275
6
            .collect()
276
6
    }
277
}
278
279
/// Cluster topology: groups, nodes, and health state.
280
#[derive(Debug, Clone, Serialize, Deserialize)]
281
pub struct Topology {
282
    /// All nodes in the cluster.
283
    nodes: HashMap<NodeId, Node>,
284
285
    /// Replica groups.
286
    groups: Vec<Group>,
287
288
    /// Replication factor (intra-group).
289
    rf: usize,
290
291
    /// Total number of logical shards (S).
292
    shards: u32,
293
}
294
295
impl Topology {
296
    /// Create a new empty topology.
297
28
    pub fn new(shards: u32, rf: usize) -> Self {
298
28
        Self {
299
28
            nodes: HashMap::new(),
300
28
            groups: Vec::new(),
301
28
            rf,
302
28
            shards,
303
28
        }
304
28
    }
305
306
    /// Add a node to the topology.
307
64
    pub fn add_node(&mut self, node: Node) {
308
64
        let group_id = node.replica_group as usize;
309
310
        // Ensure group exists
311
98
        while self.groups.len() <= group_id {
312
34
            self.groups.push(Group::new(self.groups.len() as u32));
313
34
        }
314
315
64
        self.groups[group_id].add_node(node.id.clone());
316
64
        self.nodes.insert(node.id.clone(), node);
317
64
    }
318
319
    /// Get a node by ID.
320
10
    pub fn node(&self, id: &NodeId) -> Option<&Node> {
321
10
        self.nodes.get(id)
322
10
    }
323
324
    /// Get a mutable reference to a node by ID.
325
2
    pub fn node_mut(&mut self, id: &NodeId) -> Option<&mut Node> {
326
2
        self.nodes.get_mut(id)
327
2
    }
328
329
    /// Get all nodes.
330
2
    pub fn nodes(&self) -> impl Iterator<Item = &Node> {
331
2
        self.nodes.values()
332
2
    }
333
334
    /// Get a group by ID.
335
8
    pub fn group(&self, id: u32) -> Option<&Group> {
336
8
        self.groups.get(id as usize)
337
8
    }
338
339
    /// Iterate over all groups.
340
10
    pub fn groups(&self) -> impl Iterator<Item = &Group> {
341
10
        self.groups.iter()
342
10
    }
343
344
    /// Get the replication factor.
345
16
    pub fn rf(&self) -> usize {
346
16
        self.rf
347
16
    }
348
349
    /// Get the number of shards.
350
2
    pub fn shards(&self) -> u32 {
351
2
        self.shards
352
2
    }
353
354
    /// Get the number of replica groups.
355
8
    pub fn replica_group_count(&self) -> u32 {
356
8
        self.groups.len() as u32
357
8
    }
358
359
    /// Get healthy nodes in a specific group.
360
4
    pub fn healthy_nodes_in_group(&self, group_id: u32) -> Vec<&Node> {
361
4
        self.group(group_id)
362
4
            .map(|g| g.healthy_nodes(&self.nodes))
363
4
            .unwrap_or_default()
364
4
    }
365
}
366
367
#[cfg(test)]
368
mod tests {
369
    use super::*;
370
371
    // --- Existing tests updated for address field ---
372
373
    #[test]
374
2
    fn test_node_is_healthy() {
375
2
        let mut node = Node::new(
376
2
            NodeId::new("node1".to_string()),
377
2
            "http://example.com".to_string(),
378
            0,
379
        );
380
381
        // Joining status is not healthy
382
2
        assert!(!node.is_healthy());
383
384
        // Healthy status is healthy
385
2
        node.status = NodeStatus::Healthy;
386
2
        assert!(node.is_healthy());
387
388
        // Active status is healthy (synonym for Healthy)
389
2
        node.status = NodeStatus::Active;
390
2
        assert!(node.is_healthy());
391
392
        // Degraded status is not healthy (intermittent failures)
393
2
        node.status = NodeStatus::Degraded;
394
2
        assert!(!node.is_healthy());
395
396
        // Draining status is not healthy
397
2
        node.status = NodeStatus::Draining;
398
2
        assert!(!node.is_healthy());
399
400
        // Failed status is not healthy
401
2
        node.status = NodeStatus::Failed;
402
2
        assert!(!node.is_healthy());
403
404
        // Removed status is not healthy
405
2
        node.status = NodeStatus::Removed;
406
2
        assert!(!node.is_healthy());
407
2
    }
408
409
    #[test]
410
2
    fn test_group_node_count() {
411
2
        let mut group = Group::new(0);
412
2
        assert_eq!(group.node_count(), 0);
413
414
2
        group.add_node(NodeId::new("node1".to_string()));
415
2
        assert_eq!(group.node_count(), 1);
416
417
2
        group.add_node(NodeId::new("node2".to_string()));
418
2
        assert_eq!(group.node_count(), 2);
419
420
        // Adding duplicate node doesn't increase count
421
2
        group.add_node(NodeId::new("node1".to_string()));
422
2
        assert_eq!(group.node_count(), 2);
423
2
    }
424
425
    #[test]
426
2
    fn test_topology_replica_group_count() {
427
2
        let mut topology = Topology::new(64, 2);
428
429
        // Empty topology has 0 groups
430
2
        assert_eq!(topology.replica_group_count(), 0);
431
432
        // Add nodes to group 0
433
2
        topology.add_node(Node::new(
434
2
            NodeId::new("node1".to_string()),
435
2
            "http://example.com".to_string(),
436
            0,
437
        ));
438
2
        assert_eq!(topology.replica_group_count(), 1);
439
440
        // Add nodes to group 1
441
2
        topology.add_node(Node::new(
442
2
            NodeId::new("node2".to_string()),
443
2
            "http://example.com".to_string(),
444
            1,
445
        ));
446
2
        assert_eq!(topology.replica_group_count(), 2);
447
448
        // Add more nodes to existing groups
449
2
        topology.add_node(Node::new(
450
2
            NodeId::new("node3".to_string()),
451
2
            "http://example.com".to_string(),
452
            0,
453
        ));
454
2
        assert_eq!(topology.replica_group_count(), 2);
455
2
    }
456
457
    #[test]
458
2
    fn test_topology_nodes_iter() {
459
2
        let mut topology = Topology::new(64, 1);
460
461
2
        topology.add_node(Node::new(
462
2
            NodeId::new("node1".to_string()),
463
2
            "http://example.com".to_string(),
464
            0,
465
        ));
466
2
        topology.add_node(Node::new(
467
2
            NodeId::new("node2".to_string()),
468
2
            "http://example.com".to_string(),
469
            1,
470
        ));
471
472
2
        let nodes: Vec<_> = topology.nodes().collect();
473
2
        assert_eq!(nodes.len(), 2);
474
2
    }
475
476
    #[test]
477
2
    fn test_topology_groups_iter() {
478
2
        let mut topology = Topology::new(64, 1);
479
480
2
        topology.add_node(Node::new(
481
2
            NodeId::new("node1".to_string()),
482
2
            "http://example.com".to_string(),
483
            0,
484
        ));
485
2
        topology.add_node(Node::new(
486
2
            NodeId::new("node2".to_string()),
487
2
            "http://example.com".to_string(),
488
            1,
489
        ));
490
491
2
        let groups: Vec<_> = topology.groups().collect();
492
2
        assert_eq!(groups.len(), 2);
493
2
    }
494
495
    #[test]
496
2
    fn test_node_id_from_string() {
497
2
        let id: NodeId = "test-node".to_string().into();
498
2
        assert_eq!(id.as_str(), "test-node");
499
2
    }
500
501
    #[test]
502
2
    fn test_node_id_as_ref() {
503
2
        let id = NodeId::new("test-node".to_string());
504
2
        let s: &str = id.as_ref();
505
2
        assert_eq!(s, "test-node");
506
2
    }
507
508
    // --- New tests for state transitions ---
509
510
    #[test]
511
2
    fn test_state_transition_joining_to_active() {
512
2
        assert!(NodeStatus::Joining.can_transition_to(NodeStatus::Active));
513
2
    }
514
515
    #[test]
516
2
    fn test_state_transition_active_to_draining() {
517
2
        assert!(NodeStatus::Active.can_transition_to(NodeStatus::Draining));
518
2
    }
519
520
    #[test]
521
2
    fn test_state_transition_draining_to_removed() {
522
2
        assert!(NodeStatus::Draining.can_transition_to(NodeStatus::Removed));
523
2
    }
524
525
    #[test]
526
2
    fn test_state_transition_active_to_failed() {
527
2
        assert!(NodeStatus::Active.can_transition_to(NodeStatus::Failed));
528
2
    }
529
530
    #[test]
531
2
    fn test_state_transition_draining_to_failed() {
532
2
        assert!(NodeStatus::Draining.can_transition_to(NodeStatus::Failed));
533
2
    }
534
535
    #[test]
536
2
    fn test_state_transition_failed_to_active() {
537
2
        assert!(NodeStatus::Failed.can_transition_to(NodeStatus::Active));
538
2
    }
539
540
    #[test]
541
2
    fn test_state_transition_active_to_degraded() {
542
2
        assert!(NodeStatus::Active.can_transition_to(NodeStatus::Degraded));
543
2
    }
544
545
    #[test]
546
2
    fn test_state_transition_failed_to_degraded() {
547
2
        assert!(NodeStatus::Failed.can_transition_to(NodeStatus::Degraded));
548
2
    }
549
550
    #[test]
551
2
    fn test_state_transition_degraded_to_active() {
552
2
        assert!(NodeStatus::Degraded.can_transition_to(NodeStatus::Active));
553
2
    }
554
555
    #[test]
556
2
    fn test_state_transition_healthy_active_bidirectional() {
557
2
        assert!(NodeStatus::Healthy.can_transition_to(NodeStatus::Active));
558
2
        assert!(NodeStatus::Active.can_transition_to(NodeStatus::Healthy));
559
2
    }
560
561
    #[test]
562
2
    fn test_state_transition_same_state() {
563
14
        for status in [
564
2
            NodeStatus::Healthy,
565
2
            NodeStatus::Degraded,
566
2
            NodeStatus::Active,
567
2
            NodeStatus::Joining,
568
2
            NodeStatus::Draining,
569
2
            NodeStatus::Failed,
570
2
            NodeStatus::Removed,
571
        ] {
572
14
            assert!(status.can_transition_to(status));
573
        }
574
2
    }
575
576
    #[test]
577
2
    fn test_state_transition_invalid_joining_to_draining() {
578
        // Joining node must become Active before Draining
579
2
        assert!(!NodeStatus::Joining.can_transition_to(NodeStatus::Draining));
580
2
    }
581
582
    #[test]
583
2
    fn test_state_transition_invalid_joining_to_failed() {
584
        // Joining node cannot fail (not yet active)
585
2
        assert!(!NodeStatus::Joining.can_transition_to(NodeStatus::Failed));
586
2
    }
587
588
    #[test]
589
2
    fn test_state_transition_invalid_removed_to_anything() {
590
        // Removed is terminal
591
2
        assert!(!NodeStatus::Removed.can_transition_to(NodeStatus::Active));
592
2
        assert!(!NodeStatus::Removed.can_transition_to(NodeStatus::Failed));
593
2
    }
594
595
    #[test]
596
2
    fn test_node_set_status_valid_transition() {
597
2
        let mut node = Node::new(
598
2
            NodeId::new("node1".to_string()),
599
2
            "http://example.com".to_string(),
600
            0,
601
        );
602
2
        assert_eq!(node.status, NodeStatus::Joining);
603
604
2
        assert!(node.set_status(NodeStatus::Active).is_ok());
605
2
        assert_eq!(node.status, NodeStatus::Active);
606
2
    }
607
608
    #[test]
609
2
    fn test_node_set_status_invalid_transition() {
610
2
        let mut node = Node::with_status(
611
2
            NodeId::new("node1".to_string()),
612
2
            "http://example.com".to_string(),
613
            0,
614
2
            NodeStatus::Removed,
615
        );
616
617
2
        let result = node.set_status(NodeStatus::Active);
618
2
        assert!(result.is_err());
619
2
        let err = result.unwrap_err();
620
2
        assert_eq!(err.from, NodeStatus::Removed);
621
2
        assert_eq!(err.to, NodeStatus::Active);
622
        // Status unchanged
623
2
        assert_eq!(node.status, NodeStatus::Removed);
624
2
    }
625
626
    // --- New tests for write eligibility ---
627
628
    #[test]
629
2
    fn test_write_eligible_healthy() {
630
2
        assert!(NodeStatus::Healthy.is_write_eligible_for(None));
631
2
        assert!(NodeStatus::Healthy.is_write_eligible_for(Some(0)));
632
2
    }
633
634
    #[test]
635
2
    fn test_write_eligible_active() {
636
2
        assert!(NodeStatus::Active.is_write_eligible_for(None));
637
2
        assert!(NodeStatus::Active.is_write_eligible_for(Some(0)));
638
2
    }
639
640
    #[test]
641
2
    fn test_write_eligible_degraded() {
642
2
        assert!(NodeStatus::Degraded.is_write_eligible_for(None));
643
2
        assert!(NodeStatus::Degraded.is_write_eligible_for(Some(0)));
644
2
    }
645
646
    #[test]
647
2
    fn test_write_eligible_joining() {
648
        // Joining nodes are not write-eligible
649
2
        assert!(!NodeStatus::Joining.is_write_eligible_for(None));
650
2
        assert!(!NodeStatus::Joining.is_write_eligible_for(Some(0)));
651
2
    }
652
653
    #[test]
654
2
    fn test_write_eligible_failed() {
655
        // Failed nodes are not write-eligible
656
2
        assert!(!NodeStatus::Failed.is_write_eligible_for(None));
657
2
        assert!(!NodeStatus::Failed.is_write_eligible_for(Some(0)));
658
2
    }
659
660
    #[test]
661
2
    fn test_write_eligible_removed() {
662
        // Removed nodes are not write-eligible
663
2
        assert!(!NodeStatus::Removed.is_write_eligible_for(None));
664
2
        assert!(!NodeStatus::Removed.is_write_eligible_for(Some(0)));
665
2
    }
666
667
    #[test]
668
2
    fn test_write_eligible_draining_non_drained_shard() {
669
        // Draining node is eligible for writes in general (no specific shard being checked)
670
2
        assert!(NodeStatus::Draining.is_write_eligible_for(None));
671
        // When Some(shard_id) is passed, it means that shard is being drained, so NOT eligible
672
2
        assert!(!NodeStatus::Draining.is_write_eligible_for(Some(5)));
673
2
    }
674
675
    #[test]
676
2
    fn test_write_eligible_draining_drained_shard() {
677
        // Draining node is NOT eligible for writes to shards being migrated off
678
2
        assert!(!NodeStatus::Draining.is_write_eligible_for(Some(3)));
679
2
    }
680
681
    #[test]
682
2
    fn test_node_is_write_eligible_for() {
683
2
        let node = Node::with_status(
684
2
            NodeId::new("node1".to_string()),
685
2
            "http://example.com".to_string(),
686
            0,
687
2
            NodeStatus::Active,
688
        );
689
2
        assert!(node.is_write_eligible_for(Some(0)));
690
2
    }
691
692
    // --- New tests for healthy_nodes ---
693
694
    #[test]
695
2
    fn test_group_healthy_nodes() {
696
2
        let mut group = Group::new(0);
697
2
        let mut all_nodes = HashMap::new();
698
699
2
        let node1 = Node::with_status(
700
2
            NodeId::new("node1".to_string()),
701
2
            "http://node1".to_string(),
702
            0,
703
2
            NodeStatus::Active,
704
        );
705
2
        let node2 = Node::with_status(
706
2
            NodeId::new("node2".to_string()),
707
2
            "http://node2".to_string(),
708
            0,
709
2
            NodeStatus::Degraded,
710
        );
711
2
        let node3 = Node::with_status(
712
2
            NodeId::new("node3".to_string()),
713
2
            "http://node3".to_string(),
714
            0,
715
2
            NodeStatus::Failed,
716
        );
717
718
2
        group.add_node(node1.id.clone());
719
2
        group.add_node(node2.id.clone());
720
2
        group.add_node(node3.id.clone());
721
722
2
        all_nodes.insert(node1.id.clone(), node1);
723
2
        all_nodes.insert(node2.id.clone(), node2);
724
2
        all_nodes.insert(node3.id.clone(), node3);
725
726
2
        let healthy = group.healthy_nodes(&all_nodes);
727
2
        assert_eq!(healthy.len(), 1); // Only node1 (Active) is healthy
728
2
        assert_eq!(healthy[0].id.as_str(), "node1");
729
2
    }
730
731
    #[test]
732
2
    fn test_topology_shards() {
733
2
        let topology = Topology::new(128, 3);
734
2
        assert_eq!(topology.shards(), 128);
735
2
    }
736
737
    #[test]
738
2
    fn test_topology_healthy_nodes_in_group() {
739
2
        let mut topology = Topology::new(64, 2);
740
741
2
        topology.add_node(Node::with_status(
742
2
            NodeId::new("node1".to_string()),
743
2
            "http://node1".to_string(),
744
            0,
745
2
            NodeStatus::Active,
746
        ));
747
2
        topology.add_node(Node::with_status(
748
2
            NodeId::new("node2".to_string()),
749
2
            "http://node2".to_string(),
750
            0,
751
2
            NodeStatus::Failed,
752
        ));
753
2
        topology.add_node(Node::with_status(
754
2
            NodeId::new("node3".to_string()),
755
2
            "http://node3".to_string(),
756
            1,
757
2
            NodeStatus::Active,
758
        ));
759
760
2
        let healthy_group0 = topology.healthy_nodes_in_group(0);
761
2
        assert_eq!(healthy_group0.len(), 1);
762
2
        assert_eq!(healthy_group0[0].id.as_str(), "node1");
763
764
2
        let healthy_group1 = topology.healthy_nodes_in_group(1);
765
2
        assert_eq!(healthy_group1.len(), 1);
766
2
        assert_eq!(healthy_group1[0].id.as_str(), "node3");
767
2
    }
768
769
    // --- Test for node mutation ---
770
771
    #[test]
772
2
    fn test_topology_node_mut() {
773
2
        let mut topology = Topology::new(64, 1);
774
775
2
        topology.add_node(Node::new(
776
2
            NodeId::new("node1".to_string()),
777
2
            "http://node1".to_string(),
778
            0,
779
        ));
780
781
2
        let node_id = NodeId::new("node1".to_string());
782
        {
783
2
            let node = topology.node(&node_id).unwrap();
784
2
            assert_eq!(node.status, NodeStatus::Joining);
785
        }
786
787
2
        {
788
2
            let node = topology.node_mut(&node_id).unwrap();
789
2
            node.set_status(NodeStatus::Active).unwrap();
790
2
        }
791
792
2
        let node = topology.node(&node_id).unwrap();
793
2
        assert_eq!(node.status, NodeStatus::Active);
794
2
    }
795
796
    // --- Display tests ---
797
798
    #[test]
799
2
    fn test_node_status_display() {
800
2
        assert_eq!(NodeStatus::Healthy.to_string(), "healthy");
801
2
        assert_eq!(NodeStatus::Degraded.to_string(), "degraded");
802
2
        assert_eq!(NodeStatus::Active.to_string(), "active");
803
2
        assert_eq!(NodeStatus::Joining.to_string(), "joining");
804
2
        assert_eq!(NodeStatus::Draining.to_string(), "draining");
805
2
        assert_eq!(NodeStatus::Failed.to_string(), "failed");
806
2
        assert_eq!(NodeStatus::Removed.to_string(), "removed");
807
2
    }
808
809
    #[test]
810
2
    fn test_transition_error_display() {
811
2
        let err = TransitionError {
812
2
            from: NodeStatus::Joining,
813
2
            to: NodeStatus::Draining,
814
2
        };
815
2
        let msg = format!("{}", err);
816
2
        assert!(msg.contains("invalid state transition"));
817
2
        assert!(msg.contains("joining"));
818
2
        assert!(msg.contains("draining"));
819
2
    }
820
}
\ No newline at end of file diff --git a/coverage/html/index.html b/coverage/html/index.html deleted file mode 100644 index c889b2d..0000000 --- a/coverage/html/index.html +++ /dev/null @@ -1 +0,0 @@ -

Coverage Report

Created: 2026-05-09 11:17

Click here for information about interpreting this report.

FilenameFunction CoverageLine CoverageRegion CoverageBranch Coverage
anti_entropy.rs
 100.00% (7/7)
 100.00% (70/70)
 100.00% (56/56)
- (0/0)
config.rs
  83.33% (25/30)
  94.12% (288/306)
  91.13% (267/293)
- (0/0)
config/advanced.rs
  93.75% (30/32)
  93.06% (268/288)
  90.36% (150/166)
- (0/0)
config/load.rs
  77.78% (7/9)
  80.00% (112/140)
  51.57% (82/159)
- (0/0)
config/validate.rs
 100.00% (1/1)
  57.41% (62/108)
  68.60% (59/86)
- (0/0)
merger.rs
  91.84% (45/49)
  94.67% (551/582)
  96.83% (946/977)
- (0/0)
migration.rs
  72.09% (31/43)
  77.73% (363/467)
  77.39% (558/721)
- (0/0)
reshard.rs
  80.56% (29/36)
  89.51% (290/324)
  89.67% (408/455)
- (0/0)
router.rs
  98.33% (59/60)
  96.20% (481/500)
  97.44% (990/1016)
- (0/0)
scatter.rs
 100.00% (11/11)
 100.00% (121/121)
 100.00% (214/214)
- (0/0)
score_comparability.rs
 100.00% (32/32)
  97.23% (316/325)
  98.30% (579/589)
- (0/0)
task.rs
 100.00% (16/16)
 100.00% (118/118)
 100.00% (164/164)
- (0/0)
topology.rs
 100.00% (70/70)
 100.00% (421/421)
 100.00% (776/776)
- (0/0)
Totals
  91.67% (363/396)
  91.80% (3461/3770)
  92.54% (5249/5672)
- (0/0)
Generated by llvm-cov -- llvm version 20.1.5-rust-1.88.0-stable
\ No newline at end of file diff --git a/coverage/html/style.css b/coverage/html/style.css deleted file mode 100644 index ae4f09f..0000000 --- a/coverage/html/style.css +++ /dev/null @@ -1,194 +0,0 @@ -.red { - background-color: #f004; -} -.cyan { - background-color: cyan; -} -html { - scroll-behavior: smooth; -} -body { - font-family: -apple-system, sans-serif; -} -pre { - margin-top: 0px !important; - margin-bottom: 0px !important; -} -.source-name-title { - padding: 5px 10px; - border-bottom: 1px solid #8888; - background-color: #0002; - line-height: 35px; -} -.centered { - display: table; - margin-left: left; - margin-right: auto; - border: 1px solid #8888; - border-radius: 3px; -} -.expansion-view { - margin-left: 0px; - margin-top: 5px; - margin-right: 5px; - margin-bottom: 5px; - border: 1px solid #8888; - border-radius: 3px; -} -table { - border-collapse: collapse; -} -.light-row { - border: 1px solid #8888; - border-left: none; - border-right: none; -} -.light-row-bold { - border: 1px solid #8888; - border-left: none; - border-right: none; - font-weight: bold; -} -.column-entry { - text-align: left; -} -.column-entry-bold { - font-weight: bold; - text-align: left; -} -.column-entry-yellow { - text-align: left; - background-color: #ff06; -} -.column-entry-red { - text-align: left; - background-color: #f004; -} -.column-entry-gray { - text-align: left; - background-color: #fff4; -} -.column-entry-green { - text-align: left; - background-color: #0f04; -} -.line-number { - text-align: right; -} -.covered-line { - text-align: right; - color: #06d; -} -.uncovered-line { - text-align: right; - color: #d00; -} -.uncovered-line.selected { - color: #f00; - font-weight: bold; -} -.region.red.selected { - background-color: #f008; - font-weight: bold; -} -.branch.red.selected { - background-color: #f008; - font-weight: bold; -} -.tooltip { - position: relative; - display: inline; - background-color: #bef; - text-decoration: none; -} -.tooltip span.tooltip-content { - position: absolute; - width: 100px; - margin-left: -50px; - color: #FFFFFF; - background: #000000; - height: 30px; - line-height: 30px; - text-align: center; - visibility: hidden; - border-radius: 6px; -} -.tooltip span.tooltip-content:after { - content: ''; - position: absolute; - top: 100%; - left: 50%; - margin-left: -8px; - width: 0; height: 0; - border-top: 8px solid #000000; - border-right: 8px solid transparent; - border-left: 8px solid transparent; -} -:hover.tooltip span.tooltip-content { - visibility: visible; - opacity: 0.8; - bottom: 30px; - left: 50%; - z-index: 999; -} -th, td { - vertical-align: top; - padding: 2px 8px; - border-collapse: collapse; - border-right: 1px solid #8888; - border-left: 1px solid #8888; - text-align: left; -} -td pre { - display: inline-block; - text-decoration: inherit; -} -td:first-child { - border-left: none; -} -td:last-child { - border-right: none; -} -tr:hover { - background-color: #eee; -} -tr:last-child { - border-bottom: none; -} -tr:has(> td >a:target), tr:has(> td.uncovered-line.selected) { - background-color: #8884; -} -a { - color: inherit; -} -.control { - position: fixed; - top: 0em; - right: 0em; - padding: 1em; - background: #FFF8; -} -@media (prefers-color-scheme: dark) { - body { - background-color: #222; - color: whitesmoke; - } - tr:hover { - background-color: #111; - } - .covered-line { - color: #39f; - } - .uncovered-line { - color: #f55; - } - .tooltip { - background-color: #068; - } - .control { - background: #2228; - } - tr:has(> td >a:target), tr:has(> td.uncovered-line.selected) { - background-color: #8884; - } -} diff --git a/coverage/lcov.info b/coverage/lcov.info deleted file mode 100644 index 93f422a..0000000 --- a/coverage/lcov.info +++ /dev/null @@ -1,4593 +0,0 @@ -SF:/home/coding/miroir/crates/miroir-core/src/anti_entropy.rs -FN:43,_RNvNtCs14cwtawSGIg_11miroir_core12anti_entropy25validate_migration_safety -FN:59,_RNvNtCs14cwtawSGIg_11miroir_core12anti_entropy32migration_warning_if_ae_disabled -FN:23,_RNvXNtCs14cwtawSGIg_11miroir_core12anti_entropyNtB2_17AntiEntropyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:117,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_29test_warning_when_ae_disabled -FN:76,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_34test_validate_safe_with_delta_pass -FN:89,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_41test_validate_unsafe_without_anti_entropy -FN:103,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_47test_validate_safe_with_anti_entropy_safety_net -FNDA:6,_RNvNtCs14cwtawSGIg_11miroir_core12anti_entropy25validate_migration_safety -FNDA:4,_RNvNtCs14cwtawSGIg_11miroir_core12anti_entropy32migration_warning_if_ae_disabled -FNDA:6,_RNvXNtCs14cwtawSGIg_11miroir_core12anti_entropyNtB2_17AntiEntropyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_29test_warning_when_ae_disabled -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_34test_validate_safe_with_delta_pass -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_41test_validate_unsafe_without_anti_entropy -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core12anti_entropy5testss_47test_validate_safe_with_anti_entropy_safety_net -FNF:7 -FNH:7 -DA:23,6 -DA:24,6 -DA:25,6 -DA:26,6 -DA:27,6 -DA:28,6 -DA:29,6 -DA:30,6 -DA:31,6 -DA:32,6 -DA:33,6 -DA:43,6 -DA:44,6 -DA:45,6 -DA:46,6 -DA:47,6 -DA:48,2 -DA:49,4 -DA:50,4 -DA:51,6 -DA:59,4 -DA:60,4 -DA:61,2 -DA:62,2 -DA:63,2 -DA:64,2 -DA:65,2 -DA:66,2 -DA:67,2 -DA:68,2 -DA:69,4 -DA:76,2 -DA:77,2 -DA:78,2 -DA:79,2 -DA:80,2 -DA:81,2 -DA:82,2 -DA:83,2 -DA:84,2 -DA:85,2 -DA:86,2 -DA:89,2 -DA:90,2 -DA:91,2 -DA:92,2 -DA:93,2 -DA:94,2 -DA:95,2 -DA:96,2 -DA:97,2 -DA:98,2 -DA:99,2 -DA:100,2 -DA:103,2 -DA:104,2 -DA:105,2 -DA:106,2 -DA:107,2 -DA:108,2 -DA:109,2 -DA:110,2 -DA:111,2 -DA:112,2 -DA:113,2 -DA:114,2 -DA:117,2 -DA:118,2 -DA:119,2 -DA:120,2 -BRF:0 -BRH:0 -LF:70 -LH:70 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/config.rs -FN:129,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig4load -FN:139,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig9from_yaml -FN:134,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig9load_from -FN:381,_RNvXsa_NtCs14cwtawSGIg_11miroir_core6configNtB5_22UnavailableShardPolicyNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:387,_RNvXsb_NtCs14cwtawSGIg_11miroir_core6configNtB5_22UnavailableShardPolicyNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:401,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5tests10dev_config -FN:501,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_15round_trip_yaml -FN:427,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_23default_config_is_valid -FN:438,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_25minimal_yaml_deserializes -FN:565,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_29advanced_defaults_all_enabled -FN:454,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_30full_plan_example_deserializes -FN:517,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_30validation_rejects_zero_shards -FN:509,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_33validation_rejects_ha_with_sqlite -FN:525,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_37validation_rejects_duplicate_node_ids -FN:544,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_46validation_rejects_node_outside_replica_groups -FN:556,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_46validation_rejects_scoped_key_timing_inversion -FN:124,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig8validate -FN:272,_RNvNtCs14cwtawSGIg_11miroir_core6config26default_request_timeout_ms -FN:269,_RNvNtCs14cwtawSGIg_11miroir_core6config31default_max_concurrent_requests -FN:76,_RNvXNtCs14cwtawSGIg_11miroir_core6configNtB2_12MiroirConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:169,_RNvXs0_NtCs14cwtawSGIg_11miroir_core6configNtB5_15TaskStoreConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:188,_RNvXs1_NtCs14cwtawSGIg_11miroir_core6configNtB5_11AdminConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:207,_RNvXs2_NtCs14cwtawSGIg_11miroir_core6configNtB5_12HealthConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:228,_RNvXs3_NtCs14cwtawSGIg_11miroir_core6configNtB5_13ScatterConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:247,_RNvXs4_NtCs14cwtawSGIg_11miroir_core6configNtB5_16RebalancerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:277,_RNvXs5_NtCs14cwtawSGIg_11miroir_core6configNtB5_12ServerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:298,_RNvXs6_NtCs14cwtawSGIg_11miroir_core6configNtB5_20ConnectionPoolConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:316,_RNvXs7_NtCs14cwtawSGIg_11miroir_core6configNtB5_18TaskRegistryConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:333,_RNvXs8_NtCs14cwtawSGIg_11miroir_core6configNtB5_19PeerDiscoveryConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:351,_RNvXs9_NtCs14cwtawSGIg_11miroir_core6configNtB5_20LeaderElectionConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:0,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig4load -FNDA:0,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig9from_yaml -FNDA:0,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig9load_from -FNDA:0,_RNvXsa_NtCs14cwtawSGIg_11miroir_core6configNtB5_22UnavailableShardPolicyNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:0,_RNvXsb_NtCs14cwtawSGIg_11miroir_core6configNtB5_22UnavailableShardPolicyNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:10,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5tests10dev_config -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_15round_trip_yaml -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_23default_config_is_valid -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_25minimal_yaml_deserializes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_29advanced_defaults_all_enabled -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_30full_plan_example_deserializes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_30validation_rejects_zero_shards -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_33validation_rejects_ha_with_sqlite -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_37validation_rejects_duplicate_node_ids -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_46validation_rejects_node_outside_replica_groups -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6config5testss_46validation_rejects_scoped_key_timing_inversion -FNDA:22,_RNvMs_NtCs14cwtawSGIg_11miroir_core6configNtB4_12MiroirConfig8validate -FNDA:42,_RNvNtCs14cwtawSGIg_11miroir_core6config26default_request_timeout_ms -FNDA:42,_RNvNtCs14cwtawSGIg_11miroir_core6config31default_max_concurrent_requests -FNDA:32,_RNvXNtCs14cwtawSGIg_11miroir_core6configNtB2_12MiroirConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:50,_RNvXs0_NtCs14cwtawSGIg_11miroir_core6configNtB5_15TaskStoreConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs1_NtCs14cwtawSGIg_11miroir_core6configNtB5_11AdminConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs2_NtCs14cwtawSGIg_11miroir_core6configNtB5_12HealthConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs3_NtCs14cwtawSGIg_11miroir_core6configNtB5_13ScatterConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs4_NtCs14cwtawSGIg_11miroir_core6configNtB5_16RebalancerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs5_NtCs14cwtawSGIg_11miroir_core6configNtB5_12ServerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs6_NtCs14cwtawSGIg_11miroir_core6configNtB5_20ConnectionPoolConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs7_NtCs14cwtawSGIg_11miroir_core6configNtB5_18TaskRegistryConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs8_NtCs14cwtawSGIg_11miroir_core6configNtB5_19PeerDiscoveryConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:38,_RNvXs9_NtCs14cwtawSGIg_11miroir_core6configNtB5_20LeaderElectionConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNF:30 -FNH:25 -DA:76,32 -DA:77,32 -DA:78,32 -DA:79,32 -DA:80,32 -DA:81,32 -DA:82,32 -DA:83,32 -DA:84,32 -DA:85,32 -DA:86,32 -DA:87,32 -DA:88,32 -DA:89,32 -DA:90,32 -DA:91,32 -DA:92,32 -DA:93,32 -DA:94,32 -DA:95,32 -DA:96,32 -DA:97,32 -DA:98,32 -DA:99,32 -DA:100,32 -DA:101,32 -DA:102,32 -DA:103,32 -DA:104,32 -DA:105,32 -DA:106,32 -DA:107,32 -DA:108,32 -DA:109,32 -DA:110,32 -DA:111,32 -DA:112,32 -DA:113,32 -DA:114,32 -DA:115,32 -DA:116,32 -DA:117,32 -DA:118,32 -DA:119,32 -DA:124,22 -DA:125,22 -DA:126,22 -DA:129,0 -DA:130,0 -DA:131,0 -DA:134,0 -DA:135,0 -DA:136,0 -DA:139,0 -DA:140,0 -DA:141,0 -DA:169,50 -DA:170,50 -DA:171,50 -DA:172,50 -DA:173,50 -DA:174,50 -DA:175,50 -DA:188,38 -DA:189,38 -DA:190,38 -DA:191,38 -DA:192,38 -DA:193,38 -DA:207,38 -DA:208,38 -DA:209,38 -DA:210,38 -DA:211,38 -DA:212,38 -DA:213,38 -DA:214,38 -DA:228,38 -DA:229,38 -DA:230,38 -DA:231,38 -DA:232,38 -DA:233,38 -DA:234,38 -DA:247,38 -DA:248,38 -DA:249,38 -DA:250,38 -DA:251,38 -DA:252,38 -DA:253,38 -DA:269,42 -DA:270,42 -DA:271,42 -DA:272,42 -DA:273,42 -DA:274,42 -DA:277,38 -DA:278,38 -DA:279,38 -DA:280,38 -DA:281,38 -DA:282,38 -DA:283,38 -DA:284,38 -DA:285,38 -DA:298,34 -DA:299,34 -DA:300,34 -DA:301,34 -DA:302,34 -DA:303,34 -DA:304,34 -DA:316,34 -DA:317,34 -DA:318,34 -DA:319,34 -DA:320,34 -DA:321,34 -DA:333,34 -DA:334,34 -DA:335,34 -DA:336,34 -DA:337,34 -DA:338,34 -DA:351,38 -DA:352,38 -DA:353,38 -DA:354,38 -DA:355,38 -DA:356,38 -DA:357,38 -DA:381,0 -DA:382,0 -DA:383,0 -DA:387,0 -DA:388,0 -DA:389,0 -DA:390,0 -DA:391,0 -DA:393,0 -DA:401,10 -DA:402,10 -DA:403,10 -DA:404,10 -DA:405,10 -DA:406,10 -DA:407,10 -DA:408,10 -DA:409,10 -DA:410,10 -DA:411,10 -DA:412,10 -DA:413,10 -DA:414,10 -DA:415,10 -DA:416,10 -DA:417,10 -DA:418,10 -DA:419,10 -DA:420,10 -DA:421,10 -DA:422,10 -DA:423,10 -DA:424,10 -DA:427,2 -DA:428,2 -DA:431,2 -DA:432,2 -DA:433,2 -DA:434,2 -DA:435,2 -DA:438,2 -DA:439,2 -DA:440,2 -DA:441,2 -DA:442,2 -DA:443,2 -DA:444,2 -DA:445,2 -DA:446,2 -DA:448,2 -DA:449,2 -DA:450,2 -DA:451,2 -DA:454,2 -DA:455,2 -DA:456,2 -DA:457,2 -DA:458,2 -DA:459,2 -DA:460,2 -DA:461,2 -DA:462,2 -DA:463,2 -DA:464,2 -DA:465,2 -DA:466,2 -DA:467,2 -DA:468,2 -DA:469,2 -DA:470,2 -DA:471,2 -DA:472,2 -DA:473,2 -DA:474,2 -DA:475,2 -DA:476,2 -DA:477,2 -DA:478,2 -DA:479,2 -DA:480,2 -DA:481,2 -DA:482,2 -DA:483,2 -DA:484,2 -DA:485,2 -DA:486,2 -DA:487,2 -DA:488,2 -DA:489,2 -DA:490,2 -DA:491,2 -DA:492,2 -DA:493,2 -DA:494,2 -DA:495,2 -DA:496,2 -DA:497,2 -DA:498,2 -DA:501,2 -DA:502,2 -DA:503,2 -DA:504,2 -DA:505,2 -DA:506,2 -DA:509,2 -DA:510,2 -DA:511,2 -DA:512,2 -DA:513,2 -DA:514,2 -DA:517,2 -DA:518,2 -DA:519,2 -DA:520,2 -DA:521,2 -DA:522,2 -DA:525,2 -DA:526,2 -DA:527,2 -DA:528,2 -DA:529,2 -DA:530,2 -DA:531,2 -DA:532,2 -DA:533,2 -DA:534,2 -DA:535,2 -DA:536,2 -DA:537,2 -DA:539,2 -DA:540,2 -DA:541,2 -DA:544,2 -DA:545,2 -DA:546,2 -DA:547,2 -DA:548,2 -DA:549,2 -DA:550,2 -DA:551,2 -DA:552,2 -DA:553,2 -DA:556,2 -DA:557,2 -DA:558,2 -DA:559,2 -DA:560,2 -DA:561,2 -DA:562,2 -DA:565,2 -DA:566,2 -DA:567,2 -DA:568,2 -DA:569,2 -DA:570,2 -DA:571,2 -DA:572,2 -DA:573,2 -DA:574,2 -DA:575,2 -DA:576,2 -DA:577,2 -DA:578,2 -DA:579,2 -DA:580,2 -DA:581,2 -DA:582,2 -DA:583,2 -DA:584,2 -DA:585,2 -DA:586,2 -DA:587,2 -DA:588,2 -DA:589,2 -BRF:0 -BRH:0 -LF:306 -LH:288 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/config/advanced.rs -FN:22,_RNvXNtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB2_16ReshardingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:77,_RNvXs0_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_22ReplicaSelectionConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:102,_RNvXs1_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18QueryPlannerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:126,_RNvXs2_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SettingsBroadcastConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:144,_RNvXs3_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_24SettingsDriftCheckConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:168,_RNvXs4_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_20SessionPinningConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:192,_RNvXs5_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13AliasesConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:218,_RNvXs6_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17AntiEntropyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:247,_RNvXs7_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_16DumpImportConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:271,_RNvXs8_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17IdempotencyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:294,_RNvXs9_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_21QueryCoalescingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:49,_RNvXs_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB4_13HedgingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:318,_RNvXsa_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17MultiSearchConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:344,_RNvXsb_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18VectorSearchConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:370,_RNvXsc_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9CdcConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:397,_RNvXsd_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13CdcSinkConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:422,_RNvXse_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_15CdcBufferConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:453,_RNvXsf_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9TtlConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:482,_RNvXsg_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_20TenantAffinityConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:508,_RNvXsh_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_12ShadowConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:529,_RNvXsi_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18ShadowTargetConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:554,_RNvXsj_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9IlmConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:578,_RNvXsk_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18CanaryRunnerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:609,_RNvXsl_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13AdminUiConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:642,_RNvXsm_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18AdminUiThemeConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:659,_RNvXsn_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_21AdminUiFeaturesConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:681,_RNvXso_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13ExplainConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:714,_RNvXsp_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_14SearchUiConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:748,_RNvXsq_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18SearchUiAuthConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:770,_RNvXsr_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_16OAuthProxyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:796,_RNvXss_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SearchUiRateLimitConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:815,_RNvXst_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SearchUiAnalyticsConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXNtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB2_16ReshardingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs0_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_22ReplicaSelectionConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs1_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18QueryPlannerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs2_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SettingsBroadcastConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs3_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_24SettingsDriftCheckConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs4_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_20SessionPinningConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs5_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13AliasesConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs6_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17AntiEntropyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs7_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_16DumpImportConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs8_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17IdempotencyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs9_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_21QueryCoalescingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXs_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB4_13HedgingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsa_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_17MultiSearchConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsb_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18VectorSearchConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:46,_RNvXsc_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9CdcConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:0,_RNvXsd_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13CdcSinkConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:58,_RNvXse_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_15CdcBufferConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsf_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9TtlConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsg_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_20TenantAffinityConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsh_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_12ShadowConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:0,_RNvXsi_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18ShadowTargetConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsj_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_9IlmConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsk_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18CanaryRunnerConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXsl_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13AdminUiConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:36,_RNvXsm_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18AdminUiThemeConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:36,_RNvXsn_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_21AdminUiFeaturesConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvXso_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_13ExplainConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:46,_RNvXsp_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_14SearchUiConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:48,_RNvXsq_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_18SearchUiAuthConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:50,_RNvXsr_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_16OAuthProxyConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:60,_RNvXss_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SearchUiRateLimitConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:48,_RNvXst_NtNtCs14cwtawSGIg_11miroir_core6config8advancedNtB5_23SearchUiAnalyticsConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNF:32 -FNH:30 -DA:22,34 -DA:23,34 -DA:24,34 -DA:25,34 -DA:26,34 -DA:27,34 -DA:28,34 -DA:29,34 -DA:30,34 -DA:31,34 -DA:49,34 -DA:50,34 -DA:51,34 -DA:52,34 -DA:53,34 -DA:54,34 -DA:55,34 -DA:56,34 -DA:57,34 -DA:77,34 -DA:78,34 -DA:79,34 -DA:80,34 -DA:81,34 -DA:82,34 -DA:83,34 -DA:84,34 -DA:85,34 -DA:86,34 -DA:102,34 -DA:103,34 -DA:104,34 -DA:105,34 -DA:106,34 -DA:107,34 -DA:108,34 -DA:126,34 -DA:127,34 -DA:128,34 -DA:129,34 -DA:130,34 -DA:131,34 -DA:132,34 -DA:133,34 -DA:144,34 -DA:145,34 -DA:146,34 -DA:147,34 -DA:148,34 -DA:149,34 -DA:168,34 -DA:169,34 -DA:170,34 -DA:171,34 -DA:172,34 -DA:173,34 -DA:174,34 -DA:175,34 -DA:176,34 -DA:192,34 -DA:193,34 -DA:194,34 -DA:195,34 -DA:196,34 -DA:197,34 -DA:198,34 -DA:218,34 -DA:219,34 -DA:220,34 -DA:221,34 -DA:222,34 -DA:223,34 -DA:224,34 -DA:225,34 -DA:226,34 -DA:227,34 -DA:228,34 -DA:247,34 -DA:248,34 -DA:249,34 -DA:250,34 -DA:251,34 -DA:252,34 -DA:253,34 -DA:254,34 -DA:255,34 -DA:271,34 -DA:272,34 -DA:273,34 -DA:274,34 -DA:275,34 -DA:276,34 -DA:277,34 -DA:294,34 -DA:295,34 -DA:296,34 -DA:297,34 -DA:298,34 -DA:299,34 -DA:300,34 -DA:301,34 -DA:318,34 -DA:319,34 -DA:320,34 -DA:321,34 -DA:322,34 -DA:323,34 -DA:324,34 -DA:325,34 -DA:344,34 -DA:345,34 -DA:346,34 -DA:347,34 -DA:348,34 -DA:349,34 -DA:350,34 -DA:351,34 -DA:352,34 -DA:370,46 -DA:371,46 -DA:372,46 -DA:373,46 -DA:374,46 -DA:375,46 -DA:376,46 -DA:377,46 -DA:378,46 -DA:397,0 -DA:398,0 -DA:399,0 -DA:400,0 -DA:401,0 -DA:402,0 -DA:403,0 -DA:404,0 -DA:405,0 -DA:406,0 -DA:407,0 -DA:422,58 -DA:423,58 -DA:424,58 -DA:425,58 -DA:426,58 -DA:427,58 -DA:428,58 -DA:429,58 -DA:453,34 -DA:454,34 -DA:455,34 -DA:456,34 -DA:457,34 -DA:458,34 -DA:459,34 -DA:460,34 -DA:461,34 -DA:482,34 -DA:483,34 -DA:484,34 -DA:485,34 -DA:486,34 -DA:487,34 -DA:488,34 -DA:489,34 -DA:490,34 -DA:491,34 -DA:508,34 -DA:509,34 -DA:510,34 -DA:511,34 -DA:512,34 -DA:513,34 -DA:514,34 -DA:515,34 -DA:529,0 -DA:530,0 -DA:531,0 -DA:532,0 -DA:533,0 -DA:534,0 -DA:535,0 -DA:536,0 -DA:537,0 -DA:554,34 -DA:555,34 -DA:556,34 -DA:557,34 -DA:558,34 -DA:559,34 -DA:560,34 -DA:561,34 -DA:578,34 -DA:579,34 -DA:580,34 -DA:581,34 -DA:582,34 -DA:583,34 -DA:584,34 -DA:585,34 -DA:609,34 -DA:610,34 -DA:611,34 -DA:612,34 -DA:613,34 -DA:614,34 -DA:615,34 -DA:616,34 -DA:617,34 -DA:618,34 -DA:619,34 -DA:620,34 -DA:621,34 -DA:622,34 -DA:642,36 -DA:643,36 -DA:644,36 -DA:645,36 -DA:646,36 -DA:647,36 -DA:659,36 -DA:660,36 -DA:661,36 -DA:662,36 -DA:663,36 -DA:664,36 -DA:665,36 -DA:681,34 -DA:682,34 -DA:683,34 -DA:684,34 -DA:685,34 -DA:686,34 -DA:687,34 -DA:714,46 -DA:715,46 -DA:716,46 -DA:717,46 -DA:718,46 -DA:719,46 -DA:720,46 -DA:721,46 -DA:722,46 -DA:723,46 -DA:724,46 -DA:725,46 -DA:726,46 -DA:727,46 -DA:728,46 -DA:729,46 -DA:730,46 -DA:731,46 -DA:732,46 -DA:748,48 -DA:749,48 -DA:750,48 -DA:751,48 -DA:752,48 -DA:753,48 -DA:754,48 -DA:755,48 -DA:756,48 -DA:757,48 -DA:770,50 -DA:771,50 -DA:772,50 -DA:773,50 -DA:774,50 -DA:775,50 -DA:776,50 -DA:777,50 -DA:778,50 -DA:779,50 -DA:780,50 -DA:781,50 -DA:782,50 -DA:796,60 -DA:797,60 -DA:798,60 -DA:799,60 -DA:800,60 -DA:801,60 -DA:802,60 -DA:803,60 -DA:815,48 -DA:816,48 -DA:817,48 -DA:818,48 -DA:819,48 -DA:820,48 -BRF:0 -BRH:0 -LF:288 -LH:268 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/config/load.rs -FN:95,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_25test_from_yaml_with_nodes -FN:78,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_27test_from_yaml_valid_config -FN:151,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_32test_from_yaml_with_all_sections -FN:118,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_33test_from_yaml_invalid_yaml_fails -FN:140,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_46test_from_yaml_validation_fails_on_zero_shards -FN:129,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_49test_from_yaml_validation_fails_on_ha_with_sqlite -FN:25,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load4load -FN:49,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load9load_from -FN:67,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load9from_yaml -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_25test_from_yaml_with_nodes -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_27test_from_yaml_valid_config -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_32test_from_yaml_with_all_sections -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_33test_from_yaml_invalid_yaml_fails -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_46test_from_yaml_validation_fails_on_zero_shards -FNDA:2,_RNvNtNtNtCs14cwtawSGIg_11miroir_core6config4load5testss_49test_from_yaml_validation_fails_on_ha_with_sqlite -FNDA:0,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load4load -FNDA:0,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load9load_from -FNDA:12,_RNvNtNtCs14cwtawSGIg_11miroir_core6config4load9from_yaml -FNF:9 -FNH:7 -DA:25,0 -DA:26,0 -DA:28,0 -DA:30,0 -DA:31,0 -DA:32,0 -DA:33,0 -DA:34,0 -DA:37,0 -DA:38,0 -DA:39,0 -DA:40,0 -DA:43,0 -DA:44,0 -DA:45,0 -DA:46,0 -DA:49,0 -DA:50,0 -DA:52,0 -DA:53,0 -DA:55,0 -DA:56,0 -DA:57,0 -DA:58,0 -DA:61,0 -DA:62,0 -DA:63,0 -DA:64,0 -DA:67,12 -DA:68,12 -DA:69,10 -DA:70,6 -DA:71,12 -DA:78,2 -DA:79,2 -DA:80,2 -DA:81,2 -DA:82,2 -DA:83,2 -DA:84,2 -DA:85,2 -DA:86,2 -DA:87,2 -DA:88,2 -DA:89,2 -DA:90,2 -DA:91,2 -DA:92,2 -DA:95,2 -DA:96,2 -DA:97,2 -DA:98,2 -DA:99,2 -DA:100,2 -DA:101,2 -DA:102,2 -DA:103,2 -DA:104,2 -DA:105,2 -DA:106,2 -DA:107,2 -DA:108,2 -DA:109,2 -DA:110,2 -DA:111,2 -DA:112,2 -DA:113,2 -DA:114,2 -DA:115,2 -DA:118,2 -DA:119,2 -DA:120,2 -DA:121,2 -DA:122,2 -DA:123,2 -DA:124,2 -DA:125,2 -DA:126,2 -DA:129,2 -DA:130,2 -DA:131,2 -DA:132,2 -DA:133,2 -DA:134,2 -DA:135,2 -DA:136,2 -DA:137,2 -DA:140,2 -DA:141,2 -DA:142,2 -DA:143,2 -DA:144,2 -DA:145,2 -DA:146,2 -DA:147,2 -DA:148,2 -DA:151,2 -DA:152,2 -DA:153,2 -DA:154,2 -DA:155,2 -DA:156,2 -DA:157,2 -DA:158,2 -DA:159,2 -DA:160,2 -DA:161,2 -DA:162,2 -DA:163,2 -DA:164,2 -DA:165,2 -DA:166,2 -DA:167,2 -DA:168,2 -DA:169,2 -DA:170,2 -DA:171,2 -DA:172,2 -DA:173,2 -DA:174,2 -DA:175,2 -DA:176,2 -DA:177,2 -DA:178,2 -DA:179,2 -DA:180,2 -DA:181,2 -DA:182,2 -DA:183,2 -DA:184,2 -DA:185,2 -DA:186,2 -DA:187,2 -DA:188,2 -DA:189,2 -DA:190,2 -DA:191,2 -DA:192,2 -DA:193,2 -DA:194,2 -BRF:0 -BRH:0 -LF:140 -LH:112 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/config/validate.rs -FN:3,_RNvNtNtCs14cwtawSGIg_11miroir_core6config8validate8validate -FNDA:22,_RNvNtNtCs14cwtawSGIg_11miroir_core6config8validate8validate -FNF:1 -FNH:1 -DA:3,22 -DA:5,22 -DA:6,4 -DA:7,4 -DA:8,4 -DA:9,18 -DA:12,18 -DA:13,0 -DA:14,0 -DA:15,0 -DA:16,0 -DA:17,18 -DA:20,18 -DA:21,32 -DA:22,16 -DA:23,2 -DA:24,2 -DA:25,2 -DA:26,2 -DA:27,2 -DA:28,2 -DA:29,2 -DA:30,14 -DA:32,0 -DA:35,16 -DA:36,28 -DA:37,14 -DA:38,2 -DA:39,2 -DA:40,2 -DA:41,2 -DA:42,12 -DA:46,14 -DA:47,0 -DA:48,0 -DA:49,0 -DA:50,14 -DA:53,14 -DA:54,14 -DA:55,14 -DA:56,14 -DA:57,2 -DA:58,2 -DA:59,2 -DA:60,12 -DA:61,0 -DA:64,12 -DA:65,2 -DA:66,2 -DA:67,2 -DA:68,10 -DA:71,10 -DA:72,10 -DA:73,6 -DA:75,0 -DA:76,0 -DA:77,0 -DA:78,10 -DA:81,10 -DA:82,0 -DA:83,0 -DA:84,0 -DA:85,10 -DA:88,10 -DA:89,10 -DA:90,0 -DA:91,0 -DA:92,0 -DA:93,0 -DA:94,0 -DA:95,0 -DA:96,0 -DA:97,0 -DA:99,10 -DA:100,0 -DA:101,0 -DA:102,0 -DA:103,0 -DA:104,0 -DA:105,0 -DA:106,0 -DA:107,0 -DA:108,0 -DA:110,0 -DA:113,10 -DA:114,10 -DA:115,0 -DA:116,0 -DA:117,0 -DA:118,0 -DA:119,0 -DA:120,0 -DA:122,0 -DA:125,10 -DA:126,0 -DA:127,0 -DA:128,0 -DA:129,10 -DA:132,10 -DA:133,2 -DA:134,8 -DA:137,8 -DA:138,0 -DA:139,0 -DA:140,0 -DA:141,8 -DA:143,8 -DA:144,22 -BRF:0 -BRH:0 -LF:108 -LH:62 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/merger.rs -FN:242,_RNvMs1_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScore14descending_cmp -FN:364,_RNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facets -FN:70,_RNvNtCs14cwtawSGIg_11miroir_core6merger5merge -FN:121,_RNvXNvNtCs14cwtawSGIg_11miroir_core6merger5mergeNtB2_12AscendingHitNtNtCs1p5UDGgVI4d_4core3cmp10PartialOrd11partial_cmp -FN:321,_RNvXs3_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_10MergerImplNtB5_6Merger5merge -FN:127,_RNvXs_NvNtCs14cwtawSGIg_11miroir_core6merger5mergeNtB4_12AscendingHitNtNtCs1p5UDGgVI4d_4core3cmp3Ord3cmp -FN:434,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5tests10create_hit -FN:442,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5tests21create_shard_response -FN:811,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_30test_offset_exceeds_total_hits -FN:615,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_32test_estimated_total_hits_summed -FN:837,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_32test_tie_breaking_by_primary_key -FN:456,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_global_sort_by_ranking_score -FN:824,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_limit_exceeds_available_hits -FN:540,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_miroir_shard_always_stripped -FN:696,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_34test_not_degraded_when_all_succeed -FN:675,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_35test_degraded_flag_when_shard_fails -FN:955,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_37test_strip_all_miroir_reserved_fields -FN:710,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_empty_shards_returns_empty_result -FN:554,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_facet_counts_summed_across_shards -FN:630,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_processing_time_max_across_shards -FN:901,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_41test_binary_heap_efficiency_large_fan_out -FN:488,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_41test_offset_and_limit_applied_after_merge -FN:525,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_42test_ranking_score_included_when_requested -FN:921,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_43test_offset_limit_pagination_reconstruction -FN:722,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_45test_facet_keys_unique_to_one_shard_preserved -FN:980,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_facet_filter_only_merges_requested_facets -FN:510,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_ranking_score_stripped_when_not_requested -FN:857,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_stable_serialization_same_input_same_json -FN:772,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_50test_missing_facet_distribution_handled_gracefully -FN:399,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facetss_00B7_ -FN:179,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges5_00B7_ -FN:199,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges6_00B7_ -FN:205,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges7_00B7_ -FN:371,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facets0B5_ -FN:396,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facetss_0B5_ -FN:89,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges0_0B5_ -FN:94,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges1_0B5_ -FN:161,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges3_0B5_ -FN:165,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges4_0B5_ -FN:174,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges5_0B5_ -FN:199,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges6_0B5_ -FN:205,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges7_0B5_ -FN:85,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges_0B5_ -FN:332,_RNCNvXs3_NtCs14cwtawSGIg_11miroir_core6mergerNtB7_10MergerImplNtB7_6Merger5merge0B9_ -FN:948,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_43test_offset_limit_pagination_reconstruction0B7_ -FN:227,_RNvXNtCs14cwtawSGIg_11miroir_core6mergerNtB2_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp9PartialEq2eq -FN:235,_RNvXs0_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp10PartialOrd11partial_cmp -FN:255,_RNvXs2_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp3Ord3cmp -FN:413,_RNvXs4_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_10StubMergerNtB5_6Merger5merge -FNDA:936,_RNvMs1_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScore14descending_cmp -FNDA:54,_RNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facets -FNDA:54,_RNvNtCs14cwtawSGIg_11miroir_core6merger5merge -FNDA:968,_RNvXNvNtCs14cwtawSGIg_11miroir_core6merger5mergeNtB2_12AscendingHitNtNtCs1p5UDGgVI4d_4core3cmp10PartialOrd11partial_cmp -FNDA:48,_RNvXs3_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_10MergerImplNtB5_6Merger5merge -FNDA:968,_RNvXs_NvNtCs14cwtawSGIg_11miroir_core6merger5mergeNtB4_12AscendingHitNtNtCs1p5UDGgVI4d_4core3cmp3Ord3cmp -FNDA:348,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5tests10create_hit -FNDA:46,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5tests21create_shard_response -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_30test_offset_exceeds_total_hits -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_32test_estimated_total_hits_summed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_32test_tie_breaking_by_primary_key -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_global_sort_by_ranking_score -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_limit_exceeds_available_hits -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_33test_miroir_shard_always_stripped -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_34test_not_degraded_when_all_succeed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_35test_degraded_flag_when_shard_fails -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_37test_strip_all_miroir_reserved_fields -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_empty_shards_returns_empty_result -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_facet_counts_summed_across_shards -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_38test_processing_time_max_across_shards -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_41test_binary_heap_efficiency_large_fan_out -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_41test_offset_and_limit_applied_after_merge -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_42test_ranking_score_included_when_requested -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_43test_offset_limit_pagination_reconstruction -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_45test_facet_keys_unique_to_one_shard_preserved -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_facet_filter_only_merges_requested_facets -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_ranking_score_stripped_when_not_requested -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_46test_stable_serialization_same_input_same_json -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_50test_missing_facet_distribution_handled_gracefully -FNDA:44,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facetss_00B7_ -FNDA:784,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges5_00B7_ -FNDA:74,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges6_00B7_ -FNDA:74,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges7_00B7_ -FNDA:66,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facets0B5_ -FNDA:18,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger12merge_facetss_0B5_ -FNDA:850,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges0_0B5_ -FNDA:850,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges1_0B5_ -FNDA:500,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges3_0B5_ -FNDA:436,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges4_0B5_ -FNDA:260,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges5_0B5_ -FNDA:74,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges6_0B5_ -FNDA:74,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges7_0B5_ -FNDA:74,_RNCNvNtCs14cwtawSGIg_11miroir_core6merger5merges_0B5_ -FNDA:66,_RNCNvXs3_NtCs14cwtawSGIg_11miroir_core6mergerNtB7_10MergerImplNtB7_6Merger5merge0B9_ -FNDA:100,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6merger5testss_43test_offset_limit_pagination_reconstruction0B7_ -FNDA:0,_RNvXNtCs14cwtawSGIg_11miroir_core6mergerNtB2_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp9PartialEq2eq -FNDA:0,_RNvXs0_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp10PartialOrd11partial_cmp -FNDA:0,_RNvXs2_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_12HitWithScoreNtNtCs1p5UDGgVI4d_4core3cmp3Ord3cmp -FNDA:0,_RNvXs4_NtCs14cwtawSGIg_11miroir_core6mergerNtB5_10StubMergerNtB5_6Merger5merge -FNF:49 -FNH:45 -DA:70,54 -DA:72,54 -DA:73,54 -DA:74,54 -DA:75,54 -DA:76,54 -DA:79,54 -DA:82,54 -DA:84,128 -DA:85,74 -DA:86,924 -DA:87,850 -DA:88,850 -DA:89,850 -DA:90,850 -DA:92,850 -DA:93,850 -DA:94,850 -DA:95,850 -DA:96,850 -DA:98,850 -DA:99,850 -DA:100,850 -DA:101,850 -DA:102,850 -DA:104,0 -DA:108,54 -DA:109,54 -DA:121,968 -DA:122,968 -DA:123,968 -DA:127,968 -DA:129,968 -DA:131,0 -DA:133,968 -DA:135,968 -DA:140,6 -DA:142,406 -DA:143,400 -DA:144,80 -DA:145,80 -DA:147,320 -DA:150,320 -DA:151,180 -DA:152,180 -DA:153,180 -DA:154,180 -DA:155,0 -DA:160,6 -DA:161,500 -DA:162,6 -DA:165,436 -DA:166,48 -DA:170,54 -DA:171,54 -DA:172,54 -DA:173,54 -DA:174,260 -DA:175,260 -DA:178,260 -DA:179,784 -DA:180,0 -DA:183,260 -DA:184,252 -DA:185,252 -DA:186,252 -DA:187,8 -DA:189,260 -DA:190,260 -DA:191,54 -DA:194,54 -DA:197,54 -DA:198,54 -DA:199,74 -DA:200,54 -DA:203,54 -DA:204,54 -DA:205,74 -DA:206,54 -DA:207,54 -DA:209,54 -DA:210,54 -DA:211,54 -DA:212,54 -DA:213,54 -DA:214,54 -DA:215,54 -DA:216,54 -DA:227,0 -DA:228,0 -DA:229,0 -DA:235,0 -DA:236,0 -DA:237,0 -DA:242,936 -DA:244,936 -DA:247,6 -DA:249,930 -DA:251,936 -DA:255,0 -DA:257,0 -DA:261,0 -DA:263,0 -DA:265,0 -DA:321,48 -DA:322,48 -DA:323,48 -DA:324,48 -DA:325,48 -DA:326,48 -DA:327,48 -DA:329,48 -DA:330,48 -DA:331,48 -DA:332,66 -DA:333,66 -DA:334,66 -DA:335,48 -DA:338,48 -DA:339,48 -DA:340,48 -DA:341,48 -DA:342,48 -DA:343,48 -DA:344,48 -DA:346,48 -DA:348,48 -DA:349,48 -DA:350,48 -DA:351,48 -DA:352,48 -DA:353,48 -DA:354,48 -DA:355,48 -DA:364,54 -DA:365,54 -DA:367,128 -DA:368,74 -DA:369,74 -DA:370,74 -DA:371,74 -DA:373,100 -DA:375,34 -DA:376,4 -DA:377,2 -DA:378,2 -DA:379,30 -DA:381,32 -DA:382,32 -DA:384,88 -DA:385,56 -DA:386,56 -DA:387,56 -DA:388,0 -DA:390,8 -DA:394,54 -DA:395,54 -DA:396,54 -DA:397,18 -DA:398,18 -DA:399,44 -DA:400,18 -DA:401,18 -DA:402,18 -DA:403,54 -DA:405,54 -DA:406,54 -DA:413,0 -DA:414,0 -DA:415,0 -DA:416,0 -DA:417,0 -DA:418,0 -DA:419,0 -DA:420,0 -DA:421,0 -DA:422,0 -DA:423,0 -DA:424,0 -DA:425,0 -DA:426,0 -DA:427,0 -DA:434,348 -DA:435,348 -DA:436,348 -DA:437,348 -DA:438,348 -DA:440,348 -DA:442,46 -DA:443,46 -DA:444,46 -DA:445,46 -DA:446,46 -DA:447,46 -DA:448,46 -DA:449,46 -DA:450,46 -DA:451,46 -DA:452,46 -DA:453,46 -DA:456,2 -DA:457,2 -DA:459,2 -DA:460,2 -DA:461,2 -DA:462,2 -DA:464,2 -DA:465,2 -DA:466,2 -DA:467,2 -DA:470,2 -DA:471,2 -DA:472,2 -DA:475,2 -DA:478,2 -DA:479,2 -DA:480,2 -DA:481,2 -DA:482,2 -DA:483,2 -DA:484,2 -DA:485,2 -DA:488,2 -DA:489,2 -DA:491,2 -DA:492,2 -DA:493,2 -DA:494,2 -DA:495,2 -DA:496,2 -DA:499,2 -DA:501,2 -DA:504,2 -DA:505,2 -DA:506,2 -DA:507,2 -DA:510,2 -DA:511,2 -DA:513,2 -DA:515,2 -DA:517,2 -DA:520,2 -DA:521,2 -DA:522,2 -DA:525,2 -DA:526,2 -DA:528,2 -DA:530,2 -DA:532,2 -DA:535,2 -DA:536,2 -DA:537,2 -DA:540,2 -DA:541,2 -DA:543,2 -DA:545,2 -DA:547,2 -DA:550,2 -DA:551,2 -DA:554,2 -DA:555,2 -DA:557,2 -DA:558,2 -DA:559,2 -DA:560,2 -DA:561,2 -DA:562,2 -DA:563,2 -DA:564,2 -DA:566,2 -DA:567,2 -DA:572,2 -DA:573,2 -DA:574,2 -DA:575,2 -DA:576,2 -DA:577,2 -DA:578,2 -DA:579,2 -DA:581,2 -DA:582,2 -DA:583,2 -DA:588,2 -DA:589,2 -DA:590,2 -DA:591,2 -DA:592,2 -DA:593,2 -DA:594,2 -DA:595,2 -DA:596,2 -DA:597,2 -DA:598,2 -DA:601,2 -DA:603,2 -DA:604,2 -DA:605,2 -DA:606,2 -DA:607,2 -DA:609,2 -DA:610,2 -DA:611,2 -DA:612,2 -DA:615,2 -DA:616,2 -DA:618,2 -DA:619,2 -DA:620,2 -DA:621,2 -DA:624,2 -DA:626,2 -DA:627,2 -DA:630,2 -DA:631,2 -DA:633,2 -DA:634,2 -DA:635,2 -DA:636,2 -DA:639,2 -DA:640,2 -DA:641,2 -DA:642,2 -DA:645,2 -DA:646,2 -DA:647,2 -DA:648,2 -DA:651,2 -DA:652,2 -DA:653,2 -DA:654,2 -DA:655,2 -DA:656,2 -DA:657,2 -DA:658,2 -DA:659,2 -DA:660,2 -DA:661,2 -DA:662,2 -DA:663,2 -DA:664,2 -DA:665,2 -DA:666,2 -DA:669,2 -DA:671,2 -DA:672,2 -DA:675,2 -DA:676,2 -DA:678,2 -DA:680,2 -DA:681,2 -DA:682,2 -DA:683,2 -DA:684,2 -DA:685,2 -DA:686,2 -DA:689,2 -DA:691,2 -DA:692,2 -DA:693,2 -DA:696,2 -DA:697,2 -DA:699,2 -DA:700,2 -DA:701,2 -DA:704,2 -DA:706,2 -DA:707,2 -DA:710,2 -DA:711,2 -DA:713,2 -DA:715,2 -DA:716,2 -DA:717,2 -DA:718,2 -DA:719,2 -DA:722,2 -DA:723,2 -DA:725,2 -DA:726,2 -DA:727,2 -DA:728,2 -DA:729,2 -DA:730,2 -DA:731,2 -DA:732,2 -DA:737,2 -DA:738,2 -DA:739,2 -DA:740,2 -DA:741,2 -DA:742,2 -DA:743,2 -DA:744,2 -DA:749,2 -DA:750,2 -DA:751,2 -DA:752,2 -DA:753,2 -DA:754,2 -DA:755,2 -DA:756,2 -DA:757,2 -DA:758,2 -DA:759,2 -DA:762,2 -DA:764,2 -DA:765,2 -DA:766,2 -DA:767,2 -DA:768,2 -DA:769,2 -DA:772,2 -DA:773,2 -DA:775,2 -DA:776,2 -DA:777,2 -DA:778,2 -DA:779,2 -DA:780,2 -DA:784,2 -DA:785,2 -DA:786,2 -DA:787,2 -DA:790,2 -DA:791,2 -DA:792,2 -DA:793,2 -DA:794,2 -DA:795,2 -DA:796,2 -DA:797,2 -DA:798,2 -DA:799,2 -DA:800,2 -DA:803,2 -DA:806,2 -DA:807,2 -DA:808,2 -DA:811,2 -DA:812,2 -DA:814,2 -DA:816,2 -DA:818,2 -DA:820,2 -DA:821,2 -DA:824,2 -DA:825,2 -DA:827,2 -DA:829,2 -DA:831,2 -DA:833,2 -DA:834,2 -DA:837,2 -DA:838,2 -DA:840,2 -DA:841,2 -DA:842,2 -DA:843,2 -DA:846,2 -DA:848,2 -DA:851,2 -DA:852,2 -DA:853,2 -DA:854,2 -DA:857,2 -DA:858,2 -DA:859,2 -DA:860,2 -DA:861,2 -DA:862,2 -DA:863,2 -DA:864,2 -DA:868,2 -DA:869,2 -DA:870,2 -DA:871,2 -DA:872,2 -DA:873,2 -DA:874,2 -DA:878,2 -DA:879,2 -DA:880,2 -DA:881,2 -DA:882,2 -DA:883,2 -DA:884,2 -DA:885,2 -DA:886,2 -DA:887,2 -DA:889,2 -DA:890,2 -DA:893,2 -DA:894,2 -DA:897,2 -DA:898,2 -DA:901,2 -DA:902,2 -DA:905,2 -DA:906,202 -DA:907,200 -DA:908,200 -DA:910,2 -DA:912,2 -DA:915,2 -DA:916,2 -DA:917,2 -DA:918,2 -DA:921,2 -DA:922,2 -DA:925,2 -DA:926,102 -DA:927,100 -DA:928,100 -DA:931,2 -DA:932,2 -DA:935,2 -DA:936,12 -DA:937,10 -DA:938,10 -DA:939,110 -DA:940,100 -DA:941,100 -DA:945,2 -DA:946,2 -DA:947,2 -DA:948,100 -DA:949,2 -DA:951,2 -DA:952,2 -DA:955,2 -DA:956,2 -DA:958,2 -DA:959,2 -DA:960,2 -DA:961,2 -DA:962,2 -DA:963,2 -DA:966,2 -DA:968,2 -DA:971,2 -DA:973,2 -DA:974,2 -DA:976,2 -DA:977,2 -DA:980,2 -DA:981,2 -DA:982,2 -DA:983,2 -DA:986,2 -DA:987,2 -DA:988,2 -DA:989,2 -DA:990,2 -DA:991,2 -DA:992,2 -DA:993,2 -DA:994,2 -DA:995,2 -DA:996,2 -DA:997,2 -DA:998,2 -DA:999,2 -DA:1000,2 -DA:1002,2 -DA:1005,2 -DA:1006,2 -DA:1007,2 -BRF:0 -BRH:0 -LF:582 -LH:551 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/migration.rs -FN:437,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator10is_drained -FN:377,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator13begin_cutover -FN:444,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator14complete_drain -FN:599,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15activate_shards -FN:287,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15begin_migration -FN:279,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15validate_safety -FN:319,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator16begin_dual_write -FN:623,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator16complete_cleanup -FN:414,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator18register_in_flight -FN:647,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator20is_dual_write_active -FN:556,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator20shard_delta_complete -FN:524,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator24collect_delta_candidates -FN:337,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator24shard_migration_complete -FN:268,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator3new -FN:642,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator9get_state -FN:93,_RNvXs1_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_19ShardMigrationStateNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:153,_RNvXs2_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_14MigrationPhaseNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:216,_RNvXs3_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_15MigrationConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:667,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5tests4node -FN:671,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5tests5shard -FN:806,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_24test_dual_write_tracking -FN:772,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_33test_drain_timeout_blocks_cutover -FN:676,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_33test_safe_cutover_with_delta_pass -FN:745,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_46test_skip_delta_pass_allowed_with_anti_entropy -FN:728,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_48test_unsafe_cutover_refused_without_anti_entropy -FN:440,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator10is_drained0B9_ -FN:465,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drain0B9_ -FN:515,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drains0_0B9_ -FN:504,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drains_0B9_ -FN:300,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator15begin_migration0B9_ -FN:648,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20is_dual_write_active0B9_ -FN:566,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20shard_delta_complete0B9_ -FN:588,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20shard_delta_completes_0B9_ -FN:347,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator24shard_migration_complete0B9_ -FN:367,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator24shard_migration_completes_0B9_ -FN:193,_RINvNtNtCs14cwtawSGIg_11miroir_core9migration13instant_serde11deserializepEB6_ -FN:186,_RINvNtNtCs14cwtawSGIg_11miroir_core9migration13instant_serde9serializepEB6_ -FN:428,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator10fail_write -FN:658,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator6config -FN:419,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator9ack_write -FN:39,_RNvXNtCs14cwtawSGIg_11miroir_core9migrationNtB2_11MigrationIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:59,_RNvXs0_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_7ShardIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:49,_RNvXs_NtCs14cwtawSGIg_11miroir_core9migrationNtB4_6NodeIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:6,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator10is_drained -FNDA:6,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator13begin_cutover -FNDA:6,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator14complete_drain -FNDA:4,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15activate_shards -FNDA:10,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15begin_migration -FNDA:10,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator15validate_safety -FNDA:8,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator16begin_dual_write -FNDA:4,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator16complete_cleanup -FNDA:4,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator18register_in_flight -FNDA:6,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator20is_dual_write_active -FNDA:4,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator20shard_delta_complete -FNDA:4,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator24collect_delta_candidates -FNDA:10,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator24shard_migration_complete -FNDA:10,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator3new -FNDA:6,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator9get_state -FNDA:0,_RNvXs1_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_19ShardMigrationStateNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:0,_RNvXs2_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_14MigrationPhaseNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:16,_RNvXs3_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_15MigrationConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:34,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5tests4node -FNDA:36,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5tests5shard -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_24test_dual_write_tracking -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_33test_drain_timeout_blocks_cutover -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_33test_safe_cutover_with_delta_pass -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_46test_skip_delta_pass_allowed_with_anti_entropy -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core9migration5testss_48test_unsafe_cutover_refused_without_anti_entropy -FNDA:4,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator10is_drained0B9_ -FNDA:2,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drain0B9_ -FNDA:2,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drains0_0B9_ -FNDA:2,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator14complete_drains_0B9_ -FNDA:10,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator15begin_migration0B9_ -FNDA:6,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20is_dual_write_active0B9_ -FNDA:0,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20shard_delta_complete0B9_ -FNDA:7,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator20shard_delta_completes_0B9_ -FNDA:0,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator24shard_migration_complete0B9_ -FNDA:13,_RNCNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB7_20MigrationCoordinator24shard_migration_completes_0B9_ -FNDA:0,_RINvNtNtCs14cwtawSGIg_11miroir_core9migration13instant_serde11deserializepEB6_ -FNDA:0,_RINvNtNtCs14cwtawSGIg_11miroir_core9migration13instant_serde9serializepEB6_ -FNDA:0,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator10fail_write -FNDA:0,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator6config -FNDA:0,_RNvMs4_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_20MigrationCoordinator9ack_write -FNDA:0,_RNvXNtCs14cwtawSGIg_11miroir_core9migrationNtB2_11MigrationIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:0,_RNvXs0_NtCs14cwtawSGIg_11miroir_core9migrationNtB5_7ShardIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:0,_RNvXs_NtCs14cwtawSGIg_11miroir_core9migrationNtB4_6NodeIdNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNF:43 -FNH:31 -DA:39,0 -DA:40,0 -DA:41,0 -DA:49,0 -DA:50,0 -DA:51,0 -DA:59,0 -DA:60,0 -DA:61,0 -DA:93,0 -DA:94,0 -DA:95,0 -DA:97,0 -DA:98,0 -DA:100,0 -DA:101,0 -DA:102,0 -DA:105,0 -DA:106,0 -DA:109,0 -DA:110,0 -DA:112,0 -DA:113,0 -DA:114,0 -DA:118,0 -DA:119,0 -DA:121,0 -DA:123,0 -DA:124,0 -DA:126,0 -DA:153,0 -DA:154,0 -DA:155,0 -DA:156,0 -DA:157,0 -DA:158,0 -DA:159,0 -DA:160,0 -DA:161,0 -DA:162,0 -DA:163,0 -DA:165,0 -DA:186,0 -DA:187,0 -DA:188,0 -DA:190,0 -DA:191,0 -DA:193,0 -DA:194,0 -DA:195,0 -DA:197,0 -DA:198,0 -DA:199,0 -DA:216,16 -DA:217,16 -DA:218,16 -DA:219,16 -DA:220,16 -DA:221,16 -DA:222,16 -DA:268,10 -DA:269,10 -DA:270,10 -DA:271,10 -DA:272,10 -DA:273,10 -DA:274,10 -DA:275,10 -DA:279,10 -DA:280,10 -DA:281,2 -DA:282,8 -DA:283,8 -DA:284,10 -DA:287,10 -DA:288,10 -DA:289,10 -DA:290,10 -DA:291,10 -DA:292,10 -DA:293,10 -DA:295,8 -DA:296,8 -DA:298,8 -DA:299,8 -DA:300,10 -DA:301,8 -DA:303,8 -DA:304,8 -DA:305,8 -DA:306,8 -DA:307,8 -DA:308,8 -DA:309,8 -DA:310,8 -DA:311,8 -DA:312,8 -DA:314,8 -DA:315,8 -DA:316,10 -DA:319,8 -DA:320,8 -DA:321,8 -DA:322,8 -DA:323,8 -DA:324,8 -DA:325,10 -DA:326,10 -DA:327,10 -DA:328,10 -DA:329,10 -DA:330,10 -DA:331,10 -DA:333,8 -DA:334,8 -DA:337,10 -DA:338,10 -DA:339,10 -DA:340,10 -DA:341,10 -DA:342,10 -DA:343,10 -DA:344,10 -DA:345,10 -DA:346,10 -DA:347,10 -DA:348,0 -DA:349,0 -DA:351,10 -DA:352,10 -DA:353,10 -DA:354,10 -DA:356,0 -DA:357,0 -DA:358,0 -DA:359,0 -DA:364,10 -DA:365,10 -DA:366,10 -DA:367,13 -DA:369,10 -DA:370,8 -DA:371,8 -DA:373,10 -DA:374,10 -DA:377,6 -DA:378,6 -DA:379,6 -DA:380,6 -DA:381,6 -DA:383,6 -DA:384,0 -DA:385,0 -DA:386,0 -DA:387,0 -DA:388,6 -DA:391,6 -DA:392,8 -DA:393,8 -DA:394,8 -DA:395,8 -DA:396,8 -DA:397,8 -DA:398,8 -DA:399,8 -DA:401,0 -DA:402,0 -DA:403,0 -DA:404,0 -DA:409,6 -DA:410,6 -DA:411,6 -DA:414,4 -DA:415,4 -DA:416,4 -DA:419,0 -DA:420,0 -DA:421,0 -DA:422,0 -DA:423,0 -DA:425,0 -DA:428,0 -DA:429,0 -DA:430,0 -DA:431,0 -DA:432,0 -DA:434,0 -DA:437,6 -DA:438,6 -DA:439,6 -DA:440,6 -DA:441,6 -DA:444,6 -DA:446,6 -DA:447,6 -DA:448,6 -DA:449,6 -DA:451,6 -DA:453,6 -DA:454,0 -DA:455,0 -DA:456,0 -DA:457,0 -DA:458,6 -DA:461,6 -DA:462,2 -DA:463,2 -DA:464,2 -DA:465,2 -DA:466,2 -DA:467,2 -DA:468,4 -DA:471,4 -DA:472,4 -DA:475,4 -DA:476,4 -DA:477,4 -DA:478,4 -DA:480,4 -DA:481,2 -DA:482,2 -DA:483,2 -DA:484,0 -DA:485,0 -DA:486,2 -DA:487,4 -DA:488,4 -DA:489,4 -DA:490,4 -DA:491,4 -DA:492,4 -DA:493,4 -DA:498,4 -DA:499,4 -DA:500,4 -DA:501,4 -DA:502,4 -DA:503,4 -DA:504,4 -DA:507,4 -DA:508,4 -DA:509,2 -DA:510,2 -DA:512,2 -DA:513,2 -DA:514,2 -DA:515,2 -DA:516,2 -DA:517,2 -DA:519,2 -DA:520,6 -DA:524,4 -DA:525,4 -DA:526,4 -DA:527,4 -DA:528,4 -DA:529,4 -DA:530,4 -DA:531,4 -DA:532,4 -DA:534,6 -DA:535,2 -DA:536,2 -DA:537,0 -DA:540,2 -DA:541,2 -DA:544,2 -DA:545,2 -DA:546,2 -DA:547,2 -DA:548,2 -DA:549,2 -DA:552,4 -DA:553,4 -DA:556,4 -DA:557,4 -DA:558,4 -DA:559,4 -DA:560,4 -DA:561,4 -DA:562,4 -DA:563,4 -DA:564,4 -DA:565,4 -DA:566,4 -DA:567,0 -DA:568,0 -DA:570,4 -DA:571,4 -DA:572,4 -DA:573,4 -DA:574,4 -DA:575,4 -DA:577,0 -DA:578,0 -DA:579,0 -DA:580,0 -DA:585,4 -DA:586,4 -DA:587,4 -DA:588,7 -DA:590,4 -DA:591,2 -DA:592,2 -DA:593,2 -DA:595,4 -DA:596,4 -DA:599,4 -DA:600,4 -DA:601,4 -DA:602,4 -DA:603,4 -DA:605,6 -DA:606,6 -DA:608,6 -DA:609,6 -DA:610,6 -DA:611,0 -DA:615,4 -DA:616,4 -DA:617,4 -DA:619,4 -DA:620,4 -DA:623,4 -DA:624,4 -DA:625,4 -DA:626,4 -DA:627,4 -DA:629,4 -DA:630,0 -DA:631,0 -DA:632,0 -DA:633,0 -DA:634,4 -DA:636,4 -DA:637,4 -DA:638,4 -DA:639,4 -DA:642,6 -DA:643,6 -DA:644,6 -DA:647,6 -DA:648,6 -DA:649,6 -DA:650,2 -DA:651,4 -DA:654,6 -DA:655,6 -DA:658,0 -DA:659,0 -DA:660,0 -DA:667,34 -DA:668,34 -DA:669,34 -DA:671,36 -DA:672,36 -DA:673,36 -DA:676,2 -DA:677,2 -DA:678,2 -DA:679,2 -DA:680,2 -DA:681,2 -DA:682,2 -DA:684,2 -DA:686,2 -DA:687,2 -DA:690,2 -DA:691,2 -DA:696,2 -DA:697,2 -DA:698,2 -DA:699,2 -DA:700,2 -DA:701,2 -DA:702,2 -DA:703,2 -DA:706,2 -DA:710,2 -DA:711,2 -DA:714,2 -DA:716,2 -DA:719,2 -DA:720,2 -DA:722,2 -DA:723,2 -DA:724,2 -DA:725,2 -DA:728,2 -DA:729,2 -DA:730,2 -DA:731,2 -DA:732,2 -DA:733,2 -DA:734,2 -DA:736,2 -DA:737,2 -DA:739,2 -DA:740,2 -DA:741,2 -DA:742,2 -DA:745,2 -DA:746,2 -DA:747,2 -DA:748,2 -DA:749,2 -DA:750,2 -DA:751,2 -DA:753,2 -DA:754,2 -DA:755,2 -DA:756,2 -DA:758,2 -DA:761,2 -DA:762,2 -DA:764,2 -DA:765,2 -DA:766,2 -DA:769,2 -DA:772,2 -DA:773,2 -DA:774,2 -DA:775,2 -DA:776,2 -DA:777,2 -DA:778,2 -DA:780,2 -DA:781,2 -DA:782,2 -DA:783,2 -DA:784,2 -DA:787,2 -DA:788,2 -DA:789,2 -DA:790,2 -DA:791,2 -DA:792,2 -DA:793,2 -DA:794,2 -DA:797,2 -DA:798,2 -DA:799,2 -DA:800,2 -DA:803,2 -DA:806,2 -DA:807,2 -DA:808,2 -DA:810,2 -DA:811,2 -DA:812,2 -DA:815,2 -DA:817,2 -DA:820,2 -DA:821,2 -DA:822,2 -BRF:0 -BRH:0 -LF:467 -LH:363 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/reshard.rs -FN:39,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow5parse -FN:62,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow8contains -FN:49,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow8parse_hm -FN:138,_RNvNtCs14cwtawSGIg_11miroir_core7reshard12check_window -FN:102,_RNvNtCs14cwtawSGIg_11miroir_core7reshard20default_retain_hours -FN:96,_RNvNtCs14cwtawSGIg_11miroir_core7reshard27default_backfill_batch_size -FN:93,_RNvNtCs14cwtawSGIg_11miroir_core7reshard28default_backfill_concurrency -FN:398,_RNvNtCs14cwtawSGIg_11miroir_core7reshard2cv -FN:255,_RNvNtCs14cwtawSGIg_11miroir_core7reshard8simulate -FN:107,_RNvXs0_NtCs14cwtawSGIg_11miroir_core7reshardNtB5_16ReshardingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FN:493,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_19window_guard_denied -FN:483,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_20window_guard_allowed -FN:426,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_24time_window_parse_simple -FN:448,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_25time_window_contains_wrap -FN:456,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_26time_window_boundary_start -FN:468,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_26time_window_invalid_format -FN:550,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27simulation_dual_write_is_2x -FN:440,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27time_window_contains_normal -FN:477,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27window_guard_no_restriction -FN:528,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_28simulation_storage_always_2x -FN:503,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_29window_guard_multiple_windows -FN:433,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_31time_window_parse_wrap_midnight -FN:571,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_32simulation_low_cv_with_many_docs -FN:462,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_34time_window_boundary_end_exclusive -FN:42,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow5parse0B8_ -FN:52,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hm0B8_ -FN:54,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hms0_0B8_ -FN:53,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hms_0B8_ -FN:409,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard2cv0B5_ -FN:263,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulate0B5_ -FN:304,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulates0_0B5_ -FN:299,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulates_0B5_ -FN:99,_RNvNtCs14cwtawSGIg_11miroir_core7reshard12default_true -FN:162,_RNvNtCs14cwtawSGIg_11miroir_core7reshard16check_window_now -FN:167,_RNvNtCs14cwtawSGIg_11miroir_core7reshard18current_utc_minute -FN:25,_RNvXNtCs14cwtawSGIg_11miroir_core7reshardNtB2_10TimeWindowNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:32,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow5parse -FNDA:30,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow8contains -FNDA:58,_RNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB4_10TimeWindow8parse_hm -FNDA:12,_RNvNtCs14cwtawSGIg_11miroir_core7reshard12check_window -FNDA:8,_RNvNtCs14cwtawSGIg_11miroir_core7reshard20default_retain_hours -FNDA:8,_RNvNtCs14cwtawSGIg_11miroir_core7reshard27default_backfill_batch_size -FNDA:8,_RNvNtCs14cwtawSGIg_11miroir_core7reshard28default_backfill_concurrency -FNDA:12,_RNvNtCs14cwtawSGIg_11miroir_core7reshard2cv -FNDA:6,_RNvNtCs14cwtawSGIg_11miroir_core7reshard8simulate -FNDA:8,_RNvXs0_NtCs14cwtawSGIg_11miroir_core7reshardNtB5_16ReshardingConfigNtNtCs1p5UDGgVI4d_4core7default7Default7default -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_19window_guard_denied -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_20window_guard_allowed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_24time_window_parse_simple -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_25time_window_contains_wrap -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_26time_window_boundary_start -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_26time_window_invalid_format -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27simulation_dual_write_is_2x -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27time_window_contains_normal -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_27window_guard_no_restriction -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_28simulation_storage_always_2x -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_29window_guard_multiple_windows -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_31time_window_parse_wrap_midnight -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_32simulation_low_cv_with_many_docs -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7reshard5testss_34time_window_boundary_end_exclusive -FNDA:0,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow5parse0B8_ -FNDA:2,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hm0B8_ -FNDA:0,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hms0_0B8_ -FNDA:0,_RNCNvMs_NtCs14cwtawSGIg_11miroir_core7reshardNtB6_10TimeWindow8parse_hms_0B8_ -FNDA:640,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard2cv0B5_ -FNDA:10,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulate0B5_ -FNDA:94499640,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulates0_0B5_ -FNDA:91508226,_RNCNvNtCs14cwtawSGIg_11miroir_core7reshard8simulates_0B5_ -FNDA:0,_RNvNtCs14cwtawSGIg_11miroir_core7reshard12default_true -FNDA:0,_RNvNtCs14cwtawSGIg_11miroir_core7reshard16check_window_now -FNDA:0,_RNvNtCs14cwtawSGIg_11miroir_core7reshard18current_utc_minute -FNDA:0,_RNvXNtCs14cwtawSGIg_11miroir_core7reshardNtB2_10TimeWindowNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNF:36 -FNH:29 -DA:25,0 -DA:26,0 -DA:27,0 -DA:28,0 -DA:29,0 -DA:30,0 -DA:31,0 -DA:32,0 -DA:34,0 -DA:39,32 -DA:40,32 -DA:41,32 -DA:42,32 -DA:44,32 -DA:45,26 -DA:47,32 -DA:49,58 -DA:50,58 -DA:51,58 -DA:52,58 -DA:53,56 -DA:54,56 -DA:55,56 -DA:56,4 -DA:57,52 -DA:58,52 -DA:59,58 -DA:62,30 -DA:63,30 -DA:64,24 -DA:67,6 -DA:69,30 -DA:93,8 -DA:94,8 -DA:95,8 -DA:96,8 -DA:97,8 -DA:98,8 -DA:99,0 -DA:100,0 -DA:101,0 -DA:102,8 -DA:103,8 -DA:104,8 -DA:107,8 -DA:108,8 -DA:109,8 -DA:110,8 -DA:111,8 -DA:112,8 -DA:113,8 -DA:114,8 -DA:115,8 -DA:116,8 -DA:117,8 -DA:138,12 -DA:139,12 -DA:140,2 -DA:141,10 -DA:143,18 -DA:144,14 -DA:145,14 -DA:146,0 -DA:148,14 -DA:149,6 -DA:150,6 -DA:151,6 -DA:152,8 -DA:155,4 -DA:156,4 -DA:157,4 -DA:158,4 -DA:159,12 -DA:162,0 -DA:163,0 -DA:164,0 -DA:165,0 -DA:167,0 -DA:168,0 -DA:169,0 -DA:170,0 -DA:171,0 -DA:172,0 -DA:255,6 -DA:256,6 -DA:257,6 -DA:258,6 -DA:259,6 -DA:262,6 -DA:263,10 -DA:264,10 -DA:265,32 -DA:266,32 -DA:267,32 -DA:268,10 -DA:269,10 -DA:270,6 -DA:274,6 -DA:275,6 -DA:280,6 -DA:281,6 -DA:282,6 -DA:284,22973568 -DA:285,22973568 -DA:286,22973568 -DA:287,22973568 -DA:289,22973568 -DA:290,22973568 -DA:293,43947136 -DA:294,43947136 -DA:295,43947136 -DA:297,87894272 -DA:298,43947136 -DA:299,91508226 -DA:300,43947136 -DA:302,87894272 -DA:303,43947136 -DA:304,94499640 -DA:305,43947136 -DA:311,6 -DA:312,6 -DA:315,6 -DA:317,6 -DA:320,6 -DA:321,6 -DA:325,6 -DA:327,6 -DA:330,6 -DA:331,6 -DA:333,6 -DA:334,6 -DA:337,6 -DA:338,6 -DA:340,0 -DA:347,6 -DA:348,6 -DA:349,6 -DA:350,6 -DA:351,6 -DA:353,0 -DA:356,6 -DA:358,6 -DA:359,6 -DA:360,6 -DA:361,6 -DA:362,6 -DA:363,6 -DA:364,6 -DA:365,6 -DA:366,6 -DA:367,6 -DA:368,6 -DA:369,6 -DA:370,6 -DA:371,6 -DA:372,6 -DA:373,6 -DA:374,6 -DA:375,6 -DA:376,6 -DA:377,6 -DA:378,6 -DA:379,6 -DA:380,6 -DA:381,6 -DA:382,6 -DA:383,6 -DA:384,6 -DA:385,6 -DA:386,6 -DA:387,6 -DA:388,6 -DA:389,6 -DA:390,6 -DA:391,6 -DA:392,6 -DA:393,6 -DA:394,6 -DA:395,6 -DA:398,12 -DA:399,12 -DA:400,0 -DA:401,12 -DA:402,12 -DA:403,12 -DA:404,12 -DA:405,0 -DA:406,12 -DA:407,12 -DA:408,12 -DA:409,640 -DA:410,12 -DA:411,12 -DA:412,12 -DA:413,12 -DA:426,2 -DA:427,2 -DA:428,2 -DA:429,2 -DA:430,2 -DA:433,2 -DA:434,2 -DA:435,2 -DA:436,2 -DA:437,2 -DA:440,2 -DA:441,2 -DA:442,2 -DA:443,2 -DA:444,2 -DA:445,2 -DA:448,2 -DA:449,2 -DA:450,2 -DA:451,2 -DA:452,2 -DA:453,2 -DA:456,2 -DA:457,2 -DA:458,2 -DA:459,2 -DA:462,2 -DA:463,2 -DA:464,2 -DA:465,2 -DA:468,2 -DA:469,2 -DA:470,2 -DA:471,2 -DA:472,2 -DA:477,2 -DA:478,2 -DA:479,2 -DA:480,2 -DA:483,2 -DA:484,2 -DA:485,2 -DA:486,2 -DA:487,2 -DA:488,2 -DA:489,2 -DA:490,2 -DA:493,2 -DA:494,2 -DA:495,2 -DA:496,2 -DA:497,2 -DA:498,2 -DA:499,2 -DA:500,2 -DA:503,2 -DA:504,2 -DA:505,2 -DA:506,2 -DA:507,2 -DA:509,2 -DA:510,2 -DA:514,2 -DA:515,2 -DA:519,2 -DA:520,2 -DA:523,2 -DA:528,2 -DA:530,2 -DA:531,2 -DA:532,2 -DA:533,2 -DA:534,2 -DA:535,2 -DA:536,2 -DA:537,2 -DA:538,2 -DA:539,2 -DA:540,2 -DA:541,2 -DA:542,2 -DA:543,2 -DA:544,0 -DA:547,2 -DA:550,2 -DA:551,2 -DA:552,2 -DA:553,2 -DA:554,2 -DA:555,2 -DA:556,2 -DA:557,2 -DA:558,2 -DA:559,2 -DA:560,2 -DA:561,2 -DA:562,2 -DA:563,2 -DA:564,2 -DA:565,0 -DA:568,2 -DA:571,2 -DA:573,2 -DA:574,2 -DA:575,2 -DA:576,2 -DA:577,2 -DA:578,2 -DA:579,2 -DA:580,2 -DA:581,2 -DA:582,2 -DA:583,2 -DA:584,2 -DA:585,2 -DA:586,2 -DA:587,0 -DA:590,2 -DA:591,2 -DA:592,0 -DA:595,2 -BRF:0 -BRH:0 -LF:324 -LH:290 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/router.rs -FN:34,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups_00B7_ -FN:63,_RNCNvNtCs14cwtawSGIg_11miroir_core6router12covering_set0B5_ -FN:47,_RNCNvNtCs14cwtawSGIg_11miroir_core6router13write_targets0B5_ -FN:30,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_group0B5_ -FN:39,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups0_0B5_ -FN:32,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups_0B5_ -FN:100,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_20test_score_stability -FN:108,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_score_uniqueness -FN:193,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stability -FN:240,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_24test_write_targets_count -FN:85,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_27test_rendezvous_determinism -FN:484,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment -FN:452,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_empty_nodes -FN:417,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_respects_rf -FN:282,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_query_group_distribution -FN:380,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_30test_shard_for_key_determinism -FN:736,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31acceptance_tie_breaking_node_id -FN:307,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_covering_set_one_per_shard -FN:120,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_add -FN:393,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_shard_for_key_distribution -FN:540,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_determinism_1000_runs -FN:713,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_shard_for_key_fixture -FN:154,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32test_shard_distribution_64_3_rf1 -FN:563,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_add -FN:464,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33test_write_targets_empty_topology -FN:673,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stability -FN:340,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34test_covering_set_replica_rotation -FN:476,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_35test_shard_for_key_zero_shard_count -FN:597,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_remove -FN:436,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_38test_assign_shard_rf_larger_than_nodes -FN:758,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_40acceptance_canonical_concatenation_order -FN:638,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_43acceptance_uniformity_64_shards_3_nodes_rf1 -FN:52,_RNvNtCs14cwtawSGIg_11miroir_core6router11query_group -FN:61,_RNvNtCs14cwtawSGIg_11miroir_core6router12covering_set -FN:72,_RNvNtCs14cwtawSGIg_11miroir_core6router13shard_for_key -FN:44,_RNvNtCs14cwtawSGIg_11miroir_core6router13write_targets -FN:27,_RNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_group -FN:14,_RNvNtCs14cwtawSGIg_11miroir_core6router5score -FN:522,_RNCNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment00B9_ -FN:528,_RNCNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignments_00B9_ -FN:196,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stability0B7_ -FN:200,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stabilitys_0B7_ -FN:269,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_24test_write_targets_count0B7_ -FN:88,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_27test_rendezvous_determinism0B7_ -FN:519,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment0B7_ -FN:525,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignments_0B7_ -FN:420,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_respects_rf0B7_ -FN:740,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31acceptance_tie_breaking_node_id0B7_ -FN:123,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_add0B7_ -FN:127,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_adds_0B7_ -FN:543,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_determinism_1000_runs0B7_ -FN:157,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32test_shard_distribution_64_3_rf10B7_ -FN:566,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_add0B7_ -FN:570,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_adds_0B7_ -FN:676,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stability0B7_ -FN:680,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stabilitys_0B7_ -FN:600,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_remove0B7_ -FN:604,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_removes_0B7_ -FN:439,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_38test_assign_shard_rf_larger_than_nodes0B7_ -FN:641,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_43acceptance_uniformity_64_shards_3_nodes_rf10B7_ -FNDA:0,_RNCNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups_00B7_ -FNDA:168,_RNCNvNtCs14cwtawSGIg_11miroir_core6router12covering_set0B5_ -FNDA:10,_RNCNvNtCs14cwtawSGIg_11miroir_core6router13write_targets0B5_ -FNDA:267705932,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_group0B5_ -FNDA:87905408,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups0_0B5_ -FNDA:244430928,_RNCNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_groups_0B5_ -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_20test_score_stability -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_score_uniqueness -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stability -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_24test_write_targets_count -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_27test_rendezvous_determinism -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_empty_nodes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_respects_rf -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_query_group_distribution -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_30test_shard_for_key_determinism -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31acceptance_tie_breaking_node_id -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_covering_set_one_per_shard -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_add -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_shard_for_key_distribution -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_determinism_1000_runs -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_shard_for_key_fixture -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32test_shard_distribution_64_3_rf1 -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_add -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33test_write_targets_empty_topology -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stability -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34test_covering_set_replica_rotation -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_35test_shard_for_key_zero_shard_count -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_remove -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_38test_assign_shard_rf_larger_than_nodes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_40acceptance_canonical_concatenation_order -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_43acceptance_uniformity_64_shards_3_nodes_rf1 -FNDA:2000,_RNvNtCs14cwtawSGIg_11miroir_core6router11query_group -FNDA:6,_RNvNtCs14cwtawSGIg_11miroir_core6router12covering_set -FNDA:45949152,_RNvNtCs14cwtawSGIg_11miroir_core6router13shard_for_key -FNDA:6,_RNvNtCs14cwtawSGIg_11miroir_core6router13write_targets -FNDA:87900296,_RNvNtCs14cwtawSGIg_11miroir_core6router21assign_shard_in_group -FNDA:267705944,_RNvNtCs14cwtawSGIg_11miroir_core6router5score -FNDA:2,_RNCNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment00B9_ -FNDA:4,_RNCNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignments_00B9_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stability0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_21test_top_rf_stabilitys_0B7_ -FNDA:36,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_24test_write_targets_count0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_27test_rendezvous_determinism0B7_ -FNDA:2,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignment0B7_ -FNDA:4,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_28test_group_scoped_assignments_0B7_ -FNDA:10,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_29test_assign_shard_respects_rf0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31acceptance_tie_breaking_node_id0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_add0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_31test_minimal_reshuffling_on_adds_0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32acceptance_determinism_1000_runs0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_32test_shard_distribution_64_3_rf10B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_add0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_33acceptance_reshuffle_bound_on_adds_0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stability0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_34acceptance_rf2_placement_stabilitys_0B7_ -FNDA:8,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_remove0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_36acceptance_reshuffle_bound_on_removes_0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_38test_assign_shard_rf_larger_than_nodes0B7_ -FNDA:6,_RNCNvNtNtCs14cwtawSGIg_11miroir_core6router5testss_43acceptance_uniformity_64_shards_3_nodes_rf10B7_ -FNF:60 -FNH:59 -DA:14,267705944 -DA:15,267705944 -DA:16,267705944 -DA:17,267705944 -DA:18,267705944 -DA:19,267705944 -DA:27,87900296 -DA:28,87900296 -DA:29,87900296 -DA:30,267705932 -DA:31,87900296 -DA:32,244430928 -DA:33,244430928 -DA:34,244430928 -DA:35,244430928 -DA:36,87900296 -DA:37,87900296 -DA:38,87900296 -DA:39,87905408 -DA:40,87900296 -DA:41,87900296 -DA:44,6 -DA:45,6 -DA:46,6 -DA:47,10 -DA:48,6 -DA:49,6 -DA:52,2000 -DA:53,2000 -DA:54,2000 -DA:61,6 -DA:62,6 -DA:63,168 -DA:64,168 -DA:66,168 -DA:67,168 -DA:68,6 -DA:69,6 -DA:72,45949152 -DA:73,45949152 -DA:74,45949152 -DA:75,45949152 -DA:76,45949152 -DA:85,2 -DA:86,2 -DA:87,2 -DA:88,6 -DA:89,2 -DA:90,2 -DA:92,2 -DA:93,2 -DA:95,2 -DA:96,2 -DA:100,2 -DA:101,2 -DA:102,2 -DA:103,2 -DA:104,2 -DA:108,2 -DA:109,2 -DA:110,2 -DA:111,2 -DA:113,2 -DA:114,2 -DA:115,2 -DA:116,2 -DA:120,2 -DA:121,2 -DA:122,2 -DA:123,6 -DA:124,2 -DA:125,2 -DA:126,2 -DA:127,8 -DA:128,2 -DA:130,2 -DA:131,2 -DA:133,2 -DA:134,200 -DA:135,200 -DA:136,200 -DA:139,200 -DA:140,46 -DA:141,154 -DA:145,2 -DA:146,2 -DA:147,2 -DA:148,0 -DA:150,2 -DA:154,2 -DA:155,2 -DA:156,2 -DA:157,6 -DA:158,2 -DA:159,2 -DA:160,2 -DA:162,2 -DA:163,2 -DA:165,128 -DA:166,128 -DA:167,128 -DA:168,128 -DA:169,128 -DA:170,128 -DA:171,128 -DA:175,2 -DA:179,8 -DA:180,6 -DA:181,6 -DA:182,0 -DA:187,2 -DA:188,2 -DA:189,2 -DA:193,2 -DA:194,2 -DA:195,2 -DA:196,6 -DA:197,2 -DA:198,2 -DA:199,2 -DA:200,8 -DA:201,2 -DA:202,2 -DA:203,2 -DA:205,2 -DA:206,200 -DA:207,200 -DA:208,200 -DA:211,200 -DA:212,200 -DA:215,200 -DA:216,200 -DA:217,98 -DA:218,102 -DA:224,2 -DA:225,2 -DA:226,2 -DA:227,0 -DA:231,2 -DA:232,2 -DA:233,2 -DA:234,0 -DA:236,2 -DA:240,2 -DA:241,2 -DA:244,8 -DA:245,18 -DA:246,12 -DA:247,12 -DA:248,12 -DA:249,12 -DA:250,12 -DA:251,12 -DA:252,12 -DA:255,2 -DA:256,2 -DA:259,2 -DA:262,2 -DA:263,2 -DA:266,6 -DA:267,6 -DA:268,6 -DA:269,36 -DA:270,6 -DA:271,6 -DA:272,6 -DA:273,6 -DA:274,0 -DA:278,2 -DA:282,2 -DA:283,2 -DA:284,2 -DA:286,2 -DA:287,2000 -DA:288,2000 -DA:289,2000 -DA:290,2000 -DA:293,2 -DA:294,8 -DA:295,6 -DA:296,6 -DA:297,0 -DA:299,0 -DA:300,0 -DA:303,2 -DA:307,2 -DA:308,2 -DA:309,2 -DA:310,2 -DA:313,10 -DA:314,10 -DA:315,10 -DA:316,10 -DA:317,10 -DA:318,10 -DA:319,10 -DA:320,10 -DA:322,2 -DA:323,2 -DA:324,2 -DA:325,2 -DA:327,2 -DA:330,2 -DA:333,130 -DA:334,128 -DA:336,2 -DA:340,2 -DA:341,2 -DA:342,2 -DA:345,8 -DA:346,6 -DA:347,6 -DA:348,6 -DA:349,6 -DA:350,6 -DA:351,6 -DA:352,6 -DA:354,2 -DA:355,2 -DA:356,2 -DA:358,2 -DA:359,2 -DA:364,2 -DA:365,20 -DA:366,20 -DA:367,20 -DA:368,20 -DA:372,2 -DA:373,2 -DA:374,0 -DA:376,2 -DA:380,2 -DA:381,2 -DA:382,2 -DA:384,2 -DA:385,2 -DA:387,2 -DA:388,2 -DA:389,2 -DA:393,2 -DA:394,2 -DA:395,2 -DA:397,2 -DA:398,2000 -DA:399,2000 -DA:400,2000 -DA:401,2000 -DA:402,2000 -DA:405,2 -DA:406,130 -DA:408,128 -DA:409,128 -DA:410,0 -DA:413,2 -DA:417,2 -DA:418,2 -DA:419,2 -DA:420,10 -DA:421,2 -DA:422,2 -DA:424,12 -DA:425,10 -DA:426,10 -DA:427,10 -DA:429,0 -DA:432,2 -DA:436,2 -DA:437,2 -DA:438,2 -DA:439,6 -DA:440,2 -DA:441,2 -DA:442,2 -DA:444,2 -DA:447,2 -DA:448,2 -DA:452,2 -DA:453,2 -DA:454,2 -DA:455,2 -DA:457,2 -DA:459,2 -DA:460,2 -DA:464,2 -DA:465,2 -DA:466,2 -DA:468,2 -DA:470,2 -DA:471,2 -DA:476,2 -DA:479,2 -DA:480,2 -DA:484,2 -DA:486,2 -DA:487,2 -DA:490,2 -DA:491,2 -DA:492,2 -DA:495,2 -DA:496,2 -DA:497,2 -DA:502,2 -DA:503,2 -DA:504,2 -DA:507,2 -DA:508,2 -DA:509,2 -DA:513,2 -DA:516,2 -DA:519,2 -DA:520,2 -DA:521,2 -DA:522,2 -DA:523,2 -DA:524,2 -DA:525,4 -DA:526,4 -DA:527,4 -DA:528,4 -DA:529,4 -DA:530,4 -DA:532,2 -DA:533,2 -DA:534,2 -DA:540,2 -DA:541,2 -DA:542,2 -DA:543,8 -DA:544,2 -DA:546,2002 -DA:547,2000 -DA:548,2000 -DA:550,2000 -DA:551,2000 -DA:553,2000 -DA:555,0 -DA:559,2 -DA:563,2 -DA:564,2 -DA:565,2 -DA:566,6 -DA:567,2 -DA:568,2 -DA:569,2 -DA:570,8 -DA:571,2 -DA:573,2 -DA:574,2 -DA:576,2 -DA:577,128 -DA:578,128 -DA:579,128 -DA:582,128 -DA:583,30 -DA:584,98 -DA:588,2 -DA:589,2 -DA:590,2 -DA:591,0 -DA:593,2 -DA:597,2 -DA:598,2 -DA:599,2 -DA:600,8 -DA:601,2 -DA:602,2 -DA:603,2 -DA:604,6 -DA:605,2 -DA:607,2 -DA:608,2 -DA:610,2 -DA:611,128 -DA:612,128 -DA:613,128 -DA:616,128 -DA:617,128 -DA:620,128 -DA:621,128 -DA:622,58 -DA:623,70 -DA:628,2 -DA:629,2 -DA:630,2 -DA:631,2 -DA:632,0 -DA:634,2 -DA:638,2 -DA:639,2 -DA:640,2 -DA:641,6 -DA:642,2 -DA:643,2 -DA:644,2 -DA:646,2 -DA:647,2 -DA:649,128 -DA:650,128 -DA:651,128 -DA:652,128 -DA:653,128 -DA:654,128 -DA:655,128 -DA:659,8 -DA:660,6 -DA:661,6 -DA:662,0 -DA:667,2 -DA:668,2 -DA:669,2 -DA:673,2 -DA:674,2 -DA:675,2 -DA:676,6 -DA:677,2 -DA:678,2 -DA:679,2 -DA:680,8 -DA:681,2 -DA:683,2 -DA:684,2 -DA:686,2 -DA:687,128 -DA:688,128 -DA:689,128 -DA:692,128 -DA:693,128 -DA:696,128 -DA:697,128 -DA:698,58 -DA:699,70 -DA:704,2 -DA:705,2 -DA:706,2 -DA:707,0 -DA:709,2 -DA:713,2 -DA:716,2 -DA:717,2 -DA:718,2 -DA:719,2 -DA:720,2 -DA:721,2 -DA:722,2 -DA:724,12 -DA:725,10 -DA:726,10 -DA:728,0 -DA:732,2 -DA:736,2 -DA:738,2 -DA:739,2 -DA:740,6 -DA:741,2 -DA:743,2 -DA:744,2 -DA:746,2 -DA:749,2 -DA:752,2 -DA:753,2 -DA:754,2 -DA:758,2 -DA:761,2 -DA:762,2 -DA:764,2 -DA:768,2 -DA:769,2 -DA:770,2 -DA:771,2 -DA:774,2 -DA:776,0 -DA:778,2 -BRF:0 -BRH:0 -LF:500 -LH:481 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/scatter.rs -FN:160,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_27test_node_response_creation -FN:128,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_27test_scatter_response_empty -FN:92,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_29test_scatter_request_creation -FN:139,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_31test_scatter_response_with_data -FN:176,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_34test_stub_scatter_with_empty_nodes -FN:197,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_37test_stub_scatter_with_multiple_nodes -FN:107,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_40test_stub_scatter_returns_empty_response -FN:78,_RNvXs7_NtCs14cwtawSGIg_11miroir_core7scatterNtB5_11StubScatterNtB5_7Scatter7scatter -FN:176,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_34test_stub_scatter_with_empty_nodes0B7_ -FN:197,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_37test_stub_scatter_with_multiple_nodes0B7_ -FN:107,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_40test_stub_scatter_returns_empty_response0B7_ -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_27test_node_response_creation -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_27test_scatter_response_empty -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_29test_scatter_request_creation -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_31test_scatter_response_with_data -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_34test_stub_scatter_with_empty_nodes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_37test_stub_scatter_with_multiple_nodes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_40test_stub_scatter_returns_empty_response -FNDA:6,_RNvXs7_NtCs14cwtawSGIg_11miroir_core7scatterNtB5_11StubScatterNtB5_7Scatter7scatter -FNDA:2,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_34test_stub_scatter_with_empty_nodes0B7_ -FNDA:2,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_37test_stub_scatter_with_multiple_nodes0B7_ -FNDA:2,_RNCNvNtNtCs14cwtawSGIg_11miroir_core7scatter5testss_40test_stub_scatter_returns_empty_response0B7_ -FNF:11 -FNH:11 -DA:78,6 -DA:83,6 -DA:92,2 -DA:93,2 -DA:94,2 -DA:95,2 -DA:96,2 -DA:97,2 -DA:98,2 -DA:100,2 -DA:101,2 -DA:102,2 -DA:103,2 -DA:104,2 -DA:107,2 -DA:108,2 -DA:109,2 -DA:110,2 -DA:111,2 -DA:112,2 -DA:113,2 -DA:114,2 -DA:115,2 -DA:116,2 -DA:118,2 -DA:119,2 -DA:120,2 -DA:121,2 -DA:123,2 -DA:124,2 -DA:125,2 -DA:128,2 -DA:129,2 -DA:130,2 -DA:131,2 -DA:132,2 -DA:134,2 -DA:135,2 -DA:136,2 -DA:139,2 -DA:140,2 -DA:141,2 -DA:142,2 -DA:143,2 -DA:144,2 -DA:145,2 -DA:147,2 -DA:148,2 -DA:149,2 -DA:150,2 -DA:152,2 -DA:153,2 -DA:154,2 -DA:155,2 -DA:156,2 -DA:157,2 -DA:160,2 -DA:161,2 -DA:162,2 -DA:163,2 -DA:164,2 -DA:165,2 -DA:166,2 -DA:168,2 -DA:169,2 -DA:170,2 -DA:171,2 -DA:172,2 -DA:173,2 -DA:176,2 -DA:177,2 -DA:178,2 -DA:179,2 -DA:180,2 -DA:181,2 -DA:182,2 -DA:183,2 -DA:184,2 -DA:185,2 -DA:187,2 -DA:188,2 -DA:189,2 -DA:190,2 -DA:192,2 -DA:193,2 -DA:194,2 -DA:197,2 -DA:198,2 -DA:199,2 -DA:201,2 -DA:202,2 -DA:203,2 -DA:205,2 -DA:206,2 -DA:207,2 -DA:210,2 -DA:211,2 -DA:212,2 -DA:215,2 -DA:216,2 -DA:217,2 -DA:221,2 -DA:222,2 -DA:223,2 -DA:224,2 -DA:225,2 -DA:226,2 -DA:227,2 -DA:229,2 -DA:230,2 -DA:231,2 -DA:232,2 -DA:234,2 -DA:235,2 -DA:236,2 -BRF:0 -BRH:0 -LF:121 -LH:121 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/score_comparability.rs -FN:490,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability10mean_usize -FN:347,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_stats -FN:472,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability4mean -FN:498,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability6median -FN:480,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability7std_dev -FN:133,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulate -FN:399,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujEB4_ -FN:399,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulEB4_ -FN:457,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability18jaccard_similarityjEB4_ -FN:457,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability18jaccard_similaritylEB4_ -FN:210,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEB4_ -FN:210,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs6thread9ThreadRngEB4_ -FN:242,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEB4_ -FN:408,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujE0B6_ -FN:413,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujEs_0B6_ -FN:408,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulE0B6_ -FN:413,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulEs_0B6_ -FN:237,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs_0B6_ -FN:237,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs6thread9ThreadRngEs_0B6_ -FN:258,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngE0B6_ -FN:310,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs1_0B6_ -FN:328,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs3_0B6_ -FN:267,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs_0B6_ -FN:365,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_stats0B5_ -FN:373,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_statss0_0B5_ -FN:374,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_statss1_0B5_ -FN:485,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability7std_dev0B5_ -FN:152,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulate0B5_ -FN:175,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulates0_0B5_ -FN:583,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_20test_simulation_runs -FN:544,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_22test_jaccard_identical -FN:551,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_23test_jaccard_no_overlap -FN:558,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_25test_jaccard_half_overlap -FN:528,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_25test_kendall_tau_reversed -FN:521,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_26test_kendall_tau_identical -FN:567,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_28test_skewed_distribution_sum -FN:535,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_32test_kendall_tau_partial_overlap -FN:574,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_33test_skewed_distribution_has_skew -FNDA:2,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability10mean_usize -FNDA:20,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_stats -FNDA:4,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability4mean -FNDA:2,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability6median -FNDA:2,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability7std_dev -FNDA:2,_RNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulate -FNDA:20,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujEB4_ -FNDA:6,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulEB4_ -FNDA:20,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability18jaccard_similarityjEB4_ -FNDA:6,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability18jaccard_similaritylEB4_ -FNDA:2,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEB4_ -FNDA:4,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs6thread9ThreadRngEB4_ -FNDA:20,_RINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEB4_ -FNDA:200,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujE0B6_ -FNDA:200,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taujEs_0B6_ -FNDA:30,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulE0B6_ -FNDA:30,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability11kendall_taulEs_0B6_ -FNDA:8,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs_0B6_ -FNDA:40,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability28generate_skewed_distributionNtNtNtCslmwLMq0gHiC_4rand4rngs6thread9ThreadRngEs_0B6_ -FNDA:20000,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngE0B6_ -FNDA:28462,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs1_0B6_ -FNDA:20,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs3_0B6_ -FNDA:213498,_RNCINvNtCs14cwtawSGIg_11miroir_core19score_comparability9run_queryNtNtNtCslmwLMq0gHiC_4rand4rngs3std6StdRngEs_0B6_ -FNDA:800,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_stats0B5_ -FNDA:200,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_statss0_0B5_ -FNDA:200,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability19collect_shard_statss1_0B5_ -FNDA:20,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability7std_dev0B5_ -FNDA:8,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulate0B5_ -FNDA:20,_RNCNvNtCs14cwtawSGIg_11miroir_core19score_comparability8simulates0_0B5_ -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_20test_simulation_runs -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_22test_jaccard_identical -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_23test_jaccard_no_overlap -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_25test_jaccard_half_overlap -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_25test_kendall_tau_reversed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_26test_kendall_tau_identical -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_28test_skewed_distribution_sum -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_32test_kendall_tau_partial_overlap -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core19score_comparability5testss_33test_skewed_distribution_has_skew -FNF:32 -FNH:32 -DA:133,2 -DA:137,2 -DA:140,2 -DA:141,2 -DA:142,2 -DA:143,2 -DA:144,2 -DA:148,2 -DA:149,2 -DA:150,2 -DA:151,2 -DA:152,8 -DA:153,2 -DA:154,2 -DA:155,2 -DA:156,2 -DA:157,2 -DA:158,2 -DA:161,2 -DA:163,20 -DA:164,20 -DA:165,20 -DA:166,20 -DA:169,2 -DA:170,2 -DA:171,2 -DA:172,2 -DA:173,2 -DA:174,2 -DA:175,20 -DA:177,2 -DA:178,2 -DA:180,2 -DA:181,2 -DA:182,2 -DA:183,2 -DA:184,2 -DA:186,2 -DA:187,2 -DA:188,2 -DA:189,2 -DA:190,2 -DA:191,2 -DA:192,2 -DA:193,2 -DA:194,2 -DA:195,2 -DA:196,2 -DA:198,2 -DA:199,2 -DA:200,2 -DA:201,2 -DA:202,2 -DA:203,2 -DA:204,2 -DA:210,6 -DA:211,6 -DA:212,6 -DA:213,6 -DA:214,6 -DA:215,6 -DA:217,6 -DA:221,6 -DA:222,6 -DA:223,6 -DA:224,6 -DA:227,54 -DA:228,48 -DA:229,48 -DA:232,6 -DA:233,6 -DA:235,6 -DA:236,6 -DA:237,48 -DA:238,6 -DA:239,6 -DA:242,20 -DA:243,20 -DA:244,20 -DA:245,20 -DA:246,20 -DA:247,20 -DA:253,20 -DA:257,20 -DA:258,20000 -DA:259,20000 -DA:260,20000 -DA:261,20000 -DA:262,20000 -DA:263,20000 -DA:264,20 -DA:267,213498 -DA:269,20 -DA:270,20 -DA:271,20 -DA:272,20 -DA:273,20 -DA:280,20 -DA:282,80 -DA:283,80 -DA:284,80 -DA:285,80 -DA:290,80 -DA:291,80 -DA:292,80 -DA:295,80 -DA:296,80 -DA:298,19960 -DA:299,80 -DA:300,80 -DA:301,80 -DA:302,80 -DA:303,19960 -DA:304,19960 -DA:305,19960 -DA:306,19960 -DA:310,28462 -DA:312,20 -DA:313,20 -DA:314,20 -DA:315,20 -DA:316,20 -DA:319,20 -DA:322,20 -DA:325,20 -DA:326,20 -DA:327,20 -DA:328,20 -DA:329,20 -DA:332,20 -DA:333,20 -DA:335,20 -DA:336,20 -DA:337,20 -DA:338,20 -DA:339,20 -DA:340,20 -DA:341,20 -DA:342,20 -DA:343,20 -DA:344,20 -DA:347,20 -DA:348,20 -DA:349,20 -DA:350,20 -DA:351,20 -DA:352,20 -DA:353,20 -DA:356,80 -DA:357,80 -DA:358,80 -DA:359,80 -DA:362,80 -DA:363,80 -DA:364,80 -DA:365,800 -DA:366,80 -DA:367,80 -DA:369,80 -DA:370,60 -DA:371,20 -DA:373,200 -DA:374,200 -DA:375,20 -DA:377,20 -DA:378,20 -DA:379,20 -DA:380,20 -DA:381,20 -DA:382,20 -DA:383,20 -DA:384,20 -DA:385,20 -DA:388,20 -DA:389,20 -DA:399,26 -DA:400,26 -DA:401,0 -DA:402,26 -DA:405,26 -DA:406,26 -DA:407,26 -DA:408,230 -DA:409,26 -DA:410,26 -DA:411,26 -DA:412,26 -DA:413,230 -DA:414,26 -DA:417,26 -DA:420,26 -DA:421,26 -DA:423,26 -DA:424,426 -DA:425,3784 -DA:426,3784 -DA:427,3784 -DA:429,3784 -DA:430,3784 -DA:431,3784 -DA:432,3784 -DA:435,3784 -DA:436,60 -DA:437,3724 -DA:440,60 -DA:441,38 -DA:442,38 -DA:443,22 -DA:444,22 -DA:448,26 -DA:449,26 -DA:450,20 -DA:451,6 -DA:453,6 -DA:454,26 -DA:457,26 -DA:458,26 -DA:459,26 -DA:461,26 -DA:462,26 -DA:464,26 -DA:465,0 -DA:466,26 -DA:468,26 -DA:469,26 -DA:472,4 -DA:473,4 -DA:474,0 -DA:475,4 -DA:476,4 -DA:477,4 -DA:480,2 -DA:481,2 -DA:482,0 -DA:483,2 -DA:484,2 -DA:485,20 -DA:486,2 -DA:487,2 -DA:490,2 -DA:491,2 -DA:492,0 -DA:493,2 -DA:494,2 -DA:495,2 -DA:498,2 -DA:499,2 -DA:500,0 -DA:501,2 -DA:502,2 -DA:503,2 -DA:504,2 -DA:505,2 -DA:506,2 -DA:508,0 -DA:510,2 -DA:521,2 -DA:522,2 -DA:523,2 -DA:524,2 -DA:525,2 -DA:528,2 -DA:529,2 -DA:530,2 -DA:531,2 -DA:532,2 -DA:535,2 -DA:536,2 -DA:537,2 -DA:539,2 -DA:540,2 -DA:541,2 -DA:544,2 -DA:545,2 -DA:546,2 -DA:547,2 -DA:548,2 -DA:551,2 -DA:552,2 -DA:553,2 -DA:554,2 -DA:555,2 -DA:558,2 -DA:559,2 -DA:560,2 -DA:563,2 -DA:564,2 -DA:567,2 -DA:568,2 -DA:569,2 -DA:570,2 -DA:571,2 -DA:574,2 -DA:575,2 -DA:576,2 -DA:577,2 -DA:579,2 -DA:580,2 -DA:583,2 -DA:584,2 -DA:585,2 -DA:586,2 -DA:587,2 -DA:588,2 -DA:589,2 -DA:590,2 -DA:591,2 -DA:592,2 -DA:593,2 -DA:594,2 -DA:595,2 -DA:596,2 -BRF:0 -BRH:0 -LF:325 -LH:316 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/task.rs -FN:130,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry13update_status -FN:134,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry16update_node_task -FN:126,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry3get -FN:143,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry4list -FN:116,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry8register -FN:209,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_24test_task_filter_default -FN:232,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_25test_miroir_task_creation -FN:195,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_25test_task_status_equality -FN:258,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_27test_miroir_task_with_error -FN:166,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_27test_stub_task_registry_get -FN:187,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_28test_stub_task_registry_list -FN:218,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_28test_task_filter_with_fields -FN:202,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_30test_node_task_status_equality -FN:153,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_32test_stub_task_registry_register -FN:173,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_37test_stub_task_registry_update_status -FN:180,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_40test_stub_task_registry_update_node_task -FNDA:2,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry13update_status -FNDA:2,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry16update_node_task -FNDA:2,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry3get -FNDA:2,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry4list -FNDA:2,_RNvXNtCs14cwtawSGIg_11miroir_core4taskNtB2_16StubTaskRegistryNtB2_12TaskRegistry8register -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_24test_task_filter_default -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_25test_miroir_task_creation -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_25test_task_status_equality -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_27test_miroir_task_with_error -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_27test_stub_task_registry_get -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_28test_stub_task_registry_list -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_28test_task_filter_with_fields -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_30test_node_task_status_equality -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_32test_stub_task_registry_register -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_37test_stub_task_registry_update_status -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core4task5testss_40test_stub_task_registry_update_node_task -FNF:16 -FNH:16 -DA:116,2 -DA:117,2 -DA:118,2 -DA:119,2 -DA:120,2 -DA:121,2 -DA:122,2 -DA:123,2 -DA:124,2 -DA:126,2 -DA:127,2 -DA:128,2 -DA:130,2 -DA:131,2 -DA:132,2 -DA:134,2 -DA:135,2 -DA:136,2 -DA:137,2 -DA:138,2 -DA:139,2 -DA:140,2 -DA:141,2 -DA:143,2 -DA:144,2 -DA:145,2 -DA:153,2 -DA:154,2 -DA:155,2 -DA:156,2 -DA:158,2 -DA:159,2 -DA:160,2 -DA:161,2 -DA:162,2 -DA:163,2 -DA:166,2 -DA:167,2 -DA:168,2 -DA:169,2 -DA:170,2 -DA:173,2 -DA:174,2 -DA:175,2 -DA:176,2 -DA:177,2 -DA:180,2 -DA:181,2 -DA:182,2 -DA:183,2 -DA:184,2 -DA:187,2 -DA:188,2 -DA:189,2 -DA:190,2 -DA:191,2 -DA:192,2 -DA:195,2 -DA:196,2 -DA:197,2 -DA:198,2 -DA:199,2 -DA:202,2 -DA:203,2 -DA:204,2 -DA:205,2 -DA:206,2 -DA:209,2 -DA:210,2 -DA:211,2 -DA:212,2 -DA:213,2 -DA:214,2 -DA:215,2 -DA:218,2 -DA:219,2 -DA:220,2 -DA:221,2 -DA:222,2 -DA:223,2 -DA:224,2 -DA:225,2 -DA:226,2 -DA:227,2 -DA:228,2 -DA:229,2 -DA:232,2 -DA:233,2 -DA:234,2 -DA:235,2 -DA:236,2 -DA:237,2 -DA:238,2 -DA:239,2 -DA:242,2 -DA:243,2 -DA:244,2 -DA:245,2 -DA:246,2 -DA:247,2 -DA:248,2 -DA:250,2 -DA:251,2 -DA:252,2 -DA:253,2 -DA:254,2 -DA:255,2 -DA:258,2 -DA:259,2 -DA:260,2 -DA:261,2 -DA:262,2 -DA:263,2 -DA:264,2 -DA:265,2 -DA:267,2 -DA:268,2 -DA:269,2 -BRF:0 -BRH:0 -LF:118 -LH:118 -end_of_record -SF:/home/coding/miroir/crates/miroir-core/src/topology.rs -FN:273,_RNCNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_5Group13healthy_nodes0B9_ -FN:274,_RNCNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_5Group13healthy_nodess_0B9_ -FN:362,_RNCNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_8Topology22healthy_nodes_in_group0B9_ -FN:502,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_19test_node_id_as_ref -FN:374,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_20test_node_is_healthy -FN:732,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_20test_topology_shards -FN:410,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_21test_group_node_count -FN:772,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_22test_topology_node_mut -FN:695,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_group_healthy_nodes -FN:496,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_node_id_from_string -FN:799,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_node_status_display -FN:458,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_topology_nodes_iter -FN:477,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_25test_topology_groups_iter -FN:635,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_26test_write_eligible_active -FN:654,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_26test_write_eligible_failed -FN:629,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_healthy -FN:647,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_joining -FN:661,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_removed -FN:641,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_28test_write_eligible_degraded -FN:810,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_29test_transition_error_display -FN:682,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_31test_node_is_write_eligible_for -FN:562,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_32test_state_transition_same_state -FN:426,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_33test_topology_replica_group_count -FN:738,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_36test_topology_healthy_nodes_in_group -FN:596,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_37test_node_set_status_valid_transition -FN:526,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_38test_state_transition_active_to_failed -FN:536,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_38test_state_transition_failed_to_active -FN:609,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_39test_node_set_status_invalid_transition -FN:511,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_39test_state_transition_joining_to_active -FN:541,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_active_to_degraded -FN:516,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_active_to_draining -FN:551,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_degraded_to_active -FN:531,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_draining_to_failed -FN:546,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_failed_to_degraded -FN:521,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_41test_state_transition_draining_to_removed -FN:676,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_42test_write_eligible_draining_drained_shard -FN:668,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_46test_write_eligible_draining_non_drained_shard -FN:583,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_47test_state_transition_invalid_joining_to_failed -FN:577,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_49test_state_transition_invalid_joining_to_draining -FN:589,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_49test_state_transition_invalid_removed_to_anything -FN:556,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_50test_state_transition_healthy_active_bidirectional -FN:13,_RNvMNtCs14cwtawSGIg_11miroir_core8topologyNtB2_6NodeId3new -FN:18,_RNvMNtCs14cwtawSGIg_11miroir_core8topologyNtB2_6NodeId6as_str -FN:69,_RNvMs1_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatus17can_transition_to -FN:119,_RNvMs1_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatus21is_write_eligible_for -FN:180,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node10is_healthy -FN:187,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node10set_status -FN:170,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node11with_status -FN:204,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node21is_write_eligible_for -FN:160,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node3new -FN:263,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group10node_count -FN:270,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group13healthy_nodes -FN:243,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group3new -FN:258,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group5nodes -FN:251,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group8add_node -FN:355,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology19replica_group_count -FN:360,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology22healthy_nodes_in_group -FN:345,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology2rf -FN:297,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology3new -FN:320,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology4node -FN:335,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology5group -FN:330,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology5nodes -FN:340,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology6groups -FN:350,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology6shards -FN:307,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology8add_node -FN:325,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology8node_mut -FN:30,_RNvXs0_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_6NodeIdINtNtCs1p5UDGgVI4d_4core7convert5AsRefeE6as_ref -FN:129,_RNvXs2_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatusNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:217,_RNvXs4_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_15TransitionErrorNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FN:24,_RNvXs_NtCs14cwtawSGIg_11miroir_core8topologyNtB4_6NodeIdINtNtCs1p5UDGgVI4d_4core7convert4FromNtNtCsaFPxhswmqCN_5alloc6string6StringE4from -FNDA:12,_RNCNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_5Group13healthy_nodes0B9_ -FNDA:12,_RNCNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_5Group13healthy_nodess_0B9_ -FNDA:4,_RNCNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB7_8Topology22healthy_nodes_in_group0B9_ -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_19test_node_id_as_ref -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_20test_node_is_healthy -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_20test_topology_shards -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_21test_group_node_count -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_22test_topology_node_mut -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_group_healthy_nodes -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_node_id_from_string -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_node_status_display -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_24test_topology_nodes_iter -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_25test_topology_groups_iter -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_26test_write_eligible_active -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_26test_write_eligible_failed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_healthy -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_joining -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_27test_write_eligible_removed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_28test_write_eligible_degraded -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_29test_transition_error_display -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_31test_node_is_write_eligible_for -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_32test_state_transition_same_state -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_33test_topology_replica_group_count -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_36test_topology_healthy_nodes_in_group -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_37test_node_set_status_valid_transition -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_38test_state_transition_active_to_failed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_38test_state_transition_failed_to_active -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_39test_node_set_status_invalid_transition -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_39test_state_transition_joining_to_active -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_active_to_degraded -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_active_to_draining -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_degraded_to_active -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_draining_to_failed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_40test_state_transition_failed_to_degraded -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_41test_state_transition_draining_to_removed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_42test_write_eligible_draining_drained_shard -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_46test_write_eligible_draining_non_drained_shard -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_47test_state_transition_invalid_joining_to_failed -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_49test_state_transition_invalid_joining_to_draining -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_49test_state_transition_invalid_removed_to_anything -FNDA:2,_RNvNtNtCs14cwtawSGIg_11miroir_core8topology5testss_50test_state_transition_healthy_active_bidirectional -FNDA:246,_RNvMNtCs14cwtawSGIg_11miroir_core8topologyNtB2_6NodeId3new -FNDA:267706202,_RNvMNtCs14cwtawSGIg_11miroir_core8topologyNtB2_6NodeId6as_str -FNDA:50,_RNvMs1_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatus17can_transition_to -FNDA:32,_RNvMs1_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatus21is_write_eligible_for -FNDA:26,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node10is_healthy -FNDA:6,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node10set_status -FNDA:16,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node11with_status -FNDA:2,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node21is_write_eligible_for -FNDA:62,_RNvMs3_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_4Node3new -FNDA:8,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group10node_count -FNDA:6,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group13healthy_nodes -FNDA:48,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group3new -FNDA:175788886,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group5nodes -FNDA:108,_RNvMs6_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_5Group8add_node -FNDA:8,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology19replica_group_count -FNDA:4,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology22healthy_nodes_in_group -FNDA:16,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology2rf -FNDA:28,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology3new -FNDA:10,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology4node -FNDA:8,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology5group -FNDA:2,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology5nodes -FNDA:10,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology6groups -FNDA:2,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology6shards -FNDA:64,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology8add_node -FNDA:2,_RNvMs7_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_8Topology8node_mut -FNDA:2,_RNvXs0_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_6NodeIdINtNtCs1p5UDGgVI4d_4core7convert5AsRefeE6as_ref -FNDA:18,_RNvXs2_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_10NodeStatusNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:2,_RNvXs4_NtCs14cwtawSGIg_11miroir_core8topologyNtB5_15TransitionErrorNtNtCs1p5UDGgVI4d_4core3fmt7Display3fmt -FNDA:2,_RNvXs_NtCs14cwtawSGIg_11miroir_core8topologyNtB4_6NodeIdINtNtCs1p5UDGgVI4d_4core7convert4FromNtNtCsaFPxhswmqCN_5alloc6string6StringE4from -FNF:70 -FNH:70 -DA:13,246 -DA:14,246 -DA:15,246 -DA:18,267706202 -DA:19,267706202 -DA:20,267706202 -DA:24,2 -DA:25,2 -DA:26,2 -DA:30,2 -DA:31,2 -DA:32,2 -DA:69,50 -DA:70,50 -DA:72,6 -DA:75,2 -DA:76,2 -DA:79,2 -DA:80,2 -DA:81,2 -DA:84,2 -DA:85,2 -DA:86,2 -DA:89,2 -DA:90,2 -DA:93,24 -DA:96,10 -DA:98,50 -DA:119,32 -DA:120,32 -DA:121,14 -DA:122,12 -DA:123,6 -DA:125,32 -DA:129,18 -DA:130,18 -DA:131,2 -DA:132,2 -DA:133,2 -DA:134,4 -DA:135,4 -DA:136,2 -DA:137,2 -DA:139,18 -DA:160,62 -DA:161,62 -DA:162,62 -DA:163,62 -DA:164,62 -DA:165,62 -DA:166,62 -DA:167,62 -DA:170,16 -DA:171,16 -DA:172,16 -DA:173,16 -DA:174,16 -DA:175,16 -DA:176,16 -DA:177,16 -DA:180,26 -DA:181,26 -DA:182,26 -DA:187,6 -DA:188,6 -DA:189,4 -DA:190,4 -DA:192,2 -DA:193,2 -DA:194,2 -DA:195,2 -DA:197,6 -DA:204,2 -DA:205,2 -DA:206,2 -DA:217,2 -DA:218,2 -DA:219,2 -DA:220,2 -DA:223,2 -DA:243,48 -DA:244,48 -DA:245,48 -DA:246,48 -DA:247,48 -DA:248,48 -DA:251,108 -DA:252,108 -DA:253,106 -DA:254,106 -DA:255,108 -DA:258,175788886 -DA:259,175788886 -DA:260,175788886 -DA:263,8 -DA:264,8 -DA:265,8 -DA:270,6 -DA:271,6 -DA:272,6 -DA:273,12 -DA:274,12 -DA:275,6 -DA:276,6 -DA:297,28 -DA:298,28 -DA:299,28 -DA:300,28 -DA:301,28 -DA:302,28 -DA:303,28 -DA:304,28 -DA:307,64 -DA:308,64 -DA:311,98 -DA:312,34 -DA:313,34 -DA:315,64 -DA:316,64 -DA:317,64 -DA:320,10 -DA:321,10 -DA:322,10 -DA:325,2 -DA:326,2 -DA:327,2 -DA:330,2 -DA:331,2 -DA:332,2 -DA:335,8 -DA:336,8 -DA:337,8 -DA:340,10 -DA:341,10 -DA:342,10 -DA:345,16 -DA:346,16 -DA:347,16 -DA:350,2 -DA:351,2 -DA:352,2 -DA:355,8 -DA:356,8 -DA:357,8 -DA:360,4 -DA:361,4 -DA:362,4 -DA:363,4 -DA:364,4 -DA:374,2 -DA:375,2 -DA:376,2 -DA:377,2 -DA:382,2 -DA:385,2 -DA:386,2 -DA:389,2 -DA:390,2 -DA:393,2 -DA:394,2 -DA:397,2 -DA:398,2 -DA:401,2 -DA:402,2 -DA:405,2 -DA:406,2 -DA:407,2 -DA:410,2 -DA:411,2 -DA:412,2 -DA:414,2 -DA:415,2 -DA:417,2 -DA:418,2 -DA:421,2 -DA:422,2 -DA:423,2 -DA:426,2 -DA:427,2 -DA:430,2 -DA:433,2 -DA:434,2 -DA:435,2 -DA:438,2 -DA:441,2 -DA:442,2 -DA:443,2 -DA:446,2 -DA:449,2 -DA:450,2 -DA:451,2 -DA:454,2 -DA:455,2 -DA:458,2 -DA:459,2 -DA:461,2 -DA:462,2 -DA:463,2 -DA:466,2 -DA:467,2 -DA:468,2 -DA:472,2 -DA:473,2 -DA:474,2 -DA:477,2 -DA:478,2 -DA:480,2 -DA:481,2 -DA:482,2 -DA:485,2 -DA:486,2 -DA:487,2 -DA:491,2 -DA:492,2 -DA:493,2 -DA:496,2 -DA:497,2 -DA:498,2 -DA:499,2 -DA:502,2 -DA:503,2 -DA:504,2 -DA:505,2 -DA:506,2 -DA:511,2 -DA:512,2 -DA:513,2 -DA:516,2 -DA:517,2 -DA:518,2 -DA:521,2 -DA:522,2 -DA:523,2 -DA:526,2 -DA:527,2 -DA:528,2 -DA:531,2 -DA:532,2 -DA:533,2 -DA:536,2 -DA:537,2 -DA:538,2 -DA:541,2 -DA:542,2 -DA:543,2 -DA:546,2 -DA:547,2 -DA:548,2 -DA:551,2 -DA:552,2 -DA:553,2 -DA:556,2 -DA:557,2 -DA:558,2 -DA:559,2 -DA:562,2 -DA:563,14 -DA:564,2 -DA:565,2 -DA:566,2 -DA:567,2 -DA:568,2 -DA:569,2 -DA:570,2 -DA:572,14 -DA:574,2 -DA:577,2 -DA:579,2 -DA:580,2 -DA:583,2 -DA:585,2 -DA:586,2 -DA:589,2 -DA:591,2 -DA:592,2 -DA:593,2 -DA:596,2 -DA:597,2 -DA:598,2 -DA:599,2 -DA:602,2 -DA:604,2 -DA:605,2 -DA:606,2 -DA:609,2 -DA:610,2 -DA:611,2 -DA:612,2 -DA:614,2 -DA:617,2 -DA:618,2 -DA:619,2 -DA:620,2 -DA:621,2 -DA:623,2 -DA:624,2 -DA:629,2 -DA:630,2 -DA:631,2 -DA:632,2 -DA:635,2 -DA:636,2 -DA:637,2 -DA:638,2 -DA:641,2 -DA:642,2 -DA:643,2 -DA:644,2 -DA:647,2 -DA:649,2 -DA:650,2 -DA:651,2 -DA:654,2 -DA:656,2 -DA:657,2 -DA:658,2 -DA:661,2 -DA:663,2 -DA:664,2 -DA:665,2 -DA:668,2 -DA:670,2 -DA:672,2 -DA:673,2 -DA:676,2 -DA:678,2 -DA:679,2 -DA:682,2 -DA:683,2 -DA:684,2 -DA:685,2 -DA:687,2 -DA:689,2 -DA:690,2 -DA:695,2 -DA:696,2 -DA:697,2 -DA:699,2 -DA:700,2 -DA:701,2 -DA:703,2 -DA:705,2 -DA:706,2 -DA:707,2 -DA:709,2 -DA:711,2 -DA:712,2 -DA:713,2 -DA:715,2 -DA:718,2 -DA:719,2 -DA:720,2 -DA:722,2 -DA:723,2 -DA:724,2 -DA:726,2 -DA:727,2 -DA:728,2 -DA:729,2 -DA:732,2 -DA:733,2 -DA:734,2 -DA:735,2 -DA:738,2 -DA:739,2 -DA:741,2 -DA:742,2 -DA:743,2 -DA:745,2 -DA:747,2 -DA:748,2 -DA:749,2 -DA:751,2 -DA:753,2 -DA:754,2 -DA:755,2 -DA:757,2 -DA:760,2 -DA:761,2 -DA:762,2 -DA:764,2 -DA:765,2 -DA:766,2 -DA:767,2 -DA:772,2 -DA:773,2 -DA:775,2 -DA:776,2 -DA:777,2 -DA:781,2 -DA:783,2 -DA:784,2 -DA:787,2 -DA:788,2 -DA:789,2 -DA:790,2 -DA:792,2 -DA:793,2 -DA:794,2 -DA:799,2 -DA:800,2 -DA:801,2 -DA:802,2 -DA:803,2 -DA:804,2 -DA:805,2 -DA:806,2 -DA:807,2 -DA:810,2 -DA:811,2 -DA:812,2 -DA:813,2 -DA:814,2 -DA:815,2 -DA:816,2 -DA:817,2 -DA:818,2 -DA:819,2 -BRF:0 -BRH:0 -LF:421 -LH:421 -end_of_record \ No newline at end of file diff --git a/crates/miroir-core/src/config.bak/advanced.rs b/crates/miroir-core/src/config.bak/advanced.rs deleted file mode 100644 index 3a2092c..0000000 --- a/crates/miroir-core/src/config.bak/advanced.rs +++ /dev/null @@ -1,831 +0,0 @@ -//! §13 Advanced capabilities configuration structs. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -// --------------------------------------------------------------------------- -// 13.1 Online resharding -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ReshardingConfig { - pub enabled: bool, - pub backfill_concurrency: u32, - pub backfill_batch_size: u32, - pub throttle_docs_per_sec: u32, - pub verify_before_swap: bool, - pub retain_old_index_hours: u32, -} - -impl Default for ReshardingConfig { - fn default() -> Self { - Self { - enabled: true, - backfill_concurrency: 4, - backfill_batch_size: 1000, - throttle_docs_per_sec: 0, - verify_before_swap: true, - retain_old_index_hours: 48, - } - } -} - -// --------------------------------------------------------------------------- -// 13.2 Hedged requests -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct HedgingConfig { - pub enabled: bool, - pub p95_trigger_multiplier: f64, - pub min_trigger_ms: u64, - pub max_hedges_per_query: u32, - pub cross_group_fallback: bool, -} - -impl Default for HedgingConfig { - fn default() -> Self { - Self { - enabled: true, - p95_trigger_multiplier: 1.2, - min_trigger_ms: 15, - max_hedges_per_query: 2, - cross_group_fallback: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.3 Adaptive replica selection (EWMA) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ReplicaSelectionConfig { - /// `adaptive`, `round_robin`, or `random`. - pub strategy: String, - pub latency_weight: f64, - pub inflight_weight: f64, - pub error_weight: f64, - pub ewma_half_life_ms: u64, - pub exploration_epsilon: f64, -} - -impl Default for ReplicaSelectionConfig { - fn default() -> Self { - Self { - strategy: "adaptive".into(), - latency_weight: 1.0, - inflight_weight: 2.0, - error_weight: 10.0, - ewma_half_life_ms: 5000, - exploration_epsilon: 0.05, - } - } -} - -// --------------------------------------------------------------------------- -// 13.4 Shard-aware query planner -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct QueryPlannerConfig { - pub enabled: bool, - pub max_pk_literals_narrowable: u32, - pub log_plans: bool, -} - -impl Default for QueryPlannerConfig { - fn default() -> Self { - Self { - enabled: true, - max_pk_literals_narrowable: 128, - log_plans: false, - } - } -} - -// --------------------------------------------------------------------------- -// 13.5 Two-phase settings broadcast -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SettingsBroadcastConfig { - /// `two_phase` or `sequential` (legacy). - pub strategy: String, - pub verify_timeout_s: u64, - pub max_repair_retries: u32, - pub freeze_writes_on_unrepairable: bool, -} - -impl Default for SettingsBroadcastConfig { - fn default() -> Self { - Self { - strategy: "two_phase".into(), - verify_timeout_s: 60, - max_repair_retries: 3, - freeze_writes_on_unrepairable: true, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SettingsDriftCheckConfig { - pub interval_s: u64, - pub auto_repair: bool, -} - -impl Default for SettingsDriftCheckConfig { - fn default() -> Self { - Self { - interval_s: 300, - auto_repair: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.6 Session pinning (read-your-writes) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SessionPinningConfig { - pub enabled: bool, - pub ttl_seconds: u64, - pub max_sessions: u32, - /// `block` or `route_pin`. - pub wait_strategy: String, - pub max_wait_ms: u64, -} - -impl Default for SessionPinningConfig { - fn default() -> Self { - Self { - enabled: true, - ttl_seconds: 900, - max_sessions: 100_000, - wait_strategy: "block".into(), - max_wait_ms: 5000, - } - } -} - -// --------------------------------------------------------------------------- -// 13.7 Index aliases -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AliasesConfig { - pub enabled: bool, - pub history_retention: u32, - pub require_target_exists: bool, -} - -impl Default for AliasesConfig { - fn default() -> Self { - Self { - enabled: true, - history_retention: 10, - require_target_exists: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.8 Anti-entropy shard reconciler -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AntiEntropyConfig { - pub enabled: bool, - pub schedule: String, - pub shards_per_pass: u32, - pub max_read_concurrency: u32, - pub fingerprint_batch_size: u32, - pub auto_repair: bool, - pub updated_at_field: String, -} - -impl Default for AntiEntropyConfig { - fn default() -> Self { - Self { - enabled: true, - schedule: "every 6h".into(), - shards_per_pass: 0, - max_read_concurrency: 2, - fingerprint_batch_size: 1000, - auto_repair: true, - updated_at_field: "_miroir_updated_at".into(), - } - } -} - -// --------------------------------------------------------------------------- -// 13.9 Streaming dump import -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct DumpImportConfig { - /// `streaming` or `broadcast` (legacy). - pub mode: String, - pub batch_size: u32, - pub parallel_target_writes: u32, - pub memory_buffer_bytes: u64, - pub chunk_size_bytes: u64, -} - -impl Default for DumpImportConfig { - fn default() -> Self { - Self { - mode: "streaming".into(), - batch_size: 1000, - parallel_target_writes: 8, - memory_buffer_bytes: 134_217_728, // 128 MiB - chunk_size_bytes: 268_435_456, // 256 MiB - } - } -} - -// --------------------------------------------------------------------------- -// 13.10 Idempotency keys -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct IdempotencyConfig { - pub enabled: bool, - pub ttl_seconds: u64, - pub max_cached_keys: u32, -} - -impl Default for IdempotencyConfig { - fn default() -> Self { - Self { - enabled: true, - ttl_seconds: 86400, - max_cached_keys: 1_000_000, - } - } -} - -// --------------------------------------------------------------------------- -// 13.10 Query coalescing (paired with idempotency) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct QueryCoalescingConfig { - pub enabled: bool, - pub window_ms: u64, - pub max_subscribers: u32, - pub max_pending_queries: u32, -} - -impl Default for QueryCoalescingConfig { - fn default() -> Self { - Self { - enabled: true, - window_ms: 50, - max_subscribers: 1000, - max_pending_queries: 10000, - } - } -} - -// --------------------------------------------------------------------------- -// 13.11 Multi-search batch API -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct MultiSearchConfig { - pub enabled: bool, - pub max_queries_per_batch: u32, - pub total_timeout_ms: u64, - pub per_query_timeout_ms: u64, -} - -impl Default for MultiSearchConfig { - fn default() -> Self { - Self { - enabled: true, - max_queries_per_batch: 100, - total_timeout_ms: 30000, - per_query_timeout_ms: 30000, - } - } -} - -// --------------------------------------------------------------------------- -// 13.12 Vector / hybrid search -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct VectorSearchConfig { - pub enabled: bool, - pub over_fetch_factor: u32, - /// `convex` or `rrf`. - pub merge_strategy: String, - pub hybrid_alpha_default: f64, - pub rrf_k: u32, -} - -impl Default for VectorSearchConfig { - fn default() -> Self { - Self { - enabled: true, - over_fetch_factor: 3, - merge_strategy: "convex".into(), - hybrid_alpha_default: 0.5, - rrf_k: 60, - } - } -} - -// --------------------------------------------------------------------------- -// 13.13 Change data capture (CDC) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct CdcConfig { - pub enabled: bool, - pub emit_ttl_deletes: bool, - pub emit_internal_writes: bool, - pub sinks: Vec, - pub buffer: CdcBufferConfig, -} - -impl Default for CdcConfig { - fn default() -> Self { - Self { - enabled: true, - emit_ttl_deletes: false, - emit_internal_writes: false, - sinks: Vec::new(), - buffer: CdcBufferConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct CdcSinkConfig { - /// `webhook`, `nats`, `kafka`, or `internal`. - #[serde(rename = "type")] - pub sink_type: String, - pub url: String, - pub batch_size: u32, - pub batch_flush_ms: u64, - pub include_body: bool, - pub retry_max_s: u64, - /// NATS-specific. - pub subject_prefix: Option, -} - -impl Default for CdcSinkConfig { - fn default() -> Self { - Self { - sink_type: "webhook".into(), - url: String::new(), - batch_size: 100, - batch_flush_ms: 1000, - include_body: false, - retry_max_s: 3600, - subject_prefix: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct CdcBufferConfig { - /// `memory`, `redis`, or `pvc`. - pub primary: String, - pub memory_bytes: u64, - /// `redis`, `pvc`, or `drop`. - pub overflow: String, - pub redis_bytes: u64, -} - -impl Default for CdcBufferConfig { - fn default() -> Self { - Self { - primary: "memory".into(), - memory_bytes: 67_108_864, // 64 MiB - overflow: "redis".into(), - redis_bytes: 1_073_741_824, // 1 GiB - } - } -} - -// --------------------------------------------------------------------------- -// 13.14 Document TTL -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TtlConfig { - pub enabled: bool, - pub sweep_interval_s: u64, - pub max_deletes_per_sweep: u32, - pub expires_at_field: String, - pub per_index_overrides: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TtlOverride { - pub sweep_interval_s: u64, - pub max_deletes_per_sweep: u32, -} - -impl Default for TtlConfig { - fn default() -> Self { - Self { - enabled: true, - sweep_interval_s: 300, - max_deletes_per_sweep: 10000, - expires_at_field: "_miroir_expires_at".into(), - per_index_overrides: HashMap::new(), - } - } -} - -// --------------------------------------------------------------------------- -// 13.15 Tenant-to-replica-group affinity -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TenantAffinityConfig { - pub enabled: bool, - /// `header`, `api_key`, or `explicit`. - pub mode: String, - pub header_name: String, - /// `hash`, `random`, or `reject`. - pub fallback: String, - pub static_map: HashMap, - pub dedicated_groups: Vec, -} - -impl Default for TenantAffinityConfig { - fn default() -> Self { - Self { - enabled: true, - mode: "header".into(), - header_name: "X-Miroir-Tenant".into(), - fallback: "hash".into(), - static_map: HashMap::new(), - dedicated_groups: Vec::new(), - } - } -} - -// --------------------------------------------------------------------------- -// 13.16 Traffic shadow -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ShadowConfig { - pub enabled: bool, - pub targets: Vec, - pub diff_buffer_size: u32, - pub max_shadow_latency_ms: u64, -} - -impl Default for ShadowConfig { - fn default() -> Self { - Self { - enabled: true, - targets: Vec::new(), - diff_buffer_size: 10000, - max_shadow_latency_ms: 5000, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ShadowTargetConfig { - pub name: String, - pub url: String, - pub api_key_env: String, - pub sample_rate: f64, - pub operations: Vec, -} - -impl Default for ShadowTargetConfig { - fn default() -> Self { - Self { - name: String::new(), - url: String::new(), - api_key_env: String::new(), - sample_rate: 0.05, - operations: vec!["search".into(), "multi_search".into(), "explain".into()], - } - } -} - -// --------------------------------------------------------------------------- -// 13.17 Index lifecycle management (ILM) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct IlmConfig { - pub enabled: bool, - pub check_interval_s: u64, - pub safety_lock_older_than_days: u32, - pub max_rollovers_per_check: u32, -} - -impl Default for IlmConfig { - fn default() -> Self { - Self { - enabled: true, - check_interval_s: 3600, - safety_lock_older_than_days: 7, - max_rollovers_per_check: 10, - } - } -} - -// --------------------------------------------------------------------------- -// 13.18 Synthetic canary queries -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct CanaryRunnerConfig { - pub enabled: bool, - pub max_concurrent_canaries: u32, - pub run_history_per_canary: u32, - pub emit_results_to_cdc: bool, -} - -impl Default for CanaryRunnerConfig { - fn default() -> Self { - Self { - enabled: true, - max_concurrent_canaries: 10, - run_history_per_canary: 100, - emit_results_to_cdc: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.19 Admin Web UI -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AdminUiConfig { - pub enabled: bool, - pub path: String, - /// `key`, `oauth` (future), or `none` (dev only). - pub auth: String, - pub session_ttl_s: u64, - pub read_only_mode: bool, - pub allowed_origins: Vec, - pub cors_allowed_origins: Vec, - pub csp_overrides: CspOverridesConfig, - pub theme: AdminUiThemeConfig, - pub features: AdminUiFeaturesConfig, -} - -impl Default for AdminUiConfig { - fn default() -> Self { - Self { - enabled: true, - path: "/_miroir/admin".into(), - auth: "key".into(), - session_ttl_s: 3600, - read_only_mode: false, - allowed_origins: vec!["same-origin".into()], - cors_allowed_origins: Vec::new(), - csp_overrides: CspOverridesConfig::default(), - theme: AdminUiThemeConfig::default(), - features: AdminUiFeaturesConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct CspOverridesConfig { - pub script_src: Vec, - pub img_src: Vec, - pub connect_src: Vec, -} - -impl Default for CspOverridesConfig { - fn default() -> Self { - Self { - script_src: Vec::new(), - img_src: Vec::new(), - connect_src: Vec::new(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AdminUiThemeConfig { - pub accent_color: String, - /// `auto`, `light`, or `dark`. - pub default_mode: String, -} - -impl Default for AdminUiThemeConfig { - fn default() -> Self { - Self { - accent_color: "#2563eb".into(), - default_mode: "auto".into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AdminUiFeaturesConfig { - pub sandbox: bool, - pub shadow_viewer: bool, - pub cdc_inspector: bool, -} - -impl Default for AdminUiFeaturesConfig { - fn default() -> Self { - Self { - sandbox: true, - shadow_viewer: true, - cdc_inspector: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.20 Query explain API -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ExplainConfig { - pub enabled: bool, - pub max_warnings: u32, - pub allow_execute_parameter: bool, -} - -impl Default for ExplainConfig { - fn default() -> Self { - Self { - enabled: true, - max_warnings: 20, - allow_execute_parameter: true, - } - } -} - -// --------------------------------------------------------------------------- -// 13.21 Search UI (end-user) -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SearchUiConfig { - pub enabled: bool, - pub path: String, - pub widget_script_enabled: bool, - pub embeddable: bool, - pub auth: SearchUiAuthConfig, - pub allowed_origins: Vec, - pub scoped_key_max_age_days: u32, - pub scoped_key_rotate_before_expiry_days: u32, - pub scoped_key_rotation_drain_s: u64, - pub rate_limit: SearchUiRateLimitConfig, - pub cors_allowed_origins: Vec, - pub csp_overrides: CspOverridesConfig, - pub csp: String, - pub analytics: SearchUiAnalyticsConfig, -} - -impl Default for SearchUiConfig { - fn default() -> Self { - Self { - enabled: true, - path: "/ui/search".into(), - widget_script_enabled: true, - embeddable: true, - auth: SearchUiAuthConfig::default(), - allowed_origins: vec!["*".into()], - scoped_key_max_age_days: 60, - scoped_key_rotate_before_expiry_days: 30, - scoped_key_rotation_drain_s: 120, - rate_limit: SearchUiRateLimitConfig::default(), - cors_allowed_origins: Vec::new(), - csp_overrides: CspOverridesConfig::default(), - csp: "default-src 'self'; img-src 'self' https:; style-src 'self' 'unsafe-inline'" - .into(), - analytics: SearchUiAnalyticsConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SearchUiAuthConfig { - /// `public`, `shared_key`, or `oauth_proxy`. - pub mode: String, - pub shared_key_env: String, - pub session_ttl_s: u64, - pub session_rate_limit: String, - pub jwt_secret_env: String, - pub oauth_proxy: OAuthProxyConfig, -} - -impl Default for SearchUiAuthConfig { - fn default() -> Self { - Self { - mode: "public".into(), - shared_key_env: String::new(), - session_ttl_s: 900, - session_rate_limit: "10/minute".into(), - jwt_secret_env: "SEARCH_UI_JWT_SECRET".into(), - oauth_proxy: OAuthProxyConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct OAuthProxyConfig { - pub user_header: String, - pub groups_header: String, - pub filter_template: Option, - pub attribute_map: HashMap, -} - -impl Default for OAuthProxyConfig { - fn default() -> Self { - Self { - user_header: "X-Forwarded-User".into(), - groups_header: "X-Forwarded-Groups".into(), - filter_template: Some("tenant IN [{groups}]".into()), - attribute_map: { - let mut m = HashMap::new(); - m.insert("groups".into(), "groups_array".into()); - m.insert("user".into(), "user_id_string".into()); - m - }, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SearchUiRateLimitConfig { - pub per_ip: String, - /// `redis` or `local`. - pub backend: String, - pub redis_key_prefix: String, - pub redis_ttl_s: u64, -} - -impl Default for SearchUiRateLimitConfig { - fn default() -> Self { - Self { - per_ip: "60/minute".into(), - backend: "redis".into(), - redis_key_prefix: "miroir:ratelimit:searchui:".into(), - redis_ttl_s: 60, - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct SearchUiAnalyticsConfig { - pub enabled: bool, - /// `cdc` (publishes click-throughs as CDC events). - pub sink: String, -} - -impl Default for SearchUiAnalyticsConfig { - fn default() -> Self { - Self { - enabled: false, - sink: "cdc".into(), - } - } -} diff --git a/crates/miroir-core/src/config.bak/mod.rs b/crates/miroir-core/src/config.bak/mod.rs deleted file mode 100644 index 9fbf8dd..0000000 --- a/crates/miroir-core/src/config.bak/mod.rs +++ /dev/null @@ -1,352 +0,0 @@ -mod advanced; -mod error; -mod load; -mod validate; - -pub use error::ConfigError; - -use serde::{Deserialize, Serialize}; - -/// Top-level configuration matching plan §4 YAML schema under `miroir:`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct MiroirConfig { - // --- Secrets (env-var overrides) --- - /// Client-facing API key. Env override: `MIROIR_MASTER_KEY`. - pub master_key: String, - /// Key Miroir uses on Meilisearch nodes. Env override: `MIROIR_NODE_MASTER_KEY`. - pub node_master_key: String, - - // --- Core topology --- - /// Total number of logical shards. - pub shards: u32, - /// Replication factor (intra-group replicas per shard). Production: 2. - pub replication_factor: u32, - /// Number of independent query pools. Default 1; production: 2. - pub replica_groups: u32, - - // --- Sub-structs --- - pub nodes: Vec, - pub task_store: TaskStoreConfig, - pub admin: AdminConfig, - pub health: HealthConfig, - pub scatter: ScatterConfig, - pub rebalancer: RebalancerConfig, - pub server: ServerConfig, - pub connection_pool_per_node: ConnectionPoolConfig, - pub task_registry: TaskRegistryConfig, - - // --- §13 advanced capabilities --- - pub resharding: advanced::ReshardingConfig, - pub hedging: advanced::HedgingConfig, - pub replica_selection: advanced::ReplicaSelectionConfig, - pub query_planner: advanced::QueryPlannerConfig, - pub settings_broadcast: advanced::SettingsBroadcastConfig, - pub settings_drift_check: advanced::SettingsDriftCheckConfig, - pub session_pinning: advanced::SessionPinningConfig, - pub aliases: advanced::AliasesConfig, - pub anti_entropy: advanced::AntiEntropyConfig, - pub dump_import: advanced::DumpImportConfig, - pub idempotency: advanced::IdempotencyConfig, - pub query_coalescing: advanced::QueryCoalescingConfig, - pub multi_search: advanced::MultiSearchConfig, - pub vector_search: advanced::VectorSearchConfig, - pub cdc: advanced::CdcConfig, - pub ttl: advanced::TtlConfig, - pub tenant_affinity: advanced::TenantAffinityConfig, - pub shadow: advanced::ShadowConfig, - pub ilm: advanced::IlmConfig, - pub canary_runner: advanced::CanaryRunnerConfig, - pub explain: advanced::ExplainConfig, - pub admin_ui: advanced::AdminUiConfig, - pub search_ui: advanced::SearchUiConfig, - - // --- §14 horizontal scaling --- - pub peer_discovery: PeerDiscoveryConfig, - pub leader_election: LeaderElectionConfig, - pub hpa: HpaConfig, -} - -impl Default for MiroirConfig { - fn default() -> Self { - Self { - master_key: String::new(), - node_master_key: String::new(), - shards: 64, - replication_factor: 2, - replica_groups: 1, - nodes: Vec::new(), - task_store: TaskStoreConfig::default(), - admin: AdminConfig::default(), - health: HealthConfig::default(), - scatter: ScatterConfig::default(), - rebalancer: RebalancerConfig::default(), - server: ServerConfig::default(), - connection_pool_per_node: ConnectionPoolConfig::default(), - task_registry: TaskRegistryConfig::default(), - resharding: advanced::ReshardingConfig::default(), - hedging: advanced::HedgingConfig::default(), - replica_selection: advanced::ReplicaSelectionConfig::default(), - query_planner: advanced::QueryPlannerConfig::default(), - settings_broadcast: advanced::SettingsBroadcastConfig::default(), - settings_drift_check: advanced::SettingsDriftCheckConfig::default(), - session_pinning: advanced::SessionPinningConfig::default(), - aliases: advanced::AliasesConfig::default(), - anti_entropy: advanced::AntiEntropyConfig::default(), - dump_import: advanced::DumpImportConfig::default(), - idempotency: advanced::IdempotencyConfig::default(), - query_coalescing: advanced::QueryCoalescingConfig::default(), - multi_search: advanced::MultiSearchConfig::default(), - vector_search: advanced::VectorSearchConfig::default(), - cdc: advanced::CdcConfig::default(), - ttl: advanced::TtlConfig::default(), - tenant_affinity: advanced::TenantAffinityConfig::default(), - shadow: advanced::ShadowConfig::default(), - ilm: advanced::IlmConfig::default(), - canary_runner: advanced::CanaryRunnerConfig::default(), - explain: advanced::ExplainConfig::default(), - admin_ui: advanced::AdminUiConfig::default(), - search_ui: advanced::SearchUiConfig::default(), - peer_discovery: PeerDiscoveryConfig::default(), - leader_election: LeaderElectionConfig::default(), - hpa: HpaConfig::default(), - } - } -} - -impl MiroirConfig { - /// Validate cross-field constraints. Returns `Ok(())` or a `ConfigError`. - pub fn validate(&self) -> Result<(), ConfigError> { - validate::validate(self) - } - - /// Layered loading: file → env overrides → CLI overrides. - pub fn load() -> Result { - load::load() - } -} - -/// A single Meilisearch node in the cluster topology. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct NodeConfig { - pub id: String, - pub address: String, - pub replica_group: u32, -} - -/// Task store backend configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TaskStoreConfig { - /// `sqlite` or `redis`. - pub backend: String, - /// Path to SQLite database file (sqlite backend). - pub path: String, - /// Redis URL (redis backend), e.g. `redis://host:6379`. - pub url: String, -} - -impl Default for TaskStoreConfig { - fn default() -> Self { - Self { - backend: "sqlite".into(), - path: "/data/miroir-tasks.db".into(), - url: String::new(), - } - } -} - -/// Admin API configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct AdminConfig { - pub enabled: bool, - /// Env override: `MIROIR_ADMIN_API_KEY`. - pub api_key: String, -} - -impl Default for AdminConfig { - fn default() -> Self { - Self { - enabled: true, - api_key: String::new(), - } - } -} - -/// Health check configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct HealthConfig { - pub interval_ms: u64, - pub timeout_ms: u64, - pub unhealthy_threshold: u32, - pub recovery_threshold: u32, -} - -impl Default for HealthConfig { - fn default() -> Self { - Self { - interval_ms: 5000, - timeout_ms: 2000, - unhealthy_threshold: 3, - recovery_threshold: 2, - } - } -} - -/// Scatter-gather query configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ScatterConfig { - pub node_timeout_ms: u64, - pub retry_on_timeout: bool, - /// `partial` or `error`. - pub unavailable_shard_policy: String, -} - -impl Default for ScatterConfig { - fn default() -> Self { - Self { - node_timeout_ms: 5000, - retry_on_timeout: true, - unavailable_shard_policy: "partial".into(), - } - } -} - -/// Rebalancer configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct RebalancerConfig { - pub auto_rebalance_on_recovery: bool, - pub max_concurrent_migrations: u32, - pub migration_timeout_s: u64, -} - -impl Default for RebalancerConfig { - fn default() -> Self { - Self { - auto_rebalance_on_recovery: true, - max_concurrent_migrations: 4, - migration_timeout_s: 3600, - } - } -} - -/// Server (HTTP listener) configuration. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ServerConfig { - pub port: u16, - pub bind: String, - pub max_body_bytes: u64, - #[serde(default = "default_max_concurrent_requests")] - pub max_concurrent_requests: u32, - #[serde(default = "default_request_timeout_ms")] - pub request_timeout_ms: u64, -} - -fn default_max_concurrent_requests() -> u32 { - 500 -} -fn default_request_timeout_ms() -> u64 { - 30000 -} - -impl Default for ServerConfig { - fn default() -> Self { - Self { - port: 7700, - bind: "0.0.0.0".into(), - max_body_bytes: 104_857_600, // 100 MiB - max_concurrent_requests: default_max_concurrent_requests(), - request_timeout_ms: default_request_timeout_ms(), - } - } -} - -/// HTTP/2 connection pool per-node settings (§14.8). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct ConnectionPoolConfig { - pub max_idle: u32, - pub max_total: u32, - pub idle_timeout_s: u64, -} - -impl Default for ConnectionPoolConfig { - fn default() -> Self { - Self { - max_idle: 32, - max_total: 128, - idle_timeout_s: 60, - } - } -} - -/// Task registry cache settings (§14.8). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TaskRegistryConfig { - pub cache_size: u32, - pub redis_pool_max: u32, -} - -impl Default for TaskRegistryConfig { - fn default() -> Self { - Self { - cache_size: 10000, - redis_pool_max: 50, - } - } -} - -/// Peer discovery via Kubernetes headless Service (§14.5). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct PeerDiscoveryConfig { - pub service_name: String, - pub refresh_interval_s: u64, -} - -impl Default for PeerDiscoveryConfig { - fn default() -> Self { - Self { - service_name: "miroir-headless".into(), - refresh_interval_s: 15, - } - } -} - -/// Leader election for Mode B background jobs (§14.5). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct LeaderElectionConfig { - pub enabled: bool, - pub lease_ttl_s: u64, - pub renew_interval_s: u64, -} - -impl Default for LeaderElectionConfig { - fn default() -> Self { - Self { - enabled: true, - lease_ttl_s: 10, - renew_interval_s: 3, - } - } -} - -/// Horizontal Pod Autoscaler settings (Helm-only, informational in config). -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct HpaConfig { - pub enabled: bool, -} - -impl Default for HpaConfig { - fn default() -> Self { - Self { enabled: false } - } -} diff --git a/k8s/argo-workflows/miroir-ci-docker-compose-smoke.yaml b/k8s/argo-workflows/miroir-ci-docker-compose-smoke.yaml index c178f92..cc1ca70 100644 --- a/k8s/argo-workflows/miroir-ci-docker-compose-smoke.yaml +++ b/k8s/argo-workflows/miroir-ci-docker-compose-smoke.yaml @@ -50,7 +50,7 @@ spec: - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token volumeMounts: - name: workspace diff --git a/k8s/argo-workflows/miroir-ci-smoke.yaml b/k8s/argo-workflows/miroir-ci-smoke.yaml index 974f4c4..a81ec07 100644 --- a/k8s/argo-workflows/miroir-ci-smoke.yaml +++ b/k8s/argo-workflows/miroir-ci-smoke.yaml @@ -43,7 +43,7 @@ spec: - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token resources: requests: diff --git a/k8s/argo-workflows/miroir-ci.yaml b/k8s/argo-workflows/miroir-ci.yaml index d3dcf33..7be249b 100644 --- a/k8s/argo-workflows/miroir-ci.yaml +++ b/k8s/argo-workflows/miroir-ci.yaml @@ -27,7 +27,7 @@ spec: volumes: - name: ghcr-config secret: - secretName: ghcr-credentials + secretName: ghcr-jedarden-registry items: - key: .dockerconfigjson path: config.json @@ -89,7 +89,7 @@ spec: - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token volumeMounts: - name: workspace @@ -394,7 +394,7 @@ EOF - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token volumeMounts: - name: workspace @@ -494,7 +494,7 @@ EOF - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token volumeMounts: - name: workspace @@ -518,7 +518,7 @@ EOF apk add --no-cache jq # Parse Docker config JSON for GHCR auth - # The ghcr-credentials secret uses Docker config JSON format + # The ghcr-jedarden-registry secret uses Docker config JSON format DOCKER_CONFIG="/kaniko/.docker/config.json" AUTH=$(jq -r '.auths."ghcr.io".auth' "$DOCKER_CONFIG") if [ "$AUTH" = "null" ]; then diff --git a/k8s/argo-workflows/miroir-release.yaml b/k8s/argo-workflows/miroir-release.yaml index bd6782c..c7f2c70 100644 --- a/k8s/argo-workflows/miroir-release.yaml +++ b/k8s/argo-workflows/miroir-release.yaml @@ -89,7 +89,7 @@ spec: volumes: - name: docker-config secret: - secretName: ghcr-credentials + secretName: ghcr-jedarden-registry items: - key: .dockerconfigjson path: config.json @@ -177,12 +177,12 @@ spec: - name: GHCR_TOKEN valueFrom: secretKeyRef: - name: ghcr-credentials + name: github-webhook-secret key: token - name: GITHUB_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token # ------------------------------------------------------------------ # @@ -258,7 +258,7 @@ spec: - name: GH_TOKEN valueFrom: secretKeyRef: - name: github-token + name: github-webhook-secret key: token resources: requests: diff --git a/lcov.info b/lcov.info deleted file mode 100644 index 03ed101..0000000 --- a/lcov.info +++ /dev/null @@ -1,17 +0,0 @@ -Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover Branches Missed Branches Cover ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ -anti_entropy.rs 56 0 100.00% 7 0 100.00% 70 0 100.00% 0 0 - -config.rs 293 26 91.13% 30 5 83.33% 306 18 94.12% 0 0 - -config/advanced.rs 166 16 90.36% 32 2 93.75% 288 20 93.06% 0 0 - -config/load.rs 159 77 51.57% 9 2 77.78% 140 28 80.00% 0 0 - -config/validate.rs 86 27 68.60% 1 0 100.00% 108 46 57.41% 0 0 - -merger.rs 977 31 96.83% 49 4 91.84% 582 31 94.67% 0 0 - -migration.rs 721 163 77.39% 43 12 72.09% 467 104 77.73% 0 0 - -reshard.rs 455 47 89.67% 36 7 80.56% 324 34 89.51% 0 0 - -router.rs 1016 26 97.44% 60 1 98.33% 500 19 96.20% 0 0 - -scatter.rs 214 0 100.00% 11 0 100.00% 121 0 100.00% 0 0 - -score_comparability.rs 589 10 98.30% 32 0 100.00% 325 9 97.23% 0 0 - -task.rs 164 0 100.00% 16 0 100.00% 118 0 100.00% 0 0 - -topology.rs 776 0 100.00% 70 0 100.00% 421 0 100.00% 0 0 - ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ -TOTAL 5672 423 92.54% 396 33 91.67% 3770 309 91.80% 0 0 - \ No newline at end of file diff --git a/librust_out.rlib b/librust_out.rlib deleted file mode 100644 index 5381963..0000000 Binary files a/librust_out.rlib and /dev/null differ