fix(store): retry MemWAL init and shard-claim races in ContextStore::add (multi-replica hardening) - #201
Conversation
Correction — I overstated this@beinan asked whether this is a real bug given a guarantee of not initializing two stores. Having checked the actual call sites, the answer is largely no, and my framing was wrong. Within a single process, that guarantee already holds:
So the "20/20 appends failed" figure is from a scenario I manufactured, not one the server can reach. Presenting it as a live production bug was overclaiming, and the PR description should not have omitted the precondition. What remainsOne case the single-process guarantee cannot cover: That said, I found no evidence in this repo that the server is deployed multi-replica: Plan
The retries themselves are still defensible: both errors are ones Lance itself marks retryable ( Question that decides it: will a context store ever be written by more than one server replica? If not, this should just be closed. |
```diff - cargo test -p lance-context-core -p lance-context-master --lib + cargo test --workspace --all-targets ``` `--lib` runs **unit tests only**. The preceding `--no-run` step compiled the `crates/lance-context-core/tests/*.rs` integration tests and then **discarded them** — they never executed. ## Evidence The CI log from **#199**: ``` test result: ok. 167 passed ← core unit tests test result: ok. 14 passed ← master unit tests test result: ok. 1 passed ← the one etcd test named explicitly ``` No line for the **5 WAL-merge concurrency tests that PR added**. They merged into `main` having never run in CI — as had `wal_merge_generation_cleanup.rs` before them. `--workspace` additionally covers `lance-context-api`, `-server`, `-client`, `-metrics` and the facade crate, **none of which were tested at all**. ## Impact **250 tests pass** under the new command, against **182** under `--lib`. Runtime ~7 min against the existing 30 min timeout. Same class of failure as #195 (Python CI collecting the wrong directory), in a different mechanism: tests that exist, compile, and are silently not run. I flagged `--lib` in an earlier review and then failed to re-check it when adding integration tests in #199 — a test that never executes is the same as no test. ## Note This PR is **only** the CI change, deliberately. It was originally bundled with a `ContextStore` concurrency fix in #201; on review that fix addresses a scenario that is not currently reachable in single-process deployments, so it should be argued on its own merits rather than riding along with an unambiguous CI repair. #201 will be rescoped. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
Hardening, not a fix for a live single-process bug. Read the scope note below. `ContextStore::add` has two unretried races against a concurrent writer of the same dataset: 1. `initialize_mem_wal` is a CreateIndex transaction. On a fresh dataset two writers both see `has_mem_wal == false` and both try to create it; the loser gets "Retryable commit conflict ... preempted by concurrent transaction CreateIndex" and the entire append fails. 2. Opening a `mem_wal_writer` claims the shard epoch; a concurrent open loses with "Failed to claim shard ... another writer claimed epoch N". `add` opens a fresh writer per group per call, so this recurs in steady state. Both are transient — the loser only has to re-read and reopen — and Lance marks them retryable in the message text itself. Each now gets a bounded retry (5 attempts). The MemWAL path re-checks after a conflict rather than retrying blindly: if the other writer's CreateIndex landed, there is nothing left to do. Rows carry caller-supplied ids and are de-duplicated by id at read time, so a retried append cannot double-count. Note the claim-loss message contains neither "fenced" nor "Fenced", so reusing the rollout store's `is_fenced_error` would not have matched the case this path actually hits. `is_shard_contention_error` covers both wordings. Scope: not reachable in a single process today `AppState::get_or_open_context_store` caches by name with a double-check, so one name maps to one `Arc<RwLock<ContextStore>>`, and every mutating route holds the write lock — appends are serialized. `#[derive(Clone)]` on `ContextStore` has no value-level clone in any non-test code. The Python binding is one store per `Context`. So a single process cannot produce the contending writers. What it does cover is multi-replica: `ContextStore` shards by *data* (`derive_region_id(bot_id, session_id)`), not by writer identity, so two server replicas — each with its own single store — writing the same `(bot_id, session_id)` land on the same MemWAL shard with no cross-process lock. `RolloutStore` avoids this by sharding on writer identity (`shard_id` from the instance hostname); `ContextStore` has no equivalent. There is no server deployment manifest in this repo, so this is a latent architectural gap rather than a defect anyone is currently hitting. The retries are cheap and correct either way, but the durable fix for multi-replica context writes would be per-writer sharding, not retry. Tests use two clones to simulate two independent writers of one dataset, which is the multi-process shape. Verified non-vacuous: forcing both retry predicates to `false` makes both tests fail. Co-Authored-By: Claude <noreply@anthropic.com>
8b5bfd6 to
f0d65de
Compare
Closing this — my fix makes things worse
Reproduced locally: 2 of 5 runs hang indefinitely on the same-session test. Distinct sessions never hangs (3/3 clean). So it is specifically shard contention — and it is my change that causes it. Root cause
My retry loop actively creates that situation: every retry opens a new writer, which claims the shard epoch, which fences the previous writer that may still be inside AssessmentPutting this together with the earlier correction:
Retrying at this layer is the wrong mechanism. The claim is not a lock you can politely re-take: reopening fences whoever is mid- Filing that as an issue instead. Closing this PR. Salvage from the work: the CI fix shipped separately in #202 and is the reason this was caught at all — under the old |
Hardening, not a fix for a live single-process bug. Rescoped after @beinan correctly pushed back — see the scope section.
ContextStore::addhas two unretried races against a concurrent writer of the same dataset:initialize_mem_walis aCreateIndextransaction. On a fresh dataset two writers both seehas_mem_wal == falseand both try to create it; the loser getsRetryable commit conflict ... preempted by concurrent transaction CreateIndexand the entire append fails.mem_wal_writerclaims the shard epoch; a concurrent open loses withFailed to claim shard ... another writer claimed epoch N.addopens a fresh writer per group per call, so this recurs in steady state.Both are transient — the loser only has to re-read and reopen — and Lance marks them retryable in the message text itself (
"Please retry"). Each now gets a bounded retry (5 attempts). The MemWAL path re-checks after a conflict rather than retrying blindly: if the other writer's CreateIndex landed, there's nothing left to do. Rows carry caller-supplied ids and are de-duplicated by id at read time, so a retried append cannot double-count.is_fenced_errorwould not have matched the case this path actually hits.is_shard_contention_errorcovers both wordings.Scope — not reachable in a single process today
I originally framed this as a live bug with a "20/20 appends failed" figure. That was overclaiming, and the correction matters:
AppState::get_or_open_context_storecaches by name with a double-check, so one name maps to oneArc<RwLock<ContextStore>>.store_lock.write().await— appends are serialized.#[derive(Clone)]onContextStorehas no value-level clone in any non-test code (verified).Context.A single process cannot produce the contending writers. My test manufactures them.
What it does cover: multi-replica
ContextStoreshards by data (derive_region_id(bot_id, session_id)), not by writer identity. So two server replicas — each with its own single store, each satisfying the single-store guarantee — writing the same(bot_id, session_id)land on the same MemWAL shard, with no cross-process lock.RolloutStoreavoids this by sharding on writer identity (shard_idfrom the instance hostname), documented as "each instance owns exactly one shard and no two instances ever contend".ContextStorehas no equivalent.There is no server deployment manifest in this repo (
deploy/kubernetes/has onlymaster.yaml, and master does not write context stores), so this is a latent architectural gap, not a defect anyone is currently hitting.The retries are cheap and correct either way, but the durable fix for multi-replica context writes would be per-writer sharding, not retry.
Testing
Tests use two clones to simulate two independent writers of one dataset — the multi-process shape. Verified non-vacuous: forcing both retry predicates to
falsemakes both tests fail.Now rebased onto
main; the CI change that was bundled here shipped separately as #202.Open question
Will a context store ever be written by more than one server replica? If the answer is a firm no, this can be closed as unnecessary complexity — I'd rather drop it than carry speculative hardening.
🤖 Generated with Claude Code