P5.6 §13.6: Complete session pinning acceptance tests

All 20 integration tests pass for session pinning read-your-writes:
- Write with session header → pinned to first-quorum group
- Read with pending write → routes to pinned group
- Block strategy: waits for write completion
- RoutePin strategy: routes without waiting
- Session TTL expiry and LRU eviction
- Pinned group failure handling

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-23 01:00:01 -04:00
parent 4a4d31c161
commit eea0db88df
6 changed files with 4114 additions and 3367 deletions

View file

@ -61,7 +61,7 @@
{"id":"miroir-m9q.6","title":"P6.6 HPA spec + prometheus-adapter + schema validation","description":"## What\n\nShip the HPA spec (plan §14.4):\n```yaml\napiVersion: autoscaling/v2\nkind: HorizontalPodAutoscaler\nspec:\n minReplicas: 2\n maxReplicas: 24\n behavior:\n scaleDown: { stabilizationWindowSeconds: 300 }\n scaleUp: { stabilizationWindowSeconds: 30 }\n metrics:\n - Resource cpu 70%\n - Resource memory 75%\n - Pods miroir_requests_in_flight AverageValue: 500\n - External miroir_background_queue_depth Value: 10\n```\n\nChart preconditions enforced via `values.schema.json`:\n- `hpa.enabled: true` requires `replicas >= 2 AND taskStore.backend: redis`\n- `prometheus-adapter` (or equivalent) as a documented prerequisite when HPA is enabled\n\n## Why\n\nPlan §14.4: \"`miroir_requests_in_flight` is **per-pod** and uses `type: Pods`. `miroir_background_queue_depth` is **global** and must use `type: External` with `type: Value`.\" Getting the metric type wrong produces a pathological HPA that monotonically scales to `maxReplicas`.\n\n## Details\n\n**Per-workload-tier min/max** (plan §14.7):\n| Peak QPS | minReplicas | maxReplicas |\n|---|---|---|\n| ≤ 500 | 2 | 3 |\n| ≤ 2k | 2 | 4 |\n| ≤ 5k | 4 | 8 |\n| ≤ 20k | 8 | 12 |\n| ≤ 100k | 12 | 24 |\n\nDefault values.yaml ships the ≤ 5k tier; operators override per workload.\n\n**prometheus-adapter config**: add a ConfigMap-defined `rules.externalMetrics` entry mapping `miroir_background_queue_depth` to the external metrics API. This is NOT shipped by the Miroir chart (operators install prometheus-adapter separately); the chart's `NOTES.txt` calls it out.\n\n**Stabilization windows**: scale-up fast (30s), scale-down slow (300s). Avoids pod flapping.\n\n## Acceptance\n\n- [ ] `helm lint --strict` with `hpa.enabled: true + replicas: 1` → fails with schema error\n- [ ] `helm lint --strict` with `hpa.enabled: true + replicas: 2 + backend: sqlite` → fails\n- [ ] HPA in a kind cluster: induce CPU load → scales up within 30s; load drops → scales down after 300s\n- [ ] External metric binding: `miroir_background_queue_depth` visible via `kubectl get --raw /apis/external.metrics.k8s.io/v1beta1/...`","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:40:30.676597441Z","created_by":"coding","updated_at":"2026-04-18T21:40:36.163090876Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"],"dependencies":[{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-m9q.4","type":"blocks","created_at":"2026-04-18T21:40:36.140248526Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-m9q.6","depends_on_id":"miroir-m9q.5","type":"blocks","created_at":"2026-04-18T21:40:36.163063693Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-m9q.7","title":"P6.7 Resource-pressure metrics + alerts (§14.9)","description":"## What\n\nRegister the plan §14.9 resource-pressure metrics:\n- `miroir_memory_pressure` gauge (0=ok, 1=warn >75%, 2=critical >90%)\n- `miroir_cpu_throttled_seconds_total` counter (cgroup throttling)\n- `miroir_request_queue_depth` gauge\n- `miroir_background_queue_depth{job_type}` gauge\n- `miroir_peer_pod_count` gauge\n- `miroir_leader` gauge\n- `miroir_owned_shards_count` gauge\n\nAnd the associated `PrometheusRule` alerts (plan §14.9).\n\n## Why\n\nThese surface under-scaling BEFORE user-visible impact. `miroir_memory_pressure` + `MiroirMemoryPressure` alert give operators (and HPA) a leading indicator instead of waiting for OOM-kill.\n\n## Details\n\n**cgroup reads**: on Linux, read `/sys/fs/cgroup/cpu.stat` (cgroup v2) or `/sys/fs/cgroup/cpu/cpu.stat` (v1) for `nr_throttled`/`throttled_time`. Convert throttled_time nanoseconds → seconds for the counter.\n\n**Memory pressure gauge**: read `/sys/fs/cgroup/memory.current` + `memory.max`; compute utilization; map to 0/1/2 per threshold.\n\n**PrometheusRule**:\n```yaml\n- alert: MiroirMemoryPressure\n expr: miroir_memory_pressure >= 2\n for: 5m\n- alert: MiroirRequestQueueBacklog\n expr: miroir_request_queue_depth > 500\n for: 2m\n- alert: MiroirBackgroundJobBacklog\n expr: miroir_background_queue_depth > 100\n for: 10m\n- alert: MiroirPeerDiscoveryGap\n expr: miroir_peer_pod_count < kube_deployment_status_replicas_ready{deployment=\"miroir\"}\n for: 2m\n- alert: MiroirNoLeader\n expr: sum(miroir_leader) == 0\n for: 1m\n```\n\n## Acceptance\n\n- [ ] All 7 metrics present on `:9090/metrics`\n- [ ] `miroir_memory_pressure` reports 2 when artificial allocation pushes RSS > 90% of limit\n- [ ] `MiroirNoLeader` fires after killing the leader without replacement within 1 min\n- [ ] `MiroirPeerDiscoveryGap` fires if headless Service misconfigured","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:40:30.711963985Z","created_by":"coding","updated_at":"2026-04-18T21:40:30.711963985Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-6"]}
{"id":"miroir-mkk","title":"Phase 4 — Topology Operations (rebalance, add/remove node + group, drain)","description":"## Phase 4 Epic — Topology Operations\n\nMakes the cluster *elastic*: operators can add or remove nodes within a group (capacity scaling) or add/remove entire replica groups (throughput scaling) without a full reindex and without downtime.\n\n## Why This Matters\n\nPlan §2 \"Topology changes\" and §4 \"Rebalancer\" together are **the** operational differentiator. Without this phase, Miroir is a static sharder — useful but not production-grade. Elasticity is what justifies the complexity of the whole system.\n\nPlan §15 Open Problem 1 (dual-write race) is partially mitigated by careful sequencing here and fully closed by §13.8 anti-entropy in Phase 5. Getting the sequencing right here means Phase 5's reconciler is a safety net, not the primary correctness mechanism.\n\n## Scope\n\n**Node addition (within a group; plan §2 \"Adding a node\")**\n\n1. Assign new node to a group; mark `joining`\n2. Recompute assignments — ~S/(Ng+1) shards move\n3. Dual-write: new inbound writes for affected shards go to **both** old owner and new node\n4. Background migration per shard: `GET /indexes/{uid}/documents?filter=_miroir_shard={id}&limit=1000&offset=...` → write each page to new node\n5. Mark `active`; stop dual-write; `POST /indexes/{uid}/documents/delete` with `filter=_miroir_shard={id}` on old owner\n\n**Replica-group addition (plan §2 \"Adding a new replica group\")** — mark `initializing`, background-sync from any healthy group using the same `_miroir_shard` filter, then flip to `active` and start routing queries.\n\n**Node removal (plan §2 \"Removing a node\")** — mark `draining`, recompute, migrate ~RF/Ng fraction to survivors, mark `removed`, operator deletes PVC.\n\n**Group removal (plan §2 \"Removing a replica group\")** — mark `draining`, stop routing queries; no data migration (other groups hold the docs); decommission.\n\n**Unplanned node failure (plan §2 \"Node failure\")** — mark `failed`; surviving intra-group replicas cover if RF>1; cross-group fallback if RF=1; schedule background replication to restore RF.\n\n**Admin API** (plan §4 admin table) — `POST /_miroir/nodes`, `DELETE /_miroir/nodes/{id}`, `POST /_miroir/nodes/{id}/drain`, `POST /_miroir/rebalance`, `GET /_miroir/rebalance/status`.\n\n## Design Notes\n\n- Relies on `_miroir_shard` being `filterable` on every node — set by Phase 2 index-create broadcast\n- Only one rebalance at a time per index (advisory lock → Phase 6 Mode B leader lease)\n- Chunked migration bounded by `rebalancer.max_concurrent_migrations` (default 4) to stay under the per-pod 3.75 GB envelope\n- Migration progress reported via `GET /_miroir/rebalance/status` and `miroir_rebalance_*` metrics (§10)\n- No full-corpus scans ever — the `_miroir_shard` filter is the key primitive; any code path that enumerates \"all docs\" is a bug\n\n## Open Problem Closure\n\nPlan §15 #1 — dual-write cutover race: document the exact sequencing here and note that §13.8 anti-entropy is the guaranteed safety net on the next pass.\n\n## Definition of Done\n\n- [ ] Chaos test: add a node mid-indexing — every doc remains readable; no duplicates on a subsequent search\n- [ ] Chaos test: drain a node while queries are in flight — zero client-visible failures; `X-Miroir-Degraded` absent or transient only\n- [ ] Chaos test: add a replica group while queries are in flight — existing groups unaffected; new group starts serving reads only after sync completes\n- [ ] Rebalance of a 3→4 node cluster moves ≤ 2×(1/4) of docs (optimal per plan §8 benches)\n- [ ] Restart a killed node mid-rebalance — rebalance pauses + resumes; no data loss","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"epic","assignee":"","created_at":"2026-04-18T21:19:53.993012197Z","created_by":"coding","updated_at":"2026-05-09T16:11:31.984602638Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase","phase-4"],"dependencies":[{"issue_id":"miroir-mkk","depends_on_id":"miroir-9dj","type":"blocks","created_at":"2026-04-18T21:23:08.595905334Z","created_by":"coding","metadata":"{}","thread_id":""},{"issue_id":"miroir-mkk","depends_on_id":"miroir-r3j","type":"blocks","created_at":"2026-04-18T21:23:08.609300009Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-mkk.1","title":"P4.1 Rebalancer background worker + advisory lock","description":"## What\n\nImplement the rebalancer as a background Tokio task (plan §4 \"Rebalancer\"):\n- Advisory lock — only one Miroir instance runs the rebalancer at a time (Phase 6 §14.5 Mode B replaces with leader lease)\n- Reacts to topology change events (node add/drain/fail/recover) from the admin API + health checker\n- Computes affected shards (the `~S/(Ng+1)` or `~RF/Ng` delta) using the Phase 1 router\n- Drives the migration state machine for each affected shard\n- Updates `miroir_rebalance_in_progress`, `miroir_rebalance_documents_migrated_total`, `miroir_rebalance_duration_seconds` (plan §10)\n\n## Why\n\nThe rebalancer is the orchestrator of all Phase 4 operations. Everything else in this phase is a subroutine called by this worker. Keeping it as a dedicated task — rather than inline in admin handlers — means a slow migration doesn't block admin API responses and a crash restarts cleanly from the task-store state.\n\n## Details\n\n**State machine per-shard**:\n```\nIdle → DualWriteStarted → MigrationInProgress → MigrationComplete → DualWriteStopped → OldReplicaDeleted → Idle\n```\n\n**Concurrency bound**: `rebalancer.max_concurrent_migrations` (default 4) to stay within plan §14.2 memory budget for migration buffers.\n\n**Progress persistence**: per-shard cursor in `jobs` table (Phase 3) so a pod restart resumes at the last committed offset. Idempotent per primary key (same doc re-written on resume is no-op at Meilisearch level).\n\n**Cancellation**: an admin API call can pause (not delete) an in-progress rebalance; resuming picks up at the persisted cursor.\n\n## Acceptance\n\n- [ ] Advisory lock: two pods running the rebalancer simultaneously produce 0 duplicate migrations (enforced via the `leader_lease` row for scope `rebalance:<index>`)\n- [ ] Progress persistence: kill the pod mid-migration; another takes over within lease TTL and completes without starting over\n- [ ] Metrics tick: `miroir_rebalance_documents_migrated_total` monotonically increases; `_duration_seconds` histogram records per-shard migration time","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-bravo","created_at":"2026-04-18T21:31:43.768256172Z","created_by":"coding","updated_at":"2026-05-23T04:47:42.442505057Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"]}
{"id":"miroir-mkk.1","title":"P4.1 Rebalancer background worker + advisory lock","description":"## What\n\nImplement the rebalancer as a background Tokio task (plan §4 \"Rebalancer\"):\n- Advisory lock — only one Miroir instance runs the rebalancer at a time (Phase 6 §14.5 Mode B replaces with leader lease)\n- Reacts to topology change events (node add/drain/fail/recover) from the admin API + health checker\n- Computes affected shards (the `~S/(Ng+1)` or `~RF/Ng` delta) using the Phase 1 router\n- Drives the migration state machine for each affected shard\n- Updates `miroir_rebalance_in_progress`, `miroir_rebalance_documents_migrated_total`, `miroir_rebalance_duration_seconds` (plan §10)\n\n## Why\n\nThe rebalancer is the orchestrator of all Phase 4 operations. Everything else in this phase is a subroutine called by this worker. Keeping it as a dedicated task — rather than inline in admin handlers — means a slow migration doesn't block admin API responses and a crash restarts cleanly from the task-store state.\n\n## Details\n\n**State machine per-shard**:\n```\nIdle → DualWriteStarted → MigrationInProgress → MigrationComplete → DualWriteStopped → OldReplicaDeleted → Idle\n```\n\n**Concurrency bound**: `rebalancer.max_concurrent_migrations` (default 4) to stay within plan §14.2 memory budget for migration buffers.\n\n**Progress persistence**: per-shard cursor in `jobs` table (Phase 3) so a pod restart resumes at the last committed offset. Idempotent per primary key (same doc re-written on resume is no-op at Meilisearch level).\n\n**Cancellation**: an admin API call can pause (not delete) an in-progress rebalance; resuming picks up at the persisted cursor.\n\n## Acceptance\n\n- [ ] Advisory lock: two pods running the rebalancer simultaneously produce 0 duplicate migrations (enforced via the `leader_lease` row for scope `rebalance:<index>`)\n- [ ] Progress persistence: kill the pod mid-migration; another takes over within lease TTL and completes without starting over\n- [ ] Metrics tick: `miroir_rebalance_documents_migrated_total` monotonically increases; `_duration_seconds` histogram records per-shard migration time","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-bravo","created_at":"2026-04-18T21:31:43.768256172Z","created_by":"coding","updated_at":"2026-05-23T04:57:42.569333374Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"]}
{"id":"miroir-mkk.2","title":"P4.2 Node addition: dual-write + paginated shard migration","description":"## What\n\nImplement the node-addition flow from plan §2 \"Adding a node to an existing group\":\n1. Admin API: `POST /_miroir/nodes` body `{\"id\": \"meili-N\", \"address\": \"...\", \"replica_group\": G}`\n2. Mark `joining`\n3. Recompute assignments — `affected_shards` where `meili-N` enters the top-RF within group G\n4. **Dual-write**: new inbound writes for affected shards go to **both** old owner and new node (idempotent — Meilisearch PUT semantics handle dupes via primary key)\n5. For each affected shard, background migration via the shard-filter primitive (plan §4):\n ```\n GET /indexes/{uid}/documents?filter=_miroir_shard={shard_id}&limit=1000&offset=0\n GET /indexes/{uid}/documents?filter=_miroir_shard={shard_id}&limit=1000&offset=1000\n ... until exhausted\n ```\n6. Write each page to the new node (docs already carry `_miroir_shard`)\n7. Mark `active`; stop dual-write\n8. Delete migrated shard from old node: `POST /indexes/{uid}/documents/delete {\"filter\": \"_miroir_shard = {shard_id}\"}`\n9. Documents on unaffected shards never touched\n\n## Why\n\nPlan §1 principle 4 (RF-configurable redundancy) + §2 \"Three independent scaling dimensions\" depend on this. The `_miroir_shard` filter primitive is what makes migration move only `~total_docs/(N+1)` docs instead of `total_docs` — a 10100× reduction in I/O vs. a naive \"copy everything then diff\" approach.\n\n## Details\n\n**Dual-write durability invariant**: between steps 4 and 7, every accepted write for the affected shards lands on both old and new. If dual-write is skipped while migration is running, writes arriving at that exact moment may land only on the old owner and be lost when step 8 deletes. Plan §15 Open Problem 1 is the remaining race; §13.8 anti-entropy (Phase 5) is the safety net.\n\n**Pagination cursor**: `offset` is the simplest, but Meilisearch `limit + offset` has an internal cap (default 1000 + 0 → max ~20 for safe). Configure `pagination.maxTotalHits` per-node at index creation to allow deep pagination (safe: we're just iterating our own injected shard).\n\n**Per-page batch**: `rebalancer.migration_batch_size` (default 1000) — one page read + one page write per cycle.\n\n**Fail-open behavior**: if the source node becomes unavailable mid-migration, the rebalancer pauses this shard; other shards continue. When source comes back, resume.\n\n## Acceptance\n\n- [ ] Integration test: 3-node → 4-node migration, 10K docs, each doc still retrievable by ID after migration\n- [ ] Chaos: toggle writes on/off during migration; dual-write window catches all late writes\n- [ ] Performance: migrating `~S/(Ng+1)` shards moves ≤ `total_docs / (Ng+1) × 1.1` docs (10% slack for dual-write dupes)\n- [ ] The old node is not queried for the migrated shards after step 8 (verified via log inspection)","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.790167851Z","created_by":"coding","updated_at":"2026-04-18T21:31:48.930644191Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.2","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.930624028Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-mkk.3","title":"P4.3 Node removal (drain): migrate off + delete PVC handoff","description":"## What\n\nImplement `POST /_miroir/nodes/{id}/drain` + `DELETE /_miroir/nodes/{id}` (plan §2 \"Removing a node\"):\n1. Mark `draining`; stop routing writes for its affected shards to it\n2. Recompute assignments — affected shards reassigned to surviving nodes in the same group\n3. Background migration: copy affected shards to new owners via the `_miroir_shard` filter primitive\n4. Mark `removed`\n5. `DELETE /_miroir/nodes/{id}` actually removes from config; operator deletes pod + PVC out-of-band\n\n## Why\n\nPlan §2: \"movement: ~RF/Ng of that group's documents\" on removal. The drain API decouples \"stop taking writes\" (immediate) from \"delete the pod\" (operator decision) — gives operators room to verify before committing to hardware loss.\n\n## Details\n\n**Order matters**: drain → remove. `drain` is reversible (mark `active` again); `remove` is not. CLI (`miroir-ctl node drain meili-2` per plan §11) should pause and await confirmation before the remove step.\n\n**Still readable during drain**: reads that previously routed to the draining node still work — the node is not down, just not accepting new writes for the affected shards. Read traffic naturally drifts to the replacement replica via Phase 1 `covering_set` intra-group rotation.\n\n**Safety check**: refuse drain if it would drop a shard below RF=1 in its group AND the group has no healthy peer group to fall back to. Require `--force` to override.\n\n**Post-drain verification**: query `GET /indexes/{uid}/documents?filter=_miroir_shard={s}&limit=1` against the drained node — should return 0 results for every shard before `remove` is permitted.\n\n## Acceptance\n\n- [ ] 3-node RF=2 group: drain node-1; searches still succeed with zero degraded responses\n- [ ] After drain completes, `GET /indexes/{uid}/documents?filter=_miroir_shard={s}&limit=1` on node-1 returns 0 for every shard\n- [ ] `remove` without prior `drain` → 409 conflict with a message pointing at `drain` first\n- [ ] `--force` drain that would drop a shard to 0 replicas surfaces a loud warning before proceeding","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.815997915Z","created_by":"coding","updated_at":"2026-04-18T21:31:48.943083697Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.3","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.943066166Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-mkk.4","title":"P4.4 Replica group addition: initializing → active","description":"## What\n\nImplement the \"Adding a new replica group\" flow from plan §2:\n1. Provision new nodes; assign `replica_group: G_new` in config\n2. Mark new group `initializing`; queries NOT routed here\n3. Background sync: for each shard, copy all docs from **any** healthy existing group to the new group's nodes via `filter=_miroir_shard={id}` pagination; new inbound writes already fan out to the new group immediately\n4. When all shards synced, mark group `active` — queries begin routing in round-robin\n5. Existing groups continue serving queries throughout (zero read interruption)\n\n## Why\n\nPlan §2 \"Adding a new replica group (throughput scaling)\": adding a group multiplies query capacity without touching existing groups' data. This is the primary \"we need more search QPS\" lever. Unlike intra-group rebalance which moves a subset, group-add **copies** every shard to the new group — so the I/O is proportional to total corpus size, not `1/(Ng+1)`.\n\n## Details\n\n**Source group selection**: round-robin across existing `active` groups to spread read load during sync. Per-shard picks a different source so one group isn't hammered.\n\n**Write fan-out during sync**: new group already receives writes from step 3 onward. This is the durability guarantee — only the backfill window of historical data is transient.\n\n**Progress tracking**: per-shard cursor in `jobs` table; can be paused/resumed per Phase 6 Mode C.\n\n**Verification before `active`**: `GET /indexes/{uid}/stats` against new group → docs count within 0.1% of source group (allows for writes landing during sync). If higher variance, delay the flip and investigate.\n\n## Acceptance\n\n- [ ] Integration test: RG=1 → RG=2; during sync, query throughput on original group unchanged (no regression)\n- [ ] After `active`, queries distribute round-robin between the two groups (verified via per-group metrics)\n- [ ] Mid-sync write test: 100 writes landing during the backfill window are all present on both groups when sync completes\n- [ ] Failed sync (source group becomes unavailable mid-copy) pauses without corrupting new group; resumes when source returns","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:31:43.859158013Z","created_by":"coding","updated_at":"2026-04-18T21:31:48.961616587Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["phase-4"],"dependencies":[{"issue_id":"miroir-mkk.4","depends_on_id":"miroir-mkk.1","type":"blocks","created_at":"2026-04-18T21:31:48.961576914Z","created_by":"coding","metadata":"{}","thread_id":""}]}
@ -136,8 +136,8 @@
{"id":"miroir-uhj.5.3","title":"P5.5.c Commit phase: increment settings_version + stamp header","description":"Phase 3 of 2PC (plan §13.5). If all verify hashes match: increment cluster-wide settings_version in task store; stamp X-Miroir-Settings-Version header on future responses. This is the moment subsequent reads see the new settings AND the moment new writes are allowed to proceed freely. Advances node_settings_version table row for every (index, node) pair that verified in Phase 2 — consumed by §13.5 X-Miroir-Min-Settings-Version client freshness checks.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:50:54.191201274Z","created_by":"coding","updated_at":"2026-04-18T21:52:42.847559017Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.5.3","depends_on_id":"miroir-uhj.5.2","type":"blocks","created_at":"2026-04-18T21:52:42.847536177Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-uhj.5.4","title":"P5.5.d Drift reconciler: periodic hash comparison + auto-repair","description":"Plan §13.5 'Drift reconciler (always on).' Background task every settings_drift_check.interval_s (default 5 min). Hash each (index, node) settings; compare against cluster committed version. Catches out-of-band changes (direct operator PATCH to a single node). Auto-repair: reapply cluster settings to divergent node. Scaling mode: Mode A (plan §14.6) — each pod polls a subset of (index, node) pairs by rendezvous. Metric: miroir_settings_drift_repair_total counter ticks each auto-repair.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:54.222789382Z","created_by":"coding","updated_at":"2026-04-18T21:50:54.222789382Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"]}
{"id":"miroir-uhj.5.5","title":"P5.5.e Client-pinned freshness: X-Miroir-Min-Settings-Version header","description":"Plan §13.5 'Client-pinned freshness'. Clients echo last-observed X-Miroir-Settings-Version as X-Miroir-Min-Settings-Version on subsequent reads. Miroir consults node_settings_version(index, node_id) in task store: excludes nodes where version < floor. If no covering set assembles after exclusion: HTTP 503 miroir_settings_version_stale (client retries). Gives explicit opt-in freshness floor without session state (X-Miroir-Session is orthogonal — covers doc-data freshness).","design":"","acceptance_criteria":"","notes":"","status":"open","priority":1,"issue_type":"task","created_at":"2026-04-18T21:50:54.272659154Z","created_by":"coding","updated_at":"2026-04-18T21:52:42.870100260Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.5.5","depends_on_id":"miroir-uhj.5.3","type":"blocks","created_at":"2026-04-18T21:52:42.870065730Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-uhj.6","title":"P5.6 §13.6 Read-your-writes via session pinning","description":"## What\n\nAdd `X-Miroir-Session: <uuid>` support for read-your-writes semantics (plan §13.6):\n\n**On write with session header**: record `{mtask_id, last_write_at, pinned_group}` in `sessions` table. `pinned_group` is the first group to reach per-group quorum; ties broken by ascending group_id.\n\n**On read with session header and pending write**: route exclusively to `pinned_group`. Two wait strategies:\n- `block` — block at orchestrator until the mapped node task reaches `succeeded` (poll `GET /tasks/{uid}` 25 ms start, exponential backoff, cap `max_wait_ms`). Only strategy strictly guaranteeing the prior write is visible.\n- `route_pin` — route to `pinned_group` without waiting. Caller accepts \"my own writes eventually, never cross-group stale.\"\n\n**On read without pending write**: session pin released; normal routing.\n\n**No session header**: exactly today's behavior.\n\n## Why\n\nPlan §13.6: \"SDKs work around this by polling task status — clumsy and error-prone.\" Session pinning solves it in one header with opt-in semantics.\n\n## Details\n\n**Session TTL** default 15 min; LRU bound `session_pinning.max_sessions` (default 100000 → ~50 MB plan §14.2).\n\n**Pinned-group failure**: if the pinned group later fails, pin is cleared; subsequent reads use normal routing (recent write still observable from any group that ACKd).\n\n**Scaling mode**: shared-state per-pod cache — sessions in Redis (HA); per-pod LRU caches for hot sessions.\n\n**Config** (plan §13.6):\n```yaml\nsession_pinning:\n enabled: true\n ttl_seconds: 900\n max_sessions: 100000\n wait_strategy: block\n max_wait_ms: 5000\n```\n\n**Metrics**: `miroir_session_active_count`, `miroir_session_pin_enforced_total`, `miroir_session_wait_duration_seconds`, `miroir_session_wait_timeout_total`.\n\n**Interaction with §13.11 multi-search**: per-sub-query evaluation (plan §13.11 \"Interaction\" paragraph).\n**Interaction with §13.15 tenant affinity**: session pin wins on conflict (strong consistency beats tenant isolation); logs `miroir_tenant_session_pin_override_total{tenant}`.\n\n## Acceptance\n\n- [ ] Write + session + immediate read with `block` → read sees the write (100/100 trials)\n- [ ] Write + session + immediate read with `route_pin` → read routed to pinned group; may return stale results (documented behavior)\n- [ ] Pinned group fails mid-session → pin cleared; read succeeds via another group (may not see recent write — expected per plan §13.6 \"Failure handling\")\n- [ ] Session TTL expiry: LRU evicts oldest when cap hit","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-delta","created_at":"2026-04-18T21:33:36.867183010Z","created_by":"coding","updated_at":"2026-05-23T04:53:16.600576930Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.6","depends_on_id":"miroir-uhj.5","type":"blocks","created_at":"2026-04-18T21:38:33.166505657Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-uhj.7","title":"P5.7 §13.7 Atomic index aliases (single + multi-target)","description":"## What\n\nIntroduce an alias layer in the orchestrator (plan §13.7). Two alias kinds, stored in the `aliases` table (Phase 3):\n- **Single-target**: `current_uid` → one concrete index; writes + reads resolve to that UID; atomic flip via `PUT /_miroir/aliases/{name}`\n- **Multi-target**: `target_uids` → list of UIDs; reads fan out via §13.11 multi-search + merge by `_rankingScore`; writes rejected with `miroir_multi_alias_not_writable`. Managed exclusively by §13.17 ILM.\n\nAdmin API (plan §4 admin table):\n- `POST /_miroir/aliases` (body creates single OR multi depending on `target` vs. `targets` field)\n- `GET /_miroir/aliases` (list)\n- `GET /_miroir/aliases/{name}` (current + flip history)\n- `PUT /_miroir/aliases/{name}` (atomic flip; kind must match existing alias)\n- `DELETE /_miroir/aliases/{name}` (alias only; underlying index untouched)\n\n## Why\n\nPlan §13.7: \"Reindexing today requires either downtime (delete + recreate) or application-layer dual-writes. Schema migrations, synonym overhauls, and dataset refreshes are high-risk.\" Aliases make those operational.\n\n§13.1 reshard step 5 is an alias flip; §13.17 ILM read_alias is a multi-target alias.\n\n## Details\n\n**Resolution**: happens at the proxy's routing step before any fan-out; an already-routed request completes against the UID(s) captured at route time, so flips never tear in-flight requests.\n\n**History**: `aliases.history` is a JSON array bounded by `aliases.history_retention` (default 10). Last-N flips retained for debugging + rollback.\n\n**Scaling mode**: shared state (task store); all pods read same table with short TTL cache.\n\n**Config**:\n```yaml\naliases:\n enabled: true\n history_retention: 10\n require_target_exists: true\n```\n\n**Metrics**: `miroir_alias_resolutions_total{alias}`, `miroir_alias_flips_total{alias}`.\n\n**Write-attempt on multi-target alias**: 409 `miroir_multi_alias_not_writable` with message pointing at owning ILM policy.\n\n## Acceptance\n\n- [ ] Create single-target alias → both writes + reads resolve\n- [ ] Flip: new writes land on new target; in-flight (pre-flip) request completes against the old target without error\n- [ ] Create multi-target alias → read fans out; write returns 409\n- [ ] Operator edit of an ILM-managed multi-target alias → 409 (only ILM can modify)\n- [ ] History: 11th flip evicts the oldest","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","assignee":"","created_at":"2026-04-18T21:35:21.739087923Z","created_by":"coding","updated_at":"2026-05-23T04:48:47.654650309Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"]}
{"id":"miroir-uhj.6","title":"P5.6 §13.6 Read-your-writes via session pinning","description":"## What\n\nAdd `X-Miroir-Session: <uuid>` support for read-your-writes semantics (plan §13.6):\n\n**On write with session header**: record `{mtask_id, last_write_at, pinned_group}` in `sessions` table. `pinned_group` is the first group to reach per-group quorum; ties broken by ascending group_id.\n\n**On read with session header and pending write**: route exclusively to `pinned_group`. Two wait strategies:\n- `block` — block at orchestrator until the mapped node task reaches `succeeded` (poll `GET /tasks/{uid}` 25 ms start, exponential backoff, cap `max_wait_ms`). Only strategy strictly guaranteeing the prior write is visible.\n- `route_pin` — route to `pinned_group` without waiting. Caller accepts \"my own writes eventually, never cross-group stale.\"\n\n**On read without pending write**: session pin released; normal routing.\n\n**No session header**: exactly today's behavior.\n\n## Why\n\nPlan §13.6: \"SDKs work around this by polling task status — clumsy and error-prone.\" Session pinning solves it in one header with opt-in semantics.\n\n## Details\n\n**Session TTL** default 15 min; LRU bound `session_pinning.max_sessions` (default 100000 → ~50 MB plan §14.2).\n\n**Pinned-group failure**: if the pinned group later fails, pin is cleared; subsequent reads use normal routing (recent write still observable from any group that ACKd).\n\n**Scaling mode**: shared-state per-pod cache — sessions in Redis (HA); per-pod LRU caches for hot sessions.\n\n**Config** (plan §13.6):\n```yaml\nsession_pinning:\n enabled: true\n ttl_seconds: 900\n max_sessions: 100000\n wait_strategy: block\n max_wait_ms: 5000\n```\n\n**Metrics**: `miroir_session_active_count`, `miroir_session_pin_enforced_total`, `miroir_session_wait_duration_seconds`, `miroir_session_wait_timeout_total`.\n\n**Interaction with §13.11 multi-search**: per-sub-query evaluation (plan §13.11 \"Interaction\" paragraph).\n**Interaction with §13.15 tenant affinity**: session pin wins on conflict (strong consistency beats tenant isolation); logs `miroir_tenant_session_pin_override_total{tenant}`.\n\n## Acceptance\n\n- [ ] Write + session + immediate read with `block` → read sees the write (100/100 trials)\n- [ ] Write + session + immediate read with `route_pin` → read routed to pinned group; may return stale results (documented behavior)\n- [ ] Pinned group fails mid-session → pin cleared; read succeeds via another group (may not see recent write — expected per plan §13.6 \"Failure handling\")\n- [ ] Session TTL expiry: LRU evicts oldest when cap hit","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":0,"issue_type":"task","created_at":"2026-04-18T21:33:36.867183010Z","created_by":"coding","updated_at":"2026-05-23T04:57:54.523352480Z","closed_at":"2026-05-23T04:57:54.523352480Z","close_reason":"Completed","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.6","depends_on_id":"miroir-uhj.5","type":"blocks","created_at":"2026-04-18T21:38:33.166505657Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-uhj.7","title":"P5.7 §13.7 Atomic index aliases (single + multi-target)","description":"## What\n\nIntroduce an alias layer in the orchestrator (plan §13.7). Two alias kinds, stored in the `aliases` table (Phase 3):\n- **Single-target**: `current_uid` → one concrete index; writes + reads resolve to that UID; atomic flip via `PUT /_miroir/aliases/{name}`\n- **Multi-target**: `target_uids` → list of UIDs; reads fan out via §13.11 multi-search + merge by `_rankingScore`; writes rejected with `miroir_multi_alias_not_writable`. Managed exclusively by §13.17 ILM.\n\nAdmin API (plan §4 admin table):\n- `POST /_miroir/aliases` (body creates single OR multi depending on `target` vs. `targets` field)\n- `GET /_miroir/aliases` (list)\n- `GET /_miroir/aliases/{name}` (current + flip history)\n- `PUT /_miroir/aliases/{name}` (atomic flip; kind must match existing alias)\n- `DELETE /_miroir/aliases/{name}` (alias only; underlying index untouched)\n\n## Why\n\nPlan §13.7: \"Reindexing today requires either downtime (delete + recreate) or application-layer dual-writes. Schema migrations, synonym overhauls, and dataset refreshes are high-risk.\" Aliases make those operational.\n\n§13.1 reshard step 5 is an alias flip; §13.17 ILM read_alias is a multi-target alias.\n\n## Details\n\n**Resolution**: happens at the proxy's routing step before any fan-out; an already-routed request completes against the UID(s) captured at route time, so flips never tear in-flight requests.\n\n**History**: `aliases.history` is a JSON array bounded by `aliases.history_retention` (default 10). Last-N flips retained for debugging + rollback.\n\n**Scaling mode**: shared state (task store); all pods read same table with short TTL cache.\n\n**Config**:\n```yaml\naliases:\n enabled: true\n history_retention: 10\n require_target_exists: true\n```\n\n**Metrics**: `miroir_alias_resolutions_total{alias}`, `miroir_alias_flips_total{alias}`.\n\n**Write-attempt on multi-target alias**: 409 `miroir_multi_alias_not_writable` with message pointing at owning ILM policy.\n\n## Acceptance\n\n- [ ] Create single-target alias → both writes + reads resolve\n- [ ] Flip: new writes land on new target; in-flight (pre-flip) request completes against the old target without error\n- [ ] Create multi-target alias → read fans out; write returns 409\n- [ ] Operator edit of an ILM-managed multi-target alias → 409 (only ILM can modify)\n- [ ] History: 11th flip evicts the oldest","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":0,"issue_type":"task","assignee":"claude-code-glm-4.7-echo","created_at":"2026-04-18T21:35:21.739087923Z","created_by":"coding","updated_at":"2026-05-23T04:57:59.342560766Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"]}
{"id":"miroir-uhj.8","title":"P5.8 §13.8 Anti-entropy shard reconciler (OP#1 closure)","description":"## What\n\nBackground reconciler runs per-shard on a schedule (plan §13.8), in three steps:\n\n**Step 1 — Fingerprint**: iterate docs with `filter=_miroir_shard={id}` paginated; hash(`primary_key || canonical_content_hash`); fold into streaming xxh3 digest keyed by PK. All replicas should produce the same root.\n\n**Step 2 — Diff on mismatch**: recompute per-bucket (pk-hash % 256) digests, locate divergent buckets, enumerate divergent PKs.\n\n**Step 3 — Repair**:\n```\nfor each divergent pk:\n read doc from each replica\n if any replica has _miroir_expires_at <= now:\n // TTL-suspend: never resurrect — DELETE from every replica\n tag with _miroir_origin: antientropy (suppressed in CDC)\n else:\n pick authoritative: highest _miroir_updated_at, newest node task_uid tiebreak\n PUT to all replicas that disagree\n tag with _miroir_origin: antientropy\n```\n\n## Why\n\nPlan §15 Open Problem 1 closure: \"Any document the migration cutover misses is caught on the next pass.\" Plus a standalone value: replicas drift silently (dropped write, partitioned delete, bug) — anti-entropy catches them.\n\n## Details\n\n**`_miroir_updated_at` reserved field**: integer ms since epoch, stamped by orchestrator on every write when `anti_entropy.enabled: true`. Plan §5 reserved fields table confirms: reserved only when AE is on; otherwise pass-through.\n\n**TTL interaction** (§13.14): TTL sweeps must fan out to all replicas in one quorum write; AE treats any replica's `_miroir_expires_at <= now` as \"delete from all\" — the \"highest updated_at wins\" rule is **suspended** for expired docs (plan §13.14 interaction paragraph).\n\n**Scaling mode** (plan §14.6): Mode A — each pod fingerprints and repairs its rendezvous-owned shards.\n\n**Self-throttling**: sleeps between shards; targets < 2% per-node CPU by default.\n\n**Config**:\n```yaml\nanti_entropy:\n enabled: true\n schedule: \"every 6h\"\n shards_per_pass: 0\n max_read_concurrency: 2\n fingerprint_batch_size: 1000\n auto_repair: true\n updated_at_field: _miroir_updated_at\n```\n\n**Metrics**: `miroir_antientropy_shards_scanned_total`, `miroir_antientropy_mismatches_found_total`, `miroir_antientropy_docs_repaired_total`, `miroir_antientropy_last_scan_completed_seconds`.\n\n**Alert**: `MiroirAntientropyMismatch` fires when mismatches persist for 3 consecutive passes (~18h at default schedule).\n\n## Acceptance\n\n- [ ] Induce divergence on 1 shard; reconciler detects within `schedule` interval and repairs\n- [ ] Expired-doc test: a stale write with older `updated_at` does NOT resurrect a doc whose `_miroir_expires_at <= now`\n- [ ] CDC subscribers do NOT see anti-entropy writes (filtered by `_miroir_origin`)\n- [ ] Mode A: 3 pods, each owns ~1/3 of shards; anti-entropy runs exactly once per shard per interval cluster-wide","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:35:21.765464465Z","created_by":"coding","updated_at":"2026-04-18T21:38:33.181224998Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.8","depends_on_id":"miroir-uhj.14","type":"blocks","created_at":"2026-04-18T21:38:33.181204787Z","created_by":"coding","metadata":"{}","thread_id":""}]}
{"id":"miroir-uhj.8.1","title":"P5.8.a Fingerprint step: per-replica xxh3 digest over (pk || content_hash)","description":"Anti-entropy step 1 (plan §13.8). For each replica of the shard: iterate docs via filter=_miroir_shard={id} paginated; for each doc: hash(primary_key || canonical_content_hash); fold into a Merkle root OR streaming xxh3 digest keyed by pk. All replicas SHOULD produce the same root in steady state. Costs dominated by read bandwidth (self-throttled to <2% CPU target). Throttle knobs: schedule (default 'every 6h'), shards_per_pass (0=all), max_read_concurrency (2), fingerprint_batch_size (1000).","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:51:10.718105882Z","created_by":"coding","updated_at":"2026-04-18T21:51:10.718105882Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"]}
{"id":"miroir-uhj.8.2","title":"P5.8.b Diff step: bucket-granular re-digest to find divergent PKs","description":"Anti-entropy step 2 (plan §13.8). Triggered on fingerprint root mismatch. Recompute per-bucket digests (pk-hash % 256). Bucketed comparison isolates divergence to ~0.4% of the PK space per bucket. Then enumerate divergent PKs within the bucket. Reused by §13.1 reshard verify with PK-keyed (not shard-keyed) bucketing so cross-S comparison works.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":0,"issue_type":"task","created_at":"2026-04-18T21:51:10.752927624Z","created_by":"coding","updated_at":"2026-04-18T21:52:42.911112407Z","source_repo":".","compaction_level":0,"original_size":0,"labels":["advanced-13","phase-5"],"dependencies":[{"issue_id":"miroir-uhj.8.2","depends_on_id":"miroir-uhj.8.1","type":"blocks","created_at":"2026-04-18T21:52:42.911034687Z","created_by":"coding","metadata":"{}","thread_id":""}]}

View file

@ -5,11 +5,11 @@
"model": "glm-4.7",
"exit_code": 124,
"outcome": "timeout",
"duration_ms": 600085,
"duration_ms": 600001,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-23T03:33:16.642041016Z",
"captured_at": "2026-05-23T04:57:42.549362800Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null

File diff suppressed because one or more lines are too long

View file

@ -5,11 +5,11 @@
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 283477,
"duration_ms": 294977,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-23T04:53:31.311594396Z",
"captured_at": "2026-05-23T04:58:11.781827941Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
823fdd020f880beb559cef8f686adbf7a1593adf
532f9208f8e684fb31bb3398a34edaab555bc619