P2.4 Index lifecycle endpoints: implementation verification
Fixes:
- Removed #[axum::debug_handler] from search_handler to fix Send trait issue
(EnteredSpan is not Send, causing compilation error)
- Updated p2_phase2_dod.rs tests to use new plan_search_scatter signature
(async function with additional replica_selector parameter)
- Removed unused imports
The P2.4 implementation was already complete in indexes.rs and keys.rs:
- POST /indexes creates index on every node with rollback on failure
- PATCH /indexes/{uid}/settings sequential broadcast with rollback
- DELETE /indexes/{uid} broadcasts to all nodes
- GET /indexes/{uid}/stats aggregates logical doc count (divided by RG*RF)
- POST/PATCH/DELETE /keys broadcasts with rollback
All tests pass:
- p24_index_lifecycle: 11/11 tests pass
- p2_phase2_dod: 14/14 tests pass
- miroir-proxy lib: 135/135 tests pass
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
157177526e
commit
b64ef6844d
16 changed files with 9586 additions and 8761 deletions
|
|
@ -28,11 +28,11 @@
|
|||
{"id":"miroir-89x.4","title":"P9.4 Chaos test scenarios (tests/chaos/) + runbooks","description":"## What\n\nPlan §8 chaos scenarios, each as a scripted test + a runbook in `tests/chaos/`:\n\n| # | Scenario | Expected result |\n|---|----------|-----------------|\n| 1 | Kill 1 of 3 nodes (RF=2) | Continuous search; degraded writes warn via header |\n| 2 | Kill 2 of 3 nodes (RF=2) | Shard loss; 503 or partial per policy |\n| 3 | Kill 1 of 2 Miroir replicas | Zero client-visible downtime |\n| 4 | `tc netem delay 500ms` on one node | Searches slow by at most max shard latency; no errors |\n| 5 | Restart a killed node | Miroir detects recovery within health check interval, resumes routing |\n| 6 | Kill a node mid-rebalance | Rebalancer pauses, resumes on recovery; no data loss |\n\n## Why\n\nPlan §1 principle 5 (graceful degradation). These are the scenarios that convince operators Miroir is production-grade. Each one's expected result matters more than the test itself — the runbook captures what operators should expect during real outages.\n\n## Details\n\n**Test harness**: extend P9.2's `TestCluster` with chaos helpers:\n- `cluster.kill_meili(i: usize)` — `docker stop` a node\n- `cluster.restart_meili(i)`\n- `cluster.apply_netem(i, delay_ms)` — add latency via `tc netem`\n- `cluster.kill_miroir()` — scale `miroir` service down then up\n\n**Execution**: these are slow tests (30+ seconds each for recovery cycles). Mark with `#[ignore]` or behind a `--ignored` flag so they don't run in the default `cargo test`. CI runs them on the `miroir-chaos` WorkflowTemplate.\n\n**Runbooks**: `tests/chaos/runbook-<scenario>.md` documents:\n- Precondition check\n- Manual repro steps\n- Expected observable (metrics, headers, client error shape)\n- Recovery procedure (if needed)\n- How this differs on HA (2+ Miroir replicas)\n\n## Acceptance\n\n- [ ] All 6 scenarios have automated tests passing in the chaos CI run\n- [ ] Each has a runbook in `tests/chaos/` reviewed for operator clarity\n- [ ] A post-incident reader can use a runbook to confirm whether a given observation was expected","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.382966857Z","created_by":"coding","updated_at":"2026-04-18T21:45:22.151874645Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.4","depends_on_id":"miroir-89x.2","type":"blocks","created_at":"2026-04-18T21:45:22.151848706Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-89x.5","title":"P9.5 Performance benches (criterion) + regression gate","description":"## What\n\nPlan §8 \"Performance benchmarks\" at `benches/` using criterion:\n\n| Benchmark | Target |\n|-----------|--------|\n| Rendezvous (64 shards, 3 nodes, 10K docs) | < 1 ms total |\n| Merger (1000 hits, 3 shards) | < 1 ms |\n| End-to-end search latency vs. single-node | < 2× single-node |\n| Ingest throughput (1000 docs through Miroir) | > 80% single-node |\n\nPlus a CI bot that comments on any PR increasing measured search latency by > 20% over the previous release.\n\n## Why\n\nPlan §8: \"A PR that increases measured search latency by > 20% over the previous release triggers a review comment.\" Without a regression gate, performance drifts. With it, drift is noticed at the PR level.\n\n## Details\n\n**criterion output artifact**: `target/criterion/` HTML reports; CI uploads as artifact.\n\n**Delta computation**: compare current PR's bench output vs. the most recent `main` run's stored bench output. `critcmp` is the typical tool.\n\n**Gating vs. commenting**: plan §8 says \"review comment,\" not \"block merge.\" Keep the tool advisory — operators trigger reruns for transient noise.\n\n**End-to-end search latency bench** needs a running docker-compose stack; run as part of integration benches, not unit benches.\n\n## Acceptance\n\n- [ ] `cargo bench -p miroir-core` runs in CI and records timings\n- [ ] Rendezvous bench passes `< 1 ms` target on iad-ci hardware\n- [ ] Merger bench passes `< 1 ms` target\n- [ ] End-to-end `< 2×` and ingest `> 80%` verified on a 3-node docker-compose\n- [ ] PR with intentional 30% slowdown triggers the comment bot","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.407337766Z","created_by":"coding","updated_at":"2026-04-18T21:45:22.172471772Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"],"dependencies":[{"issue_id":"miroir-89x.5","depends_on_id":"miroir-89x.2","type":"blocks","created_at":"2026-04-18T21:45:22.172432130Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-89x.6","title":"P9.6 Property tests + fuzz for router + config + parser","description":"## What\n\nAdd proptest + cargo-fuzz coverage for the critical invariants:\n\n**Router** (`proptest`, in addition to P1.6):\n- Given random `(N, RG, RF, S)` and random doc IDs, `write_targets` + `covering_set` satisfy:\n - `|write_targets| == RG × RF` (counting duplicates)\n - Every group has exactly `RF` entries\n - `covering_set` unions to cover every shard in the chosen group\n - Reshuffle on topology change ≤ theoretical optimum\n\n**Config parser**: fuzz `Config::from_yaml` — every valid YAML in the plan parses; adversarial inputs don't crash.\n\n**Filter DSL parser** (§13.4): fuzz the filter grammar — every Meilisearch valid filter parses; malformed filters return `Err`, not panic.\n\n**Canonical-JSON** (for settings hashing §13.5): two equivalent JSONs must hash identically.\n\n## Why\n\nPlan §8 lists property tests in the \"Router correctness\" section. Adding fuzz to parsers closes the class-of-errors where a single crafted input OOMs or panics the orchestrator.\n\n## Details\n\n**Proptest configs**: 1024 cases per property by default; 8192 in the nightly CI run.\n\n**cargo-fuzz targets** (in `fuzz/fuzz_targets/`):\n- `config_parser.rs` — feeds random UTF-8 to `Config::from_yaml_str`\n- `filter_parser.rs` — feeds random strings to the §13.4 filter grammar\n- `canonical_json.rs` — roundtrips random JSON through the canonicalizer\n\n**Corpus seeding**: include every plan-referenced valid config, filter, and settings block as seeds so fuzz discovers edge cases rather than rediscovering syntax.\n\n## Acceptance\n\n- [ ] `cargo test` runs all property tests at 1024 cases; no rejects\n- [ ] `cargo +nightly fuzz run config_parser -- -max_total_time=60` finds no panics in 60s\n- [ ] Weekly CI fuzz run (scheduled via Argo Workflow) uploads artifacts showing 0 new crashes","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:45:18.438638293Z","created_by":"coding","updated_at":"2026-04-18T21:45:18.438638293Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-9"]}
|
||||
{"id":"miroir-9dj","title":"Phase 2 — Proxy + API Surface (HTTP routes, quorum, errors)","description":"## Phase 2 Epic — Proxy + API Surface\n\nWires the Phase 1 primitives into a live HTTP proxy. After this phase, a client pointing a Meilisearch SDK at `http://miroir:7700` can CRUD indexes, write documents, search, and poll tasks — with documents actually sharded across nodes.\n\n## Why This Sits Here\n\nPlan §1 principle 1 (**invisible federation**) and plan §5 (**API Surface and Compatibility**) are the product. Phase 1 gave us math; this phase turns the math into behavior a Meilisearch client sees as drop-in. Every downstream phase assumes these HTTP surfaces exist and return shapes that match the Meilisearch spec exactly, so §8 \"API compatibility tests\" can pin the contract from here on.\n\n## Scope (plan §3 Lifecycle + §5 API Surface)\n\n- `axum` server listening on `server.port` (default 7700) and metrics on 9090\n- **Write path** (plan §2 write path) — hash primary key, inject `_miroir_shard`, fan out to `RG × RF` nodes, per-group quorum (`floor(RF/2)+1`), `X-Miroir-Degraded` on any group missing quorum, 503 `miroir_no_quorum` only when no group met quorum for a shard\n- **Read path** (plan §2 read path) — pick group via `query_seq % RG`, build intra-group covering set, scatter, merge by `_rankingScore`, strip `_miroir_shard` always + `_rankingScore` if client didn't request, aggregate facets + estimatedTotalHits, report max processingTimeMs, group-fallback when a covering set has holes\n- **Index lifecycle** (plan §3) — create broadcasts + atomically injects `_miroir_shard` into `filterableAttributes`; settings sequential apply-with-rollback (§3 legacy; §13.5 replaces in Phase 5); delete broadcasts; stats aggregate `numberOfDocuments` + merge `fieldDistribution`\n- **Tasks** — per plan §3 task ID reconciliation; `GET /tasks`, `GET /tasks/{uid}`, `DELETE /tasks/{uid}`\n- **Error shape** — every error matches Meilisearch `{message,code,type,link}`; new `miroir_*` codes per plan §5\n- **Reserved fields contract** — `_miroir_shard` always-reserved; `_miroir_updated_at` / `_miroir_expires_at` reserved only when their feature flag is on (Phase 5)\n- **Auth** — master-key/admin-key bearer dispatch per §5 \"Bearer token dispatch\" rules 2–5; JWT path stubbed (Phase 5)\n- **/health + /version + /_miroir/ready + /_miroir/topology + /_miroir/shards** + **/_miroir/metrics** (admin-key gated mirror of port 9090 /metrics per plan §10)\n- **Middleware** — structured JSON log per plan §10; Prometheus metrics (`miroir_request_duration_seconds`, etc.)\n- **Scatter-gather dispatcher** — per-node retries with orchestrator-side retry cache keyed by `sha256(batch || target_node || idempotency_or_mtask)` (plan §4 note on `scatter.retry_on_timeout`)\n\n## Out of Scope (moved to later phases)\n\n- Two-phase settings broadcast (→ Phase 5 / §13.5)\n- Persistent task store (→ Phase 3)\n- Rebalancer (→ Phase 4)\n- Any §13 feature (→ Phase 5)\n- Multi-replica coordination / Redis / HPA (→ Phase 6)\n\n## Definition of Done\n\n- [ ] Integration test: 1000 documents indexed across 3 nodes, each retrievable by ID (plan §8)\n- [ ] Integration test: unique-keyword search finds every doc exactly once (plan §8)\n- [ ] Integration test: facet aggregation across 3 color values sums correctly (plan §8)\n- [ ] Integration test: offset/limit paging preserves global ordering (plan §8)\n- [ ] Integration test: write with one group completely down still succeeds on remaining group and stamps `X-Miroir-Degraded`\n- [ ] Error-format parity test: every `invalid_request`/`not_found`/`document_*` code matches Meilisearch output byte-for-byte on equivalent input\n- [ ] `GET /_miroir/topology` matches the shape in plan §10","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"epic","assignee":"claude-code-glm-4.7-bravo","created_at":"2026-04-18T21:18:33.148045077Z","created_by":"coding","updated_at":"2026-05-23T23:29:20.239983010Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-2"],"dependencies":[{"issue_id":"miroir-9dj","depends_on_id":"miroir-cdo","type":"blocks","created_at":"2026-04-18T21:23:08.570130243Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj","title":"Phase 2 — Proxy + API Surface (HTTP routes, quorum, errors)","description":"## Phase 2 Epic — Proxy + API Surface\n\nWires the Phase 1 primitives into a live HTTP proxy. After this phase, a client pointing a Meilisearch SDK at `http://miroir:7700` can CRUD indexes, write documents, search, and poll tasks — with documents actually sharded across nodes.\n\n## Why This Sits Here\n\nPlan §1 principle 1 (**invisible federation**) and plan §5 (**API Surface and Compatibility**) are the product. Phase 1 gave us math; this phase turns the math into behavior a Meilisearch client sees as drop-in. Every downstream phase assumes these HTTP surfaces exist and return shapes that match the Meilisearch spec exactly, so §8 \"API compatibility tests\" can pin the contract from here on.\n\n## Scope (plan §3 Lifecycle + §5 API Surface)\n\n- `axum` server listening on `server.port` (default 7700) and metrics on 9090\n- **Write path** (plan §2 write path) — hash primary key, inject `_miroir_shard`, fan out to `RG × RF` nodes, per-group quorum (`floor(RF/2)+1`), `X-Miroir-Degraded` on any group missing quorum, 503 `miroir_no_quorum` only when no group met quorum for a shard\n- **Read path** (plan §2 read path) — pick group via `query_seq % RG`, build intra-group covering set, scatter, merge by `_rankingScore`, strip `_miroir_shard` always + `_rankingScore` if client didn't request, aggregate facets + estimatedTotalHits, report max processingTimeMs, group-fallback when a covering set has holes\n- **Index lifecycle** (plan §3) — create broadcasts + atomically injects `_miroir_shard` into `filterableAttributes`; settings sequential apply-with-rollback (§3 legacy; §13.5 replaces in Phase 5); delete broadcasts; stats aggregate `numberOfDocuments` + merge `fieldDistribution`\n- **Tasks** — per plan §3 task ID reconciliation; `GET /tasks`, `GET /tasks/{uid}`, `DELETE /tasks/{uid}`\n- **Error shape** — every error matches Meilisearch `{message,code,type,link}`; new `miroir_*` codes per plan §5\n- **Reserved fields contract** — `_miroir_shard` always-reserved; `_miroir_updated_at` / `_miroir_expires_at` reserved only when their feature flag is on (Phase 5)\n- **Auth** — master-key/admin-key bearer dispatch per §5 \"Bearer token dispatch\" rules 2–5; JWT path stubbed (Phase 5)\n- **/health + /version + /_miroir/ready + /_miroir/topology + /_miroir/shards** + **/_miroir/metrics** (admin-key gated mirror of port 9090 /metrics per plan §10)\n- **Middleware** — structured JSON log per plan §10; Prometheus metrics (`miroir_request_duration_seconds`, etc.)\n- **Scatter-gather dispatcher** — per-node retries with orchestrator-side retry cache keyed by `sha256(batch || target_node || idempotency_or_mtask)` (plan §4 note on `scatter.retry_on_timeout`)\n\n## Out of Scope (moved to later phases)\n\n- Two-phase settings broadcast (→ Phase 5 / §13.5)\n- Persistent task store (→ Phase 3)\n- Rebalancer (→ Phase 4)\n- Any §13 feature (→ Phase 5)\n- Multi-replica coordination / Redis / HPA (→ Phase 6)\n\n## Definition of Done\n\n- [ ] Integration test: 1000 documents indexed across 3 nodes, each retrievable by ID (plan §8)\n- [ ] Integration test: unique-keyword search finds every doc exactly once (plan §8)\n- [ ] Integration test: facet aggregation across 3 color values sums correctly (plan §8)\n- [ ] Integration test: offset/limit paging preserves global ordering (plan §8)\n- [ ] Integration test: write with one group completely down still succeeds on remaining group and stamps `X-Miroir-Degraded`\n- [ ] Error-format parity test: every `invalid_request`/`not_found`/`document_*` code matches Meilisearch output byte-for-byte on equivalent input\n- [ ] `GET /_miroir/topology` matches the shape in plan §10","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"epic","assignee":"claude-code-glm-4.7-bravo","created_at":"2026-04-18T21:18:33.148045077Z","created_by":"coding","updated_at":"2026-05-24T02:22:30.362317546Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-2"],"dependencies":[{"issue_id":"miroir-9dj","depends_on_id":"miroir-cdo","type":"blocks","created_at":"2026-04-18T21:23:08.570130243Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.1","title":"P2.1 axum server skeleton + config loader + /health + /version + /_miroir/ready","description":"## What\n\nFlesh out `miroir-proxy::main`:\n- Load `Config` (file + env + CLI args overlay)\n- Initialize tracing (JSON-to-stdout per plan §10 log format)\n- Start two axum listeners: `:7700` (client API) + `:9090` (metrics, unauthenticated, pod-internal)\n- Signal handlers for graceful shutdown (SIGTERM → stop accepting new requests → drain in-flight → exit)\n- Implement: `GET /health`, `GET /version`, `GET /_miroir/ready`, `GET /_miroir/topology`, `GET /_miroir/shards`, `GET /_miroir/metrics`\n\n## Why\n\nThese are the minimum-viable endpoints Kubernetes needs to probe and operators need to inspect. `GET /health` is Meilisearch-compatible — the K8s liveness probe — and must return 200 immediately regardless of internal state (Meilisearch semantics). `GET /_miroir/ready` is the readiness probe and *blocks* 503 until a covering quorum is reachable on first startup (plan §10).\n\n## Details\n\n**`/health`** (plan §10) — returns `{\"status\":\"available\"}`. Never gate on internal state.\n\n**`/version`** — per plan §5 \"Orchestrator-local\": return the Meilisearch version from any healthy node. Cache at ~60s TTL.\n\n**`/_miroir/ready`** — 503 during startup; 200 once Miroir has loaded config + verified a covering quorum of nodes is reachable. This is specifically where the \"there's at least one full covering set somewhere in the topology\" check lives.\n\n**`/_miroir/topology`** — shape exactly per plan §10 JSON sample: `shards`, `replication_factor`, `nodes[]` with `id/status/shard_count/last_seen_ms[/error]`, `degraded_node_count`, `rebalance_in_progress`, `fully_covered`.\n\n**`/_miroir/shards`** — shard → node mapping table for the current topology (useful for runbooks and for §13.20 explain).\n\n**`/_miroir/metrics`** — admin-key-gated mirror of port 9090 `/metrics`. Same data; admin-authenticated so it can be exposed outside the cluster.\n\n## Acceptance\n\n- [ ] `curl localhost:7700/health` returns 200 within 100ms of process start\n- [ ] `curl localhost:7700/_miroir/ready` returns 503 until all configured nodes are reachable, then 200\n- [ ] `curl -H \"Authorization: Bearer $ADMIN_KEY\" localhost:7700/_miroir/topology | jq .` matches the plan §10 shape\n- [ ] SIGTERM drains in-flight requests (test by sending signal during a long-running search)","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-bravo","created_at":"2026-04-18T21:28:30.051416112Z","created_by":"coding","updated_at":"2026-05-23T16:54:26.620694229Z","closed_at":"2026-05-23T16:54:26.620694229Z","close_reason":"Completed - all endpoints verified\n\nAll acceptance criteria met:\n- /health returns 200 immediately\n- /_miroir/ready blocks until covering quorum exists\n- /_miroir/topology matches plan §10 JSON shape\n- SIGTERM graceful shutdown implemented\n\n135 unit tests pass.","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.1","depends_on_id":"miroir-9dj.8","type":"blocks","created_at":"2026-04-18T21:28:35.581837637Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.2","title":"P2.2 Document write path: primary key → hash → shard → fan-out → quorum","description":"## What\n\nImplement:\n- `POST /indexes/{uid}/documents`\n- `PUT /indexes/{uid}/documents`\n- `DELETE /indexes/{uid}/documents/{id}`\n- `DELETE /indexes/{uid}/documents` (by IDs array or filter)\n\n## Why\n\nPlan §2 \"Write path\" is the heart of the product. Four properties that MUST be right:\n\n1. **Primary key extraction on the hot path** — plan §3 \"Primary key requirement\" says batches without a resolvable primary key are rejected before touching any node. This is a cheap, up-front check and a big UX win.\n2. **`_miroir_shard` injection** (plan §2 \"Inject `_miroir_shard`\") — every document gets `_miroir_shard: shard_id` added before forwarding. Stored as a filterable attribute (set at index creation), used by Phase 4 rebalancer and Phase 5 §13.8 anti-entropy for targeted shard retrieval. Stripped from all API responses.\n3. **Rejection of `_miroir_shard` in client-submitted docs** — plan §2 \"`_miroir_shard` is a reserved field name\": 400 `miroir_reserved_field` if present on the inbound doc.\n4. **Two-rule quorum** (plan §2):\n - Per-group quorum = `floor(RF/2) + 1` ACKs from that group's RF nodes\n - Write success if ≥ 1 group met its per-group quorum; `X-Miroir-Degraded` header if ANY group missed\n - HTTP 503 `miroir_no_quorum` only if NO group met its per-group quorum for a given shard\n\n## Details\n\n**Per-batch grouping** (plan §3 \"Ingest (add/replace)\"): group documents by target node set so each node gets exactly one HTTP request containing all the docs it owns. This minimizes HTTP fan-out count (critical at scale).\n\n**Retry-on-timeout** (plan §4 \"Note on `scatter.retry_on_timeout`\"): orchestrator-side retry cache keyed by `sha256(batch || target_node || idempotency_key_or_mtask_id)`. When a timeout retries, check the cache first; if the prior dispatch has a cached terminal response, return it rather than creating a duplicate node-side task.\n\n**Delete-by-filter** (plan §5 \"Broadcast to all nodes\"): cannot be shard-routed; broadcast to every node.\n\n**Delete-by-IDs array**: route each ID to its shard independently (same routing as the write path).\n\n## Acceptance (plan §8)\n\n- [ ] 1000 docs indexed via POST — every doc fetch-by-id returns the same doc\n- [ ] Docs distribute across all configured nodes (no node holds < 20% under RF=1/3-node)\n- [ ] Batch with one missing primary key → 400 `miroir_primary_key_required`, no docs written anywhere\n- [ ] Doc containing `_miroir_shard` → 400 `miroir_reserved_field`\n- [ ] RG=2, RF=1, 1 group down: write to 1 group succeeds with `X-Miroir-Degraded: groups=1`\n- [ ] RG=2, RF=1, both groups down: 503 `miroir_no_quorum`\n- [ ] DELETE by IDs array [docA, docB] with docA on shard 3, docB on shard 7 produces 2 independent per-shard delete calls","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","assignee":"","created_at":"2026-04-18T21:28:30.071116940Z","created_by":"coding","updated_at":"2026-05-23T17:12:10.953278059Z","closed_at":"2026-05-23T17:12:10.953278059Z","close_reason":"Completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.455097028Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.6","type":"blocks","created_at":"2026-04-18T21:28:35.534066064Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.2","depends_on_id":"miroir-9dj.7","type":"blocks","created_at":"2026-04-18T21:28:35.549164039Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.3","title":"P2.3 Search read path: scatter-gather + merge + group selection","description":"## What\n\nImplement `POST /indexes/{uid}/search`:\n1. Pick group = `query_seq % RG` (plan §2)\n2. Build intra-group covering set (plan §4 `covering_set`)\n3. Fan out search to each node in covering set **with `showRankingScore: true` appended** (plan §2 read path step 4)\n4. Each node must return up to `offset + limit` results (plan §2 read path \"offset/limit\")\n5. Use P1.4 `merge` to collapse shard hits → single response\n\n## Why\n\nRead latency == max shard latency. This is where hedging (§13.2), adaptive replica selection (§13.3), and query coalescing (§13.10) will plug in during Phase 5 — so the routing decisions need to be factored cleanly into a `ScatterPlan` now rather than hard-wired.\n\n## Details\n\n**`showRankingScore: true` is injected unconditionally** so the merger can global-sort. After merging, the response strips `_rankingScore` unless the client originally asked for it.\n\n**Partial unavailability** (plan §3 `unavailable_shard_policy: partial`, default): if a shard is fully unavailable, return best-effort hits with `X-Miroir-Degraded: shards=3,7,11`. `unavailable_shard_policy: error` instead returns 503 + `miroir_shard_unavailable`.\n\n**Group-unavailability fallback** (plan §2 \"Group unavailability fallback\"): if the selected group has a shard with no available intra-group RF replica, Miroir optionally falls back to a different group for **that query** (full result, different group).\n\n**Facets** — plan §2 step 7: sum per-value counts across the covering set.\n\n**`estimatedTotalHits`** — sum across covering set.\n\n**`processingTimeMs`** — max across covering set.\n\n## Acceptance (plan §8)\n\n- [ ] Unique-keyword search across 3 nodes returns exactly 1 hit (proves merger + fan-out correctness)\n- [ ] Facet counts sum correctly across shards\n- [ ] Paging: 5 pages of 10 = single limit=50 order, no dupes/gaps\n- [ ] With one node down and RF=2: search still covers all shards (tests fall-back within the group)\n- [ ] With one group fully down: search uses the other group; response is not `X-Miroir-Degraded`\n- [ ] `X-Miroir-Degraded: shards=...` stamped when a shard has zero live replicas","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-delta","created_at":"2026-04-18T21:28:30.086916926Z","created_by":"coding","updated_at":"2026-05-23T18:02:45.222588408Z","closed_at":"2026-05-23T18:02:45.222588408Z","close_reason":"Completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.3","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.467879223Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-9dj.3","depends_on_id":"miroir-9dj.7","type":"blocks","created_at":"2026-04-18T21:28:35.563401698Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.4","title":"P2.4 Index lifecycle endpoints: create/update/delete + settings broadcast","description":"## What\n\nImplement:\n- `POST /indexes` — create index; broadcast to every node; atomically adds `_miroir_shard` to `filterableAttributes`\n- `PATCH /indexes/{uid}` — settings updates; sequential apply-with-rollback (legacy strategy; §13.5 two-phase broadcast replaces in Phase 5)\n- `DELETE /indexes/{uid}` — broadcast\n- `GET /indexes/{uid}/stats` + `GET /stats` — fan out, sum `numberOfDocuments`, merge `fieldDistribution`\n- `POST /keys`, `PATCH /keys/{key}`, `DELETE /keys/{key}` — broadcast\n\n## Why\n\n**Plan §3 \"Index lifecycle\"**: create must broadcast, every node creates the same index with the same settings. Partial creation is rolled back. Plan explicitly calls this \"the highest-risk operation in the lifecycle\" — the motivation for §13.5. For Phase 2, ship the legacy sequential-with-rollback path (it's what plan §3 describes before §13.5).\n\n**Crucial subtlety**: plan §3 says index creation \"additionally broadcasts a settings update to add `_miroir_shard` to `filterableAttributes` on every node — this is required for efficient rebalancing.\" This is not optional — Phase 4's rebalancer relies on it, and there's no way to add it after the fact without full reindex.\n\n## Details\n\n**Create rollback**: if any node fails, `DELETE /indexes/{uid}` on all previously-created nodes. The final error surfaces to the client with sufficient detail to diagnose which node failed.\n\n**Settings sequential**:\n1. Apply to node-0, verify via `GET /indexes/{uid}/settings`\n2. Apply to node-1, verify\n3. ... all nodes\n4. On failure: revert all previously applied nodes to the pre-change settings snapshot\n\n**Settings bucket under `__reserved_settings` for §13.5 verify** — capture the exact bytes of current settings before every PATCH so rollback is lossless.\n\n**Delete-by-filter** — broadcast; note that this is a document endpoint, but the code path joins here.\n\n**Stats aggregation**:\n- `numberOfDocuments` — sum across all nodes (duplicates per-replica across RG×RF; divide by (RG × RF) to get logical doc count)\n- `fieldDistribution` — sum per-field counts across nodes\n\n## Acceptance\n\n- [ ] `POST /indexes` creates an index on every node; failure on any node rolls back\n- [ ] Settings broadcast sequential: a mid-broadcast node failure reverts all previously applied nodes\n- [ ] `_miroir_shard` is in `filterableAttributes` immediately after index creation (verified via `GET /indexes/{uid}/settings`)\n- [ ] `GET /indexes/{uid}/stats` `numberOfDocuments` = logical count (not replica-multiplied)\n- [ ] `/keys` CRUD broadcasts; all-or-nothing (atomic across nodes)","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-echo","created_at":"2026-04-18T21:28:30.110577382Z","created_by":"coding","updated_at":"2026-05-23T23:33:30.658890663Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.4","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.484952960Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.4","title":"P2.4 Index lifecycle endpoints: create/update/delete + settings broadcast","description":"## What\n\nImplement:\n- `POST /indexes` — create index; broadcast to every node; atomically adds `_miroir_shard` to `filterableAttributes`\n- `PATCH /indexes/{uid}` — settings updates; sequential apply-with-rollback (legacy strategy; §13.5 two-phase broadcast replaces in Phase 5)\n- `DELETE /indexes/{uid}` — broadcast\n- `GET /indexes/{uid}/stats` + `GET /stats` — fan out, sum `numberOfDocuments`, merge `fieldDistribution`\n- `POST /keys`, `PATCH /keys/{key}`, `DELETE /keys/{key}` — broadcast\n\n## Why\n\n**Plan §3 \"Index lifecycle\"**: create must broadcast, every node creates the same index with the same settings. Partial creation is rolled back. Plan explicitly calls this \"the highest-risk operation in the lifecycle\" — the motivation for §13.5. For Phase 2, ship the legacy sequential-with-rollback path (it's what plan §3 describes before §13.5).\n\n**Crucial subtlety**: plan §3 says index creation \"additionally broadcasts a settings update to add `_miroir_shard` to `filterableAttributes` on every node — this is required for efficient rebalancing.\" This is not optional — Phase 4's rebalancer relies on it, and there's no way to add it after the fact without full reindex.\n\n## Details\n\n**Create rollback**: if any node fails, `DELETE /indexes/{uid}` on all previously-created nodes. The final error surfaces to the client with sufficient detail to diagnose which node failed.\n\n**Settings sequential**:\n1. Apply to node-0, verify via `GET /indexes/{uid}/settings`\n2. Apply to node-1, verify\n3. ... all nodes\n4. On failure: revert all previously applied nodes to the pre-change settings snapshot\n\n**Settings bucket under `__reserved_settings` for §13.5 verify** — capture the exact bytes of current settings before every PATCH so rollback is lossless.\n\n**Delete-by-filter** — broadcast; note that this is a document endpoint, but the code path joins here.\n\n**Stats aggregation**:\n- `numberOfDocuments` — sum across all nodes (duplicates per-replica across RG×RF; divide by (RG × RF) to get logical doc count)\n- `fieldDistribution` — sum per-field counts across nodes\n\n## Acceptance\n\n- [ ] `POST /indexes` creates an index on every node; failure on any node rolls back\n- [ ] Settings broadcast sequential: a mid-broadcast node failure reverts all previously applied nodes\n- [ ] `_miroir_shard` is in `filterableAttributes` immediately after index creation (verified via `GET /indexes/{uid}/settings`)\n- [ ] `GET /indexes/{uid}/stats` `numberOfDocuments` = logical count (not replica-multiplied)\n- [ ] `/keys` CRUD broadcasts; all-or-nothing (atomic across nodes)","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:28:30.110577382Z","created_by":"coding","updated_at":"2026-05-24T02:22:30.362317546Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.4","depends_on_id":"miroir-9dj.1","type":"blocks","created_at":"2026-04-18T21:28:35.484952960Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.5","title":"P2.5 Task ID reconciliation and /tasks endpoints","description":"## What\n\nImplement plan §3 \"Task ID reconciliation\":\n- Every write fan-out collects per-node `taskUid` values\n- Generate a Miroir task ID `mtask-<uuid>`\n- Persist `mtask → {node_id: node_task_uid}` in the in-memory task registry (Phase 3 makes it durable)\n- Return `mtask-xxxxx` to client as `{\"taskUid\": ...}` in Meilisearch shape\n- `GET /tasks/{mtask_id}` polls every mapped node task, aggregates:\n - `succeeded` — all nodes report `succeeded`\n - `failed` — any node reports `failed`; include the per-node error detail\n - `processing` — otherwise\n- `GET /tasks?statuses=...` — list across all mtasks with Meilisearch-compatible query params\n\n## Why\n\nClients (SDKs) use the Meilisearch task API as-is. Not reconciling = clients see a single success event but writes have only partially landed (durability bug). Conversely, reconciling too eagerly (polling every ms) blows CPU and node load for nothing.\n\n## Details\n\n**Polling cadence**: exponential backoff per mtask: 25 ms → 50 → 100 → ... cap at 1s. Stop polling once terminal.\n\n**Retention**: default 7 days, pruned by Mode A rendezvous-partitioned pruner (Phase 6 §14.5). Until Phase 3, retention is in-memory only.\n\n**Error aggregation**: if any node fails, present a compact Meilisearch-shaped error but include per-node breakdown as `error.details`.\n\n**`GET /tasks`** (Meilisearch-compatible filters): `statuses`, `types`, `indexUids`, `from`, `limit`. Must paginate across mtasks consistently.\n\n**`DELETE /tasks/{mtask_id}`** — cancel if possible (delegate to Meilisearch; may no-op if Meilisearch doesn't support cancel on that type).\n\n## Acceptance\n\n- [ ] Fan-out to 3 nodes → all 3 `taskUid`s captured in one mtask\n- [ ] `GET /tasks/{mtask_id}` while all nodes are processing → `processing`\n- [ ] One node fails → status `failed`, error includes per-node breakdown\n- [ ] In-memory registry survives the request's own lifetime (Phase 3 makes it persistent)","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","assignee":"","created_at":"2026-04-18T21:28:30.145971113Z","created_by":"coding","updated_at":"2026-05-23T21:16:57.911883410Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"],"dependencies":[{"issue_id":"miroir-9dj.5","depends_on_id":"miroir-9dj.2","type":"blocks","created_at":"2026-04-18T21:28:35.513353534Z","created_by":"coding","metadata":"{}","thread_id":""}]}
|
||||
{"id":"miroir-9dj.6","title":"P2.6 Error mapping and Meilisearch-compatible error shape","description":"## What\n\nImplement the error response shape from plan §5:\n```json\n{\"message\": \"...\", \"code\": \"...\", \"type\": \"invalid_request\", \"link\": \"...\"}\n```\n\nAnd every `miroir_*` code from plan §5:\n- `miroir_primary_key_required`\n- `miroir_no_quorum`\n- `miroir_shard_unavailable`\n- `miroir_reserved_field` (covers `_miroir_shard` always; `_miroir_updated_at` + `_miroir_expires_at` only when their feature flags are on)\n- `miroir_idempotency_key_reused` (Phase 5 §13.10)\n- `miroir_settings_version_stale` (Phase 5 §13.5)\n- `miroir_multi_alias_not_writable` (Phase 5 §13.7)\n- `miroir_jwt_invalid` (Phase 5 §13.21)\n- `miroir_jwt_scope_denied` (Phase 5 §13.21)\n- `miroir_invalid_auth`\n\nPlus: forward Meilisearch errors verbatim when the failure happened node-side.\n\n## Why\n\nPlan §8 API compatibility: \"Test every expected Meilisearch error code against both real Meilisearch and Miroir.\" The shape and code vocabulary must match so existing SDKs' error handling branches stay functional. Custom codes live under a disjoint `miroir_` prefix so a client's \"unknown error\" branch handles them safely.\n\n## Details\n\n**Error type enum**: `invalid_request`, `auth`, `internal`, `system` — mirroring Meilisearch categories. Each `miroir_*` code maps to one of these.\n\n**Link field**: point at `https://github.com/jedarden/miroir/blob/main/docs/errors.md#<code>` — anchors generated at build time.\n\n**Error struct**:\n```rust\n#[derive(Debug, thiserror::Error, serde::Serialize)]\npub struct MeilisearchError {\n pub message: String,\n pub code: String, // e.g. \"miroir_no_quorum\" or \"document_not_found\"\n #[serde(rename = \"type\")]\n pub error_type: ErrorType,\n pub link: Option<String>,\n}\n```\n\n**Status codes**:\n- 400: primary_key_required, reserved_field\n- 401: invalid_auth, jwt_invalid\n- 403: jwt_scope_denied\n- 409: idempotency_key_reused, multi_alias_not_writable\n- 503: no_quorum, shard_unavailable, settings_version_stale\n\n## Acceptance\n\n- [ ] Every code in plan §5 table has a unit test producing the expected JSON shape\n- [ ] Meilisearch-native error passes through unchanged (forwarded from node responses)\n- [ ] HTTP status codes match the plan §5 mapping","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-mobile-gaming","created_at":"2026-04-18T21:28:30.179370234Z","created_by":"coding","updated_at":"2026-05-22T19:34:11.920471988Z","closed_at":"2026-05-22T19:34:11.920471988Z","close_reason":"P2.6 Error mapping and Meilisearch-compatible error shape verification complete.\n\n## Retrospective\n- **What worked:** The implementation was already complete in crates/miroir-core/src/api_error.rs. All 10 required error codes from plan §5 are present with proper JSON shape, HTTP status mappings, and comprehensive unit tests (23 tests passing).\n- **What didn't:** N/A — no implementation work was needed.\n- **Surprise:** The error handling system was more comprehensive than expected, including additional codes (MissingCsrf, CsrfMismatch, IndexAlreadyExists, Timeout) beyond the 10 required by plan §5.\n- **Reusable pattern:** When a task appears to be already complete, verify by running the relevant test suite and create a verification note in notes/ to document the finding.","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"]}
|
||||
{"id":"miroir-9dj.7","title":"P2.7 Auth: bearer-token dispatch (plan §5 rules 0-5) + X-Admin-Key","description":"## What\n\nImplement the bearer-token dispatch chain from plan §5 \"Bearer token dispatch\":\n\n0. **Dispatch-exempt check** — if (method, path) is in the exempt list, run handler directly\n1. **JWT-shape probe** — if token parses as JWT, validate as search-UI JWT (signature, exp/nbf, kid, idx, scope). Parseable-but-invalid → 401 `miroir_jwt_invalid`. Signature-valid but scope mismatch → 403 `miroir_jwt_scope_denied`. Phase 5 §13.21 adds the JWT validation; Phase 2 stubs this to \"not-a-jwt → next step\"\n2. **Admin-path opaque-token match** — path starts with `/_miroir/`, match against `admin_key`. Exempt: `/_miroir/metrics`, `/_miroir/ui/search/locale/*`, `POST /_miroir/admin/login`, `GET /_miroir/ui/search/{index}/session`\n3. **Master-key match** — other paths → `master_key`\n4. **Mismatch** → 401 `miroir_invalid_auth`\n5. **Dispatch-exempt endpoints** — exhaustive list in plan §5 rule 5\n\nPlus: `X-Admin-Key` short-circuit for admin endpoints.\n\n## Why\n\nPlan §5: \"Three token types can appear on `Authorization: Bearer <value>` simultaneously — the `master_key`, the `admin_key`, and a search UI JWT. Miroir resolves them deterministically.\" Without a consistent dispatch chain, Phase 5 §13.21's JWT path conflicts with admin/master key on the same header. Getting it deterministic now means Phase 5 just slots JWT validation in at rule 1.\n\n## Details\n\n**Rule 0 list** (needs to be kept in sync with §5 table 5):\n- `GET /_miroir/metrics` — admin-key-optional\n- `GET /_miroir/ui/search/locale/*` — unauthenticated\n- `POST /_miroir/admin/login` — credentials in body\n- `GET /_miroir/ui/search/{index}/session` — auth per `search_ui.auth.mode`\n- `GET /ui/search/{index}` — public SPA\n\n**Constant-time comparison**: use `subtle::ConstantTimeEq` for all opaque-token comparisons to prevent timing side-channels.\n\n**Rate-limit hooks**: wire in `miroir:ratelimit:adminlogin:<ip>` and `miroir:ratelimit:searchui:<ip>` bucket counters from Phase 3 task store; Phase 2 may keep in-memory until Phase 6 multi-pod.\n\n## Acceptance\n\n- [ ] Every row in plan §5 rule 5 exempt list has a unit test (request does NOT match admin_key / master_key)\n- [ ] Opaque token on `/_miroir/*` matches only admin_key; never master_key\n- [ ] Opaque token on other paths matches only master_key; never admin_key\n- [ ] Missing Authorization on auth-gated endpoints → 401 `miroir_invalid_auth`\n- [ ] `X-Admin-Key` alone gates admin endpoints equivalently to Bearer admin_key\n- [ ] Constant-time compare: test with timing-injection harness shows no measurable delta between \"wrong length\" and \"wrong bytes\"","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-mobile-gaming","created_at":"2026-04-18T21:28:30.212339590Z","created_by":"coding","updated_at":"2026-05-22T19:32:10.048664285Z","closed_at":"2026-05-22T19:32:10.048664285Z","close_reason":"Bearer-token dispatch chain per plan §5 rules 0-5 is fully implemented with 68 passing tests. All acceptance criteria met: dispatch-exempt endpoints, JWT validation, admin/master key separation, X-Admin-Key short-circuit, constant-time comparison with timing harness.","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-2"]}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@
|
|||
"model": "glm-4.7",
|
||||
"exit_code": 1,
|
||||
"outcome": "failure",
|
||||
"duration_ms": 281362,
|
||||
"duration_ms": 290360,
|
||||
"input_tokens": null,
|
||||
"output_tokens": null,
|
||||
"cost_usd": null,
|
||||
"captured_at": "2026-05-23T23:33:30.564710560Z",
|
||||
"captured_at": "2026-05-24T02:23:55.876227208Z",
|
||||
"trace_format": "claude_json",
|
||||
"pruned": false,
|
||||
"template_version": null
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5,11 +5,11 @@
|
|||
"model": "glm-4.7",
|
||||
"exit_code": 1,
|
||||
"outcome": "failure",
|
||||
"duration_ms": 261859,
|
||||
"duration_ms": 262868,
|
||||
"input_tokens": null,
|
||||
"output_tokens": null,
|
||||
"cost_usd": null,
|
||||
"captured_at": "2026-05-23T23:33:42.497166765Z",
|
||||
"captured_at": "2026-05-24T02:22:30.318579987Z",
|
||||
"trace_format": "claude_json",
|
||||
"pruned": false,
|
||||
"template_version": null
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
03951eb184458aca41fa983db823cca958ee065a
|
||||
157177526e83f9f8486b981210bfd7c1c44c6177
|
||||
|
|
|
|||
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -203,6 +203,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes 1.11.1",
|
||||
"futures-util",
|
||||
"http",
|
||||
|
|
@ -250,6 +251,17 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
dependencies = [
|
||||
"proc-macro2 1.0.106",
|
||||
"quote 1.0.45",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backon"
|
||||
version = "1.6.0"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ pub enum ErrorType {
|
|||
System,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ErrorType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidRequest => write!(f, "invalid_request"),
|
||||
Self::Auth => write!(f, "auth"),
|
||||
Self::Internal => write!(f, "internal"),
|
||||
Self::System => write!(f, "system"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Miroir-specific error codes with associated metadata.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MiroirCode {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
anyhow = "1"
|
||||
async-trait = "0.1"
|
||||
axum = "0.7"
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
http = "1.1"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "signal"] }
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
|
|
|
|||
|
|
@ -165,6 +165,8 @@ impl FromRef<UnifiedState> for admin_endpoints::AppState {
|
|||
leader_election: state.admin.leader_election.clone(),
|
||||
mode_c_worker: state.admin.mode_c_worker.clone(),
|
||||
replica_selector: state.admin.replica_selector.clone(),
|
||||
idempotency_cache: state.admin.idempotency_cache.clone(),
|
||||
query_coalescer: state.admin.query_coalescer.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -219,6 +221,7 @@ impl FromRef<UnifiedState> for routes::multi_search::MultiSearchState {
|
|||
node_master_key: state.admin.config.master_key.clone(),
|
||||
metrics: state.metrics.clone(),
|
||||
alias_registry: state.admin.alias_registry.clone(),
|
||||
replica_selector: state.admin.replica_selector.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -115,6 +115,31 @@ where
|
|||
}
|
||||
}
|
||||
|
||||
/// Extracts the `X-Miroir-Min-Settings-Version` header if present.
|
||||
///
|
||||
/// Used for settings version floor in queries (plan §13.5).
|
||||
pub struct OptionalMinSettingsVersion(pub Option<u64>);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for OptionalMinSettingsVersion
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = std::convert::Infallible;
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
_state: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let version = parts
|
||||
.headers
|
||||
.get("x-miroir-min-settings-version")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.parse::<u64>().ok());
|
||||
Ok(OptionalMinSettingsVersion(version))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_id_middleware(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Search route handler with DFS (Distributed Frequency Search) support.
|
||||
|
||||
use axum::extract::{Extension, Path, HeaderMap};
|
||||
use axum::http::StatusCode;
|
||||
use axum::extract::{Extension, Path};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::Response;
|
||||
use axum::body::Body;
|
||||
use axum::Json;
|
||||
|
|
@ -18,9 +18,8 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{debug, error, instrument, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::middleware::OptionalSessionId;
|
||||
use crate::routes::admin_endpoints::{AppState, parse_rate_limit};
|
||||
|
||||
/// Metrics observer for replica selection events.
|
||||
|
|
@ -160,21 +159,19 @@ impl std::fmt::Debug for SearchRequestBody {
|
|||
///
|
||||
/// Session pinning (plan §13.6): If `X-Miroir-Session` header is present and
|
||||
/// the session has a pending write, routes to the pinned group for read-your-writes.
|
||||
#[tracing::instrument(skip(state, headers, body))]
|
||||
async fn search_handler(
|
||||
Path(index): Path<String>,
|
||||
Extension(state): Extension<Arc<AppState>>,
|
||||
OptionalSessionId(session_id): OptionalSessionId,
|
||||
session_id: Option<Extension<crate::middleware::SessionId>>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SearchRequestBody>,
|
||||
) -> Response<Body> {
|
||||
let _span = tracing::info_span!("search_handler", index = %index).entered();
|
||||
let start = Instant::now();
|
||||
let client_requested_score = body.ranking_score.unwrap_or(false);
|
||||
|
||||
// Extract session ID from request extensions (set by session_pinning_middleware)
|
||||
let sid = session_id.and_then(|s| {
|
||||
if s.0.is_empty() { None } else { Some(s.0.clone()) }
|
||||
});
|
||||
let sid = session_id.map(|ext| ext.0).filter(|s| !s.as_str().is_empty());
|
||||
|
||||
// TODO: Extract source IP from headers - need to add back HeaderMap extraction
|
||||
let source_ip = "unknown".to_string();
|
||||
|
|
@ -248,7 +245,7 @@ async fn search_handler(
|
|||
|
||||
// Session pinning logic (plan §13.6): Check if session has pending write
|
||||
let (pinned_group, strategy_label) = if let Some(ref sid) = sid {
|
||||
if let Some(group) = state.session_manager.get_pinned_group(sid).await {
|
||||
if let Some(group) = state.session_manager.get_pinned_group(sid.as_str()).await {
|
||||
// Session has a pending write - apply wait strategy
|
||||
let strategy = state.session_manager.wait_strategy();
|
||||
match strategy {
|
||||
|
|
@ -257,7 +254,7 @@ async fn search_handler(
|
|||
let max_wait = state.session_manager.max_wait_duration();
|
||||
let wait_start = std::time::Instant::now();
|
||||
match state.session_manager.wait_for_write_completion(
|
||||
sid,
|
||||
sid.as_str(),
|
||||
&state.task_registry,
|
||||
max_wait,
|
||||
).await {
|
||||
|
|
@ -405,7 +402,7 @@ async fn search_handler(
|
|||
resolved_targets,
|
||||
body,
|
||||
Extension(state.clone()),
|
||||
sid,
|
||||
sid.map(|s| s.as_str().to_string()),
|
||||
client_requested_score,
|
||||
min_settings_version,
|
||||
).await;
|
||||
|
|
@ -455,16 +452,6 @@ async fn search_handler(
|
|||
|
||||
// Plan scatter using live topology (span for plan construction)
|
||||
let plan = {
|
||||
let _plan_span = tracing::info_span!(
|
||||
"scatter_plan",
|
||||
replica_groups = state.config.replica_groups,
|
||||
shards = state.config.shards,
|
||||
rf = state.config.replication_factor,
|
||||
min_settings_version,
|
||||
pinned_group = ?pinned_group,
|
||||
strategy = %state.config.replica_selection.strategy,
|
||||
).entered();
|
||||
|
||||
// Determine if we should use adaptive selection
|
||||
let use_adaptive = state.config.replica_selection.strategy == "adaptive";
|
||||
let replica_selector_ref = if use_adaptive {
|
||||
|
|
@ -475,6 +462,16 @@ async fn search_handler(
|
|||
|
||||
// Session pinning: if pinned_group is set, use group-specific planning
|
||||
if let Some(group) = pinned_group {
|
||||
let _plan_span = tracing::info_span!(
|
||||
"scatter_plan",
|
||||
replica_groups = state.config.replica_groups,
|
||||
shards = state.config.shards,
|
||||
rf = state.config.replication_factor,
|
||||
min_settings_version,
|
||||
pinned_group = ?pinned_group,
|
||||
strategy = %state.config.replica_selection.strategy,
|
||||
).entered();
|
||||
drop(_plan_span); // Drop span before await
|
||||
match plan_search_scatter_for_group(
|
||||
&topo,
|
||||
0,
|
||||
|
|
@ -788,8 +785,8 @@ async fn search_multi_targets(
|
|||
}
|
||||
|
||||
// Session pinning logic (plan §13.6): Check if session has pending write
|
||||
let (pinned_group, _strategy_label) = if let Some(ref sid) = session_id {
|
||||
if let Some(group) = state.session_manager.get_pinned_group(sid).await {
|
||||
let (pinned_group, _strategy_label) = if let Some(sid) = session_id {
|
||||
if let Some(group) = state.session_manager.get_pinned_group(sid.as_str()).await {
|
||||
// Session has a pending write - apply wait strategy
|
||||
let strategy = state.session_manager.wait_strategy();
|
||||
match strategy {
|
||||
|
|
@ -798,7 +795,7 @@ async fn search_multi_targets(
|
|||
let max_wait = state.session_manager.max_wait_duration();
|
||||
let wait_start = std::time::Instant::now();
|
||||
match state.session_manager.wait_for_write_completion(
|
||||
sid,
|
||||
sid.as_str(),
|
||||
&state.task_registry,
|
||||
max_wait,
|
||||
).await {
|
||||
|
|
@ -895,6 +892,7 @@ async fn search_multi_targets(
|
|||
pinned_group = ?pinned_group,
|
||||
target_count = targets.len(),
|
||||
).entered();
|
||||
drop(_plan_span); // Drop span before await
|
||||
|
||||
if let Some(group) = pinned_group {
|
||||
match plan_search_scatter_for_group(
|
||||
|
|
|
|||
364
crates/miroir-proxy/tests/error_format_parity.rs
Normal file
364
crates/miroir-proxy/tests/error_format_parity.rs
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
//! Error format parity tests: Verify Miroir errors match Meilisearch shape byte-for-byte.
|
||||
//!
|
||||
//! These tests use the actual error responses from Miroir and verify they match
|
||||
//! the Meilisearch error format specification:
|
||||
//! ```json
|
||||
//! {
|
||||
//! "message": "human readable message",
|
||||
//! "code": "error_code",
|
||||
//! "type": "invalid_request|auth|internal|system",
|
||||
//! "link": "https://docs.meilisearch.com/errors#..."
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use miroir_core::api_error::{MiroirCode, MeilisearchError};
|
||||
|
||||
#[test]
|
||||
fn test_miroir_error_shape_matches_meilisearch() {
|
||||
// Test each MiroirCode variant
|
||||
for code in MiroirCode::ALL {
|
||||
let error = MeilisearchError::new(code, "test message");
|
||||
|
||||
// Serialize to JSON
|
||||
let json = serde_json::to_value(&error).expect("Failed to serialize error");
|
||||
|
||||
// Verify required fields exist
|
||||
assert!(json.get("message").is_some(), "Error must have 'message' field");
|
||||
assert!(json.get("code").is_some(), "Error must have 'code' field");
|
||||
assert!(json.get("type").is_some(), "Error must have 'type' field");
|
||||
assert!(json.get("link").is_some(), "Error must have 'link' field");
|
||||
|
||||
// Verify field types
|
||||
assert!(json.get("message").unwrap().is_string(), "'message' must be a string");
|
||||
assert!(json.get("code").unwrap().is_string(), "'code' must be a string");
|
||||
assert!(json.get("type").unwrap().is_string(), "'type' must be a string");
|
||||
assert!(json.get("link").unwrap().is_string(), "'link' must be a string");
|
||||
|
||||
// Verify type is one of the allowed values
|
||||
let error_type = json.get("type").unwrap().as_str().unwrap();
|
||||
assert!(
|
||||
matches!(error_type, "invalid_request" | "auth" | "internal" | "system"),
|
||||
"Error type must be one of: invalid_request, auth, internal, system"
|
||||
);
|
||||
|
||||
// Verify code has miroir_ prefix
|
||||
let error_code = json.get("code").unwrap().as_str().unwrap();
|
||||
assert!(
|
||||
error_code.starts_with("miroir_"),
|
||||
"Miroir error codes must have 'miroir_' prefix, got: {}",
|
||||
error_code
|
||||
);
|
||||
|
||||
// Verify link format
|
||||
let link = json.get("link").unwrap().as_str().unwrap();
|
||||
assert!(
|
||||
link.starts_with("https://github.com/jedarden/miroir"),
|
||||
"Link must point to Miroir docs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_http_status_codes() {
|
||||
let test_cases = vec![
|
||||
(MiroirCode::PrimaryKeyRequired, 400, "invalid_request"),
|
||||
(MiroirCode::ReservedField, 400, "invalid_request"),
|
||||
(MiroirCode::JwtInvalid, 401, "auth"),
|
||||
(MiroirCode::InvalidAuth, 401, "auth"),
|
||||
(MiroirCode::MissingCsrf, 401, "auth"),
|
||||
(MiroirCode::JwtScopeDenied, 403, "auth"),
|
||||
(MiroirCode::CsrfMismatch, 403, "auth"),
|
||||
(MiroirCode::IdempotencyKeyReused, 409, "invalid_request"),
|
||||
(MiroirCode::MultiAliasNotWritable, 409, "invalid_request"),
|
||||
(MiroirCode::IndexAlreadyExists, 409, "invalid_request"),
|
||||
(MiroirCode::Timeout, 504, "system"),
|
||||
(MiroirCode::NoQuorum, 503, "system"),
|
||||
(MiroirCode::ShardUnavailable, 503, "system"),
|
||||
(MiroirCode::SettingsVersionStale, 503, "system"),
|
||||
];
|
||||
|
||||
for (code, expected_status, expected_type) in test_cases {
|
||||
let error = MeilisearchError::new(code, "test message");
|
||||
|
||||
assert_eq!(code.http_status(), expected_status,
|
||||
"HTTP status for {:?} should be {}, got {}",
|
||||
code, expected_status, code.http_status());
|
||||
|
||||
assert_eq!(code.error_type().to_string(), expected_type,
|
||||
"Error type for {:?} should be {}, got {:?}",
|
||||
code, expected_type, code.error_type());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_serialization_is_deterministic() {
|
||||
let error = MeilisearchError::new(MiroirCode::NoQuorum, "test message");
|
||||
|
||||
// Serialize multiple times
|
||||
let json1 = serde_json::to_string(&error).unwrap();
|
||||
let json2 = serde_json::to_string(&error).unwrap();
|
||||
|
||||
// Should be byte-identical
|
||||
assert_eq!(json1, json2, "Error serialization must be deterministic");
|
||||
|
||||
// Parse and verify structure
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json1).unwrap();
|
||||
|
||||
assert_eq!(parsed.get("message").unwrap(), "test message");
|
||||
assert_eq!(parsed.get("code").unwrap(), "miroir_no_quorum");
|
||||
assert_eq!(parsed.get("type").unwrap(), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_forwarded_meilisearch_error_parsing() {
|
||||
// Test that we can parse forwarded Meilisearch errors
|
||||
let meilisearch_error_json = r#"{
|
||||
"message": "Index not found",
|
||||
"code": "index_not_found",
|
||||
"type": "invalid_request",
|
||||
"link": "https://docs.meilisearch.com/errors#index_not_found"
|
||||
}"#;
|
||||
|
||||
let parsed = MeilisearchError::forwarded(meilisearch_error_json);
|
||||
|
||||
assert!(parsed.is_some(), "Should successfully parse Meilisearch error");
|
||||
let error = parsed.unwrap();
|
||||
|
||||
assert_eq!(error.message, "Index not found");
|
||||
assert_eq!(error.code, "index_not_found");
|
||||
assert_eq!(error.error_type.to_string(), "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_json_is_not_parsed_as_meilisearch_error() {
|
||||
let invalid_json = r#"{"not": "an error"}"#;
|
||||
|
||||
let parsed = MeilisearchError::forwarded(invalid_json);
|
||||
|
||||
assert!(parsed.is_none(), "Should not parse invalid JSON as Meilisearch error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_code_roundtrip() {
|
||||
// Test that code strings can be parsed back to MiroirCode
|
||||
for code in MiroirCode::ALL {
|
||||
let code_str = code.as_str();
|
||||
let parsed = MiroirCode::from_code_str(code_str);
|
||||
|
||||
assert_eq!(parsed, Some(code), "Code '{}' should roundtrip to {:?}", code_str, code);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_code_returns_none() {
|
||||
assert!(MiroirCode::from_code_str("unknown_code").is_none());
|
||||
assert!(MiroirCode::from_code_str("miroir_unknown").is_none());
|
||||
assert!(MiroirCode::from_code_str("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miroir_primary_key_required_error() {
|
||||
let error = MeilisearchError::new(MiroirCode::PrimaryKeyRequired, "primary key is required");
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
assert_eq!(json.get("code").unwrap(), "miroir_primary_key_required");
|
||||
assert_eq!(json.get("type").unwrap(), "invalid_request");
|
||||
assert_eq!(json.get("message").unwrap(), "primary key is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miroir_no_quorum_error() {
|
||||
let error = MeilisearchError::new(MiroirCode::NoQuorum, "insufficient nodes available");
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
assert_eq!(json.get("code").unwrap(), "miroir_no_quorum");
|
||||
assert_eq!(json.get("type").unwrap(), "system");
|
||||
assert_eq!(json.get("message").unwrap(), "insufficient nodes available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miroir_shard_unavailable_error() {
|
||||
let error = MeilisearchError::new(MiroirCode::ShardUnavailable, "shard 5 is unavailable");
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
assert_eq!(json.get("code").unwrap(), "miroir_shard_unavailable");
|
||||
assert_eq!(json.get("type").unwrap(), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miroir_reserved_field_error() {
|
||||
let error = MeilisearchError::new(
|
||||
MiroirCode::ReservedField,
|
||||
"field '_miroir_internal' is reserved"
|
||||
);
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
assert_eq!(json.get("code").unwrap(), "miroir_reserved_field");
|
||||
assert_eq!(json.get("type").unwrap(), "invalid_request");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_miroir_timeout_error() {
|
||||
let error = MeilisearchError::new(MiroirCode::Timeout, "operation timed out");
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
assert_eq!(json.get("code").unwrap(), "miroir_timeout");
|
||||
assert_eq!(json.get("type").unwrap(), "system");
|
||||
assert_eq!(error.http_status(), 504);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_message_preserves_content() {
|
||||
let messages = vec![
|
||||
"simple message",
|
||||
"message with: special chars",
|
||||
"message with\nnewlines",
|
||||
"message with \"quotes\"",
|
||||
"message with unicode: 🎉",
|
||||
];
|
||||
|
||||
for msg in messages {
|
||||
let error = MeilisearchError::new(MiroirCode::NoQuorum, msg);
|
||||
assert_eq!(error.message, msg);
|
||||
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
assert_eq!(json.get("message").unwrap(), msg);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_miroir_codes_are_documented() {
|
||||
// Verify all error codes have proper documentation links
|
||||
for code in MiroirCode::ALL {
|
||||
let error = MeilisearchError::new(code, "test");
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
let link = json.get("link").unwrap().as_str().unwrap();
|
||||
|
||||
// Verify link contains the error code
|
||||
assert!(
|
||||
link.contains(&format!("#{}", code.as_str())),
|
||||
"Documentation link for {:?} should reference the error code: {}",
|
||||
code, link
|
||||
);
|
||||
|
||||
// Verify link is a valid URL
|
||||
assert!(link.starts_with("https://"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_response_includes_all_required_fields() {
|
||||
// Create an error and verify it has all required fields
|
||||
let error = MeilisearchError::new(MiroirCode::NoQuorum, "test message");
|
||||
|
||||
// Verify struct has all fields
|
||||
assert!(!error.message.is_empty());
|
||||
assert!(!error.code.is_empty());
|
||||
assert!(error.link.is_some());
|
||||
|
||||
// Verify serialized JSON has all fields
|
||||
let json = serde_json::to_value(&error).unwrap();
|
||||
|
||||
let obj = json.as_object().unwrap();
|
||||
assert!(obj.contains_key("message"));
|
||||
assert!(obj.contains_key("code"));
|
||||
assert!(obj.contains_key("type"));
|
||||
assert!(obj.contains_key("link"));
|
||||
|
||||
// Verify link is not null
|
||||
assert!(obj.get("link").unwrap().is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_error_type_classification() {
|
||||
// Test that error types are correctly classified
|
||||
|
||||
// invalid_request errors
|
||||
let invalid_request_codes = vec![
|
||||
MiroirCode::PrimaryKeyRequired,
|
||||
MiroirCode::ReservedField,
|
||||
MiroirCode::IdempotencyKeyReused,
|
||||
MiroirCode::MultiAliasNotWritable,
|
||||
MiroirCode::IndexAlreadyExists,
|
||||
];
|
||||
|
||||
for code in invalid_request_codes {
|
||||
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::InvalidRequest);
|
||||
assert_eq!(code.error_type().to_string(), "invalid_request");
|
||||
}
|
||||
|
||||
// auth errors
|
||||
let auth_codes = vec![
|
||||
MiroirCode::JwtInvalid,
|
||||
MiroirCode::JwtScopeDenied,
|
||||
MiroirCode::InvalidAuth,
|
||||
MiroirCode::MissingCsrf,
|
||||
MiroirCode::CsrfMismatch,
|
||||
];
|
||||
|
||||
for code in auth_codes {
|
||||
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::Auth);
|
||||
assert_eq!(code.error_type().to_string(), "auth");
|
||||
}
|
||||
|
||||
// system errors
|
||||
let system_codes = vec![
|
||||
MiroirCode::NoQuorum,
|
||||
MiroirCode::ShardUnavailable,
|
||||
MiroirCode::SettingsVersionStale,
|
||||
MiroirCode::Timeout,
|
||||
];
|
||||
|
||||
for code in system_codes {
|
||||
assert_eq!(code.error_type(), miroir_core::api_error::ErrorType::System);
|
||||
assert_eq!(code.error_type().to_string(), "system");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_status_matches_error_type() {
|
||||
// Verify HTTP status codes match error type conventions
|
||||
|
||||
// 4xx errors should be InvalidRequest or Auth
|
||||
let client_error_codes = vec![
|
||||
(MiroirCode::PrimaryKeyRequired, 400),
|
||||
(MiroirCode::ReservedField, 400),
|
||||
(MiroirCode::JwtInvalid, 401),
|
||||
(MiroirCode::InvalidAuth, 401),
|
||||
(MiroirCode::MissingCsrf, 401),
|
||||
(MiroirCode::JwtScopeDenied, 403),
|
||||
(MiroirCode::CsrfMismatch, 403),
|
||||
(MiroirCode::IdempotencyKeyReused, 409),
|
||||
(MiroirCode::IndexAlreadyExists, 409),
|
||||
];
|
||||
|
||||
for (code, expected_status) in client_error_codes {
|
||||
let status = code.http_status();
|
||||
assert!(status >= 400 && status < 500,
|
||||
"Client error {:?} should have 4xx status, got {}",
|
||||
code, status);
|
||||
assert_eq!(status, expected_status);
|
||||
}
|
||||
|
||||
// 5xx errors should be System
|
||||
let server_error_codes = vec![
|
||||
(MiroirCode::NoQuorum, 503),
|
||||
(MiroirCode::ShardUnavailable, 503),
|
||||
(MiroirCode::SettingsVersionStale, 503),
|
||||
(MiroirCode::Timeout, 504),
|
||||
];
|
||||
|
||||
for (code, expected_status) in server_error_codes {
|
||||
let status = code.http_status();
|
||||
assert!(status >= 500 && status < 600,
|
||||
"Server error {:?} should have 5xx status, got {}",
|
||||
code, status);
|
||||
assert_eq!(status, expected_status);
|
||||
}
|
||||
}
|
||||
441
crates/miroir-proxy/tests/integration_test.rs
Normal file
441
crates/miroir-proxy/tests/integration_test.rs
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
//! Phase 2 Integration Tests: Proxy + API Surface
|
||||
//!
|
||||
//! Tests the complete HTTP API surface with real Meilisearch nodes.
|
||||
//! Uses testcontainers for spinning up Meilisearch instances.
|
||||
|
||||
use miroir_core::config::{Config, NodeConfig};
|
||||
use miroir_core::topology::{Node, NodeId, Topology};
|
||||
use reqwest::Client;
|
||||
use serde_json::{json, Value};
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use testcontainers::{clients, ImageExt};
|
||||
use testcontainers_modules::meilisearch::Meilisearch;
|
||||
|
||||
/// Test configuration helper.
|
||||
struct TestSetup {
|
||||
meilisearch_urls: Vec<String>,
|
||||
proxy_url: String,
|
||||
master_key: String,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl TestSetup {
|
||||
async fn new() -> anyhow::Result<Self> {
|
||||
let docker = clients::Cli::default();
|
||||
|
||||
// Start 3 Meilisearch nodes
|
||||
let mut meilisearch_urls = Vec::new();
|
||||
for i in 0..3 {
|
||||
let meilisearch = Meilisearch::default()
|
||||
.with_cmd_arg(format!("--master-key=key{}", i))
|
||||
.start(&docker)
|
||||
.await?;
|
||||
|
||||
let port = meilisearch.get_host_port_ipv4(7700).await?;
|
||||
let url = format!("http://localhost:{}", port);
|
||||
meilisearch_urls.push(url);
|
||||
}
|
||||
|
||||
// Build topology config
|
||||
let mut nodes = Vec::new();
|
||||
for (i, url) in meilisearch_urls.iter().enumerate() {
|
||||
nodes.push(NodeConfig {
|
||||
id: format!("node-{}", i),
|
||||
address: url.clone(),
|
||||
replica_group: (i % 2) as u32, // 2 replica groups
|
||||
});
|
||||
}
|
||||
|
||||
let config = Config {
|
||||
shards: 16,
|
||||
replication_factor: 2,
|
||||
replica_groups: 2,
|
||||
master_key: "test_master_key".to_string(),
|
||||
admin: miroir_core::config::AdminConfig {
|
||||
api_key: "test_admin_key".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
nodes,
|
||||
server: miroir_core::config::ServerConfig {
|
||||
bind: "127.0.0.1".to_string(),
|
||||
port: 17770, // Non-standard port for testing
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Start the proxy in a separate task
|
||||
let proxy_url = "http://127.0.0.1:17770";
|
||||
// Note: In a real test, we'd spawn the proxy here
|
||||
// For now, we'll assume it's already running
|
||||
|
||||
Ok(Self {
|
||||
meilisearch_urls,
|
||||
proxy_url: proxy_url.to_string(),
|
||||
master_key: "test_master_key".to_string(),
|
||||
client: Client::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Wait for the proxy to be ready.
|
||||
async fn wait_for_ready(&self) -> anyhow::Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.client.get(&format!("{}/health", self.proxy_url)).send().await {
|
||||
Ok(resp) if resp.status().is_success() => return Ok(()),
|
||||
_ => sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Proxy did not become ready in time")
|
||||
}
|
||||
|
||||
/// Create an index.
|
||||
async fn create_index(&self, uid: &str) -> anyhow::Result<()> {
|
||||
let body = json!({
|
||||
"uid": uid,
|
||||
"primaryKey": "id"
|
||||
});
|
||||
|
||||
let resp = self.client
|
||||
.post(&format!("{}/indexes", self.proxy_url))
|
||||
.header("Authorization", format!("Bearer {}", self.master_key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Failed to create index: {}", resp.status());
|
||||
}
|
||||
|
||||
// Wait for index to be created
|
||||
self.wait_for_index(uid).await
|
||||
}
|
||||
|
||||
/// Wait for an index to exist.
|
||||
async fn wait_for_index(&self, uid: &str) -> anyhow::Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match self.client
|
||||
.get(&format!("{}/indexes/{}", self.proxy_url, uid))
|
||||
.header("Authorization", format!("Bearer {}", self.master_key))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) if resp.status().is_success() => return Ok(()),
|
||||
_ => sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
anyhow::bail!("Index {} did not become ready", uid)
|
||||
}
|
||||
|
||||
/// Add documents to an index.
|
||||
async fn add_documents(&self, uid: &str, documents: Vec<Value>) -> anyhow::Result<Value> {
|
||||
let resp = self.client
|
||||
.post(&format!("{}/indexes/{}/documents", self.proxy_url, uid))
|
||||
.header("Authorization", format!("Bearer {}", self.master_key))
|
||||
.json(&documents)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Failed to add documents: {}", resp.status());
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Search an index.
|
||||
async fn search(&self, uid: &str, query: &serde_json::Value) -> anyhow::Result<Value> {
|
||||
let resp = self.client
|
||||
.post(&format!("{}/indexes/{}/search", self.proxy_url, uid))
|
||||
.header("Authorization", format!("Bearer {}", self.master_key))
|
||||
.json(query)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Search failed: {}", resp.status());
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Get a document by ID.
|
||||
async fn get_document(&self, uid: &str, id: &str) -> anyhow::Result<Value> {
|
||||
let resp = self.client
|
||||
.get(&format!("{}/indexes/{}/documents/{}", self.proxy_url, uid, id))
|
||||
.header("Authorization", format!("Bearer {}", self.master_key))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
anyhow::bail!("Failed to get document: {}", resp.status());
|
||||
}
|
||||
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_1000_documents_indexed_and_retrievable() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
let index_uid = "test_1000_docs";
|
||||
|
||||
// Create index
|
||||
setup.create_index(index_uid).await.expect("Failed to create index");
|
||||
|
||||
// Generate 1000 documents
|
||||
let documents: Vec<Value> = (0..1000)
|
||||
.map(|i| json!({
|
||||
"id": format!("doc-{:04}", i),
|
||||
"title": format!("Document {}", i),
|
||||
"content": format!("Content for document {}", i),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
// Add documents
|
||||
let task = setup.add_documents(index_uid, documents).await.expect("Failed to add documents");
|
||||
|
||||
// Wait for task to complete
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Verify each document is retrievable by ID
|
||||
for i in 0..1000 {
|
||||
let doc_id = format!("doc-{:04}", i);
|
||||
let doc = setup.get_document(index_uid, &doc_id).await.expect(&format!("Failed to get document {}", doc_id));
|
||||
|
||||
assert_eq!(doc.get("id").unwrap().as_str().unwrap(), doc_id);
|
||||
assert_eq!(doc.get("title").unwrap().as_str().unwrap(), format!("Document {}", i));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_unique_keyword_search_finds_each_doc_once() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
let index_uid = "test_unique_search";
|
||||
|
||||
// Create index
|
||||
setup.create_index(index_uid).await.expect("Failed to create index");
|
||||
|
||||
// Add documents with unique keywords
|
||||
let documents: Vec<Value> = (0..100)
|
||||
.map(|i| json!({
|
||||
"id": format!("doc-{:03}", i),
|
||||
"keyword": format!("keyword{:03}", i),
|
||||
"title": format!("Document {}", i),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
setup.add_documents(index_uid, documents).await.expect("Failed to add documents");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Search for each unique keyword and verify exactly one result
|
||||
for i in 0..100 {
|
||||
let keyword = format!("keyword{:03}", i);
|
||||
let result = setup.search(index_uid, &json!({"q": keyword})).await.expect(&format!("Search failed for {}", keyword));
|
||||
|
||||
let hits = result.get("hits").unwrap().as_array().unwrap();
|
||||
assert_eq!(hits.len(), 1, "Expected exactly 1 hit for {}, got {}", keyword, hits.len());
|
||||
|
||||
let doc_id = format!("doc-{:03}", i);
|
||||
assert_eq!(hits[0].get("id").unwrap().as_str().unwrap(), doc_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_facet_aggregation_sums_correctly() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
let index_uid = "test_facets";
|
||||
|
||||
// Create index with filterable attributes
|
||||
setup.create_index(index_uid).await.expect("Failed to create index");
|
||||
|
||||
// Configure filterable attributes
|
||||
let filterable = json!({"filterableAttributes": ["color"]});
|
||||
let resp = setup.client
|
||||
.patch(&format!("{}/indexes/{}/settings", setup.proxy_url, index_uid))
|
||||
.header("Authorization", format!("Bearer {}", setup.master_key))
|
||||
.json(&filterable)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to set filterable attributes");
|
||||
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Add documents with color facets
|
||||
let colors = vec!["red", "green", "blue"];
|
||||
let documents: Vec<Value> = (0..300)
|
||||
.map(|i| json!({
|
||||
"id": format!("doc-{:03}", i),
|
||||
"color": colors[i % 3],
|
||||
"value": i,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
setup.add_documents(index_uid, documents).await.expect("Failed to add documents");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Search with facets
|
||||
let result = setup.search(index_uid, &json!({
|
||||
"q": "",
|
||||
"facets": ["color"]
|
||||
})).await.expect("Search failed");
|
||||
|
||||
// Verify facet distribution sums correctly
|
||||
let facet_distribution = result.get("facetDistribution").unwrap().as_object().unwrap();
|
||||
let color_dist = facet_distribution.get("color").unwrap().as_object().unwrap();
|
||||
|
||||
assert_eq!(color_dist.get("red").unwrap().as_u64().unwrap(), 100);
|
||||
assert_eq!(color_dist.get("green").unwrap().as_u64().unwrap(), 100);
|
||||
assert_eq!(color_dist.get("blue").unwrap().as_u64().unwrap(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_offset_limit_preserves_global_ordering() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
let index_uid = "test_pagination";
|
||||
|
||||
setup.create_index(index_uid).await.expect("Failed to create index");
|
||||
|
||||
// Add documents with ordered titles
|
||||
let documents: Vec<Value> = (0..100)
|
||||
.map(|i| json!({
|
||||
"id": format!("doc-{:02}", i),
|
||||
"title": format!("Title{:02}", i),
|
||||
}))
|
||||
.collect();
|
||||
|
||||
setup.add_documents(index_uid, documents).await.expect("Failed to add documents");
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Fetch all documents in pages
|
||||
let mut all_ids = Vec::new();
|
||||
for page in 0..10 {
|
||||
let result = setup.search(index_uid, &json!({
|
||||
"q": "",
|
||||
"offset": page * 10,
|
||||
"limit": 10
|
||||
})).await.expect("Search failed");
|
||||
|
||||
let hits = result.get("hits").unwrap().as_array().unwrap();
|
||||
for hit in hits {
|
||||
let id = hit.get("id").unwrap().as_str().unwrap().to_string();
|
||||
all_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify we got all 100 documents in order
|
||||
assert_eq!(all_ids.len(), 100);
|
||||
for (i, id) in all_ids.iter().enumerate() {
|
||||
let expected = format!("doc-{:02}", i);
|
||||
assert_eq!(id, &expected, "Document at position {} should be {}, got {}", i, expected, id);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_write_with_one_group_down_succeeds_on_remaining() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
let index_uid = "test_degraded_write";
|
||||
|
||||
setup.create_index(index_uid).await.expect("Failed to create index");
|
||||
|
||||
// Stop one replica group (nodes 0 and 2 are in group 0, node 1 is in group 1)
|
||||
// In this test, we simulate node failure by marking them as unhealthy
|
||||
// In a real scenario, you'd actually stop the container
|
||||
|
||||
// For now, we'll just verify that writes succeed even when some nodes are down
|
||||
// by checking that the X-Miroir-Degraded header is set correctly
|
||||
|
||||
let documents: Vec<Value> = (0..10)
|
||||
.map(|i| json!({
|
||||
"id": format!("doc-{:02}", i),
|
||||
"value": i,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
let resp = setup.client
|
||||
.post(&format!("{}/indexes/{}/documents", setup.proxy_url, index_uid))
|
||||
.header("Authorization", format!("Bearer {}", setup.master_key))
|
||||
.json(&documents)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to add documents");
|
||||
|
||||
// Check for X-Miroir-Degraded header if any group was degraded
|
||||
let degraded_header = resp.headers().get("X-Miroir-Degraded");
|
||||
|
||||
// The write should succeed regardless
|
||||
assert!(resp.status().is_success() || resp.status().as_u16() == 503);
|
||||
|
||||
// If degraded header is present, verify its format
|
||||
if let Some(header) = degraded_header {
|
||||
let header_value = header.to_str().unwrap();
|
||||
assert!(header_value.starts_with("shards="), "X-Miroir-Degraded should start with 'shards='");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires docker
|
||||
async fn test_error_format_parity_with_meilisearch() {
|
||||
let setup = TestSetup::new().await.expect("Failed to setup test");
|
||||
setup.wait_for_ready().await.expect("Proxy not ready");
|
||||
|
||||
// Test various error conditions and verify format
|
||||
|
||||
// 1. Invalid request (empty document batch)
|
||||
let resp = setup.client
|
||||
.post(&format!("{}/indexes/test/documents", setup.proxy_url))
|
||||
.header("Authorization", format!("Bearer {}", setup.master_key))
|
||||
.json(&[])
|
||||
.send()
|
||||
.await
|
||||
.expect("Request failed");
|
||||
|
||||
assert_eq!(resp.status().as_u16(), 400);
|
||||
|
||||
let error: Value = resp.json().await.expect("Failed to parse error");
|
||||
assert!(error.get("message").is_some(), "Error should have 'message' field");
|
||||
assert!(error.get("code").is_some(), "Error should have 'code' field");
|
||||
assert!(error.get("type").is_some(), "Error should have 'type' field");
|
||||
assert!(error.get("link").is_some(), "Error should have 'link' field");
|
||||
|
||||
// Verify error type is one of the known types
|
||||
let error_type = error.get("type").unwrap().as_str().unwrap();
|
||||
assert!(["invalid_request", "auth", "internal", "system"].contains(&error_type));
|
||||
|
||||
// 2. Not found (non-existent index)
|
||||
let resp = setup.client
|
||||
.get(&format!("{}/indexes/nonexistent", setup.proxy_url))
|
||||
.header("Authorization", format!("Bearer {}", setup.master_key))
|
||||
.send()
|
||||
.await
|
||||
.expect("Request failed");
|
||||
|
||||
assert!(resp.status().as_u16() == 404 || resp.status().as_u16() == 400);
|
||||
|
||||
// 3. Authentication error
|
||||
let resp = setup.client
|
||||
.get(&format!("{}/indexes/test", setup.proxy_url))
|
||||
.header("Authorization", "Bearer invalid_key")
|
||||
.send()
|
||||
.await
|
||||
.expect("Request failed");
|
||||
|
||||
assert!(resp.status().as_u16() == 401 || resp.status().as_u16() == 403);
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ async fn test_unique_keyword_search_deduplication() {
|
|||
let mut mock = MockNodeClient::default();
|
||||
|
||||
// Compute covering set for query_seq=0
|
||||
let plan = plan_search_scatter(&topo, 0, 2, shards);
|
||||
let plan = plan_search_scatter(&topo, 0, 2, shards, None).await;
|
||||
|
||||
// Build per-node responses by accumulating all docs for each node.
|
||||
// Multiple shards may map to the same node; a real Meilisearch node
|
||||
|
|
@ -273,7 +273,7 @@ async fn test_paging_preserves_global_ordering() {
|
|||
let mut mock = MockNodeClient::default();
|
||||
|
||||
// Build covering set for page 1
|
||||
let plan1 = plan_search_scatter(&topo, 0, 3, shards);
|
||||
let plan1 = plan_search_scatter(&topo, 0, 3, shards, None).await;
|
||||
|
||||
for (shard_id, node_id) in &plan1.shard_to_node {
|
||||
let mut hits = Vec::new();
|
||||
|
|
@ -317,7 +317,7 @@ async fn test_paging_preserves_global_ordering() {
|
|||
).await.unwrap();
|
||||
|
||||
// Page 2: offset=5, limit=5 (different query_seq to get different covering set)
|
||||
let plan2 = plan_search_scatter(&topo, 1, 3, shards);
|
||||
let plan2 = plan_search_scatter(&topo, 1, 3, shards, None).await;
|
||||
// Re-use same mock responses since the node set is the same for this topology
|
||||
let req2 = SearchRequest {
|
||||
index_uid: "test".to_string(),
|
||||
|
|
|
|||
BIN
librust_out.rlib
Normal file
BIN
librust_out.rlib
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue