fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier - #263
Merged
Merged
Conversation
…earch start barrier Every engine's `search()` synchronized its measured-window start with `Barrier::new(parallel + 1)`: a participant count fixed *before* the workers exist. Two ordinary failures make that count unmeetable, and both produce a permanent hang with no output rather than an error (#214): 1. The OS refuses a thread. `Scope::spawn` panics on EAGAIN (`ulimit -u`, cgroup `pids.max` — i.e. any CI container with a large `parallel`). The panic unwinds into `thread::scope`'s drop, which joins the workers already spawned; they are parked in `ready.wait()` waiting for `parallel + 1` arrivals that can never happen. 2. A worker panics before the barrier — e.g. an out-of-bounds index in the "prime" query on a short query set. The coordinator then parks in `ready.wait()` forever. `--search-timeout` defaults to `0.0` (disabled), so no watchdog breaks it. Reproduced end to end against the unmodified pre-fix shape, under a real lowered `RLIMIT_NPROC`: it panics on spawn and then hangs (SIGKILLed at 30s). The same harness on this branch fails in 1.4ms with could not start redis-search worker 4 of 4: Resource temporarily unavailable (os error 11). The OS refused the thread — lower `parallel`, or raise the thread/process limit (ulimit -u, cgroup pids.max) New `vector_db_benchmark::start_gate` replaces both barriers and the `OnceLock` start cell with a gate whose wait is satisfied by ticket *outcomes* rather than a count: * `WorkerPool::spawn` goes through `thread::Builder::spawn_scoped`, which returns `io::Result` instead of panicking. * Each worker is issued a `WorkerTicket` BEFORE it is spawned. A ticket settles by arriving, by reporting a setup failure, or — via `Drop` — by being lost to a panic or to a thread the OS never started. Any terminal outcome satisfies the coordinator, so there is no arrival count left unmet. * `Drop for WorkerPool` aborts the gate, so an early `?` anywhere in the scope closure releases parked workers instead of handing control to the same deadlock. Failure semantics follow the settled policy — anything that changes the reported number is a hard error. A worker that never started, died before the gate, failed setup, or panicked mid-run means the run measured fewer workers than the `parallel` it reports, so `WorkerPool::start` returns `Err` naming what happened. It no longer silently proceeds at `parallel - k` (previously a failed connect crossed both barriers, returned empty, and had its error discarded with no log line), and it no longer hangs. Applied to all 15 affected harnesses across 14 engines: chroma, dragonfly, elasticsearch, kividb, milvus, mongodb, opensearch, pgvector, qdrant, redis, valkey, vectorsets, weaviate (gRPC + GraphQL) and vertex (search + mixed). Weaviate's gRPC path drives tokio tasks rather than scoped threads, so it coordinates the same `StartGate` by hand; Vertex's open-loop path keeps its 100ms scheduling lead via `start_with`. Tests: `src/start_gate.rs` carries two `legacy_*` tests that replicate the pre-fix `Barrier` shape under both failure modes and assert it NEVER completes (watchdog, 2s), so "the new code returns Err" is evidence of something. The matching `WorkerPool` tests drive a real injected spawn failure (`#[cfg(test)]` thread-local seam — no runtime backdoor) and a real worker panic, and assert a prompt, informative error. Against a reverted gate both hang indefinitely (SIGKILLed at 30s); on this branch both pass in under 10ms. `tests/overhead_invariants.rs` gains INV-4 / INV-4b: no `Barrier::new(...)` may reappear in an engine, and every fan-out harness must still park at the gate. Closes #214 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Build ValidationPlatforms tested:
Tests performed:
|
This was referenced Aug 8, 2026
Open
…d make the guards real Round-2 review fixes on top of the #214 start-gate replacement. MUST-FIX items: * Weaviate's gRPC branch was the one harness driving a bare `StartGate` instead of `WorkerPool`, so it had no equivalent of `Drop for WorkerPool` → `gate.abort()`. A coordinator-future panic between the first `ticket()` and `wait_ready` unwound out of `rt.block_on`, and `Runtime::drop` then joined tasks parked on a condvar nobody would notify — #214's own shape surviving on the branch that fixes #214. New `AbortGateOnDrop`, declared AFTER `rt` so drop order aborts the gate before the workers are joined, plus INV-4c to require it. (Its `Builder::build()` panics rather than returning `Err` on EAGAIN, so that harness reports a refused thread as a panic, not the friendly message. Loud, not hung — documented in place.) * `tests/overhead_invariants.rs` never ran in CI: every `cargo test` in the workflows is `--lib --bins --release` (which excludes `tests/*.rs`) or a named `--test integration_<engine>`. INV-2/INV-3 had therefore never been enforced, and INV-4/INV-4b would have landed unfailable. Added `cargo test --test overhead_invariants --release` to the unit-test job. * `WorkerPool::spawn` now mints the ticket and passes it to the worker closure (`spawn(|ticket| …)`), and `WorkerPool::ticket` is gone. The 1:1 ticket/worker pairing was convention only, and three reviewers broke it through the public API — spawn with no ticket, `mem::forget(ticket)`, mint N-1 for N workers — each reintroducing the #214 deadlock with no `Barrier` anywhere, compiling clean and passing both guards. The first two are now unrepresentable (they fail to compile); for the third, `WorkerPool::new` takes the planned worker count and `start` refuses a pool that spawned fewer than it plans. * The guards were 24% effective with four false positives. All searches now run over comment- and string-stripped source, which also fixes the live hazard that INV-4 forbade *documenting* the bug it removes — every engine used to carry exactly such a comment. INV-4 bans the `Barrier` TYPE anywhere under `src/` rather than one spelling of the call, closing the alias / type-alias / UFCS / other-directory evasions, with a per-line `INV-4-ALLOW: <reason>` opt-out for a future legitimate barrier. INV-4b derives its engine list from `engine/mod.rs` (new engines opted in by default, exclusions carry a reason), requires one park per harness so ungating one of vertex's or weaviate's two is caught, bans `let _ = ticket.arrive_and_wait()`, and requires the park to sit below the first setup-failure arm so hoisting it above client construction — putting connection setup back inside the measured window — is caught. * `WorkerPool::spawn`'s error read "worker k of k" because it passed `index + 1` for both numerator and denominator; it now names the planned `parallel`. `StartGate::wait_ready` takes the harness label, so "N never reached the start gate" says which engine. pgvector's setup-failure message walked into `postgres::Error`'s `Display`, which is literally "db error" — it now reads `as_db_error()` / the `source()` chain, so the likeliest real trigger reads `FATAL: sorry, too many clients already` instead. Addendum blockers: * A failing search point discarded the points that already succeeded: `experiment.rs` flushed `pending_saves` only after the whole phase, so a sweep whose last point fails wrote zero files. That contradicts the policy stated for `--fail-on-dropped-queries` ("results files are still written before the run fails, so the evidence survives"). The hard failure is now recorded and raised after the flush. * The shipped pgvector configs have 25 points at `parallel: 100`, and Postgres defaults to `max_connections = 100` with 3 reserved — so a stock server is one stray session away from a hard failure now that a short-staffed pool is an error. The repo's own compose service now sets `max_connections=200`, and pgvector warns before fanning out when `parallel` exceeds the live budget, with the arithmetic. The published `parallel` values are unchanged. Tests: 13 start-gate unit tests (adds the planned-vs-spawned mismatch and the dropped-ticket cases). Against a reverted gate, 6 of the 13 fail and four hang unboundedly (SIGKILLed at 30s); on this branch all 13 pass in 2.00s, which is the two `legacy_*` hang proofs. A 10-mutant campaign over the guards kills all 8 realistic misses and leaves both false-positive probes alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker Build ValidationPlatforms tested:
Tests performed:
|
This was referenced Aug 8, 2026
…4 opt-out actually work
`strip_comments_and_strings` treated every escape as two consumed
characters, so a `\`-plus-newline string continuation swallowed its
newline and the stripped text ended up shorter than the file. There are
219 such continuations under `src/`.
Both INV-4 behaviours depend on that alignment, and both were broken:
* violations print `path:line` from the stripped text, so any barrier
below the first continuation in a file was reported — and quoted —
against the wrong source line (verified live: a probe on redis.rs
raw line 3836 was reported as 3833);
* the `INV-4-ALLOW:` opt-out is read from the RAW line at that index,
so a marker on the real line was ignored, and a marker three lines
ABOVE an unrelated barrier silently exempted it.
Latent — no `INV-4-ALLOW:` marker exists anywhere under `src/` yet, so
nothing was masked — but the file's own doc comment claimed "newlines are
preserved so reported line numbers stay true", which is exactly the class
of shipped false claim about a guard that this cycle has been clearing.
The escape branch now emits `\n` when the escaped character is a newline,
making the doc comment true.
Tightened the opt-out while pinning it. A marker may TRAIL the offending
line, or sit on the line above as a STANDALONE comment (a `use` reads
better with its reason above it) — but a marker trailing line N no longer
exempts line N+1, which would let one annotated barrier quietly cover its
unannotated neighbour.
Three tests pin it: a fixture asserting per-line width and that only the
code barrier (not the one in a comment) is reported; a whole-tree oracle
asserting stripped line count == raw line count for every file under
`src/`, plus that the tree still contains the `\`-continuations the case
depends on; and a marker test with a continuation above two adjacent
barriers, one annotated. All three fail against the unfixed stripper.
Also documents the two residual ways to defeat the ticket coupling.
`WorkerPool::spawn` minting the ticket prevents ACCIDENTAL omission — you
cannot forget one, mint too few, or pair the wrong ticket with the wrong
worker, and the compiler enforces all three — but `mem::forget(ticket)`
and sending it out of the closure over a channel both leave it unsettled
and hang `wait_ready`. Says so plainly rather than claiming the mistake is
unrepresentable.
Follow-ups filed, not fixed here: #276 (INV-4c is a per-file existence
check, so a second bare `StartGate` in `weaviate.rs` slips through) and
#277 (the pgvector connection-budget warning counts background workers
and subtracts superuser-reserved slots from a superuser connection, so it
warns spuriously).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
Merged rather than rebased: the branch is already pushed and this repo's standing rule is to add a commit, never to force-push.
Docker Build ValidationPlatforms tested:
Tests performed:
|
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
Merging rather than rebasing: the branch is already published and rebasing it once produced a non-fast-forward that must not be resolved by force-push. #263 replaced the fixed-count `Barrier` in all 14 engine search harnesses with `start_gate`, touching the same worker-spawn and error-propagation paths this branch adds `corpus_row_count()` and the pre-search reuse guard to, so the merge is exercised rather than assumed: see the commit that follows for the gate results on this tree.
Docker Build ValidationPlatforms tested:
Tests performed:
|
fcostaoliveira
added a commit
that referenced
this pull request
Aug 8, 2026
…t an engine (#223) INV-4b (added by #263, and first actually RUN by CI this afternoon) derives its engine list from `engine/mod.rs`'s `mod X;` lines minus a hand-maintained EXCUSED list, then requires each survivor to hold a `WorkerPool::new` / `StartGate::new`. `engine/geo.rs` is 359 lines of spherical geometry with zero `impl Engine for` blocks, so it has no timed search to gate and belongs with the five helpers already listed (index_naming, redis_utils, vertex_grpc, weaviate_grpc, filter_guard). The message it printed — "the synchronized start is gone" — reads as a regression, but a file that never had a gate cannot have lost one. Not touching that message here: it is #263's guard, and geo work should not be entangled with harness work. The message, and the fact that EXCUSED must be hand-fed for every future non-engine module, are filed as #287. `cargo test --test overhead_invariants`: 9 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
Every engine's
search()synchronized its measured-window start withBarrier::new(parallel + 1)— a participant count fixed before the workers exist. Two ordinary failures make that count unmeetable, and both produce a permanent hang with no output rather than an error:Scope::spawnpanics onEAGAIN(ulimit -u, cgrouppids.max— i.e. any CI container with a largeparallel). The panic unwinds intothread::scope's drop, which joins the workers already spawned; they are parked inready.wait()waiting forparallel + 1arrivals that can never happen.ready.wait()forever.--search-timeoutdefaults to0.0(disabled), so no watchdog breaks it.The second half: a published number whose name lies
The barrier deadlock is the headline, but the same code had a quieter defect that needed no fault injection at all. Both cases below are a shipped config against a stock server, reproduced during review:
pgvector
parallel: 100(25 such points inpgvector-single-node.json) against Postgres 17 withmax_connections=64:parallel: 100,rps: 23.5FATAL: sorry, too many clients alreadyonly 64 of 100 pgvector-search workers reached the start gate — 36 failed setup: pgvector-search worker connect failed: FATAL: sorry, too many clients alreadyA 64-wide run labelled 100. The 36 failed workers crossed both barriers, returned empty, and had their errors discarded with no log line.
Redis
maxclients=24,parallel: 64: master exits 0 and publishesparallel: 64, rps: 299.5, failed_queries: 0, mean_recall: 1.0— measured by 24 workers. This branch exits 1 withonly 24 of 64 redis-search workers reached the start gate — 40 failed setup … Refusing to report a run at parallel=64 that measured fewer workers.Blast radius (independently derived)
The issue named 6 engines; its own follow-up comment had already raised that to 13.
grep -rn 'Barrier::new'oversrc/gives 15 harnesses across 14 engines — the extra being vertex, whose two harnesses spell itBarrier::new(workers + 1):searchready+go+OnceLockstart cellsearchgRPC and GraphQLsearchandmixedOnceLock+ spinChecked for the same shape in other fixed-count primitives and found none: no
mpscchannel awaiting N messages (the only channel isexperiment.rs's watchdog, which usesrecv_timeoutand is disconnect-safe), noCondvar/latch counters, noWaitGroup.src/redisearch/andsrc/vectorsets/also contain barrier-free scopes but are not declared inlib.rsand are not compiled (confirmed against cargo's own dep-info).23
s.spawnsites remain, in the upload / filter-only / mixed / turbopuffer paths. None has a fixed-count wait, so a refused spawn there panics loudly rather than hanging: 8 rely on an explicith.join().unwrap()and 15 onthread::scope's implicit join, and either way the panic reaches the main thread. One of them —turbopuffer.rs:586— is nonetheless inside a timed search harness, so it measures connection setup inside the window; that and seven other timed harnesses are #266.The fix
New
vector_db_benchmark::start_gatereplaces both barriers and theOnceLockstart cell with a gate whose wait is satisfied by ticket outcomes rather than a count:WorkerPool::spawngoes throughthread::Builder::spawn_scoped, which returnsio::Resultinstead of panicking, and mints the worker's ticket itself, handing it to the closure. A worker cannot exist without a ticket, or a ticket without a worker — that pairing is the whole deadlock-freedom argument, so it is an API property, not a convention.WorkerPool::newalso takes the planned worker count, andstartrefuses a pool that spawned fewer than it plans.Drop— by being lost to a panic or to a thread the OS never started. Any terminal outcome satisfies the coordinator, so no arrival count is ever left unmet.Drop for WorkerPoolaborts the gate, so an early?anywhere in the scope closure releases parked workers. Weaviate's gRPC path fans outtokiotasks rather than scoped threads and drives a bareStartGate, so it holds anAbortGateOnDropdeclared after the runtime — without it, a coordinator-future panic left tasks parked on a condvar andRuntime::dropjoined them forever, which is thread-spawn failure deadlocks the fixed-count Barrier in 6 engines' search harness #214's own shape. INV-4c now requires that guard.Vertex's open-loop path keeps its 100 ms scheduling lead via
start_with. Every other engine's diff is mechanical.Failure semantics
Per the settled policy — anything that changes the reported number is a hard error. A worker that never started, died before the gate, failed setup, or panicked mid-run means the run measured fewer workers than the
parallelit reports, soWorkerPool::startreturnsErrnaming what happened.Scope of the "silent degradation" half. This closes it for the closed-loop
search()of 14 engines only.search_mixed(4 engines),search_filter_only(3) andturbopuffer::searchstill publish aparallelfrom config with no guard — tracked in #266.Mid-run panics are a diagnostic improvement, not a correctness fix. Master does not publish a wrong result here: all 14 engines used
h.join().unwrap(), so a worker panic re-panics on the main thread and the process exits 101. The gain is a clean exit 1 with1 of 8 redis-search workers panicked mid-run; discarding the run rather than reporting partial results, and a named thread (redis-search-4) where master printsthread '<unnamed>'.Reproduction — before / after
Real
RLIMIT_NPROC(ulimit -uset 8 above the current task count,parallel=64), against the pre-fix shape extracted verbatim into a standalone binary:failed to spawn thread: Os { code: 11, kind: WouldBlock }, then hangs. SIGKILLed at 30 s in 2 of 3 runs; the third could not start becausetimeoutitself could not fork. 30 s is thetimeoutbound — a floor, not a measured hang duration.could not start redis-search worker 7 of 64: Resource temporarily unavailable (os error 11). The OS refused the thread — lower parallel, or raise the thread/process limit (ulimit -u, cgroup pids.max). Single-machine numbers, and the extraction harness is not in this diff; an independent reviewer measured 142 µs–9.3 ms on other boxes, and one noisier box 17–27 ms. The claim reproduces qualitatively, not to the decimal.In CI, deterministically, via a
#[cfg(test)]thread-local spawn-failure seam (absent from a release build by bothstringsandnm— a test seam, not a runtime backdoor), plus real worker panics and real dropped tickets. Reverting the gate to the pre-fix semantics — fixed arrival count, noDropsettling,Scope::spawn's panic, failed workers crossing both barriers, no planned-vs-spawned check — turns 6 of the 13 start-gate tests red:Those six only report rather than hanging forever because each wraps its call in a 5 s watchdog. With the watchdog raised to an hour, four of them are SIGKILLed at the 30 s
timeoutbound —spawn_failure_…,worker_panic_before_the_gate_…,worker_setup_failure_…,a_worker_that_settles_nothing_…. On this branch all 13 pass; the suite takes 2.00 s, which is the twolegacy_*hang proofs deliberately watching a known-deadlocked shape for 2 s each. Every other test completes well under the 5 s bound it asserts (per-test timing is nightly-only, so no finer figure is quoted).Tests and guards
src/start_gate.rs— 13 unit tests. Twolegacy_*tests replicate the pre-fixBarriershape under both failure modes and assert it never completes, so "the new code returnsErr" is evidence of something rather than a tautology. Reviewers confirmed they are non-tautological: neuteringDrop for WorkerTicketalone produces exactly one failure, a full pre-fix revert produces six.tests/overhead_invariants.rs— this file had never run in CI. Everycargo testin the workflows is--lib --bins --release(which does not buildtests/*.rs) or a named--test integration_<engine>, so INV-2 and INV-3 have been unenforced since they landed, and INV-4 would have been unfailable on day one. This PR addscargo test --test overhead_invariants --releaseto the unit-test job.Barriertype, and all 14 engines used to carry a comment describing the barrier it removes, so the guard forbade documenting its own subject.Barriertype anywhere undersrc/, not one spelling of the call — closing the alias, type-alias, UFCS and other-directory evasions at once — with a per-line// INV-4-ALLOW: <reason>opt-out so a future legitimate barrier is possible but reviewable.engine/mod.rs(new engines opted in by default; each exclusion carries a reason), requires one park per harness so ungating one of vertex's or weaviate's two is caught, banslet _ = ticket.arrive_and_wait(), and requires the park to sit below the first setup-failure arm — which catches hoisting it above client construction, i.e. connection setup back inside the measured window.AbortGateOnDropwherever a bareStartGateis driven.Mutation campaign — 10/10 as intended (8 realistic misses killed, 2 false-positive probes left alone):
let _ = ticket.arrive_and_wait()(redis)use std::sync::Barrier as Rendezvous(kividb)type Gate = std::sync::Barrier+ UFCS (milvus)AbortGateOnDropremoved (weaviate)Barrier::newBarrier::newin a commentINV-4-ALLOWThe three API-level mutants reviewers used to break the ticket/spawn pairing are now unrepresentable:
pool.spawn(\|\| {})andpool.ticket()fail to compile (E0593,E0599), and minting N-1 for N planned fails at runtime withtest spawned 3 workers but the run is labelled parallel=4.Other review fixes
WorkerPool::spawn's error read "worker k of k" (both operands wereindex + 1); it now names the plannedparallel—worker 7 of 64.StartGate::wait_readytakes the harness label, so "N never reached the start gate" says which engine.postgres::Error'sDisplay, which is literally"db error"; it now readsas_db_error()and thesource()chain, so the likeliest real trigger readsFATAL: sorry, too many clients already.experiment.rsflushedpending_savesonly after the whole phase, so aparallel 1, 2, 4sweep in which only4fails wrote zero files. That contradicts the policy stated for--fail-on-dropped-queries("results files are still written before the run fails, so the evidence survives"). The hard failure is now recorded and raised after the flush.Behaviour change to be aware of
The shipped pgvector configs have 25 points at
parallel: 100, and Postgres defaults tomax_connections = 100with 3 reserved for superusers — so on a stock server those points are one stray session away from a hard failure that master papered over. Mitigations here, none of which change a publishedparallelvalue:tests/docker-compose.test.ymlstarts pgvector withmax_connections=200.parallelexceeds the live budget, with the arithmetic (max_connections=,superuser_reserved=, in use, available). Advisory only — it is racy by construction and meaningless behind a pooler; the gate stays the authority.--repetitions(default 3) already absorbs transient failures: a run whose rep 1 was refused but whose reps 2–3 succeeded still exits 0 with a valid result. Only deterministic failures go red.Follow-ups filed (not fixed here)
search_mixed×4,search_filter_only×3,turbopuffer::search).wait_readyhas no deadline, so a worker that hangs inside setup still hangs the run. Not a regression (the barrier was identical), but the module doc's "progress is therefore guaranteed" overstated it and has been softened.go.wait()— a second thundering herd inside the window.--exit-on-error falsepublishes a truncated summary andconcurrency_curvereports a knee computed from it.StartGateinweaviate.rswould slip through (there is one today, and it is guarded).WorkerPool::startmerges sample buffers insidetotal_time. Sub-millisecond at 10k queries; also notes that pre-merge results carry a slightly larger dead-time penalty than post-merge ones, which matters because this repo publishes trend charts.Note for whoever sequences this with #246
engine/opensearch.rswas flagged as off-limits for PR #246, but it is one of the six engines the issue names and leaving it would keep an identical hang alive next door. The escape hatch is not "drop one hunk" — it is 7 hunks; dropping only the harness leaves an unuseduse …WorkerPooland failsclippy -D warnings, and dropping the file entirely makes this PR's own tests fail (INV-4 flagsopensearch.rs:1516/1517, INV-4b derives it fromengine/mod.rs). My earlier non-overlap claim also missed my own import hunk at@@ -16,6, which falls inside #246's@@ -8,12; git merges it because the insertion points differ by two lines — resolved by margin, not by design.git merge-treeis clean for+#251,+#246and all three together; if a conflict ever appears at that import, keep all threeuselines.Closes #214
Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com