Commit Graph
100 Commits
Author SHA1 Message Date
Andrey VasnetsovandClaude Fable 5 5c37004f67 [UIO] Split async into extension traits, implement only where genuinely async (#10424)
* Split async IO into extension traits; only async-capable backends implement them

Move `read_bytes_async` / `open_async` off the universal `UniversalRead` /
`UniversalReadFs` traits into dedicated extension traits, `UniversalReadAsync`
and `UniversalReadFsAsync` (traits/async_io.rs). Only backends with a genuine
async story implement them — the blob family, the disk caches layered over it,
and a trivial ready-impl for mmap (tests and the mmap lookup path) — each in a
dedicated async_io.rs next to its sync impl.

`CachedFs` now requires its inner filesystem to be `UniversalReadFsAsync`; the
requirement reaches segment code through one supertrait bound on
`UniversalReadExt`. io_uring implements no async surface anymore: the
tokio_uring bridge thread, its tests, the musl-gated tokio-uring dependency,
and the `IoUringFile` read-only-segment wiring (`UniversalReadExt` impl and
the *RoIoUring condition-checker variants) are deleted — io_uring is not a
read-only-segment backend.

The payoff for live reload: `CachedFs::resolve_prefetched` awaits every parked
prefetch, and the edge refresh flow now runs preload -> resolve -> reload, so
the per-segment write locks never wait on IO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decouple UniversalReadExt from the async filesystem requirement

UniversalReadExt is condition-checker dispatch; it never consumed the async
surface itself. Drop its `Fs: UniversalReadFsAsync` supertrait bound and relax
CachedFs's struct-level bound back to `UniversalReadFs` — the async requirement
now lives on the one impl that consumes it, `CachedReadFs for CachedFs`
(schedule_open parks the inner filesystem's `open_async` futures).

The bound then surfaces only on the lifecycle/preload impl blocks that go
through CachedReadFs (segment open, live-preload/reload, config reload, edge
load/refresh); the search path carries no async bounds at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:45:57 +02:00
Andrey VasnetsovandClaude Fable 5 c6d8c8f347 Add tools/callgraph: interactive call-graph reports via rust-analyzer (#10422)
* Add tools/callgraph: interactive call-graph reports via rust-analyzer

Generates a self-contained HTML report for one function: pan/zoom graphviz
graph of callers and callees, per-node docs and source snippets, exact call
sites with context, GitHub/editor links.

- rust-analyzer call hierarchy over LSP gives resolved (not textual) edges;
  trait declarations and impls are bridged via goto-declaration /
  goto-implementation so dispatch through a trait doesn't dead-end the walk
- test code excluded by running rust-analyzer with cfg(test) disabled, plus
  path filters for tests/, benches/, examples/ targets
- no dependencies beyond rust-analyzer and graphviz on PATH

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg

* Add screenshot to tools/callgraph README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:45:56 +02:00
1dad4d9f9f Let multivector runs straddle chunk boundaries (#10283)
* Read vector runs that straddle a chunk boundary

Resolve a run into per-chunk parts instead of a single range, borrowing
when it lands in one chunk and copying when it spans two. The read
pipeline schedules one range per read, so a straddling run is read
outside it.

No writer produces such a run yet, so this changes nothing on its own.
It is what a reader needs before one does — including edge and
live-reload readers, which read files a different version wrote.

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

* Place multivector runs without regard to chunk boundaries

Writers appended a multivector's inner vectors at the end of the row
space unless the run would cross a chunk boundary, in which case they
skipped the chunk tail — the batch writers padding the skipped rows with
explicit zero rows. That made chunk geometry part of the interface every
multivector storage had to reuse.

Runs now go at the end unconditionally and the chunked storage splits
the write across chunks, as it already did for a batch of single
vectors.

What is left of the geometry is a size cap: a multivector may not exceed
one chunk. It is fill-independent, so it constrains nothing about
placement, and it is what the volatile storage needs anyway — that one
returns a plain slice and so cannot serve a straddling run.

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

* Split a run at chunk boundaries in one place

Reading, writing in place and appending each derived the split from
`remaining_chunk_capacity`, so every one of them had to know that a run
does not necessarily fit where it starts.

`split_run` hands out the parts instead: one per chunk the run covers,
each carrying where it goes and how much of the run it takes. Nothing
asks how much room is left any more, and `get_chunk_offset` goes with it.

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

* Keep straddling runs on the read pipeline

Reading a straddling run outside the pipeline blocked the scheduling
loop on one read, which costs a round trip on a backend that fetches
remotely and drops the batch back to sequential.

A run is now scheduled as one read per chunk it covers. Parts complete
in any order, so each run holds what has landed until the last part
does, then hands the callback the stitched vectors. Runs taking a single
read carry the caller's data in the tag and never touch that table.

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

* Stop capping a multivector at one chunk

The cap outlived its reason on disk, but the volatile storage still
needed it: its `get_many` handed out a slice of one chunk, so a run that
crossed a boundary had nowhere to come from. And since a volatile
storage is a target of the batched copy that builds a segment, dropping
the cap only on disk would have turned a rejected write into a failed
merge.

So the volatile storage splits and stitches too. Both are a few lines
each, and placing a run no longer skips a chunk tail, so `extend` is now
`insert_many` at the end of the storage.

Nothing user-facing moves: `MAX_MULTIVECTOR_FLATTENED_LEN` caps a
multivector at 1M elements, far inside a 32 MiB chunk, so the storages
only ever rejected what reached them unvalidated.

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

* Schedule a single-read run without the queue

The scheduling loop resolved every run into the queue and then took it
straight back out, so the overwhelmingly common run — one that fits a
chunk — paid a push and a pop for nothing. It now goes to the pipeline
directly, and the queue holds only what a straddling run leaves behind.

Worth ~10% on the multivector read benchmark, and it collapses the
"top up, then take" pair into one decision. Extracting that bookkeeping
into helpers instead was measured and is much worse: the mmap pipeline
alternates one schedule with one wait, so the loop body is a few dozen
nanoseconds, and a helper carrying the cold map and stitching paths is
too big for the compiler to inline back into it.

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

* Repoint the multivector WAL-replay test at a live rejection

The test upserted a multivector too large for a storage chunk, which no
longer fails: the storages stopped capping one at a chunk. Nothing else
covered a multivector operation that only the apply path rejects.

A raw blob that is not a whole number of quantized records still does,
so the test now uses that, alongside its dense and sparse siblings.

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

* Test reading multivectors with legacy chunk-tail padding

Locks the compatibility contract that pre-straddle files — runs that
skip a chunk's leftover slots — still reopen as single-chunk borrows.

* chore: retrigger CI after flaky test-consensus-compose

* Move ReadTag into for_each_vector, its only user

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AN4Hgbd65gDhesthJk5bUY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-09-03 12:45:53 +02:00
Andrey Vasnetsov 43da5dfe23 HNSW: stop exact-duplicate vectors from becoming a sink at level 0 (#10239) 2026-09-03 12:41:06 +02:00
Andrey VasnetsovandClaude Fable 5 a8ed37c907 Move avg_vector_for_recommendation into segment, re-export from edge (#10261)
The `average_vector` recommend strategy folds the examples into one query
vector. That fold lived in `collection::recommendations`, though it only
touches segment/sparse types (`VectorRef`, `VectorInternal`,
`SparseVector::combine_aggregate`, `TypedMultiDenseVector`); edge-based
consumers (the serverless search worker) had to re-implement it because the
`collection` crate is the whole node layer.

Move `avg_vector_for_recommendation` (+ its private `avg_vectors` /
`merge_positive_and_negative_avg`) next to `RecoQuery` in
`segment::vector_storage::query`, returning `OperationResult` (validation
errors map to `CollectionError::BadInput` as before), keep the two collection
call sites on it, and re-export it — plus `VectorRef`, needed to call it —
from edge. Tests move along, plus one for the fold itself.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:06 +02:00
Andrey VasnetsovandClaude Fable 5 c26139efdc Edge: seed appendable segment on load from existing segments' indexes (#10257)
`ensure_appendable_segment` used a shard-root `payload_index.json` that
nothing in edge ever wrote, so a shard loaded with only immutable
segments got a bare appendable segment and the appendable chain stayed
unindexed until a merge happened to include an indexed segment.

Build the segment directly and seed it with the union of
`get_indexed_fields()` over the loaded segments, the same reconciliation
the optimizer performs for its CoW segment. Drops the shard-root file
from edge.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:05 +02:00
Andrey VasnetsovandClaude Fable 5 3448d5b39d tests: restart reinit peer on the same port in readyz test (#10255)
`test_reinit_removed_peer_readyz_ignores_old_cluster` restarted the
reinitialized peer on a fresh port. A changed `--uri` makes the peer
announce its new address to every address-book entry, including the
injected old first peer, which re-adds it to the *old* cluster as a
learner and starts replicating its log to it. Normally the restarted
peer is a term ahead and ignores those messages, but when the reinit
run's hard state save did not finish before the kill, it restarted at
the old term, accepted the old leader, took its log (commit 13 > 12)
and failed the guard.

The scenario is a plain restart, so keep the URI; then nothing is
announced and only the `/readyz` membership filter is exercised.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:05 +02:00
generall e43e9a6fc9 bump syn 3.0.3 2026-09-03 12:41:04 +02:00
Andrey VasnetsovandClaude Opus 5 93f5ef2abb [UpdateOnly] Honor upsert update_mode in the batch writer (#10236)
* [UpdateOnly] Honor upsert update_mode in the batch writer

The writer rejected every `UpsertPointsConditional`. Accept the ones whose
condition is empty — `insert_only` and `update_only` — since existence is
the whole gate they need, and locating a batch's points already answers it.

The gate is evaluated per mutation at its position in the fold, so an
`insert_only` upsert sees a point an earlier operation of the same batch
created, matching a leader that resolves each operation only after the ones
before it were applied. A conditional upsert may therefore not discard the
mutations it follows.

Rejecting an upsert also means never reading the point it would have
overwritten: `needs_stored_point` asks whether the first mutation that
applies to an existing point discards it, so an `insert_only` batch pays
nothing for the ids that are already taken.

A conditional upsert carrying a real filter is still rejected — evaluating
one needs payload indexes the writer never fetches.

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

* [UpdateOnly] State contracts in the update-mode docstrings

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

* [UpdateOnly] Trim the update-mode diff

Drop `--update-mode` from edge-shard-update: the modes are covered by unit
and end-to-end tests, and the flag cost a wrapper enum, a conversion and a
parameter threaded through both run paths. The tool still reports rejected
points, which the exhaustive match requires.

Inline `always_applies` into its one caller, drop the two test-batch
wrappers over `conditional_batch`, drop the `update_only` fold test whose
truth table two other tests already assert, and shorten two over-long
comment blocks.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:41:02 +02:00
Andrey VasnetsovandClaude Fable 5 fe27f6a0a9 Add acosh expression to formula query (#10231)
Unary inverse hyperbolic cosine, parallel to sqrt/ln/exp/log10, in REST,
gRPC, and edge (FFI + Python) interfaces. Inputs below 1 produce the same
NonFiniteNumber error as an invalid sqrt or ln.

Closes #10186

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:01 +02:00
Andrey VasnetsovandClaude Opus 5 3b06a9606a gitignore: ignore lib/edge/publish/target (#10237)
`amalgamate.py` builds the generated crate in place, leaving a Cargo target
directory next to the sources. `/examples/target` was ignored but the
publish crate's own was not, so it showed up as ~32k untracked files that a
path-wide `git add` picks up.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:41:01 +02:00
Andrey VasnetsovandClaude Fable 5 c539340d2d [UpdateOnly] Writer over object storage (#10214)
* Drop the vestigial UniversalWrite bound from the update-only writer

Neither writer kind performs in-place writes: AppendableSegment is built on
UniversalAppend, and DeleteOnlySegment tombstones via whole-mask atomic_save
(UniversalWriteFileOps), which UniversalAppend's supertrait already carries.
The bound is a leftover from the DiskIdTracker-based iterations that mutated
the deleted mask in place.

With it gone, UpdateOnlyEdgeShard::apply_batch is instantiable with the
object-store-appendable CachedBlobFile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* edge-shard-update: --apply writes the batch, over object storage too

Open the object-storage backends through CachedBlobFs/CachedBlobFile
instead of the read-only DiskCacheFs handle, so the shard is appendable in
both modes, and add --apply: generate the same schema-derived batch and run
apply_batch instead of preview_batch. Dry run stays the default and the
generation is shared, so the preview cannot drift from what an apply would
do. AwsConfig::native_append is exposed as --native-append for
AiStor/RustFS-style endpoints; the Cached* types join io_bridge_object_store's
re-export of the io_bridge stack.

Applying to a leader-produced shard currently fails with a clean refusal —
its appendable segment's payload storage was created in mutable mode, which
the append-only writer rejects — the known segment-bootstrap gap, next in
line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* CachedBlobFile: latency tracing for append_bytes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UpdateOnlyEdgeShard: sequential batches through one writer

Writers open once at shard open, next to the lookup segments they resume
from. apply_batch hands the writer back on success, live-reloading the
lookup half of every segment the batch wrote to (new
LookupSegment::live_reload, mirroring the read-only segment's); on error
the writer is consumed, since its lookups may no longer describe the
durable state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* edge-shard-update: --interactive mode, sequential batches on one writer

After each applied batch, prompt on stdin for the next round's ids and
apply them through the writer apply_batch handed back — no shard
re-open — with op-num (and seed) incremented per round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* CachedBlobFile: create the missing object on an offset-0 rewrite append

The caller-side rewrite path (part-copy S3 stores below the direct-append
threshold) validated the offset against the mirror length, whose
initialization HEAD-requests the remote and surfaced NotFound for an
object that does not exist yet. Direct-append backends (GCS compose,
native append) already create the object on an offset-0 append; the
rewrite now reads a missing remote as length zero so its whole-object PUT
does the same, and a non-zero offset against a missing object reports an
offset conflict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UpdateBatchOutcome: per-point records of retired slots

Each applied point now carries a PointApplyRecord: what happened to it
(stored/deleted/skipped/missing) and which slots it vacated where —
tombstoned per segment, or superseded in place for the old write-target
copy of a stored point. Built in the same loop that decides
tombstone-vs-supersede, so the report cannot drift from the writes.

edge-shard-update logs one line per point after the applied summary,
telling a fresh insert from an overwrite and naming the segments the old
copies were deleted from.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:41:00 +02:00
Andrey VasnetsovandClaude Fable 5 bcb7a45cc7 Add CachedBlobFile: cached reads + write-through appends for object stores (#10206)
* Add CachedBlobFile: cached reads + write-through appends for object stores

Combine a DiskCache mirror (reads) with a BlobFile remote handle (appends)
into CachedBlobFile/CachedBlobFs, the appendable universal-IO citizen for
object stores. Appends perform the remote mutation inline and are durable
at Ok: a native write-offset append in AppendMode::Native (with a soft
limit on appends per object), or a whole-object rewrite in
AppendMode::Rewrite for stores without native append. After a successful
append the mirror length is advanced without extra IO; appended blocks
fault in from the remote on first read.

The multipart UploadPartCopy rewrite path (prefix >= 5 MiB) and the
rewrite-required error classification are left as todo!() pending the
AsyncRewrite backend capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Backend-advertised AppendMethod; reactive appended-block cap recovery

Replace CachedBlobFile's stored AppendMode with AsyncAppend::supported_append:
the backend advertises Native or PartialUpload, and append takes a matching
AppendRequest variant, rejecting the ones it does not support. The multipart
UploadPartCopy todo moves into the S3 backend's PartialUpload arm.

Drop the native_appends soft-limit counter: it is per-handle in-memory state
that resets on every restart, so it can never be the correctness mechanism
and persisting it would not make it authoritative either. The store is the
authority: hitting its appended-block cap now surfaces as the new
UniversalIoError::AppendRewriteRequired (S3 400 TooManyParts), and
CachedBlobFile recovers with a whole-object rewrite. Unrecognized errors
stay hard errors instead of silently triggering rewrites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Per-store append strategies; server-side rewrites for plain S3 and GCS

Replace the single AppendContext struct with an enum of strategy objects,
one per store capability, each owning its append logic:

- NativeAppend: the signed write-offset PutObject (S3 Express, MinIO
  AiStor; AwsConfig::native_append declares it for AiStor-like endpoints,
  s3_express implies it).
- PartCopyAppend: plain S3 — appends land as one atomic multipart rewrite
  whose prefix parts are server-side UploadPartCopy requests; nothing but
  the appended data crosses the network. object_store keeps such
  provider-specific calls out of its portable surface, so the requests are
  hand-signed like the native append.
- ComposeAppend: GCS — the appended data is uploaded as a temporary
  neighbor object and composed onto the destination server-side,
  conditional on the observed generation (a real compare-and-swap).

AppendMethod is replaced by AppendSupport, which tells the caller the only
thing it needs: when the store takes a direct append. Always (native, and
compose: no part minimums, no block cap), AboveThreshold (part-copy: the
copied prefix lands as non-last multipart parts, >= 5 MiB each), or Never.
CachedBlobFile drops its hardcoded MIN_COPY_PREFIX and rewrites locally
only below the backend-advertised threshold; AppendRequest::Rewrite now
means only "append and rebuild as a single blob" — the appended-block cap
recovery.

The append module is split one file per strategy, with a shared
SignedRequestContext transport and a test-only HTTP stub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DiskCache tracks the remote object's etag

Seeded from the new known_etag open extra (OpenExtra::with_known_etag),
refreshed from FileInfo on schedule_reopen, and settable directly for
callers that mutate the remote out of band.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove AppendRequest enum; appended-block cap recovery moves into the backend

AsyncAppend::append takes plain (path, offset, data). A native S3 store
that rejects an append with TooManyParts now falls back to the part-copy
rewrite inside the dispatcher, instead of surfacing AppendRewriteRequired
to CachedBlobFile for a second Rewrite request. The Rewrite variant was
handled identically to Append everywhere except that one native path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Escalate to download+rewrite when the store rejects a part-copy rewrite

The cap-recovery rewrite is chosen by the store's returned error, not a
client-side threshold: a part-copy attempt rejected with EntityTooSmall
(typed as UniversalIoError::AppendEntityTooSmall, parsed from the S3
error <Code>) falls back to downloading the sub-part-minimum prefix and
PUTting the whole object back, guarded by a prefix-length offset check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix S3 Express appends: zonal endpoint + s3express SigV4 service

Hand-issued appends targeted the standard endpoint and signed as "s3",
so every append to a directory bucket got 404 NoSuchBucket, masked as
AppendOffsetConflict by the 404 mapping. Derive the zonal
{bucket}.s3express-{az}.{region} base from the mandatory --{az}--x-s3
bucket suffix (mirroring object_store's private derivation), carry the
SigV4 service name in SignedRequestContext, and treat a 404 as a
conflict only for NoSuchKey or bodiless responses — NoSuchBucket stays
a loud error guarding the endpoint derivation. extract_xml_tag moves up
to the context module and now tolerates tag attributes and
pretty-printed bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Server-side etag precondition on appends; BlobFile loses UniversalAppend

AsyncAppend::append carries an expected_etag that S3 part-copy rewrites
attach as x-amz-copy-source-if-match (412 -> AppendEtagMismatch, a new
typed error) and download_rewrite checks against the GET's own etag;
native write-offset PUTs and GCS compose ignore it. BlobFile appends
only through the inherent etag-aware append_bytes now — CachedBlobFile
calls it directly with its DiskCache-tracked etag — and BlobFs's
mutating ops become inherent, delegated from CachedBlobFs, per the
standing TODOs. The append conformance battery runs over the
CachedBlobFs stack, via new direct constructors that share one backend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop unfulfilled too_many_arguments expectation

rewrite_parts has exactly seven parameters — at the clippy threshold,
not over it — so the lint never fires and the expect fails CI under
-D unfulfilled-lint-expectations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:39:04 +02:00
Andrey VasnetsovandClaude Fable 5 db2a5bbe5c Move delete-only tombstoning into per-format update-only id trackers (#10212)
* Move delete-only tombstoning into per-format update-only id trackers

DeleteOnlySegment::tombstone_points wrote the deleted mask file directly,
hard-coding that both immutable id-tracker formats store it the same way.
Give each immutable format (in-RAM and disk-resident) its own update-only
tracker that owns the decision of where its tombstones go, and dispatch
through DeleteOnlyIdTrackerEnum, which lives next to ReadOnlyIdTrackerEnum.
Both formats share one deleted-mask file today, so the trackers delegate to
a shared writer in deleted_storage.rs; a format that diverges later changes
only its own update_only module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Enforce empty-batch guard in the shared tombstone writer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:39:03 +02:00
Andrey VasnetsovandClaude Sonnet 5 548574d9c3 fix: route append-only point deletion through delete_point_internal (#10199)
Alternative to #10188. Instead of skipping check_consistency_and_repair's
storage cleanup entirely on append-only segments, and keeping a separate
delete_point_tombstone_only helper that callers must remember to pick, fold
the append-only decision into delete_point_internal itself:

- delete_point_internal now branches on is_append_only_delete() internally:
  tombstone-only (id tracker drop only) when true, full payload/field-index
  clear + id-tracker drop otherwise.
- delete_point_tombstone_only is removed; both call sites (ordinary
  delete_point, and check_consistency_and_repair's cleanup of dangling
  versions found by fix_id_tracker_inconsistencies) now just call
  delete_point_internal, so the repair path gets correct append-only
  behavior for free instead of needing its own explicit check.
- version_tracker.set_payload (payload-storage version, used for partial
  snapshots) moves inside delete_point_internal too, gated behind the same
  branch: it's only bumped when payload storage is actually touched. Took
  the opportunity to also thread it through an explicit op_num: Option<..>
  parameter, since check_consistency_and_repair's repair pass has no real
  op_num to associate the change with.

This keeps "how to delete a point" a property of segment state rather than
something every caller has to branch on externally, which is what let the
original bug slip through check_consistency_and_repair in the first place.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:39:01 +02:00
Andrey VasnetsovandClaude Fable 5 015ece2b11 [UpdateOnly] tombstone points in immutable segments via whole-mask rewrite (#10196)
* [UpdateOnly] tombstone points in immutable segments via whole-mask rewrite

DeleteOnlySegment::tombstone_points marks the retired slots in the
segment's deleted-points bitmask (id_tracker.deleted, shared by the
immutable and disk-resident tracker formats) and replaces the file
whole via atomic_save — the one mutation that works on backends
without random-offset writes. Both read-only trackers already
live-reload this file by opening a fresh handle and diffing, so the
rewrite needs no read-side changes.

The mutation cycle lives in StoredBitSlice::atomic_update: read the
stored bits (or start from a caller-provided seed), apply the update,
save atomically; a closure error writes nothing. The seed comes from
the read phase by analogy to AppendableIdTrackerState:
LookupSegment::writer_state now returns WriterIdTrackerState, whose
DeleteOnly variant carries the deleted mask when the tracker already
holds it in memory — always for the immutable tracker, only if
materialized for the disk-resident one, which deliberately avoids
loading the full deleted set.

Tombstoning needs no more of the backend than reads plus atomic_save,
so DeleteOnlySegment's bound drops to
UniversalRead<Fs: UniversalWriteFileOps>.

Unlike the writable trackers' drop(), the slot's version is not zeroed
(the versions file is in-place-mutated, which object stores cannot do):
deletion authority in these formats is the bit — every lookup filters
through it — and a stale version on a tombstoned slot is the same state
a crash between drop-bit and drop-version leaves, which
fix_inconsistencies already absorbs as storage cleanup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Close the temp-file handle in tests that atomically replace it

NamedTempFile holds the file open for its lifetime, and Windows refuses
the rename in atomic_save while any handle is open. into_temp_path()
closes the handle and keeps the deletion guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 12:39:00 +02:00
Andrey VasnetsovandClaude Sonnet 5 d2cdaf1cfb Add edge-tool: CLI for creating, seeding, optimizing, and uploading local edge collections (#10159)
* Add edge-tool: CLI for creating, seeding, optimizing, and uploading local edge collections

Mirrors the style of lib/edge/tools/shard_update and shard_query: `create` builds a
minimal EdgeShard on disk (dense/sparse vectors, quantization presets including
turbo4, payload indexes, target segment count), `upsert` seeds it with random points
matching its live schema, `optimize` runs the shard optimizers, and `upload` pushes
the resulting directory to S3/GCS. Useful for quickly spinning up test collections
without a running Qdrant server, then promoting them to object storage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* edge-tool: initialize feature flags, enable serverless_compatible, fix --sparse ambiguity

Initialize the global feature-flag OnceLock at startup (with serverless_compatible
set, cascading write_segment_manifest/append_only_mutations/compact_bitmask/
append_only_storages) so runs no longer spam "Feature flags not initialized!" and
collections are created in the serverless-compatible format.

Also splits --sparse into a plain boolean flag plus a repeatable --sparse-name:
clap's optional-value parsing for the old `--sparse [NAME]` form silently
swallowed a following positional PATH as the sparse vector's name whenever
--sparse was the last flag before it (e.g. `create --dense 1024 --sparse ./col`).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* edge-tool: fix --sparse=NAME to require_equals instead of a separate flag

The --sparse/--sparse-name split from the previous commit lost the ability to
name a sparse vector with --sparse itself. Restore a single --sparse[=NAME]
flag, but with require_equals(true): clap then only binds a value via
--sparse=NAME, never via a following bare token, so it stays safe next to the
trailing PATH positional in every position (bare --sparse, --sparse=NAME, or
multiple --sparse=NAME occurrences) without reintroducing the ambiguity that
made --sparse swallow PATH as its value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* edge-tool: remove --segments from create, it has no effect there

EdgeOptimizersConfig::default_segment_number only feeds MergeOptimizer as a
merge-down ceiling (reduce segment count when it exceeds the target); unlike
the main collection's LocalShard::build_local, EdgeShard::new never loops to
pre-create N appendable segments. A freshly created collection always starts
at exactly 1 segment, so passing --segments to `create` was silently a no-op.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* edge-tool: add --indexing-threshold-kb to create

Unlike --segments (removed previously), the indexing threshold is a parameter
IndexingOptimizer actually consults on every optimize() run: segments larger
than it get an HNSW index built. Verified end-to-end (create with a 1KB
threshold, upsert 2000 points, optimize) that it produces an hnsw-indexed
segment where it would otherwise stay plain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* edge-tool: add --clean to upload, wiping the destination prefix first

Lists every object under DESTINATION and deletes it via ObjectStore::delete_stream
before uploading, so re-uploading a collection recreated with a different shape
(different segment UUIDs) doesn't leave the old segment's files behind.

Verified against the local S3 proxy: uploaded one collection, then a second,
differently-shaped one to the same prefix without --clean (29 objects, stale
leftovers from the first); re-uploading the second with --clean correctly
dropped it back to exactly its own 19 files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:38:59 +02:00
Andrey VasnetsovandClaude Fable 5 15fe017cab [UpdateOnly] create Blobstore-backed storages append-only under a feature flag (#10154)
* [UpdateOnly] create Blobstore-backed storages append-only under a feature flag

A new `append_only_storages` feature flag, enabled by `serverless_compatible`,
switches every Blobstore creation site — the payload storage, the appendable
field indexes (numeric, map, geo, full-text) and the sparse vector storage —
to the append-only Logstore mode. One shared helper maps each site's Gridstore
layout to its Logstore counterpart, carrying the page size and compression
over; blocks and regions have no append-only equivalent. Only creation
consults the flag: an existing storage keeps its persisted mode, both modes
are always readable, so flipping the flag never strands data.

Two changes make the flag usable rather than booby-trapped:

`Logstore::delete_value` now succeeds trivially where nothing is stored, as
mutable mode does, and errors only for a stored value. The ordinary write
paths delete defensively — an index clears a slot before filling it, an empty
value is stored as a deletion — and only ever hit occupied slots when
something is genuinely mutated in place.

A segment derives `append_only_storages` from the persisted payload storage
mode when it opens — not from the flags, which may have changed since it was
created — and it forces append-only mutation semantics on itself: every
mutation clones to a fresh slot, and the same-operation slot-reuse shortcut
is disabled, since the second step of a multi-step write would rewrite a
payload row those storages cannot rewrite.

The end-to-end test runs as its own binary (feature flags are process-global)
with `serverless_compatible` on: the segment comes out holding Logstore
storages, and upserts, updates of existing points, multi-step same-operation
writes, deletes, an index build over existing points, a flush and a reload all
run against them.

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

* [UpdateOnly] assume the flag pairing instead of deriving it, trim the docs

Per review: `append_only_storages` without `append_only_mutations` is not a
state to defend against — `init_feature_flags` forces the pairing, and the
same-operation slot-reuse check reads the flag directly. That deletes the
segment-side derivation: the `append_only_storages` segment field, the
persisted-mode read at open, and the `is_append_only` accessor chain through
`Blobstore`, `PayloadStorageImpl` and `PayloadStorageEnum`.

Docstrings and comments trimmed to the guarantees; how the write paths use
them is their own business.

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

* [UpdateOnly] restructure the creation config around mode-neutral options

`CreateOptions` in the blobstore crate holds what a caller actually decides —
page size, block size, compression — and `into_config(append_only)` turns them
into the config of either mode, each taking the fields it can express. The
segment-side `storage_config` supplies only the mode, from the feature flag.

That removes the misnomer chain the previous cut left behind: nothing named
gridstore returns a config that might not be one, and no call site builds a
`GridstoreConfig` just to have its fields repacked.

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

* [UpdateOnly] keep Logstore strict; fix the callers that deleted nothing

Per review, `Logstore::delete_value` goes back to an unconditional error: a
delete reaching an append-only storage is a caller bug to fix, not a case to
absorb. The callers that issued vacuous deletes are fixed instead:

- The numeric and geo indexes only delete from the storage when their
  in-memory index actually held values at the slot — the two are written in
  lockstep, so an empty slot has nothing stored either. The map and text
  indexes already worked this way.
- The sparse storage skips the delete for keys at or past its end, where
  nothing was ever stored.

Each removed call was wasted work in mutable mode too. The e2e test now also
drives a numeric index and the sparse storage against append-only mode, and
asserts that deleting a stored sparse vector fails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [UpdateOnly] drop the same-op slot reuse; upserts write the whole point at once

The append-only path never needs a multi-step point write: the one real
multi-stepper was the shard's upsert — `upsert_point` followed by a payload
step under one operation number — and it now goes through
`upsert_moved_point`, which writes vectors and payload as one operation and
one slot. With that, the same-operation slot-reuse carve-out in
`handle_point_mutate` has nothing to carry: on an append-only segment every
mutating step clones to a fresh slot, unconditionally, and the
`append_only_storages` special case disappears with it. The version gate
skips only on strictly newer versions, so a caller that still multi-steps
stays correct — it pays a slot per step.

`PointToUpsert` now exposes the point's parts — raw vectors, decoded vectors,
payload — and both write paths are provided from them: `upsert_into` hands
the parts to `upsert_moved_point`, `write_moved` adapts them to the
copy-on-write move callback. The two hand-written `upsert_into` bodies and
the follow-up payload helper are gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Regenerate OpenAPI for the `append_only_storages` feature flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* one extra debug assertion

* fmt

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:38:59 +02:00
Andrey VasnetsovandClaude Sonnet 5 629c012077 [UpdateOnly] wire every component into the appendable segment (#10152)
* [UpdateOnly] wire every component into the appendable segment

`AppendableSegment::store_points` sheds its `todo!()`: the id tracker claims a
fresh slot per point, every component writes its data at those slots — each
named vector storage, the payload storage, the payload indexes — and only then
do the versions cover them, the step that makes the points visible to readers.
A crash anywhere in between leaves claimed, unpublished slots, which the next
writer to open the segment retires. Each vector comes from whichever half of
`FullyQualifiedPoint` holds it: the batch's decoded vectors win over the bytes
carried from the point's previous slot, and a name in neither still takes its
slot as a vector the point does not have.

The store components open lazily, on the first `store_points`. A batch that
only deletes writes nothing but the mappings log, so it never pays for those
opens — and it keeps working against segments whose payload storage was created
in mutable mode, which the append-only writers refuse and which is all any
leader builds today.

The writer now also remembers what it stored, so `tombstone_points` skips a
point this very batch wrote instead of retiring its fresh slot; the caller can
hand over every slot a stored point used to occupy without holding that rule.

`UpdateOnlySegmentEnum::open` takes the segment config, which is where the
writer learns which vector storages exist.

The end-to-end edge tests now run stores the whole way through: located and
resolved through the `LookupSegment`s, appended by the writer, and read back
through an ordinary follower — a new point with its payload, a rewrite winning
over the old copy, a replayed batch skipping on the published versions, and a
second writer resuming every component where the first ended. The leader still
writes its payload storage in mutable mode, so the tests recreate it empty in
append-only mode, standing in for segment creation wiring that does not exist
yet.

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

* [UpdateOnly] drop the stored-ids guard from `tombstone_points`

The caller already never asks to retire a point its batch stored — it has to
hold that rule regardless, since `preview` mirrors it to count outcomes — so
the writer-side set was redundant state, and it made `tombstone_points`
silently drop requests instead of honoring a stated contract. The contract is
now stated: only points the batch deletes go here, because a delete addresses
the external id and would take a stored point's fresh slot along with the
stale one.

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

* [UpdateOnly] gate the store tests off Windows

The leader's writable storage preallocates chunk files, and the append-only
writer cuts them back to end at the data — its append offset is a
compare-and-swap token, so a file longer than the data would make every append
conflict. That cut replaces the file, which Windows refuses while the writer's
own `LookupSegment`s hold it memory-mapped; on Linux the old inode simply lives
on under the mappings. Nothing to fix in the writer: Windows cannot shrink a
mapped file, and the production target is object storage, where neither
preallocation nor mmap exists.

The delete tests keep running everywhere; the store tests move into a
`#[cfg(not(windows))]` module together with the imports and helpers only they
use, so the Windows build carries no unused-import warnings. Cross-checked with
`--target x86_64-pc-windows-msvc`.

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

* [UpdateOnly] wire the quantized overlay into StoreComponents

Opens UpdateOnlyQuantizedVectors alongside each dense, non-multivector,
non-Turbo4-datatype vector's raw storage, when the segment's quantization
config supports incremental appends (Binary/Turbo). Multivector and Turbo4
combinations are out of scope (see UpdateOnlyQuantizedVectors' own doc
comment) — such a vector simply has no quantized overlay entry and stays
searchable exactly through its raw storage alone, same as before.

store_points keeps the overlay's row count in exact lockstep with the raw
storage: every point takes a row in both, in the same order, at the same
id (start_slot + offset) — a decoded vector encoded for real, a
Raw-bytes-carryover blob decoded back to f32 per its actual storage
datatype (mirroring QuantizedVectors::create_impl's use of
PrimitiveVectorElement::quantization_preprocess for the same purpose on
the non-update-only path), and a Missing vector as an all-zero placeholder.
Skipping a row for the latter two cases would silently misalign every
later quantized lookup — scoring one point's vector against another's
quantized copy — so this mirrors the raw storage's own "every point takes
its slot" rule exactly rather than only handling the common decoded case.

UpdateOnlyQuantizedVectors now retains its resolved QuantizedVectorsConfig
(exposed via quantization_config()/dim()) rather than discarding it after
opening storage, since a reopened overlay's persisted config is the source
of truth for how to decode carried-over bytes — not necessarily identical
to whatever live config the caller has to hand. Its now-unused flusher()
is dropped: like every other update-only storage in this stack, a write is
already durable when append_many/upsert_vector returns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:38:58 +02:00
5f699480e0 [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo) (#10161)
* [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo)

Appendable/plain segments can carry live quantized vectors today: PlainVectorIndex::
update_vector calls quantized_vectors.upsert_vector alongside the raw vector on every
insert (lib/segment/src/index/plain_vector_index/lifecycle.rs), auto-created for a
fresh segment when appendable_quantization is on and the method supports it
(QuantizationConfig::supports_appendable — Binary and Turbo only; Scalar/Product are
policy-gated off regardless of storage backend). The update-only vector-storage stack
(this PR's base) had no equivalent: UpdateOnlyVectorStorage::open never read
quantization_config, and nothing under vector_storage/*/update_only/ mentioned
quantization at all — a segment configured with quantization would silently lose it
end-to-end once written through this path.

This adds UpdateOnlyQuantizedVectors, mirroring QuantizedVectors' auto-create/reopen
behavior but scoped to dense (single-vector) Binary/Turbo — the two methods that
support incremental appends, matching current capability exactly (multivector support
is a follow-up: it needs its own append-only offsets storage, mirroring
MultivectorOffsetsStorageChunked the same way this mirrors QuantizedChunkedStorage).

The only new machinery is UpdateOnlyQuantizedChunkedStorage, an EncodedStorage backed
by UpdateOnlyChunkedVectors (append-only, S: UniversalAppend) instead of
ChunkedVectors' positional writes (S: UniversalWrite) — everything else reuses the
quantization crate's EncodedVectorsBin::encode/load and EncodedVectorsTQ::encode/load
completely unchanged, since both are already generic over the storage backend. It
writes files in the exact layout QuantizedChunkedStorage reads, so a promoted segment's
quantized data reads through the existing, unmodified reader with no new reading code.
UpdateOnlyChunkedVectors gains one addition: a `get` method to read back a single
vector, needed because EncodedVectors::load validates the storage's vector size by
reading vector 0 (skipped when the store is still empty).

Verified: the update-only writer's persisted bytes, read back through the standard
(non-update-only) QuantizedChunkedStorage + EncodedVectorsBin/TQ::load, match a
RAM-backed reference fed the same vectors one at a time through upsert_vector,
byte-for-byte, for both Binary and Turbo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] fix quantized reopen: resume writing shouldn't validate stored reads

The previous commit made reopening a non-empty quantized overlay panic
(EncodedVectorsBin/TQ::load validates a non-empty store by reading its
vector 0, which UpdateOnlyQuantizedChunkedStorage's write-only design
cannot serve) and worked around it with a redundant pre-check plus a
todo!(), narrowing the tests to single-session-only writes.

Both of those were the wrong fix. A writer resuming appends doesn't need
`load`'s read-and-validate — it only needs the fitted metadata (encoding,
stats) to keep encoding consistently, and that invariant already holds by
construction: every vector this writer ever encodes is sized from the same
`quantized_vector_size` `load` and the new path both read. Added
`EncodedVectorsBin`/`EncodedVectorsTQ::reopen_for_write` to the
quantization crate — identical to `load` minus the validating read — and
switched `open_existing` to it. `UpdateOnlyQuantizedChunkedStorage` stays
write-only as originally designed; no new read capability, no pre-check,
no todo.

Tests restored to the original two-writer split (write half, drop, reopen,
write the rest), now genuinely exercising resume-with-data instead of
avoiding it, and still passing byte-for-byte against the reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] split EncodedStorage into EncodedStorageWrite + EncodedStorage

A write-only storage (the update-only quantized overlay) had to fake a
full EncodedStorage impl with unreachable!() read stubs just to satisfy
EncodedVectorsBin/TQ's generic bound. Split the trait so a write-only
backend only needs to implement EncodedStorageWrite; EncodedStorage adds
the read methods on top. The overlay now implements EncodedStorageWrite
alone — no panicking stand-ins for methods that don't exist.

* [UpdateOnly] remove UpdateOnlyQuantizedVectors::create

Nothing in this stack builds the first appendable segment of a
collection yet (that's still a todo!() in edge/src/update_only), so
create() had no real caller and open() had to guess from file absence
whether to invoke it. open() now only reopens an overlay create()
already persisted; the bootstrap logic moved into tests.rs as a
private fixture helper, since tests still need it to build fixtures.

* [UpdateOnly] fix CI: codespell typo and lint dead-code on unwired write path

codespell flagged "implementors" (wants "implementers") in two doc
comments. Separately, CI's lint job runs clippy without --all-targets,
so the update-only quantized write path — genuinely unreachable from
any non-test code until #10152 wires it into a segment — trips
-D warnings dead-code. Scope #![allow(dead_code)] to the two files
that are only exercised by their own tests today, and allow the now
test-only UpdateOnlyQuantizedChunkedStorageBuilder re-export.

* [UpdateOnly] fix ast-grep: use expect(dead_code) instead of allow

* fix CI: remove unused EncodedStorageWrite import in gpu vector storage

Left over from splitting EncodedStorage into EncodedStorageWrite +
EncodedStorage; only caught under --all-features since gpu is gated
behind a feature flag.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-09-03 12:38:58 +02:00
Andrey VasnetsovandClaude Opus 5 ff9c7e3078 [UpdateOnly] implement the appendable vector storages (#10151)
* [UpdateOnly] drop the `UniversalWrite` bound from `UpdateOnlyChunkedVectors`

Nothing in it needs random-offset writes: the config, the chunk listing and the
status file all go through `UniversalReadFs` / `UniversalWriteFileOps`, which
`UniversalAppend` already provides. The bound excluded the object-store backend
this writer exists for.

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

* [UpdateOnly] implement `UpdateOnlyDenseVectorStorage`

The vectors go into `UpdateOnlyChunkedVectors` and the deleted flags into
`UpdateOnlyStoredFlags`, both of which already append; what this adds is the
directory layout and the rule for a point with no vector under this name.

Such a point still takes its slot, holding a placeholder, and is flagged
deleted — slots are shared across every named storage of the segment, so
skipping one would shift every later vector of this storage against the id
tracker. Only the missing ones are flagged: an unflagged slot reads as present,
and the mask is explicitly allowed to be shorter than the vector count, so a
batch where every point has a vector rewrites no mask at all.

`VectorToStore` is the input, mirroring the two halves of `FullyQualifiedPoint`:
vectors the batch decoded, and storage-native bytes carried over from a point's
previous slot which are appended without a decode round-trip.

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

* [UpdateOnly] implement `UpdateOnlyMultiDenseVectorStorage`

Rows here are not indexed by point slot — a point owns a run of them — so this
writer tracks where the row space ends and places each run itself, reading the
end from the chunked storage on open. A run that would straddle a chunk skips to
the next one, as the writable side does, since a read of a multi-vector assumes
its rows are contiguous within a chunk. The rows of a batch are therefore not
always one span, and each span is appended on its own; the gap a skip leaves is
zero-filled by the append that follows it.

A point with no multi-vector here owns no rows at all: its offset entry says so.
Unlike the single-vector storages there is no row to keep aligned, because the
offsets are what map slot to rows.

Adds `stored_len` and `remaining_chunk_keys` to `UpdateOnlyChunkedVectors` —
the vector count read that #10114 dropped as unused now has a user.

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

* [UpdateOnly] implement `UpdateOnlySparseVectorStorage`

The vectors go into `UpdateOnlyBlobstore` — the sparse storage is the one that
was already blob-backed — and the flags into `UpdateOnlyStoredFlags`. A point
with no sparse vector stores nothing at all, since the storage is keyed by slot
and an unwritten slot is already "no vector"; it is flagged instead.

`UpdateOnlyStoredFlags::open` now materializes its directory rather than waiting
for the first flag. Storages use that directory as the marker that they exist:
`MmapSparseVectorStorage::open_or_create` takes its absence for "not created
yet" and starts a fresh storage over the top of the old one. A batch that flags
nothing must still leave it behind. Caught by the resume test.

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

* [UpdateOnly] implement the TurboQuant vector storages

`UpdateOnlyTurboVectorStorage` and its multivector counterpart. The quantizer is
rebuilt from the dimension and distance rather than read back — it carries no
learned state, so the two sides encode identically, which the test asserts by
comparing the encoded bytes against what the writable storage produces for the
same vector.

The multivector one places runs of rows exactly as the plain multivector storage
does, skipping to the next chunk rather than straddling one.

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

* [UpdateOnly] add `UpdateOnlyVectorStorage`, the dispatch over the five families

Selects the writer from the vector config the way the writable side selects the
storage, and refuses a storage type an update-only segment cannot have: the mmap
ones are built whole rather than appended to, and the empty placeholder has no
files.

Sparse gets its own opener, since sparse vectors are configured separately from
dense ones rather than through `VectorDataConfig`.

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

* Fix clippy under `-D warnings`

- `is_multiple_of` in place of the manual remainder checks in the two
  multivector writers.
- Drop the `dead_code` expectations on `UpdateOnlyChunkedVectors`: the vector
  storages use it now, so the expectation no longer holds.
- Drop a `TypedMultiDenseVectorRef::from` that converts to its own type.

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

* [UpdateOnly] fix a stale doc and an inconsistent guard

The doc on `UpdateOnlyStoredFlags::open` still said nothing is created until
the first flush, from before open started materializing the directory eagerly.
And the span-merge guard in the multivector writer hedged with `dim.max(1)`
while the same function divides by bare `dim` three lines up.

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

* [UpdateOnly] append a multivector batch's rows once, with the gaps as zero rows

The chunk layer packs rows consecutively while runs must not straddle a chunk,
so a batch's rows are not gapless. The old bridge grouped them into contiguous
spans and appended each on its own, leaning on `ensure_chunk_lengths` — the
repair path — to zero-fill the gap before every span, and saving the status
once per span. Making the gaps explicit zero rows removes all of that: one
append per batch, through the normal write path, one status save.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:36 +02:00
Andrey VasnetsovandClaude Opus 5 fae2909daf [UpdateOnly] implement UpdateOnlyStructPayloadIndex, the per-segment fan-out (#10150)
* [UpdateOnly] implement `UpdateOnlyStructPayloadIndex`, the per-segment fan-out

Every field index of one segment, opened for a batch and dropped with it — the
update-only counterpart of `ReadOnlyStructPayloadIndex`, and the level
`AppendableSegment` needs: it takes the points a batch stores and leaves every
index of every indexed field current.

It reads which indexes a field has from the payload config, exactly as the
read-only side does, and holds nothing else. No payload storage, no id tracker,
no vector storages: those are there to answer queries and to work out what an
update means, and by the time a batch reaches here that is settled — each point
arrives with the payload it will be stored with.

Every field is offered every point, including points whose payload holds nothing
under it. An index that stores values per point stores none for those; the null
index records that the point has no value there, which is the whole reason it
exists. That is simpler than the writable path's add-or-remove split, which is
only needed because a slot there may already hold something.

A field whose index types the config does not spell out is refused. That config
predates those types being recorded, and the writable index repairs it by
deriving them from the schema on its next open; this writer builds no indexes,
so it cannot, and going on would leave whatever is on disk to rot.

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

* [UpdateOnly] trim the doc comments on the payload index fan-out

Keep the guarantees and the non-obvious rationale, drop the restatements.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:36 +02:00
Andrey VasnetsovandClaude Opus 5 c186f9d2e8 [UpdateOnly] implement UpdateOnlyFieldIndex for the appendable payload indexes (#10147)
* [UpdateOnly] implement `UpdateOnlyFieldIndex` for the appendable payload indexes

The payload index write half for the update-only segment writer, over a backend
that only appends.

An appendable field index keeps two things: the values it persists per point,
and the in-memory structure it answers queries from. Only the first is state —
the second is rebuilt from it on every open, by the mutable index and by its
read-only counterpart alike. A writer that never answers a query therefore holds
nothing: it turns a point's payload into the values its index would persist,
appends them at the point's slot, and is done.

What differs between index types is only that translation, so that is all
`UpdateOnlyIndexKind` captures; `UpdateOnlyValueIndex` is the storage around it,
the same for all of them, and each kind lives next to the index it writes for as
the read-only counterparts do. The extraction itself is taken from the index
types' own `ValueIndexer` and `NumericIndexIntoInnerValue` impls rather than
restated, so the two sides cannot drift apart. `UpdateOnlyFieldIndex` dispatches
over the nine covered index types, mirroring `ReadOnlyFieldIndex`.

What the writer emits is the append-only mode of the very same storage the
mutable index writes, and `Blobstore` selects the mode from the persisted
config, so the read side needs no change: every test here writes through the
update-only writer and reads back through the ordinary appendable index, opened
on the directory the writer produced.

The boolean and null indexes are not covered and are refused loudly rather than
skipped. They keep a bitmask over all points instead of values per point, and
persist it through random-offset writes, which an append-only backend does not
offer. A skipped index goes stale and then answers queries wrongly, and the null
index complements every other index of every indexed field — so a caller that
took a silent skip for "nothing to do" would leave every field it touched wrong.
Covering them needs an append-only bitmask representation first.

That is also why this stops short of the struct-payload-index fan-out: until
bool and null can be written, a component that claims to keep a field's indexes
current could not.

`UpdateOnlyPayloadStorage` moves onto the shared `UpdateOnlyBlobstore` extracted
here, which is what it already was.

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

* [UpdateOnly] share the flattening, tokenization and flush guard

Cleanup pass over the field-index writers, from a reuse/simplification review.

Share what was restated:

- `ValueIndexer::flatten_values` is now a provided method holding the loop that
  `add_point` had inlined. The update-only kinds call it instead of the free
  `extracted_values`, which built one throwaway `Vec` per input value on top.
- `FullTextIndex::tokenize_document` and `serialize_stored_document` hold the
  sentinel placement and the phrase-matching order-vs-sort decision that the
  update-only kind had copied out of `MutableFullTextIndex::add_many`. Both
  sides call them, so a document written by one always matches the phrases the
  other would.

Simplify:

- `UpdateOnlyFieldIndex::open` matches on the index type alone and takes the
  text params via `TextIndexParams::try_from`, as `ReadOnlyFieldIndex::open`
  does. That drops the schema tuple, the nine-arm mismatch block and the
  `Option` return.
- The `UuidIndex` variant is gone: that discriminant is historically map-backed,
  and both the writable selector and the read-only mirror already collapse it
  into `UuidMapIndex` — its `storage_dir` is `map_dir`, so a numeric-kind writer
  was writing into a directory everyone else opens as a map index.
- Why bool and null cannot be written append-only now lives in
  `PayloadIndexType::is_append_only_writable`, next to `storage_dir`, so that
  whoever decides a field is update-only-serviceable can ask rather than
  rediscover it; `open` consults it as a backstop.
- Dead `new()` constructors on the two zero-sized kinds.

Skip the flush when nothing was buffered, in `UpdateOnlyBlobstore` rather than
in one caller: a flush with nothing to write still syncs every page file of the
storage, and for a field index an empty batch is the common case — every point
that lacks the field, or holds a value the index rejects, stores nothing.

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

* [UpdateOnly] move each index kind under the appendable index it writes for

`numeric_index/update_only.rs` and its three siblings sat at the index-type
level, next to the enum over all three storage variants, although each writes
for the appendable variant alone. They now live at
`<index>/mutable_<index>/update_only/`, beside that variant's `read_only/`
counterpart, which is the same split for the same reason.

`mutable_text_index` is private, so the text kind is re-exported from
`full_text_index` for the dispatch enum to name.

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

* [UpdateOnly] cover the bool and null indexes by rewriting their masks whole

These two keep a bitmask over all points rather than values per point, so
keeping one current means changing bytes in the middle of it — which an
append-only backend cannot do. It can replace a file outright, and that is
enough: `UpdateOnlyStoredFlags` reads the mask into memory on open, sets the
batch's bits, and writes both files back whole, in the same format
`DynamicStoredFlags` uses. A reader cannot tell which side produced them.

The mask goes out before the length that publishes it, so a torn batch falls
back to the shorter mask rather than to flags that were never written, and the
whole-file write is charged to the hardware counter at flush — it is the write
that actually happened, not the handful of bits the batch touched.

`UpdateOnlyBoolIndex` and `UpdateOnlyNullIndex` sit on that, next to their
mutable index like the other kinds. The null classification (which values count
as present, which as null) moves into `classify_payload`, shared with
`MutableNullIndex::add_point`, and the boolean one reuses that index's own
`ValueIndexer`. Both are recorded for every point of a batch, including those
whose field holds nothing: "this point has no value here" is precisely what
these indexes are asked.

With that, `UpdateOnlyFieldIndex` covers every index type `ReadOnlyFieldIndex`
does, so the refusal and `PayloadIndexType::is_append_only_writable` are gone.

The cost is that a batch rewrites the entire mask however few bits it touched —
about 1.2 MiB per flag set for a segment of ten million points. Documented on
the writer.

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

* [UpdateOnly] trim the doc comments on the field index writers

Keep the guarantees and the non-obvious rationale, drop the restatements and
the comments that narrate the next line. One code change: `values.contains(&true)`
in place of `values.iter().any(|value| *value)` on the boolean index.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:36 +02:00
Andrey VasnetsovandClaude Opus 5 28e26b8888 [UpdateOnly] implement UpdateOnlyPayloadStorage (#10146)
* [UpdateOnly] implement `UpdateOnlyPayloadStorage`

The payload write half for the update-only segment writer: a short-lived
storage opened for one batch and dropped with it, over a backend that only
appends.

Backed by a `Logstore` — the append-only mode of the same storage the writable
`PayloadStorageImpl` uses — so a slot's payload is written once and never
rewritten. `append_many` takes one payload per point at the slot the ID tracker
claimed for it and flushes, so a batch is durable when the call returns and
nothing is buffered across calls. Puts only buffer, so the flush is what
touches the files: one append per touched page file plus one to the tracker,
regardless of how many points the batch holds. A point with an empty payload is
skipped, since an unwritten slot already reads back as an empty payload, and so
is any gap between slots, which the tracker materializes as unmapped entries.

`Logstore` had to leave the `Blobstore` facade for this: `Blobstore`'s type is
bound at `UniversalWrite + UniversalAppend` for the sake of its `Gridstore`
variant, so it cannot be named on a backend that only appends. Its cross-crate
surface is `open_or_create`, `put_value` and `flusher`, nothing more; the new
`open_or_create` mirrors `Blobstore`'s and rejects a storage created in mutable
mode rather than opening it.

Not wired into `AppendableSegment` yet — `store_points` stays `todo!()` until
the vector storages and field indexes exist, as with `UpdateOnlyChunkedVectors`.

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

* [UpdateOnly] trim the doc comments on the payload storage writer

Keep the guarantees and the non-obvious rationale, drop the restatements — the
merged-baseline style of `UpdateOnlyChunkedVectors` and `AppendableSegment`.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:36 +02:00
Andrey VasnetsovandClaude Opus 5 671a19f30c [UpdateOnly] split UpdateOnlySegment into its lookup and writer phases (#10142)
* [UpdateOnly] split UpdateOnlySegment into its lookup and writer phases

Applying a batch runs in two phases that agree on almost nothing, and
`UpdateOnlySegment` was both: `resolve.rs` used every field, `append.rs`
used none of them and could not — a `ReadOnlyPayloadStorage` has no append
path. The `fs` field existed only for the writes that were never wired up.

Split along that line:

* `LookupSegment` (was `UpdateOnlySegment`) is the read phase. Every segment
  of a shard is opened as one, on read-only bounds, and the phase above them
  aggregates. Loses the dead `fs` field.
* `DeleteOnlySegment` and `AppendableSegment` are the write phase, one
  segment each, `UpdateOnlySegmentEnum` over the two. Opened for one batch
  and dropped with it, matching the append-only components, which buffer
  nothing across calls.

The phases meet at `SegmentWriterState`, produced by
`LookupSegment::writer_state` and consumed by `UpdateOnlySegmentEnum::open`.
It carries the mappings-log tail an appendable writer resumes from, which
`UpdateOnlyAppendableIdTracker::new` requires to come from one and the same
read of that log. The writer kind follows the id-tracker format that was
loaded, not the segment config: the format decides how a point is retired.

That difference makes `tombstone_points` take both ids, `(external, slot)`;
an immutable segment marks the slot in its deleted-points bitmask, an
appendable one records a retirement for the id in its mappings log. The
appendable half is implemented — deletes now run end-to-end. `store_points`
and the immutable bitmask remain `todo!()`, still waiting on the append-only
storages and field indexes.

Two bugs surfaced while wiring it up:

* A point stored into the write target must not have its old slot retired
  there: appending records a mapping that supersedes it, and retiring the id
  on top would take the new slot with it.
* A second `apply_batch` through one writer resurrected deleted points. It
  resumed the log from the `mappings_end` its own first batch had moved past,
  and appending there cut that batch's entries off. Refused now; lifting it
  means reloading the segments after a batch.

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

* [UpdateOnly] one batch per writer, enforced by the type system

Cleanup pass over the phase split.

`apply_batch` now takes `self`. It could only ever serve one batch — the
segments are read when the writer opens, and that read is both what a batch
resolves against and what its writers resume from — and the runtime guard
enforcing that cost a flag, its doc, two imports, a hand-maintained
`writes_anything` condition, an error branch and a test. Consuming the writer
makes the second call a compile error instead.

Also:

* drop `LookupSegment::uuid`, which nothing ever read, along with the two
  parameters and the argument that fed it;
* `AppendableSegment::tombstone_points` was a copy of the tracker's own
  `retire_pending_inserts`; both now go through `delete_points`;
* fold the duplicated "segment disappeared mid-batch" error into
  `LookupSegmentHolder::get`, and restore `write_target_uuid` as an `Option`,
  which is what two of its three callers wanted;
* one fixture helper for the writer tests instead of three copies;
* state the mappings-log co-read invariant once, with pointers, instead of
  three times.

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

* [UpdateOnly] cut the writer surface down to what it does

* `flush()` is gone from both writers and the enum. Both bodies were `Ok(())`
  and would stay that way: the id tracker persists what it writes before
  returning, and the deleted-points bitmask writer does not exist yet. The
  ordering it looked like it enforced — new slots durable before the tombstones
  retiring the old ones — falls out of call order, since every write is durable
  when it returns. Bring it back with the first storage that buffers.
* `SegmentWriterState` was an enum of one unit variant and one payload, which
  is `Option`. `writer_state()` returns `Option<AppendableIdTrackerState>`, and
  `None` reads as what it means: no mappings log to resume, so a delete-only
  writer.
* `LookupVectorData` wrapped a single `Arc<AtomicRefCell<_>>`; the map holds it
  directly now.
* `appendable` joins the five `pub` fields around it, and `is_appendable()`
  goes.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:33 +02:00
fe5a063dc2 Add UpdateOnlyAppendableIdTracker, the append-only ID tracker writer (#10093)
* Add UpdateOnlyAppendableIdTracker, the append-only writer

The write counterpart of `ReadOnlyAppendableIdTracker`, producing the two
files that tracker already consumes — `mutable_id_tracker.mappings`, an
append-only log of mapping changes, and `mutable_id_tracker.versions`, a
dense array of one version per slot — through `UniversalAppend`, so the
same code drives a local file and an object store.

`insert_operations` records a batch of `MappingOperation`s in order: an
insert claims the next slot above the highest one in use and reports it,
a delete retires an external id and claims nothing. Nothing is rewritten
in place, so re-inserting a live id moves it to a fresh slot and
supersedes the old one — the update-only shape of an update.

`set_internal_versions` extends the versions array. Ids may come in any
order but must be exactly the slots the array does not cover yet: a slot
below the end would need an in-place overwrite, and a hole would have to
be zero-filled — and since "covered by the versions file" *is* the commit
signal for readers, that would publish a slot as a live point of version
0 before its data exists. Both are rejected rather than written.

Both methods append at an offset they probed for, never at an implicit
end: the offset is a compare-and-swap token, so a file that has moved on
since the probe is rejected instead of being written twice or in the
wrong place.

Both have persisted what they wrote when they return `Ok` — append, then
run the handle's flusher — and nothing is buffered across calls. The
order of the two calls, claim the slot then commit the version, is what
makes a crash in between safe: readers ignore slots the versions array
does not cover. Cleaning up the slots such a crash abandons is left to
the opener, along with repairing a torn tail; the writer fails loudly
rather than guessing.

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

* Share the versions file format between both write paths

`set_internal_versions` was reimplementing what `versions_storage`
already knew: entries are `SeqNumberType`-sized, slot `n` lives at
`n * VERSION_ELEMENT_SIZE`, a file length that is not a whole number of
entries has a torn tail. Every one of those facts existed in two to four
places, spelled as inline `/`, `%` and `write_u64::<FileEndianess>`, so
the append-only writer could drift away from the in-place one silently.

Move them into `versions_storage`, which now owns the format for both
writers and both readers:

- `write_version` / `read_version`, the entry codec, with a static
  assertion tying its `u64` to `VERSION_ELEMENT_SIZE` so a change to
  `SeqNumberType` cannot silently shrink every offset;
- `version_offset` and `versions_byte_len`, the slot arithmetic;
- `VersionsLayout`, which splits a file length into committed entries
  and a partial tail. The two writers still react differently — the
  in-place one truncates the tail, the append-only one refuses it,
  because an append cannot — but they no longer each work out what the
  tail is.

`store_version_changes`, `load_versions`, `set_internal_versions` and
the read-only tracker's live reload all go through it. The write loops
themselves stay separate: one seeks to sparse offsets, the other emits a
validated consecutive run, and merging them would obscure both. What
they share is where the bytes go, which is the part that must not
diverge — and a new test pins it down by writing the same versions
through both writers and comparing the files byte for byte.

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

* Append mapping and version entries as batches

Both writers built one concatenated buffer and handed it to `append`.
`append_batch` takes the entries as separate buffers and places them in a
single operation — a vectored write locally, one request on an object
store — so the entry boundaries reach the backend instead of being
flattened away first.

Versions are fixed-size, so the entries are the payload's
`chunks_exact`. Mapping changes are variable-length, so their bounds are
recorded as they are serialized.

Both keep the compare-and-swap offset, which `append_batch` validates the
same way `append` does.

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

* Test the writer against MutableIdTracker end to end

The suite leaned on round-trips through the storage loaders, which only
restated what the writer had just written. Four such tests are replaced
by one that checks the property actually worth having: drive the
append-only writer and `MutableIdTracker` with the same points, versions
and deletes, open each segment through `ReadOnlyAppendableIdTracker`, and
require the two views to be indistinguishable — counts, deleted state,
external ids, live points' versions, and id resolution.

Versions are compared for live points only. `MutableIdTracker::drop`
overwrites the slot with `DELETED_POINT_VERSION`, which an append cannot
do, so the append-only writer leaves the point's original version there;
neither is observable for a point that is gone.

The remaining tests keep what a round-trip cannot show: slot allocation
across calls, instances and deletes (three tests folded into one), the
rejection of holes and rewrites, and the byte-for-byte agreement of the
two writers on the versions file.

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

* better buffer names

* fmt

* Heal a torn versions tail instead of refusing to write

A writer that dies mid-entry leaves the versions file ending inside a
slot. The in-place writer already truncated that tail before writing;
the append-only writer refused, which left the file unwritable forever
since an append cannot truncate.

Share the decision — what counts as torn, the healthy length, the
warning — in `heal_versions_tail`, and let each writer supply the shrink
its backend can do: `set_len` in place, or reading the committed prefix
back and putting it in place as a whole file where there is no truncate.

Dropping the tail loses nothing: the array covers a slot only once its
whole entry is there, so a partial entry belongs to a slot no reader ever
saw and no writer counted as committed.

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

* Append mappings at the end of the log, not the end of the file

Mapping entries vary in length, so no length tells you whether the log
ends on an entry boundary. Appending at the file's end therefore could
not fail: a torn entry was silently appended after, and every entry from
there on was framed off the stray bytes.

Carry the boundary instead. `new` takes the offset just past the last
complete entry — `ReadOnlyAppendableIdTracker::mappings_read_to`, from
the same view that supplies `max_internal_id` — and appends there, which
turns a file ending elsewhere into an append offset conflict. On that
conflict `heal_mappings` cuts the file back to the log's end, the same
read-prefix-and-rewrite the versions file heals with, and writes the
batch again.

A torn entry and a batch that landed unacknowledged are indistinguishable
without parsing, and need not be told apart: neither `max_internal_id`
nor `mappings_end` moves before an append is durable, so the retry writes
the same bytes at the same offset.

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

* Move the healing functions into their own file

`update_only/mod.rs` had grown to hold the writer, its two public write
paths and the two repair routines they fall back on. Split the latter
out: `heal_versions` and `heal_mappings` move verbatim into
`update_only/heal.rs`, as a second impl block, following the layout the
read-only half already uses (`lifecycle.rs`, `live_reload.rs`).

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

* Retire inherited pending inserts when opening the writer

A slot is spoken for from the moment the mappings log claims it —
components may already have written data at it — so the writer must
resume above every slot the log ever handed out. Deriving that bound
from a reader's point set undercounts it twice over: a claimed slot
whose version was never committed is not in the mapping, and one whose
external id was deleted afterwards is not among the pending inserts
either. Track it in the log instead, as
`ReadOnlyAppendableIdTracker::max_claimed_internal_id`, bumped on every
insert entry regardless of what becomes of the point, and take it as
`UpdateOnlyAppendableIdTracker::new`'s bound.

The points on those claimed-but-unversioned slots are the other half.
They cannot be adopted: a writer stopped partway through them, so some
components hold their data and others do not, and which is unknowable
here. They cannot be left alone either, the versions array being dense —
covering any slot above one of them publishes it, half-written. So `new`
now takes the pending inserts explicitly and retires them, recording a
`Delete` per id before the writer can be used at all, which is what
makes it fallible. Doing it at construction rather than lazily on the
first write means no write path can be added later that forgets to.

`set_internal_versions` accordingly stops rejecting holes: it writes the
whole run from the end of the array through the highest id given,
covering skipped slots with `DELETED_POINT_VERSION` as the in-place
writer's seek already does. It gains an upper bound in exchange —
publishing a slot means covering every slot below it, so an id the log
never claimed is refused.

Live-reload semantics are unchanged.

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

* Skip UpdateOnlyAppendableIdTracker heal tests on Windows

Healing replaces the file via atomic_save while an mmap handle is still open, which Windows denies with os error 5.

* Drop mmap handles before healing ID tracker files

atomic_save cannot replace a path while an mmap is still open on Windows.
Copy the committed prefix, drop the handle, then rewrite and reopen.

* Trim ID tracker docs and drop redundant helpers

Condense the doc comments on the update-only tracker to the style of the
sibling modules, merge a duplicate impl block, and remove `read_version`
and a debug assert that restates its own operands.

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

* Drop the versions layout helpers and inline the arithmetic

VersionsLayout, versions_byte_len and version_offset wrapped one divmod
and one multiplication between them, and adopting the struct made the
live-reload hunk longer than the line it replaced. Compute the committed
length where it is needed instead, and let heal_versions_tail and
heal_versions return unit, since no caller used the layout they handed
back.

Also drop the writer's unused max_claimed_internal_id accessor, and fold
retires_inherited_pending_inserts_at_construction into
retires_inherited_pending_inserts: the merged test asserts the
retirement happened before the writer did anything, and commits a real
version to the retired slot rather than letting it take the filler, so
it still shows the Delete is what hides the point.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-09-03 12:36:32 +02:00
Andrey VasnetsovandClaude Opus 5 e01d91aee5 test(resharding): crash a follower, not the leader, in the scale-down revert test (#10104)
The crashing peer was picked positionally, so it could be the raft leader.
The staging crash exits inside the apply of the `Dead` entry while raft
messages leave through an async send queue, so a crashing leader takes the
append carrying the new commit index down with it. The other live peer is
then left holding that entry appended but uncommitted and, as one voter out
of three, can never commit it: it never aborts resharding, and the test
times out waiting for its resharding state to clear — the restart that
would restore quorum only comes after that wait.

Pick the victim after the receiver is killed instead: wait for the two live
peers to agree on a live leader, keep that leader as the survivor and crash
the follower. The survivor then commits and applies the abort locally, with
no commit index left to escape a dying process.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:31 +02:00
278140c482 Add /profiler/consensus_lag to measure apply lag between peers (#10090)
* Add /profiler/consensus_lag to measure apply lag between peers

Raft commit index advances on a peer whose apply loop is stalled, so the
existing signals - `raft_info.commit` and the `all_nodes_have_same_commit`
test helper - report a stuck peer as healthy. Nothing exposes how long a
peer has been behind at *applying* entries, which is what shard transfer's
`await_consensus_sync` barrier actually waits on.

Each peer now keeps a ring of the last 32 entries it applied, stamped with
its own wall clock and the time that entry took to apply. The ring is in
memory on ConsensusManager, not in Persistent, so the on-disk format is
untouched.

`/profiler/consensus_lag` collects those rings from every peer over a new
internal RPC and lines them up on the entry indices they share. Each entry
is measured from whichever peer applied it first, so a lag is never
negative; the peer that is first can differ per entry, so the baseline is
per entry rather than a single chosen peer. Entries only one peer still
remembers are excluded, otherwise a peer would be measured against itself.

A peer stalled part-way through an entry keeps healthy lag statistics -
everything it did apply, it applied on time - so the report carries
`behind_entries` and `newest_applied_age_ms` alongside, which is what
actually exposes the stall.

Peers that fail or time out are listed rather than failing the request: a
partial answer is more useful than none when the point is to find a peer
that stopped answering.

The endpoint follows `/profiler/slow_requests`: manage access, and outside
OpenAPI, so no endpoint-count or ACTION_ACCESS guard applies.

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

* Move applied-entry log into its own module

Keeps the new code out of files that are already large. The ring, its entry
type and the snapshot served over RPC move to
`content_manager/consensus/applied_log.rs`, alongside the other consensus
internals; `ConsensusManager` is left with a field, an accessor and the one
`record` call in the apply loop.

The grpc encoding moves next to the decoding it mirrors, in
`common/consensus_lag.rs`, leaving the internal service handler three lines
instead of thirty.

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

* Test that a consensus stall is still in the report after the peer catches up

* Take each peer's applied index from consensus state, not its ring

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: tellet-q <elena.dubrovina@qdrant.com>
2026-09-03 12:36:31 +02:00
Andrey VasnetsovandClaude Opus 5 15e7aa38e8 [UIO] Pin the append filesystem to its handle (#10092)
`UniversalRead::Fs` was reachable from an append handle and known to be
a `UniversalWriteFileOps`, but not pinned: `fs.open_append(..)` on it
returned some `Fs::AppendFile`, not the handle type in hand. Pin it the
way the read side is pinned, so `S::Fs` both opens `S` for reading and
hands `S` out as its append handle:

    UniversalRead<Fs: UniversalWriteFileOps<AppendFile = Self>>

Every append handle's canonical filesystem already produced itself
(`MmapFs → MmapFile`, `IoUringFs → IoUringFile`, `BlobFs<A> →
BlobFile<A>`), so this only writes down what held — the workspace
compiles unchanged.

Generic-over-`<S: UniversalAppend>` code now reaches its file-creating,
append-opening filesystem as `S::Fs` with no second associated type, and
`&impl UniversalWriteFileOps<AppendFile = S>` accepts any other producer
— the mirror of `&impl UniversalReadFs<File = S>`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:30 +02:00
Andrey VasnetsovandClaude Opus 5 f39b1bc6fb Bump edge packages (Python + Rust + FFI) to 0.8.0 (#10098)
Minor version bump of the edge packages from `0.7.2` to `0.8.0`.

- `lib/edge/python/Cargo.toml`: `qdrant-edge-py` 0.7.2 -> 0.8.0
- `lib/edge/publish/amalgamate.py`: `VERSION` constant bumped
  (`qdrant-edge` on crates.io)
- `lib/edge/ffi/Cargo.toml`: `qdrant-edge-ffi` bumped, kept in sync with the
  other two as its header comment requires
- `lib/edge/publish/ast-grep-rules.yaml`: inline package version comment
  updated (it was stale at 0.7.1)
- `Cargo.lock`: regenerated version entries

Follows the same pattern as #9252.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:36:29 +02:00
Andrey VasnetsovandClaude Opus 5 2cba392cc6 [UIO] Open append handles through UniversalWriteFileOps (#10091)
A file handle that appends was only reachable through
`UniversalReadFs::open` with `writeable: true`, which ties the append
capability to the backend's read handle type. Give the write-side
filesystem trait its own opening path instead:

    type AppendFile: UniversalAppend;
    fn open_append(&self, path, options) -> UioResult<Self::AppendFile>;

`AppendFile` is deliberately not tied to `UniversalReadFs::File`: the two
capabilities live on independent traits, so a backend may serve reads
through one handle type and appends through another, and a filesystem
that opens no read handles at all still names an append handle. For the
same reason there is no `OpenExtra` parameter — the append handle may
come from a different backend, whose per-open knobs would not apply.
`OpenOptions::for_append` forces `writeable` on, since a read-only
append handle is a contradiction rather than an error worth propagating.

Drop the `UniversalWriteFileOps` impls on the two disk caches first.
Both were vestigial: `DiskCacheFs` got its forwarding-to-remote impl
mechanically in the read/write trait split (#9682) and no caller ever
used it, and `BlockCacheFs` lives in a module that is dead code. Neither
cache can open a writeable file, so neither can produce an append
handle; mutations go straight to the backing storage. An
`assert_not_impl_any!` locks this in next to the existing one on
`DiskCache`.

`BlobFs` consequently requires `AsyncAppend` rather than `AsyncWrite`:
only a backend with a native single-request append can hand out an
append handle. Object stores that can just put whole objects (GCS,
Azure) stay read-only through universal I/O; nothing used their write
side.

The conformance suite gains `run_open_append_conformance`, run by
`run_append_conformance` and public for filesystems whose own `File`
does not append. It covers `MmapFs`, `IoUringFs` and `BlobFs` over the
in-memory object store.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 12:33:34 +02:00
Andrey VasnetsovandClaude Opus 5 74f3e85b94 Bump version to 1.19.0 (#10084)
* Bump version to 1.19.0
* Update missed cherry picks
* Add OpenAPI spec for v1.19.x

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:48:45 +02:00
Andrey VasnetsovandClaude Opus 5 c34acc1a36 Global quota API (#10035)
* feat: global quota API

Memory and disk are node-wide resources, so configuring their thresholds
per collection through strict mode makes little sense. Move them behind a
single cluster-wide `QuotaManager`.

The quota config is seeded from `storage.quotas` in the settings (and so
from env vars), overridden by `quota.json` in the storage directory, and
updated cluster-wide through a new `SetQuotaConfig` consensus operation
which rewrites that file on every peer. Raft snapshots carry it too, so a
peer that joins by snapshot picks it up.

Quotas are enforced wherever the strict mode memory and disk checks used
to run, but no longer gated behind `strict_mode.enabled`: a value set in
an enabled strict mode config still wins per resource, the quota is the
default. Rejections name both the condition that tripped and the config
that governs it.

`GET /quotas` reports the config plus current utilization to global read
users; `PUT /quotas` replaces it for global manage users.

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

* fix: cover the quota endpoints in the API consistency checks

`test_all_rest_endpoints_are_covered` and the OpenAPI endpoint count both
break on any new REST endpoint. Add `GET`/`PUT /quotas` to `ACTION_ACCESS`
with their JWT access tests, and bump the expected API count. The quota
endpoints stay out of `REST_ENDPOINT_WHITELIST`: that list is for
data-plane endpoints reported per-endpoint in metrics.

Also add a Raft snapshot CBOR compatibility test — snapshots are exchanged
between peers of different versions during a rolling upgrade, so
`quota_config` must be absent-tolerant in both directions.

Review feedback: persist through `SaveOnDisk`, which already implements the
write-before-swap protocol this was doing by hand; validate the config at
both persistence boundaries, since a hand-edited quota file or a config
arriving through consensus does not pass the REST handler's validation, and
a `0%` limit would reject every update forever.

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

* test: assert seeding a quota from invalid settings persists nothing

Follow-up to review feedback claiming `SaveOnDisk::load_or_init` writes the
init value before it is validated. It does not — only `SaveOnDisk::new`
persists — but the property matters: were seeding to persist first, invalid
settings would leave a `quota.json` that fails validation on every
subsequent start, and the node could only be recovered by deleting it by
hand. Pin it down with a test.

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

* refactor: make QuotaManager the single reader of memory and disk

The quota checks measured memory and disk themselves, while the optimizer
and the WAL disk watcher each called `fs4::available_space` behind their
own ad-hoc caches. Fold all of it into QuotaManager: it owns the readings,
the freshness policy, and the limits they are compared against.

Moves the module to `lib/shard`, since the optimizer sits below `storage`
and has to reach it; `storage::quota` re-exports it, so consensus, the
`/quotas` API and StorageConfig are unchanged. The manager is installed as
a process singleton by TableOfContent, ahead of loading any collection.

- Callers hand in QuotaLimits overrides instead of a StrictModeConfig, and
  an override can now only tighten. A collection-level admin could raise
  `max_disk_usage_percent` past a cluster-wide limit that needed global
  manage rights to set; ties resolve to the quota so the rejection names
  the knob that actually has to change.
- Measurements are cached for 5s, but a reading at or above its limit is
  never reused: a rejected client retries, and freeing the resource has to
  take effect on the next request rather than a TTL later.
- `fits_on_disk` sizes an optimization against physical free space only,
  never the configured limits. Optimizations are what free a full disk, so
  the quota must not be what stops one.
- `percent_of` widens to u128 instead of saturating the multiply, which
  under-reported utilization (failing open) above ~184 PB.
- StorageConfig::quotas is optional; absent means no quota is enforced.

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

* feat: don't recover dead replicas onto a node at a resource limit

Recovering a dead replica pulls a whole copy of its shard onto this node.
If it is already at its memory or disk quota that transfer cannot finish,
and starting it only pushes the node further past the limit. Skip it and
reconsider on a later sync, once the resource frees up.

Adds QuotaManager::check_capacity for work that lands bytes here without
being an update. Unlike fits_on_disk the configured limits do apply:
taking on a replica is not what frees a full node, so there is no deadlock
to avoid by letting it through.

The check is hoisted out of the per-shard loop because a node over its
limit re-measures on every call, so checking per dead shard would cost a
statvfs each. It is free when no quota is configured.

Also trims the comments across the quota module, which had grown well past
what the code needs.

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

* test: drop trivial and duplicated quota tests

Six tests removed, ~140 lines, with no loss of coverage:

- a_rejection_names_the_knob_that_has_to_change asserted that a format!
  contains its own literals; the message is covered end-to-end by the
  override test and by test_global_quota.py.
- a_node_over_its_quota_has_no_capacity_to_take_on_a_replica was 30 lines
  for check_capacity, a one-line delegation to check_update the test above
  it already calls.
- a_rejecting_measurement_is_never_served_from_the_cache duplicated the
  meter test, which proves the same rule with an injected reader instead
  of inferring it from the real filesystem.
- free_space_is_reported_without_enforcing_anything covered a one-line
  accessor, and its point is what the fits_on_disk test is for.
- The two resolve tests and the three meter tests each collapse into one.

DiskFit::Unknown keeps its coverage as two lines inside the fits_on_disk
test rather than its own fixture. The snapshot compat pair becomes one
test: the second only asserted cluster_metadata.is_empty(), which says
nothing about quotas — the real check was the deserialize, now an expect
that states it.

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

* fix: re-measure free space as the disk fills, and drop a Windows-only assert

Two CI failures, both from this branch.

e2e test_low_disk: the DiskUsageWatcher I replaced escalated to checking
on every call once free space fell below 512 MB. Folding it into the quota
manager lost that — available_bytes passed no limit, so a reading was
reused for the full 5s however little space was left. On a disk filling as
fast as that test fills it, 5s blind is enough to actually run out and the
WAL write dies instead of returning "No space left on device".

available_bytes now takes a watch_below level and never reuses a reading
under it, which is what the old ladder was expressing. The watcher passes
max(min_free, 512 MB), so the escalation point is back; above it the 5s
cache still costs fewer syscalls than the old 128-call ladder. fits_on_disk
gets the same rule by passing required_bytes, so a merge that does not fit
re-checks rather than sitting on a stale sample.

Windows: fits_on_disk on a missing path was asserted to be Unknown, but
GetDiskFreeSpaceEx resolves up to the containing drive and succeeds — as
common::disk_usage's own test documents. Dropped; the branch is a two-line
else and is not portably reachable.

Also renames an_optimization_is_sized_against_the_disk_not_the_quota, which
needed explaining to be understood.

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

* feat: report the quota config in telemetry

Reads it from the quota manager rather than the settings, so it is the
config the node is actually enforcing: a peer that missed a consensus
update reports what it is applying, not what the cluster agreed on.

Gated on global access, the same access `GET /quotas` requires, and left
out of `PeerTelemetry` — a quota is per-node state, so each peer reports
its own.

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

* fix: regenerate OpenAPI, and cover the quota in the telemetry key sets

Two CI failures from the previous commit.

Referencing QuotaConfig from TelemetryData moves its definition earlier
in `components/schemas`, because TelemetryData is generated ahead of
QuotaStatus. Regenerated rather than hand-patched, so the schema is a
pure move.

test_telemetry_detail asserts the exact set of top-level telemetry keys.
The quota is reported at every level, including 0 — it is three scalars,
it is the default the endpoint serves, and it is what explains an update
being rejected — so both key sets gain it.

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

* feat: remove `max_disk_usage_percent` from strict mode

Disk is a node-wide resource, so a per-collection percentage of it never
meant anything a caller could act on: the limit describes how full the
*node* is, and which collection the write happens to target has nothing
to do with it. The global quota is where it belongs.

It shipped in 1.18.2 without documentation, so this drops it outright
rather than deprecating. Removal is soft in every direction: StrictModeConfig
has no `deny_unknown_fields`, so a client still sending it gets it ignored
rather than a 400, and the same struct deserializes the persisted collection
config, so collections created on 1.18.2+ keep loading. Proto field 22 is
reserved so the number is never reused.

The e2e test becomes a quota test — the fixture and the timing are the
interesting parts and they carry over unchanged; only how the threshold is
configured differs.

`max_resident_memory_percent` was documented and stays for now.

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

* refactor: enforce the strict mode memory limit outside the quota

`max_resident_memory_percent` was folded into the quota as an override,
which meant the quota check had to know about strict mode, and retiring
the setting would mean unpicking `EffectiveLimit` and `LimitSource` from
the resolution logic.

It is now a check of its own in `verification/mod.rs`, next to the strict
mode checks it belongs with, borrowing only the measurement from the quota
manager — which stays the node's single reader of process memory, so both
checks still share one reading. Deleting the setting later is deleting one
function and its one caller.

`QuotaManager::check_update` takes no arguments and consults the quota
alone. A collection can still only tighten the limit for itself, because
its own check runs in addition rather than in place of the quota's, and
each rejection now names the config that has to change without having to
carry a `LimitSource` to say so.

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

* refactor: enforce the quota on the update path, not in strict mode

The quota check sat inside `check_strict_mode_toc_batch` only because that
was the one place holding the collection's strict mode config. It doesn't
need one any more, and the placement had a real cost: coverage depended on
each handler remembering to ask for a strict mode check, and four of the
internal update RPCs do — `sync_internal`, which moves the most bytes onto
a node, does not.

It now runs in `Collection::update_from_client` and `update_from_peer`,
which every update passes through. `update_from_client` checks ahead of the
shard split, so an operation is accepted or refused whole rather than
landing on some shards and being refused by others.

Classification moves with it, from ~10 `consumes_memory` impls on request
DTOs to one exhaustive `CollectionUpdateOperations::consumes_quota`. The
internal enum has variants — raw upserts, conditional upserts, the syncs —
that have no client-facing request type, so per-DTO impls structurally
could not classify them.

Shard-transfer syncs stay excluded, as they are today: a transfer is sized
up once before it starts, and refusing its batches partway abandons work
that is nearly done only for it to restart from the beginning.

Index and named-vector creation reach shards through consensus, past this
check — a peer must not refuse what the cluster agreed to — so they keep
their pre-consensus check, now against the quota manager directly.

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

* feat: deprecate `max_resident_memory_percent` in strict mode

Same reason the disk threshold went: memory is node-wide, so a
per-collection percentage of it caps how full the *node* is, which has
nothing to do with which collection is being written to. The node-wide
quota caps it once for everything.

Unlike the disk threshold this one shipped documented, in 1.18.0, so it
keeps working — as a limit a collection can tighten for itself, never lift
— and gets the usual markers: `#[deprecated]` on both Rust structs,
`[deprecated = true]` on proto field 21, and `deprecated: true` in the
OpenAPI schema, which schemars derives from the attribute.

The note names 1.21 as the removal. Recording a version matters here: the
audit in docs/plans/overdue-deprecations.md found that this repo has never
written a removal deadline down, and members of the 1.15.0 deprecation
batch are still in tree.

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

* fix: reconcile the quota readers with #9891

#9891 landed effective (cgroup) figures in telemetry while this branch was
making QuotaManager the single reader of memory and disk. Two collisions,
neither of which git sees.

`segment::utils::mem::total_memory_bytes` is now a shared accessor with a
5s TTL, so a cgroup resize is picked up. The quota module had its own
`OnceLock` copy that froze the value at startup — exactly what #9891 set
out to fix — so it delegates to the shared one instead.

Telemetry's new `disk_size` called `common::disk_usage::disk_usage`
directly. That reader lost its TTL cache on this branch when the caching
moved into the quota manager's meter, so it would have taken an uncached
`statvfs` on every telemetry request, and it put a second disk reader back
in the tree. It goes through `QuotaManager::disk_capacity_bytes` now,
sharing the reading the quota check already takes. Verified it still
reports the storage filesystem, matching `df`.

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

* refactor: split the quota manager by what each half does

`manager.rs` had grown to 450 lines holding three separate jobs: owning
the config and its file, taking the readings, and comparing one against
the other.

- `manager/store.rs` — the `Store` enum, `QUOTA_CONFIG_FILE`, and config
  validation, which is now the store's own business rather than something
  every caller has to remember to do first.
- `manager/measure.rs` — every reading, and `DiskFit`. The "nothing else
  calls `statvfs` or reads process RSS" claim is now checkable by looking
  at one file.
- `manager/enforce.rs` — `check_update` / `check_capacity` and the
  threshold comparison.
- `manager/mod.rs` — the struct, its construction, and the config
  accessors: what a reader needs to see first.

Tests move with their subject. No behaviour change: `set_config` used to
validate before delegating to the store, and now the store validates on
write, which is the same order of operations from the outside.

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

* fix: count copy-on-write deletes toward the quota

Dropping a vector or a payload key does not free anything on its own:
copy-on-write rewrites the point to produce the version without that
field, so storage grows first and is only reclaimed once the optimizer
gets to it. Gating those as if they were reclaiming space let a full node
keep taking writes that make it fuller.

Deleting whole points stays exempt. That is the one operation that has to
work on a node at its limit, or there is no way back under it.

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

* docs: say that the quota's reported usage is per node

`GET /quotas` returns one cluster-wide config and one set of utilization
figures, which reads as though both describe the cluster. They do not:
memory and disk are node-local, so `usage` is whatever the peer that
served the request is seeing, and a peer under its limit says nothing
about the others.

Also corrects `resident_memory_percent`, which claimed to be a share of
total system memory. It is a share of the memory available to the
process, which under a cgroup is the limit rather than the host's RAM.

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

* feat: treat a node over its quota as a failed replica, not a bad request

A quota rejection described the request as invalid (400) and was classified
non-transient, which is how the replica set recognises errors that every
replica would produce alike. A quota is the opposite: the input is fine and
the answer depends on which machine you ask. On the default `wait=false`
path that combination silently dropped the write — `update.rs` only
deactivates transient failures when nothing completed — leaving the replica
Active and permanently missing data its co-replicas had.

It is now `InsufficientStorage`, transient, HTTP 507 / gRPC
`ResourceExhausted`. So a node that is out of room is handled like one that
is offline:

- last active replica, or every replica over quota: nothing could take the
  write, and the client is told the cluster is out of room.
- more than one replica: the full node is deactivated through the same path
  a dead peer takes, and the update stands if enough replicas accepted it.
  `check_capacity` already keeps recovery off that node until it has room.

The check also moves off `update_from_client`, which applied the
coordinator's own limit to the whole operation even when it held no replica
of the shards being written. Each replica set now gates its own local write
and records the refusal as a failure of this peer, so a node only ever
answers for itself.

`ResourceExhausted` is shared with rate limiting, and the reverse conversion
mapped it straight to `RateLimitExceeded` — a forwarded rejection came back
as 429. Statuses now carry a marker so the two stay distinguishable across
the wire.

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

* feat: report quota pressure per node, and across the cluster

A quota is node-local, so finding out which node has hit one meant asking
each of them in turn — and nothing at all showed up in monitoring.

`/metrics` gains a `quota_exceeded` gauge for the local node. It is emitted
only while the quota is enabled: with it off the value would be a constant
0 that says nothing about the node, and an alert built on it would go quiet
rather than fire if someone disabled the quota. Telemetry's `quota` field
carries the same verdict alongside the config, since that is where the
metric is derived from.

`GET /quotas` now answers for the whole cluster. A new `GetQuotaUsage` RPC
on the internal `QdrantInternal` service returns what one peer is using,
and the handler fans it out to every known peer in parallel. Peers that do
not answer are left out rather than failing the request — the nodes that
are out of room are exactly the ones most likely to time out, and a partial
answer still names them. Outside distributed mode the field is absent
rather than a map of one.

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

* feat: report the quota metric per resource

`quota_exceeded` was one flag for the whole node, which does not say what
to go and fix — disk is freed by deleting or optimizing, memory by
unloading. It now carries a `resource` label:

    quota_exceeded{resource="memory"} 0
    quota_exceeded{resource="disk"} 1

A resource with no limit gets no series at all, for the same reason the
metric is absent while the quota is disabled: a series that can never reach
1 reads as healthy and would quietly carry an alert that cannot fire.

`QuotaManager::exceeded` returns the per-resource verdict, with `None` for
a resource this node does not cap. Telemetry reports the same breakdown,
since the metric is derived from it. The peer usage RPC keeps a single
flag — it sits next to both percentages, so it only has to answer "is this
peer refusing writes".

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

* fix: drop a no-op error conversion the linter caught

`check_global_access` already returns a `StorageError`, so mapping it
through `StorageError::from` converted the type to itself and tripped
`clippy::useless_conversion`.

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

* feat: hold a tripped quota until usage clears a release margin

A resource resting on its limit crosses it in both directions on the noise
between two readings, and each crossing is expensive: the node refuses a
write, its replica is deactivated, usage dips, recovery starts sending a
whole shard copy back, and the arriving data pushes it over again. The loop
sustains itself, and every lap costs a shard transfer.

A limit now trips at its configured value but only clears once usage has
fallen 5 percentage points below it, so the crossing has to be real. The
margin is floored at 1%, since a limit smaller than the margin would
otherwise be impossible to fall back under and would strand the node.

The verdict is carried on the manager rather than recomputed, which makes
it the thing reporting shows: expect `exceeded` to be set while the
utilization next to it is already back under the limit. Rejections say so
too, rather than claiming a limit that is no longer exceeded:

    Disk usage is at 87% of total capacity. It reached the configured limit
    of 90% and has to fall below 85% before this node takes writes again.

Changing the config clears the verdicts. New limits are a deliberate act,
and should not be held back by the margin of a limit that no longer exists.

Both resources are now evaluated on every check instead of stopping at the
first failure, so a verdict is never left behind reporting a reading that
has since been superseded.

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

* feat: make the quota release margin configurable

5 points is a guess about how noisy a deployment's usage is, which is not
something one number can be right about: a node whose disk moves in
gigabyte steps needs a wider margin than one that creeps, and an operator
who wants the old flip-on-every-reading behaviour should be able to ask
for it.

`release_margin_percent` joins the rest of the quota config, so it seeds
from `QDRANT__STORAGE__QUOTAS__RELEASE_MARGIN_PERCENT`, replicates through
consensus, and changes with `PUT /quotas`. Defaults to 5 and is filled in
when a request omits it, so it always answers with the margin actually in
force rather than leaving the caller to assume one. `0` releases as soon as
usage is back under the limit.

`QuotaConfig` grows a hand-written `Default` for it, since deriving one
would have quietly defaulted the margin to 0 and disabled the hysteresis
for anyone constructing a config in code.

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

* refactor: leave the release margin unset by default, and hold verdicts in atomics

`release_margin_percent` is `null` unless someone sets it, rather than
materialising 5 into every config. A quota written today then does not pin a
number a later release may want to revise, and `{"enabled": false}` still
round-trips as itself. `QuotaConfig::limits` resolves it, next to `enabled`,
so enforcement never sees the unset case.

The verdicts move from a `Mutex<QuotaExceeded>` to one `AtomicBool` per
resource. They are judged independently and nothing reads them as a pair, so
the lock only added contention to the path every update takes; a verdict
that races a concurrent check is re-decided by the next one from a fresh
reading.

That also drops the tri-state. Only "was this over its limit" has to survive
between checks — whether a resource is enforced at all follows from the
config and the reading, so it is derived when reporting rather than stored.

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

* docs: drop a comment arguing with a design that was never here

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:45 +02:00
Andrey VasnetsovandClaude Opus 5 3ddb1f1714 Reorganize chunked_vectors: dedicated read-only module (#10062)
* Move chunked-vectors read-only code into a dedicated module

`chunked_vectors` kept the read-only view (`ChunkedVectorsRead`), its
`LiveReload` impl and the writer in flat sibling files. Reshape it to the
layout the other components use: a `read_only/` submodule split into
`mod.rs` / `lifecycle.rs` / `read_ops.rs` / `live_reload.rs`, with the
writer's impls split into `lifecycle.rs` and `write_ops.rs` next to the
struct in `mod.rs`.

Two things did not survive as pure code motion:

- The file-path and metadata readers (`config_file`, `status_file`,
  `load_config`, `read_status_len`) were associated fns on
  `ChunkedVectorsRead` that the writer reached through the type. They are
  now free `pub(super)` fns in `config.rs`, next to the types they read,
  so both sides get at them without widening visibility across the
  read-only boundary. `ChunkedVectorsRead::status_file` was `pub` but
  unused outside the module.

- `preopen_chunks` moved from `chunks.rs` to `read_only/lifecycle.rs`
  beside its only caller; `chunks.rs` keeps the shared chunk-name
  matching and gains a `chunks_prefix` helper for the two listing sites.

`ChunkedVectorsRead` is no longer re-exported from the component root, so
importers spell out `chunked_vectors::read_only::ChunkedVectorsRead` —
same as the sibling `dense` / `multi_dense` / `sparse` / `turbo`
read-only modules. No behaviour change.

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

* Rename ChunkedVectorsRead to ReadOnlyChunkedVectors

Read-only structs are named `ReadOnly*` across the codebase
(`ReadOnlyChunkedDenseVectorStorage`, `ReadOnlySparseVectorStorage`,
`ReadOnlyDiskIdTracker`, `ReadOnlyNumericIndexInner`, ...), while the
`*Read` suffix marks the read-side traits (`VectorStorageRead`,
`NumericIndexRead`, `PayloadFieldIndexRead`). `ChunkedVectorsRead` is a
struct wearing the trait suffix; rename it to match its peers now that it
lives in a `read_only` module.

Pure rename, no other change.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:45 +02:00
Andrey VasnetsovandClaude Opus 5 c2df77a3bf Serialize snapshot recoveries of the same shard (#10026)
Two snapshot recoveries of one shard resolve as "last to finish wins".
`restore_local_replica_from` is destructive on its own - for a full recovery
it takes the `LocalShard::clear` + `move_data` branch - and nothing is atomic
across the download that precedes it.

That picks the wrong winner. A recovery gets abandoned because it stalled,
which is exactly why its caller timed out and retried, so it finishes *after*
the retry that replaced it and restores on top of it. It keeps running because
`recover_shard_snapshot_impl` is not cancel safe, so a caller that walked away
cannot stop it:

    stalled: clear -> download ..........................-> restore -> rolls back retry
    retry:      clear -> download -> restore -> Ok to caller -> caller writes

The retry's `Ok` is what the sender of a snapshot shard transfer acts on: it
switches the transfer to `Partial` and flushes its queue proxy into the shard.
The stalled recovery then discards those writes - acknowledged, then lost.

Add a per-shard recovery lock on `ShardReplicaSet`, held across the whole
recovery (clear, download and restore), so what a caller asked for last is what
survives. Clearing before the download is kept, so recovery still never needs
disk space for two copies of the shard.

`ShardRecoveryGuard` already existed to track recovery progress for the whole
recovery, so the lock is folded into it rather than added alongside:
`ActiveRecoveries::start` takes ownership of the lock guard, making it
impossible to start a recovery without holding it, and `Collection::
start_shard_recovery` is the single call that acquires both.

Queueing behind another recovery is logged. An abandoned recovery is otherwise
invisible - no live caller, no request, no error - and shows up only as a
transfer sitting in `Recovering`.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:44 +02:00
6aebf544d6 feat: UpdateOnlySegment / UpdateOnlyEdgeShard batch writer skeleton (#10021)
* feat: `UpdateOnlySegment` / `UpdateOnlyEdgeShard` batch writer skeleton

Mirror image of the read-only pair, for the serverless updater: a
shard/segment whose public surface is writes only, built for batches of
many tiny operations against remote, append-only storage.

Implemented:

* `UpdateOnlySegment<S>` with a deliberately narrow open — id tracker,
  payload storage and one storage per named vector, all cold. No vector
  index, no quantized vectors, no payload index on the segments the
  writer only reads from.
* `SegmentUpdateView`, the shared home of resolution logic, generic over
  the component traits (`VectorDataStorageRead` is a `VectorDataRead`
  without the index, so a segment that opens no index can produce the
  view). Batched `locate_points` / `point_versions` /
  `read_stored_points`.
* `UpdateOnlyEdgeShard<S>::apply_batch`: fold the batch to one entry per
  point, locate the points, read only the ones that cannot be resolved
  from the batch alone, materialize `FullyQualifiedPoint`s, append them
  and tombstone the slots they replace.

`todo!()`, pending the append-only components on the roadmap (appendable
`DynamicStoredFlags` and `ChunkedVectors`, an appendable payload
blobstore and field indexes): `store_points`, `tombstone_points`,
`flush`, and creating the first appendable segment.

Filter-selected operations, point sync, conditional upserts and the
schema-level operations are rejected up front rather than silently
skipped.

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

* fix: codespell implementor → implementer in SegmentUpdateView docs

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: trim update-only writer docstrings to guarantees

Less verbose throughout: state each function's contract — ordering,
absent-value behavior, preconditions, durability — and drop narration
about where types are used or why alternatives were rejected.

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

* refactor: fold SegmentUpdateView into UpdateOnlySegment as inherent methods

The view was premature: it had exactly one producer, and its trait
bounds bought an unexercised option. Resolution (locate / versions /
read raw) now lives as inherent methods on UpdateOnlySegment, still
generic over the backend. A shared view can be extracted when a second
producer appears, e.g. batched CoW moves out of regular segments.

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

* refactor: split edge update_only batch.rs into a module

Pure move: mutation.rs (PointMutation fold + materialize), plan.rs
(UpdateBatchPlan operation intake), tests.rs. PointUpdates::new/push
narrowed to pub(super).

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

* feat: parallel per-segment batch reads + tombstone every copy of a point

locate_points and read_stored_points visit segments in parallel on a
dedicated edge-update rayon pool (build_search_pool generalized to
build_segment_pool with a thread-name prefix).

locate_points now keeps every slot a point occupies, not just the
newest copy: a rewrite or delete retires all of them. Tombstoning only
the newest slot would let an older duplicate left by an interrupted
move outlive the point — and resurrect it after a delete.

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

* refactor: point_versions returns a map keyed by internal id

The id tracker's batch read is keyed by internal id already; returning
AHashMap drops the positions_of reverse-lookup adapter. Absent key =
unwritten slot, defaulted to version 0 at the caller.

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

* feat: accept a deferred threshold when opening UpdateOnlySegment

Groundwork for an external rebuilder working the same directory: the
cutoff loads slots at or above it into the appendable id tracker's
deferred track (same appendable-only filter as ReadOnlySegment). It
hides nothing from the writer — resolution runs WithDeferred, so every
point still locates at its latest slot. The edge shard passes None
until the rebuilder coordination exists.

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

* feat: preview_batch — resolve a batch without writing anything

apply_batch and the new preview_batch share one resolution stage
(resolve_batch: locate, read, materialize into per-point PointActions),
so a dry-run reports exactly what an apply would do. Plus
segment_configs(): per-segment configs with the write target marked.

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

* feat: prefetched + parallel segment opens for the update-only writer

UpdateOnlySegment::open now mirrors ReadOnlySegment::open: a
per-segment CachedFs primed by preopen, config parsed once and handed
to open_via. The edge shard opens segments in parallel on its pool,
keeping fail-hard semantics. With Populate::No throughout, prefetches
transfer no data-file content — only configs, the id tracker and the
deleted flags, whose opens consume them whole anyway.

Also: ReadOnlyAppendableIdTracker::preopen now tolerates the
not-yet-created mappings/versions files of an empty appendable segment,
matching its open's contract — previously unreachable because followers
skip appendable segments on error, while the writer must open them.

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

* feat: edge-shard-update — dry-run batch upserts against a shard

Counterpart of edge-shard-query for the write path: opens an
UpdateOnlyEdgeShard over a local directory or S3/GCS object storage,
generates random points shaped by the shard's own schema (segment
config + payload-index schema), and logs what applying them would do —
locations, versions, actions, tombstones — via preview_batch. Nothing
is written: the write half is still todo!().

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

* fix: box PointAction::Store to appease clippy::large_enum_variant

A resolved point is ~384 bytes while every other variant is empty.

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

* fix: adapt to dev's dead-code sweep (#10030)

Restore NamedVectors::remove_ref — removed as dead on dev, but the
batch fold's DeleteVectors arm is now its first caller. Drop the
allow(dead_code) on segment::update_only (no longer needed) and switch
the writer's unread fs field to expect(dead_code), per the new
ast-grep rule.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:18:44 +02:00
Andrey VasnetsovandClaude Opus 5 de1748bec6 Make deprecated on_disk_payload optional in the REST schema (#10020)
* Make deprecated `on_disk_payload` optional in the API schema

`CollectionParams::on_disk_payload` was the last place the deprecated flag was
still a bare bool, in both the REST schema and the gRPC `CollectionParams`
message. Clients generated from those schemas model it as a required bool, so
removing the field in a future version would break them.

Make it optional in both schemas while keeping it populated on every path that
builds `CollectionParams`, so responses and the persisted collection config
still carry a value and existing clients keep working until it is removed.

The gRPC change is wire-compatible: proto3 `optional` only adds a synthetic
oneof for presence tracking, the field number and wire type are unchanged. It
does change what an absent field means, though, so decode absent as `false` when
reading a remote peer's collection info -- peers that predate the change encode
a plain bool, which is omitted from the wire when false.

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

* Keep gRPC `on_disk_payload` a plain bool

Adding proto3 `optional` would let a new client tell "unset" from `false`, but
it also changes what an absent field means. A pre-upgrade server encodes a plain
bool, which is omitted from the wire when false, so a client generated from the
new proto would decode `false` as "unset" -- and upgrading the client first is
the order we recommend.

gRPC does not need the change anyway: protobuf tolerates a missing field by
design, so a gRPC client does not break at runtime when we stop sending this
one. The breakage this addresses is on the REST side, where a generated model
with a required non-nullable bool fails to deserialize a response that omits the
field. JSON encodes `false` explicitly, so the REST schema change carries no
equivalent ambiguity.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:18:44 +02:00
a11f8bb4ae feat: io_uring setting to control which components use the io_uring backend (#10008)
* feat: `io_uring` setting to control which components use the io_uring backend

A few components have both an mmap and an io_uring variant reading the very
same files: the immutable dense vector storages, the single-file TurboQuant
storage, and the mmap payload storage. Until now the choice was a side effect
of `async_scorer` — a vector-search knob — plus, for the payload storage, a
feature flag that was parked off because io_uring is ~2x slower than mmap when
the data fits the page cache (#9310, #9409).

Add `storage.performance.io_uring`, optional, with two modes:

- unset (default): unchanged behaviour. The vector storages keep following
  `async_scorer`; the payload storage stays on mmap.
- `disabled`: no component uses io_uring.
- `auto`: a component uses io_uring when its memory placement is `cold` (data
  is left on disk, so reads hit the disk and there is something to gain), its
  feature flag allows it, and the kernel supports io_uring. Components meant to
  sit in RAM keep using mmap.

The decision lives in one place, `segment::common::io_uring::use_io_uring`, so
the openers no longer each reach for the async-scorer global. Kernel support is
now probed up front through `is_io_uring_supported()` instead of opening a file
and falling back on error.

`async_payload_storage` now defaults to on: it no longer decides anything by
itself, it only lifts the ban, and the payload storage no longer follows
`async_scorer` at all — so turning it on cannot silently move an existing
`async_scorer: true` deployment onto the slower path.

Which backend a component ended up on depends on the config, the placement and
the kernel at once, so report it in `SegmentInfo`: `vector_data[name].io_backend`
and `payload_storage_io_backend`, both `"mmap" | "io_uring"`, absent for
components that have no such choice.

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

* Trim comments, drop trivial tests

Two tests were only restating their own implementation: `test_mode_round_trip`
round-tripped the encode/decode pair next to it, and `test_io_uring_config`
checked that serde deserializes a two-variant enum. The mode matrix test stays,
it is the one that pins the semantics.

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

* Flatten `IoBackend` in OpenAPI, derive `JsonSchema` for `IoUringMode`

Per-variant doc comments on a plain string enum make schemars emit a `oneOf`
of anonymous single-value objects instead of a flat `enum`. Move the variant
descriptions into the enum doc, as `Memory` and friends already do.

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

* Update lib/segment/src/vector_storage/turbo/turbo_vector_storage.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* Update lib/segment/src/types.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* Update lib/segment/src/types.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* Update lib/segment/src/types.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* Update lib/segment/src/types.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* upd openapi schema

* Update lib/common/common/src/flags.rs

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>

* Require kernel io_uring support in the async-scorer fallback

`use_io_uring` returned `get_async_scorer()` verbatim when the `io_uring`
setting is unset, so an enabled async scorer on a kernel without io_uring
opened the io_uring storage, failed, and fell back to mmap with an error
log per segment. Gate that branch on `is_io_uring_supported()` too, like
`Auto` already is, so the component just stays on mmap.

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

* upd openapi schema

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>
2026-08-04 11:18:44 +02:00
Andrey Vasnetsov d5ccd03ca9 do not generate oneOf (#10011) 2026-08-04 11:18:44 +02:00
73e6c16f9c Remove deprecated search endpoints from OpenAPI, deprecate them in gRPC (#9982)
* Remove deprecated search/recommend/discover endpoints from OpenAPI

Remove deprecated REST API endpoint definitions from the OpenAPI
generator. These endpoints were deprecated in v1.13.3 (`f4ced2567`,
#5907, 2025-01-30) in favor of the universal `/points/query` endpoint:

- POST /points/search
- POST /points/search/batch
- POST /points/search/groups
- POST /points/recommend
- POST /points/recommend/batch
- POST /points/recommend/groups
- POST /points/discover
- POST /points/discover/batch

Also removes the corresponding request types from the schema generator
and updates the expected API count in the consistency check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate OpenAPI integration tests to /points/query

The deprecated /points/search, /points/recommend and /points/discover
endpoints (along with their /batch and /groups variants) were removed
from the OpenAPI spec, which caused validation failures in the Python
integration test harness.

This commit migrates the affected tests to the universal /points/query
endpoint:

- Delete tests dedicated to the deprecated endpoints:
  test_recommend.py, test_discover.py, test_multicollection_reco.py,
  test_recommendation_multivector.py
- Refactor remaining tests to call /points/query (and /query/batch,
  /query/groups), translating request bodies (vector -> query / using,
  positive/negative -> query.recommend, target/context -> query.discover)
  and unwrapping the new result.points response shape.
- Drop equivalence assertions against the now-removed legacy endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Relax non-empty assertions in migrated recommend/discover tests

The previous migration added `len(...) > 0` assertions to tests that
previously only checked equivalence between the deprecated and new
API. These assertions are too strict because the parametrized
`query_filter` cases legitimately produce empty result sets.

Drop the `> 0` assertion and rely on `request_with_validation` to
verify the response is well-formed and HTTP OK.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Migrate remaining OpenAPI tests off deprecated search endpoints

Tests added to dev after the original migration was written still call
/points/search and /points/recommend/groups through
`request_with_validation`, which resolves the endpoint against the
OpenAPI spec and therefore breaks once the endpoint is not in the spec:

- test_turbo4_storage.py, test_sparse_idf_corpus.py, test_validation.py:
  translate /points/search to /points/query (vector{name,vector} ->
  query + using, result -> result.points).
- test_group.py: drop the /points/recommend/groups half of the
  lookup_from validation test in favour of the query equivalent.

test_sparse_idf_corpus.py's test_query_api_supports_idf_corpus goes
away: with the helper on /points/query every test in the file now
exercises what it asserted.

Also record why test_recommend_group cannot assert on its groups: it
uses every point in the collection as a recommend example, so all of
them are excluded and the result is legitimately empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Regenerate openapi.json without the deprecated search endpoints

Drops the 8 deprecated paths and the request schemas that only they
referenced: Search/Recommend/Discover request (+Batch, +Groups) types
and their exclusive dependencies (NamedVector, NamedSparseVector,
NamedVectorStruct, UsingVector, RecommendExample, ContextExamplePair).

Regenerated output is a strict subset of the previous spec, and every
remaining $ref still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Deprecate the search/recommend/discover RPCs in gRPC

The REST counterparts have carried `deprecated: true` since v1.13.3 and
are now gone from the OpenAPI spec, while the gRPC RPCs never got any
deprecation annotation at all. Mark all 8 with `option deprecated = true`
so generated clients warn, and point each doc comment at its `Query`
replacement.

tonic puts `#[deprecated]` on the generated client methods only; the
server trait gets the doc comment alone, so our own `impl` is unaffected.
The RPCs keep serving traffic — this is annotation only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Restore the deleted recommend/discover suites on /points/query

The earlier migration deleted these four files outright, but the
query-side tests it left behind are all shallow smoke tests
(`len(result) > 0`, `"points" in result[0]`). The deleted ones carried
invariants with no query-API equivalent anywhere, so deleting them was a
real loss of coverage rather than de-duplication:

- test_recommend.py: default strategy equals average_vector; batch
  results identical to sequential singles across six request shapes;
  best_score with only negatives yields all-negative scores; best_score
  with a single positive orders identically to a nearest query; raw
  vectors as examples equal ids as examples.
- test_discover.py: context-only scores are all <= 0; target-only orders
  identically to a nearest query but scores differently; with a fixed
  context the integer part of the score is stable while the decimal part
  moves, and vice versa with a fixed target; batch equals singles;
  lookup_from by id equals by vector.
- test_multicollection_reco.py: cross-collection lookup_from, plus
  wrong-vector-size, unknown-collection and unknown-vector rejections.
- test_recommendation_multivector.py: the same recommend invariants over
  a max_sim multivector collection, which the query suite never covered.

Only test_recommend_missing_lookup_from_collection_with_raw_vector is
dropped as genuinely redundant — test_query.py's
test_query_missing_lookup_from_collection covers query, query/batch and
prefetch.

Two request-shape differences the translation had to absorb:

- Giving no examples at all is 422 (a RecommendInput validation rule),
  where the legacy API reported 400 from the query itself. A malformed
  example, such as an empty vector, is still 400.
- DiscoverInput requires the `context` key and accepts only an explicit
  null to mean "no context", so target-only discover must spell it out.
  The legacy API let it be omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:18:43 +02:00
5819d224da fix: evict page cache of files that are mapped twice (#9984)
* fix: evict page cache of files that are mapped twice

`clear_cache()` on a `memory: cold` vector storage was a silent no-op: after
optimization the whole storage stayed resident in the page cache.

`MmapFile` opened with `need_sequential` holds two mappings of the same file
(`MADV_RANDOM` + `MADV_SEQUENTIAL`), and `MADV_PAGEOUT` skips any page carrying
more than one page-table reference. Any page faulted through both mappings was
therefore never reclaimed, no matter which mapping was advised. Quantization is
one way to get there: it reads the raw vectors through the sequential mapping
while `populate_vector_storages()` populated the random one, so with
quantization enabled `matrix.dat` stayed 100% cached after the build, and
without it the same build evicted down to ~1%.

`POSIX_FADV_DONTNEED` alone does not help either, as it skips pages with any
page-table reference. So zap the page tables of both mappings first
(`MADV_DONTNEED` on a shared file mapping only drops the PTEs; the data stays
in the page cache and refaults on the next access) and then evict through the
file. Dirty pages are still kept, exactly as before — `MADV_PAGEOUT` did not
write back filesystem pages either — so callers that flush first, like
`SegmentBuilder::build`, get a complete eviction.

Also affects the payload storage (gridstore pages), the disk id tracker reader
and quantized multivector offsets, which open with `need_sequential` too.

Measured on a 200k x 256 build with quantization: `matrix.dat` 100% -> 0.0%
resident, whole segment 67% -> 2.4%.

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

* fix: satisfy clippy::cast_lossless in the eviction test

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

* MADV_DONTNEED should not be safe

* Document why Madviseable::clear_cache might not be enough

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: xzfc <xzfcpw@gmail.com>
2026-08-04 11:18:43 +02:00
Andrey VasnetsovandClaude Fable 5 8b7411e8d8 Support memory placement in service-level storage config defaults (#9950)
* Support memory placement in service-level storage config defaults

Follow-up to #9684: `storage.payload.memory` and
`storage.collection.vectors.memory` set service-wide placement defaults
for newly created collections, deprecating `storage.on_disk_payload` and
`storage.collection.vectors.on_disk`.

Defaults resolve as: request `memory` > request legacy flag > service
`memory` > service legacy flag; exactly one level is filled to avoid
spurious memory-vs-legacy mismatch warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Mark hnsw_index.on_disk deprecated in config.yaml, document memory option

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:43 +02:00
Andrey VasnetsovandClaude Opus 4.8 a66b898dfc docs: describe StartResharding fields in OpenAPI (#9946)
Add doc comments to `StartResharding` fields so the generated OpenAPI
spec explains what a user has to pass, and regenerate the spec.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:18:42 +02:00
Andrey VasnetsovandClaude Fable 5 8c11f488ac Clean up stale shard transfers when applying consensus snapshot (#9928)
A transfer source that misses the transfer abort (e.g. while partitioned
or paused) keeps its local shard wrapped in a proxy. When such a peer can
only catch up via consensus snapshot, snapshot application re-creates
payload indexes with an update operation that the stale forward proxy
forwards to a transfer target which may no longer have the shard. The
resulting precondition error fails snapshot application and stops the
consensus thread ("No target shard N found for update"), leaving the
peer unable to ever catch up.

Snapshot application now explicitly cleans up transfers that are no
longer registered in consensus: the transfer task is stopped and the
proxy is reverted via the new `ShardReplicaSet::discard_proxy_local`,
which is infallible, never contacts the remote, and forgets queued
updates (replica states in the same snapshot already reflect the
transfer outcome).

The consensus test reproduces the incident: pause the transfer source
mid-transfer, restart the other peers so the aborted transfer can only
be learned via snapshot, and verify the source recovers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:42 +02:00
Andrey VasnetsovandClaude Fable 5 21ed82fcc6 Batch IdTracker read operations, pipelined in disk-resident trackers (#9809)
* Add batch id-tracker lookups, pipelined in the disk-resident trackers

Introduce batch counterparts for the per-point IdTracker read operations
(external->internal resolution, internal->external lookup, version lookup,
deleted checks) and implement them with pipelined reads in the disk-resident
trackers, where the per-point path costs one storage round-trip per lookup:

- StoredBitSlice::get_bits_batch: dedupe the containing u64 elements and
  fetch them through one read_batch pass.
- DiskMappingReader::lookup_batch: group e2i keys by run block, read each
  unique block once, binary-search all its keys.
- DiskMappingReader::external_ids_batch: schedule all i2e data slots plus
  the deduplicated is_uuid bitmap bytes in one pass.
- DiskMappingsSource::{points_deleted_batch,resolve_internal_batch,
  resolve_external_batch}: shared batched resolution for both disk trackers.
- IdTrackerRead::{external_ids_batch,internal_versions_batch}: trait-level
  batch methods with loop defaults for in-RAM trackers; overridden (along
  with resolve_external_ids) in DiskIdTracker and ReadOnlyDiskIdTracker,
  and forwarded through both tracker enums so the overrides dispatch.

Wire the batch operations into the read paths: search result processing
(external ids + versions), scroll filtered_read_by_index (chunked), and
HasId condition conversion/cardinality estimation. retrieve() already goes
through resolve_external_ids and picks up the batched dispatch.

Batch read-by-id keeps the read-only tracker's laziness: no full
deleted-set materialization (covered by test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Rework batch id-tracker interfaces to iterator inputs and callback output

Review follow-up: the slice-in/Vecs-out shapes forced intermediate collects
at every call site, costing the in-RAM trackers (whose defaults are plain
loops) allocations the pre-batch code never had.

- resolve_external_ids now takes `impl IntoIterator<Item = PointIdType>`
  and delivers `(id, offset)` pairs through a callback, in input order:
  has_id conditions stream the id set straight into the offsets set/vec,
  retrieve fills its parallel vectors directly.
- external_ids_batch / internal_versions_batch take iterator inputs but
  keep `Vec<Option<_>>` outputs (search result processing needs one aligned
  slot per input); search drops its two offset collects, scroll passes the
  itertools chunk directly.
- In-RAM trait defaults stream with no intermediate allocation; the disk
  overrides buffer the input once internally, where the block-grouped
  reads need the whole batch anyway.

Also document on the writable DiskIdTracker why points_deleted_batch and
internal_versions_batch are deliberately not overridden there: deleted and
versions are RAM-resident by design, so the default loop has no IO to
pipeline; only the read-only tracker keeps them on disk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove accidentally committed local example

inspect_mutable_id_tracker.rs is a local debugging harness that was swept
into the previous commit by a directory-level git add; it is not part of
the batch-interface change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add PointIdBatch and thread it through resolve_external_ids

Replace the `impl IntoIterator` / `&[PointIdType]` inputs on the
id-tracker external->internal resolve path with a small `PointIdBatch`
trait (Copy + num_ids + iter_ids), implemented for `&[PointIdType]` and
`&AHashSet<PointIdType>`. Being re-iterable and length-known lets the
disk trackers batch the reads without first collecting the input.

On the disk path this drops several intermediate allocations:
- no input `Vec<PointIdType>` collect in resolve_external_ids_batch;
- lookup_batch returns compact `(id, offset)` pairs (id rebuilt from
  is_uuid + key) instead of a size-N `Option` reorder buffer;
- lookup_batch no longer groups keys by block up front: a block is one
  ~16 KiB DiskCache block, so same-block reads are deduplicated by the
  cache (piggybacked in flight, a plain hit once resident);
- resolve_internal_batch filters deleted pairs in place and feeds
  points_deleted_batch an offset iterator, dropping the offsets Vec.

Delivery is no longer input-ordered; the id travels with the offset into
the callback, so callers that need positions still have them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stream disk id resolve through a callback; prefetch deleted flags whole

Follow-up to the PointIdBatch rework, removing the last buffers from the
disk external->internal resolve path and dropping the redundant deleted
batching.

- `lookup_batch` and `resolve_internal_batch` now hand each resolved
  `(id, offset)` to a callback as its block completes instead of
  returning a `Vec`; `resolve_external_ids_batch` just forwards the
  caller's callback and logs on error. No buffer is built on the path.
- Drop `points_deleted_batch`: the deleted set is resident (writable
  tracker) or prefetched whole (read-only), so a per-point `point_deleted`
  check is as cheap as a pipelined read would be. `resolve_internal_batch`
  captures the first check error out of band and surfaces it after the
  pass.
- Prefetch the deleted flags whole (`Populate::PreferBackground`) in both
  `try_preopen` and `try_open`, via a shared `deleted_open_options`, so
  the per-point checks stay off remote storage.
- `PointIdBatch` loses the now-unused `num_ids`; only `iter_ids` remains.

Delivery is fully streamed, so on a mid-pass storage error the callbacks
for already-resolved live points have fired before the error surfaces —
acceptable at the best-effort `IdTrackerRead` boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Surface id resolve errors to the trait; drop redundant batch wrapper

`IdTrackerRead::resolve_external_ids` now returns `OperationResult<()>`
instead of swallowing storage errors. The disk path previously logged
and dropped a `resolve_internal_batch` error at this boundary; now it
propagates so callers see failed id resolution.

- The in-RAM default returns `Ok(())`; both enum forwarders and both disk
  overrides propagate. The three call sites (retrieve, HasId cardinality,
  HasId checker) are already in `OperationResult` functions, so they just
  gain a `?`.
- Drop the `resolve_external_ids_batch` free function: with its
  log-and-swallow gone it only forwarded to `resolve_internal_batch`, so
  the two overrides call that directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stream internal_versions_batch through a callback

Give internal_versions_batch the same treatment as resolve_external_ids:
it takes a callback and returns OperationResult<()> instead of a
Vec<Option<..>>, so the disk override no longer allocates. It walks the
input lazily (no collect), tags each pipelined read with its internal_id,
and streams (internal_id, version) to the callback as reads complete;
out-of-range offsets are skipped and a storage error propagates instead
of being logged and swallowed.

The in-RAM default and both enum forwarders match. process_search_result
now builds a small internal_id -> version map from the callback and looks
up by scored offset (handling duplicate offsets naturally), keeping the
same missing-version-is-an-error behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Stream external_ids_batch through a callback; drop chunked scroll batching

Align external_ids_batch with internal_versions_batch: iterator input,
(offset, id) callback output, no internal buffering, storage errors
propagated. The deleted filter runs inside the lazy range iterator, so
each tracker implements the override directly against its own deleted
source (resident bitvec vs prefetched on-disk file) and the redundant
DiskMappingsSource::resolve_external_batch pass-through is removed,
along with the now-unused log_lookup_err_batch.

filtered_read_by_index feeds the whole candidate iterator into one
batch pass and lets the IO layer pipeline the reads; the limit case
keeps the top-smallest ids in a bounded priority queue. This retires
ID_TRACKER_BATCH_SIZE. Search result processing pairs external ids by
internal id, mirroring the versions map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove accidentally committed local example, again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop unused StoredBitSlice::get_bits_batch

Its only caller, the read-only tracker's points_deleted_batch override,
was removed when the deleted flags became whole-file prefetched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Write deleted_open_options fields explicitly, comment the why

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Simplify read-only external_ids_batch error handling via try_filter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add infallible point_deleted helper to the writable disk tracker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop PointIdBatch in favor of impl IntoIterator<Item = PointIdType>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:41 +02:00
Andrey VasnetsovandClaude Fable 5 8828a92580 docs: fix collection metadata removal description (#9907)
Setting metadata to an empty object does not clear it: the update merges
key by key, so an empty object is a no-op (and over gRPC an empty map is
indistinguishable from an absent one). Per-key removal via null values is
the mechanism that was actually implemented and tested in #7123.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:41 +02:00
Andrey VasnetsovandClaude Fable 5 55cf6b7d04 Slice filtering condition: sliced scroll / deterministic sampling (#9899)
* feat: slice filtering condition for sliced scroll and deterministic sampling

Add a `slice` filter condition selecting points where
`stable_hash(point_id) % total == index`. The hash is SipHash-2-4 with a
zero key over canonical id bytes (8 LE bytes for numeric ids, 16 RFC 4122
bytes for UUIDs) — a frozen public contract, independent of the internal
resharding ring hash, reproducible by clients to predict membership.

For a fixed `total`, slices are disjoint and cover all points, enabling
parallel scroll streams (ES sliced-scroll style) and reproducible sampling
that composes with any other filter condition.

- REST: `{"slice": {"total": N, "index": R}}`; gRPC: `SliceCondition` in
  the condition oneof (tag 8)
- Evaluated per point via id_tracker external-id lookup; no payload index
  needed; cardinality estimated as `points / total` with no primary clause
- `total >= 1` enforced by NonZeroU32 at parse time, `index < total` by
  validation in both REST and gRPC paths
- Hash contract locked by test vectors independently reproduced with a
  reference SipHash-2-4 implementation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* tests: minimal OpenAPI test for slice filter condition

Scrolls all slices of a fixed total over numeric + UUID ids asserting
disjointness and full coverage, checks must_not inversion, and pins the
two rejection paths (422 for index >= total, 400 for total = 0). Requests
and responses are validated against the regenerated OpenAPI spec by the
test harness.

Note: the spec cannot itself reject total = 0 client-side — the Condition
anyOf falls through to the permissive Filter schema, as with any invalid
condition — so rejection is asserted via the server response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:41 +02:00
Andrey VasnetsovandClaude Fable 5 7ef503c863 Edge: request-specific structures for EdgeShardRead (#9901)
Replace the mixed read interface (internal types, explicit parameter
enumeration, ad-hoc custom types) with edge-owned request structs, one
file per request in src/requests/. Each has a new() constructor taking
only the required parameters, a no-macro fluent builder in src/builders/,
and a From conversion into the internal request type in
requests/conversions/, grouped by request type. Conversions construct
and destructure with full field lists, so a parameter added on either
side fails compilation instead of being silently dropped.

The old reexport aliases (ScrollRequest = ScrollRequestInternal, etc.)
are replaced by the edge types under the same names; retrieve() takes a
RetrieveRequest instead of a parameter triple. Python bindings wrap the
edge types, and the published Rust examples use the builders.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:41 +02:00
Andrey VasnetsovandClaude Fable 5 441fa01c24 refactor: group edge crate internals into edge_shard and read_view modules (#9898)
* refactor: group edge crate internals into edge_shard and read_view modules

Restructure lib/edge/src so the top level only contains the modules that
form the public crate surface. Implementation files move under the type
they implement:

- edge_shard/: the EdgeShard struct with its load/config-resolution
  helpers (previously inlined in lib.rs), plus optimize, shard_read,
  snapshots, and update
- read_view/: the EdgeShardRead/ReadSegmentHandle traits and
  EdgeReadView (previously read_view.rs), plus the per-operation impl
  files count, facet, grouping, info, matrix, query, retrieve, scroll,
  and search; build_search_pool (previously pool.rs) is folded into
  read_view/mod.rs next to par_map_segments, the seam it powers

lib.rs is now a thin facade of module declarations and re-exports. The
public API is unchanged: all previously exported names resolve exactly
as before, verified against the edge tests, the python bindings,
edge-shard-query, and the regenerated publish amalgamation with its
examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor: trim EdgeShardRead surface and split read_view/read_only modules

Trait surface:
- Drop query_scroll and rescore_with_formula from EdgeShardRead and the
  EdgeShard inherent wrappers; only the internal query pipeline used
  them, via the pub(crate) EdgeReadView methods that remain.
- Hide the snapshot plumbing (read_segments, search_pool, plus
  config_snapshot/path providers) in a crate-private ReadViewProvider
  trait. EdgeShardRead now declares only user-facing methods and is
  implemented for every provider through a blanket impl, so the
  plumbing is not callable from user code (verified with a negative
  compile test; a private supertrait alone leaves supertrait methods
  callable through generic bounds). An empty sealed marker supertrait
  keeps the trait unimplementable downstream.
- config_snapshot and path stay public: used by edge-shard-query and
  the python bindings.

Module layout:
- read_view/: mod.rs keeps EdgeReadView and build_search_pool;
  ReadSegmentHandle moves to handle.rs, the trait machinery to
  shard_read.rs, and the nine per-operation impl files into ops/.
- read_only/: the follower's ReadViewProvider impl moves out of mod.rs
  into shard_read.rs, mirroring edge_shard/shard_read.rs.

Verified: edge tests, python bindings, edge-shard-query, regenerated
publish amalgamation with all examples compiling and running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:18:40 +02:00
Andrey VasnetsovandClaude Fable 5 bd0048ea78 DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split (#9878)
* DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split

Move the is_uuid flags out of the i2e file into a separate
id_tracker.is_uuid file in the compact StoredBitmask format (#9871),
loaded whole into RAM as a RoaringBitmap on open and prefetched in
preopen, so slot decoding never reads the flag from disk. Bump the
on-disk format version to 2 (DiskIdTracker is unreleased; no migration).

Add StoredBitmask::read_ones() in common to normalize any stored
encoding into a bitmap of set positions, with logical_len validated
against the u32 position space at open.

Restructure disk_id_tracker: read_only.rs becomes read_only/{mod,
lifecycle,live_reload,id_tracker_read} mirroring the immutable tracker,
and reader.rs becomes reader/{mod,lifecycle,lookup,iter}.

Also unbox the DiskMappingsRef iterators (impl Iterator instead of
Box<dyn>), and make IdTrackerRead::iter_internal_versions fallible so
the read-only disk tracker propagates storage errors instead of
silently truncating; its implementation now reads the versions file in
one pass (cleanup-on-open drains it anyway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split disk_id_tracker mod.rs into lifecycle / read / write files

Mirror the read_only/ layout: mod.rs keeps the struct and resident-RAM
helpers, lifecycle.rs the build/open paths, id_tracker_read.rs the
DiskMappingsSource + IdTrackerRead impls, id_tracker.rs the mutable
IdTracker impl. Code moved verbatim; only imports and a field doc touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tighten disk_id_tracker docstrings around guarantees

State contracts (residency, laziness, error semantics, atomicity)
instead of narrating which structures hold or use what.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:04 +02:00
Andrey VasnetsovandClaude Fable 5 3e22d70fd0 Deduplicate plain and raw upsert/sync drivers via point traits (#9877)
* Deduplicate plain and raw upsert drivers via PointToUpsert trait

upsert_points and upsert_points_raw were ~60-line clones differing only
in how the point struct writes itself into a segment. Extract a private
PointToUpsert trait with the two variation points — upsert_into (in-place
write) and write_moved (CoW-move record transform) — implemented for
PointStructPersisted and PointStructRawPersisted, and fold the chunked
driver into a single generic upsert_points_impl.

The public functions keep their names and signatures as thin wrappers.
The duplicated upsert_with_payload/upsert_raw_with_payload tails collapse
into one shared set_full_or_clear_payload helper, which also carries the
single has_point debug assertion.

No behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deduplicate plain and raw sync drivers via PointToSync trait

sync_points and sync_points_raw were ~80-line clones of the same 5-step
algorithm, differing only in the retrieval call (retrieve vs retrieve_raw)
and the stored-record type compared against. Extend the upsert approach
with a PointToSync subtrait carrying an associated StoredRecord type,
retrieve_stored, and is_equal_to (delegating to the existing inherent
methods), and fold the drivers into a single generic sync_points_impl.

Step 5 calls upsert_points_impl directly; PointToUpsert and
upsert_points_impl become pub(super) to be visible within points/.
Public signatures unchanged.

No behavior changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:04 +02:00
Andrey VasnetsovandClaude Fable 5 7a26102061 Split shard update module into per-operation submodules (#9876)
lib/shard/src/update.rs grew to 2200+ lines. Split it into an update/
directory by operation kind, moving code verbatim:

- mod.rs: process_* dispatch entry points + re-exports (public API and
  crate-internal paths are unchanged)
- points/: upsert.rs (plain, conditional and raw), delete.rs (by id and
  by filter), sync.rs (plain and raw)
- vectors.rs, payload.rs, field_index.rs: per-kind apply functions
- helpers.rs: shared filter-based point selection (incl. the deferred
  points corner case) and check_unprocessed_points
- tests.rs: the test module, unchanged

Only additions are per-file imports, re-export lists and two
pub(super) visibility bumps for now-cross-module helpers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:04 +02:00
Andrey VasnetsovandClaude Fable 5 eafec09a1e Compact stored bitmask for on-disk field index deleted masks (#9871)
* Compact stored bitmask for on-disk field index deleted masks

Add StoredBitmask: a compact persisted bitmask written and read as a
whole. The payload is a roaring bitmap of whichever bit value is the
minority (mostly-0 and mostly-1 masks both stay tiny), falling back to
raw dense bits when roaring would not be smaller, so the file is never
larger than the dense representation. Files are replaced atomically via
UniversalWriteFileOps::atomic_save; there is no in-place mutation.

Use it for the write-once "no values" masks of the on-disk numeric,
geo, map and full-text indexes, replacing the raw dense bitslice files
sized at point_count/8 bytes regardless of content.

Writing the new format is gated by the compact_bitmask feature flag
(default off; enabled by `all` and `serverless_compatible`). Reading is
format-agnostic regardless of the flag: the compact deleted_mask.bin is
tried first, then the index-specific legacy file, so old segments stay
readable and flag-off builds produce byte-identical legacy files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split save_bitmask into named helpers

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Regenerate OpenAPI spec for compact_bitmask feature flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review findings on stored bitmask

- Avoid u64 overflow in the payload bound check when opening a mask
  with a corrupted payload_len.
- Reject roaring payload positions beyond logical_len at read time,
  enforcing the BitmaskContent range contract for corrupted files.
- Remove the opposite-format mask file after a successful save, so a
  rebuild with a flipped compact_bitmask flag can't leave a stale
  compact file shadowing the fresh legacy one (or an orphaned legacy
  file next to a compact one).
- Make the compact-open numeric test tolerate builds that already
  wrote the compact format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:03 +02:00
Andrey VasnetsovandClaude Fable 5 055bcd5c09 Add recoverable OutOfAppendableCapacity operation error (#9860)
Preparation for capping appendable segment growth in the update path
(#9158): a dedicated error for "all appendable segments reached
max_segment_size", so the update pipeline can recognize it and provision a
fresh appendable segment before re-applying the operation.

Maps to a transient service error at the collection level: if it ever
escapes recovery, failed-operation recovery re-applies the operation.

Part 1/5 of the appendable segment overflow fix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:03 +02:00
Andrey VasnetsovandClaude Opus 4.8 444ea9de7b Add usage skill file for edge-shard-query tool (#9836)
Standalone usage reference for `edge-shard-query`: backends (S3 / GCS /
uio-grpc), connection and tuning flags, the scroll / search / search-sparse
sub-commands, filtering, and the live-reload diff mode.

Written to stand on its own, so it can be shared as a link without also
sharing the source.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:17:02 +02:00
Andrey VasnetsovandClaude Fable 5 cd149ab649 Add search-sparse sub-command to edge-shard-query tool (#9834)
Sparse nearest-neighbour search over a ReadOnlyEdgeShard, reusing the
same SearchRequest path as dense search with VectorInternal::Sparse.
The query vector is accepted as a JSON object or an index:value pair
list, sorted and validated before use. --vector is required (no random
fallback: the sparse vocabulary is not recoverable from the shard
config), and --hnsw-ef is omitted since it does not apply to the
sparse index.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:02 +02:00
Andrey VasnetsovandClaude Fable 5 ebcf5241e6 Add optional block-index sidecar to accelerate on-disk binary searches (#9810)
* Add optional block-index sidecar to accelerate on-disk binary searches

Binary search directly over an unpopulated mmap costs O(log n) random
reads scattered across the whole file — one page fault (or remote range
read) per probe on high-latency storage. Introduce SortedBlockIndex: an
optional sidecar file storing the first element of every 16KiB block of
a sorted on-disk array, read whole into RAM at open. A lookup becomes an
in-RAM partition_point over the block firsts plus a single contiguous
read of one block, bisected in RAM.

Wire it into the three remaining scatter-probe sites:
- on-disk numeric index: binary_search_pairs over data.bin
- on-disk geo index: counts_of_hash over counts_per_hash.bin
- on-disk geo index: all_points start boundary over points_map.bin

The sidecar is backward and forward compatible: absent (old segments) or
failing validation, readers fall back to the existing plain binary
search; old code simply ignores the extra file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Validate block-index sidecar snapshot recovery and preopen coverage

- snapshot round-trip test (Regular + Streamable): the sidecars written by
  the on-disk numeric and geo indexes are collected via files() into the
  snapshot tar and restored byte-for-byte, with correct query results on
  the reloaded segment
- preopen tests for both indexes: after schedule_prefetch, open loads the
  sidecar from the CachedFs prefetch pool even when the file is unlinked
  from disk; an absent sidecar neither fails preopen nor open (fallback)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:02 +02:00
76f4f3e2ce Per-query IDF corpus for sparse vector search (#9661)
* Add per-query IDF corpus for sparse vector search

Let the caller choose, per query, which population sparse IDF statistics
are computed over. `params.idf` is either `"global"` (default, unchanged
behavior) or `{"corpus": <filter>}`, where the corpus filter is
independent of - and usually broader than - the retrieval filter.
Decoupling the two keeps the score scale stable when the retrieval
filter tightens: term importance is measured against a population the
user names, not against whatever subset the filter happens to select.

Design decisions:
- Corpus grammar is restricted to a conjunction (`must`) of `match`
  conditions on payload fields; loosening later is backward compatible.
- Strict mode validates the corpus filter like a read filter
  (unindexed fields rejected).
- `idf` on a vector without the IDF modifier is a validation error,
  never silently ignored.
- An empty corpus yields degenerate but corpus-scoped scores (smoothed
  IDF over N=0), never a fallback to global statistics - in multi-tenant
  collections a fallback would leak term statistics across tenants.

Implementation:
- QueryContext IDF stats are keyed by corpus, so one batch can mix
  requests with different corpora.
- Statistics come from the sparse index: df(term) is counted over the
  query terms' posting lists only, never by scanning stored vectors.
  Small corpora (under ~1/32 of the segment, by cardinality estimate)
  are kept as a sorted id list galloping through posting lists via
  skip_to; large ones as a dense membership mask filled streaming from
  the filtered-points iterator. A misestimated small corpus degrades
  into the mask.
- Exposed uniformly: REST (`params.idf`), gRPC (`IdfParams` message),
  edge python bindings; OpenAPI schema regenerated.

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

* Apply rustfmt

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

* Fix clippy manual_is_multiple_of in sparse IDF corpus test.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Allow any filter as IDF corpus

Drop the must+match grammar restriction on the corpus filter. A
restriction enforced only as a validation step over the full Filter
type buys nothing; if a narrower corpus syntax is ever wanted, it
should be a dedicated API-level type instead.

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

* Fix build: add memory field to SparseIndexConfig in idf corpus test

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:17:02 +02:00
Andrey VasnetsovandClaude Fable 5 a1c7449c02 Reset first_voter and prune address book on first-peer --reinit (#9785)
* Reset first_voter and prune address book on first-peer --reinit

A peer removed from consensus and killed after `RemoveNode(self)` was
committed but before it was applied keeps the old cluster's
`first_voter` and peer addresses in `raft_state.json`. First-peer
`--reinit` reset `conf_state` to a single voter (itself) but left both
untouched (`first_voter` is in fact never reset, even when the removal
is applied).

Both values are served to peers bootstrapping onto the reinitialized
cluster. A joining peer seeds its initial `conf_state` with the
advertised `first_voter`, and Raft conf-changes are deltas on top of
that base - so a stale `first_voter` permanently corrupts the joining
peer's voter set: it ends up with {old first peer, itself}, missing the
actual leader. Its `/readyz` then treats the still-alive old peer as a
cluster member and waits for the old cluster's commit index, which its
own consensus never reaches.

This only manifests when the reinitialized leader replicates its log as
plain entries (nothing applied before the kill, so the log anchor is
index 0). If the leader sends a snapshot instead, the snapshot's full
`conf_state` heals the corrupted seed - which is why
test_reinit_removed_peer only failed sporadically on CI.

Fix first-peer `--reinit` to behave like founding a fresh cluster:
reset `first_voter` to this peer (not `None`, or `recover_first_voter`
would re-derive the old first voter from the retained Raft log) and
prune `peer_address_by_id` to this peer only.

The kill-before-apply state is now injected deterministically into
test_reinit_removed_peer, reproducing the exact CI failure against the
unfixed binary. Since `--reinit` now prunes the address book, the
stale-address injection in
test_reinit_removed_peer_readyz_ignores_old_cluster moved to a restart
without `--reinit`, so it keeps exercising the /readyz `conf_state`
membership filter from #9688.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix test_reinit_consensus expecting stale address book after --reinit

The test waited for cluster size 2 right after starting the reinitialized
first peer, before the second peer was even started. That only passed
because first-peer --reinit used to keep the old cluster's addresses in
the address book - the stale state the previous commit removes. A
reinitialized first peer is a fresh single-member cluster; the other
peers re-join and re-register their new addresses right after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:02 +02:00
Andrey VasnetsovandClaude Fable 5 68395b4a9b [UIO] Request-specific load profile for read-only opens (#9797)
* Introduce request-specific LoadProfile with per-component placement

A read-only shard opened for one known request (the serverless cold-start
path) doesn't have to warm components the request will never touch.
LoadProfile captures that from the request: warm components keep the
persisted-config placement, everything else is parked cold. All placement
decisions live in one place, so the memory placement of a whole segment
under a profile is reviewable in one file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais

* Thread LoadProfile through the read-only segment open

ReadOnlySegment::open takes an optional profile; first_preopen and
open_via resolve it into per-component populate overrides so the opens
make the same placement decisions the prefetches did. Pinned components
that materialize on open regardless (quantized RAM storage kinds, the
immutable-RAM sparse index) and appendable components ignore the
override; the HNSW graph and immutable payload indexes demote fully.
Config reloads follow the new config alone and pass no override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais

* Open ReadOnlyEdgeShard under a request-derived load profile

ReadOnlyEdgeShard::open takes an optional LoadProfile, applies it to
every segment open and keeps it so segments discovered by a later
refresh load with the same placement. ScrollRequestInternal and
CoreSearchRequest gain load_profile() constructors, and edge-shard-query
builds the request before the open and passes its profile (opt out with
--no-load-profile).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais

* Demote pinned quantized vectors and sparse index under a cold profile

Within the immutable layout the quantized RAM and mmap loaders share the
on-disk format — only how the data is brought into memory differs — and
the immutable-RAM sparse index has the same lazy mmap open low-memory
mode already downgrades to. So a cold populate override now demotes the
effective placement itself (Memory::with_populate_override, shared with
the HNSW residency mapping) instead of only skipping cache priming: a
pinned quantized storage opens the mmap kind cold, and a pinned sparse
index opens as Mmap, so neither reads its data on a cold start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais

* Add LoadProfile::merge for composite queries

A composite query runs multiple core requests — e.g. a hybrid search
runs one core search per vector, each with its own filter. Its profile
is the union of its parts': merge extends the warm sets and ORs the
payload-storage flag, so a component either part needs warm stays warm.

The union is sound because every placement method is monotone in the
warm sets (growing them only turns "park cold" into "keep configured
placement"), so the merged profile dominates each input; and minimal,
warming nothing no part asked for.

Combine profiles with reduce, not fold: merge's identity element is
the coldest profile (empty warm sets), the opposite of passing no
profile at all — deliberately no empty()/Default constructor exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Adapt vanished-segment test to the profile-aware open signature

The test landed on dev (#9777) after the load-profile signature change
was written, so the rebase left its ReadOnlySegment::open calls without
the new load_profile argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Defer vector index open entirely under a cold load profile

A cold placement is not enough for the vector index on remote backends:
GraphLinksView requires the whole links file as one contiguous slice,
and the disk cache can only lend a borrowed slice once every block is
locally present — so even a Cold HNSW open mirrors the entire
links_compressed.bin (8.2 MB / 1.1 s per segment in the serverless
cold-start trace), plus the unconditional graph.bin metadata read.
The only way not to fetch the index is not to open it.

LoadProfile::vector_index_placement is replaced by
vector_index_deferred: a vector the request never scores now gets a
DeferredVectorIndex — a new VectorIndexReadEnum variant holding the
open arguments (an owned clone of the segment's raw backend, path,
config, shared component handles) and a OnceLock. Nothing is opened or
prefetched for it at segment open.

Per-method policy of the deferred variant:
- search, fill_idf_statistics and populate open the index on first use
  (with the cold placement the profile chose), so the profile contract
  holds: a request the profile did not predict still works, just pays
  the open then;
- is_index reports true without opening (deferral only ever wraps a
  real HNSW or sparse index; plain opens no files and is never
  deferred);
- telemetry, indexed_vector_count and sizes answer conservative
  defaults rather than trigger a remote fetch for a statistic.

Tests: deleting the vector_index directory before an open under a
scroll profile leaves open, filtered reads and payload reads working —
proof that nothing of the index is read — while a search surfaces the
missing files; and a segment opened under a scroll profile answers
searches identically to an eagerly opened one via the transparent
first-use open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make --vector optional in edge-shard-query: random query after open

Omitting --vector on the search sub-command now searches with a random
vector. The request is still built before the shard opens — the load
profile only needs the vector name, not its values — with an empty
placeholder; once the shard is open, fill_random_vector reads the
dimension of the queried vector from the derived shard config and
fills in uniform-random f32s (with a clear error if the named vector
is not in the config).

The vector is generated once, so live-reload iterations re-run the
identical random query and the printed diffs stay meaningful.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Scope index deferral to the HNSW graph via a lazy OnceLock load

Replace the DeferredVectorIndex wrapper (and the VectorIndexReadEnum::
Deferred variant) with deferral inside ReadOnlyHNSWIndex itself: the
graph lives in a OnceLock (same first-wins arbitration as
ReadOnlyRoaringFlags::bitmap) alongside the retained raw backend and
residency, and loads on first use with a cold placement. The config
read stays eager — one tiny, absence-tolerated file — so telemetry,
is_on_disk and indexed_vector_count report real values where the
Deferred arms answered with hard defaults.

The sparse index needs no deferral: its mmap open reads lazily, with
only small JSON metadata eager. A profile that never scores the vector
now passes a cold placement override (LoadProfile::
vector_index_placement) into the eager open_sparse, which demotes
ImmutableRam to the lazy Mmap open like low-memory mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Express HNSW graph deferral as a populate override, not a bool param

Replace the `deferred: bool` on the read-only HNSW open/preopen (and
the VectorIndexReadEnum pass-through) with the same
`populate_override: Option<Populate>` every other component takes. A
cold *override* defers the graph load — graph_deferred() mirrors the
cold-override match of open_sparse — while a config-derived cold
placement (or the low-memory clamp) keeps the eager load, since only a
request-specific override carries the "never scored" prediction.

With dense and sparse now consuming the same signal,
LoadProfile::vector_index_deferred is gone: a single
vector_index_placement() serves both index kinds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Serialize the deferred graph load via once_cell's get_or_try_init

Loading outside the lock (std OnceLock's fallible init is still
unstable) let a search burst on a deferred vector fetch the whole
graph once per thread. Swap the cell for once_cell::sync::OnceCell:
the fallible load runs inside the cell's lock, concurrent first users
block on the one load, and a failed load leaves the cell empty so the
next caller retries.

Addresses https://github.com/qdrant/qdrant/pull/9797#discussion_r3573727836

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 963995662c [UIO] implement VectorIndexReadEnum::preopen (#9782)
* [UIO] implement VectorIndexReadEnum::preopen

Schedule background prefetch of the HNSW graph (config, graph data,
links) and sparse index (config, inverted index, version, indices
tracker) files, wired into the segment's first_preopen for every dense
and sparse vector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Adapt index preopen to caller-controlled populate

Now that CachedFs respects the caller's populate: prefetch Cached HNSW
links with a background populate instead of the open's blocking one;
populate the immutable-RAM sparse index data (read in full on open)
while the mmap variant stays cold; apply the low-memory ImmutableRam
downgrade in preopen_sparse since it now changes populate behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Decide sparse index preopen populate from the index type

All readable sparse index variants share the compressed-mmap on-disk
format, so the datatype dispatch in preopen_sparse selected nothing.
Replace the per-TInvertedIndex preopen_ro trait machinery with one
populate-parameterized preopen in the sparse crate: the effective
index type alone decides whether the index data is warmed
(immutable-RAM reads it in full) or parked cold (mmap reads lazily).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive sparse index preopen populate from the segment config

The segment config's sparse_vector_data already carries the
SparseIndexConfig, so preopen_sparse doesn't need to read the persisted
copy at all: it derives populate from the segment-side index type and
merely schedules the config file for open_sparse to consume — still a
single fetch, without threading the parsed config through open_via.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resolve index preopen placement via memory_placement

The HNSW read-only open and preopen used the deprecated on_disk flag
directly; resolve the graph residency like the writable open instead —
memory parameter with on_disk fallback, clamped by low-memory mode,
including the pinned placement. The sparse index preopen likewise
derives its populate from the effective memory placement, so the
cached mmap index is prefetched warm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
e1a4f9867d Fix live-reload staleness of in-place-mutated files on caching backends (#9812)
* Mutate same-operation slots in place in append-only mode

With append_only_mutations enabled, every mutation of an existing point
clones it to a fresh internal id. Shard-level updates decompose one
point write into several SegmentEntry steps (upsert_with_payload issues
upsert_point plus set_full_payload/clear_payload), so a single upsert
burned one slot per step, leaving a chain of immediately-dead clones.

A slot whose version already equals op_num was written by an earlier
step of the current operation. It cannot be durable yet — the segment
write lock is held across the whole operation, so no flush (and no
read-only follower) can have observed it, and versions flush last so a
crash discards it and WAL replay re-applies the whole operation.
Mutate such slots in place: one operation now allocates exactly one
slot regardless of how many steps it decomposes into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stamp payload storage version in write_point_parts

overwrite_payload mutates payload storage, but the fused write never
bumped version_tracker's payload version — the old CoW path did, via
set_full_payload. The segment manifest would stamp payload files with a
stale version, letting a partial snapshot skip payload storage that
contains the moved point's row, so the restored id tracker would point
at an offset with no payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resync ReadOnlyRoaringFlags from disk on live-reload

The flags file is preallocated to power-of-two capacity and mutated in
place within its length, which the held handle's reopen() — an
append-only-growth contract on caching backends — never picks up: bits
the writer changes inside already-cached DiskCache blocks stayed stale
forever on shard followers.

Drop the `impl LiveReload for ReadOnlyRoaringFlags` altogether: this
storage holds arbitrary flags with no notion of points, so a point-delta
interface (deleted/new points) did not belong here — open never applied
deleted points either. Replace it with an inherent live_reload(fs) that
opens a fresh StoredBitSlice (a fresh open always mirrors the current
remote bytes), resyncs the materialized bitmap from it, and swaps the
handle. The on-disk flags are the sole source of truth.

To make refresh-by-fresh-open safe while the old handle is still alive,
every DiskCache open now mirrors into a uniquely-named local file
(.{pid}-{counter} suffix) and removes it on drop. Mirrors were already
truncated on every open (block validity is in-memory only), so the
stable name carried no state.

Also add trace logging for live-reload consumed mapping changes and
pending deltas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resync immutable id tracker deleted bitmap via fresh handle on live-reload

The immutable tracker's id_tracker.deleted file is a fixed-size bitmap
whose bits the writer flips in place — its length never changes — so the
held handle's reopen(), an append-only-growth contract on caching
backends, never picked it up: the pre-deletion state cached at open was
served forever and live-reload never reported deletions on shard
followers.

live_reload now takes the fs, opens a fresh StoredBitSlice (a fresh open
always mirrors the current remote bytes), diffs it against the tracker's
current state — mappings already reflect every previously reported
deletion, so they are the baseline — and swaps the handle in.
ReadOnlyIdTrackerEnum and the segment-level reload pass the fs through;
the appendable and disk-resident variants keep their no-arg reloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Resync disk id tracker deleted bitmap via fresh handle on live-reload

Same staleness as the immutable tracker: the deleted file is a
fixed-size bitmap mutated in place, so reopen() — append-only-growth on
caching backends — served the pre-deletion state forever.

live_reload now takes the fs, opens a fresh StoredBitSlice, and swaps
it into deleted_file, so the per-point get_bit lookups read fresh state
from then on too. The deleted_full take/diff/set baseline logic is
unchanged. The enum's DiskResident arm passes the fs through.

The regression test now covers both trackers over DiskCacheFs; the disk
leg was verified to fail under the old reopen-based behavior. The disk
tracker's versions file staleness is a separate, still-open case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Introduce header-stateless ReadOnlyTracker for gridstore live-reload

The gridstore tracker file is preallocated and mutated in place (header
rewrites, slot updates), which the reader's held handle never picked up
on caching backends: Tracker::live_reload read the header through the
stale handle — before reopen(), and reopen() would not have refreshed
cached blocks anyway — concluded "no new pointers", and never reloaded
pages. New points' payloads read as empty on shard followers.

Replace the reader's tracker with a dedicated ReadOnlyTracker that holds
nothing but the storage handle: reads don't need header state (slot
addressing is positional, unwritten slots read as None in the
zero-initialized file) and readers have no pending-updates buffer. Its
live_reload opens a fresh handle and swaps it in. Tracker::live_reload
is deleted.

A new TrackerRead trait (max_point_offset/get/iter) is implemented by
both the writable Tracker and ReadOnlyTracker, and GridstoreView is
generic over it, so writer and reader share the read logic.

max_point_offset reads the stored header count as plain data through the
current handle (fresh as of the last reload) rather than deriving it
from slot capacity: sparse vector storage builds total_vector_count from
it, and the capacity of the preallocated file (1MB -> 65536 slots) would
inflate every follower segment's sparse count. The method is now
fallible; consumers propagate the error.

GridstoreReader::live_reload refreshes tracker and pages unconditionally
— the tracker carries no reliable cheap change signal. Making this
incremental again is a deferred follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Re-open last held chunk on chunked-vectors live-reload

Chunk files are preallocated to full size, so appended vectors are
in-place writes within the existing file length. On caching backends a
held handle keeps serving blocks fetched earlier — and a block fetched
near the old tail extends into then-unwritten space — so vectors
appended into that block read back as stale bytes after a reload.

Mirror Pages::live_reload: on a len change (the status file is read
through a fresh open every reload, so it stays a reliable gate), drop
and re-open the last held chunk — the only one that can have gained
vectors — alongside adopting newly created chunk files. Earlier,
fully-filled chunks keep their handles; their contents cannot change.

Covers dense, multi-dense, and quantized-chunked storages, which all
delegate to ChunkedVectorsRead::live_reload. Avoiding the whole-chunk
refetch per reload is a deferred follow-up, together with the analogous
gridstore pages/tracker case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix lint: fs_err::create_dir_all in tests, drop unnecessary cast

CI clippy runs with --all-targets and disallows std::fs methods in
favor of fs_err; the new live-reload regression tests used
std::fs::create_dir_all directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Tombstone-only deletes on non-appendable segments in append-only mode

The tombstone-only delete path existed but was gated on
is_append_only(), which requires the segment to be appendable. Deletes
landing on non-appendable segments — notably the CoW update deleting the
point's old copy — still went through delete_point_internal, which
clears the payload row in place before the id-tracker drop.

Clearing the payload destroys committed state of an offset that stays
visible to live-reload followers until the drop is flushed: a follower
refreshing in that window resolves the point through its (unchanged)
id-tracker view and reads an empty payload — observed as a one-refresh
{} payload flicker on a point update. Writer-side flush ordering cannot
close the window, since the follower samples the id tracker and the
payload storage at different instants; the versions commit protocol
protects inserts exactly because the marker is read first, and deletes
have no marker. This is the same failure class for which vector-deletion
propagation was disabled (see the comment in delete_point_internal).

Gate deletes on the new is_append_only_delete() — append_only_mutations
alone, no appendability requirement: tombstoning needs nothing from the
segment but the id tracker. Normal deployments keep eager payload
clearing and prompt space reuse; clone-based mutations keep requiring
appendability via is_append_only().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: chunked vectors reload

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Daniel Boros <dancixx@gmail.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 43a2f898ea tests: stop uploaders cleanly before consistency check in WAL delta tests (#9817)
The end-of-test teardown killed the uploader processes and slept for one
second before scrolling all peers for the consistency check. Killing the
client does not cancel an already-sent upsert server-side: on slow CI the
last PUT can take longer than the sleep, so its replication is still
propagating while the peers are scrolled at slightly different times,
making the scrolls diverge by the last batch of points.

Observed in test_shard_wal_delta_transfer_abort_and_retry: peer 0 was
scrolled at 19.089s, received the forwarded batch at 19.179s, while
peer 1 (which had already applied it locally) was scrolled at 19.267s.

Use stop_update_process() (introduced in #8713 for the pre-peer-kill
case) so the uploader exits between requests. Since uploads use
wait=true, once the last PUT returns all active replicas have applied
it, and the scrolls can no longer race.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
f201849fac [UIO] implement ReadOnlyQuantizedVectors::preopen (#9781)
* [UIO] implement ReadOnlyQuantizedVectors::preopen

Schedule background prefetch of the quantization config, per-method
metadata, quantized data and multivector offsets, wired into the
segment's first_preopen for every dense vector with quantization
configured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* additional stage for quantized config preopen

* better quantized preopening

* Partially populate the first quantized vector in preopen

The load reads the first vector off quantized.data (and off the first
chunk in the chunked layout) to validate the stored vector size — on a
cold open that was a round-trip. Use Populate::Partial (#9769) to
prefetch exactly that prefix; the exact size comes from the quantized
config the preopen already read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restore the layout-probing quantized preopen

Recovers the pre-rewrite design: preopen never reads the quantization
config — it probes both layout candidates against the listing snapshot
(a segment only contains the layout its real config selects), derives
warmth from the segment-side quantization config (placement resolution
incl. cached and low-memory), iterates storage types exhaustively for
layout coverage, and schedules the config for open to parse once off
the parked handle — no threading, no second preopen stage.

Keeps the first-vector partial populate, with the size now derived
from the segment config via construct_vector_parameters, and keeps
VectorStorageType::is_pinned. Restores the full preopen test matrix
and OneshotFile::preopen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review fixes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-08-04 11:17:01 +02:00
52c5d3ca5d Fix use-after-free in u8-quantized vector reads on owning storages (#9801)
* Fix use-after-free in u8-quantized vector reads on owning storages

EncodedVectorsU8::get_vec_ptr extracted a raw pointer from the buffer
returned by EncodedStorage::get_vector_data and dropped the buffer
before the pointer was dereferenced. For storages returning
Cow::Borrowed (mmap) this happened to be sound, but for storages that
return Cow::Owned (disk-cache misses, uring backends) every user of
get_vec_ptr read freed memory: the internal SIMD scorers,
encode_internal_vector, and get_quantized_vector_offset_and_code, which
exported the dangling buffer through a safe &[u8].

Make parse_vec_data return the code as a slice borrowing the input, so
the borrow checker forces callers to keep the storage buffer alive
while reading it, and drop get_vec_ptr entirely. This matches how
encoded_vectors_binary already binds the buffers at its scoring sites.
Change get_quantized_vector_offset_and_code to return the code as a
sub-view of the buffer Cow itself, and adapt its GPU caller.

The new regression test drives these paths through a storage that
always returns owned buffers; under Miri it reproduces the
use-after-free on the previous code and passes with this fix.

Fixes #9799

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* additional assertion

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 abc97939f8 Resolve vanished segments against the manifest in follower refresh (#9792)
Third step of the ReadOnlyEdgeShard not-found handling (after #9763 and
#9777): shard-level resolution of live-reload failures, per the
live-reload design requirements.

refresh() is now a bounded retry loop over single-manifest-snapshot
attempts. Within an attempt every survivor live-reloads even if one
fails (they are independent); failures split by classification:

- Not-found: resolved against a re-read manifest. Gone from the
  manifest means the leader removed the segment mid-reload — drop it
  and re-run the attempt to pick up its replacements immediately.
  Still listed means essential files are genuinely missing — escalate.
- Anything else: escalate after reloading the other survivors,
  replacing the previous warn-and-swallow that could hide a corrupted
  segment forever. Safe: a failed segment keeps serving its
  pre-refresh state and pending_reload replays the unapplied delta on
  the next refresh.

If attempts run out (leader churning segments continuously) refresh
logs a warning and returns Ok: every swap was atomic, so the shard is
consistent, just possibly not the newest; the next refresh continues.

open_with_enumerator now reuses the refresh machinery: empty holder +
refresh() + the open-only merge_follower_config overlay, removing the
duplicated load/derive logic. At open there are no survivors, so open
behavior is unchanged (#9762 already made it tolerant of unloadable
segments via the superset-biased manifest contract).

Load-side failures stay as settled by #9762: unloadable listed
segments are skipped and retried on every refresh.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 921f5a512b Allocate a single slot per operation in append-only mutations (#9804)
* Fuse CoW point move into a single destination write

The CoW arm of apply_points_with_conditional_move wrote a moved point in
three SegmentEntry steps: upsert_point_raw, update_vectors,
set_full_payload. With append_only_mutations enabled every step clones
the whole point to a fresh internal id, so one moved point burned three
slots (the first holding an empty point for plain upserts, which clear
raw_vectors) and left two immediately-dead clones behind, tripling the
id-tracker changelog and vector writes.

Add SegmentEntry::upsert_moved_point, which writes raw vectors, the
decoded overlay, and the payload in one operation — allocating exactly
one slot — and use it on the CoW move path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Mutate same-operation slots in place in append-only mode

With append_only_mutations enabled, every mutation of an existing point
clones it to a fresh internal id. Shard-level updates decompose one
point write into several SegmentEntry steps (upsert_with_payload issues
upsert_point plus set_full_payload/clear_payload), so a single upsert
burned one slot per step, leaving a chain of immediately-dead clones.

A slot whose version already equals op_num was written by an earlier
step of the current operation. It cannot be durable yet — the segment
write lock is held across the whole operation, so no flush (and no
read-only follower) can have observed it, and versions flush last so a
crash discards it and WAL replay re-applies the whole operation.
Mutate such slots in place: one operation now allocates exactly one
slot regardless of how many steps it decomposes into.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Stamp payload storage version in write_point_parts

overwrite_payload mutates payload storage, but the fused write never
bumped version_tracker's payload version — the old CoW path did, via
set_full_payload. The segment manifest would stamp payload files with a
stale version, letting a partial snapshot skip payload storage that
contains the moved point's row, so the restored id tracker would point
at an offset with no payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 902e57fec8 Bump client timeout in flaky snapshots-consensus e2e test (#9808)
TestSnapshotsInterferenceWithConsensus flakes when the initial
create_collection exceeds the 10s client read timeout. Cluster logs
from a failing run show the primary stalling its consensus loop for
~5s while creating local shards on a loaded runner, which triggered
a leader election that delayed full shard activation to ~10.3s.

Setup operations now get a 30s client timeout. The regression check
for #7489 is unaffected: it relies on the server-side operation
timeout passed to delete_collection, not the client read timeout.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 6a86160f27 Fix flaky disk exhaustion in edge-test CI job (#9807)
The publish/ directory is a separate Cargo workspace, so it does not
inherit the root workspace's [profile.dev] debug = "line-tables-only"
override. Example binaries were built with full debug info, each
statically linking qdrant-edge at ~700 MB per binary. Linking several
of them concurrently ran the runner out of disk, surfacing as
"mold: failed to write to an output file. Disk full?" + SIGBUS.

Mirror the debug info override in the publish workspace and free
~20 GB of unused preinstalled software on Linux runners as headroom.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
bbc2694954 Add optional last-modified timestamp to ListedFile and CachedFs FileInfo (#9803)
* Add optional last-modified timestamp to ListedFile and CachedFs FileInfo

Filled where the listing backend exposes one: local filesystems
(entry metadata) and object stores (ObjectMeta::last_modified). The
uio-grpc backend reports None since the RPC does not carry mtimes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Carry last-modified over the StorageRead ListFiles RPC

Extend ListFilesEntry with an optional google.protobuf.Timestamp, fill
it on the server from the listing metadata, and convert it back to
SystemTime in the uio-grpc client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update lib/common/io_bridge_object_store/src/source.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix missing SystemTime import in io_bridge_object_store

The CodeRabbit-suggested last_modified mapping used SystemTime::from
without importing std::time::SystemTime, breaking compile and CI.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Retrigger CI after flaky integration-tests-consensus timeout

The compile fix is in; the prior run failed on an unrelated 30s timeout
in test_collection_recovery, not on PR changes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 b074c18b59 Stamp payload storage version on append-only clone paths (#9806)
clone_and_mutate_point and clone_and_replace_point_raw rewrite the
point's payload row at a fresh internal id, mutating payload storage —
but never bumped version_tracker's payload version. Payload operations
bump it at the caller, so their clones were stamped; vectors-only
operations (upsert_point, upsert_point_raw, update_vectors,
delete_vector) never stamp payload, leaving the manifest with a stale
payload version. A partial snapshot could then skip payload storage
files that changed, and the restored id tracker would point at offsets
with no payload row.

Stamp inside the clone helpers, where the mutation happens. The payload
entry points move their bump into the in-place closure so the clone arm
is not double-bumped: a same-version double bump collapses the tracked
version to None, degrading the stamp to the segment version.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 b80a10ecfd Add uio-grpc backend and key-triggered live-reload to edge-shard-query (#9795)
* Add uio-grpc backend and key-triggered live-reload to edge-shard-query

--backend uio-grpc opens the shard directly over a running Qdrant
peer's StorageRead gRPC service (public gRPC endpoint, api-key aware),
addressed by --collection/--shard-id — no object storage involved. The
UioGrpcSource backend existed since #9634 but was never wired into the
tool's CLI. --bucket is now per-backend optional (required for
aws/gcs), and the default cache dir is scoped by collection/shard for
uio-grpc, whose mirror has no distinguishing key prefix.

--live-reload-key runs the same watch loop as --live-reload with each
reload triggered by pressing Enter instead of a timer — easier when
stepping through a debug scenario. Timer mode is unchanged; the two
flags are mutually exclusive. Closed stdin ends the loop gracefully,
so the flag cannot busy-loop on piped input (and is documented as
incompatible with @- stdin request arguments).

Verified end-to-end against a live instance started with
QDRANT__FEATURE_FLAGS__WRITE_SEGMENT_MANIFEST=true: scroll over
uio-grpc returns all points with payloads, timer mode picks up an
upsert + delete as +/- diff lines, and key mode fires one reload per
Enter and exits cleanly on EOF.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Log StorageRead not-found gRPC failures at debug level

A read-only follower routinely probes files the writer creates lazily
(e.g. the mutable id tracker's mappings and versions before the first
flush), so every uio-grpc follower poll spammed INFO logs like:

  gRPC /qdrant.StorageRead/FileLength failed with NotFound
  "File not found: .../mutable_id_tracker.versions"

NotFound on /qdrant.StorageRead/* now logs at debug; NotFound on all
other services (missing collection etc.) stays at info. The service is
matched by parsing the path's service component and comparing it to
the tonic-generated storage_read_server::SERVICE_NAME constant rather
than a hard-coded path string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 e426241a7b Add --live-reload watch mode to edge-shard-query (#9791)
With --live-reload <SECONDS> the tool keeps running after the first
answer: every interval it refreshes the ReadOnlyEdgeShard from object
storage, re-runs the same request, and prints the difference against
the previous results — `+` id appeared, `-` id disappeared,
`~ old -> new` for changed content (payload/vector/score/version).
Each cycle prints a summary line; pure reordering prints nothing.

The request is parsed once into a PreparedRequest (ScrollRequest /
SearchRequest are Clone), so every iteration re-runs the identical
request and the filter/vector parsing and logging no longer repeat.
The first run prints the full result set in the existing format.

A failed refresh is logged and retried next tick — the shard keeps
serving its previous state, so a transiently unreachable bucket does
not kill the watch loop. Status lines go to stderr via log, diff rows
to stdout, keeping stdout machine-consumable.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:01 +02:00
Andrey VasnetsovandClaude Fable 5 9e6892e272 Preserve not-found classification on read-only reload/open paths (#9777)
Follow-up to #9763: an audit of the component live-reload and read-only
open paths found four places where a not-found error lost its structured
classification before reaching OperationError::FileNotFound:

- MmapFile::reopen opened the file with a bare `?`, producing an
  unstructured Io(NotFound) — and this is the central call on the local
  follower's live-reload path, so no mid-reload segment removal would
  ever have classified on the mmap backend. Wrapped with
  extract_not_found.
- BlockCacheFs::open and CachedSlice::reopen had the same bare-`?`
  pattern over CachedSlice::open's io::Result. Both wrapped.
- ReadOnlySegment::open_via hand-rolled a service_error for a missing
  version file. The version file is written last, so its absence is
  exactly the vanished-mid-open signal; now FileNotFound { path }.
- ReadOnlyRoaringFlags::read_status_len masked NotFound internally, so
  live_reload silently kept a stale len if the status file vanished
  mid-reload. The raw reader now propagates; open masks it explicitly
  with ok_not_found() (works on OperationResult since #9763), and
  live_reload requires the file.

Everything else audited came back correct: lazily-created files
(mutable id tracker, mutable-index gridstore) keep masking absence,
optional-component probes at open keep ok_not_found, and the
object-store bridge already maps missing objects to structured
NotFound.

New test vanished_segment_classifies_not_found pins both legs
end-to-end: opening a missing segment directory and live-reloading a
segment whose directory was removed both classify via is_not_found().

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
1bd3ee9b98 [UIO] implement VectorStorageReadEnum::preopen (#9780)
* [UIO] implement VectorStorageReadEnum::preopen

Schedule background prefetch of every file the read-only vector
storages open, wired into the segment's first_preopen between the id
tracker and the payload indexes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* polish PR

- fully populate deleted bitslices
- don't use `.ok_not_found()`
- section comments

* don't overwrite `populate` in `CachedFs`

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Fable 5 048a04bb64 Fix payload index inconsistency after restart: flush-before-build (alternative to #9737) (#9767)
* Make payload index creation durably self-contained (alternative to #9737)

Flush the segment (serialized with the flush pipeline) before building a
field index, and flush the built index before saving the config, so the
synchronously written config never durably outruns the state the build
observed or the index data it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive wipe_field_dirs candidates from PayloadIndexType exhaustively

A hardcoded directory list silently misses new index types. Map each
PayloadIndexType variant to its storage directory in an exhaustive match
and iterate the enum, so a new variant does not compile until its
directory is declared and is then wiped on rebuilds automatically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Flush built index in build phase, not under the segment write lock

apply_index runs under the segment write lock, and flushing a freshly
built index there blocks reads for the duration of the flush. Move the
flush into Segment::build_field_index, which runs under the upgradable
read lock: searches keep flowing, durability ordering is unchanged
(index data still becomes durable before the config lists it), and the
not-yet-installed storages cannot overlap any captured flusher pass.

Also validate the incompatible index type switch (keyword -> full-text)
across a simulated crash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Fable 5 363a8fec38 Fix /readyz false-positive on freshly bootstrapped peer (#9774)
A bootstrapping peer seeds `conf_state` with just the first voter of the
cluster (see `Consensus::init`) until it applies the configuration change
entries of the Raft log. Since #9688, `member_peer_addresses()` filters
known peer addresses by `conf_state` membership, so during that catch-up
window the health checker saw a single member, took the single-node
short-circuit in `cluster_commit_index()`, and latched /readyz to ready
before the peer reached the cluster commit index.

Only apply the `conf_state` filter when this peer is a member itself.
This keeps the #9688 behavior for reinitialized peers (whose conf_state
is reset to the peer itself) and genuine single-node clusters, while a
catching-up peer falls back to all known peer addresses and properly
waits for the cluster commit index.

Fixes flaky test_replace_running_peer_without_shards_same_uri, where
the test queried collection state right after /readyz passed, before
the peer applied the CreateCollection entry.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Fable 5 e65a5c9ca5 Classify structured not-found errors as OperationError::FileNotFound (#9763)
Read-only edge-shard followers need to distinguish "essential segment file
missing because the leader removed the segment mid-reload" (re-check the
manifest, absorb) from real corruption (escalate). The classification
already exists at the universal-io layer (UniversalIoError::NotFound,
mmap MissingFile, both carrying the path), but died at the OperationError
boundary where everything collapsed into ServiceError strings.

- Add OperationError::FileNotFound { path } and route the structured
  sources into it: UniversalIoError::NotFound, MmapError::MissingFile,
  and GridstoreError::UniversalIo(NotFound) (the route payload-storage
  live-reload errors take).
- Implement IsNotFound for OperationError so follower code can classify
  (and OperationResult::ok_not_found() works where lazily-created files
  are legitimately absent).
- Raw io::Error NotFound intentionally stays ServiceError: it has no
  structured path; universal-io wraps not-found at the call site via
  extract_not_found, so classification belongs at the source.
- CollectionError maps FileNotFound like a service error, keeping
  external API behavior unchanged.

Groundwork for not-found handling in ReadOnlyEdgeShard live-reload.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Opus 4.8 7e5bb25814 Lazy roaring flags bitmap and bool index counts (#9749)
* [AI] make ReadOnlyRoaringFlags bitmap and bool index counts lazy

Opening a read-only segment scanned every flags file end to end:
`ReadOnlyRoaringFlags::open` materialized the whole RoaringBitmap via
`iter_ones()`. Every payload field carries a null index, so this was paid
per field per segment, for bitmaps most queries never touch.

Make the bitmap a `OnceLock`, filled by a scan on first access. Open now
reads only the tiny status file. `ReadOnlyBoolIndex`'s three eager count
fields collapse into one lazily-derived, cached `BoolCounts`; its
`live_reload` refreshes them in place when present and leaves them unset
otherwise, so reloading an index nothing queries stays scan-free.

Propagate the resulting `OperationResult` through `RoaringFlagsRead`,
`PayloadFieldIndexRead::count_indexed_points`, `FieldIndexRead`,
`PayloadIndexRead::{indexed_points, get_telemetry_data}`, `build_info` /
`build_telemetry` and `SegmentEntry::{info, get_telemetry_data}`, out
into shard, edge and collection.

`ram_usage_bytes` stays infallible: an unmaterialized bitmap holds no
RAM, so it reports 0 via the new `bitmap_if_materialized`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] correct `preopen` comment: `open` no longer scans the flags file

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [AI] fix edge examples for fallible `info()`

`EdgeShardRead::info` now returns `OperationResult<ShardInfo>`. The
examples live in their own workspace (lib/edge/publish), so the main
`cargo check --workspace` never saw them.

Every call site sits in `fn main() -> Result<(), Box<dyn Error>>`, so
propagate with `?`. `bm25-search` compiled either way but would have
printed the `Result` rather than the `ShardInfo`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Fable 5 d094e795f3 test(consensus): de-flake replicate_points_stream_transfer_updates override case (#9755)
* test(consensus): de-flake replicate_points_stream_transfer_updates override case

With override_points=True the background writer re-upserts points
9990-9999, re-rolling their city payload. Points flipping away from
"London" legitimately drop out of the filtered count on both shards, so
asserting dest_filtered_count >= original snapshot count is not a valid
invariant. On a slow CI runner the sleep(1)+kill() stopped the writer
right after the overrides, before new inserts could compensate, making
a net-negative flip likely (observed: 4954 >= 4959 failure).

Replace the blind kill with a bounded workload (60 points) joined
cleanly before the ~10s transfer of 10k points can finish, assert the
writer exit code, and allow the filtered count to drop by up to the
number of overridden points.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(consensus): guard that writer finishes while transfer is running

The exact count consistency check requires every concurrent write to go
through the transfer proxy. Make that precondition explicit: if the
transfer ever finishes before the writer, fail with a clear message
instead of a confusing count mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(consensus): make replicate_points update consistency checks exact

Assign the city payload deterministically by point ID parity so filter
membership can never change under concurrent overwrites. All assertions
become exact ID-set comparisons with no slack: random city re-rolls made
count-based checks unsound, since the forward proxy filters forwarded
updates by post-update state and a point flipping out of the filter
legitimately goes stale or missing on the destination.

Replace the background writer process (sleep/kill/join choreography)
with synchronous wait=true upserts issued while the transfer streams the
initial points. Leave low point IDs unoccupied and insert into them
during the transfer: the stream cursor passes them immediately, so these
points can only reach the destination through live update forwarding,
which the previous layout (writes at the stream tail) never verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:17:00 +02:00
Andrey VasnetsovandClaude Fable 5 a976a92eb4 ci: build single wheel on dev push, test all stable Pythons on release (#9751)
Building the full qdrant-edge wheel matrix (15 build jobs + 8 test jobs
across Linux/macOS/Windows, glibc/musl, CPython/PyPy) on every push to
dev is too expensive. Dev pushes now build only the most popular flavor:
x86_64 manylinux_2_17 CPython. Thanks to abi3-py310 that single wheel
covers every CPython >= 3.10, and it is tested on the latest stable
Python (3.14) on ubuntu-latest.

The full platform matrix still runs on manual workflow_dispatch
releases, and their test matrix is extended from {3.10, pypy3.11} to
all stable CPythons (3.10-3.14) plus PyPy on all four OS runners.

Since macOS/Windows builds are skipped on push, edge-py-test and
merge-artifacts switch to !cancelled()-based conditions so they are not
skip-propagated.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Opus 4.8 05dc7409ca [AI] Read SegmentState and PayloadConfig once per read-only segment open (#9746)
`first_preopen` and `open_via` each parsed `segment_state.json`, and
`ReadOnlyStructPayloadIndex::{preopen, open}` each parsed the payload
index `config.json`. Thread the configs the preopen pass already read
into the open pass instead, and factor the duplicated
`PayloadStorageType -> Populate` match into a helper.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Opus 4.8 cf19e6bb0a [AI] implement ReadOnlyRoaringFlags::preopen (#9747)
Schedule background prefetch of the status and flags files that
`ReadOnlyRoaringFlags::open` reads, probing existence through the status
file the same way `open` does.

Wire it up through the two roaring-flag backed leaves — bool and null
index — whose arms in `ReadOnlyFieldIndex::preopen` were hardcoded
`false` placeholders. Every field index carries a null index alongside
it, so this puts the flags on the live segment-open prefetch path.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Opus 4.8 e87c603b00 perf(universal-io): serve known file length from CachedFs snapshot on plain open (#9741)
`CachedFs::cache_file_info` already snapshots every file's size from the
`list_files` result, but that size was only threaded into opens on the
prefetch path (`schedule_prefetch` -> `with_known_len`). Files opened
directly through the fallback `open` — e.g. segments the loader opens
lazily rather than prefetching — forwarded the caller's `OpenExtra`
unchanged, so `known_len` was `None` and the backend later issued a
remote `len`/HEAD to size the file.

Thread the snapshot size into the fallback open too: when a snapshot
exists and lists the path, apply `with_known_len(info.size)`. This lets
`DiskCacheFs::open` go straight to `State::Ready`, eliminating a remote
metadata round-trip per lazily-opened file on blob backends (S3). No-op
when no snapshot was taken or for backends where `with_known_len` is
meaningless.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Opus 4.8 ce3b1ceed8 debug logging of the s3 connection (#9597)
* debug logging of the s3 connection

* refactor(io_bridge): make S3 latency logs opt-in and low-overhead

Move the blob-backend latency traces onto a dedicated `io_bridge::latency`
log target at `trace` level, so they are silent by default and can be
toggled as one group at runtime without a rebuild (e.g.
`RUST_LOG=io_bridge::latency=trace`). Guard the timing `Instant::now()`
behind `log_enabled!` so there is no overhead when the target is disabled.

Also add `list_files` timing, and switch the shard_query CLI logger to
millisecond timestamp resolution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Opus 4.8 ca0beb9bff feat(edge): add --search-threads to shard_query tool (#9736)
Add a `--search-threads` option to the edge-shard-query tool so the
number of threads in the shard's search thread pool can be specified.
When set, it builds an EdgeConfig with `max_search_threads` and passes
it to `ReadOnlyEdgeShard::open`, overriding the CPU-derived default used
for both parallel segment reads at open and running searches.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
Andrey Vasnetsov 7ad57559a8 fix memory enum openapi (#9735)
* make sure Memory object is generated as enum in OpenAPI

* fmt
2026-08-04 11:16:59 +02:00
Andrey VasnetsovandClaude Fable 5 7cc755c225 Bound request snapshots in the slow requests log (#9726)
* Bound request snapshots in the slow requests log

Entries in the slow requests log retain a full serde_json::Value snapshot
of the (internal) shard request indefinitely. The distance matrix API
internally generates a batch of `sample` queries, each carrying a has_id
filter with all `sample` sampled ids, so a single log entry ballooned to
sample^2 ids expanded into a JSON tree (~90 bytes/id): ~90MB per entry for
sample=1000, ~2GB for sample=5000. Random sample ids give every request a
fresh content hash, so each call added a new entry until the 32-slot
queue filled — OOM long before that for larger samples.

Truncate all arrays in logged request bodies to 64 elements plus an
omission marker. Query batches are serialized per element up to the cap,
so the full untruncated JSON tree is never materialized even transiently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix rustfmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:59 +02:00
428e196f7d CachedReadFs: prefetch-backed read-only segment opens (#9712)
* Add CachedReadFs: prefetch-backed read-only universal-io filesystem

Snapshots the file listing at construction and serves opens from
explicitly prefetched handles (take-once, shared across clones via
Arc<Mutex>). A non-prefetched open falls back to a direct open on the
inner filesystem, panicking in debug builds and warning in release.

CachedFile is a transparent wrapper needed to satisfy the bidirectional
UniversalReadFs<File = Self> pinning, following the ReadOnly pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Serve read-only segment opens from CachedReadFs prefetch pool

ReadOnlySegment::open builds a per-segment CachedReadFs: the files known
in advance (version.info, segment.json) are scheduled before the listing
snapshot is taken so their fetch overlaps the listing round-trip, then
every remaining listed file is scheduled, running all fetches in parallel
instead of serializing them inside component opens. Existence checks and
format-detection probes are answered from the snapshot without touching
the inner filesystem.

Stored handles are taken out of the pool via the new
CachedReadFs::take_file, which returns the raw inner file — component
types stay over plain S, and CachedFile exists only transiently inside
open-read-discard helpers (read_json_via etc.) through the trait impl.
The read-only open path takes &CachedReadFs<S::Fs> concretely; storing
wrappers gained from-file constructors (StoredBitSlice::from_file,
UniversalHashMap::from_file, ReadOnly::from_file, gridstore
Tracker::open_cached / Pages::open_cached, read_chunks_cached).

Snapshot-less, CachedReadFs is a passthrough to the inner filesystem —
used by reload paths, writable build paths that reuse the on-disk index
opens, and tests, all of which keep their previous behavior. Components
that retain a filesystem for later reloads store the raw inner backend
(CachedReadFs::inner), never the stale snapshot.

Also: local_list_files now recurses into subdirectories, matching the
flat key-prefix semantics of object-store listings; the immutable id
tracker probes its defining file via exists (free on the snapshot)
instead of a probe-open that would consume the take-once handle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Group imports per nightly rustfmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix sparse search bench for open_ro over CachedReadFs

CI clippy runs --all-targets; the bench target was missed by the
--tests sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Match listing prefixes by path component, not by string

The recursive local_list_files compared whole path strings; on Windows a
joined prefix mixes `/` and `\` (`shard\index/chunk_`) while walked
entry paths use `\` throughout, so nothing ever matched (broke
list_files_returns_paths_relative_to_shard_dir on Windows CI).

Match the entry name at the prefix's final position against the
prefix's final component instead, then walk matched directories
exhaustively — same semantics, separator-agnostic. Apply the same
component-based matching to the CachedReadFs snapshot filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* do not cache everything

* fmt

* dont unwrap files_info

* fix clippy

* relax debug assertion for now

* [AI] refactor into extension trait, relax Fs<->File requirement (#9725)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-08-04 11:16:58 +02:00
ad3abf7e45 Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components

Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:

- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure

The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.

New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
  `GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
  gain a `cached` tier (mmap + populate on open)

`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix gpu-gated tests for the new `memory` field

CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean

- OpenAPI tests: create/update collections with `memory` on every component,
  assert the parameters are echoed in collection info, assert legacy-only
  collections expose no new fields, and assert `pinned` is rejected (422)
  for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
  `sparse_index_config.json` instead of the legacy-resolved placement, so
  configurations using only the deprecated `on_disk` flag keep byte-identical
  files that older Qdrant versions load without unknown fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Validate collection meta ops at construction, not only in the API layer

The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.

Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.

`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 11:16:58 +02:00
f5c5beb3ce Resolve filter-based update operations to point ids before WAL write (#9678)
* Resolve filter-based update operations to point ids before WAL write

Filter/condition-resolving operations (delete-by-filter, conditional
upsert, the *-by-filter payload/vector operations) stored their filter
in the WAL and re-resolved it against live segment state on every
apply. Replay-time state can differ from the original apply-time state
(the optimizer drops deleted points and their version records during
compaction), so WAL replay was not a deterministic function of the log
and could resurrect filter-deleted points.

Resolve such operations into concrete point ids at submit time, under a
fence that guarantees the resolution sees exactly the operations that
precede it in WAL order. The WAL now only ever contains id-based
operations (pre-existing variants only — no format change), so replay
applies the exact same point set as the original run.

Fixes #9575

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW

* Fix rustfmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW

* Drop coordinator-side resolution: every replica resolves locally

Replicas holding the same data resolve the same filter to the same point
set, and replicas that already diverged would not become consistent by
agreeing on a filter's resolution. Forward the original filter operation
as usual and let each replica's submit fallback resolve it under its own
fence — one uniform path regardless of where the update lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW

* Guard against is_filter_resolving / resolve_operation drift

A resolved operation must never still classify as filter-resolving,
otherwise a filter-carrying record could reach the WAL again (#9575).
Catch one direction of drift between the gate and the rewriter with a
debug assertion right after resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Dedup points-vs-filter precedence into resolve_points_or_filter

The "explicit id list wins over the filter" rule was written twice on
the resolver side (DeletePayload arm and resolve_set_payload); a future
tweak landing in one copy only would make SetPayload and DeletePayload
silently diverge in what gets persisted to the WAL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Assert rewritten WAL record reuses the incoming clock tag

The single-record-reuses-the-tag property is what WAL-delta recovery
and replica dedup rely on, but no test asserted it: submit the
delete-by-filter with a real clock tag and check the resolved
DeletePoints record carries it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Test replay of old-style filter records left in the WAL

Upgraded nodes can still hold WALs with unresolved filter operations;
the by-filter apply paths are kept so they replay one final time with
the old semantics. No test covered that path (the new submit flow can
no longer produce such WALs), so append a raw DeletePointsByFilter
record at the WAL layer, reload, and assert the matched points are
gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add consensus test for per-replica filter-op resolution

Exercises the replicated path for filter/condition-resolving updates:
the coordinator forwards the original filter op and each replica
resolves it locally (delete-by-filter, insert-only and update-filter
conditional upserts, set-payload-by-filter, including per-shard empty
resolutions on a 2-shard collection). Asserts both replicas hold
identical state (reads prefer the local replica), then restarts the
whole cluster and asserts each replica replays its id-based WAL to the
same state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Arnaud Gourlay <arnaud.gourlay@gmail.com>
2026-08-04 11:16:58 +02:00
Andrey VasnetsovandClaude Fable 5 2bb287d693 Make EdgeConfig tunables optional with a layered fallback chain (#9714)
Tunable EdgeConfig parameters (on_disk_payload, hnsw_config, optimizers)
are now Option, and every tunable resolves through the fallback chain
provided -> persisted -> derived from segments -> default when loading
an existing shard. Leaving a parameter unspecified keeps the shard as it
is; an explicit value overwrites it and existing segments converge to it
through the optimizers.

vectors/sparse_vectors are excluded from overwrite semantics: an empty
map inherits the persisted/segment-derived definitions, a non-empty map
is validated for compatibility against the loaded segments (size,
distance, multivector, datatype, sparse modifier) and fails the load on
mismatch.

The derived layer folds over all segments in UUID order instead of
taking an arbitrary first segment, so a plain appendable segment (which
carries no HNSW parameters) can never mask an indexed segment's actual
build parameters. Previously a lost edge_config.json could resolve
unspecified HNSW params to compiled-in defaults and silently trigger a
full re-index via ConfigMismatchOptimizer.

The read-only follower accepts an optional config on open: provided
tunables are applied once over the segment-derived config (vectors
always come from the segments), and refresh re-derives from segments
alone.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:58 +02:00
Andrey VasnetsovandClaude Fable 5 30f00477c6 Add disk-resident id tracker (#9657)
* Add disk-resident id tracker

Introduce a disk-resident id tracker family that keeps the point-id mapping
on disk instead of loading it into RAM, so resident memory no longer scales
with total point count. This removes the last component of a segment that
forces a full RAM load, enabling object-storage / edge followers and cutting
RAM for regular deployments that opt in.

New on-disk mapping format (`disk_id_tracker/on_disk_format.rs`): random-access
`id_tracker.i2e` (internal->external) + `id_tracker.e2i` (external->internal as
sorted num/uuid runs with a resident sparse block index). `id_tracker.versions`
and `id_tracker.deleted` are reused unchanged.

Trackers (sharing a lazy `DiskMappingReader` core and a `DiskMappingsSource`
trait):
- `DiskIdTracker` — writable, deletion-only; a new `IdTrackerEnum` variant used
  by regular segments, keeping deleted+versions resident and the mapping on disk.
- `ReadOnlyDiskIdTracker` — read-only live-reload mirror for followers; per-point
  `get_bit` deletion checks on read-by-id, full deleted set materialized lazily.

Selection: created when the `serverless_compatible` feature flag is set (in
`segment_builder`); loaded by attempting each format in `ReadOnlyIdTrackerEnum::
detect_and_load` (no per-file `exists` round-trips) and by file-presence
detection unified in `IdTrackerFormat`.

`PointMappingsRefEnum` is generic over the read backend so the disk variant is a
concrete `DiskMappingsRef` (no trait object). The `DiskMappingsSource` read
surface returns `OperationResult`; errors are only swallowed at the infallible
`IdTrackerRead` boundary. `ImmutableIdTracker` is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: apply rustfmt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix clippy: collapse nested if in DiskIdTracker::drop

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align on-disk id tracker headers and sections to 16 bytes

Pad the i2e header to 32 bytes and the e2i header to 48 bytes, and
zero-pad between e2i sections so every section starts on a 16-byte
boundary. This keeps the files mmap+transmute-friendly: the u128 arrays
(i2e slots, e2i uuid sparse index) need 16-byte alignment in Rust.

Add on_disk_sections_are_aligned test pinning the invariant and the
store/parse padding agreement via exact file-length checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Fable 5 fd86a18815 Fix /readyz of reinitialized peer waiting on a foreign consensus (#9688)
Applying `RemoveNode(self)` prunes all other peers from the removed
peer's persisted address book, but the process may be stopped after the
removal is committed and before the entry is applied. The old first
peer's address then survives `--reinit`, and the readiness checker -
which treated every `peer_address_by_id` entry as a cluster member -
would wait for the reinitialized peer to reach the *old* cluster's
commit index: a foreign consensus it can never catch up with, so
`/readyz` never passed.

Filter the address book by current `conf_state` membership instead,
falling back to all known addresses while `conf_state` is still empty
(a bootstrapping node that has not applied any configuration change
yet). After `--reinit` the `conf_state` is reset to a single voter, so
the readiness check correctly ignores peers of the old cluster.

Fixes flaky `test_reinit_removed_peer`, which hit this race when the
removed peer was killed before applying its own `RemoveNode`. The new
regression test simulates that state and fails with the exact CI error
without the fix.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Fable 5 2573869577 Add prefix matching option to keyword index (#9683)
* Add prefix matching option to keyword index

Introduce an opt-in `prefix` option for the keyword payload index and a
new `match: { "prefix": ... }` filter condition, enabling efficient
byte-wise prefix filtering over keyword values (e.g. URL prefixes,
web-ui value autocompletion via facet + prefix filter).

Index side: a new `prefix_index.bin` file stores a sorted, front-coded
key dictionary with a resident block index (cumulative counts per
block); it is an ordered view over the keys of `values_to_points.bin`
and stores no postings. Presence of the file signals prefix support at
load time, so legacy segments load unchanged and enabling the option
goes through the standard incompatible-schema rebuild. The mutable
variant keeps an in-RAM ordered key set (not persisted), the immutable
variant builds a sorted key vector at load, and the on-disk variant
reads the dictionary lazily (block index resident, 1-2 block reads per
prefix lookup; reader is generic over UniversalRead).

Query side: prefix conditions are served from the dictionary when
available (filter + cardinality estimation from per-block aggregates),
from the forward index as per-point checks, and degrade to the payload
full-scan fallback otherwise - same execution model as other match
conditions. Strict mode (`unindexed_filtering_*`) rejects prefix
queries on fields without a prefix-enabled keyword index via a new
KeywordPrefix capability.

HNSW payload blocks: prefix-enabled indexes additionally emit prefix
blocks for heavy branching trie nodes (single-child chains collapsed to
their longest common prefix, one block per distinct point set, emitted
largest-first) so filtered search with prefix conditions gets navigable
subgraphs without rebuilding the same subset repeatedly.

API: `prefix` flag on KeywordIndexParams (REST bool, gRPC empty
message for extensibility), `prefix` variant in the Match oneof, edge
python bindings, regenerated OpenAPI spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split prefix index into a dedicated module, fix clippy in tests

Reorganize the flat prefix_index.rs / prefix_read.rs into a
map_index/prefix_index/ module: format.rs (on-disk layout primitives),
writer.rs, reader.rs (PrefixIndex), map_read.rs (StrMapIndexPrefixRead
with per-variant impls) and tests.rs, with a file-format diagram and a
read-path walkthrough in the module docs. No logic changes.

Also fix clippy --all-targets complaints in test code: replace a
wildcard Match arm with an exhaustive list and a field-reassign-with-
default with a struct literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add OpenAPI test for prefix match and snapshot file-tracking test

- tests/openapi/test_prefix_match.py: index-less fallback, prefix index
  creation with schema echo, scroll/count parity against ground truth,
  facet + prefix filter (the autocompletion flow), strict-mode rejection
  without the prefix capability.
- test_prefix_index_file_tracking: `prefix_index.bin` is listed in
  `files()` / `immutable_files()` exactly when built with the option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Replace hand-rolled varint parsing with bytemuck Pod records

Per review: the prefix index format now uses fixed-size little-endian
Pod records (BlockEntry 24 B, KeyEntry 12 B, Header 40 B) written with
bytemuck::bytes_of and read back by copy via pod_read_unaligned — no
manual varint encode/decode, no alignment requirement, one shared
read_record helper. Costs ~9 bytes per key on disk versus LEB128; the
raw key bytes dominate dictionary size, so the simplification wins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fetch the whole candidate block range with a single storage read

Candidate key blocks of a prefix lookup are contiguous in the file, so
enumerate them from one ranged read instead of one read per block; the
over-read versus the exact key range is bounded by the two boundary
blocks. Block decoding is split into a storage-free helper reused by
the per-block path of stats estimation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Align prefix payload blocks with the geo index granularity principle

Geo's large_hashes emits only the smallest geohash regions above the
threshold — a disjoint antichain, never a parent nested with its
children. Prefix payload blocks now follow the same rule: a heavy
collapsed trie node is emitted only if nothing heavy is nested inside
it, counting both deeper qualifying prefixes and single heavy values
(which already get their own exact-match blocks). Emitted blocks are
therefore mutually disjoint and disjoint from exact-value blocks; no
near-collection-sized ancestor subgraphs, no reliance on the HNSW
connectivity check to skip nested duplicates.

Implemented as a `covered` flag propagated through the existing
LCP-interval scan, still one O(total key bytes) pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Document block wire format and unaligned-read rationale in decode_block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Fable 5 e784102f78 Fix flaky count check in snapshot transfer missing-point test (#9685)
Killing the background load processes does not cancel requests already
executing server-side: a wait=true upsert accepted just before the kill
can still be propagating to the second replica while the test counts
points, so peers transiently observe different totals (e.g. [20120,
20118, 20118] on CI). Poll the exact counts until they converge instead
of asserting on the first sample.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Fable 5 b185c46435 [UIO] Split UniversalReadFileOps into read and write traits (#9682)
UniversalReadFileOps mixed read-side operations (from_context,
list_files, exists) with mutating ones (create, create_dir, remove,
remove_dir, atomic_save). Move the mutating operations to a new
UniversalWriteFileOps subtrait, mirroring UniversalRead/UniversalWrite.

- UniversalWrite now requires Fs: UniversalWriteFileOps, so generic
  consumers (gridstore) keep reaching write ops through S::Fs.
- ReadOnlyFs drops its runtime-erroring write stubs: read-only is now
  a compile-time property.
- DiskCacheFs implements the write side only when the remote fs does.
- BlobFs and the async AsyncRead trait are read-only: the async write
  methods are removed from AsyncRead and its backends (object store,
  uio-grpc) along with the tests that exercised them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
11ef80468b Explicit HNSW links residency: cold / cached / pinned (#9669)
* Explicit HNSW links residency: cold / cached / pinned

Introduce GraphLinksResidency to make graph links memory residency an
explicit choice instead of a side effect of the IO backend:

- Cold: mmap without populate, pages fault in on demand (on_disk: true)
- Cached: mmap with blocking populate, evictable (on_disk: false)
- Pinned: materialized into anonymous heap, page cache evicted after
  the read; kept internal for now (no production caller selects it),
  non-borrowable universal-IO backends (io_uring, object stores) fall
  back to it by necessity

Fixes along the way:

- Freshly built non-on_disk indexes no longer pin links in heap: the
  builder now always serializes to disk and re-loads as mmap
  (Cold/Cached by on_disk), so a just-built index has the same
  single-copy residency as one loaded after restart, instead of a heap
  copy plus the freshly written file in page cache.
- Materializing fallbacks evict the page cache after copying to heap
  (same hygiene as read_whole_via), so links are never resident twice.
- Memory reporter now reports heap-materialized links as RAM with
  files as persistence-only, instead of claiming page-cache intent for
  data that never touches the page cache (previously such links were
  invisible: 0 RAM, 0 cached, full size as "expected cache").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update lib/segment/src/index/hnsw_index/graph_links/links.rs

Co-authored-by: Tim Visée <tim+github@visee.me>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Tim Visée <tim+github@visee.me>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Fable 5 20e099d233 Trace-log 'Missing request message.' internal gRPC errors (#9663)
Since the tonic 0.14 upgrade (hyper 1.x), a client cancelling a unary
call between HEADERS and DATA surfaces as Internal "Missing request
message." instead of Cancelled, because hyper hides stream resets
during request body read (hyperium/hyper#3681). Cluster read fan-out
generates these routinely, flooding logs with spurious ERROR lines.
Log them at trace level, matching the existing Cancelled handling.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00
Andrey VasnetsovandClaude Opus 4.8 2c8a18571b Fix reinit failing with "removed all voters" on a removed peer (#9654)
* Fix reinit failing with "removed all voters" on a removed peer

When `--reinit` is run on the first peer, `conf_state` is reset to a
single voter (this peer), but the Raft log is left untouched. If this
peer had been removed from consensus before reinit, its log still holds
a committed-but-unapplied `RemoveNode(self)` conf-change. On startup that
entry is replayed on top of the freshly reset single-voter config, and
Raft aborts with "removed all voters", so the node can never start.

Resetting only the apply-progress queue is not enough: a fresh
single-node leader re-commits and re-applies any log entries still
physically present beyond `commit`, re-triggering the failure. The stale
tail has to be physically dropped from the WAL.

On the first-peer reinit path, discard committed-but-unapplied entries
inherited from the previous cluster: truncate the WAL to the last applied
index, pin `commit` to it and clear the apply-progress queue. The entry
at `commit` is retained as the snapshot anchor, so the first peer can
still serve snapshots to bootstrapping peers.

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

* Add regression test for reinit of a removed peer

Starts a 2-node cluster, gracefully removes the second peer from consensus
(so it commits `RemoveNode(self)` into its own WAL), then reinitializes it
as a fresh first peer. Before the fix this panicked on startup with
"removed all voters"; the test asserts the peer comes back online, elects
itself leader, and can still seed a fresh bootstrapping peer.

Verified the test fails against the pre-fix binary with exactly:
  Failed to apply configuration change entry
  Caused by: Error in Raft consensus: removed all voters

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-04 11:16:57 +02:00