Commit Graph
2327 Commits
Author SHA1 Message Date
Tim Visée 9f01222ccf Integrate new bitflags structure (#10123)
* Add `FlagsMode::from_feature_flags`, the mode for newly created flags

Compact in serverless-compatible deployments, dynamic otherwise. Only
creation consults it; opening existing flags detects their mode from
disk.

* Support the compact mode in the read-only flags types

Add `ReadOnlyFlags`, the mode-dispatching union of the two read-only
counterparts, serving the shared `RoaringFlagsRead` surface. Teach
`InMemoryBitvecFlags` to detect the mode it opens; its compact
`reload_appended` decodes the whole (small) file, as the format has no
random access.

* Create flags through mode selection in storages and indexes

Vector storage deleted flags and the bool/null indexes now open through
`open_or_create` with the mode from the feature flags: serverless
deployments create compact flags, dedicated ones keep creating dynamic
flags, and existing flags are opened in their detected mode either way.

* Read flags in either mode in the read-only bool and null indexes

`ReadOnlyFlags` shares the `RoaringFlagsRead` surface and the lifecycle
signatures of the roaring type it replaces, so the swap is a type
rename.

* Add TODO to not lock bitmask structure during flush

* `MutableStoredBitmask::save` returns the number of bytes written

Zero when the skip-clean save wrote nothing. Lets wrappers charge the
actual write to a hardware counter.

* Refuse to open compact flags in a dynamic-mode directory

Creating the compact file next to dynamic files would leave a directory
of both modes behind, which every later open rejects — refuse up front
instead. Both production callers already rule the case out through
`FlagsMode::detect`, so this only removes a foot-gun for future callers.

The open-or-eagerly-create logic moves into
`open_or_create_compact_mask`, shared with the update-only writer next.

* Rewrite `UpdateOnlyStoredFlags` onto the compact bitmask

The update-only flags writer now writes the compact mode — a single
roaring-encoded `compact_flags.dat` through `MutableStoredBitmask` —
instead of rewriting the whole padded dynamic file pair every batch. A
flush with no effective changes now writes nothing at all, where the old
writer rewrote the full mask on any `set`.

This also fixes opening serverless-created segments: the old open
eagerly wrote a `status.dat` into directories the writable side had
created in the compact mode, leaving files of both modes behind and
poisoning the directory for every later open.

A directory already holding dynamic-mode flags is refused loudly rather
than kept current or migrated; rebuild the segment to migrate its flags.
Migration may come later.

Drops the now-dead `InMemoryBitvecFlags::into_bitvec` and
`DynamicFlagsStatus::new`, and demotes `file_size_for` to private.

* Run edge tests with serverless feature flags

The edge fixtures ran with default feature flags, building leader shards
with dynamic-mode flags — a configuration edge never serves in
production, and one the update-only flags writer now refuses. It also
hid that the writer poisoned compact directories: no test exercised
update-only writes over a serverless-created shard.

Feature flags are process-global and first-init-wins, so every fixture
in the binary initializes the same serverless set; the manifest test
folds into it, since serverless implies `write_segment_manifest`.

* Don't use sequencial mode for one shot reads
2026-08-18 17:51:21 +02:00
Tim Visée 542c576dd5 Add segment level type for serverless bitflags (#10121)
* Add `CompactStoredFlags`, segment wrapper over the mutable bitmask

RAM-resident flags with a Flusher (skips the write when clean, cancels after drop) and files lister, backed by one compact stored-bitmask file rewritten whole on flush. Not integrated yet.

* Add `FlagsMode`, detecting the storage mode of a flags directory

`Dynamic` is the existing mmap stack for dedicated deployments, `Compact`
the compact stored-bitmask file for serverless ones; detection probes
which files are present. Also add the clippy allow the compact flags
tests were missing.

* Support the compact storage mode in `BitvecFlags` and `RoaringFlags`

The wrappers keep their in-memory read state in both modes; the new
`FlagsStorage` dispatches the write side between `BufferedDynamicFlags`
and `CompactStoredFlags`. `open_or_create` opens existing flags in their
detected mode and only applies `mode_if_create` to fresh ones — existing
call sites keep constructing the dynamic stack through `new`.

* Add `ReadOnlyCompactFlags`, read-only counterpart of compact flags

Bound to `UniversalRead`: opens on the bitmask header alone,
materializes the bitmap lazily on first query, and never creates a
missing file. Implements `RoaringFlagsRead` for the shared query
surface; `live_reload` reopens a fresh handle, as flushes replace the
file whole but cached handles keep serving the bytes they were opened
on. Not integrated yet.

* Skip compact live-reload tests on Windows, which forbids the rename

Both tests replace the compact flags file behind a reader whose disk
cache keeps the "remote" file mapped. On Unix the rename-over succeeds
and the mapping serves the old inode — the staleness under test — but
Windows forbids renaming over a mapped file, failing the writer's flush
with access denied. A limitation of the local-mmap remote stand-in, not
of the reload logic, which stays covered on the other targets.

* Don't check legacy flag file
2026-08-18 16:13:37 +02:00
xzfc 74dd4b71e3 Integrate batched HNSW (#10194)
* [14] HnswGraph: wrapper enum over in-RAM and batched graphs

* [15] HnswGraph: route async backends to the batched graph

* [15.a] De-tautologize `test_open_matrix`

Anti-pattern: `expect_batched` mirrors `format_is_batched` logic.

* [16] HNSW healing: reopen as direct

* [17] Add async_hnsw_graph feature flag

* Batch size
2026-08-18 12:24:16 +00:00
Luis Cossío 57e7389f91 [LiveReload] Prepare segment preload (#10221)
* genericize live_reload fs parameters

* impl live_preload for ReadOnlySegment

* split edge refresh into preload and apply passes

* only rotate file infos after successful reload
2026-08-17 21:43:24 -04:00
dependabot[bot] 3732be6961 build(deps): bump charabia from 0.9.9 to 0.10.0 (#10247)
Bumps [charabia](https://github.com/meilisearch/charabia) from 0.9.9 to 0.10.0.
- [Release notes](https://github.com/meilisearch/charabia/releases)
- [Commits](https://github.com/meilisearch/charabia/compare/v0.9.9...v0.10.0)

---
updated-dependencies:
- dependency-name: charabia
  dependency-version: 0.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-17 22:21:57 +02:00
Yash Singh 09b2da8512 fix(segment): propagate mmap read errors from numeric index check_values_any (#10181)
The on-disk numeric index ended check_values_any with .unwrap_or(false),
so a real mmap read error was downgraded to "point does not match" and a
filtered search/scroll could silently return an incomplete result set.

Promote NumericIndexRead::check_values_any to OperationResult<bool> and
propagate the error through the dispatcher and the range condition checker,
which already returns OperationResult<bool>. Resolves the two FIXMEs in
numeric_index_read.rs and storage/read_ops.rs.
2026-08-17 15:41:58 +02:00
David Dallakyan 85b1fc01c6 docs(segment): fix leftover mmap_* rustdoc after on_disk rename (#10232)
Point geo OnDiskGeoIndex at on_disk_geo_index and payload storage at
PayloadStorageImpl. The mmap_* modules were renamed and no longer exist.
2026-08-17 13:27:08 +02:00
Andrey VasnetsovandClaude Fable 5 e32d3fbf89 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-08-17 10:47:20 +02:00
Andrey VasnetsovandClaude Fable 5 5f8cef9ebd [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-08-14 20:06:00 +02:00
Arnaud GourlayandClaude Fable 5 00b17c3e74 Replace sys-info dependency with already-present sysinfo (#10216)
The app telemetry was the only user of the sys-info crate, while segment
already depends on sysinfo for cgroup-aware memory accounting. Read the
distribution id/version via sysinfo statics, and the disk size fallback
via common::disk_usage, so the whole sys-info crate (and its bundled C
sources) drops out of the build. sysinfo is hoisted to a workspace
dependency, shared by the root crate and segment.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:09:39 +02:00
Andrey VasnetsovandClaude Fable 5 06ffcb881f 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-08-13 18:26:49 +02:00
Ivan PleshkovandClaude Opus 5 86b9330628 transfer: send raw payloads, behind feature flags (#10066)
A raw point can carry its payload as the byte blob it is stored as, mirroring
`PointStructRaw.raw_payload` on the internal gRPC API. The blob travels from the
sending node into the receiving node's WAL untouched, so the sender never parses
the payload it read and neither node builds a protobuf value tree for it.

It is parsed exactly once, where the operation is unpacked for apply
(`process_point_operation`), because that is the first place the parsed form is
actually needed: `set_full_payload` goes through the payload index, which cannot
be updated from bytes. The gRPC boundary therefore only checks the encoding tag
and rejects a point that sets both payload fields, the way the enclosing request
already rejects both `points` and `raw_points`.

Moving the parse onto the apply path makes its error classification load-bearing,
so a malformed blob is reported as `OperationError::MalformedPayloadBlob` — the
payload sibling of `MalformedVectorBlob`, mapped to `CollectionError::BadInput`
for the same reason: a bad blob that reached the WAL has to be skipped on replay
instead of crash-looping recovery.

Three consequences of the blob living that long are handled explicitly rather
than by convention:

- `decode_payload_raw` takes the blob only once it has parsed, so a failure
  leaves the point holding it instead of holding neither representation.
- `upsert_points_raw` and `sync_points_raw` refuse a point that still carries a
  blob. They read the parsed payload, so such a point would otherwise be stored
  with no payload at all, and a `debug_assert!` would not catch it in release.
- `is_equal_to` compares blob to stored blob as bytes. A differing encoding costs
  a redundant upsert on sync, never a skipped one.

The `raw_payload_transfer` bench measures the trade, per 100-point batch (one
transfer batch) at payloads of ~200 B / ~700 B / ~7 KB:

- Sender, storage bytes to wire: 16x / 37x / 113x faster. This is where the whole
  win is — no parse of the blob that was read, no value tree built.
- WAL encode: 5x / 11x / 25x faster, writing a byte string instead of a map.
- Receiver, wire to applicable point: 1.09x / 1.10x / 1.06x. Near neutral, as it
  swaps walking a prost value tree for a JSON parse.
- Wire bytes: ~6% smaller. WAL bytes: 10-32% *larger*, because the blob is JSON
  while a parsed payload is written as a compact CBOR map.

The WAL growth is accepted rather than fixed: decoding earlier to win those bytes
back costs a second full deserialization, and would leave the receiving side with
a `payload_raw` that is never populated. Making the blob itself compact belongs in
the payload storage encoding (`RawPayloadEncoding` is the extension point for it),
not here.

Two flags, both off by default and both sender-only (nodes accept raw points and
raw payloads regardless), read where the transfer batch is prepared:

- `transfer_raw_points` transfers every collection as raw points, not only those
  whose vector storage would drift in a decode-encode round-trip.
- `transfer_raw_payloads` ships the blob a raw read hands out; without it the
  prepared batch decodes it back into the parsed payload, and the wire message is
  exactly what it is today.

Neither is enabled by `all`: a node only accepts them once it runs a version that
understands them, so they can only be switched on a release later. Nothing
enforces that yet — the transfer has no peer-version gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:28:50 +02:00
xzfc b23ffbfc15 Batched HNSW: search implementation (#10010)
* [12] GraphLayersBatched: batched-IO HNSW search

* [12.a] Batched ACORN, simplify
2026-08-13 01:46:08 +00:00
Andrey VasnetsovandClaude Fable 5 7aed0c751f 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-08-13 00:10:09 +02:00
Yash Singh d31ef8ead9 fix(hnsw): clamp zero sample in get_random_layer to avoid usize::MAX level (#9959)
get_random_layer draws from a half-open [0, 1) uniform, so the sample can be 0.0; ln(0.0) is -inf and the float-to-usize cast saturates the level to usize::MAX, making set_levels allocate unboundedly. Clamp the sample to f64::MIN_POSITIVE. Extracts a small level_from_sample helper and tests it.
2026-08-12 14:28:32 +02:00
Andrey VasnetsovandClaude Sonnet 5 03a09ef51f 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-08-12 13:40:33 +02:00
Yash Singh 884e405f43 docs(segment): correct cardinality confidence level to ~0.95 for z=2.0 (#9967)
confidence_agresti_coull_interval uses z = 2.0, which is ~95.45% confidence, not the 0.99 the doc comment claimed (0.99 needs z ~ 2.576). Correct the comment; z is left unchanged since raising it would widen every interval and break test_confidence_interval.
2026-08-12 11:04:28 +02:00
Dimas Prabowo 36f6f69fe3 perf(avx): implement in-place L2 normalization for cosine_preprocess (#8650)
* perf(avx): implement SIMD-accelerated L2 normalization

* test: scale building cancellation workload for optimized builds
2026-08-12 11:04:01 +02:00
Andrey VasnetsovandClaude Fable 5 b43f70a6b6 [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-08-12 09:56:23 +02:00
Luis Cossío bdd4faccf7 [LiveReload] Add live_preload (#10036)
* LiveReload: change associated type, add `live_preload`

* adjust existing trait implementations
2026-08-11 17:41:45 -04:00
Luis Cossío 8f5e83b13d [CachedFs] Unchanged file open is no-op (#10050)
* add `UniversalIoError::UnchangedOpen`

* add & impl `CachedReadFs::reschedule_prefetch`

* clippy

* add `OkUnchanged` helper

* propagate scheduling errors immediately

* drop lock before reacquiring it

* clear prefetched_files on new snapshot

* only avoid prefetch on full FileInfo equality

* `UnchangedOpen` maps to `Cancelled`

* match-all match
2026-08-11 17:41:44 -04:00
xzfc b5f6d2b30b Batched HNSW reader (#10054) 2026-08-11 19:13:52 +00:00
Andrey VasnetsovandClaude Fable 5 6633205704 [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-08-11 15:35:55 +02:00
Andrey VasnetsovandClaude Sonnet 5 309c64945a [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-08-11 11:23:01 +02:00
1a8c9a6397 [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-08-11 10:57:47 +02:00
Andrey VasnetsovandClaude Opus 5 89f1ae939c [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-08-11 10:44:27 +02:00
Andrey VasnetsovandClaude Opus 5 e95f56861b [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-08-11 10:33:44 +02:00
Andrey VasnetsovandClaude Opus 5 9d1894d311 [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-08-11 10:29:25 +02:00
Andrey VasnetsovandClaude Opus 5 c8261beaec [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-08-11 10:19:49 +02:00
dependabot[bot] c5f32f1315 build(deps): bump num-derive from 0.4.2 to 0.5.1 (#10174)
Bumps [num-derive](https://github.com/rust-num/num-derive) from 0.4.2 to 0.5.1.
- [Changelog](https://github.com/rust-num/num-derive/blob/main/RELEASES.md)
- [Commits](https://github.com/rust-num/num-derive/compare/num-derive-0.4.2...num-derive-0.5.1)

---
updated-dependencies:
- dependency-name: num-derive
  dependency-version: 0.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-10 16:37:34 -04:00
Jojii 4c9d124ab4 Move deferred iter into PointMappings. (#10156) 2026-08-10 10:55:00 +02:00
Andrey VasnetsovandClaude Opus 5 1d18e46551 [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-08-09 00:42:54 +02:00
qdrant-cloud-bot 2805fe4e2a fix: stop underestimating is_empty / not-null cardinality by 1/3 (re-introduce #10128) (#10141)
* fix: stop underestimating is_empty / not-null cardinality by 1/3

Re-introduce #10128 after its revert so CI can exercise
payload_index_test::test_read_operations / test_is_empty_conditions.

* test: stop requiring is_empty struct exp to beat plain

NullIndex complement estimates use an indexed upper bound (may include
soft-deletes); that is not guaranteed to be closer to truth than plain's
available/2 guess. Assert upper-bound semantics instead.

* test: drop is_empty exp==max assertion

That locked in NullIndex implementation detail. Keep result parity and
min/max bounds only; document why exp-vs-plain is not checked.
2026-08-08 20:01:08 +02:00
2c024ba037 [UpdateOnly] implement UpdateOnlyChunkedVectors (#10114)
* AI + manual: impl `UpdateOnlyChunkedVectors`

* AI: simplify

* graceful handling of unexpected file lengths

fix test

* incorporate updates from #10119

* drop the unused status read on open

The vector count loaded at open was never consulted: every batch carries the
offset it starts at, and the chunks are reconciled against that offset. Drop
the field and the read, and fold both watermark writes into `save_len`.

A corrupt status file no longer blocks opening the writer — the first batch
overwrites it.

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

* fix clippy: ensure_chunk_lengths no longer needs &mut self

Dropping the status field left it with nothing to mutate. `append_many` keeps
`&mut self` — nothing in this module is exported, so the lint reaches it too,
but the exclusive borrow is what enforces the single-writer contract the
appends rest on.

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

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:00:07 +02:00
qdrant-cloud-bot 34d3f35fd7 Revert "fix: stop underestimating is_empty / not-null cardinality by 1/3 (#10128)" (#10140)
This reverts commit 50fe2e8139.
2026-08-08 18:51:15 +02:00
qdrant-cloud-bot 50fe2e8139 fix: stop underestimating is_empty / not-null cardinality by 1/3 (#10128)
NullIndex used an arbitrary `exp = 2/3 * estimated` heuristic for
complement conditions (`is_empty=true`, `is_null=false`), which caused
steady-state approximate counts to under-report by ~35% even with no
deletes (see #10120). Use the indexed upper bound as the expected count
instead.
2026-08-08 17:54:55 +02:00
b60f298dea 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-08-08 17:41:59 +02:00
Jojii 7c0948628d Batched deletion checks in full-scan/exact search (10-35% faster Turbo4) (#10042)
* Batched bitmap checks in full-scan/exact search

Instead of walking the deleted bitmap bit by bit and re-checking every
point, the new peek_top_visible reads all bitmaps one u64 at a time and
handles 64 points in one step.

About 1.5x QPS on unfiltered full scans.

* Remove debug statement

* Extract parallel bitmap scaning into separate helper

* Optimizing `BatchedBitmapScan`

* Also apply to HNSW full-scan fallback
2026-08-07 14:04:05 +02:00
xzfc c8e7ef80a8 Batched HNSW: preliminary refactoring (#10052)
* [1] refactor: EntryPoint: derive Copy

* [2] refactor: extract GraphLayers::probe_links_format

* [3] refactor: merge …/graph_links/{links, storage}.rs

* [4] refactor: GraphLinks: inline GraphLinksEnum methods

* [5] refactor: graph_links/view.rs split into view_utils.rs

Later these utils would be used in links_file.rs.

* [6] spelling: clarify error_size

* [7] refactor: TestGraphLinksVectors::{assert_base_vector, assert_link_vector}

* [8] refactor: extract entry-point selection out of GraphLayers::search

* [9] refactor: Introduce GraphWithVectorScorers

* [10] refactor: extract load_or_derive_config
2026-08-07 03:39:53 +00:00
Srimon Danguria f4ad4f4c25 chore(deps): drop dead dependencies in edge-path crates (#10109)
- blobstore: move `dataset` to dev-dependencies (test/bench only)
- shard: remove unused `fs4`
- segment: move `tap` to dev-dependencies (test/bench only)
- sparse: move `tempfile` to dev-dependencies (test only)

Removes the `dataset -> reqwest -> hyper/tower/h2` root from the
`edge` dependency graph.
2026-08-06 17:41:57 +02:00
0a6cb3b4cf [Raw payloads]: read payload as stored bytes in retrieve_raw (#10040)
* segment: read payload as stored in retrieve_raw

`retrieve_raw` already hands back vectors as stored; let the caller ask for the
payload the same way, so a reader that only relocates a point parses nothing.
`RawPayloadFormat` states what the caller wants — no payload, parsed, or as
stored — and replaces the `WithPayload` argument, which could express a key
selection that a raw read cannot serve anyway.

[`MaybeRawPayload`] states what came back, which can differ from the request in
one direction only: a payload storage that keeps payloads parsed cannot answer
`Raw` with a blob, and now says so instead of encoding a payload for a reader
that would parse it straight back.

The raw path reaches the blobstore through `read_payloads_maybe_raw`, mirroring
`read_payloads` down the payload storage and payload index traits, so it keeps
the batched read.

Every caller asks for `Parsed`, so this changes no behaviour: the copy-on-write
move and the sync comparison need the parsed payload anyway, and the shard
transfer switches over with the feature flag that ships the blob to another
node.

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

* segment: always hand out the stored payload blob from retrieve_raw

Review follow-up: instead of telling `retrieve_raw` in which form to
return the payload, it always returns it as stored and a caller that
needs the parsed form decodes it itself.

- Drop `RawPayloadFormat` and the payload parameter it replaced: no
  production caller ever asked for anything but the whole payload, and
  a selector cannot be applied to an opaque blob anyway.
- Drop `MaybeRawPayload` / `MaybeRawPayloadRef`: only
  `InMemoryPayloadStorage` could produce the parsed variant, and no
  segment can be built with that storage (`PayloadStorageType` is
  `Mmap` or `InRamMmap`, both blobstore-backed). `SegmentRecordRaw`
  carries a plain `Option<RawPayload>`.
- `PayloadStorageRead::read_payloads_maybe_raw` becomes
  `read_payloads_raw` and hands out `Option<&[u8]>`. The in-memory
  storage keeps payloads parsed, so it encodes on read, producing the
  bytes an on-disk storage would have written.

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

* api: decode a received raw payload with the shared decoder

`decode_payload` at the gRPC boundary matched on the encoding and parsed
the blob itself, duplicating `RawPayload::decode`. Add the inbound
conversion from the wire type and let the one decoder do the reading, so
another encoding has a single place to be taught.

The conversion also rejects an encoding number no variant maps to, which
prost would otherwise hand out as the default encoding — a blob from a
node that writes payloads some other way must not be read as JSON.

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

* simplification

---------

Co-authored-by: Ivan Pleshkov <ivan.pleshkov@qdrant.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 16:26:52 +02:00
d32d46ed27 build(deps): bump sysinfo from 0.38.4 to 0.39.6 (#10077)
* build(deps): bump sysinfo from 0.38.4 to 0.39.6

Bumps [sysinfo](https://github.com/GuillaumeGomez/sysinfo) from 0.38.4 to 0.39.6.
- [Changelog](https://github.com/GuillaumeGomez/sysinfo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/GuillaumeGomez/sysinfo/compare/v0.38.4...v0.39.6)

---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.39.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: limit sysinfo features to avoid macOS objc2 trait overflow

sysinfo 0.39's default `user` feature pulls objc2-open-directory on macOS,
which exposes objc2::Retained's IntoIterator blanket impl into collection and
overflows trait resolution for Anonymize derives. Only the system feature is
needed for memory queries.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[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 12:57:43 +02:00
Andrey VasnetsovandClaude Opus 5 aa6c5d8403 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-03 11:20:34 +02:00
Andrey VasnetsovandClaude Opus 5 4a453329fc 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-03 00:45:40 +02:00
Kumar ShivenduandClaude Opus 4.8 7743616b3b Report effective (cgroup) CPU, RAM and disk in telemetry (#9891)
* Report effective (cgroup) CPU, RAM and disk in telemetry

The `system` block reported host-level figures that ignore the limits the
kernel actually enforces on the process:

- `cores`     <- sys_info::cpu_num()   (host socket count)
- `ram_size`  <- sys_info::mem_info()  (host total RAM)
- `disk_size` <- sys_info::disk_info() (container root fs)

On any cgroup-limited deployment (containers, Kubernetes pods, systemd
slices) these overstate what Qdrant can use, are misleading for capacity /
oversubscription analysis, and don't match how Qdrant sizes itself.

Report the effective values instead, reusing existing helpers:

- `cores`     -> common::cpu::get_num_cpus()          (already drives sizing)
- `ram_size`  -> segment::utils::mem::total_memory_bytes()
                 (cgroup limit via cgroups_rs, else sysinfo host total)
- `disk_size` -> common::disk_usage::disk_usage(storage_path)
                 (data-volume capacity, cached; sys_info host disk fallback)

`Mem::new()` is not free (it builds a sysinfo System and loads the cgroup
memory controller), so `total_memory_bytes()` caches with a 5s TTL — matching
the disk-usage cache — instead of recomputing per call. The TTL (rather than
caching once) means an in-place cgroup memory resize is reflected within a few
seconds, consistent with how `disk_size` and `cores` already behave. The
strict-mode helper in `collection` now delegates to this shared accessor
(dropping its own OnceLock), so it too becomes resize-aware.

ram_size/disk_size stay in KiB to match the previous unit.

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

* Simplify comments and code

* Simplify openapi spec

* minor comment improve

* mem: cache total_memory_bytes like disk_usage (5s TTL)

`total_memory_bytes()` mirrors `common::disk_usage::disk_usage`: a small 5s
TTL cache over `Mem::new().total_memory_bytes()`. `Mem::new()` is not free
(builds a sysinfo System + loads the cgroup controller), and the short TTL
keeps the value in step with an in-place cgroup memory resize rather than
freezing at startup. Shared by telemetry and strict-mode, like the disk cache.

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

* Regenerate OpenAPI spec

Add the ram_size / disk_size field descriptions produced by schemars from the
updated telemetry doc comments, keeping the generated spec consistent.

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

* mem: address review — parking_lot mutex, hold lock, saturating_duration_since

- Use parking_lot::Mutex (no poisoning; lock() returns the guard directly).
- Hold the lock across the whole method — single acquisition, simpler.
- saturating_duration_since instead of duration_since (no panic on a cached
  timestamp spuriously ahead of now).

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-02 14:31:06 +02:00
xzfcandLuis Cossío a6a9143afc bitpacking_ordered: batched reads (#10038)
* bitpacking_ordered: batched reads

* Replace ranges with pairs

* "unsufficient" -> "insufficient"

---------

Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-08-01 01:51:54 +00:00
Luis Cossío c07d57bd8f chore: fix dead code lints on macos (#10045) 2026-07-31 14:32:32 -04:00
9f3c07b00d 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-07-31 09:10:42 +02:00
xzfc 75385df69f Remove dead code (#10030)
* Remove dead code

* Remove unused dependencies

* `allow(dead_code)` -> `expect(dead_code)`

* ast-grep: rule-tests/*-test.yml => tests/*-test.yml

For brevity.

* ast-grep: forbid allow(dead_code)
2026-07-30 20:59:31 +00:00
xzfc 39547e3a67 chore: bench_cache (#10028) 2026-07-30 11:58:06 +00:00