Skip to content

fix(skip-flags): --skip-upload must reuse the corpus, not destroy it (#238) - #271

Merged
fcostaoliveira merged 6 commits into
masterfrom
fix/skip-flags-preserve-corpus-238
Aug 8, 2026
Merged

fix(skip-flags): --skip-upload must reuse the corpus, not destroy it (#238)#271
fcostaoliveira merged 6 commits into
masterfrom
fix/skip-flags-preserve-corpus-238

Conversation

@fcostaoliveira

@fcostaoliveira fcostaoliveira commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

The bug

--skip-upload exists to reuse an already-populated corpus. Two separate paths destroyed it instead.

Path 1 — configure(). With --skip-vector-index also set, experiment.rs still routed through Engine::configure(), destructive on 14 of the 15 engines (only Vertex is not). The flag pair meaning "do not upload, do not build an index, just use what is there" deleted the corpus, then benchmarked the empty index, printed a QPS number and exited 0.

Path 2 — engine.delete(). Cleanup runs at the end of every successful experiment unless --keep-data, which defaults to false. So the plainest form of the flag reused a corpus, measured it, and then deleted it. Found in review; both original regression tests passed --keep-data, so neither covered it.

Empirical matrix (established live)

--skip-vector-index is wired for redis / valkey / mongodb only (experiment.rs:274); every other engine's config is skipped with a warning before configure() is reached, so path 1 is reachable on exactly those three — and all three destroy. Path 2 applies to every engine.

engine path 1 (--skip-upload --skip-vector-index) path 2 (--skip-upload, no --keep-data)
redis DESTROYSFT.DROPINDEX … DD DESTROYSDBSIZE 400 → 0
valkey DESTROYSFT.DROPINDEX + SCAN/UNLINK DESTROYS (same delete())
mongodb DESTROYScollection.drop(), unbounded: unlike redis/valkey's namespace-scoped DD/UNLINK it removes the whole benchmark collection DESTROYS
qdrant not reached (config skipped) DESTROYSpoints_count 100 → collection MISSING
vectorsets not reached DESTROYSVCARD idx 100 → 0
chroma, es, opensearch, pgvector, weaviate, milvus, turbopuffer, kividb, dragonfly not reached (configure() is destructive for all of them anyway) DESTROYS
vertex not reached non-destructive configure(); delete() guarded by reuse_index_ids()

A third shape survives and the new guard is what catches it: if phase 1 was a normal upload, phase 2 destroys nothing — it publishes a QPS number from an empty <engine>-no-vector index while the real corpus sits untouched under the original config name.

Semantics

--skip-upload = "the server already holds the corpus I want; do not create, drop, recreate or otherwise modify it."

  1. configure() never runs on that path, in any flag combination. The removed arm was unnecessary anyway: --skip-vector-index rewrites the config name to <engine>-no-vector, so the prior --skip-vector-index --keep-data upload already left the schema-only index the second phase needs.

  2. Cleanup never runs either. keep_data is forced on for --skip-upload.

  3. The corpus is verified before anything is measured. Engine::corpus_row_count() reads the count off the live server and the runner compares it with the corpus measured on disk:

    • fewer rows than the corpus holds, including zero → hard error;
    • more → warning;
    • an estimate that comes up short → warning only (see pgvector below);
    • a probe failure → a hard error that says probe failure, never "the corpus is empty";
    • no count implemented → a printed note that the reuse went unverified.

    Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant, elasticsearch, opensearch, pgvector, mongodb. Chroma, Milvus and Weaviate all expose a count we have not wired up (Wire corpus_row_count() for Chroma, Milvus and Weaviate #285); only Turbopuffer and Vertex are genuinely uncountable.

Proof — server-side row counts, both binaries

Binaries confirmed distinct by a strings probe for a symbol added here (did not create this corpus: absent in pre, present in post). Digests are recorded but are not the load-bearing evidence — a debug binary embeds its build path, so they cannot falsify anything on their own.

Path 2, three engines with different code paths (pre = 0a1bb3f, this branch's first commit; post = HEAD):

                              BEFORE            AFTER (pre)       AFTER (post)
qdrant      points_count        100          collection MISSING       100
vectorsets  VCARD idx           100                 0                 100
redis       DBSIZE              400                 0                 400

All three pre-fix runs printed Cleanup (deleting index and data) and exited 0; the qdrant one printed Reuse check — server holds 100 of 100 expected rows first, then deleted the collection it had just certified. Post-fix all three print Keep data (--skip-upload did not create this corpus, so it does not delete it).

Path 1, vs master (6f8b814) — redis FT.INFO num_docs 400 → 0 while printing QPS: 825.2; mongodb countDocuments 400 → 0 while printing 118.2/38.6 QPS; valkey DBSIZE 400 → 0. All 400 → 400 on this branch.

Probe failures no longer fabricate an empty corpus (corpus intact in all three):

redis   FT.INFO ACL-denied, real num_docs=400
        → "could not read the server-side corpus size … (NoPerm: User lim has no
           permissions to run the 'FT.INFO' command). This is a PROBE failure, not a
           verdict on the corpus — the data may well be intact."   corpus after: 400
qdrant  dead gRPC port, real points_count=100  → same shape, names the connect error
mongo   dead port                              → same shape, names the topology error

A wrong vector_count no longer disables the guard (server holding 0 of 100, datasets.json under-declaring 50):

pre    Reuse check — SKIPPED (dataset does not declare … a corpus size)
       → QPS: 3171.7, Recall: 0.0000    exit 0, result file written
post   WARNING: dataset 'd238' declares vector_count 50 but its corpus on disk holds 100.
       The --skip-upload reuse check uses the MEASURED 100; fix vector_count …
       Experiment failed: … holds 0 of the 100 rows …    exit 1, no file

The verdict now reaches the artifact (2-config sweep, e-m16 amputated to 50, e-m32 intact at 100):

run rc search files outcome
default (--exit-on-error true) 1 0 m32's correct measurement is lost too
--exit-on-error false 0 1 m32 recall 1.0 published; summary rejected_experiments names e-m16the remedy the error message and README now teach
--allow-partial-corpus 0 2 m16's wrong 0.42 published, tagged waived_by_allow_partial_corpus: true

Every result file carries params.corpus_reuse — e.g. {"status":"verified","expected_rows":100,"actual_rows":100,"actual_is_estimate":false,"waived_by_allow_partial_corpus":false}.

Engine state that configure() used to set

Making "configure never runs here" a documented contract required auditing what depends on it:

  • pgvectordistance_op was empty on this path, splicing as embedding $1 and failing every query after a green reuse check. Now re-derived in search() (pure function of the dataset). hnsw_ops_class is not read in search — that half of the reported claim did not hold.
  • turbopufferdistance_metric defaulted to cosine_distance: an l2/dot dataset measured under the wrong metric, no error, plausible file. Re-derived.
  • milvusmetric_type empty. Re-derived.
  • chromacollection_id comes from a server response, so it is not derivable; now resolved by name.
  • All five Redis-wire enginescommandstats_baseline: None means "RESETSTAT ran, counters are zero". With configure() skipped, nothing reset them, so check_commandstats compared this run against zero while the counters held every failure since server start — a hard error on a run in which nothing failed. A commandstats_primed flag disambiguates and search() primes exactly once. (This also restores the CONFIG RESETSTAT that master's --skip-vector-index path performed.)
  • vertex — already self-heals in search(); untouched. The claim that it is inoperable did not hold.
  • mongodbload_schema_types in search() is kept as defensive symmetry with redis/valkey, and the comment now says so. It is not load-bearing for a pure search: the filter builders type literals off the JSON value and never read the cache.

pgvector's count is an estimate on purpose — measured, not asserted

The original finding was measurement integrity: count(*) primed the page cache immediately before the search phase, converting a cold-cache run into a warm one. "Estimates only warn" addresses the abort behaviour and not that, so here is the cache half, measured on a cold 1M-row / 768-dim table built exactly like this engine's (SERIAL PK + vector NOT NULL, STORAGE PLAIN, never VACUUMed; 3906 MB, 500000 relpages), postgres restarted between runs:

heap blocks touched cache primed cold wall clock
SELECT count(*) FROM items 500,000 3906 MB (the whole heap) 1.58 s
ANALYZE items 30,001 234 MB 0.84 s

EXPLAIN (ANALYZE, BUFFERS) confirms the plan: Parallel Seq Scan on items … Buffers: shared hit=96 read=499904.

Is ANALYZE cheap on a 1M-row table? Yes, and — more importantly — it is bounded. ANALYZE samples 300 × default_statistics_target = 30,000 rows regardless of table size, so its footprint is constant while count(*) grows linearly with the corpus. At 1536 dims (~7.8 GB) count(*) primes all 7.8 GB; ANALYZE still touches ~30,000 blocks.

But bounded is not free, and the body should not claim otherwise: ANALYZE still pulls ~234 MB — about 6% of this heap — into cache before the search phase, and it writes statistics. The perturbation is capped, not eliminated. That is stated in the shipped code comment and the README rather than left as a favourable silence.

Live behaviour: Reuse check — server holds ≈100 of 100 expected rows, then a clean search (which failed on every query before the distance_op fix).

Merged with #263's start gate, and exercised rather than assumed

This branch is merged (not rebased — it is published, and rebasing it once already produced a non-fast-forward that must never be resolved by force-push) with master 01145d4. #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 a pre-search hard error to. The merge was textually conflict-free — which is exactly the case where a clean merge that does not compile is the risk — so it was verified, not assumed:

  • Both sides survive and compile. src/start_gate.rs 994 lines, 14 engines routing through it, 11 corpus_row_count impls, 8 prime_commandstats_if_needed call sites, the keep_data forcing intact. cargo build --bins --tests clean.
  • The reuse error still reaches exit(1). fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier #263 converted an in-search return Err into fatal_search_error + break 'search_phase so pending_saves is flushed first. My guard fires before the search phase and returns through ? directly, so that restructuring does not intercept it. Live on the merged binary, against a half-deleted Qdrant collection: EXIT CODE = 1.
  • No partial or empty artifact for --plot to chart. Same run: 0 search result files, 0 summary files, 0 chart lines, and fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier #263's wrote N completed search point(s) message correctly does not fire (pending_saves is empty because nothing had run yet).
  • The complement works too — guard passes → fix(harness): thread-spawn failure and worker panics deadlocked the search start barrier #263's gate runs a real search → Reuse check — server holds 100 of 100 expected rows, three reps, and with no --keep-data the collection still holds 100 afterwards, with params.corpus_reuse {"status":"verified", …} in the artifact.
  • INV-4 / INV-4b / INV-4c pass. In all 8 search entry points across the five Redis-wire engines, prime_commandstats_if_needed()? sits immediately after the signature — before every dispatch and before every WorkerPool::new — so its early return cannot strand a gate (INV-4c's concern). No Barrier was added.

Tests

8 live integration tests across 5 engines, all verified RED. Against master (6f8b814):

valkey   --skip-upload --skip-vector-index unlinked the corpus it was told to reuse
         (400 -> 0 keys) — issue #238
mongodb  --skip-upload --skip-vector-index dropped the collection it was told to
         reuse (400 -> 0 docs) — issue #238
redis    destroyed the indexed corpus (400 -> 0 docs)  [first commit]

Against this branch's first commit 0a1bb3f (i.e. the review round's own regressions):

redis    --skip-upload without --keep-data deleted the corpus it reused  left: -1  right: 400
redis    recorded verdict: null   left: Null  right: "short"     (nothing reached the artifact)
redis    recorded verdict: null   left: Null  right: "surplus"
qdrant   --skip-upload without --keep-data deleted the collection it reused  left: 0  right: 400
kividb   KiviDB must report a count (hnsw_live_count), not degrade to a note

Mutation probe (both directions). With the guard mutated to fire only for configs whose name contains redis:

config named cfg238short   → test FAILED   (mutant killed)
config named redis-238-short → test ok     (mutant survives)

Engine::name() returns the config name, so the rename is what makes the test discriminating.

A note on KiviDB: its HNSW graph is independent of the hashes — UNLINKing 45 of 100 documents leaves hnsw_live_count at 100 and recall at 1.0000 (measured), so the count is right and there is nothing to catch. The test amputates with FT.DROPINDEX instead, which is what actually removes the searchable corpus.

Gates, on the tree merged with current master 01145d4 (#263's start gate):

  • cargo test --lib --bins: 872 passed, 0 failed across all six binaries (126 + 0 + 0 + 0 + 2 + 744). Master baseline is 860; +12 is the new unit tests.
  • cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.
  • tests/overhead_invariants.rs 9/9, including inv4_no_fixed_count_start_barrier, inv4_search_harnesses_route_through_the_start_gate and inv4_bare_start_gate_users_hold_an_abort_guard. integration_cli 4/4.
  • Suites, on dedicated containers: integration_redis 38/38, integration_valkey 26/26, integration_qdrant 27/27, integration_mongodb 19/19, integration_kividb 20/20 (test_kividb_flat_algorithm_works filtered — it hangs >40 min on this box, uses no --skip-upload, and hangs on master too).
  • integration_redis and integration_qdrant gain the REDIS_TEST_PORT / QDRANT_TEST_{REST,GRPC}_PORT overrides the valkey/kividb/mongodb suites already had, so a session can test its own container rather than flushing a shared one.

Correction to my earlier body: I previously reported integration_opensearch failing 16/16 and attributed it to container contention. The suite passes 16/16 on master and on this branch; the failure was disk pressure — the shared cluster had crossed the 90% watermark, so index creation returned index_create_block_exception: blocked by [FORBIDDEN/10/cluster create-index blocked (api)], tripping assert!(resp.status().is_success()) everywhere. Not this change, but the stated cause was wrong. Also corrected: the claim that Valkey Search rejects a vector-less FT.CREATE — it does not on the image docker-compose.test.yml pins (valkey 9.1.1); the real result is stronger, master destroys 400 keys while printing a QPS line and exiting 0.

On recall as a detector: the mean_recall: 1.0 I quoted for a half-deleted Qdrant collection is deletion-pattern-dependent, not a property. random-100 has 9 queries with one ground-truth neighbour each in ids 0-8, so deleting the upper half removes no ground-truth point → 1.0, and deleting the lower half → 0.0. Recall is a coin flip on which rows went; a server-side count is not. The qualification is now in the code comments and the test doc, and the new Qdrant test deletes the upper half deliberately.

Follow-ups filed, deliberately not fixed here

#279 (cardinality is not identity — a sibling config's corpus certifies as this one's on the 6 single-object engines), #280 (--skip-if-exists is a no-op without --skip-upload), #281 (--reset-between-configs on a run that never uploads), #282 (--update-search-ratio mutates the reused corpus by design), #283 (--skip-vector-index collapses every config into <type>-no-vector, defeating #151-4; the collision guard runs on pre-rewrite names), #284 (ES/OS _count is refresh-scoped), #285 (wire the count for Chroma/Milvus/Weaviate).

Also out of scope: #251 filter builders, #246 force-merge, #263 harness start gate, #264 results-JSON config recording (the reuse verdict should join its provenance block once both land); Valkey Search's own --skip-vector-index behaviour; the hardcoded VectorSets idx key (#236).

One new per-experiment cost, only on the --skip-upload path: measuring the corpus is a header read for npy/hdf5, but a full-file line count for the two jsonl datasets — cheap at their size, noted in the code.

Closes #238

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

fcostaoliveira and others added 2 commits August 8, 2026 14:05
…238)

`--skip-upload` exists to reuse an already-loaded corpus. With
`--skip-vector-index` also set, `experiment.rs` still routed through
`Engine::configure()` — which is destructive on 13 of the 15 engines — so the
flag pair that means "do not upload, do not build an index, just use what is
there" deleted the corpus first and then benchmarked the empty index, printing
a QPS number and exiting 0.

Measured live, before/after server-side row counts on the unfixed binary:

  redis     FT.INFO num_docs 400 -> 0   (FT.DROPINDEX idx:redis-no-vector DD)
            still reported QPS 825.2
  valkey    DBSIZE           400 -> 0   (FT.DROPINDEX + SCAN/UNLINK)
  mongodb   countDocuments   400 -> 0   (collection.drop())
            still reported QPS 118.2 / 61.1

Semantics, now predictable from the flag name:

  --skip-upload = "the server already holds the corpus I want; do not create,
  drop, recreate or otherwise modify it."

1. configure() is never called on the --skip-upload path, in any flag
   combination. The removed arm was also unnecessary: --skip-vector-index
   rewrites the config name to `<engine>-no-vector`, so the prior
   `--skip-vector-index --keep-data` upload already left exactly the
   schema-only index the second phase needs.

2. New `Engine::corpus_row_count()` reads the row count back OFF THE LIVE
   SERVER (FT.INFO num_docs, GET /collections/<n> points_count, _count,
   countDocuments, SELECT count(*), VCARD) and the runner compares it with the
   dataset's measured corpus size before measuring anything:
     - fewer rows than declared, including zero (missing index) -> hard error;
       it changes the reported number, and nothing else catches it. A
       half-deleted Qdrant collection answers every query without error and
       reports mean_recall: 1.0.
     - more rows than declared -> warning.
     - engine cannot count -> printed note that the reuse went unverified.
   `--allow-partial-corpus` downgrades the hard error to a warning.

Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant,
elasticsearch, opensearch, pgvector and mongodb; chroma, milvus, weaviate,
turbopuffer and vertex keep the default `Ok(None)` note.

One adjacent minimal touch: mongodb's `search()` now primes its schema-type
cache, which configure() used to do — otherwise a numeric filter on the
--skip-upload path would be built against string literals and match nothing.

Tests: 2 live integration tests (both verified RED against 595740f: "destroyed
the indexed corpus (400 -> 0 docs)" and "must be a hard error, but the run
succeeded" with Recall 0.6000 printed on a half-deleted corpus) + 11 unit tests.

Closes #238

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…238)

`--skip-upload` exists to reuse an already-loaded corpus. With
`--skip-vector-index` also set, `experiment.rs` still routed through
`Engine::configure()` — which is destructive on 13 of the 15 engines — so the
flag pair that means "do not upload, do not build an index, just use what is
there" deleted the corpus first and then benchmarked the empty index, printing
a QPS number and exiting 0.

Measured live, before/after server-side row counts on the unfixed binary:

  redis     FT.INFO num_docs 400 -> 0   (FT.DROPINDEX idx:redis-no-vector DD)
            still reported QPS 825.2
  valkey    DBSIZE           400 -> 0   (FT.DROPINDEX + SCAN/UNLINK)
  mongodb   countDocuments   400 -> 0   (collection.drop())
            still reported QPS 118.2 / 61.1

Semantics, now predictable from the flag name:

  --skip-upload = "the server already holds the corpus I want; do not create,
  drop, recreate or otherwise modify it."

1. configure() is never called on the --skip-upload path, in any flag
   combination. The removed arm was also unnecessary: --skip-vector-index
   rewrites the config name to `<engine>-no-vector`, so the prior
   `--skip-vector-index --keep-data` upload already left exactly the
   schema-only index the second phase needs.

2. New `Engine::corpus_row_count()` reads the row count back OFF THE LIVE
   SERVER (FT.INFO num_docs, GET /collections/<n> points_count, _count,
   countDocuments, SELECT count(*), VCARD) and the runner compares it with the
   dataset's measured corpus size before measuring anything:
     - fewer rows than declared, including zero (missing index) -> hard error;
       it changes the reported number, and nothing else catches it. A
       half-deleted Qdrant collection answers every query without error and
       reports mean_recall: 1.0.
     - more rows than declared -> warning.
     - engine cannot count -> printed note that the reuse went unverified.
   `--allow-partial-corpus` downgrades the hard error to a warning.

Implemented for redis, valkey, dragonfly, kividb, vectorsets, qdrant,
elasticsearch, opensearch, pgvector and mongodb; chroma, milvus, weaviate,
turbopuffer and vertex keep the default `Ok(None)` note.

One adjacent minimal touch: mongodb's `search()` now primes its schema-type
cache, which configure() used to do — otherwise a numeric filter on the
--skip-upload path would be built against string literals and match nothing.

Tests: 2 live integration tests (both verified RED against 595740f: "destroyed
the indexed corpus (400 -> 0 docs)" and "must be a hard error, but the run
succeeded" with Recall 0.6000 printed on a half-deleted corpus) + 11 unit tests.

Closes #238

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Docker Build Validation

Platforms tested:

  • linux/amd64 (built and tested)
  • linux/arm64 (build validated)

Tests performed:

  • --help command
  • --describe datasets command
  • Integration test (random-100 dataset + Redis)
  • Multi-platform build validation (amd64 + arm64)

fcostaoliveira and others added 2 commits August 8, 2026 16:40
…d honest

Review round on #271. Nine blockers/must-fixes.

A. `--skip-upload` alone still destroyed the corpus. `configure()` is not the
   only destructive call: `engine.delete()` runs at the end of every successful
   experiment unless `--keep-data`, which defaults to FALSE. Measured on this
   branch before the fix: qdrant 400 points -> collection MISSING, vectorsets
   `VCARD idx` 400 -> 0, redis `DBSIZE` 400 -> 0, all exit 0. `keep_data` is now
   forced on for `--skip-upload`: a run that did not create the corpus never
   deletes it. Both prior regression tests passed `--keep-data`, so neither
   covered this; two new ones (redis + qdrant) do.

B. KiviDB could not count. Its `FT.INFO` has no `num_docs` — it reports
   `hnsw_live_count` — so the guard silently degraded to "cannot count" while
   the docs claimed coverage. `ft_info_num_docs` now reads either.

C. A probe failure was fabricated into a corpus size of zero. `Err(_) =>
   Ok(Some(0))` made the trait's own error path unreachable, so a `NOPERM`
   FT.INFO, a refused gRPC port or an unreachable mongod all reported "the
   corpus is empty or missing" over an intact corpus — pointing the user at the
   wrong problem and inviting a destructive re-upload. Errors now propagate;
   only a reply that names the index/collection as absent counts as 0. Qdrant
   additionally passed `points_count` through as an Option instead of
   `unwrap_or(0)`; ES/OS now reject a non-2xx (a CLOSED index is HTTP 400 over
   an intact corpus) and a reply with failed shards.

D. A wrong `vector_count` disabled the guard. `corpus_completeness_target()`
   turns a declared-vs-measured conflict into an `Err`, which degraded to
   "cannot verify" — so an UNDER-declared count waved through the exact
   half-empty corpus the guard exists to catch (`Recall: 0.0000`, exit 0). The
   check now measures the corpus and warns about the bad declaration.

E. The verdict never reached the artifact. Result files carry
   `params.corpus_reuse` (status, expected/actual rows, estimate flag, whether
   `--allow-partial-corpus` waived it) and the summary carries
   `rejected_experiments`, so a sweep that lost most of its configs no longer
   charts as a complete one.

F. Test gaps. `Engine::name()` is the CONFIG name, so configs named `redis-*`
   let a redis-only mutation of the guard survive; the configs are renamed
   (`cfg238*`) and the mutant now dies. New live tests for valkey, mongodb,
   qdrant and kividb — three of the four engines proven destructive were
   protected by prose. The Surplus arm now has runtime coverage.
   `integration_redis`/`integration_qdrant` gain the port override the other
   suites already had, so a session can test its own container.

G. `configure()`-only state, now that "configure never runs here" is a
   documented contract. pgvector re-derives `distance_op` (empty splices as
   `embedding  $1` and failed EVERY query after a green reuse check);
   turbopuffer re-derives `distance_metric` (defaulted to cosine — silently the
   wrong metric, no error); milvus re-derives `metric_type`; chroma resolves
   `collection_id` by name. All five Redis-wire engines now prime the
   commandstats baseline in `search()`: `None` means "RESETSTAT ran", so on the
   skip-upload path the check compared against zero while the counters held
   every failure since server start, failing runs in which nothing failed.
   Vertex already self-heals and is untouched.

H. pgvector's count no longer warms the page cache. The engine never VACUUMs and
   forces STORAGE PLAIN, so `count(*)` seq-scans the whole heap (~1.1GB at 1M
   rows) immediately before the search phase, turning a cold run warm. It now
   uses ANALYZE + `reltuples`, and an ESTIMATE may only warn, never abort.

I. Accuracy. 13 engines -> 14 (only Vertex is non-destructive), in the shipped
   comment as well as the docs. The qdrant `mean_recall: 1.0` claim is
   deletion-pattern-dependent, not a property, and is now qualified everywhere
   it appears. The trait doc says plainly that only the four Redis-wire engines
   address a per-config object, so elsewhere a sibling config's corpus certifies
   as this one's — cardinality is not identity. The "engine cannot count"
   wording no longer blames the database for a gap in this tool.

Also: the rejection message and the README now name `--exit-on-error false` as
the remedy for a sweep, because `--allow-partial-corpus` re-publishes exactly
the number the guard suppresses. MongoDB's `search()` keeps its
`load_schema_types` call with a comment that says what it actually is —
defensive symmetry with redis/valkey, not a fix for a numeric-filter bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ly; no force-push)

# Conflicts:
#	README.md
#	src/bin/vector_db_benchmark/engine/dragonfly.rs
#	src/bin/vector_db_benchmark/engine/elasticsearch.rs
#	src/bin/vector_db_benchmark/engine/kividb.rs
#	src/bin/vector_db_benchmark/engine/mod.rs
#	src/bin/vector_db_benchmark/engine/mongodb_engine.rs
#	src/bin/vector_db_benchmark/engine/opensearch.rs
#	src/bin/vector_db_benchmark/engine/pgvector.rs
#	src/bin/vector_db_benchmark/engine/qdrant.rs
#	src/bin/vector_db_benchmark/engine/redis.rs
#	src/bin/vector_db_benchmark/engine/redis_utils.rs
#	src/bin/vector_db_benchmark/engine/valkey.rs
#	src/bin/vector_db_benchmark/engine/vectorsets.rs
#	src/bin/vector_db_benchmark/experiment.rs
#	tests/integration_redis.rs
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.
…assert it

The original #238 finding was measurement integrity, not abort behaviour:
`SELECT count(*)` pulled the whole corpus into the page cache immediately before
the search phase, converting a cold-cache pgvector run into a warm one. Switching
to `ANALYZE` + `reltuples` was asserted to fix that but never measured.

Measured on a cold 1M-row / 768-dim table built exactly like this engine's
(SERIAL PK + vector NOT NULL, STORAGE PLAIN, never VACUUMed; 3906 MB, 500000
relpages), postgres restarted between runs:

                     heap blocks touched   cache primed   cold wall clock
  SELECT count(*)          500 000           3906 MB          1.58 s
  ANALYZE items             30 001            234 MB          0.84 s

`EXPLAIN (ANALYZE, BUFFERS)` confirms the plan: `Parallel Seq Scan on items …
Buffers: shared hit=96 read=499904`.

The decisive property is not the 16.7x ratio but the bound: ANALYZE samples
`300 * default_statistics_target` = 30 000 rows REGARDLESS of table size, so its
footprint is constant while count(*) grows linearly. At 1536 dims (~7.8 GB)
count(*) primes all of it; ANALYZE still touches ~30 000 blocks.

Stated honestly in both the code and the README: bounded is not free. ANALYZE
still primes ~234 MB, ~6% of this heap, so the perturbation is capped rather than
eliminated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@fcostaoliveira
fcostaoliveira merged commit fd92603 into master Aug 8, 2026
18 checks passed
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Docker Build Validation

Platforms tested:

  • linux/amd64 (built and tested)
  • linux/arm64 (build validated)

Tests performed:

  • --help command
  • --describe datasets command
  • Integration test (random-100 dataset + Redis)
  • Multi-platform build validation (amd64 + arm64)

fcostaoliveira added a commit that referenced this pull request Aug 8, 2026
…gh corpus_row_count

The textual conflict was one import line. The real conflict was semantic and the
merge tools could not see it: #271 added `Engine::corpus_row_count()` to
`vectorsets.rs` as `VCARD idx` — the literal key — while this branch was moving
every other site to the per-config `idx:<config>`. Merged naively, the corpus
verification `--skip-upload` depends on would count a DIFFERENT key from the one
the upload and the search use.

That is not theoretical. Reproduced live on the merge, seeding the legacy `idx`
with exactly the expected 400 rows:

  Experiment stage: Reuse check — server holds 400 of 400 expected rows
  → QPS: 1924.5, Recall: 0.0000, Precision@returned: 0.0000, MRR: 0.0000
  exit 0

The guard passed on the wrong key, the search ran against an empty per-config
key, and a recall-0.0 result file was published under a config name that claims
otherwise — a brand-new silent-wrong result created by the merge itself, in the
exact area both PRs are about. `corpus_row_count` now probes `self.config.key`,
with a comment naming the hazard so a future merge does not re-introduce it.

`vectorsets.rs` again contains the string "idx" exactly once: the default-base
argument to `derive_index_name`.

REVIEWER FINDING (ensure_index_exists): not fixed by adding a second mechanism,
because #271 already shipped the right one. VectorSets has no `ensure_index_exists`
analogue and `VSIM` on a missing key returns an empty array with no error, so
"search succeeded" is not evidence. But `--skip-upload` now runs
`check_corpus_reuse_precondition` → `corpus_row_count` → `VCARD <key>`, and 0 rows
against a declared 400 is classified `Short` → hard error:

  --skip-upload: the corpus you asked to reuse is empty or missing — config
  'vectorsets-migrate' holds 0 of the 400 rows dataset 'vs-migrate' declares.

So the README's promise that "--skip-upload against a missing/mismatched index now
hard-errors instead of silently writing a recall 0.0 file" is now TRUE for the
fifth member of the family, via the mechanism the other four also route through
for their row counts. A `VCARD`-based check inside `vectorsets.rs` would have been
a second, redundant gate.

New integration test `test_vectorsets_skip_upload_hard_errors_on_a_pre_236_corpus`
pins exactly this. It seeds the legacy un-suffixed key with EXACTLY the expected
row count, which is what gives it teeth: a `corpus_row_count` reading the old key
sees 400 of 400, returns Ok, and the run proceeds — the RED transcript above. It
carries a positive control (upload, then `--skip-upload` against the config's OWN
corpus must succeed) so it cannot pass by rejecting everything, and it runs over a
private base (`idx236mig`) rather than the global `idx`, because
`test_vectorsets_two_configs_do_not_clobber_each_other` asserts `idx` is never
written and two tests mutating it would race at default test threads — the very
property #236 restored.

Docs: README's "Per-config index isolation" section now names VectorSets in the
heading, lists `VECTORSETS_INDEX_NAME` with the other four `*_INDEX_NAME` knobs and
their `_EXACT` rule, describes the single-key shape (no doc-key prefix), gives the
pre-#236 cut-off alongside the pre-#151-4 one, and explains why the hard-error
promise holds for an engine whose search cannot fail. The `--skip-upload` section's
destructive-configure list now reads `DEL <key>` rather than `DEL idx`.

Gates on the merge commit (new baseline: master fd92603 = 872):
  cargo test --lib --bins  → 874 passed, 0 failed
    lib 126 | vector-db-benchmark 746 | generate-dataset 2 | bench_hdf5/jsonl/npy 0
    (+2 = the two new index_naming tests)
  cargo clippy --all-targets -- -D warnings  → clean
  cargo fmt --check                          → clean
  cargo test --test integration_vectorsets   → 10/10 at DEFAULT test threads (live redis:8.8.0)
  cargo test --test overhead_invariants      → 9/9

Two-config coexistence re-verified on the merged tree, server-side:
  after A: VCARD idx:vectorsets-iso-a=400 (VDIM 8)  | legacy VCARD idx=0
  after B: VCARD idx:vectorsets-iso-a=400 (VDIM 8),
           VCARD idx:vectorsets-iso-b=400 (VDIM 16) | EXISTS idx=0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fcostaoliveira added a commit that referenced this pull request Aug 8, 2026
Merged, not rebased: the branch is published and the standing rule here is to
add a commit rather than force-push.

#271 adds a `corpus_reuse` argument to `build_search_result_json`; kept
alongside `engine_params`. It also adds `--allow-partial-corpus", which the
invocation guard immediately flagged as unrecorded — the first live catch by
that guard, and exactly the flag whose presence an artifact most needs to
state, since it suppresses the short-corpus refusal and therefore permits a
wrong recall under a config name that claims otherwise.
fcostaoliveira added a commit that referenced this pull request Aug 8, 2026
…/test debt

Follow-up to the merge commit, closing the three reviewer blockers.

BLOCKER 2 — get_memory_usage over-reported once configs could coexist.

It returns server-wide `INFO memory:used_memory`. That was CORRECT as a per-config
figure before this PR: the hardcoded key plus a destructive `DEL` in `configure()`
guaranteed exactly one resident corpus, so the server total WAS this config's cost.
Making configs coexist silently turned it into the sum over all of them — 2× for
two configs, N× for an N-config sweep, and the new coexistence test is exactly that
scenario. A metric that was right became wrong as a side effect of a correctness
fix, which is the worst way to lose one.

`VINFO` carries no memory field (verified live), but the corpus is a single key, so
`MEMORY USAGE <key>` attributes it exactly. Published as `index_memory_bytes`, the
same field name redis/valkey/dragonfly/kividb already use for their per-index
`FT.INFO` figure, so no consumer needs a special case. `used_memory` keeps its
shape and meaning as the global secondary, with the caveat spelled out in a comment
mirroring redis.rs's.

Measured on the merged tree, two 400-doc configs on one server:

  A alone:            index_memory_bytes=287,740  used_memory=1,985,664
  B, with A resident: index_memory_bytes=287,740  used_memory=2,319,864
  MEMORY USAGE idx:vectorsets-mem-a=287,740  idx:vectorsets-mem-b=287,740

The per-config figure is flat and matches the key; the global one grows. New test
`test_vectorsets_index_memory_is_per_config_not_server_wide` pins it and is RED
without the fix (`index_memory_bytes` absent → -1 for both configs).

Its final assertion deliberately does NOT check that `used_memory` grew between the
two runs: that is global state any concurrent test moves either way, and it flaked
1-in-3 in the full suite. It asserts a floor instead (`used_memory > 2 ×
index_memory_bytes`) — same point, and other corpora landing only make it safer.

BLOCKER 1 leftovers — the stale docs and the missing test.

`corpus_row_count`'s doc comment still said "The key is the hardcoded `idx` (issue
#236): this count therefore cannot distinguish two configs sharing one server", and
`engine/mod.rs`'s trait doc still listed VectorSets among the engines with "exactly
ONE such object per server, shared by every config". Both were written before this
PR and both now assert the opposite of the code. Rewritten: VectorSets is the fifth
per-config member, and its count is what makes the `--skip-upload` promise true for
an engine whose search cannot fail.

`test_vectorsets_corpus_row_count_tracks_the_live_key` is the family test #271
shipped for kividb/mongodb/qdrant/valkey and not for vectorsets — which is why CI
could not see the hardcoded probe. Same shape: upload, `VREM` half the corpus behind
the tool's back, assert the run hard-errors with "holds 200 of the 400 rows". A
constant, or a read of the old shared key, fails it.

BLOCKER 3 — no new mechanism added, because #271 already provides one.

With `corpus_row_count` reading the derived key, `--skip-upload` against a
pre-#236 corpus classifies `Short` and hard-errors before anything is measured. An
`ensure_index_exists` analogue would be a second, redundant gate on the same path.
Covered by `test_vectorsets_skip_upload_hard_errors_on_a_pre_236_corpus` (added in
the merge commit), which seeds the legacy key with EXACTLY the expected row count
so a probe on the wrong key would certify 400-of-400 and let the run publish
recall 0.0.

Docs — the third stale README claim.

The "#230 datetime corpora" section still read "`VCARD idx`" and "the engine uses a
single hardcoded key (`idx`, issue #236), there is not even a name change for the
tool to detect. A stale corpus of the right size is found, the run succeeds, and
only the recall number is wrong." Every clause was falsified. It now separates the
two cut-offs: a pre-#230 corpus IS found (right size, wrong encoding — the one
remaining silent case), while a pre-#236 corpus is NOT found and is rejected, with
the reason spelled out: `VSIM` on a missing key returns an empty array with rc=0,
so `failed_queries` stays 0 and the empty search is FAST — recall 0.0 at inflated
QPS is worse than a plain zero.

Gates (baseline: master fd92603 = 872):
  cargo test --lib --bins → 874 passed, 0 failed
    lib 126 | vector-db-benchmark 746 | generate-dataset 2 | bench_hdf5/jsonl/npy 0
  cargo clippy --all-targets -- -D warnings → clean
  cargo fmt --check                        → clean
  cargo test --test integration_vectorsets → 12/12 at DEFAULT test threads,
    three consecutive runs, no flake (master, measured the same way in a clean
    worktree three times: 1 passed / 6 failed)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Docker Build Validation

Platforms tested:

  • linux/amd64 (built and tested)
  • linux/arm64 (build validated)

Tests performed:

  • --help command
  • --describe datasets command
  • Integration test (random-100 dataset + Redis)
  • Multi-platform build validation (amd64 + arm64)

1 similar comment
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

Docker Build Validation

Platforms tested:

  • linux/amd64 (built and tested)
  • linux/arm64 (build validated)

Tests performed:

  • --help command
  • --describe datasets command
  • Integration test (random-100 dataset + Redis)
  • Multi-platform build validation (amd64 + arm64)

fcostaoliveira added a commit that referenced this pull request Aug 8, 2026
…uns stop clobbering one corpus (#278)

Closes #236.

VectorSets addressed a single hardcoded vector-set key `idx`, so every config on one
server shared and clobbered the same corpus. The key is now derived per config —
`derive_index_name("VECTORSETS_INDEX_NAME", "idx", &engine_config.name)` → `idx:<config>`
— making VectorSets the fifth per-config engine alongside Redis/Valkey/Dragonfly/KiviDB,
with a `VECTORSETS_INDEX_NAME_EXACT` escape hatch guarded against two-config collisions
at startup.

Measured on master, same test file, dedicated container: config A uploads 400 rows, then
config B overwrites them — `VCARD idx:vectorsets-iso-a = 0` while the legacy `VCARD idx`
holds 400. Master passes 0-2 of 7 depending on interleaving (a race, measured over 11
runs: 0/7 ×3, 1/6 ×7, 2/5 ×1); the branch is 12/12 at default threads ×3 and under
`--test-threads=1`, with `KEYS *` empty after every run.

A merge hazard with #271 was caught and fixed before it landed: `corpus_row_count()`
probed `VCARD "idx"` hardcoded while every other site moved to the derived key. Git did
not conflict on that line. Reproduced live before fixing — the reuse guard printed
`Reuse check — server holds 400 of 400 expected rows`, then published `Recall: 0.0000` at
inflated QPS and exited 0. The probe now names `self.config.key` and carries a MERGE
HAZARD comment; the vectorsets `corpus_row_count` test that #271 shipped for four other
engines is added.

Memory is now attributed per config: `index_memory_bytes` from `MEMORY USAGE <key>`,
published under the same field name the four FT engines use, degrading to `null` rather
than to a wrong number. Measured live: 100 × 32-d → 73,274 B; 1000 × 32-d → 724,482 B.

Scope, stated honestly: the fix is correct at all eleven call sites, but the test suite
covers five. `vadd_single`, `delete`, `configure`'s `DEL` and both `VINFO` sites have no
guard — filed as #293, along with the underlying reason a mixed-workload test cannot see
it (`update_count` counts client-side attempts, never the server's acceptance into the
searched corpus). Data isolation is fixed; measurement isolation is not, because
`CONFIG RESETSTAT` remains server-global. Also filed: #291 (dead `src/vectorsets/`),
#294 (the sanitize-collision guard path is untested for every engine).

Reviewed by seven adversarial agents. 874 unit tests (872 + 2), integration 12/12 ×3,
`overhead_invariants` 9/9, fmt and clippy clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--skip-upload --skip-vector-index DESTROYS the corpus it was told to reuse

1 participant