Skip to content

mongodb: give every config its own collection and search index (#306) - #308

Merged
fcostaoliveira merged 2 commits into
masterfrom
fix/mongodb-per-config-index-306
Aug 11, 2026
Merged

fcostaoliveira merged 2 commits into
masterfrom
fix/mongodb-per-config-index-306

Conversation

@fcostaoliveira

Copy link
Copy Markdown
Collaborator

Closes #306.

The bug

MongoDB addressed one fixed bench.vectors / vector_index for every config, and never adopted the per-config namespacing from #151-4 (used today by redis, valkey, dragonfly, kividb, vectorsets).

Under --skip-upload, the shipped 12-config HNSW sweep (M ∈ {16,32,64} × EF_CONSTRUCTION ∈ {100,200,400,800}) therefore produced 12 result files with 12 distinct HNSW labels, all measured against whichever index the first config built — --skip-upload skips configure(), and configure() is the only caller of create_vector_index.

The reuse precondition could not catch it: corpus_row_count is countDocuments({}), which interrogates the write store, while queries are served by the Atlas search index — a separate system that can be missing, stale, or built with different parameters while the count is perfect.

The fix

collection_name and index_name now derive from engine::index_naming::derive_index_name, following the Redis-family convention exactly. MONGODB_COLLECTION / MONGODB_INDEX_NAME remain overrides but are now bases with the sanitized config name appended, with the usual <BASE>_EXACT=1 escape hatch — the same precedent #151-4 set for REDIS_INDEX_NAME.

MongoDB-specific wrinkle: the server refuses a <db>.<collection> namespace over 255 bytes (verified live against mongodb-atlas-local:8.0.17 — 200 bytes accepted, 250 rejected). Over-budget names become <head>~<16 hex FNV-1a-64 of the whole name>, hashed rather than truncated, so two configs sharing a long prefix cannot collapse back onto one collection. FNV rather than DefaultHasher because SipHash is not guaranteed stable across Rust releases and the name must resolve identically from a later build for --skip-upload to find the corpus. The search index name is left unbounded (a 5000-byte one was accepted).

MongoDB also joins the #151-4 startup collision guard. Its destructible namespace is the collection, not the index — configure() drops the collection and the search index lives inside it — so that is what the guard compares, and it calls the engine's own naming function rather than re-deriving, so the guard checks the name production actually uses.

index_naming itself is untouched: a 255-byte namespace ceiling is MongoDB-specific knowledge with no Redis analogue, so the bound lives in the engine. Zero conflict surface with other in-flight branches there.

Tests

Every test was verified RED by mutation — reverting derivation to env_or, replacing the hash with plain truncation, dropping db_name from the budget, and removing "mongodb" from the guard.

Test Breaks when
sweep_configs_derive_distinct_collections_and_indexes derivation reverted
shipped_hnsw_sweep_gives_every_config_its_own_collection reads the real 12-config file; a colliding 13th config fails here
long_config_names_are_bounded_without_colliding plain truncation — this is the #294 sanitize-collision path, now tested
the_namespace_budget_accounts_for_the_database_name budget stops subtracting db_name.len()+1
bounding_is_deterministic hash swapped (pins literal FNV vectors)
bounding_survives_a_budget_smaller_than_the_digest slice panic at tiny budgets
bounding_cuts_on_a_char_boundary mid-UTF-8 slice panic (env base is unsanitized)
collection_env_override_is_a_base_not_a_pin derivation reverted
test_binary_mongodb_skip_upload_against_another_configs_corpus_is_fatal the headline regression — config B reusing config A's corpus must abort, and A's corpus must survive
test_binary_mongodb_exact_pin_with_two_configs_is_rejected_at_startup "mongodb" removed from the guard; asserts the error text
test_binary_mongodb_keep_data_keeps_every_configs_corpus pins the experiment.rs:298-305 claim, which is now true for MongoDB

Verification

make check clean · cargo test --bin vector-db-benchmark 883 pass · cargo test --lib 129 pass · MONGODB_PORT=27018 cargo test --test integration_mongodb 25 pass · overhead_invariants 9 pass · integration_vectorsets exact_pin passes (its asserted substring is preserved).

Reviewer notes — deliberate calls worth challenging

  1. MONGODB_COLLECTION / MONGODB_INDEX_NAME semantics change from "exact name" to "base name". Anyone scripting against a fixed collection now gets vectors:<config>; _EXACT=1 restores the old behaviour. This follows the Vertex benchmark reliability: 401 handling, token refresh, --engines-file no-op, Redis M×EFC index collision, search watchdog #151-4 precedent, but it is a user-visible break and no doc outside the engine mentions these vars.
  2. The index name is namespaced too, not just the collection. Per-config collections alone fully isolate (an Atlas search index is scoped to its collection), and the guard now rejects the pinned shared-collection case anyway — so this half arguably buys nothing today. It matches the issue text and costs nothing; dropping it is a clean one-line revert if you prefer a smaller blast radius on MONGODB_INDEX_NAME.
  3. No identity marker added to corpus_row_count. Per-config naming makes a cross-config --skip-upload count 0 and fail, so a marker would answer the same question one layer later. The dataset dimension of --skip-upload verifies corpus SIZE, not corpus IDENTITY (cardinality is not identity) #279 (one config reused across two same-sized datasets) is deliberately left open — that is identical on all 15 engines and wants one cross-engine mechanism.

Merge order

This conflicts with #305's branch in mongodb_engine.rs and tests/integration_mongodb.rs (both edit the same file in different regions). #305 fixes a higher-severity bug — recall published against a ~1%-indexed index — so land #305 first and I will rebase this.

🤖 Generated with Claude Code

The shipped `mongodb-single-node.json` sweep is 12 configs — M ∈ {16,32,64}
× EF_CONSTRUCTION ∈ {100,200,400,800}. All 12 addressed one hardcoded
`bench.vectors` / `vector_index`, because `collection_name` and `index_name`
were read straight from process-wide env with fixed defaults and MongoDB
never joined the per-config namespacing of #151-4.

Two things followed. `configure()` calls `collection.drop()`, so config B
destroyed config A's corpus — which also made the `--keep-data` note in
`experiment::run` ("keeps EVERY config's data resident simultaneously")
false for this engine alone. And `--skip-upload` skips `configure()`
entirely, which is the only caller of `create_vector_index` and therefore
the only place `hnsw_m`/`hnsw_ef_construction` are applied: every config
then queried whichever HNSW graph the first one happened to build. Twelve
result files, twelve distinct M/EF labels, one measurement, exit 0, with
the reuse check printing "server holds N of N" because `countDocuments`
answers cardinality and the corpus it counted really was the right size —
it just belonged to a different config.

The fix routes both names through `engine::index_naming::derive_index_name`,
exactly as redis/valkey/dragonfly/kividb/vectorsets do: `MONGODB_COLLECTION`
and `MONGODB_INDEX_NAME` remain overrides but are now BASES, with the
sanitized config name always appended so a pinned base cannot re-collapse
a sweep, and with the usual `_EXACT` escape hatch. `index_naming` itself is
untouched — no shared behaviour changed.

MongoDB needs one thing the Redis family does not: the server refuses a
`<db>.<collection>` namespace over 255 bytes (verified against
mongodb-atlas-local 8.0.17 — 200 bytes accepted, 250 rejected). So the
collection name is bounded to the budget left by the database name.
Bounding is `<head>~<16 hex FNV-1a-64 over the WHOLE name>`, not a
truncation: two configs sharing a long prefix and differing only in the
tail would otherwise land back on one collection, reinstating this exact
bug through the back door (#294's "untested sanitize-collision path" — it
is tested here). FNV rather than DefaultHasher because SipHash's output is
not guaranteed stable across Rust releases and the name has to resolve to
the same collection from a differently-built binary, or `--skip-upload`
cannot find the corpus it was promised. The search index name is left
unbounded: the same server accepted a 5000-byte one.

MongoDB also joins the #151-4 startup collision guard. Its destructible
namespace is the collection, so that — not the search index — is what the
guard compares, and `MONGODB_COLLECTION_EXACT` with more than one config is
now rejected instead of silently re-arming the shared-collection world.

`corpus_row_count` deliberately stays a pure `countDocuments` and gains no
identity marker (the issue's fallback suggestion). Per-config naming already
answers the question the marker would: a `--skip-upload` run against another
config's corpus now finds no collection of its own, counts 0, and is failed
by the reuse precondition. What remains is the DATASET dimension — one config
reused across two same-sized datasets still certifies — and that is #279,
identical on all 15 engines; a MongoDB-only marker would close it here and
hide it everywhere else.

The `--keep-data` claim at experiment.rs:298-305 is now true for MongoDB, and
there is a test that says so.

Tests (each verified RED by mutating the production code back):
  * sweep_configs_derive_distinct_collections_and_indexes — literal names
  * shipped_hnsw_sweep_gives_every_config_its_own_collection — reads the real
    12-config sweep file, so a 13th colliding config fails here
  * long_config_names_are_bounded_without_colliding — the truncation-collision
    path; red under both a plain truncate and a full revert
  * the_namespace_budget_accounts_for_the_database_name
  * bounding_is_deterministic / _survives_a_budget_smaller_than_the_digest /
    _cuts_on_a_char_boundary
  * collection_env_override_is_a_base_not_a_pin
  * test_binary_mongodb_skip_upload_against_another_configs_corpus_is_fatal
  * test_binary_mongodb_exact_pin_with_two_configs_is_rejected_at_startup
  * test_binary_mongodb_keep_data_keeps_every_configs_corpus

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

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)

…fig-index-306

# Conflicts:
#	src/bin/vector_db_benchmark/engine/mongodb_engine.rs
#	tests/integration_mongodb.rs
@github-actions

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
fcostaoliveira merged commit 2909116 into master Aug 11, 2026
18 checks passed
@fcostaoliveira
fcostaoliveira deleted the fix/mongodb-per-config-index-306 branch August 11, 2026 00:23
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.

mongodb: --skip-upload measures ONE index for all 12 configs in the shipped HNSW sweep (no per-config namespacing, #151-4 never applied)

1 participant