Skip to content

Release v0.4.0 — Serverless: stateless writers, tenant partitions, serve-from-storage - #13

Merged
EdgarBabajanyan merged 38 commits into
mainfrom
release/v0.4.0
Jul 5, 2026
Merged

Release v0.4.0 — Serverless: stateless writers, tenant partitions, serve-from-storage#13
EdgarBabajanyan merged 38 commits into
mainfrom
release/v0.4.0

Conversation

@EdgarBabajanyan

Copy link
Copy Markdown
Contributor

What this PR is

The complete v0.4.0 release in one merge: it contains the full stacked feature set (#10 warm serverless → #11 tenant partitions → #12 serve-from-storage) plus the release commit (version bump to 0.4.0, CHANGELOG stamp, lockfile). Merging this ships everything; #10/#11/#12 can then be closed as superseded (their commits land via this PR, authorship and sign-offs intact).

What v0.4.0 ships

Warm serverless — object storage as the source of truth with disposable nodes: stateless writer role (COMPASS_ROLE=writer, instant boot, append-only), CAS id-block allocator (no id collisions across nodes), bucket-durable collection config, background manifest refresher + min_seq read-your-writes, lazy attach + LRU detach.

Tenant partitions"config": {"partition_by": "tenant_id"} routes every chunk to an internal per-tenant namespace behind one collection API. Ids stay collection-unique; partitions auto-create on first ingest (writers included), attach on demand, hide from listings, cascade-delete. Serving RAM tracks the hot-tenant set, not tenant count. Works fully in local mode.

Serve-from-storageCOMPASS_COLD_SERVE=true: semantic queries on never-attached collections answered from object-storage range reads (segment format CSEG0003: IVF-clustered vectors + row-addressable metadata). First cold query ~70ms vs minutes of index rebuild; read-your-writes by construction; warm promotion after repeated hits.

Fixed along the way: facets broken since v0.2 (wiped per ingest, empty after restart, counted deleted chunks); O(collection) warm restarts (20.2s → 1.6s at 100k); vector-space rebuilds that never activated; 500s for missing collections (now 404); sub-1000-vector keymap bug; telemetry flipped to strictly opt-in.

Lean pass: ~1,200 lines of dead weight removed, dead_code lint re-enabled crate-wide, one filter semantics for search + deletes, docs truth-swept (README/ARCHITECTURE/CONTRIBUTING now describe v0.4 reality; new docs/deployment.md + docs/search-quality.md).

Evidence

  • Suites: 109 local / 164 object-storage; clippy clean under -D warnings; four build combinations verified; CI green on every commit.
  • 58-check live E2E (full + writer + cold node on MinIO), every endpoint and role.
  • Quality evals vs exact ground truth: warm HNSW recall@10 = 1.000 (structured data); cold IVF = 1.000 at default nprobe; exhaustive-probe sanity = 1.000 proves the cold pipeline exact; FTS 50/50 exact-token, 1.000 topical precision.
  • Live benches: +48% ingest / −57% RSS / −72% bucket bytes vs v0.3.0; 200-tenant bounded-RAM proof; 300k cold-serve proof (boot 0.3s @ 28MiB, first query 70ms).
  • Three independent audit rounds (adversarial correctness ×2, lean/pork, OSS readiness); every confirmed finding fixed with regression tests.

Migration notes

  • Local mode: no changes required; nothing cloud-shaped activates without cloud config (guard-tested).
  • Cloud mode: pre-v0.4 namespaces migrate organically (config back-fill, allocator seeding). Do NOT mix pre-v0.4 and v0.4 writers on one bucket; old readers fail loudly on CSEG0003 segments.
  • Telemetry is now opt-in (COMPASS_TELEMETRY=on).

After merge: tag v0.4.0 (the release workflow's version gate now reads the workspace manifest — fixed in this PR; it would have failed before).

Public plan for evolving Compass from a cloud-durable single-node engine
(v0.3.0) to a fully serverless database, in eight phases across four releases
— every item additive and Apache 2.0. v0.4.0 targets Phases 0-3 ("warm
serverless"): stateless writes, lazy attach, manifest refresh.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
The object-storage feature and its real-S3 integration tests never ran in CI
— only the default build was tested, so a cloud-path regression would merge
green. Add a test-cloud job running `cargo test --features object-storage`
against a MinIO service container with a pre-created bucket, which also
exercises the env-gated s3_integration tests (server-side ETag CAS, LSM
lifecycle with GC, concurrent appends) on every PR.

Also add a dependency-free DCO check requiring Signed-off-by on every PR
commit, ahead of external contributions.

Neither job touches the required-checks list.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Cold rebuild fabricated collection metadata: vector-space specs were inferred
from recovered embeddings (model:"recovered"), created_at was reset to now(),
and CollectionConfig — including embed_model — was silently replaced with
defaults. A detached/recovered collection therefore lost user configuration.

- create_collection (cloud mode) now writes a create-only
  {ns}/collection.json (name, created_at, vector spaces, default space, dims,
  config) plus an EMPTY manifest, so zero-ingest collections are discoverable
  from a fresh disk and stateless writers can validate against real config.
  Bucket failures roll the local creation back; colliding with a pre-v0.4
  namespace that has data is refused.
- rebuild_collection_from_storage reads the bucket config; the embedding
  inference remains only as a pre-v0.4 fallback and back-fills the bucket
  config create-only (organic migration, runs at most once).
- Vector-space CRUD (add/delete/set-default/mark-active) is bucket-first:
  short-lock validate -> CAS the bucket config with revalidation against the
  latest doc -> write-lock local apply. No S3 round-trip ever holds the
  collections lock (existing codebase rule).
- All gated on cloud_mode: in local mode a Storage-issued collection.json
  would collide with the real local metadata file.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Chunk ids were minted from a per-node next_id counter, so a write required
the stateful node that had the collection attached — and two nodes writing
one namespace would collide. Recovery makes the collision concrete: a
rebuilt node computes next_id = max_id+1, which can land inside another
writer's active block.

- storage/id_alloc.rs: {ns}/id-alloc holds the next block start; blocks of
  max(10_000, batch) are claimed via the proven get_versioned/put_if_match
  CAS loop. Concurrent claims receive disjoint ranges (tested with 16
  racing claimants). Crashed writers leak at most their pooled ranges —
  gaps are fine; the defended invariant is no-reuse, and replay ordering
  comes from manifest seq, not ids.
- In cloud mode EVERY ingest path allocates from blocks (attached nodes
  pool on the collection; refills never hold the collections lock across
  the S3 round-trip). next_id becomes a diagnostic high-water mark. Local
  mode is untouched.
- Seeding: create_collection seeds the allocator at 0; pre-v0.4 namespaces
  migrate on first claim by seeding from the bucket-derived high-water
  mark (create-only, race-safe). Rolling caveat documented in the roadmap:
  do not run v0.3 and v0.4 writers against one bucket.
- COMPASS_ROLE=writer: durable-append-only node. Boots instantly (no local
  collections, no recovery), validates dims against the cached bucket
  config (re-fetching once on validation failure so a stale cache never
  poisons a durable fragment), claims ids, appends ONE WAL fragment, and
  returns. Deletes append tombstones (idempotent on replay); relation
  creates store target_status "missing" (re-resolved at read time on
  serving nodes); queries and delete-by-filter are refused with clear
  errors. Consistency contract: durable immediately, searchable after a
  serving node's refresh/attach.

Tests: writer end-to-end with zero local state; writer/attached id
disjointness; pre-v0.4 allocator migration; bucket-config recovery
(real model specs + created_at survive cold rebuild, replacing the
model:"recovered" inference); zero-ingest collections survive node loss.
Suites: 103 default / 133 object-storage, all green.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…n_seq

Serving nodes never re-read the manifest after boot, so two nodes on one
bucket diverged forever, and a stateless writer's fragments were invisible
until a restart.

- SeqTracker per collection: `contiguous` frontier + out-of-band set. A
  node's own appends apply locally ahead of remote fragments with earlier
  seqs, so a single watermark would skip those remote fragments forever;
  the out-of-band set closes that hole. All five local append sites thread
  their seq into the tracker, and a check-and-mark under the write lock
  resolves the race where the refresher applies a node's own fragment
  between its S3 append and lock reacquisition (replay is idempotent).
- apply_fragment_locally: one shared replay path mirroring materialize's
  kind dispatch (data/tombstone/relation upsert+delete), reused by ingest,
  delete, and the refresher. Data replay skips already-present ids (so
  chunk_count can't double-count) and QUARANTINES wrong-dims embeddings
  with a loud error instead of corrupting the mmap vector file.
- refresh_collection: poll the manifest, apply fragments past the frontier
  in seq order. Two-branch compaction rule: watermark within our frontier
  -> skip segments and replay the tail; watermark PAST our frontier means
  fragments we never saw were folded -> full re-attach. Background
  refresher task (COMPASS_REFRESH_INTERVAL, default 5s, 0 disables) holds
  a Weak so it dies with the manager.
- applied_seq persisted in collection metadata: a persistent-disk restart
  knows how fresh its local state is and catches up the delta instead of
  rebuilding (may lag on relation-only applies; replay is idempotent).
- Read-your-writes: ingest/delete responses carry the fragment seq;
  SearchRequest.min_seq refreshes-then-serves with a bounded (2s) wait,
  rejecting seqs beyond the write history.

Tests: two-node convergence (chunks, deletes, relations); no double-apply
of own writes; both compaction branches (stale node re-attaches, current
node skips); cross-node min_seq read-your-writes. Suites: 103 default /
137 object-storage.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…budget

Cloud boot rebuilt EVERY collection from the bucket before serving — cold
start was O(total data) and attached state was unbounded, which caps how
many namespaces one worker can host.

- COMPASS_LAZY_ATTACH: boot registers bucket namespaces (one delimiter
  listing) and attaches on first request. A request stampede on a cold
  namespace rebuilds ONCE via a per-namespace mutex; the global collections
  lock is never held across the rebuild. Collections created by OTHER nodes
  after boot attach on demand too (registry miss falls through to a bucket
  existence check). Default off — eager boot remains today's behavior.
- COMPASS_MAX_ATTACHED: LRU budget. Past it, the least-recently-used
  collection detaches: removed from the map under the write lock, local
  files deleted only after the lock is released. Detach is safe because the
  bucket now carries everything (config included — the prior commit); the
  namespace stays registered and re-attaches on demand with all data.
- Every entry point (search, ingest, deletes, relations, facets, vector-
  space CRUD, compact, get/list) ensures attachment first; list/get answer
  from bucket configs for registered-but-unattached namespaces (live counts
  are known only once attached — documented).
- new_with_storage_opts: fully-explicit constructor (role, lazy, budget) so
  tests avoid process-global env races; env parsing stays in
  new_with_storage. Writer-role nodes skip local loading and recovery
  entirely — instant boot.

Tests: lazy boot does not rebuild; 8-way attach stampede attaches once;
LRU eviction at budget 1 with full-data re-attach; foreign creates attach
on demand. Suites: 103 default / 140 object-storage.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…ion bugs

An independent review round on the warm-serverless work found three critical
and six high-severity issues before merge. All fixed here, each with a
regression test:

- Config convergence (critical): vector-space changes are CAS'd into the
  bucket config, not written as fragments, so already-attached nodes never
  learned about them — and then QUARANTINED chunks carrying the new space
  (silent per-node data loss). refresh_collection now syncs the bucket
  config (spaces, default, CollectionConfig) before replaying fragments.
- Mark-after-apply (critical): delete/relations/ingest recorded a fragment
  seq as applied BEFORE applying it, so a failed local apply was invisible
  to the refresher forever (a node would serve deleted data indefinitely).
  All sites now mark only after a successful apply; replay is idempotent.
- Serialized destructive transitions (critical): refresh-triggered full
  re-attach and LRU eviction now take the same per-namespace attach mutex
  as ensure_attached — two rebuilds (or a rebuild racing a file deletion)
  can no longer run into the same live index directory.
- Delete/recreate detection: a vanished manifest detaches the collection
  (instead of warning forever while serving dead data); a recreated
  collection (bucket created_at differs) forces a full re-attach. Local
  caches (writer pools, configs, registry, attach locks) purge on delete.
- Writer hardening: deletes validate the namespace (a tombstone for a bogus
  name used to CREATE a phantom bucket namespace) and reject ids at/past
  the allocator frontier (a u64::MAX id would poison max_id -> next_id
  overflow / id reuse on every future rebuild). Writers refuse
  create_collection and vector-space CRUD with clear errors.
- True LRU: last_used is now stamped on search/ingest/facets/relations —
  eviction previously keyed on attach order and evicted the HOTTEST
  collection under budget pressure (rebuild thrash).
- Refresher efficiency: fragment refs are filtered by seq BEFORE payload
  fetches (a caught-up node downloads nothing per tick) and fragments apply
  under per-fragment lock holds, so a large backlog can't cause a node-wide
  read outage. Eviction during an ingest's S3-append gap no longer erases
  the (healthy, durable) batch — evicted is distinguished from deleted.
- delete_collection works on lazy/evicted collections, never holds the
  global lock across S3, and purges all namespace caches.
- Keymap persistence (pre-existing, exposed by block ids): sub-1000-vector
  collections never persisted the HNSW keymap, silently relying on identity
  key->id mapping that broke as soon as ids were non-dense. The keymap is
  now saved on every build and synthesized as identity for pre-fix dirs.
- CI: DCO ignores merge commits; the MinIO job fails loudly if the
  integration tests were silently skipped (env-drift guard).

New tests: SeqTracker unit semantics; true-LRU eviction; config propagation
across nodes; ingest racing a refresher loop; persistent-disk restart
catching up a writer's delta; wrong-dims quarantine without corruption;
min_seq local-mode + boundary; writer phantom-namespace/absurd-id rejection;
delete+recreate detection. Suites: 103 default / 149 object-storage.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
The bitnami/minio service-container tag didn't exist, and GitHub service
containers can't override the image command that minio/minio requires
(`server /data`). Start MinIO with docker run (same images as
docker-compose.minio.yml), wait on its health endpoint, and create the
bucket with mc — the silent-skip guard already fails the job if the
integration tests don't actually run.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Three scale walls fell together because they shared one root: segments were
a single JSON object produced by an O(collection) fold.

- JSON segments encoded f32 embeddings as decimal text (~10x bloat) and were
  written with a single PUT (S3 caps those at 5GB) — compaction simply
  failed once the live set crossed a few GB, roughly 1-3M chunks.
- Compaction materialized the ENTIRE collection into RAM every 32 fragments
  (O(collection) memory + IO per cycle; quadratic write amplification).

Now:
- Segment v2: magic-tagged sectioned binary — chunk metadata (JSON, sans
  embeddings), embeddings per space as raw f32 LE rows keyed by id,
  relations, cross-segment tombstone sections, max_id. v1 JSON and the
  oldest bare-array form still decode (versioned fallback); unknown
  sections are skipped for forward compat.
- Storage::put_large: multipart upload on the object-store backend (16MB
  parts) past a single-PUT threshold; segments are immutable and UUID-keyed
  so no CAS token is needed on them.
- Partitioned compaction: the routine cycle folds ONLY the WAL tail into an
  APPENDED segment — O(batch), not O(collection). Deletes that don't match
  anything within the tail are carried as segment tombstone sections and
  applied against older segments at materialize time (relation deletes
  too). A full merge into one segment runs only past 8 accumulated
  segments — the sole O(live-set) operation, 1/8th as often. GC discipline
  is unchanged: folded objects stage one cycle, CAS losers delete their
  orphan segment.

Tests: v2 round-trip (multi-space embeddings, tombstones), cross-segment
delete via a real two-fold sequence, GC bounds re-verified across 14 cycles
crossing the merge threshold. Suites: 105 default / 151 object-storage.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…d HNSW saves

Two more scale walls: every delete (and every replayed fragment) rebuilt the
ENTIRE filter index, and every ingest batch rewrote the ENTIRE HNSW index
file — both O(collection) per write, disqualifying past ~10M chunks.

- FilterIndex: the numeric range index moves from a sorted Vec (O(N) per
  update) to a BTreeMap keyed by total-order-encoded f64 bits (O(log N)
  inserts/removes, range scans union pre-grouped treemaps). New remove()
  reverses insert() exactly, pruning empty entries. Ingest/replay now
  insert incrementally; deletes and ingest-failure compensation remove
  incrementally; full rebuilds remain only on attach/recovery. finalize()
  is a no-op kept for API compat; the (unwired) persistence codec is
  updated for the new layout.
- HNSW: the in-RAM index stays mutable across batches (first mutation loads
  from disk once); the index FILE is rewritten every 16 batches instead of
  every batch. Vectors are already durable per batch in the mmap file, so a
  crash between saves leaves only a stale index — detected at load
  (index.size() < keymap length) and rebuilt from the mmap, then saved.
  Save-batching state lives on the collection, not the vector state.

Suites: 105 default / 151 object-storage — including the delete/facet tests
that verify incremental filter maintenance agrees with the old full rebuild.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
LoadedCollection held EVERY chunk — embeddings included — in a RAM HashMap,
capping a serving node somewhere in the 5-20M chunk range regardless of
compute. The bounded LRU read-through cache existed (search/chunk_cache.rs)
but was never wired.

- chunk_store is now the ChunkCache (bounded LRU over redb, default 100k
  resident): search assembly reads hits via get/get_batch; scoring metadata
  batches through it; parent enrichment reads through it; deletes fetch
  metadata for incremental filter-index removal through it.
- Existence checks (relation target_status, delete eligibility, replay
  dedup) go through the filter index universe — a treemap of LIVE ids
  maintained incrementally on every path, so no chunk load is needed to
  answer "does id X exist". Replay dedup additionally consults tombstones
  (a deleted id must not be re-applied just because it left the universe).
- Full scans (delete-by-filter, rebuild-job export, TAMS segment lookup)
  stream from redb via for_each instead of iterating a resident map. Boot
  rehydration builds the filter index streaming and keeps only the max-id
  scan; the chunks themselves stay on disk.

The entire existing suite — search, relations, deletes, facets, TAMS,
cloud convergence, lazy attach — passes unchanged on the out-of-core path:
105 default / 151 object-storage, clippy clean.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Operating multi-node deployments blind was a production blocker. /metrics
(Prometheus text, unauthenticated like /health) exposes ingest/search/delete
counters, refresh convergence (fragments applied, forced re-attaches),
attach counts + cumulative time, compactions, and quarantined chunks, plus
per-collection chunk-count and applied-seq gauges. Hand-rolled atomics — no
new metrics dependency; call sites go through free functions so a real
facade can replace the implementation later.

COMPASS_MAX_CONCURRENCY bounds in-flight requests (tower global concurrency
limit) instead of queueing without limit; unset = unlimited.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
COMPASS_SCALE_N gates a harness that ingests N synthetic chunks through the
full cloud path (local-disk Storage backend — same code, disk-bound),
measures ingest throughput, cold-attach time, and search latency, and skips
cleanly when unset. First measured point (250k chunks, 128 dims, under ARM
emulation): 1,603 chunks/s ingest, 134.5s cold attach, 5.5ms search.

docs/scale-envelope.md records measured numbers only, names cold-attach as
the binding constraint now that the RAM/segment/compaction/per-write walls
are gone, and states plainly that billion-vector serving waits on Phase 6
serve-from-storage indexes.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…action safety

An adversarial review of the five scale-wall commits found two criticals and
a set of high/medium issues; all fixed:

- Boot panic on DEFAULT env (critical): the concurrency layer passed
  usize::MAX/2 permits to tokio's Semaphore, whose hard cap is
  usize::MAX>>3 — the server asserted at startup whenever
  COMPASS_MAX_CONCURRENCY was unset. Capped under MAX_PERMITS.
- HNSW hole (critical): a batch erroring after in-RAM adds but before a
  save left the next batch reloading a STALE index file, adding only new
  vectors, and saving — permanently missing up to 15 committed batches
  (recall silently degraded until a cold attach). The fresh-load path now
  detects index.size() < base_key and heals from the mmap before adding.
- Compaction folds now use the STRICT fragment reader (the tolerant one
  skips NotFound — advancing the watermark past an unread fragment would be
  silent data loss), and the segment merge NEVER runs in the same
  invocation as a fold, restoring the one-cycle GC grace for readers
  holding the pre-fold manifest.
- Stale-index rebuild truncates at the keymap length (orphan mmap tail rows
  can't be indexed under fabricated ids); ambiguous manifest-commit
  failures no longer delete the possibly-referenced new segment (only a
  definite CAS conflict does); skipped filter-index removals are logged
  loudly; /metrics reads attached collections only (it was an
  unauthenticated one-S3-GET-per-namespace-per-scrape amplifier in lazy
  mode); the scale harness exercises put_large.
- fold_tail keeps carried tombstones on re-create (safe: materialize
  applies segment tombstones before the segment's own chunks) so folded
  and unfolded relation state can never diverge.

Second measured point (500k chunks, 128 dims, emulated): 1,392 chunks/s
ingest, 369.3s cold attach, 11.2ms search — attach scales linearly, as the
envelope doc now states along with the merge/attach RAM caveat.
Suites: 105 default / 152 object-storage, clippy clean.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…clude deleted

The live E2E harness (scripts/e2e.sh, added here) caught facets returning
{} on a healthy collection. Three distinct bugs, all in the same design:

- build_index returned facet bitsets for the new batch only, and
  apply_ingest_commit replaced the collection's facet state with them —
  every ingest after the first wiped all prior facets (latent since v0.2).
- open_index returned empty facet state and nothing ever rebuilt it, so
  facets were permanently empty after any restart or re-attach.
- The bitsets were dense arrays keyed by insertion position while the
  query side intersected them by chunk id; id-block allocation (cloud
  mode) breaks the dense-id assumption entirely.

Fix: facets are now HashMap<field, HashMap<value, RoaringTreemap>> keyed
by chunk id. Ingest absorbs prior state instead of replacing it; the
load/rebuild chunk scans rebuild facets in the same pass that builds the
filter index; and get_facets intersects each value's treemap with the
FilterIndex live-id universe, so tombstoned chunks stop inflating counts
(previously deleted chunks were counted until a full FTS rebuild).

scripts/e2e.sh is a 44-check live-stack harness covering every endpoint,
every filter operator, writer-role behavior, refresh visibility, and
compaction survival; it is what caught this.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
The v0.3.0 comparison bench caught a warm-restart regression: 20.2s vs
1.1s at 100k chunks. Boot logs show why:

    HNSW index at /app/data/benchcoll/vectors/default.index is stale
    (97000 < 100000); rebuilding from mmap

Batched HNSW persistence (HNSW_SAVE_EVERY=16) means a clean shutdown can
leave the index file up to 15 batches behind the per-batch-durable mmap
and keymap — so nearly every warm restart of an actively-written
collection took the stale path, and that path re-inserted ALL vectors
(O(collection), ~20s per 100k under emulation).

The runtime heal in apply_ingest_commit already does this right: load
the existing graph and append only rows index.size()..keymap.len() from
the mmap. Mirror it at load time, then re-view the saved file so the
healed index stays mmap-backed instead of RAM-resident. The keymap is
persisted per batch, so the index file is only ever behind it, never
ahead — appending the tail is always sufficient.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Three independent audit passes (dead code, additive-ness, over-engineering)
swept the codebase. This commit acts on the mechanical findings:

Deleted (grep-proven zero call sites in any target):
- BitSet dense-bitset facet structure — fully replaced by chunk-id-keyed
  RoaringTreemaps; the query side had already stopped using it
- search/backend.rs (VectorIndex wiring, UsearchHnswIndex, build_backend,
  COMPASS_BACKEND env) — zero constructors anywhere; the live vector path
  calls usearch directly. With it go the compass-index-api dependency and
  the gpu feature, which gated code no runtime path could ever select.
  The compass-index-api / compass-vector-gpu workspace crates remain for a
  future real wiring but no longer ship weight into the binary
- lsm::compact generic primitive — the live compaction path is
  read_uncompacted_fragments_strict + append_segment /
  replace_with_single_segment; its three tests are PORTED to those
  primitives (fold, watermark advance, strict-read abort on missing
  fragment), not dropped
- filter.rs (matches_filters + 7 tests): delete_by_filter now resolves ids
  through the same roaring FilterIndex::eligible pushdown search uses, so
  search and delete-by-filter can never disagree on filter semantics
- FilterExpr::eval + eval_predicate + stringify_metadata: the third,
  never-called filter evaluator
- FilterIndex serialize/deserialize + MetadataKey codec (~200 lines of
  never-wired persistence scaffolding), FilterIndex::finalize() no-op and
  its call sites, FilterIndex::is_empty
- save_vectors legacy writer (reader stays for migration), VectorState.dims
  and five FtsState field handles nobody read, three unread
  FilteredSearchExplain fields, SharedStorage alias,
  build_filter_index_from_chunks, RelationshipGraph::len
- rayon dependency: its only use was current_num_threads() inside
  128.max(...) — now std::thread::available_parallelism via one helper

Fixed (dead-code finding that was actually a live bug):
- mark_vector_space_active was never called: a completed vector-space
  rebuild updated only the in-RAM progress tracker — the space stayed
  status=building in collection metadata and the rebuilt index was never
  hot-loaded until restart. start_rebuild now takes the manager and calls
  it on completion; activation failure is reported as a failed rebuild
- The unreachable filters branch in tantivy_fts::search (with its stale
  TODO) and the vestigial existing_count param of build_index are gone

Lint hygiene: dead_code removed from the crate-level allow list — it had
been suppressing ALL dead-code detection, which is how this pile
accumulated. Test-only helpers are now cfg(test); the two deliberate
API-surface items (Storage::get_range, ObjectMeta size/version — the
sectioned-segment range-read contract) carry targeted allows with reasons.

Net -1,178 lines. Suites: 94 local / 141 object-storage, clippy clean
under -D warnings with the lint live.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
- local_mode_writes_no_wal now also asserts the id-block allocator object
  is never seeded locally and that ids stay dense next_id values across
  batches (the audit flagged both as untested local-mode invariants)
- New writer_role_is_neutralized_in_local_mode: a stray COMPASS_ROLE=writer
  on a local-disk deployment must not disable serving — cloud_mode forces
  the role to Full; previously only code-verified
- ARCHITECTURE.md data layout matched a format that no longer exists
  (meta.json/chunks.bin/index.usearch); now documents the real one
  (collection.json/chunks.redb/tantivy//vectors/<space>.*) plus the cloud
  bucket layout (manifest, wal/, segments/, id-alloc)
- CLAUDE.md/README API tables gain GET /metrics and /segments/at; CLAUDE.md
  stops claiming a wired GPU feature (the crate is standalone until wired)
- serverless-roadmap status: Phases 0-3 shipped on this branch
- .env.example documents COMPASS_MAX_CONCURRENCY
- Deleted an orphaned doc block and a stale 'Planned follow-up' note that
  described already-shipped filter pushdown

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Pure file move: 49% of the 7,118-line module was #[cfg(test)] code. Each
module is now a child file (same module tree, so super::* keeps private
access); mod.rs is 3,720 lines of engine code. No test changed: 142 pass.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Searching a missing collection returned HTTP 500 (api/search.rs mapped
every engine error to INTERNAL_SERVER_ERROR) and ingesting into one
returned 400 — stringly Box<dyn Error> carried no classification, and
three handlers (delete, relations, segments) each had their own copy of
a substring-sniffing mapper ('msg.contains("not found")').

All 32 not-found construction sites in the collection manager now build a
typed NotFound error; one shared api::error_response maps it to 404 by
downcast (no string sniffing), logs 500-class details server-side, and
keeps each handler's default for everything else. The three duplicate
mappers are gone.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
The dead-code pass gated it #[cfg(all(test, feature))] but the
object-store backend calls it at RUNTIME (object_store_backend.rs:219).
No CI job compiles the non-test object-storage combination — only the
release Docker build does — so the gate slipped through green checks:

    error[E0599]: no method named `is_empty` found for `&Version`

Gate is now #[cfg(any(test, feature = "object-storage"))]: present for
the backend and the integration tests, still dead-code-checked in the
default build.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Nothing in CI compiled the cloud feature without cfg(test) — the exact
combination the release Docker image builds — so a mis-scoped cfg gate
broke the image while every check stayed green. One cargo check line
closes the gap.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…on (Phase 6 core)

Create a collection with config.partition_by = "tenant_id" and every
chunk routes to an internal per-tenant namespace. A partition IS a full
collection under the hood — own LSM manifest/WAL/segments, Tantivy dir,
vector files, attach/evict lifecycle — so Phase 6 is a router at the
manager entry points over the existing engine, not new machinery. This
is what moves the scale envelope from per-COLLECTION to per-TENANT: a
query for tenant T attaches T's slice only, LRU eviction and refresh
scale with the hot-tenant set, and RAM is bounded regardless of how
many tenants (or how many billion vectors) the collection holds.

Mechanics:
- Partition namespace: {parent}--part--{value} (separator reserved at
  create; partition values validated kebab-case, len <= 64). Namespaces
  reuse every existing storage/path/attach rule unchanged.
- Ingest groups by metadata[partition_by] and delegates per group,
  auto-creating partitions on first sight (inheriting the parent's
  vector spaces + embed model). Writer role routes the same way,
  bootstrapping partition bucket objects with create-only writes.
- Chunk ids are COLLECTION-unique: partitions claim CAS-leased blocks
  from the PARENT's allocator — in local mode too (the allocator is a
  CAS-updated file; no WAL/manifest objects locally). Without this,
  delete-by-id and search results would be ambiguous across tenants.
- Search and delete-by-filter require a filter on the partition field:
  exact match routes to one partition, set membership fans out (<=16,
  merged by score). A tenant with no data is empty, not an error.
  Bare-id deletes are rejected with guidance (ids don't name their
  partition; probing every partition is unbounded).
- Partitions attach on demand even in non-lazy cloud mode (a writer can
  mint one at any time — 'all attached at boot' can never hold), are
  hidden from listings, and are cascade-deleted with the parent from
  disk, registry, and bucket.
- Fenced with clear errors until routed: relations, facets, TAMS
  lookup, vector-space CRUD, min_seq across a multi-partition fan-out.
  Writer-role fences consult the bucket config, so a writer can't
  durably append relations/tombstones into the parent namespace that no
  serving node ever materializes.

Tests (11 new): tenant isolation on shared query terms, id uniqueness
under interleaved multi-tenant ingest, filter-routed deletes, listing/
cascade behavior, fence + validation errors, restart persistence,
writer-role ingest visible on a serving node that predates the
partitions, and cold rebuild of a partitioned collection from the
bucket alone (ids keep minting without reuse). Suites: 105 local / 153
object-storage, clippy clean, all four build combinations verified.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Warm serverless had one wall left between it and true serverless: a
query on an unattached collection paid a full index rebuild first —
minutes at millions of vectors. This makes cold namespaces answer
semantic queries directly from object storage in a handful of small
range reads.

Segment format CSEG0003 (v2 stays readable; pre-v0.5 readers fail
loudly on v3 instead of silently dropping sections):
- meta2/metaidx: per-chunk JSON rows + a sorted (id, offset, len) index
  so any chunk hydrates with one byte-range read
- cent:/clu: per vector space past 5k rows: k-means centroids + a
  cluster directory (tiny, cacheable) and the vectors grouped by
  cluster, unit-normalized (cosine == dot). Below the threshold the
  flat emb: section remains and is brute-forced whole.
- Compaction builds the clusters (deterministic sampled Lloyd's,
  sqrt(n) clusters capped at 4096) — background CPU, off the write path.

Cold query path (search/cold.rs), COMPASS_COLD_SERVE=true (implies
lazy attach; cloud only):
- manifest read -> cached per-segment artifacts (TOC, centroids,
  tombstones, metadata index; immutable, keyed by segment id) ->
  nprobe nearest clusters fetched concurrently -> WAL tail
  brute-forced fresh -> newest-generation dedupe -> top candidates
  hydrated by byte range -> metadata filters -> top_k.
- Freshness: the manifest is read per query, so cold reads see every
  committed write including the tail — read-your-writes holds by
  construction (min_seq validated against next_seq).
- Composes with tenant partitions: partition namespaces cold-serve
  through the same router; isolation verified.
- FTS on a cold namespace is a clear error (inverted indexes still
  need an attach); hybrid degrades the same way.
- Warm promotion: COMPASS_WARM_AFTER cold hits (default 3) spawn a
  background attach so hot namespaces migrate to the fast path.
- RAM per cold namespace: centroids + directories + metadata index —
  megabytes, independent of collection size.

Also: partition_field now reads the bucket config instead of forcing
an attach (routing metadata must not warm anything); futures becomes
an unconditional dep (cold path uses concurrent range reads).

Tests: IVF roundtrip + self-recall, CSEG0003 clustered roundtrip +
v2 compat, cold search without attach (self-recall through clusters,
tail visibility, tombstone exclusion, filters, FTS fence), partition
composition, warm promotion. Suites: 106 local / 159 object-storage,
clippy clean, all four build combinations verified.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
The 500k live proof caught it: with compaction never having run (the
ingest node was OOM-killed mid-run), the cold path brute-forced a
422-fragment tail EVERY query — 2.4s latency and 4.7GiB RSS, silently.
A healthy namespace keeps its tail under the auto-compact threshold
(32); past 2x that, cold serving now fails loudly with a compact-first
hint rather than materializing the dataset per query.

With the guard in and compaction run properly, the 300k proof shows
the intended shape: fresh node, empty disk, boot 0.3s at 28MiB RSS,
FIRST query 70ms (vs minutes of attach), steady-state p50 25ms at
66MiB RSS, filters correct.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Optional COLD= node section: answers without attach, filters apply,
FTS rejected with guidance, writer-tail read-your-writes visible
instantly on the cold node, metrics counting.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…ngest, error propagation

Two independent audit passes (adversarial correctness + lean/pork) over
the Phase 5+6 diff. Fixes, by severity:

CRITICAL — cold reads applied every segment's tombstones to ALL
segments; materialize() applies a segment's carried tombstones to OLDER
segments only, so a chunk re-ingested after its delete folded would be
served warm but permanently suppressed cold. Tombstones are now
per-generation (a candidate dies only to a tombstone from a NEWER
generation; tail replay stays seq-ordered). Regression test covers both
directions.

HIGH — ensure_partition required the parent to be ATTACHED, so first
ingest of a new tenant failed on lazy/cold-serve nodes (exactly the
node type Phase 5 creates); the partition template now falls back to
the bucket config. And partition_field / is_partitioned_any_role
swallowed transient storage errors as 'not partitioned', which would
misroute tenant writes (or writer tombstones) into the parent namespace
— storage errors now propagate; only genuine NotFound means
unpartitioned.

MEDIUM — warm promotion un-latched (>= + reset) so an evicted namespace
can warm again; non-lazy LRU eviction restricted to partition
namespaces (an evicted normal collection could never re-attach there);
cold path now REJECTS what it cannot honor (hybrid with text, recency,
boosts, relationship options) instead of silently returning different
rankings than warm; filtered cold queries overfetch 8x deeper with the
recall contract documented; writer-side stale-config partition
bootstrap re-validates the parent with a fresh read (no resurrecting
cascade-deleted collections); config-less-but-data namespaces cold-serve
via manifest fallback (warm/cold parity).

LOW/lean — v2 segments rejected with an upgrade hint (previously read
whole then dropped every hit at hydration); cold 'contains' filter now
matches string lists only (FilterIndex parity); LocalDiskStorage
get_range clamps range ends like S3/GCS (416 parity for start-past-EOF);
metaidx bounds validated; paged metadata index now testable and tested;
partition create races adopt the namespace instead of destroying its
config; writer delete errors point to a serving node; local partitioned
create rolls back on allocator seed failure; shared segment magic
consts; renamed encode_segment_v3/decode_segment_sectioned; comment and
Cargo.toml drift fixed.

Suites: 109 local / 163 object-storage, clippy clean, four build
combinations verified.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…call contract

Evals: 20k docs, exact numpy ground truth, structured vs adversarial
datasets. Warm HNSW 1.000 recall@10 (structured) / 0.895 (uniform);
cold IVF reaches 1.000 at the default nprobe=8 on structured data and
degrades steeply on uniform data (0.41@8) — inherent IVF behavior, with
the exhaustive-probe row (1.000) proving the pipeline itself is exact.
FTS: 50/50 exact-token top-1, topical precision@10 = 1.000.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
…uth sweep

An external-perspective audit of the repo as a stranger would clone it.
The blockers, all fixed:

- Telemetry contradicted the product's core promise: README says 'fully
  offline, data never leaves the machine' while a PostHog startup event +
  daily heartbeat defaulted ON. Telemetry is now strictly OPT-IN
  (COMPASS_TELEMETRY=on), DO_NOT_TRACK honored, README gains an honest
  Telemetry section, .env.example documents exactly what is sent.
- cargo publish would hard-fail: no license field on any crate. Added
  Apache-2.0 to [workspace.package], inherited by all three crates.
- The release workflow's version gate grepped crates/compass/Cargo.toml,
  which says 'version.workspace = true' — every tag would fail. It now
  reads the workspace manifest.
- CONTRIBUTING told strangers to run commands that fail on a fresh clone
  (cargo test --workspace pulls the CUDA-only crate; --features gpu does
  not exist). Corrected to the exact CI invocations + Linux prerequisites
  + the Windows linker caveat.
- README was stale on exactly what v0.4 ships: it claimed 'not stateless
  multi-node serving' and never mentioned writers, partitions, or cold
  serve. Added the serverless-topologies and multi-tenant sections, fixed
  the quickstart (FTS works with zero downloads; semantic points at
  scripts/download-models.sh), documented unauthenticated /metrics.
- CHANGELOG's Unreleased section contradicted itself ('warm, not cold' in
  the same release that ships the cold path) and cited pre-v0.5.

Also: new docs/deployment.md (the three topologies + honest fleet gaps),
ARCHITECTURE.md purged of the removed backend-selector section and taught
the real module map + CSEG0003, scale-envelope updated for partitions +
cold reads, security contact unified (security@runcaptain.com — verify
the mailbox exists before release), SECURITY.md no longer claims runtime
model downloads, duplicate issue templates removed, broken doc link
dropped, mangled .env.example section headers fixed.

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
- Workspace version 0.3.0 -> 0.4.0 (internal compass-index-api pin in lockstep)
- CHANGELOG: stamp [Unreleased] as [0.4.0] - 2026-07-04
- Cargo.lock regenerated

Signed-off-by: Edgar Babajanyan <bedgar2005@gmail.com>
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.

1 participant