8000 fix(cluster): route each index with its own shard count (acked ids 404 after a wider index) by xerj-org · Pull Request #2992 · xerj-org/xerj · GitHub
[go: up one dir, main page]

Skip to content

fix(cluster): route each index with its own shard count (acked ids 404 after a wider index) - #2992

Open
xerj-org wants to merge 1 commit into
mainfrom
fix/cluster-per-index-shard-routing
Open

xerj-org wants to merge 1 commit into
mainfrom
fix/cluster-per-index-shard-routing

Conversation

@xerj-org
@xerj-org xerj-org commented Nov 6, 2026
Copy link
Copy Markdown
Owner

Problem (data loss)

ShardRouter::route_doc hashed every index with one cluster-wide width. install_shard_plan and assign_one_shard widened it to the widest plan seen. When one index got more shards (a node started with four members proposes a 4-shard plan), every older index was re-bucketed:

  • acknowledged ids routed to a shard that did not hold them, so GET returned 404 and members disagreed with each other
  • new writes hashing to shard 3 got "write locally" from route_write (the older index has no shard 3 in its plan), so they were stored outside the plan, where search never looked

Fix

  • xerj-cluster router.rs: per-index width index_shards, set from each index's own assignments (assign, update_from_metadata, which also takes IndexMetadata.num_shards when larger) and removed in clear_index. route_doc uses it. Only an index with no plan falls back to the old num_shards, so single-node routing is unchanged. Adds a Route enum (NoPlan / Owned / Unowned) via route(), plus index_num_shards().
  • xerj-engine engine.rs: install_shard_plan and assign_one_shard no longer widen a global width. route_write and primary_of now return Result<Option<..>, ShardUnassigned>: a planned shard with no primary is an error and never means "write locally".
  • xerj-api cluster_shard.rs: index, get, delete and update dispatch, the forwarded update handler, replicate_write, bulk_refused_here and bulk_on_primaries now answer 503 shard_not_assigned_exception for an unassigned shard (bulk returns a 503 per item) instead of storing the document locally.
  • Search fan-out, backfill and health already read the routing table (search_targets / shards_owned_by). None of them loop over 0..num_shards, so nothing else needed the per-index width.

Before / after

Test: in-process 3-node InMemoryBus ring (tests/cluster_per_index_shard_width.rs). Create old (3 shards, 1 replica), bulk 2,000 docs, then commit a 4-shard new.

(member, id) GETs of acked old ids that missed _search total on each member
before (origin/main 7458a22) 994 / 6,000 (16.6%) not reached (test stopped at the GET assert)
after 0 / 6,000 2,000 on all 3
after, plus 500 more writes through every member 0 / 7,500 2,500 on all 3

Tests

  • New router unit tests:
    • 10,000 ids of a 3-shard index keep exactly the same route after a 4-shard index is installed, and the new index uses width 4.
    • A plan that omits shard 2 routes those ids as Unowned.
  • test_router_update_from_metadata now checks the per-index width. It used to check that the cluster-wide width grew.
  • New engine test (shard_router_write_path.rs): route_write and primary_of return Err for the missing shard, and Ok(None) for an index with no plan.
  • New API test: PUT and _bulk to an unassigned shard get 503 shard_not_assigned_exception on every member, and nothing is stored locally.
  • cargo test --release -j 16 -p xerj-cluster -p xerj-api: 1203 passed, 0 failed.
  • The regression test binary passed 20 of 20 runs. The bug is deterministic, not a flake.
  • ES-YAML conformance, run against a private node on :19740 with a throwaway data dir: 1448 passed, 0 failed, 10 skipped.

Out of scope

This does not repair documents already misplaced on a running cluster. Ids written under the widened width stay on the shard they were written to. Fixing that needs a re-route or reindex pass, which should be its own change.

The lab ring repro (xerj-cluster-lab S4) was not re-run. All numbers above come from the in-process ring.

Reference-coding

Skipped. xerj code needs the reference server on :9200, which this lane could not reach, and no quickwit or meilisearch clone exists under ~/.xerj-code/corpora. The change stays inside this repo's own router (a per-index width map and a routing enum).

🤖 Generated with Claude Code

Root cause. ShardRouter::route_doc hashed every index with one
cluster-wide width (`num_shards`). install_shard_plan / assign_one_shard
(and update_from_metadata) widened that width to the widest plan seen,
so creating one index with more shards (a node started with four
members proposes a 4-shard plan; `old` had 3) re-bucketed every older
index. Acknowledged ids then routed to a shard that did not hold them
(404 on GET, members disagreeing), and route_write returned "write
locally" for ids that landed on shard 3, which `old` has no plan entry
for, so those writes sat outside the plan where search never looked.

Fix.
- xerj-cluster router: per-index width `index_shards`, set from each
  index's own assignments in assign / update_from_metadata (also takes
  IndexMetadata.num_shards when larger), removed in clear_index.
  route_doc uses it; only an unplanned index uses the fallback
  `num_shards`, so single-node routing is unchanged. New `Route` enum
  (NoPlan / Owned / Unowned) via `route()`, `index_num_shards()`.
- xerj-engine: install_shard_plan and assign_one_shard no longer touch
  the global width. route_write and primary_of now return
  Result<Option<..>, ShardUnassigned>: a planned shard with no primary
  is an error, never "write locally".
- xerj-api cluster_shard: index/get/delete/update dispatch, the
  forwarded update handler, replicate_write, bulk_refused_here and
  bulk_on_primaries answer 503 shard_not_assigned_exception (bulk: a
  per-item 503) for an unassigned shard instead of storing locally.

No caller iterates 0..num_shards for search fan-out, health or backfill:
fan-out uses search_targets / shards_owned_by from the routing table.

Before/after (in-process 3-node InMemoryBus ring,
tests/cluster_per_index_shard_width.rs): create `old` (3 shards, 1
replica), bulk 2,000 docs, then commit a 4-shard `new`.
  before: 994 of 6,000 (member, id) GETs of acked `old` ids missed
          (16.6%) right after `new` was created.
  after:  0 of 6,000 missed; _search total 2,000 on every member; 500
          more writes through every member -> total 2,500, 0 of 7,500
          GETs missed.

Tests added: router unit tests (10k ids of a 3-shard index unchanged
after a 4-shard index is installed; a plan omitting shard 2 routes
Unowned), engine test (route_write/primary_of Err for the gap, Ok(None)
for an unplanned index), and an API test (503
shard_not_assigned_exception for PUT and bulk on every member, nothing
stored locally). test_router_update_from_metadata now asserts the
per-index width instead of cluster-wide widening.

Out of scope: documents already misplaced on a running cluster are not
moved.

Reference-coding: skipped. `xerj code` needs the :9200 reference
server, which was unreachable from this lane, and no quickwit or
meilisearch clone is in ~/.xerj-code/corpora. The change is confined
to this repo's own router (per-index width map + routing enum).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

0