mirror of
https://github.com/qdrant/qdrant.git
synced 2026-09-21 13:37:46 -05:00
78fb78bdde809755ac950147157eae2f3b31ba4a
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5c37004f67 |
[UIO] Split async into extension traits, implement only where genuinely async (#10424)
* Split async IO into extension traits; only async-capable backends implement them Move `read_bytes_async` / `open_async` off the universal `UniversalRead` / `UniversalReadFs` traits into dedicated extension traits, `UniversalReadAsync` and `UniversalReadFsAsync` (traits/async_io.rs). Only backends with a genuine async story implement them — the blob family, the disk caches layered over it, and a trivial ready-impl for mmap (tests and the mmap lookup path) — each in a dedicated async_io.rs next to its sync impl. `CachedFs` now requires its inner filesystem to be `UniversalReadFsAsync`; the requirement reaches segment code through one supertrait bound on `UniversalReadExt`. io_uring implements no async surface anymore: the tokio_uring bridge thread, its tests, the musl-gated tokio-uring dependency, and the `IoUringFile` read-only-segment wiring (`UniversalReadExt` impl and the *RoIoUring condition-checker variants) are deleted — io_uring is not a read-only-segment backend. The payoff for live reload: `CachedFs::resolve_prefetched` awaits every parked prefetch, and the edge refresh flow now runs preload -> resolve -> reload, so the per-segment write locks never wait on IO. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Decouple UniversalReadExt from the async filesystem requirement UniversalReadExt is condition-checker dispatch; it never consumed the async surface itself. Drop its `Fs: UniversalReadFsAsync` supertrait bound and relax CachedFs's struct-level bound back to `UniversalReadFs` — the async requirement now lives on the one impl that consumes it, `CachedReadFs for CachedFs` (schedule_open parks the inner filesystem's `open_async` futures). The bound then surfaces only on the lifecycle/preload impl blocks that go through CachedReadFs (segment open, live-preload/reload, config reload, edge load/refresh); the search path carries no async bounds at all. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c6d8c8f347 |
Add tools/callgraph: interactive call-graph reports via rust-analyzer (#10422)
* Add tools/callgraph: interactive call-graph reports via rust-analyzer Generates a self-contained HTML report for one function: pan/zoom graphviz graph of callers and callees, per-node docs and source snippets, exact call sites with context, GitHub/editor links. - rust-analyzer call hierarchy over LSP gives resolved (not textual) edges; trait declarations and impls are bridged via goto-declaration / goto-implementation so dispatch through a trait doesn't dead-end the walk - test code excluded by running rust-analyzer with cfg(test) disabled, plus path filters for tests/, benches/, examples/ targets - no dependencies beyond rust-analyzer and graphviz on PATH Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg * Add screenshot to tools/callgraph README Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RgxVoa6hfvgB7r6FVbcGmg --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1dad4d9f9f |
Let multivector runs straddle chunk boundaries (#10283)
* Read vector runs that straddle a chunk boundary Resolve a run into per-chunk parts instead of a single range, borrowing when it lands in one chunk and copying when it spans two. The read pipeline schedules one range per read, so a straddling run is read outside it. No writer produces such a run yet, so this changes nothing on its own. It is what a reader needs before one does — including edge and live-reload readers, which read files a different version wrote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Place multivector runs without regard to chunk boundaries Writers appended a multivector's inner vectors at the end of the row space unless the run would cross a chunk boundary, in which case they skipped the chunk tail — the batch writers padding the skipped rows with explicit zero rows. That made chunk geometry part of the interface every multivector storage had to reuse. Runs now go at the end unconditionally and the chunked storage splits the write across chunks, as it already did for a batch of single vectors. What is left of the geometry is a size cap: a multivector may not exceed one chunk. It is fill-independent, so it constrains nothing about placement, and it is what the volatile storage needs anyway — that one returns a plain slice and so cannot serve a straddling run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Split a run at chunk boundaries in one place Reading, writing in place and appending each derived the split from `remaining_chunk_capacity`, so every one of them had to know that a run does not necessarily fit where it starts. `split_run` hands out the parts instead: one per chunk the run covers, each carrying where it goes and how much of the run it takes. Nothing asks how much room is left any more, and `get_chunk_offset` goes with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep straddling runs on the read pipeline Reading a straddling run outside the pipeline blocked the scheduling loop on one read, which costs a round trip on a backend that fetches remotely and drops the batch back to sequential. A run is now scheduled as one read per chunk it covers. Parts complete in any order, so each run holds what has landed until the last part does, then hands the callback the stitched vectors. Runs taking a single read carry the caller's data in the tag and never touch that table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Stop capping a multivector at one chunk The cap outlived its reason on disk, but the volatile storage still needed it: its `get_many` handed out a slice of one chunk, so a run that crossed a boundary had nowhere to come from. And since a volatile storage is a target of the batched copy that builds a segment, dropping the cap only on disk would have turned a rejected write into a failed merge. So the volatile storage splits and stitches too. Both are a few lines each, and placing a run no longer skips a chunk tail, so `extend` is now `insert_many` at the end of the storage. Nothing user-facing moves: `MAX_MULTIVECTOR_FLATTENED_LEN` caps a multivector at 1M elements, far inside a 32 MiB chunk, so the storages only ever rejected what reached them unvalidated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Schedule a single-read run without the queue The scheduling loop resolved every run into the queue and then took it straight back out, so the overwhelmingly common run — one that fits a chunk — paid a push and a pop for nothing. It now goes to the pipeline directly, and the queue holds only what a straddling run leaves behind. Worth ~10% on the multivector read benchmark, and it collapses the "top up, then take" pair into one decision. Extracting that bookkeeping into helpers instead was measured and is much worse: the mmap pipeline alternates one schedule with one wait, so the loop body is a few dozen nanoseconds, and a helper carrying the cold map and stitching paths is too big for the compiler to inline back into it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Repoint the multivector WAL-replay test at a live rejection The test upserted a multivector too large for a storage chunk, which no longer fails: the storages stopped capping one at a chunk. Nothing else covered a multivector operation that only the apply path rejects. A raw blob that is not a whole number of quantized records still does, so the test now uses that, alongside its dense and sparse siblings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Test reading multivectors with legacy chunk-tail padding Locks the compatibility contract that pre-straddle files — runs that skip a chunk's leftover slots — still reopen as single-chunk borrows. * chore: retrigger CI after flaky test-consensus-compose * Move ReadTag into for_each_vector, its only user Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AN4Hgbd65gDhesthJk5bUY --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> |
||
|
|
43da5dfe23 | HNSW: stop exact-duplicate vectors from becoming a sink at level 0 (#10239) | ||
|
|
a8ed37c907 |
Move avg_vector_for_recommendation into segment, re-export from edge (#10261)
The `average_vector` recommend strategy folds the examples into one query vector. That fold lived in `collection::recommendations`, though it only touches segment/sparse types (`VectorRef`, `VectorInternal`, `SparseVector::combine_aggregate`, `TypedMultiDenseVector`); edge-based consumers (the serverless search worker) had to re-implement it because the `collection` crate is the whole node layer. Move `avg_vector_for_recommendation` (+ its private `avg_vectors` / `merge_positive_and_negative_avg`) next to `RecoQuery` in `segment::vector_storage::query`, returning `OperationResult` (validation errors map to `CollectionError::BadInput` as before), keep the two collection call sites on it, and re-export it — plus `VectorRef`, needed to call it — from edge. Tests move along, plus one for the fold itself. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c26139efdc |
Edge: seed appendable segment on load from existing segments' indexes (#10257)
`ensure_appendable_segment` used a shard-root `payload_index.json` that nothing in edge ever wrote, so a shard loaded with only immutable segments got a bare appendable segment and the appendable chain stayed unindexed until a merge happened to include an indexed segment. Build the segment directly and seed it with the union of `get_indexed_fields()` over the loaded segments, the same reconciliation the optimizer performs for its CoW segment. Drops the shard-root file from edge. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3448d5b39d |
tests: restart reinit peer on the same port in readyz test (#10255)
`test_reinit_removed_peer_readyz_ignores_old_cluster` restarted the reinitialized peer on a fresh port. A changed `--uri` makes the peer announce its new address to every address-book entry, including the injected old first peer, which re-adds it to the *old* cluster as a learner and starts replicating its log to it. Normally the restarted peer is a term ahead and ignores those messages, but when the reinit run's hard state save did not finish before the kill, it restarted at the old term, accepted the old leader, took its log (commit 13 > 12) and failed the guard. The scenario is a plain restart, so keep the URI; then nothing is announced and only the `/readyz` membership filter is exercised. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e43e9a6fc9 | bump syn 3.0.3 | ||
|
|
93f5ef2abb |
[UpdateOnly] Honor upsert update_mode in the batch writer (#10236)
* [UpdateOnly] Honor upsert update_mode in the batch writer The writer rejected every `UpsertPointsConditional`. Accept the ones whose condition is empty — `insert_only` and `update_only` — since existence is the whole gate they need, and locating a batch's points already answers it. The gate is evaluated per mutation at its position in the fold, so an `insert_only` upsert sees a point an earlier operation of the same batch created, matching a leader that resolves each operation only after the ones before it were applied. A conditional upsert may therefore not discard the mutations it follows. Rejecting an upsert also means never reading the point it would have overwritten: `needs_stored_point` asks whether the first mutation that applies to an existing point discards it, so an `insert_only` batch pays nothing for the ids that are already taken. A conditional upsert carrying a real filter is still rejected — evaluating one needs payload indexes the writer never fetches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] State contracts in the update-mode docstrings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] Trim the update-mode diff Drop `--update-mode` from edge-shard-update: the modes are covered by unit and end-to-end tests, and the flag cost a wrapper enum, a conversion and a parameter threaded through both run paths. The tool still reports rejected points, which the exhaustive match requires. Inline `always_applies` into its one caller, drop the two test-batch wrappers over `conditional_batch`, drop the `update_only` fold test whose truth table two other tests already assert, and shorten two over-long comment blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe27f6a0a9 |
Add acosh expression to formula query (#10231)
Unary inverse hyperbolic cosine, parallel to sqrt/ln/exp/log10, in REST, gRPC, and edge (FFI + Python) interfaces. Inputs below 1 produce the same NonFiniteNumber error as an invalid sqrt or ln. Closes #10186 Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3b06a9606a |
gitignore: ignore lib/edge/publish/target (#10237)
`amalgamate.py` builds the generated crate in place, leaving a Cargo target directory next to the sources. `/examples/target` was ignored but the publish crate's own was not, so it showed up as ~32k untracked files that a path-wide `git add` picks up. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c539340d2d |
[UpdateOnly] Writer over object storage (#10214)
* Drop the vestigial UniversalWrite bound from the update-only writer Neither writer kind performs in-place writes: AppendableSegment is built on UniversalAppend, and DeleteOnlySegment tombstones via whole-mask atomic_save (UniversalWriteFileOps), which UniversalAppend's supertrait already carries. The bound is a leftover from the DiskIdTracker-based iterations that mutated the deleted mask in place. With it gone, UpdateOnlyEdgeShard::apply_batch is instantiable with the object-store-appendable CachedBlobFile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * edge-shard-update: --apply writes the batch, over object storage too Open the object-storage backends through CachedBlobFs/CachedBlobFile instead of the read-only DiskCacheFs handle, so the shard is appendable in both modes, and add --apply: generate the same schema-derived batch and run apply_batch instead of preview_batch. Dry run stays the default and the generation is shared, so the preview cannot drift from what an apply would do. AwsConfig::native_append is exposed as --native-append for AiStor/RustFS-style endpoints; the Cached* types join io_bridge_object_store's re-export of the io_bridge stack. Applying to a leader-produced shard currently fails with a clean refusal — its appendable segment's payload storage was created in mutable mode, which the append-only writer rejects — the known segment-bootstrap gap, next in line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CachedBlobFile: latency tracing for append_bytes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * UpdateOnlyEdgeShard: sequential batches through one writer Writers open once at shard open, next to the lookup segments they resume from. apply_batch hands the writer back on success, live-reloading the lookup half of every segment the batch wrote to (new LookupSegment::live_reload, mirroring the read-only segment's); on error the writer is consumed, since its lookups may no longer describe the durable state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * edge-shard-update: --interactive mode, sequential batches on one writer After each applied batch, prompt on stdin for the next round's ids and apply them through the writer apply_batch handed back — no shard re-open — with op-num (and seed) incremented per round. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * CachedBlobFile: create the missing object on an offset-0 rewrite append The caller-side rewrite path (part-copy S3 stores below the direct-append threshold) validated the offset against the mirror length, whose initialization HEAD-requests the remote and surfaced NotFound for an object that does not exist yet. Direct-append backends (GCS compose, native append) already create the object on an offset-0 append; the rewrite now reads a missing remote as length zero so its whole-object PUT does the same, and a non-zero offset against a missing object reports an offset conflict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * UpdateBatchOutcome: per-point records of retired slots Each applied point now carries a PointApplyRecord: what happened to it (stored/deleted/skipped/missing) and which slots it vacated where — tombstoned per segment, or superseded in place for the old write-target copy of a stored point. Built in the same loop that decides tombstone-vs-supersede, so the report cannot drift from the writes. edge-shard-update logs one line per point after the applied summary, telling a fresh insert from an overwrite and naming the segments the old copies were deleted from. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bcb7a45cc7 |
Add CachedBlobFile: cached reads + write-through appends for object stores (#10206)
* Add CachedBlobFile: cached reads + write-through appends for object stores Combine a DiskCache mirror (reads) with a BlobFile remote handle (appends) into CachedBlobFile/CachedBlobFs, the appendable universal-IO citizen for object stores. Appends perform the remote mutation inline and are durable at Ok: a native write-offset append in AppendMode::Native (with a soft limit on appends per object), or a whole-object rewrite in AppendMode::Rewrite for stores without native append. After a successful append the mirror length is advanced without extra IO; appended blocks fault in from the remote on first read. The multipart UploadPartCopy rewrite path (prefix >= 5 MiB) and the rewrite-required error classification are left as todo!() pending the AsyncRewrite backend capability. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Backend-advertised AppendMethod; reactive appended-block cap recovery Replace CachedBlobFile's stored AppendMode with AsyncAppend::supported_append: the backend advertises Native or PartialUpload, and append takes a matching AppendRequest variant, rejecting the ones it does not support. The multipart UploadPartCopy todo moves into the S3 backend's PartialUpload arm. Drop the native_appends soft-limit counter: it is per-handle in-memory state that resets on every restart, so it can never be the correctness mechanism and persisting it would not make it authoritative either. The store is the authority: hitting its appended-block cap now surfaces as the new UniversalIoError::AppendRewriteRequired (S3 400 TooManyParts), and CachedBlobFile recovers with a whole-object rewrite. Unrecognized errors stay hard errors instead of silently triggering rewrites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Per-store append strategies; server-side rewrites for plain S3 and GCS Replace the single AppendContext struct with an enum of strategy objects, one per store capability, each owning its append logic: - NativeAppend: the signed write-offset PutObject (S3 Express, MinIO AiStor; AwsConfig::native_append declares it for AiStor-like endpoints, s3_express implies it). - PartCopyAppend: plain S3 — appends land as one atomic multipart rewrite whose prefix parts are server-side UploadPartCopy requests; nothing but the appended data crosses the network. object_store keeps such provider-specific calls out of its portable surface, so the requests are hand-signed like the native append. - ComposeAppend: GCS — the appended data is uploaded as a temporary neighbor object and composed onto the destination server-side, conditional on the observed generation (a real compare-and-swap). AppendMethod is replaced by AppendSupport, which tells the caller the only thing it needs: when the store takes a direct append. Always (native, and compose: no part minimums, no block cap), AboveThreshold (part-copy: the copied prefix lands as non-last multipart parts, >= 5 MiB each), or Never. CachedBlobFile drops its hardcoded MIN_COPY_PREFIX and rewrites locally only below the backend-advertised threshold; AppendRequest::Rewrite now means only "append and rebuild as a single blob" — the appended-block cap recovery. The append module is split one file per strategy, with a shared SignedRequestContext transport and a test-only HTTP stub. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * DiskCache tracks the remote object's etag Seeded from the new known_etag open extra (OpenExtra::with_known_etag), refreshed from FileInfo on schedule_reopen, and settable directly for callers that mutate the remote out of band. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove AppendRequest enum; appended-block cap recovery moves into the backend AsyncAppend::append takes plain (path, offset, data). A native S3 store that rejects an append with TooManyParts now falls back to the part-copy rewrite inside the dispatcher, instead of surfacing AppendRewriteRequired to CachedBlobFile for a second Rewrite request. The Rewrite variant was handled identically to Append everywhere except that one native path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Escalate to download+rewrite when the store rejects a part-copy rewrite The cap-recovery rewrite is chosen by the store's returned error, not a client-side threshold: a part-copy attempt rejected with EntityTooSmall (typed as UniversalIoError::AppendEntityTooSmall, parsed from the S3 error <Code>) falls back to downloading the sub-part-minimum prefix and PUTting the whole object back, guarded by a prefix-length offset check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix S3 Express appends: zonal endpoint + s3express SigV4 service Hand-issued appends targeted the standard endpoint and signed as "s3", so every append to a directory bucket got 404 NoSuchBucket, masked as AppendOffsetConflict by the 404 mapping. Derive the zonal {bucket}.s3express-{az}.{region} base from the mandatory --{az}--x-s3 bucket suffix (mirroring object_store's private derivation), carry the SigV4 service name in SignedRequestContext, and treat a 404 as a conflict only for NoSuchKey or bodiless responses — NoSuchBucket stays a loud error guarding the endpoint derivation. extract_xml_tag moves up to the context module and now tolerates tag attributes and pretty-printed bodies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Server-side etag precondition on appends; BlobFile loses UniversalAppend AsyncAppend::append carries an expected_etag that S3 part-copy rewrites attach as x-amz-copy-source-if-match (412 -> AppendEtagMismatch, a new typed error) and download_rewrite checks against the GET's own etag; native write-offset PUTs and GCS compose ignore it. BlobFile appends only through the inherent etag-aware append_bytes now — CachedBlobFile calls it directly with its DiskCache-tracked etag — and BlobFs's mutating ops become inherent, delegated from CachedBlobFs, per the standing TODOs. The append conformance battery runs over the CachedBlobFs stack, via new direct constructors that share one backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop unfulfilled too_many_arguments expectation rewrite_parts has exactly seven parameters — at the clippy threshold, not over it — so the lint never fires and the expect fails CI under -D unfulfilled-lint-expectations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
db2a5bbe5c |
Move delete-only tombstoning into per-format update-only id trackers (#10212)
* Move delete-only tombstoning into per-format update-only id trackers DeleteOnlySegment::tombstone_points wrote the deleted mask file directly, hard-coding that both immutable id-tracker formats store it the same way. Give each immutable format (in-RAM and disk-resident) its own update-only tracker that owns the decision of where its tombstones go, and dispatch through DeleteOnlyIdTrackerEnum, which lives next to ReadOnlyIdTrackerEnum. Both formats share one deleted-mask file today, so the trackers delegate to a shared writer in deleted_storage.rs; a format that diverges later changes only its own update_only module. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Enforce empty-batch guard in the shared tombstone writer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
548574d9c3 |
fix: route append-only point deletion through delete_point_internal (#10199)
Alternative to #10188. Instead of skipping check_consistency_and_repair's storage cleanup entirely on append-only segments, and keeping a separate delete_point_tombstone_only helper that callers must remember to pick, fold the append-only decision into delete_point_internal itself: - delete_point_internal now branches on is_append_only_delete() internally: tombstone-only (id tracker drop only) when true, full payload/field-index clear + id-tracker drop otherwise. - delete_point_tombstone_only is removed; both call sites (ordinary delete_point, and check_consistency_and_repair's cleanup of dangling versions found by fix_id_tracker_inconsistencies) now just call delete_point_internal, so the repair path gets correct append-only behavior for free instead of needing its own explicit check. - version_tracker.set_payload (payload-storage version, used for partial snapshots) moves inside delete_point_internal too, gated behind the same branch: it's only bumped when payload storage is actually touched. Took the opportunity to also thread it through an explicit op_num: Option<..> parameter, since check_consistency_and_repair's repair pass has no real op_num to associate the change with. This keeps "how to delete a point" a property of segment state rather than something every caller has to branch on externally, which is what let the original bug slip through check_consistency_and_repair in the first place. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
015ece2b11 |
[UpdateOnly] tombstone points in immutable segments via whole-mask rewrite (#10196)
* [UpdateOnly] tombstone points in immutable segments via whole-mask rewrite DeleteOnlySegment::tombstone_points marks the retired slots in the segment's deleted-points bitmask (id_tracker.deleted, shared by the immutable and disk-resident tracker formats) and replaces the file whole via atomic_save — the one mutation that works on backends without random-offset writes. Both read-only trackers already live-reload this file by opening a fresh handle and diffing, so the rewrite needs no read-side changes. The mutation cycle lives in StoredBitSlice::atomic_update: read the stored bits (or start from a caller-provided seed), apply the update, save atomically; a closure error writes nothing. The seed comes from the read phase by analogy to AppendableIdTrackerState: LookupSegment::writer_state now returns WriterIdTrackerState, whose DeleteOnly variant carries the deleted mask when the tracker already holds it in memory — always for the immutable tracker, only if materialized for the disk-resident one, which deliberately avoids loading the full deleted set. Tombstoning needs no more of the backend than reads plus atomic_save, so DeleteOnlySegment's bound drops to UniversalRead<Fs: UniversalWriteFileOps>. Unlike the writable trackers' drop(), the slot's version is not zeroed (the versions file is in-place-mutated, which object stores cannot do): deletion authority in these formats is the bit — every lookup filters through it — and a stale version on a tombstoned slot is the same state a crash between drop-bit and drop-version leaves, which fix_inconsistencies already absorbs as storage cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Close the temp-file handle in tests that atomically replace it NamedTempFile holds the file open for its lifetime, and Windows refuses the rename in atomic_save while any handle is open. into_temp_path() closes the handle and keeps the deletion guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d2cdaf1cfb |
Add edge-tool: CLI for creating, seeding, optimizing, and uploading local edge collections (#10159)
* Add edge-tool: CLI for creating, seeding, optimizing, and uploading local edge collections Mirrors the style of lib/edge/tools/shard_update and shard_query: `create` builds a minimal EdgeShard on disk (dense/sparse vectors, quantization presets including turbo4, payload indexes, target segment count), `upsert` seeds it with random points matching its live schema, `optimize` runs the shard optimizers, and `upload` pushes the resulting directory to S3/GCS. Useful for quickly spinning up test collections without a running Qdrant server, then promoting them to object storage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * edge-tool: initialize feature flags, enable serverless_compatible, fix --sparse ambiguity Initialize the global feature-flag OnceLock at startup (with serverless_compatible set, cascading write_segment_manifest/append_only_mutations/compact_bitmask/ append_only_storages) so runs no longer spam "Feature flags not initialized!" and collections are created in the serverless-compatible format. Also splits --sparse into a plain boolean flag plus a repeatable --sparse-name: clap's optional-value parsing for the old `--sparse [NAME]` form silently swallowed a following positional PATH as the sparse vector's name whenever --sparse was the last flag before it (e.g. `create --dense 1024 --sparse ./col`). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * edge-tool: fix --sparse=NAME to require_equals instead of a separate flag The --sparse/--sparse-name split from the previous commit lost the ability to name a sparse vector with --sparse itself. Restore a single --sparse[=NAME] flag, but with require_equals(true): clap then only binds a value via --sparse=NAME, never via a following bare token, so it stays safe next to the trailing PATH positional in every position (bare --sparse, --sparse=NAME, or multiple --sparse=NAME occurrences) without reintroducing the ambiguity that made --sparse swallow PATH as its value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * edge-tool: remove --segments from create, it has no effect there EdgeOptimizersConfig::default_segment_number only feeds MergeOptimizer as a merge-down ceiling (reduce segment count when it exceeds the target); unlike the main collection's LocalShard::build_local, EdgeShard::new never loops to pre-create N appendable segments. A freshly created collection always starts at exactly 1 segment, so passing --segments to `create` was silently a no-op. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * edge-tool: add --indexing-threshold-kb to create Unlike --segments (removed previously), the indexing threshold is a parameter IndexingOptimizer actually consults on every optimize() run: segments larger than it get an HNSW index built. Verified end-to-end (create with a 1KB threshold, upsert 2000 points, optimize) that it produces an hnsw-indexed segment where it would otherwise stay plain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * edge-tool: add --clean to upload, wiping the destination prefix first Lists every object under DESTINATION and deletes it via ObjectStore::delete_stream before uploading, so re-uploading a collection recreated with a different shape (different segment UUIDs) doesn't leave the old segment's files behind. Verified against the local S3 proxy: uploaded one collection, then a second, differently-shaped one to the same prefix without --clean (29 objects, stale leftovers from the first); re-uploading the second with --clean correctly dropped it back to exactly its own 19 files. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
15fe017cab |
[UpdateOnly] create Blobstore-backed storages append-only under a feature flag (#10154)
* [UpdateOnly] create Blobstore-backed storages append-only under a feature flag A new `append_only_storages` feature flag, enabled by `serverless_compatible`, switches every Blobstore creation site — the payload storage, the appendable field indexes (numeric, map, geo, full-text) and the sparse vector storage — to the append-only Logstore mode. One shared helper maps each site's Gridstore layout to its Logstore counterpart, carrying the page size and compression over; blocks and regions have no append-only equivalent. Only creation consults the flag: an existing storage keeps its persisted mode, both modes are always readable, so flipping the flag never strands data. Two changes make the flag usable rather than booby-trapped: `Logstore::delete_value` now succeeds trivially where nothing is stored, as mutable mode does, and errors only for a stored value. The ordinary write paths delete defensively — an index clears a slot before filling it, an empty value is stored as a deletion — and only ever hit occupied slots when something is genuinely mutated in place. A segment derives `append_only_storages` from the persisted payload storage mode when it opens — not from the flags, which may have changed since it was created — and it forces append-only mutation semantics on itself: every mutation clones to a fresh slot, and the same-operation slot-reuse shortcut is disabled, since the second step of a multi-step write would rewrite a payload row those storages cannot rewrite. The end-to-end test runs as its own binary (feature flags are process-global) with `serverless_compatible` on: the segment comes out holding Logstore storages, and upserts, updates of existing points, multi-step same-operation writes, deletes, an index build over existing points, a flush and a reload all run against them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] assume the flag pairing instead of deriving it, trim the docs Per review: `append_only_storages` without `append_only_mutations` is not a state to defend against — `init_feature_flags` forces the pairing, and the same-operation slot-reuse check reads the flag directly. That deletes the segment-side derivation: the `append_only_storages` segment field, the persisted-mode read at open, and the `is_append_only` accessor chain through `Blobstore`, `PayloadStorageImpl` and `PayloadStorageEnum`. Docstrings and comments trimmed to the guarantees; how the write paths use them is their own business. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] restructure the creation config around mode-neutral options `CreateOptions` in the blobstore crate holds what a caller actually decides — page size, block size, compression — and `into_config(append_only)` turns them into the config of either mode, each taking the fields it can express. The segment-side `storage_config` supplies only the mode, from the feature flag. That removes the misnomer chain the previous cut left behind: nothing named gridstore returns a config that might not be one, and no call site builds a `GridstoreConfig` just to have its fields repacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] keep Logstore strict; fix the callers that deleted nothing Per review, `Logstore::delete_value` goes back to an unconditional error: a delete reaching an append-only storage is a caller bug to fix, not a case to absorb. The callers that issued vacuous deletes are fixed instead: - The numeric and geo indexes only delete from the storage when their in-memory index actually held values at the slot — the two are written in lockstep, so an empty slot has nothing stored either. The map and text indexes already worked this way. - The sparse storage skips the delete for keys at or past its end, where nothing was ever stored. Each removed call was wasted work in mutable mode too. The e2e test now also drives a numeric index and the sparse storage against append-only mode, and asserts that deleting a stored sparse vector fails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [UpdateOnly] drop the same-op slot reuse; upserts write the whole point at once The append-only path never needs a multi-step point write: the one real multi-stepper was the shard's upsert — `upsert_point` followed by a payload step under one operation number — and it now goes through `upsert_moved_point`, which writes vectors and payload as one operation and one slot. With that, the same-operation slot-reuse carve-out in `handle_point_mutate` has nothing to carry: on an append-only segment every mutating step clones to a fresh slot, unconditionally, and the `append_only_storages` special case disappears with it. The version gate skips only on strictly newer versions, so a caller that still multi-steps stays correct — it pays a slot per step. `PointToUpsert` now exposes the point's parts — raw vectors, decoded vectors, payload — and both write paths are provided from them: `upsert_into` hands the parts to `upsert_moved_point`, `write_moved` adapts them to the copy-on-write move callback. The two hand-written `upsert_into` bodies and the follow-up payload helper are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Regenerate OpenAPI for the `append_only_storages` feature flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * one extra debug assertion * fmt --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
629c012077 |
[UpdateOnly] wire every component into the appendable segment (#10152)
* [UpdateOnly] wire every component into the appendable segment `AppendableSegment::store_points` sheds its `todo!()`: the id tracker claims a fresh slot per point, every component writes its data at those slots — each named vector storage, the payload storage, the payload indexes — and only then do the versions cover them, the step that makes the points visible to readers. A crash anywhere in between leaves claimed, unpublished slots, which the next writer to open the segment retires. Each vector comes from whichever half of `FullyQualifiedPoint` holds it: the batch's decoded vectors win over the bytes carried from the point's previous slot, and a name in neither still takes its slot as a vector the point does not have. The store components open lazily, on the first `store_points`. A batch that only deletes writes nothing but the mappings log, so it never pays for those opens — and it keeps working against segments whose payload storage was created in mutable mode, which the append-only writers refuse and which is all any leader builds today. The writer now also remembers what it stored, so `tombstone_points` skips a point this very batch wrote instead of retiring its fresh slot; the caller can hand over every slot a stored point used to occupy without holding that rule. `UpdateOnlySegmentEnum::open` takes the segment config, which is where the writer learns which vector storages exist. The end-to-end edge tests now run stores the whole way through: located and resolved through the `LookupSegment`s, appended by the writer, and read back through an ordinary follower — a new point with its payload, a rewrite winning over the old copy, a replayed batch skipping on the published versions, and a second writer resuming every component where the first ended. The leader still writes its payload storage in mutable mode, so the tests recreate it empty in append-only mode, standing in for segment creation wiring that does not exist yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] drop the stored-ids guard from `tombstone_points` The caller already never asks to retire a point its batch stored — it has to hold that rule regardless, since `preview` mirrors it to count outcomes — so the writer-side set was redundant state, and it made `tombstone_points` silently drop requests instead of honoring a stated contract. The contract is now stated: only points the batch deletes go here, because a delete addresses the external id and would take a stored point's fresh slot along with the stale one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] gate the store tests off Windows The leader's writable storage preallocates chunk files, and the append-only writer cuts them back to end at the data — its append offset is a compare-and-swap token, so a file longer than the data would make every append conflict. That cut replaces the file, which Windows refuses while the writer's own `LookupSegment`s hold it memory-mapped; on Linux the old inode simply lives on under the mappings. Nothing to fix in the writer: Windows cannot shrink a mapped file, and the production target is object storage, where neither preallocation nor mmap exists. The delete tests keep running everywhere; the store tests move into a `#[cfg(not(windows))]` module together with the imports and helpers only they use, so the Windows build carries no unused-import warnings. Cross-checked with `--target x86_64-pc-windows-msvc`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] wire the quantized overlay into StoreComponents Opens UpdateOnlyQuantizedVectors alongside each dense, non-multivector, non-Turbo4-datatype vector's raw storage, when the segment's quantization config supports incremental appends (Binary/Turbo). Multivector and Turbo4 combinations are out of scope (see UpdateOnlyQuantizedVectors' own doc comment) — such a vector simply has no quantized overlay entry and stays searchable exactly through its raw storage alone, same as before. store_points keeps the overlay's row count in exact lockstep with the raw storage: every point takes a row in both, in the same order, at the same id (start_slot + offset) — a decoded vector encoded for real, a Raw-bytes-carryover blob decoded back to f32 per its actual storage datatype (mirroring QuantizedVectors::create_impl's use of PrimitiveVectorElement::quantization_preprocess for the same purpose on the non-update-only path), and a Missing vector as an all-zero placeholder. Skipping a row for the latter two cases would silently misalign every later quantized lookup — scoring one point's vector against another's quantized copy — so this mirrors the raw storage's own "every point takes its slot" rule exactly rather than only handling the common decoded case. UpdateOnlyQuantizedVectors now retains its resolved QuantizedVectorsConfig (exposed via quantization_config()/dim()) rather than discarding it after opening storage, since a reopened overlay's persisted config is the source of truth for how to decode carried-over bytes — not necessarily identical to whatever live config the caller has to hand. Its now-unused flusher() is dropped: like every other update-only storage in this stack, a write is already durable when append_many/upsert_vector returns. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5f699480e0 |
[UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo) (#10161)
* [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo) Appendable/plain segments can carry live quantized vectors today: PlainVectorIndex:: update_vector calls quantized_vectors.upsert_vector alongside the raw vector on every insert (lib/segment/src/index/plain_vector_index/lifecycle.rs), auto-created for a fresh segment when appendable_quantization is on and the method supports it (QuantizationConfig::supports_appendable — Binary and Turbo only; Scalar/Product are policy-gated off regardless of storage backend). The update-only vector-storage stack (this PR's base) had no equivalent: UpdateOnlyVectorStorage::open never read quantization_config, and nothing under vector_storage/*/update_only/ mentioned quantization at all — a segment configured with quantization would silently lose it end-to-end once written through this path. This adds UpdateOnlyQuantizedVectors, mirroring QuantizedVectors' auto-create/reopen behavior but scoped to dense (single-vector) Binary/Turbo — the two methods that support incremental appends, matching current capability exactly (multivector support is a follow-up: it needs its own append-only offsets storage, mirroring MultivectorOffsetsStorageChunked the same way this mirrors QuantizedChunkedStorage). The only new machinery is UpdateOnlyQuantizedChunkedStorage, an EncodedStorage backed by UpdateOnlyChunkedVectors (append-only, S: UniversalAppend) instead of ChunkedVectors' positional writes (S: UniversalWrite) — everything else reuses the quantization crate's EncodedVectorsBin::encode/load and EncodedVectorsTQ::encode/load completely unchanged, since both are already generic over the storage backend. It writes files in the exact layout QuantizedChunkedStorage reads, so a promoted segment's quantized data reads through the existing, unmodified reader with no new reading code. UpdateOnlyChunkedVectors gains one addition: a `get` method to read back a single vector, needed because EncodedVectors::load validates the storage's vector size by reading vector 0 (skipped when the store is still empty). Verified: the update-only writer's persisted bytes, read back through the standard (non-update-only) QuantizedChunkedStorage + EncodedVectorsBin/TQ::load, match a RAM-backed reference fed the same vectors one at a time through upsert_vector, byte-for-byte, for both Binary and Turbo. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * [UpdateOnly] fix quantized reopen: resume writing shouldn't validate stored reads The previous commit made reopening a non-empty quantized overlay panic (EncodedVectorsBin/TQ::load validates a non-empty store by reading its vector 0, which UpdateOnlyQuantizedChunkedStorage's write-only design cannot serve) and worked around it with a redundant pre-check plus a todo!(), narrowing the tests to single-session-only writes. Both of those were the wrong fix. A writer resuming appends doesn't need `load`'s read-and-validate — it only needs the fitted metadata (encoding, stats) to keep encoding consistently, and that invariant already holds by construction: every vector this writer ever encodes is sized from the same `quantized_vector_size` `load` and the new path both read. Added `EncodedVectorsBin`/`EncodedVectorsTQ::reopen_for_write` to the quantization crate — identical to `load` minus the validating read — and switched `open_existing` to it. `UpdateOnlyQuantizedChunkedStorage` stays write-only as originally designed; no new read capability, no pre-check, no todo. Tests restored to the original two-writer split (write half, drop, reopen, write the rest), now genuinely exercising resume-with-data instead of avoiding it, and still passing byte-for-byte against the reference. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * [UpdateOnly] split EncodedStorage into EncodedStorageWrite + EncodedStorage A write-only storage (the update-only quantized overlay) had to fake a full EncodedStorage impl with unreachable!() read stubs just to satisfy EncodedVectorsBin/TQ's generic bound. Split the trait so a write-only backend only needs to implement EncodedStorageWrite; EncodedStorage adds the read methods on top. The overlay now implements EncodedStorageWrite alone — no panicking stand-ins for methods that don't exist. * [UpdateOnly] remove UpdateOnlyQuantizedVectors::create Nothing in this stack builds the first appendable segment of a collection yet (that's still a todo!() in edge/src/update_only), so create() had no real caller and open() had to guess from file absence whether to invoke it. open() now only reopens an overlay create() already persisted; the bootstrap logic moved into tests.rs as a private fixture helper, since tests still need it to build fixtures. * [UpdateOnly] fix CI: codespell typo and lint dead-code on unwired write path codespell flagged "implementors" (wants "implementers") in two doc comments. Separately, CI's lint job runs clippy without --all-targets, so the update-only quantized write path — genuinely unreachable from any non-test code until #10152 wires it into a segment — trips -D warnings dead-code. Scope #![allow(dead_code)] to the two files that are only exercised by their own tests today, and allow the now test-only UpdateOnlyQuantizedChunkedStorageBuilder re-export. * [UpdateOnly] fix ast-grep: use expect(dead_code) instead of allow * fix CI: remove unused EncodedStorageWrite import in gpu vector storage Left over from splitting EncodedStorage into EncodedStorageWrite + EncodedStorage; only caught under --all-features since gpu is gated behind a feature flag. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> |
||
|
|
ff9c7e3078 |
[UpdateOnly] implement the appendable vector storages (#10151)
* [UpdateOnly] drop the `UniversalWrite` bound from `UpdateOnlyChunkedVectors` Nothing in it needs random-offset writes: the config, the chunk listing and the status file all go through `UniversalReadFs` / `UniversalWriteFileOps`, which `UniversalAppend` already provides. The bound excluded the object-store backend this writer exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] implement `UpdateOnlyDenseVectorStorage` The vectors go into `UpdateOnlyChunkedVectors` and the deleted flags into `UpdateOnlyStoredFlags`, both of which already append; what this adds is the directory layout and the rule for a point with no vector under this name. Such a point still takes its slot, holding a placeholder, and is flagged deleted — slots are shared across every named storage of the segment, so skipping one would shift every later vector of this storage against the id tracker. Only the missing ones are flagged: an unflagged slot reads as present, and the mask is explicitly allowed to be shorter than the vector count, so a batch where every point has a vector rewrites no mask at all. `VectorToStore` is the input, mirroring the two halves of `FullyQualifiedPoint`: vectors the batch decoded, and storage-native bytes carried over from a point's previous slot which are appended without a decode round-trip. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] implement `UpdateOnlyMultiDenseVectorStorage` Rows here are not indexed by point slot — a point owns a run of them — so this writer tracks where the row space ends and places each run itself, reading the end from the chunked storage on open. A run that would straddle a chunk skips to the next one, as the writable side does, since a read of a multi-vector assumes its rows are contiguous within a chunk. The rows of a batch are therefore not always one span, and each span is appended on its own; the gap a skip leaves is zero-filled by the append that follows it. A point with no multi-vector here owns no rows at all: its offset entry says so. Unlike the single-vector storages there is no row to keep aligned, because the offsets are what map slot to rows. Adds `stored_len` and `remaining_chunk_keys` to `UpdateOnlyChunkedVectors` — the vector count read that #10114 dropped as unused now has a user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] implement `UpdateOnlySparseVectorStorage` The vectors go into `UpdateOnlyBlobstore` — the sparse storage is the one that was already blob-backed — and the flags into `UpdateOnlyStoredFlags`. A point with no sparse vector stores nothing at all, since the storage is keyed by slot and an unwritten slot is already "no vector"; it is flagged instead. `UpdateOnlyStoredFlags::open` now materializes its directory rather than waiting for the first flag. Storages use that directory as the marker that they exist: `MmapSparseVectorStorage::open_or_create` takes its absence for "not created yet" and starts a fresh storage over the top of the old one. A batch that flags nothing must still leave it behind. Caught by the resume test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] implement the TurboQuant vector storages `UpdateOnlyTurboVectorStorage` and its multivector counterpart. The quantizer is rebuilt from the dimension and distance rather than read back — it carries no learned state, so the two sides encode identically, which the test asserts by comparing the encoded bytes against what the writable storage produces for the same vector. The multivector one places runs of rows exactly as the plain multivector storage does, skipping to the next chunk rather than straddling one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] add `UpdateOnlyVectorStorage`, the dispatch over the five families Selects the writer from the vector config the way the writable side selects the storage, and refuses a storage type an update-only segment cannot have: the mmap ones are built whole rather than appended to, and the empty placeholder has no files. Sparse gets its own opener, since sparse vectors are configured separately from dense ones rather than through `VectorDataConfig`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix clippy under `-D warnings` - `is_multiple_of` in place of the manual remainder checks in the two multivector writers. - Drop the `dead_code` expectations on `UpdateOnlyChunkedVectors`: the vector storages use it now, so the expectation no longer holds. - Drop a `TypedMultiDenseVectorRef::from` that converts to its own type. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] fix a stale doc and an inconsistent guard The doc on `UpdateOnlyStoredFlags::open` still said nothing is created until the first flush, from before open started materializing the directory eagerly. And the span-merge guard in the multivector writer hedged with `dim.max(1)` while the same function divides by bare `dim` three lines up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] append a multivector batch's rows once, with the gaps as zero rows The chunk layer packs rows consecutively while runs must not straddle a chunk, so a batch's rows are not gapless. The old bridge grouped them into contiguous spans and appended each on its own, leaning on `ensure_chunk_lengths` — the repair path — to zero-fill the gap before every span, and saving the status once per span. Making the gaps explicit zero rows removes all of that: one append per batch, through the normal write path, one status save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fae2909daf |
[UpdateOnly] implement UpdateOnlyStructPayloadIndex, the per-segment fan-out (#10150)
* [UpdateOnly] implement `UpdateOnlyStructPayloadIndex`, the per-segment fan-out Every field index of one segment, opened for a batch and dropped with it — the update-only counterpart of `ReadOnlyStructPayloadIndex`, and the level `AppendableSegment` needs: it takes the points a batch stores and leaves every index of every indexed field current. It reads which indexes a field has from the payload config, exactly as the read-only side does, and holds nothing else. No payload storage, no id tracker, no vector storages: those are there to answer queries and to work out what an update means, and by the time a batch reaches here that is settled — each point arrives with the payload it will be stored with. Every field is offered every point, including points whose payload holds nothing under it. An index that stores values per point stores none for those; the null index records that the point has no value there, which is the whole reason it exists. That is simpler than the writable path's add-or-remove split, which is only needed because a slot there may already hold something. A field whose index types the config does not spell out is refused. That config predates those types being recorded, and the writable index repairs it by deriving them from the schema on its next open; this writer builds no indexes, so it cannot, and going on would leave whatever is on disk to rot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] trim the doc comments on the payload index fan-out Keep the guarantees and the non-obvious rationale, drop the restatements. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c186f9d2e8 |
[UpdateOnly] implement UpdateOnlyFieldIndex for the appendable payload indexes (#10147)
* [UpdateOnly] implement `UpdateOnlyFieldIndex` for the appendable payload indexes The payload index write half for the update-only segment writer, over a backend that only appends. An appendable field index keeps two things: the values it persists per point, and the in-memory structure it answers queries from. Only the first is state — the second is rebuilt from it on every open, by the mutable index and by its read-only counterpart alike. A writer that never answers a query therefore holds nothing: it turns a point's payload into the values its index would persist, appends them at the point's slot, and is done. What differs between index types is only that translation, so that is all `UpdateOnlyIndexKind` captures; `UpdateOnlyValueIndex` is the storage around it, the same for all of them, and each kind lives next to the index it writes for as the read-only counterparts do. The extraction itself is taken from the index types' own `ValueIndexer` and `NumericIndexIntoInnerValue` impls rather than restated, so the two sides cannot drift apart. `UpdateOnlyFieldIndex` dispatches over the nine covered index types, mirroring `ReadOnlyFieldIndex`. What the writer emits is the append-only mode of the very same storage the mutable index writes, and `Blobstore` selects the mode from the persisted config, so the read side needs no change: every test here writes through the update-only writer and reads back through the ordinary appendable index, opened on the directory the writer produced. The boolean and null indexes are not covered and are refused loudly rather than skipped. They keep a bitmask over all points instead of values per point, and persist it through random-offset writes, which an append-only backend does not offer. A skipped index goes stale and then answers queries wrongly, and the null index complements every other index of every indexed field — so a caller that took a silent skip for "nothing to do" would leave every field it touched wrong. Covering them needs an append-only bitmask representation first. That is also why this stops short of the struct-payload-index fan-out: until bool and null can be written, a component that claims to keep a field's indexes current could not. `UpdateOnlyPayloadStorage` moves onto the shared `UpdateOnlyBlobstore` extracted here, which is what it already was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] share the flattening, tokenization and flush guard Cleanup pass over the field-index writers, from a reuse/simplification review. Share what was restated: - `ValueIndexer::flatten_values` is now a provided method holding the loop that `add_point` had inlined. The update-only kinds call it instead of the free `extracted_values`, which built one throwaway `Vec` per input value on top. - `FullTextIndex::tokenize_document` and `serialize_stored_document` hold the sentinel placement and the phrase-matching order-vs-sort decision that the update-only kind had copied out of `MutableFullTextIndex::add_many`. Both sides call them, so a document written by one always matches the phrases the other would. Simplify: - `UpdateOnlyFieldIndex::open` matches on the index type alone and takes the text params via `TextIndexParams::try_from`, as `ReadOnlyFieldIndex::open` does. That drops the schema tuple, the nine-arm mismatch block and the `Option` return. - The `UuidIndex` variant is gone: that discriminant is historically map-backed, and both the writable selector and the read-only mirror already collapse it into `UuidMapIndex` — its `storage_dir` is `map_dir`, so a numeric-kind writer was writing into a directory everyone else opens as a map index. - Why bool and null cannot be written append-only now lives in `PayloadIndexType::is_append_only_writable`, next to `storage_dir`, so that whoever decides a field is update-only-serviceable can ask rather than rediscover it; `open` consults it as a backstop. - Dead `new()` constructors on the two zero-sized kinds. Skip the flush when nothing was buffered, in `UpdateOnlyBlobstore` rather than in one caller: a flush with nothing to write still syncs every page file of the storage, and for a field index an empty batch is the common case — every point that lacks the field, or holds a value the index rejects, stores nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] move each index kind under the appendable index it writes for `numeric_index/update_only.rs` and its three siblings sat at the index-type level, next to the enum over all three storage variants, although each writes for the appendable variant alone. They now live at `<index>/mutable_<index>/update_only/`, beside that variant's `read_only/` counterpart, which is the same split for the same reason. `mutable_text_index` is private, so the text kind is re-exported from `full_text_index` for the dispatch enum to name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] cover the bool and null indexes by rewriting their masks whole These two keep a bitmask over all points rather than values per point, so keeping one current means changing bytes in the middle of it — which an append-only backend cannot do. It can replace a file outright, and that is enough: `UpdateOnlyStoredFlags` reads the mask into memory on open, sets the batch's bits, and writes both files back whole, in the same format `DynamicStoredFlags` uses. A reader cannot tell which side produced them. The mask goes out before the length that publishes it, so a torn batch falls back to the shorter mask rather than to flags that were never written, and the whole-file write is charged to the hardware counter at flush — it is the write that actually happened, not the handful of bits the batch touched. `UpdateOnlyBoolIndex` and `UpdateOnlyNullIndex` sit on that, next to their mutable index like the other kinds. The null classification (which values count as present, which as null) moves into `classify_payload`, shared with `MutableNullIndex::add_point`, and the boolean one reuses that index's own `ValueIndexer`. Both are recorded for every point of a batch, including those whose field holds nothing: "this point has no value here" is precisely what these indexes are asked. With that, `UpdateOnlyFieldIndex` covers every index type `ReadOnlyFieldIndex` does, so the refusal and `PayloadIndexType::is_append_only_writable` are gone. The cost is that a batch rewrites the entire mask however few bits it touched — about 1.2 MiB per flag set for a segment of ten million points. Documented on the writer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] trim the doc comments on the field index writers Keep the guarantees and the non-obvious rationale, drop the restatements and the comments that narrate the next line. One code change: `values.contains(&true)` in place of `values.iter().any(|value| *value)` on the boolean index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
28e26b8888 |
[UpdateOnly] implement UpdateOnlyPayloadStorage (#10146)
* [UpdateOnly] implement `UpdateOnlyPayloadStorage` The payload write half for the update-only segment writer: a short-lived storage opened for one batch and dropped with it, over a backend that only appends. Backed by a `Logstore` — the append-only mode of the same storage the writable `PayloadStorageImpl` uses — so a slot's payload is written once and never rewritten. `append_many` takes one payload per point at the slot the ID tracker claimed for it and flushes, so a batch is durable when the call returns and nothing is buffered across calls. Puts only buffer, so the flush is what touches the files: one append per touched page file plus one to the tracker, regardless of how many points the batch holds. A point with an empty payload is skipped, since an unwritten slot already reads back as an empty payload, and so is any gap between slots, which the tracker materializes as unmapped entries. `Logstore` had to leave the `Blobstore` facade for this: `Blobstore`'s type is bound at `UniversalWrite + UniversalAppend` for the sake of its `Gridstore` variant, so it cannot be named on a backend that only appends. Its cross-crate surface is `open_or_create`, `put_value` and `flusher`, nothing more; the new `open_or_create` mirrors `Blobstore`'s and rejects a storage created in mutable mode rather than opening it. Not wired into `AppendableSegment` yet — `store_points` stays `todo!()` until the vector storages and field indexes exist, as with `UpdateOnlyChunkedVectors`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] trim the doc comments on the payload storage writer Keep the guarantees and the non-obvious rationale, drop the restatements — the merged-baseline style of `UpdateOnlyChunkedVectors` and `AppendableSegment`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
671a19f30c |
[UpdateOnly] split UpdateOnlySegment into its lookup and writer phases (#10142)
* [UpdateOnly] split UpdateOnlySegment into its lookup and writer phases Applying a batch runs in two phases that agree on almost nothing, and `UpdateOnlySegment` was both: `resolve.rs` used every field, `append.rs` used none of them and could not — a `ReadOnlyPayloadStorage` has no append path. The `fs` field existed only for the writes that were never wired up. Split along that line: * `LookupSegment` (was `UpdateOnlySegment`) is the read phase. Every segment of a shard is opened as one, on read-only bounds, and the phase above them aggregates. Loses the dead `fs` field. * `DeleteOnlySegment` and `AppendableSegment` are the write phase, one segment each, `UpdateOnlySegmentEnum` over the two. Opened for one batch and dropped with it, matching the append-only components, which buffer nothing across calls. The phases meet at `SegmentWriterState`, produced by `LookupSegment::writer_state` and consumed by `UpdateOnlySegmentEnum::open`. It carries the mappings-log tail an appendable writer resumes from, which `UpdateOnlyAppendableIdTracker::new` requires to come from one and the same read of that log. The writer kind follows the id-tracker format that was loaded, not the segment config: the format decides how a point is retired. That difference makes `tombstone_points` take both ids, `(external, slot)`; an immutable segment marks the slot in its deleted-points bitmask, an appendable one records a retirement for the id in its mappings log. The appendable half is implemented — deletes now run end-to-end. `store_points` and the immutable bitmask remain `todo!()`, still waiting on the append-only storages and field indexes. Two bugs surfaced while wiring it up: * A point stored into the write target must not have its old slot retired there: appending records a mapping that supersedes it, and retiring the id on top would take the new slot with it. * A second `apply_batch` through one writer resurrected deleted points. It resumed the log from the `mappings_end` its own first batch had moved past, and appending there cut that batch's entries off. Refused now; lifting it means reloading the segments after a batch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] one batch per writer, enforced by the type system Cleanup pass over the phase split. `apply_batch` now takes `self`. It could only ever serve one batch — the segments are read when the writer opens, and that read is both what a batch resolves against and what its writers resume from — and the runtime guard enforcing that cost a flag, its doc, two imports, a hand-maintained `writes_anything` condition, an error branch and a test. Consuming the writer makes the second call a compile error instead. Also: * drop `LookupSegment::uuid`, which nothing ever read, along with the two parameters and the argument that fed it; * `AppendableSegment::tombstone_points` was a copy of the tracker's own `retire_pending_inserts`; both now go through `delete_points`; * fold the duplicated "segment disappeared mid-batch" error into `LookupSegmentHolder::get`, and restore `write_target_uuid` as an `Option`, which is what two of its three callers wanted; * one fixture helper for the writer tests instead of three copies; * state the mappings-log co-read invariant once, with pointers, instead of three times. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * [UpdateOnly] cut the writer surface down to what it does * `flush()` is gone from both writers and the enum. Both bodies were `Ok(())` and would stay that way: the id tracker persists what it writes before returning, and the deleted-points bitmask writer does not exist yet. The ordering it looked like it enforced — new slots durable before the tombstones retiring the old ones — falls out of call order, since every write is durable when it returns. Bring it back with the first storage that buffers. * `SegmentWriterState` was an enum of one unit variant and one payload, which is `Option`. `writer_state()` returns `Option<AppendableIdTrackerState>`, and `None` reads as what it means: no mappings log to resume, so a delete-only writer. * `LookupVectorData` wrapped a single `Arc<AtomicRefCell<_>>`; the map holds it directly now. * `appendable` joins the five `pub` fields around it, and `is_appendable()` goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fe5a063dc2 |
Add UpdateOnlyAppendableIdTracker, the append-only ID tracker writer (#10093)
* Add UpdateOnlyAppendableIdTracker, the append-only writer The write counterpart of `ReadOnlyAppendableIdTracker`, producing the two files that tracker already consumes — `mutable_id_tracker.mappings`, an append-only log of mapping changes, and `mutable_id_tracker.versions`, a dense array of one version per slot — through `UniversalAppend`, so the same code drives a local file and an object store. `insert_operations` records a batch of `MappingOperation`s in order: an insert claims the next slot above the highest one in use and reports it, a delete retires an external id and claims nothing. Nothing is rewritten in place, so re-inserting a live id moves it to a fresh slot and supersedes the old one — the update-only shape of an update. `set_internal_versions` extends the versions array. Ids may come in any order but must be exactly the slots the array does not cover yet: a slot below the end would need an in-place overwrite, and a hole would have to be zero-filled — and since "covered by the versions file" *is* the commit signal for readers, that would publish a slot as a live point of version 0 before its data exists. Both are rejected rather than written. Both methods append at an offset they probed for, never at an implicit end: the offset is a compare-and-swap token, so a file that has moved on since the probe is rejected instead of being written twice or in the wrong place. Both have persisted what they wrote when they return `Ok` — append, then run the handle's flusher — and nothing is buffered across calls. The order of the two calls, claim the slot then commit the version, is what makes a crash in between safe: readers ignore slots the versions array does not cover. Cleaning up the slots such a crash abandons is left to the opener, along with repairing a torn tail; the writer fails loudly rather than guessing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Share the versions file format between both write paths `set_internal_versions` was reimplementing what `versions_storage` already knew: entries are `SeqNumberType`-sized, slot `n` lives at `n * VERSION_ELEMENT_SIZE`, a file length that is not a whole number of entries has a torn tail. Every one of those facts existed in two to four places, spelled as inline `/`, `%` and `write_u64::<FileEndianess>`, so the append-only writer could drift away from the in-place one silently. Move them into `versions_storage`, which now owns the format for both writers and both readers: - `write_version` / `read_version`, the entry codec, with a static assertion tying its `u64` to `VERSION_ELEMENT_SIZE` so a change to `SeqNumberType` cannot silently shrink every offset; - `version_offset` and `versions_byte_len`, the slot arithmetic; - `VersionsLayout`, which splits a file length into committed entries and a partial tail. The two writers still react differently — the in-place one truncates the tail, the append-only one refuses it, because an append cannot — but they no longer each work out what the tail is. `store_version_changes`, `load_versions`, `set_internal_versions` and the read-only tracker's live reload all go through it. The write loops themselves stay separate: one seeks to sparse offsets, the other emits a validated consecutive run, and merging them would obscure both. What they share is where the bytes go, which is the part that must not diverge — and a new test pins it down by writing the same versions through both writers and comparing the files byte for byte. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Append mapping and version entries as batches Both writers built one concatenated buffer and handed it to `append`. `append_batch` takes the entries as separate buffers and places them in a single operation — a vectored write locally, one request on an object store — so the entry boundaries reach the backend instead of being flattened away first. Versions are fixed-size, so the entries are the payload's `chunks_exact`. Mapping changes are variable-length, so their bounds are recorded as they are serialized. Both keep the compare-and-swap offset, which `append_batch` validates the same way `append` does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Test the writer against MutableIdTracker end to end The suite leaned on round-trips through the storage loaders, which only restated what the writer had just written. Four such tests are replaced by one that checks the property actually worth having: drive the append-only writer and `MutableIdTracker` with the same points, versions and deletes, open each segment through `ReadOnlyAppendableIdTracker`, and require the two views to be indistinguishable — counts, deleted state, external ids, live points' versions, and id resolution. Versions are compared for live points only. `MutableIdTracker::drop` overwrites the slot with `DELETED_POINT_VERSION`, which an append cannot do, so the append-only writer leaves the point's original version there; neither is observable for a point that is gone. The remaining tests keep what a round-trip cannot show: slot allocation across calls, instances and deletes (three tests folded into one), the rejection of holes and rewrites, and the byte-for-byte agreement of the two writers on the versions file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * better buffer names * fmt * Heal a torn versions tail instead of refusing to write A writer that dies mid-entry leaves the versions file ending inside a slot. The in-place writer already truncated that tail before writing; the append-only writer refused, which left the file unwritable forever since an append cannot truncate. Share the decision — what counts as torn, the healthy length, the warning — in `heal_versions_tail`, and let each writer supply the shrink its backend can do: `set_len` in place, or reading the committed prefix back and putting it in place as a whole file where there is no truncate. Dropping the tail loses nothing: the array covers a slot only once its whole entry is there, so a partial entry belongs to a slot no reader ever saw and no writer counted as committed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Append mappings at the end of the log, not the end of the file Mapping entries vary in length, so no length tells you whether the log ends on an entry boundary. Appending at the file's end therefore could not fail: a torn entry was silently appended after, and every entry from there on was framed off the stray bytes. Carry the boundary instead. `new` takes the offset just past the last complete entry — `ReadOnlyAppendableIdTracker::mappings_read_to`, from the same view that supplies `max_internal_id` — and appends there, which turns a file ending elsewhere into an append offset conflict. On that conflict `heal_mappings` cuts the file back to the log's end, the same read-prefix-and-rewrite the versions file heals with, and writes the batch again. A torn entry and a batch that landed unacknowledged are indistinguishable without parsing, and need not be told apart: neither `max_internal_id` nor `mappings_end` moves before an append is durable, so the retry writes the same bytes at the same offset. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Move the healing functions into their own file `update_only/mod.rs` had grown to hold the writer, its two public write paths and the two repair routines they fall back on. Split the latter out: `heal_versions` and `heal_mappings` move verbatim into `update_only/heal.rs`, as a second impl block, following the layout the read-only half already uses (`lifecycle.rs`, `live_reload.rs`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Retire inherited pending inserts when opening the writer A slot is spoken for from the moment the mappings log claims it — components may already have written data at it — so the writer must resume above every slot the log ever handed out. Deriving that bound from a reader's point set undercounts it twice over: a claimed slot whose version was never committed is not in the mapping, and one whose external id was deleted afterwards is not among the pending inserts either. Track it in the log instead, as `ReadOnlyAppendableIdTracker::max_claimed_internal_id`, bumped on every insert entry regardless of what becomes of the point, and take it as `UpdateOnlyAppendableIdTracker::new`'s bound. The points on those claimed-but-unversioned slots are the other half. They cannot be adopted: a writer stopped partway through them, so some components hold their data and others do not, and which is unknowable here. They cannot be left alone either, the versions array being dense — covering any slot above one of them publishes it, half-written. So `new` now takes the pending inserts explicitly and retires them, recording a `Delete` per id before the writer can be used at all, which is what makes it fallible. Doing it at construction rather than lazily on the first write means no write path can be added later that forgets to. `set_internal_versions` accordingly stops rejecting holes: it writes the whole run from the end of the array through the highest id given, covering skipped slots with `DELETED_POINT_VERSION` as the in-place writer's seek already does. It gains an upper bound in exchange — publishing a slot means covering every slot below it, so an id the log never claimed is refused. Live-reload semantics are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Skip UpdateOnlyAppendableIdTracker heal tests on Windows Healing replaces the file via atomic_save while an mmap handle is still open, which Windows denies with os error 5. * Drop mmap handles before healing ID tracker files atomic_save cannot replace a path while an mmap is still open on Windows. Copy the committed prefix, drop the handle, then rewrite and reopen. * Trim ID tracker docs and drop redundant helpers Condense the doc comments on the update-only tracker to the style of the sibling modules, merge a duplicate impl block, and remove `read_version` and a debug assert that restates its own operands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Drop the versions layout helpers and inline the arithmetic VersionsLayout, versions_byte_len and version_offset wrapped one divmod and one multiplication between them, and adopting the struct made the live-reload hunk longer than the line it replaced. Compute the committed length where it is needed instead, and let heal_versions_tail and heal_versions return unit, since no caller used the layout they handed back. Also drop the writer's unused max_claimed_internal_id accessor, and fold retires_inherited_pending_inserts_at_construction into retires_inherited_pending_inserts: the merged test asserts the retirement happened before the writer did anything, and commits a real version to the retired slot rather than letting it take the filler, so it still shows the Delete is what hides the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com> |
||
|
|
e01d91aee5 |
test(resharding): crash a follower, not the leader, in the scale-down revert test (#10104)
The crashing peer was picked positionally, so it could be the raft leader. The staging crash exits inside the apply of the `Dead` entry while raft messages leave through an async send queue, so a crashing leader takes the append carrying the new commit index down with it. The other live peer is then left holding that entry appended but uncommitted and, as one voter out of three, can never commit it: it never aborts resharding, and the test times out waiting for its resharding state to clear — the restart that would restore quorum only comes after that wait. Pick the victim after the receiver is killed instead: wait for the two live peers to agree on a live leader, keep that leader as the survivor and crash the follower. The survivor then commits and applies the abort locally, with no commit index left to escape a dying process. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
278140c482 |
Add /profiler/consensus_lag to measure apply lag between peers (#10090)
* Add /profiler/consensus_lag to measure apply lag between peers Raft commit index advances on a peer whose apply loop is stalled, so the existing signals - `raft_info.commit` and the `all_nodes_have_same_commit` test helper - report a stuck peer as healthy. Nothing exposes how long a peer has been behind at *applying* entries, which is what shard transfer's `await_consensus_sync` barrier actually waits on. Each peer now keeps a ring of the last 32 entries it applied, stamped with its own wall clock and the time that entry took to apply. The ring is in memory on ConsensusManager, not in Persistent, so the on-disk format is untouched. `/profiler/consensus_lag` collects those rings from every peer over a new internal RPC and lines them up on the entry indices they share. Each entry is measured from whichever peer applied it first, so a lag is never negative; the peer that is first can differ per entry, so the baseline is per entry rather than a single chosen peer. Entries only one peer still remembers are excluded, otherwise a peer would be measured against itself. A peer stalled part-way through an entry keeps healthy lag statistics - everything it did apply, it applied on time - so the report carries `behind_entries` and `newest_applied_age_ms` alongside, which is what actually exposes the stall. Peers that fail or time out are listed rather than failing the request: a partial answer is more useful than none when the point is to find a peer that stopped answering. The endpoint follows `/profiler/slow_requests`: manage access, and outside OpenAPI, so no endpoint-count or ACTION_ACCESS guard applies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Move applied-entry log into its own module Keeps the new code out of files that are already large. The ring, its entry type and the snapshot served over RPC move to `content_manager/consensus/applied_log.rs`, alongside the other consensus internals; `ConsensusManager` is left with a field, an accessor and the one `record` call in the apply loop. The grpc encoding moves next to the decoding it mirrors, in `common/consensus_lag.rs`, leaving the internal service handler three lines instead of thirty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Test that a consensus stall is still in the report after the peer catches up * Take each peer's applied index from consensus state, not its ring --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: tellet-q <elena.dubrovina@qdrant.com> |
||
|
|
15e7aa38e8 |
[UIO] Pin the append filesystem to its handle (#10092)
`UniversalRead::Fs` was reachable from an append handle and known to be
a `UniversalWriteFileOps`, but not pinned: `fs.open_append(..)` on it
returned some `Fs::AppendFile`, not the handle type in hand. Pin it the
way the read side is pinned, so `S::Fs` both opens `S` for reading and
hands `S` out as its append handle:
UniversalRead<Fs: UniversalWriteFileOps<AppendFile = Self>>
Every append handle's canonical filesystem already produced itself
(`MmapFs → MmapFile`, `IoUringFs → IoUringFile`, `BlobFs<A> →
BlobFile<A>`), so this only writes down what held — the workspace
compiles unchanged.
Generic-over-`<S: UniversalAppend>` code now reaches its file-creating,
append-opening filesystem as `S::Fs` with no second associated type, and
`&impl UniversalWriteFileOps<AppendFile = S>` accepts any other producer
— the mirror of `&impl UniversalReadFs<File = S>`.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
f39b1bc6fb |
Bump edge packages (Python + Rust + FFI) to 0.8.0 (#10098)
Minor version bump of the edge packages from `0.7.2` to `0.8.0`. - `lib/edge/python/Cargo.toml`: `qdrant-edge-py` 0.7.2 -> 0.8.0 - `lib/edge/publish/amalgamate.py`: `VERSION` constant bumped (`qdrant-edge` on crates.io) - `lib/edge/ffi/Cargo.toml`: `qdrant-edge-ffi` bumped, kept in sync with the other two as its header comment requires - `lib/edge/publish/ast-grep-rules.yaml`: inline package version comment updated (it was stale at 0.7.1) - `Cargo.lock`: regenerated version entries Follows the same pattern as #9252. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
2cba392cc6 |
[UIO] Open append handles through UniversalWriteFileOps (#10091)
A file handle that appends was only reachable through
`UniversalReadFs::open` with `writeable: true`, which ties the append
capability to the backend's read handle type. Give the write-side
filesystem trait its own opening path instead:
type AppendFile: UniversalAppend;
fn open_append(&self, path, options) -> UioResult<Self::AppendFile>;
`AppendFile` is deliberately not tied to `UniversalReadFs::File`: the two
capabilities live on independent traits, so a backend may serve reads
through one handle type and appends through another, and a filesystem
that opens no read handles at all still names an append handle. For the
same reason there is no `OpenExtra` parameter — the append handle may
come from a different backend, whose per-open knobs would not apply.
`OpenOptions::for_append` forces `writeable` on, since a read-only
append handle is a contradiction rather than an error worth propagating.
Drop the `UniversalWriteFileOps` impls on the two disk caches first.
Both were vestigial: `DiskCacheFs` got its forwarding-to-remote impl
mechanically in the read/write trait split (#9682) and no caller ever
used it, and `BlockCacheFs` lives in a module that is dead code. Neither
cache can open a writeable file, so neither can produce an append
handle; mutations go straight to the backing storage. An
`assert_not_impl_any!` locks this in next to the existing one on
`DiskCache`.
`BlobFs` consequently requires `AsyncAppend` rather than `AsyncWrite`:
only a backend with a native single-request append can hand out an
append handle. Object stores that can just put whole objects (GCS,
Azure) stay read-only through universal I/O; nothing used their write
side.
The conformance suite gains `run_open_append_conformance`, run by
`run_append_conformance` and public for filesystems whose own `File`
does not append. It covers `MmapFs`, `IoUringFs` and `BlobFs` over the
in-memory object store.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
74f3e85b94 |
Bump version to 1.19.0 (#10084)
* Bump version to 1.19.0 * Update missed cherry picks * Add OpenAPI spec for v1.19.x Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c34acc1a36 |
Global quota API (#10035)
* feat: global quota API Memory and disk are node-wide resources, so configuring their thresholds per collection through strict mode makes little sense. Move them behind a single cluster-wide `QuotaManager`. The quota config is seeded from `storage.quotas` in the settings (and so from env vars), overridden by `quota.json` in the storage directory, and updated cluster-wide through a new `SetQuotaConfig` consensus operation which rewrites that file on every peer. Raft snapshots carry it too, so a peer that joins by snapshot picks it up. Quotas are enforced wherever the strict mode memory and disk checks used to run, but no longer gated behind `strict_mode.enabled`: a value set in an enabled strict mode config still wins per resource, the quota is the default. Rejections name both the condition that tripped and the config that governs it. `GET /quotas` reports the config plus current utilization to global read users; `PUT /quotas` replaces it for global manage users. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: cover the quota endpoints in the API consistency checks `test_all_rest_endpoints_are_covered` and the OpenAPI endpoint count both break on any new REST endpoint. Add `GET`/`PUT /quotas` to `ACTION_ACCESS` with their JWT access tests, and bump the expected API count. The quota endpoints stay out of `REST_ENDPOINT_WHITELIST`: that list is for data-plane endpoints reported per-endpoint in metrics. Also add a Raft snapshot CBOR compatibility test — snapshots are exchanged between peers of different versions during a rolling upgrade, so `quota_config` must be absent-tolerant in both directions. Review feedback: persist through `SaveOnDisk`, which already implements the write-before-swap protocol this was doing by hand; validate the config at both persistence boundaries, since a hand-edited quota file or a config arriving through consensus does not pass the REST handler's validation, and a `0%` limit would reject every update forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: assert seeding a quota from invalid settings persists nothing Follow-up to review feedback claiming `SaveOnDisk::load_or_init` writes the init value before it is validated. It does not — only `SaveOnDisk::new` persists — but the property matters: were seeding to persist first, invalid settings would leave a `quota.json` that fails validation on every subsequent start, and the node could only be recovered by deleting it by hand. Pin it down with a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: make QuotaManager the single reader of memory and disk The quota checks measured memory and disk themselves, while the optimizer and the WAL disk watcher each called `fs4::available_space` behind their own ad-hoc caches. Fold all of it into QuotaManager: it owns the readings, the freshness policy, and the limits they are compared against. Moves the module to `lib/shard`, since the optimizer sits below `storage` and has to reach it; `storage::quota` re-exports it, so consensus, the `/quotas` API and StorageConfig are unchanged. The manager is installed as a process singleton by TableOfContent, ahead of loading any collection. - Callers hand in QuotaLimits overrides instead of a StrictModeConfig, and an override can now only tighten. A collection-level admin could raise `max_disk_usage_percent` past a cluster-wide limit that needed global manage rights to set; ties resolve to the quota so the rejection names the knob that actually has to change. - Measurements are cached for 5s, but a reading at or above its limit is never reused: a rejected client retries, and freeing the resource has to take effect on the next request rather than a TTL later. - `fits_on_disk` sizes an optimization against physical free space only, never the configured limits. Optimizations are what free a full disk, so the quota must not be what stops one. - `percent_of` widens to u128 instead of saturating the multiply, which under-reported utilization (failing open) above ~184 PB. - StorageConfig::quotas is optional; absent means no quota is enforced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: don't recover dead replicas onto a node at a resource limit Recovering a dead replica pulls a whole copy of its shard onto this node. If it is already at its memory or disk quota that transfer cannot finish, and starting it only pushes the node further past the limit. Skip it and reconsider on a later sync, once the resource frees up. Adds QuotaManager::check_capacity for work that lands bytes here without being an update. Unlike fits_on_disk the configured limits do apply: taking on a replica is not what frees a full node, so there is no deadlock to avoid by letting it through. The check is hoisted out of the per-shard loop because a node over its limit re-measures on every call, so checking per dead shard would cost a statvfs each. It is free when no quota is configured. Also trims the comments across the quota module, which had grown well past what the code needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test: drop trivial and duplicated quota tests Six tests removed, ~140 lines, with no loss of coverage: - a_rejection_names_the_knob_that_has_to_change asserted that a format! contains its own literals; the message is covered end-to-end by the override test and by test_global_quota.py. - a_node_over_its_quota_has_no_capacity_to_take_on_a_replica was 30 lines for check_capacity, a one-line delegation to check_update the test above it already calls. - a_rejecting_measurement_is_never_served_from_the_cache duplicated the meter test, which proves the same rule with an injected reader instead of inferring it from the real filesystem. - free_space_is_reported_without_enforcing_anything covered a one-line accessor, and its point is what the fits_on_disk test is for. - The two resolve tests and the three meter tests each collapse into one. DiskFit::Unknown keeps its coverage as two lines inside the fits_on_disk test rather than its own fixture. The snapshot compat pair becomes one test: the second only asserted cluster_metadata.is_empty(), which says nothing about quotas — the real check was the deserialize, now an expect that states it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: re-measure free space as the disk fills, and drop a Windows-only assert Two CI failures, both from this branch. e2e test_low_disk: the DiskUsageWatcher I replaced escalated to checking on every call once free space fell below 512 MB. Folding it into the quota manager lost that — available_bytes passed no limit, so a reading was reused for the full 5s however little space was left. On a disk filling as fast as that test fills it, 5s blind is enough to actually run out and the WAL write dies instead of returning "No space left on device". available_bytes now takes a watch_below level and never reuses a reading under it, which is what the old ladder was expressing. The watcher passes max(min_free, 512 MB), so the escalation point is back; above it the 5s cache still costs fewer syscalls than the old 128-call ladder. fits_on_disk gets the same rule by passing required_bytes, so a merge that does not fit re-checks rather than sitting on a stale sample. Windows: fits_on_disk on a missing path was asserted to be Unknown, but GetDiskFreeSpaceEx resolves up to the containing drive and succeeds — as common::disk_usage's own test documents. Dropped; the branch is a two-line else and is not portably reachable. Also renames an_optimization_is_sized_against_the_disk_not_the_quota, which needed explaining to be understood. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report the quota config in telemetry Reads it from the quota manager rather than the settings, so it is the config the node is actually enforcing: a peer that missed a consensus update reports what it is applying, not what the cluster agreed on. Gated on global access, the same access `GET /quotas` requires, and left out of `PeerTelemetry` — a quota is per-node state, so each peer reports its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: regenerate OpenAPI, and cover the quota in the telemetry key sets Two CI failures from the previous commit. Referencing QuotaConfig from TelemetryData moves its definition earlier in `components/schemas`, because TelemetryData is generated ahead of QuotaStatus. Regenerated rather than hand-patched, so the schema is a pure move. test_telemetry_detail asserts the exact set of top-level telemetry keys. The quota is reported at every level, including 0 — it is three scalars, it is the default the endpoint serves, and it is what explains an update being rejected — so both key sets gain it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: remove `max_disk_usage_percent` from strict mode Disk is a node-wide resource, so a per-collection percentage of it never meant anything a caller could act on: the limit describes how full the *node* is, and which collection the write happens to target has nothing to do with it. The global quota is where it belongs. It shipped in 1.18.2 without documentation, so this drops it outright rather than deprecating. Removal is soft in every direction: StrictModeConfig has no `deny_unknown_fields`, so a client still sending it gets it ignored rather than a 400, and the same struct deserializes the persisted collection config, so collections created on 1.18.2+ keep loading. Proto field 22 is reserved so the number is never reused. The e2e test becomes a quota test — the fixture and the timing are the interesting parts and they carry over unchanged; only how the threshold is configured differs. `max_resident_memory_percent` was documented and stays for now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: enforce the strict mode memory limit outside the quota `max_resident_memory_percent` was folded into the quota as an override, which meant the quota check had to know about strict mode, and retiring the setting would mean unpicking `EffectiveLimit` and `LimitSource` from the resolution logic. It is now a check of its own in `verification/mod.rs`, next to the strict mode checks it belongs with, borrowing only the measurement from the quota manager — which stays the node's single reader of process memory, so both checks still share one reading. Deleting the setting later is deleting one function and its one caller. `QuotaManager::check_update` takes no arguments and consults the quota alone. A collection can still only tighten the limit for itself, because its own check runs in addition rather than in place of the quota's, and each rejection now names the config that has to change without having to carry a `LimitSource` to say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: enforce the quota on the update path, not in strict mode The quota check sat inside `check_strict_mode_toc_batch` only because that was the one place holding the collection's strict mode config. It doesn't need one any more, and the placement had a real cost: coverage depended on each handler remembering to ask for a strict mode check, and four of the internal update RPCs do — `sync_internal`, which moves the most bytes onto a node, does not. It now runs in `Collection::update_from_client` and `update_from_peer`, which every update passes through. `update_from_client` checks ahead of the shard split, so an operation is accepted or refused whole rather than landing on some shards and being refused by others. Classification moves with it, from ~10 `consumes_memory` impls on request DTOs to one exhaustive `CollectionUpdateOperations::consumes_quota`. The internal enum has variants — raw upserts, conditional upserts, the syncs — that have no client-facing request type, so per-DTO impls structurally could not classify them. Shard-transfer syncs stay excluded, as they are today: a transfer is sized up once before it starts, and refusing its batches partway abandons work that is nearly done only for it to restart from the beginning. Index and named-vector creation reach shards through consensus, past this check — a peer must not refuse what the cluster agreed to — so they keep their pre-consensus check, now against the quota manager directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: deprecate `max_resident_memory_percent` in strict mode Same reason the disk threshold went: memory is node-wide, so a per-collection percentage of it caps how full the *node* is, which has nothing to do with which collection is being written to. The node-wide quota caps it once for everything. Unlike the disk threshold this one shipped documented, in 1.18.0, so it keeps working — as a limit a collection can tighten for itself, never lift — and gets the usual markers: `#[deprecated]` on both Rust structs, `[deprecated = true]` on proto field 21, and `deprecated: true` in the OpenAPI schema, which schemars derives from the attribute. The note names 1.21 as the removal. Recording a version matters here: the audit in docs/plans/overdue-deprecations.md found that this repo has never written a removal deadline down, and members of the 1.15.0 deprecation batch are still in tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: reconcile the quota readers with #9891 #9891 landed effective (cgroup) figures in telemetry while this branch was making QuotaManager the single reader of memory and disk. Two collisions, neither of which git sees. `segment::utils::mem::total_memory_bytes` is now a shared accessor with a 5s TTL, so a cgroup resize is picked up. The quota module had its own `OnceLock` copy that froze the value at startup — exactly what #9891 set out to fix — so it delegates to the shared one instead. Telemetry's new `disk_size` called `common::disk_usage::disk_usage` directly. That reader lost its TTL cache on this branch when the caching moved into the quota manager's meter, so it would have taken an uncached `statvfs` on every telemetry request, and it put a second disk reader back in the tree. It goes through `QuotaManager::disk_capacity_bytes` now, sharing the reading the quota check already takes. Verified it still reports the storage filesystem, matching `df`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: split the quota manager by what each half does `manager.rs` had grown to 450 lines holding three separate jobs: owning the config and its file, taking the readings, and comparing one against the other. - `manager/store.rs` — the `Store` enum, `QUOTA_CONFIG_FILE`, and config validation, which is now the store's own business rather than something every caller has to remember to do first. - `manager/measure.rs` — every reading, and `DiskFit`. The "nothing else calls `statvfs` or reads process RSS" claim is now checkable by looking at one file. - `manager/enforce.rs` — `check_update` / `check_capacity` and the threshold comparison. - `manager/mod.rs` — the struct, its construction, and the config accessors: what a reader needs to see first. Tests move with their subject. No behaviour change: `set_config` used to validate before delegating to the store, and now the store validates on write, which is the same order of operations from the outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: count copy-on-write deletes toward the quota Dropping a vector or a payload key does not free anything on its own: copy-on-write rewrites the point to produce the version without that field, so storage grows first and is only reclaimed once the optimizer gets to it. Gating those as if they were reclaiming space let a full node keep taking writes that make it fuller. Deleting whole points stays exempt. That is the one operation that has to work on a node at its limit, or there is no way back under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: say that the quota's reported usage is per node `GET /quotas` returns one cluster-wide config and one set of utilization figures, which reads as though both describe the cluster. They do not: memory and disk are node-local, so `usage` is whatever the peer that served the request is seeing, and a peer under its limit says nothing about the others. Also corrects `resident_memory_percent`, which claimed to be a share of total system memory. It is a share of the memory available to the process, which under a cgroup is the limit rather than the host's RAM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: treat a node over its quota as a failed replica, not a bad request A quota rejection described the request as invalid (400) and was classified non-transient, which is how the replica set recognises errors that every replica would produce alike. A quota is the opposite: the input is fine and the answer depends on which machine you ask. On the default `wait=false` path that combination silently dropped the write — `update.rs` only deactivates transient failures when nothing completed — leaving the replica Active and permanently missing data its co-replicas had. It is now `InsufficientStorage`, transient, HTTP 507 / gRPC `ResourceExhausted`. So a node that is out of room is handled like one that is offline: - last active replica, or every replica over quota: nothing could take the write, and the client is told the cluster is out of room. - more than one replica: the full node is deactivated through the same path a dead peer takes, and the update stands if enough replicas accepted it. `check_capacity` already keeps recovery off that node until it has room. The check also moves off `update_from_client`, which applied the coordinator's own limit to the whole operation even when it held no replica of the shards being written. Each replica set now gates its own local write and records the refusal as a failure of this peer, so a node only ever answers for itself. `ResourceExhausted` is shared with rate limiting, and the reverse conversion mapped it straight to `RateLimitExceeded` — a forwarded rejection came back as 429. Statuses now carry a marker so the two stay distinguishable across the wire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report quota pressure per node, and across the cluster A quota is node-local, so finding out which node has hit one meant asking each of them in turn — and nothing at all showed up in monitoring. `/metrics` gains a `quota_exceeded` gauge for the local node. It is emitted only while the quota is enabled: with it off the value would be a constant 0 that says nothing about the node, and an alert built on it would go quiet rather than fire if someone disabled the quota. Telemetry's `quota` field carries the same verdict alongside the config, since that is where the metric is derived from. `GET /quotas` now answers for the whole cluster. A new `GetQuotaUsage` RPC on the internal `QdrantInternal` service returns what one peer is using, and the handler fans it out to every known peer in parallel. Peers that do not answer are left out rather than failing the request — the nodes that are out of room are exactly the ones most likely to time out, and a partial answer still names them. Outside distributed mode the field is absent rather than a map of one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: report the quota metric per resource `quota_exceeded` was one flag for the whole node, which does not say what to go and fix — disk is freed by deleting or optimizing, memory by unloading. It now carries a `resource` label: quota_exceeded{resource="memory"} 0 quota_exceeded{resource="disk"} 1 A resource with no limit gets no series at all, for the same reason the metric is absent while the quota is disabled: a series that can never reach 1 reads as healthy and would quietly carry an alert that cannot fire. `QuotaManager::exceeded` returns the per-resource verdict, with `None` for a resource this node does not cap. Telemetry reports the same breakdown, since the metric is derived from it. The peer usage RPC keeps a single flag — it sits next to both percentages, so it only has to answer "is this peer refusing writes". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop a no-op error conversion the linter caught `check_global_access` already returns a `StorageError`, so mapping it through `StorageError::from` converted the type to itself and tripped `clippy::useless_conversion`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: hold a tripped quota until usage clears a release margin A resource resting on its limit crosses it in both directions on the noise between two readings, and each crossing is expensive: the node refuses a write, its replica is deactivated, usage dips, recovery starts sending a whole shard copy back, and the arriving data pushes it over again. The loop sustains itself, and every lap costs a shard transfer. A limit now trips at its configured value but only clears once usage has fallen 5 percentage points below it, so the crossing has to be real. The margin is floored at 1%, since a limit smaller than the margin would otherwise be impossible to fall back under and would strand the node. The verdict is carried on the manager rather than recomputed, which makes it the thing reporting shows: expect `exceeded` to be set while the utilization next to it is already back under the limit. Rejections say so too, rather than claiming a limit that is no longer exceeded: Disk usage is at 87% of total capacity. It reached the configured limit of 90% and has to fall below 85% before this node takes writes again. Changing the config clears the verdicts. New limits are a deliberate act, and should not be held back by the margin of a limit that no longer exists. Both resources are now evaluated on every check instead of stopping at the first failure, so a verdict is never left behind reporting a reading that has since been superseded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: make the quota release margin configurable 5 points is a guess about how noisy a deployment's usage is, which is not something one number can be right about: a node whose disk moves in gigabyte steps needs a wider margin than one that creeps, and an operator who wants the old flip-on-every-reading behaviour should be able to ask for it. `release_margin_percent` joins the rest of the quota config, so it seeds from `QDRANT__STORAGE__QUOTAS__RELEASE_MARGIN_PERCENT`, replicates through consensus, and changes with `PUT /quotas`. Defaults to 5 and is filled in when a request omits it, so it always answers with the margin actually in force rather than leaving the caller to assume one. `0` releases as soon as usage is back under the limit. `QuotaConfig` grows a hand-written `Default` for it, since deriving one would have quietly defaulted the margin to 0 and disabled the hysteresis for anyone constructing a config in code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: leave the release margin unset by default, and hold verdicts in atomics `release_margin_percent` is `null` unless someone sets it, rather than materialising 5 into every config. A quota written today then does not pin a number a later release may want to revise, and `{"enabled": false}` still round-trips as itself. `QuotaConfig::limits` resolves it, next to `enabled`, so enforcement never sees the unset case. The verdicts move from a `Mutex<QuotaExceeded>` to one `AtomicBool` per resource. They are judged independently and nothing reads them as a pair, so the lock only added contention to the path every update takes; a verdict that races a concurrent check is re-decided by the next one from a fresh reading. That also drops the tri-state. Only "was this over its limit" has to survive between checks — whether a resource is enforced at all follows from the config and the reading, so it is derived when reporting rather than stored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: drop a comment arguing with a design that was never here Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3ddb1f1714 |
Reorganize chunked_vectors: dedicated read-only module (#10062)
* Move chunked-vectors read-only code into a dedicated module `chunked_vectors` kept the read-only view (`ChunkedVectorsRead`), its `LiveReload` impl and the writer in flat sibling files. Reshape it to the layout the other components use: a `read_only/` submodule split into `mod.rs` / `lifecycle.rs` / `read_ops.rs` / `live_reload.rs`, with the writer's impls split into `lifecycle.rs` and `write_ops.rs` next to the struct in `mod.rs`. Two things did not survive as pure code motion: - The file-path and metadata readers (`config_file`, `status_file`, `load_config`, `read_status_len`) were associated fns on `ChunkedVectorsRead` that the writer reached through the type. They are now free `pub(super)` fns in `config.rs`, next to the types they read, so both sides get at them without widening visibility across the read-only boundary. `ChunkedVectorsRead::status_file` was `pub` but unused outside the module. - `preopen_chunks` moved from `chunks.rs` to `read_only/lifecycle.rs` beside its only caller; `chunks.rs` keeps the shared chunk-name matching and gains a `chunks_prefix` helper for the two listing sites. `ChunkedVectorsRead` is no longer re-exported from the component root, so importers spell out `chunked_vectors::read_only::ChunkedVectorsRead` — same as the sibling `dense` / `multi_dense` / `sparse` / `turbo` read-only modules. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Rename ChunkedVectorsRead to ReadOnlyChunkedVectors Read-only structs are named `ReadOnly*` across the codebase (`ReadOnlyChunkedDenseVectorStorage`, `ReadOnlySparseVectorStorage`, `ReadOnlyDiskIdTracker`, `ReadOnlyNumericIndexInner`, ...), while the `*Read` suffix marks the read-side traits (`VectorStorageRead`, `NumericIndexRead`, `PayloadFieldIndexRead`). `ChunkedVectorsRead` is a struct wearing the trait suffix; rename it to match its peers now that it lives in a `read_only` module. Pure rename, no other change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c2df77a3bf |
Serialize snapshot recoveries of the same shard (#10026)
Two snapshot recoveries of one shard resolve as "last to finish wins".
`restore_local_replica_from` is destructive on its own - for a full recovery
it takes the `LocalShard::clear` + `move_data` branch - and nothing is atomic
across the download that precedes it.
That picks the wrong winner. A recovery gets abandoned because it stalled,
which is exactly why its caller timed out and retried, so it finishes *after*
the retry that replaced it and restores on top of it. It keeps running because
`recover_shard_snapshot_impl` is not cancel safe, so a caller that walked away
cannot stop it:
stalled: clear -> download ..........................-> restore -> rolls back retry
retry: clear -> download -> restore -> Ok to caller -> caller writes
The retry's `Ok` is what the sender of a snapshot shard transfer acts on: it
switches the transfer to `Partial` and flushes its queue proxy into the shard.
The stalled recovery then discards those writes - acknowledged, then lost.
Add a per-shard recovery lock on `ShardReplicaSet`, held across the whole
recovery (clear, download and restore), so what a caller asked for last is what
survives. Clearing before the download is kept, so recovery still never needs
disk space for two copies of the shard.
`ShardRecoveryGuard` already existed to track recovery progress for the whole
recovery, so the lock is folded into it rather than added alongside:
`ActiveRecoveries::start` takes ownership of the lock guard, making it
impossible to start a recovery without holding it, and `Collection::
start_shard_recovery` is the single call that acquires both.
Queueing behind another recovery is logged. An abandoned recovery is otherwise
invisible - no live caller, no request, no error - and shows up only as a
transfer sitting in `Recovering`.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6aebf544d6 |
feat: UpdateOnlySegment / UpdateOnlyEdgeShard batch writer skeleton (#10021)
* feat: `UpdateOnlySegment` / `UpdateOnlyEdgeShard` batch writer skeleton Mirror image of the read-only pair, for the serverless updater: a shard/segment whose public surface is writes only, built for batches of many tiny operations against remote, append-only storage. Implemented: * `UpdateOnlySegment<S>` with a deliberately narrow open — id tracker, payload storage and one storage per named vector, all cold. No vector index, no quantized vectors, no payload index on the segments the writer only reads from. * `SegmentUpdateView`, the shared home of resolution logic, generic over the component traits (`VectorDataStorageRead` is a `VectorDataRead` without the index, so a segment that opens no index can produce the view). Batched `locate_points` / `point_versions` / `read_stored_points`. * `UpdateOnlyEdgeShard<S>::apply_batch`: fold the batch to one entry per point, locate the points, read only the ones that cannot be resolved from the batch alone, materialize `FullyQualifiedPoint`s, append them and tombstone the slots they replace. `todo!()`, pending the append-only components on the roadmap (appendable `DynamicStoredFlags` and `ChunkedVectors`, an appendable payload blobstore and field indexes): `store_points`, `tombstone_points`, `flush`, and creating the first appendable segment. Filter-selected operations, point sync, conditional upserts and the schema-level operations are rejected up front rather than silently skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: codespell implementor → implementer in SegmentUpdateView docs Co-authored-by: Cursor <cursoragent@cursor.com> * docs: trim update-only writer docstrings to guarantees Less verbose throughout: state each function's contract — ordering, absent-value behavior, preconditions, durability — and drop narration about where types are used or why alternatives were rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: fold SegmentUpdateView into UpdateOnlySegment as inherent methods The view was premature: it had exactly one producer, and its trait bounds bought an unexercised option. Resolution (locate / versions / read raw) now lives as inherent methods on UpdateOnlySegment, still generic over the backend. A shared view can be extracted when a second producer appears, e.g. batched CoW moves out of regular segments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: split edge update_only batch.rs into a module Pure move: mutation.rs (PointMutation fold + materialize), plan.rs (UpdateBatchPlan operation intake), tests.rs. PointUpdates::new/push narrowed to pub(super). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: parallel per-segment batch reads + tombstone every copy of a point locate_points and read_stored_points visit segments in parallel on a dedicated edge-update rayon pool (build_search_pool generalized to build_segment_pool with a thread-name prefix). locate_points now keeps every slot a point occupies, not just the newest copy: a rewrite or delete retires all of them. Tombstoning only the newest slot would let an older duplicate left by an interrupted move outlive the point — and resurrect it after a delete. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: point_versions returns a map keyed by internal id The id tracker's batch read is keyed by internal id already; returning AHashMap drops the positions_of reverse-lookup adapter. Absent key = unwritten slot, defaulted to version 0 at the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: accept a deferred threshold when opening UpdateOnlySegment Groundwork for an external rebuilder working the same directory: the cutoff loads slots at or above it into the appendable id tracker's deferred track (same appendable-only filter as ReadOnlySegment). It hides nothing from the writer — resolution runs WithDeferred, so every point still locates at its latest slot. The edge shard passes None until the rebuilder coordination exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: preview_batch — resolve a batch without writing anything apply_batch and the new preview_batch share one resolution stage (resolve_batch: locate, read, materialize into per-point PointActions), so a dry-run reports exactly what an apply would do. Plus segment_configs(): per-segment configs with the write target marked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: prefetched + parallel segment opens for the update-only writer UpdateOnlySegment::open now mirrors ReadOnlySegment::open: a per-segment CachedFs primed by preopen, config parsed once and handed to open_via. The edge shard opens segments in parallel on its pool, keeping fail-hard semantics. With Populate::No throughout, prefetches transfer no data-file content — only configs, the id tracker and the deleted flags, whose opens consume them whole anyway. Also: ReadOnlyAppendableIdTracker::preopen now tolerates the not-yet-created mappings/versions files of an empty appendable segment, matching its open's contract — previously unreachable because followers skip appendable segments on error, while the writer must open them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: edge-shard-update — dry-run batch upserts against a shard Counterpart of edge-shard-query for the write path: opens an UpdateOnlyEdgeShard over a local directory or S3/GCS object storage, generates random points shaped by the shard's own schema (segment config + payload-index schema), and logs what applying them would do — locations, versions, actions, tombstones — via preview_batch. Nothing is written: the write half is still todo!(). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: box PointAction::Store to appease clippy::large_enum_variant A resolved point is ~384 bytes while every other variant is empty. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: adapt to dev's dead-code sweep (#10030) Restore NamedVectors::remove_ref — removed as dead on dev, but the batch fold's DeleteVectors arm is now its first caller. Drop the allow(dead_code) on segment::update_only (no longer needed) and switch the writer's unread fs field to expect(dead_code), per the new ast-grep rule. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
de1748bec6 |
Make deprecated on_disk_payload optional in the REST schema (#10020)
* Make deprecated `on_disk_payload` optional in the API schema `CollectionParams::on_disk_payload` was the last place the deprecated flag was still a bare bool, in both the REST schema and the gRPC `CollectionParams` message. Clients generated from those schemas model it as a required bool, so removing the field in a future version would break them. Make it optional in both schemas while keeping it populated on every path that builds `CollectionParams`, so responses and the persisted collection config still carry a value and existing clients keep working until it is removed. The gRPC change is wire-compatible: proto3 `optional` only adds a synthetic oneof for presence tracking, the field number and wire type are unchanged. It does change what an absent field means, though, so decode absent as `false` when reading a remote peer's collection info -- peers that predate the change encode a plain bool, which is omitted from the wire when false. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Keep gRPC `on_disk_payload` a plain bool Adding proto3 `optional` would let a new client tell "unset" from `false`, but it also changes what an absent field means. A pre-upgrade server encodes a plain bool, which is omitted from the wire when false, so a client generated from the new proto would decode `false` as "unset" -- and upgrading the client first is the order we recommend. gRPC does not need the change anyway: protobuf tolerates a missing field by design, so a gRPC client does not break at runtime when we stop sending this one. The breakage this addresses is on the REST side, where a generated model with a required non-nullable bool fails to deserialize a response that omits the field. JSON encodes `false` explicitly, so the REST schema change carries no equivalent ambiguity. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a11f8bb4ae |
feat: io_uring setting to control which components use the io_uring backend (#10008)
* feat: `io_uring` setting to control which components use the io_uring backend A few components have both an mmap and an io_uring variant reading the very same files: the immutable dense vector storages, the single-file TurboQuant storage, and the mmap payload storage. Until now the choice was a side effect of `async_scorer` — a vector-search knob — plus, for the payload storage, a feature flag that was parked off because io_uring is ~2x slower than mmap when the data fits the page cache (#9310, #9409). Add `storage.performance.io_uring`, optional, with two modes: - unset (default): unchanged behaviour. The vector storages keep following `async_scorer`; the payload storage stays on mmap. - `disabled`: no component uses io_uring. - `auto`: a component uses io_uring when its memory placement is `cold` (data is left on disk, so reads hit the disk and there is something to gain), its feature flag allows it, and the kernel supports io_uring. Components meant to sit in RAM keep using mmap. The decision lives in one place, `segment::common::io_uring::use_io_uring`, so the openers no longer each reach for the async-scorer global. Kernel support is now probed up front through `is_io_uring_supported()` instead of opening a file and falling back on error. `async_payload_storage` now defaults to on: it no longer decides anything by itself, it only lifts the ban, and the payload storage no longer follows `async_scorer` at all — so turning it on cannot silently move an existing `async_scorer: true` deployment onto the slower path. Which backend a component ended up on depends on the config, the placement and the kernel at once, so report it in `SegmentInfo`: `vector_data[name].io_backend` and `payload_storage_io_backend`, both `"mmap" | "io_uring"`, absent for components that have no such choice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Trim comments, drop trivial tests Two tests were only restating their own implementation: `test_mode_round_trip` round-tripped the encode/decode pair next to it, and `test_io_uring_config` checked that serde deserializes a two-variant enum. The mode matrix test stays, it is the one that pins the semantics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Flatten `IoBackend` in OpenAPI, derive `JsonSchema` for `IoUringMode` Per-variant doc comments on a plain string enum make schemars emit a `oneOf` of anonymous single-value objects instead of a flat `enum`. Move the variant descriptions into the enum doc, as `Memory` and friends already do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Update lib/segment/src/vector_storage/turbo/turbo_vector_storage.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * Update lib/segment/src/types.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * Update lib/segment/src/types.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * Update lib/segment/src/types.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * Update lib/segment/src/types.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * upd openapi schema * Update lib/common/common/src/flags.rs Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> * Require kernel io_uring support in the async-scorer fallback `use_io_uring` returned `get_async_scorer()` verbatim when the `io_uring` setting is unset, so an enabled async scorer on a kernel without io_uring opened the io_uring storage, failed, and fell back to mmap with an error log per segment. Gate that branch on `is_io_uring_supported()` too, like `Auto` already is, so the component just stays on mmap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * upd openapi schema --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com> |
||
|
|
d5ccd03ca9 | do not generate oneOf (#10011) | ||
|
|
73e6c16f9c |
Remove deprecated search endpoints from OpenAPI, deprecate them in gRPC (#9982)
* Remove deprecated search/recommend/discover endpoints from OpenAPI Remove deprecated REST API endpoint definitions from the OpenAPI generator. These endpoints were deprecated in v1.13.3 (`f4ced2567`, #5907, 2025-01-30) in favor of the universal `/points/query` endpoint: - POST /points/search - POST /points/search/batch - POST /points/search/groups - POST /points/recommend - POST /points/recommend/batch - POST /points/recommend/groups - POST /points/discover - POST /points/discover/batch Also removes the corresponding request types from the schema generator and updates the expected API count in the consistency check. Co-authored-by: Cursor <cursoragent@cursor.com> * Migrate OpenAPI integration tests to /points/query The deprecated /points/search, /points/recommend and /points/discover endpoints (along with their /batch and /groups variants) were removed from the OpenAPI spec, which caused validation failures in the Python integration test harness. This commit migrates the affected tests to the universal /points/query endpoint: - Delete tests dedicated to the deprecated endpoints: test_recommend.py, test_discover.py, test_multicollection_reco.py, test_recommendation_multivector.py - Refactor remaining tests to call /points/query (and /query/batch, /query/groups), translating request bodies (vector -> query / using, positive/negative -> query.recommend, target/context -> query.discover) and unwrapping the new result.points response shape. - Drop equivalence assertions against the now-removed legacy endpoints. Co-authored-by: Cursor <cursoragent@cursor.com> * Relax non-empty assertions in migrated recommend/discover tests The previous migration added `len(...) > 0` assertions to tests that previously only checked equivalence between the deprecated and new API. These assertions are too strict because the parametrized `query_filter` cases legitimately produce empty result sets. Drop the `> 0` assertion and rely on `request_with_validation` to verify the response is well-formed and HTTP OK. Co-authored-by: Cursor <cursoragent@cursor.com> * Migrate remaining OpenAPI tests off deprecated search endpoints Tests added to dev after the original migration was written still call /points/search and /points/recommend/groups through `request_with_validation`, which resolves the endpoint against the OpenAPI spec and therefore breaks once the endpoint is not in the spec: - test_turbo4_storage.py, test_sparse_idf_corpus.py, test_validation.py: translate /points/search to /points/query (vector{name,vector} -> query + using, result -> result.points). - test_group.py: drop the /points/recommend/groups half of the lookup_from validation test in favour of the query equivalent. test_sparse_idf_corpus.py's test_query_api_supports_idf_corpus goes away: with the helper on /points/query every test in the file now exercises what it asserted. Also record why test_recommend_group cannot assert on its groups: it uses every point in the collection as a recommend example, so all of them are excluded and the result is legitimately empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Regenerate openapi.json without the deprecated search endpoints Drops the 8 deprecated paths and the request schemas that only they referenced: Search/Recommend/Discover request (+Batch, +Groups) types and their exclusive dependencies (NamedVector, NamedSparseVector, NamedVectorStruct, UsingVector, RecommendExample, ContextExamplePair). Regenerated output is a strict subset of the previous spec, and every remaining $ref still resolves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Deprecate the search/recommend/discover RPCs in gRPC The REST counterparts have carried `deprecated: true` since v1.13.3 and are now gone from the OpenAPI spec, while the gRPC RPCs never got any deprecation annotation at all. Mark all 8 with `option deprecated = true` so generated clients warn, and point each doc comment at its `Query` replacement. tonic puts `#[deprecated]` on the generated client methods only; the server trait gets the doc comment alone, so our own `impl` is unaffected. The RPCs keep serving traffic — this is annotation only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Restore the deleted recommend/discover suites on /points/query The earlier migration deleted these four files outright, but the query-side tests it left behind are all shallow smoke tests (`len(result) > 0`, `"points" in result[0]`). The deleted ones carried invariants with no query-API equivalent anywhere, so deleting them was a real loss of coverage rather than de-duplication: - test_recommend.py: default strategy equals average_vector; batch results identical to sequential singles across six request shapes; best_score with only negatives yields all-negative scores; best_score with a single positive orders identically to a nearest query; raw vectors as examples equal ids as examples. - test_discover.py: context-only scores are all <= 0; target-only orders identically to a nearest query but scores differently; with a fixed context the integer part of the score is stable while the decimal part moves, and vice versa with a fixed target; batch equals singles; lookup_from by id equals by vector. - test_multicollection_reco.py: cross-collection lookup_from, plus wrong-vector-size, unknown-collection and unknown-vector rejections. - test_recommendation_multivector.py: the same recommend invariants over a max_sim multivector collection, which the query suite never covered. Only test_recommend_missing_lookup_from_collection_with_raw_vector is dropped as genuinely redundant — test_query.py's test_query_missing_lookup_from_collection covers query, query/batch and prefetch. Two request-shape differences the translation had to absorb: - Giving no examples at all is 422 (a RecommendInput validation rule), where the legacy API reported 400 from the query itself. A malformed example, such as an empty vector, is still 400. - DiscoverInput requires the `context` key and accepts only an explicit null to mean "no context", so target-only discover must spell it out. The legacy API let it be omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5819d224da |
fix: evict page cache of files that are mapped twice (#9984)
* fix: evict page cache of files that are mapped twice `clear_cache()` on a `memory: cold` vector storage was a silent no-op: after optimization the whole storage stayed resident in the page cache. `MmapFile` opened with `need_sequential` holds two mappings of the same file (`MADV_RANDOM` + `MADV_SEQUENTIAL`), and `MADV_PAGEOUT` skips any page carrying more than one page-table reference. Any page faulted through both mappings was therefore never reclaimed, no matter which mapping was advised. Quantization is one way to get there: it reads the raw vectors through the sequential mapping while `populate_vector_storages()` populated the random one, so with quantization enabled `matrix.dat` stayed 100% cached after the build, and without it the same build evicted down to ~1%. `POSIX_FADV_DONTNEED` alone does not help either, as it skips pages with any page-table reference. So zap the page tables of both mappings first (`MADV_DONTNEED` on a shared file mapping only drops the PTEs; the data stays in the page cache and refaults on the next access) and then evict through the file. Dirty pages are still kept, exactly as before — `MADV_PAGEOUT` did not write back filesystem pages either — so callers that flush first, like `SegmentBuilder::build`, get a complete eviction. Also affects the payload storage (gridstore pages), the disk id tracker reader and quantized multivector offsets, which open with `need_sequential` too. Measured on a 200k x 256 build with quantization: `matrix.dat` 100% -> 0.0% resident, whole segment 67% -> 2.4%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: satisfy clippy::cast_lossless in the eviction test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * MADV_DONTNEED should not be safe * Document why Madviseable::clear_cache might not be enough --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: xzfc <xzfcpw@gmail.com> |
||
|
|
8b7411e8d8 |
Support memory placement in service-level storage config defaults (#9950)
* Support memory placement in service-level storage config defaults Follow-up to #9684: `storage.payload.memory` and `storage.collection.vectors.memory` set service-wide placement defaults for newly created collections, deprecating `storage.on_disk_payload` and `storage.collection.vectors.on_disk`. Defaults resolve as: request `memory` > request legacy flag > service `memory` > service legacy flag; exactly one level is filled to avoid spurious memory-vs-legacy mismatch warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mark hnsw_index.on_disk deprecated in config.yaml, document memory option Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a66b898dfc |
docs: describe StartResharding fields in OpenAPI (#9946)
Add doc comments to `StartResharding` fields so the generated OpenAPI spec explains what a user has to pass, and regenerate the spec. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8c11f488ac |
Clean up stale shard transfers when applying consensus snapshot (#9928)
A transfer source that misses the transfer abort (e.g. while partitioned
or paused) keeps its local shard wrapped in a proxy. When such a peer can
only catch up via consensus snapshot, snapshot application re-creates
payload indexes with an update operation that the stale forward proxy
forwards to a transfer target which may no longer have the shard. The
resulting precondition error fails snapshot application and stops the
consensus thread ("No target shard N found for update"), leaving the
peer unable to ever catch up.
Snapshot application now explicitly cleans up transfers that are no
longer registered in consensus: the transfer task is stopped and the
proxy is reverted via the new `ShardReplicaSet::discard_proxy_local`,
which is infallible, never contacts the remote, and forgets queued
updates (replica states in the same snapshot already reflect the
transfer outcome).
The consensus test reproduces the incident: pause the transfer source
mid-transfer, restart the other peers so the aborted transfer can only
be learned via snapshot, and verify the source recovers.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
21ed82fcc6 |
Batch IdTracker read operations, pipelined in disk-resident trackers (#9809)
* Add batch id-tracker lookups, pipelined in the disk-resident trackers
Introduce batch counterparts for the per-point IdTracker read operations
(external->internal resolution, internal->external lookup, version lookup,
deleted checks) and implement them with pipelined reads in the disk-resident
trackers, where the per-point path costs one storage round-trip per lookup:
- StoredBitSlice::get_bits_batch: dedupe the containing u64 elements and
fetch them through one read_batch pass.
- DiskMappingReader::lookup_batch: group e2i keys by run block, read each
unique block once, binary-search all its keys.
- DiskMappingReader::external_ids_batch: schedule all i2e data slots plus
the deduplicated is_uuid bitmap bytes in one pass.
- DiskMappingsSource::{points_deleted_batch,resolve_internal_batch,
resolve_external_batch}: shared batched resolution for both disk trackers.
- IdTrackerRead::{external_ids_batch,internal_versions_batch}: trait-level
batch methods with loop defaults for in-RAM trackers; overridden (along
with resolve_external_ids) in DiskIdTracker and ReadOnlyDiskIdTracker,
and forwarded through both tracker enums so the overrides dispatch.
Wire the batch operations into the read paths: search result processing
(external ids + versions), scroll filtered_read_by_index (chunked), and
HasId condition conversion/cardinality estimation. retrieve() already goes
through resolve_external_ids and picks up the batched dispatch.
Batch read-by-id keeps the read-only tracker's laziness: no full
deleted-set materialization (covered by test).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Rework batch id-tracker interfaces to iterator inputs and callback output
Review follow-up: the slice-in/Vecs-out shapes forced intermediate collects
at every call site, costing the in-RAM trackers (whose defaults are plain
loops) allocations the pre-batch code never had.
- resolve_external_ids now takes `impl IntoIterator<Item = PointIdType>`
and delivers `(id, offset)` pairs through a callback, in input order:
has_id conditions stream the id set straight into the offsets set/vec,
retrieve fills its parallel vectors directly.
- external_ids_batch / internal_versions_batch take iterator inputs but
keep `Vec<Option<_>>` outputs (search result processing needs one aligned
slot per input); search drops its two offset collects, scroll passes the
itertools chunk directly.
- In-RAM trait defaults stream with no intermediate allocation; the disk
overrides buffer the input once internally, where the block-grouped
reads need the whole batch anyway.
Also document on the writable DiskIdTracker why points_deleted_batch and
internal_versions_batch are deliberately not overridden there: deleted and
versions are RAM-resident by design, so the default loop has no IO to
pipeline; only the read-only tracker keeps them on disk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove accidentally committed local example
inspect_mutable_id_tracker.rs is a local debugging harness that was swept
into the previous commit by a directory-level git add; it is not part of
the batch-interface change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add PointIdBatch and thread it through resolve_external_ids
Replace the `impl IntoIterator` / `&[PointIdType]` inputs on the
id-tracker external->internal resolve path with a small `PointIdBatch`
trait (Copy + num_ids + iter_ids), implemented for `&[PointIdType]` and
`&AHashSet<PointIdType>`. Being re-iterable and length-known lets the
disk trackers batch the reads without first collecting the input.
On the disk path this drops several intermediate allocations:
- no input `Vec<PointIdType>` collect in resolve_external_ids_batch;
- lookup_batch returns compact `(id, offset)` pairs (id rebuilt from
is_uuid + key) instead of a size-N `Option` reorder buffer;
- lookup_batch no longer groups keys by block up front: a block is one
~16 KiB DiskCache block, so same-block reads are deduplicated by the
cache (piggybacked in flight, a plain hit once resident);
- resolve_internal_batch filters deleted pairs in place and feeds
points_deleted_batch an offset iterator, dropping the offsets Vec.
Delivery is no longer input-ordered; the id travels with the offset into
the callback, so callers that need positions still have them.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stream disk id resolve through a callback; prefetch deleted flags whole
Follow-up to the PointIdBatch rework, removing the last buffers from the
disk external->internal resolve path and dropping the redundant deleted
batching.
- `lookup_batch` and `resolve_internal_batch` now hand each resolved
`(id, offset)` to a callback as its block completes instead of
returning a `Vec`; `resolve_external_ids_batch` just forwards the
caller's callback and logs on error. No buffer is built on the path.
- Drop `points_deleted_batch`: the deleted set is resident (writable
tracker) or prefetched whole (read-only), so a per-point `point_deleted`
check is as cheap as a pipelined read would be. `resolve_internal_batch`
captures the first check error out of band and surfaces it after the
pass.
- Prefetch the deleted flags whole (`Populate::PreferBackground`) in both
`try_preopen` and `try_open`, via a shared `deleted_open_options`, so
the per-point checks stay off remote storage.
- `PointIdBatch` loses the now-unused `num_ids`; only `iter_ids` remains.
Delivery is fully streamed, so on a mid-pass storage error the callbacks
for already-resolved live points have fired before the error surfaces —
acceptable at the best-effort `IdTrackerRead` boundary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Surface id resolve errors to the trait; drop redundant batch wrapper
`IdTrackerRead::resolve_external_ids` now returns `OperationResult<()>`
instead of swallowing storage errors. The disk path previously logged
and dropped a `resolve_internal_batch` error at this boundary; now it
propagates so callers see failed id resolution.
- The in-RAM default returns `Ok(())`; both enum forwarders and both disk
overrides propagate. The three call sites (retrieve, HasId cardinality,
HasId checker) are already in `OperationResult` functions, so they just
gain a `?`.
- Drop the `resolve_external_ids_batch` free function: with its
log-and-swallow gone it only forwarded to `resolve_internal_batch`, so
the two overrides call that directly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stream internal_versions_batch through a callback
Give internal_versions_batch the same treatment as resolve_external_ids:
it takes a callback and returns OperationResult<()> instead of a
Vec<Option<..>>, so the disk override no longer allocates. It walks the
input lazily (no collect), tags each pipelined read with its internal_id,
and streams (internal_id, version) to the callback as reads complete;
out-of-range offsets are skipped and a storage error propagates instead
of being logged and swallowed.
The in-RAM default and both enum forwarders match. process_search_result
now builds a small internal_id -> version map from the callback and looks
up by scored offset (handling duplicate offsets naturally), keeping the
same missing-version-is-an-error behaviour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Stream external_ids_batch through a callback; drop chunked scroll batching
Align external_ids_batch with internal_versions_batch: iterator input,
(offset, id) callback output, no internal buffering, storage errors
propagated. The deleted filter runs inside the lazy range iterator, so
each tracker implements the override directly against its own deleted
source (resident bitvec vs prefetched on-disk file) and the redundant
DiskMappingsSource::resolve_external_batch pass-through is removed,
along with the now-unused log_lookup_err_batch.
filtered_read_by_index feeds the whole candidate iterator into one
batch pass and lets the IO layer pipeline the reads; the limit case
keeps the top-smallest ids in a bounded priority queue. This retires
ID_TRACKER_BATCH_SIZE. Search result processing pairs external ids by
internal id, mirroring the versions map.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Remove accidentally committed local example, again
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop unused StoredBitSlice::get_bits_batch
Its only caller, the read-only tracker's points_deleted_batch override,
was removed when the deleted flags became whole-file prefetched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Write deleted_open_options fields explicitly, comment the why
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Simplify read-only external_ids_batch error handling via try_filter
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add infallible point_deleted helper to the writable disk tracker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Drop PointIdBatch in favor of impl IntoIterator<Item = PointIdType>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
8828a92580 |
docs: fix collection metadata removal description (#9907)
Setting metadata to an empty object does not clear it: the update merges key by key, so an empty object is a no-op (and over gRPC an empty map is indistinguishable from an absent one). Per-key removal via null values is the mechanism that was actually implemented and tested in #7123. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
55cf6b7d04 |
Slice filtering condition: sliced scroll / deterministic sampling (#9899)
* feat: slice filtering condition for sliced scroll and deterministic sampling
Add a `slice` filter condition selecting points where
`stable_hash(point_id) % total == index`. The hash is SipHash-2-4 with a
zero key over canonical id bytes (8 LE bytes for numeric ids, 16 RFC 4122
bytes for UUIDs) — a frozen public contract, independent of the internal
resharding ring hash, reproducible by clients to predict membership.
For a fixed `total`, slices are disjoint and cover all points, enabling
parallel scroll streams (ES sliced-scroll style) and reproducible sampling
that composes with any other filter condition.
- REST: `{"slice": {"total": N, "index": R}}`; gRPC: `SliceCondition` in
the condition oneof (tag 8)
- Evaluated per point via id_tracker external-id lookup; no payload index
needed; cardinality estimated as `points / total` with no primary clause
- `total >= 1` enforced by NonZeroU32 at parse time, `index < total` by
validation in both REST and gRPC paths
- Hash contract locked by test vectors independently reproduced with a
reference SipHash-2-4 implementation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* tests: minimal OpenAPI test for slice filter condition
Scrolls all slices of a fixed total over numeric + UUID ids asserting
disjointness and full coverage, checks must_not inversion, and pins the
two rejection paths (422 for index >= total, 400 for total = 0). Requests
and responses are validated against the regenerated OpenAPI spec by the
test harness.
Note: the spec cannot itself reject total = 0 client-side — the Condition
anyOf falls through to the permissive Filter schema, as with any invalid
condition — so rejection is asserted via the server response.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7ef503c863 |
Edge: request-specific structures for EdgeShardRead (#9901)
Replace the mixed read interface (internal types, explicit parameter enumeration, ad-hoc custom types) with edge-owned request structs, one file per request in src/requests/. Each has a new() constructor taking only the required parameters, a no-macro fluent builder in src/builders/, and a From conversion into the internal request type in requests/conversions/, grouped by request type. Conversions construct and destructure with full field lists, so a parameter added on either side fails compilation instead of being silently dropped. The old reexport aliases (ScrollRequest = ScrollRequestInternal, etc.) are replaced by the edge types under the same names; retrieve() takes a RetrieveRequest instead of a parameter triple. Python bindings wrap the edge types, and the published Rust examples use the builders. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
441fa01c24 |
refactor: group edge crate internals into edge_shard and read_view modules (#9898)
* refactor: group edge crate internals into edge_shard and read_view modules Restructure lib/edge/src so the top level only contains the modules that form the public crate surface. Implementation files move under the type they implement: - edge_shard/: the EdgeShard struct with its load/config-resolution helpers (previously inlined in lib.rs), plus optimize, shard_read, snapshots, and update - read_view/: the EdgeShardRead/ReadSegmentHandle traits and EdgeReadView (previously read_view.rs), plus the per-operation impl files count, facet, grouping, info, matrix, query, retrieve, scroll, and search; build_search_pool (previously pool.rs) is folded into read_view/mod.rs next to par_map_segments, the seam it powers lib.rs is now a thin facade of module declarations and re-exports. The public API is unchanged: all previously exported names resolve exactly as before, verified against the edge tests, the python bindings, edge-shard-query, and the regenerated publish amalgamation with its examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: trim EdgeShardRead surface and split read_view/read_only modules Trait surface: - Drop query_scroll and rescore_with_formula from EdgeShardRead and the EdgeShard inherent wrappers; only the internal query pipeline used them, via the pub(crate) EdgeReadView methods that remain. - Hide the snapshot plumbing (read_segments, search_pool, plus config_snapshot/path providers) in a crate-private ReadViewProvider trait. EdgeShardRead now declares only user-facing methods and is implemented for every provider through a blanket impl, so the plumbing is not callable from user code (verified with a negative compile test; a private supertrait alone leaves supertrait methods callable through generic bounds). An empty sealed marker supertrait keeps the trait unimplementable downstream. - config_snapshot and path stay public: used by edge-shard-query and the python bindings. Module layout: - read_view/: mod.rs keeps EdgeReadView and build_search_pool; ReadSegmentHandle moves to handle.rs, the trait machinery to shard_read.rs, and the nine per-operation impl files into ops/. - read_only/: the follower's ReadViewProvider impl moves out of mod.rs into shard_read.rs, mirroring edge_shard/shard_read.rs. Verified: edge tests, python bindings, edge-shard-query, regenerated publish amalgamation with all examples compiling and running. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bd0048ea78 |
DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split (#9878)
* DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split Move the is_uuid flags out of the i2e file into a separate id_tracker.is_uuid file in the compact StoredBitmask format (#9871), loaded whole into RAM as a RoaringBitmap on open and prefetched in preopen, so slot decoding never reads the flag from disk. Bump the on-disk format version to 2 (DiskIdTracker is unreleased; no migration). Add StoredBitmask::read_ones() in common to normalize any stored encoding into a bitmap of set positions, with logical_len validated against the u32 position space at open. Restructure disk_id_tracker: read_only.rs becomes read_only/{mod, lifecycle,live_reload,id_tracker_read} mirroring the immutable tracker, and reader.rs becomes reader/{mod,lifecycle,lookup,iter}. Also unbox the DiskMappingsRef iterators (impl Iterator instead of Box<dyn>), and make IdTrackerRead::iter_internal_versions fallible so the read-only disk tracker propagates storage errors instead of silently truncating; its implementation now reads the versions file in one pass (cleanup-on-open drains it anyway). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split disk_id_tracker mod.rs into lifecycle / read / write files Mirror the read_only/ layout: mod.rs keeps the struct and resident-RAM helpers, lifecycle.rs the build/open paths, id_tracker_read.rs the DiskMappingsSource + IdTrackerRead impls, id_tracker.rs the mutable IdTracker impl. Code moved verbatim; only imports and a field doc touched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tighten disk_id_tracker docstrings around guarantees State contracts (residency, laziness, error semantics, atomicity) instead of narrating which structures hold or use what. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3e22d70fd0 |
Deduplicate plain and raw upsert/sync drivers via point traits (#9877)
* Deduplicate plain and raw upsert drivers via PointToUpsert trait upsert_points and upsert_points_raw were ~60-line clones differing only in how the point struct writes itself into a segment. Extract a private PointToUpsert trait with the two variation points — upsert_into (in-place write) and write_moved (CoW-move record transform) — implemented for PointStructPersisted and PointStructRawPersisted, and fold the chunked driver into a single generic upsert_points_impl. The public functions keep their names and signatures as thin wrappers. The duplicated upsert_with_payload/upsert_raw_with_payload tails collapse into one shared set_full_or_clear_payload helper, which also carries the single has_point debug assertion. No behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deduplicate plain and raw sync drivers via PointToSync trait sync_points and sync_points_raw were ~80-line clones of the same 5-step algorithm, differing only in the retrieval call (retrieve vs retrieve_raw) and the stored-record type compared against. Extend the upsert approach with a PointToSync subtrait carrying an associated StoredRecord type, retrieve_stored, and is_equal_to (delegating to the existing inherent methods), and fold the drivers into a single generic sync_points_impl. Step 5 calls upsert_points_impl directly; PointToUpsert and upsert_points_impl become pub(super) to be visible within points/. Public signatures unchanged. No behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7a26102061 |
Split shard update module into per-operation submodules (#9876)
lib/shard/src/update.rs grew to 2200+ lines. Split it into an update/ directory by operation kind, moving code verbatim: - mod.rs: process_* dispatch entry points + re-exports (public API and crate-internal paths are unchanged) - points/: upsert.rs (plain, conditional and raw), delete.rs (by id and by filter), sync.rs (plain and raw) - vectors.rs, payload.rs, field_index.rs: per-kind apply functions - helpers.rs: shared filter-based point selection (incl. the deferred points corner case) and check_unprocessed_points - tests.rs: the test module, unchanged Only additions are per-file imports, re-export lists and two pub(super) visibility bumps for now-cross-module helpers. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eafec09a1e |
Compact stored bitmask for on-disk field index deleted masks (#9871)
* Compact stored bitmask for on-disk field index deleted masks Add StoredBitmask: a compact persisted bitmask written and read as a whole. The payload is a roaring bitmap of whichever bit value is the minority (mostly-0 and mostly-1 masks both stay tiny), falling back to raw dense bits when roaring would not be smaller, so the file is never larger than the dense representation. Files are replaced atomically via UniversalWriteFileOps::atomic_save; there is no in-place mutation. Use it for the write-once "no values" masks of the on-disk numeric, geo, map and full-text indexes, replacing the raw dense bitslice files sized at point_count/8 bytes regardless of content. Writing the new format is gated by the compact_bitmask feature flag (default off; enabled by `all` and `serverless_compatible`). Reading is format-agnostic regardless of the flag: the compact deleted_mask.bin is tried first, then the index-specific legacy file, so old segments stay readable and flag-off builds produce byte-identical legacy files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split save_bitmask into named helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Regenerate OpenAPI spec for compact_bitmask feature flag Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review findings on stored bitmask - Avoid u64 overflow in the payload bound check when opening a mask with a corrupted payload_len. - Reject roaring payload positions beyond logical_len at read time, enforcing the BitmaskContent range contract for corrupted files. - Remove the opposite-format mask file after a successful save, so a rebuild with a flipped compact_bitmask flag can't leave a stale compact file shadowing the fresh legacy one (or an orphaned legacy file next to a compact one). - Make the compact-open numeric test tolerate builds that already wrote the compact format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
055bcd5c09 |
Add recoverable OutOfAppendableCapacity operation error (#9860)
Preparation for capping appendable segment growth in the update path (#9158): a dedicated error for "all appendable segments reached max_segment_size", so the update pipeline can recognize it and provision a fresh appendable segment before re-applying the operation. Maps to a transient service error at the collection level: if it ever escapes recovery, failed-operation recovery re-applies the operation. Part 1/5 of the appendable segment overflow fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
444ea9de7b |
Add usage skill file for edge-shard-query tool (#9836)
Standalone usage reference for `edge-shard-query`: backends (S3 / GCS / uio-grpc), connection and tuning flags, the scroll / search / search-sparse sub-commands, filtering, and the live-reload diff mode. Written to stand on its own, so it can be shared as a link without also sharing the source. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
cd149ab649 |
Add search-sparse sub-command to edge-shard-query tool (#9834)
Sparse nearest-neighbour search over a ReadOnlyEdgeShard, reusing the same SearchRequest path as dense search with VectorInternal::Sparse. The query vector is accepted as a JSON object or an index:value pair list, sorted and validated before use. --vector is required (no random fallback: the sparse vocabulary is not recoverable from the shard config), and --hnsw-ef is omitted since it does not apply to the sparse index. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ebcf5241e6 |
Add optional block-index sidecar to accelerate on-disk binary searches (#9810)
* Add optional block-index sidecar to accelerate on-disk binary searches Binary search directly over an unpopulated mmap costs O(log n) random reads scattered across the whole file — one page fault (or remote range read) per probe on high-latency storage. Introduce SortedBlockIndex: an optional sidecar file storing the first element of every 16KiB block of a sorted on-disk array, read whole into RAM at open. A lookup becomes an in-RAM partition_point over the block firsts plus a single contiguous read of one block, bisected in RAM. Wire it into the three remaining scatter-probe sites: - on-disk numeric index: binary_search_pairs over data.bin - on-disk geo index: counts_of_hash over counts_per_hash.bin - on-disk geo index: all_points start boundary over points_map.bin The sidecar is backward and forward compatible: absent (old segments) or failing validation, readers fall back to the existing plain binary search; old code simply ignores the extra file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Validate block-index sidecar snapshot recovery and preopen coverage - snapshot round-trip test (Regular + Streamable): the sidecars written by the on-disk numeric and geo indexes are collected via files() into the snapshot tar and restored byte-for-byte, with correct query results on the reloaded segment - preopen tests for both indexes: after schedule_prefetch, open loads the sidecar from the CachedFs prefetch pool even when the file is unlinked from disk; an absent sidecar neither fails preopen nor open (fallback) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
76f4f3e2ce |
Per-query IDF corpus for sparse vector search (#9661)
* Add per-query IDF corpus for sparse vector search
Let the caller choose, per query, which population sparse IDF statistics
are computed over. `params.idf` is either `"global"` (default, unchanged
behavior) or `{"corpus": <filter>}`, where the corpus filter is
independent of - and usually broader than - the retrieval filter.
Decoupling the two keeps the score scale stable when the retrieval
filter tightens: term importance is measured against a population the
user names, not against whatever subset the filter happens to select.
Design decisions:
- Corpus grammar is restricted to a conjunction (`must`) of `match`
conditions on payload fields; loosening later is backward compatible.
- Strict mode validates the corpus filter like a read filter
(unindexed fields rejected).
- `idf` on a vector without the IDF modifier is a validation error,
never silently ignored.
- An empty corpus yields degenerate but corpus-scoped scores (smoothed
IDF over N=0), never a fallback to global statistics - in multi-tenant
collections a fallback would leak term statistics across tenants.
Implementation:
- QueryContext IDF stats are keyed by corpus, so one batch can mix
requests with different corpora.
- Statistics come from the sparse index: df(term) is counted over the
query terms' posting lists only, never by scanning stored vectors.
Small corpora (under ~1/32 of the segment, by cardinality estimate)
are kept as a sorted id list galloping through posting lists via
skip_to; large ones as a dense membership mask filled streaming from
the filtered-points iterator. A misestimated small corpus degrades
into the mask.
- Exposed uniformly: REST (`params.idf`), gRPC (`IdfParams` message),
edge python bindings; OpenAPI schema regenerated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Apply rustfmt
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix clippy manual_is_multiple_of in sparse IDF corpus test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Allow any filter as IDF corpus
Drop the must+match grammar restriction on the corpus filter. A
restriction enforced only as a validation step over the full Filter
type buys nothing; if a narrower corpus syntax is ever wanted, it
should be a dedicated API-level type instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Fix build: add memory field to SparseIndexConfig in idf corpus test
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
a1c7449c02 |
Reset first_voter and prune address book on first-peer --reinit (#9785)
* Reset first_voter and prune address book on first-peer --reinit
A peer removed from consensus and killed after `RemoveNode(self)` was
committed but before it was applied keeps the old cluster's
`first_voter` and peer addresses in `raft_state.json`. First-peer
`--reinit` reset `conf_state` to a single voter (itself) but left both
untouched (`first_voter` is in fact never reset, even when the removal
is applied).
Both values are served to peers bootstrapping onto the reinitialized
cluster. A joining peer seeds its initial `conf_state` with the
advertised `first_voter`, and Raft conf-changes are deltas on top of
that base - so a stale `first_voter` permanently corrupts the joining
peer's voter set: it ends up with {old first peer, itself}, missing the
actual leader. Its `/readyz` then treats the still-alive old peer as a
cluster member and waits for the old cluster's commit index, which its
own consensus never reaches.
This only manifests when the reinitialized leader replicates its log as
plain entries (nothing applied before the kill, so the log anchor is
index 0). If the leader sends a snapshot instead, the snapshot's full
`conf_state` heals the corrupted seed - which is why
test_reinit_removed_peer only failed sporadically on CI.
Fix first-peer `--reinit` to behave like founding a fresh cluster:
reset `first_voter` to this peer (not `None`, or `recover_first_voter`
would re-derive the old first voter from the retained Raft log) and
prune `peer_address_by_id` to this peer only.
The kill-before-apply state is now injected deterministically into
test_reinit_removed_peer, reproducing the exact CI failure against the
unfixed binary. Since `--reinit` now prunes the address book, the
stale-address injection in
test_reinit_removed_peer_readyz_ignores_old_cluster moved to a restart
without `--reinit`, so it keeps exercising the /readyz `conf_state`
membership filter from #9688.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fix test_reinit_consensus expecting stale address book after --reinit
The test waited for cluster size 2 right after starting the reinitialized
first peer, before the second peer was even started. That only passed
because first-peer --reinit used to keep the old cluster's addresses in
the address book - the stale state the previous commit removes. A
reinitialized first peer is a fresh single-member cluster; the other
peers re-join and re-register their new addresses right after.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
68395b4a9b |
[UIO] Request-specific load profile for read-only opens (#9797)
* Introduce request-specific LoadProfile with per-component placement A read-only shard opened for one known request (the serverless cold-start path) doesn't have to warm components the request will never touch. LoadProfile captures that from the request: warm components keep the persisted-config placement, everything else is parked cold. All placement decisions live in one place, so the memory placement of a whole segment under a profile is reviewable in one file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais * Thread LoadProfile through the read-only segment open ReadOnlySegment::open takes an optional profile; first_preopen and open_via resolve it into per-component populate overrides so the opens make the same placement decisions the prefetches did. Pinned components that materialize on open regardless (quantized RAM storage kinds, the immutable-RAM sparse index) and appendable components ignore the override; the HNSW graph and immutable payload indexes demote fully. Config reloads follow the new config alone and pass no override. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais * Open ReadOnlyEdgeShard under a request-derived load profile ReadOnlyEdgeShard::open takes an optional LoadProfile, applies it to every segment open and keeps it so segments discovered by a later refresh load with the same placement. ScrollRequestInternal and CoreSearchRequest gain load_profile() constructors, and edge-shard-query builds the request before the open and passes its profile (opt out with --no-load-profile). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais * Demote pinned quantized vectors and sparse index under a cold profile Within the immutable layout the quantized RAM and mmap loaders share the on-disk format — only how the data is brought into memory differs — and the immutable-RAM sparse index has the same lazy mmap open low-memory mode already downgrades to. So a cold populate override now demotes the effective placement itself (Memory::with_populate_override, shared with the HNSW residency mapping) instead of only skipping cache priming: a pinned quantized storage opens the mmap kind cold, and a pinned sparse index opens as Mmap, so neither reads its data on a cold start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BzbiZFVHooxKVoJX4k6Ais * Add LoadProfile::merge for composite queries A composite query runs multiple core requests — e.g. a hybrid search runs one core search per vector, each with its own filter. Its profile is the union of its parts': merge extends the warm sets and ORs the payload-storage flag, so a component either part needs warm stays warm. The union is sound because every placement method is monotone in the warm sets (growing them only turns "park cold" into "keep configured placement"), so the merged profile dominates each input; and minimal, warming nothing no part asked for. Combine profiles with reduce, not fold: merge's identity element is the coldest profile (empty warm sets), the opposite of passing no profile at all — deliberately no empty()/Default constructor exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Adapt vanished-segment test to the profile-aware open signature The test landed on dev (#9777) after the load-profile signature change was written, so the rebase left its ReadOnlySegment::open calls without the new load_profile argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Defer vector index open entirely under a cold load profile A cold placement is not enough for the vector index on remote backends: GraphLinksView requires the whole links file as one contiguous slice, and the disk cache can only lend a borrowed slice once every block is locally present — so even a Cold HNSW open mirrors the entire links_compressed.bin (8.2 MB / 1.1 s per segment in the serverless cold-start trace), plus the unconditional graph.bin metadata read. The only way not to fetch the index is not to open it. LoadProfile::vector_index_placement is replaced by vector_index_deferred: a vector the request never scores now gets a DeferredVectorIndex — a new VectorIndexReadEnum variant holding the open arguments (an owned clone of the segment's raw backend, path, config, shared component handles) and a OnceLock. Nothing is opened or prefetched for it at segment open. Per-method policy of the deferred variant: - search, fill_idf_statistics and populate open the index on first use (with the cold placement the profile chose), so the profile contract holds: a request the profile did not predict still works, just pays the open then; - is_index reports true without opening (deferral only ever wraps a real HNSW or sparse index; plain opens no files and is never deferred); - telemetry, indexed_vector_count and sizes answer conservative defaults rather than trigger a remote fetch for a statistic. Tests: deleting the vector_index directory before an open under a scroll profile leaves open, filtered reads and payload reads working — proof that nothing of the index is read — while a search surfaces the missing files; and a segment opened under a scroll profile answers searches identically to an eagerly opened one via the transparent first-use open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Make --vector optional in edge-shard-query: random query after open Omitting --vector on the search sub-command now searches with a random vector. The request is still built before the shard opens — the load profile only needs the vector name, not its values — with an empty placeholder; once the shard is open, fill_random_vector reads the dimension of the queried vector from the derived shard config and fills in uniform-random f32s (with a clear error if the named vector is not in the config). The vector is generated once, so live-reload iterations re-run the identical random query and the printed diffs stay meaningful. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Scope index deferral to the HNSW graph via a lazy OnceLock load Replace the DeferredVectorIndex wrapper (and the VectorIndexReadEnum:: Deferred variant) with deferral inside ReadOnlyHNSWIndex itself: the graph lives in a OnceLock (same first-wins arbitration as ReadOnlyRoaringFlags::bitmap) alongside the retained raw backend and residency, and loads on first use with a cold placement. The config read stays eager — one tiny, absence-tolerated file — so telemetry, is_on_disk and indexed_vector_count report real values where the Deferred arms answered with hard defaults. The sparse index needs no deferral: its mmap open reads lazily, with only small JSON metadata eager. A profile that never scores the vector now passes a cold placement override (LoadProfile:: vector_index_placement) into the eager open_sparse, which demotes ImmutableRam to the lazy Mmap open like low-memory mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Express HNSW graph deferral as a populate override, not a bool param Replace the `deferred: bool` on the read-only HNSW open/preopen (and the VectorIndexReadEnum pass-through) with the same `populate_override: Option<Populate>` every other component takes. A cold *override* defers the graph load — graph_deferred() mirrors the cold-override match of open_sparse — while a config-derived cold placement (or the low-memory clamp) keeps the eager load, since only a request-specific override carries the "never scored" prediction. With dense and sparse now consuming the same signal, LoadProfile::vector_index_deferred is gone: a single vector_index_placement() serves both index kinds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serialize the deferred graph load via once_cell's get_or_try_init Loading outside the lock (std OnceLock's fallible init is still unstable) let a search burst on a deferred vector fetch the whole graph once per thread. Swap the cell for once_cell::sync::OnceCell: the fallible load runs inside the cell's lock, concurrent first users block on the one load, and a failed load leaves the cell empty so the next caller retries. Addresses https://github.com/qdrant/qdrant/pull/9797#discussion_r3573727836 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
963995662c |
[UIO] implement VectorIndexReadEnum::preopen (#9782)
* [UIO] implement VectorIndexReadEnum::preopen Schedule background prefetch of the HNSW graph (config, graph data, links) and sparse index (config, inverted index, version, indices tracker) files, wired into the segment's first_preopen for every dense and sparse vector. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Adapt index preopen to caller-controlled populate Now that CachedFs respects the caller's populate: prefetch Cached HNSW links with a background populate instead of the open's blocking one; populate the immutable-RAM sparse index data (read in full on open) while the mmap variant stays cold; apply the low-memory ImmutableRam downgrade in preopen_sparse since it now changes populate behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Decide sparse index preopen populate from the index type All readable sparse index variants share the compressed-mmap on-disk format, so the datatype dispatch in preopen_sparse selected nothing. Replace the per-TInvertedIndex preopen_ro trait machinery with one populate-parameterized preopen in the sparse crate: the effective index type alone decides whether the index data is warmed (immutable-RAM reads it in full) or parked cold (mmap reads lazily). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Derive sparse index preopen populate from the segment config The segment config's sparse_vector_data already carries the SparseIndexConfig, so preopen_sparse doesn't need to read the persisted copy at all: it derives populate from the segment-side index type and merely schedules the config file for open_sparse to consume — still a single fetch, without threading the parsed config through open_via. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Resolve index preopen placement via memory_placement The HNSW read-only open and preopen used the deprecated on_disk flag directly; resolve the graph residency like the writable open instead — memory parameter with on_disk fallback, clamped by low-memory mode, including the pinned placement. The sparse index preopen likewise derives its populate from the effective memory placement, so the cached mmap index is prefetched warm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e1a4f9867d |
Fix live-reload staleness of in-place-mutated files on caching backends (#9812)
* Mutate same-operation slots in place in append-only mode With append_only_mutations enabled, every mutation of an existing point clones it to a fresh internal id. Shard-level updates decompose one point write into several SegmentEntry steps (upsert_with_payload issues upsert_point plus set_full_payload/clear_payload), so a single upsert burned one slot per step, leaving a chain of immediately-dead clones. A slot whose version already equals op_num was written by an earlier step of the current operation. It cannot be durable yet — the segment write lock is held across the whole operation, so no flush (and no read-only follower) can have observed it, and versions flush last so a crash discards it and WAL replay re-applies the whole operation. Mutate such slots in place: one operation now allocates exactly one slot regardless of how many steps it decomposes into. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stamp payload storage version in write_point_parts overwrite_payload mutates payload storage, but the fused write never bumped version_tracker's payload version — the old CoW path did, via set_full_payload. The segment manifest would stamp payload files with a stale version, letting a partial snapshot skip payload storage that contains the moved point's row, so the restored id tracker would point at an offset with no payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Resync ReadOnlyRoaringFlags from disk on live-reload The flags file is preallocated to power-of-two capacity and mutated in place within its length, which the held handle's reopen() — an append-only-growth contract on caching backends — never picks up: bits the writer changes inside already-cached DiskCache blocks stayed stale forever on shard followers. Drop the `impl LiveReload for ReadOnlyRoaringFlags` altogether: this storage holds arbitrary flags with no notion of points, so a point-delta interface (deleted/new points) did not belong here — open never applied deleted points either. Replace it with an inherent live_reload(fs) that opens a fresh StoredBitSlice (a fresh open always mirrors the current remote bytes), resyncs the materialized bitmap from it, and swaps the handle. The on-disk flags are the sole source of truth. To make refresh-by-fresh-open safe while the old handle is still alive, every DiskCache open now mirrors into a uniquely-named local file (.{pid}-{counter} suffix) and removes it on drop. Mirrors were already truncated on every open (block validity is in-memory only), so the stable name carried no state. Also add trace logging for live-reload consumed mapping changes and pending deltas. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Resync immutable id tracker deleted bitmap via fresh handle on live-reload The immutable tracker's id_tracker.deleted file is a fixed-size bitmap whose bits the writer flips in place — its length never changes — so the held handle's reopen(), an append-only-growth contract on caching backends, never picked it up: the pre-deletion state cached at open was served forever and live-reload never reported deletions on shard followers. live_reload now takes the fs, opens a fresh StoredBitSlice (a fresh open always mirrors the current remote bytes), diffs it against the tracker's current state — mappings already reflect every previously reported deletion, so they are the baseline — and swaps the handle in. ReadOnlyIdTrackerEnum and the segment-level reload pass the fs through; the appendable and disk-resident variants keep their no-arg reloads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Resync disk id tracker deleted bitmap via fresh handle on live-reload Same staleness as the immutable tracker: the deleted file is a fixed-size bitmap mutated in place, so reopen() — append-only-growth on caching backends — served the pre-deletion state forever. live_reload now takes the fs, opens a fresh StoredBitSlice, and swaps it into deleted_file, so the per-point get_bit lookups read fresh state from then on too. The deleted_full take/diff/set baseline logic is unchanged. The enum's DiskResident arm passes the fs through. The regression test now covers both trackers over DiskCacheFs; the disk leg was verified to fail under the old reopen-based behavior. The disk tracker's versions file staleness is a separate, still-open case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Introduce header-stateless ReadOnlyTracker for gridstore live-reload The gridstore tracker file is preallocated and mutated in place (header rewrites, slot updates), which the reader's held handle never picked up on caching backends: Tracker::live_reload read the header through the stale handle — before reopen(), and reopen() would not have refreshed cached blocks anyway — concluded "no new pointers", and never reloaded pages. New points' payloads read as empty on shard followers. Replace the reader's tracker with a dedicated ReadOnlyTracker that holds nothing but the storage handle: reads don't need header state (slot addressing is positional, unwritten slots read as None in the zero-initialized file) and readers have no pending-updates buffer. Its live_reload opens a fresh handle and swaps it in. Tracker::live_reload is deleted. A new TrackerRead trait (max_point_offset/get/iter) is implemented by both the writable Tracker and ReadOnlyTracker, and GridstoreView is generic over it, so writer and reader share the read logic. max_point_offset reads the stored header count as plain data through the current handle (fresh as of the last reload) rather than deriving it from slot capacity: sparse vector storage builds total_vector_count from it, and the capacity of the preallocated file (1MB -> 65536 slots) would inflate every follower segment's sparse count. The method is now fallible; consumers propagate the error. GridstoreReader::live_reload refreshes tracker and pages unconditionally — the tracker carries no reliable cheap change signal. Making this incremental again is a deferred follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Re-open last held chunk on chunked-vectors live-reload Chunk files are preallocated to full size, so appended vectors are in-place writes within the existing file length. On caching backends a held handle keeps serving blocks fetched earlier — and a block fetched near the old tail extends into then-unwritten space — so vectors appended into that block read back as stale bytes after a reload. Mirror Pages::live_reload: on a len change (the status file is read through a fresh open every reload, so it stays a reliable gate), drop and re-open the last held chunk — the only one that can have gained vectors — alongside adopting newly created chunk files. Earlier, fully-filled chunks keep their handles; their contents cannot change. Covers dense, multi-dense, and quantized-chunked storages, which all delegate to ChunkedVectorsRead::live_reload. Avoiding the whole-chunk refetch per reload is a deferred follow-up, together with the analogous gridstore pages/tracker case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix lint: fs_err::create_dir_all in tests, drop unnecessary cast CI clippy runs with --all-targets and disallows std::fs methods in favor of fs_err; the new live-reload regression tests used std::fs::create_dir_all directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Tombstone-only deletes on non-appendable segments in append-only mode The tombstone-only delete path existed but was gated on is_append_only(), which requires the segment to be appendable. Deletes landing on non-appendable segments — notably the CoW update deleting the point's old copy — still went through delete_point_internal, which clears the payload row in place before the id-tracker drop. Clearing the payload destroys committed state of an offset that stays visible to live-reload followers until the drop is flushed: a follower refreshing in that window resolves the point through its (unchanged) id-tracker view and reads an empty payload — observed as a one-refresh {} payload flicker on a point update. Writer-side flush ordering cannot close the window, since the follower samples the id tracker and the payload storage at different instants; the versions commit protocol protects inserts exactly because the marker is read first, and deletes have no marker. This is the same failure class for which vector-deletion propagation was disabled (see the comment in delete_point_internal). Gate deletes on the new is_append_only_delete() — append_only_mutations alone, no appendability requirement: tombstoning needs nothing from the segment but the id tracker. Normal deployments keep eager payload clearing and prompt space reuse; clone-based mutations keep requiring appendability via is_append_only(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: chunked vectors reload --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Daniel Boros <dancixx@gmail.com> |
||
|
|
43a2f898ea |
tests: stop uploaders cleanly before consistency check in WAL delta tests (#9817)
The end-of-test teardown killed the uploader processes and slept for one second before scrolling all peers for the consistency check. Killing the client does not cancel an already-sent upsert server-side: on slow CI the last PUT can take longer than the sleep, so its replication is still propagating while the peers are scrolled at slightly different times, making the scrolls diverge by the last batch of points. Observed in test_shard_wal_delta_transfer_abort_and_retry: peer 0 was scrolled at 19.089s, received the forwarded batch at 19.179s, while peer 1 (which had already applied it locally) was scrolled at 19.267s. Use stop_update_process() (introduced in #8713 for the pre-peer-kill case) so the uploader exits between requests. Since uploads use wait=true, once the last PUT returns all active replicas have applied it, and the scrolls can no longer race. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f201849fac |
[UIO] implement ReadOnlyQuantizedVectors::preopen (#9781)
* [UIO] implement ReadOnlyQuantizedVectors::preopen Schedule background prefetch of the quantization config, per-method metadata, quantized data and multivector offsets, wired into the segment's first_preopen for every dense vector with quantization configured. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * additional stage for quantized config preopen * better quantized preopening * Partially populate the first quantized vector in preopen The load reads the first vector off quantized.data (and off the first chunk in the chunked layout) to validate the stored vector size — on a cold open that was a round-trip. Use Populate::Partial (#9769) to prefetch exactly that prefix; the exact size comes from the quantized config the preopen already read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restore the layout-probing quantized preopen Recovers the pre-rewrite design: preopen never reads the quantization config — it probes both layout candidates against the listing snapshot (a segment only contains the layout its real config selects), derives warmth from the segment-side quantization config (placement resolution incl. cached and low-memory), iterates storage types exhaustively for layout coverage, and schedules the config for open to parse once off the parked handle — no threading, no second preopen stage. Keeps the first-vector partial populate, with the size now derived from the segment config via construct_vector_parameters, and keeps VectorStorageType::is_pinned. Restores the full preopen test matrix and OneshotFile::preopen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review fixes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Luis Cossío <luis.cossio@outlook.com> |
||
|
|
52c5d3ca5d |
Fix use-after-free in u8-quantized vector reads on owning storages (#9801)
* Fix use-after-free in u8-quantized vector reads on owning storages EncodedVectorsU8::get_vec_ptr extracted a raw pointer from the buffer returned by EncodedStorage::get_vector_data and dropped the buffer before the pointer was dereferenced. For storages returning Cow::Borrowed (mmap) this happened to be sound, but for storages that return Cow::Owned (disk-cache misses, uring backends) every user of get_vec_ptr read freed memory: the internal SIMD scorers, encode_internal_vector, and get_quantized_vector_offset_and_code, which exported the dangling buffer through a safe &[u8]. Make parse_vec_data return the code as a slice borrowing the input, so the borrow checker forces callers to keep the storage buffer alive while reading it, and drop get_vec_ptr entirely. This matches how encoded_vectors_binary already binds the buffers at its scoring sites. Change get_quantized_vector_offset_and_code to return the code as a sub-view of the buffer Cow itself, and adapt its GPU caller. The new regression test drives these paths through a storage that always returns owned buffers; under Miri it reproduces the use-after-free on the previous code and passes with this fix. Fixes #9799 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * additional assertion --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com> |
||
|
|
abc97939f8 |
Resolve vanished segments against the manifest in follower refresh (#9792)
Third step of the ReadOnlyEdgeShard not-found handling (after #9763 and #9777): shard-level resolution of live-reload failures, per the live-reload design requirements. refresh() is now a bounded retry loop over single-manifest-snapshot attempts. Within an attempt every survivor live-reloads even if one fails (they are independent); failures split by classification: - Not-found: resolved against a re-read manifest. Gone from the manifest means the leader removed the segment mid-reload — drop it and re-run the attempt to pick up its replacements immediately. Still listed means essential files are genuinely missing — escalate. - Anything else: escalate after reloading the other survivors, replacing the previous warn-and-swallow that could hide a corrupted segment forever. Safe: a failed segment keeps serving its pre-refresh state and pending_reload replays the unapplied delta on the next refresh. If attempts run out (leader churning segments continuously) refresh logs a warning and returns Ok: every swap was atomic, so the shard is consistent, just possibly not the newest; the next refresh continues. open_with_enumerator now reuses the refresh machinery: empty holder + refresh() + the open-only merge_follower_config overlay, removing the duplicated load/derive logic. At open there are no survivors, so open behavior is unchanged (#9762 already made it tolerant of unloadable segments via the superset-biased manifest contract). Load-side failures stay as settled by #9762: unloadable listed segments are skipped and retried on every refresh. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
921f5a512b |
Allocate a single slot per operation in append-only mutations (#9804)
* Fuse CoW point move into a single destination write The CoW arm of apply_points_with_conditional_move wrote a moved point in three SegmentEntry steps: upsert_point_raw, update_vectors, set_full_payload. With append_only_mutations enabled every step clones the whole point to a fresh internal id, so one moved point burned three slots (the first holding an empty point for plain upserts, which clear raw_vectors) and left two immediately-dead clones behind, tripling the id-tracker changelog and vector writes. Add SegmentEntry::upsert_moved_point, which writes raw vectors, the decoded overlay, and the payload in one operation — allocating exactly one slot — and use it on the CoW move path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mutate same-operation slots in place in append-only mode With append_only_mutations enabled, every mutation of an existing point clones it to a fresh internal id. Shard-level updates decompose one point write into several SegmentEntry steps (upsert_with_payload issues upsert_point plus set_full_payload/clear_payload), so a single upsert burned one slot per step, leaving a chain of immediately-dead clones. A slot whose version already equals op_num was written by an earlier step of the current operation. It cannot be durable yet — the segment write lock is held across the whole operation, so no flush (and no read-only follower) can have observed it, and versions flush last so a crash discards it and WAL replay re-applies the whole operation. Mutate such slots in place: one operation now allocates exactly one slot regardless of how many steps it decomposes into. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stamp payload storage version in write_point_parts overwrite_payload mutates payload storage, but the fused write never bumped version_tracker's payload version — the old CoW path did, via set_full_payload. The segment manifest would stamp payload files with a stale version, letting a partial snapshot skip payload storage that contains the moved point's row, so the restored id tracker would point at an offset with no payload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
902e57fec8 |
Bump client timeout in flaky snapshots-consensus e2e test (#9808)
TestSnapshotsInterferenceWithConsensus flakes when the initial create_collection exceeds the 10s client read timeout. Cluster logs from a failing run show the primary stalling its consensus loop for ~5s while creating local shards on a loaded runner, which triggered a leader election that delayed full shard activation to ~10.3s. Setup operations now get a 30s client timeout. The regression check for #7489 is unaffected: it relies on the server-side operation timeout passed to delete_collection, not the client read timeout. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6a86160f27 |
Fix flaky disk exhaustion in edge-test CI job (#9807)
The publish/ directory is a separate Cargo workspace, so it does not inherit the root workspace's [profile.dev] debug = "line-tables-only" override. Example binaries were built with full debug info, each statically linking qdrant-edge at ~700 MB per binary. Linking several of them concurrently ran the runner out of disk, surfacing as "mold: failed to write to an output file. Disk full?" + SIGBUS. Mirror the debug info override in the publish workspace and free ~20 GB of unused preinstalled software on Linux runners as headroom. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bbc2694954 |
Add optional last-modified timestamp to ListedFile and CachedFs FileInfo (#9803)
* Add optional last-modified timestamp to ListedFile and CachedFs FileInfo Filled where the listing backend exposes one: local filesystems (entry metadata) and object stores (ObjectMeta::last_modified). The uio-grpc backend reports None since the RPC does not carry mtimes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Carry last-modified over the StorageRead ListFiles RPC Extend ListFilesEntry with an optional google.protobuf.Timestamp, fill it on the server from the listing metadata, and convert it back to SystemTime in the uio-grpc client. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update lib/common/io_bridge_object_store/src/source.rs Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Fix missing SystemTime import in io_bridge_object_store The CodeRabbit-suggested last_modified mapping used SystemTime::from without importing std::time::SystemTime, breaking compile and CI. Co-authored-by: Cursor <cursoragent@cursor.com> * Retrigger CI after flaky integration-tests-consensus timeout The compile fix is in; the prior run failed on an unrelated 30s timeout in test_collection_recovery, not on PR changes. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
b074c18b59 |
Stamp payload storage version on append-only clone paths (#9806)
clone_and_mutate_point and clone_and_replace_point_raw rewrite the point's payload row at a fresh internal id, mutating payload storage — but never bumped version_tracker's payload version. Payload operations bump it at the caller, so their clones were stamped; vectors-only operations (upsert_point, upsert_point_raw, update_vectors, delete_vector) never stamp payload, leaving the manifest with a stale payload version. A partial snapshot could then skip payload storage files that changed, and the restored id tracker would point at offsets with no payload row. Stamp inside the clone helpers, where the mutation happens. The payload entry points move their bump into the in-place closure so the clone arm is not double-bumped: a same-version double bump collapses the tracked version to None, degrading the stamp to the segment version. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b80a10ecfd |
Add uio-grpc backend and key-triggered live-reload to edge-shard-query (#9795)
* Add uio-grpc backend and key-triggered live-reload to edge-shard-query --backend uio-grpc opens the shard directly over a running Qdrant peer's StorageRead gRPC service (public gRPC endpoint, api-key aware), addressed by --collection/--shard-id — no object storage involved. The UioGrpcSource backend existed since #9634 but was never wired into the tool's CLI. --bucket is now per-backend optional (required for aws/gcs), and the default cache dir is scoped by collection/shard for uio-grpc, whose mirror has no distinguishing key prefix. --live-reload-key runs the same watch loop as --live-reload with each reload triggered by pressing Enter instead of a timer — easier when stepping through a debug scenario. Timer mode is unchanged; the two flags are mutually exclusive. Closed stdin ends the loop gracefully, so the flag cannot busy-loop on piped input (and is documented as incompatible with @- stdin request arguments). Verified end-to-end against a live instance started with QDRANT__FEATURE_FLAGS__WRITE_SEGMENT_MANIFEST=true: scroll over uio-grpc returns all points with payloads, timer mode picks up an upsert + delete as +/- diff lines, and key mode fires one reload per Enter and exits cleanly on EOF. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Log StorageRead not-found gRPC failures at debug level A read-only follower routinely probes files the writer creates lazily (e.g. the mutable id tracker's mappings and versions before the first flush), so every uio-grpc follower poll spammed INFO logs like: gRPC /qdrant.StorageRead/FileLength failed with NotFound "File not found: .../mutable_id_tracker.versions" NotFound on /qdrant.StorageRead/* now logs at debug; NotFound on all other services (missing collection etc.) stays at info. The service is matched by parsing the path's service component and comparing it to the tonic-generated storage_read_server::SERVICE_NAME constant rather than a hard-coded path string. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e426241a7b |
Add --live-reload watch mode to edge-shard-query (#9791)
With --live-reload <SECONDS> the tool keeps running after the first answer: every interval it refreshes the ReadOnlyEdgeShard from object storage, re-runs the same request, and prints the difference against the previous results — `+` id appeared, `-` id disappeared, `~ old -> new` for changed content (payload/vector/score/version). Each cycle prints a summary line; pure reordering prints nothing. The request is parsed once into a PreparedRequest (ScrollRequest / SearchRequest are Clone), so every iteration re-runs the identical request and the filter/vector parsing and logging no longer repeat. The first run prints the full result set in the existing format. A failed refresh is logged and retried next tick — the shard keeps serving its previous state, so a transiently unreachable bucket does not kill the watch loop. Status lines go to stderr via log, diff rows to stdout, keeping stdout machine-consumable. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9e6892e272 |
Preserve not-found classification on read-only reload/open paths (#9777)
Follow-up to #9763: an audit of the component live-reload and read-only open paths found four places where a not-found error lost its structured classification before reaching OperationError::FileNotFound: - MmapFile::reopen opened the file with a bare `?`, producing an unstructured Io(NotFound) — and this is the central call on the local follower's live-reload path, so no mid-reload segment removal would ever have classified on the mmap backend. Wrapped with extract_not_found. - BlockCacheFs::open and CachedSlice::reopen had the same bare-`?` pattern over CachedSlice::open's io::Result. Both wrapped. - ReadOnlySegment::open_via hand-rolled a service_error for a missing version file. The version file is written last, so its absence is exactly the vanished-mid-open signal; now FileNotFound { path }. - ReadOnlyRoaringFlags::read_status_len masked NotFound internally, so live_reload silently kept a stale len if the status file vanished mid-reload. The raw reader now propagates; open masks it explicitly with ok_not_found() (works on OperationResult since #9763), and live_reload requires the file. Everything else audited came back correct: lazily-created files (mutable id tracker, mutable-index gridstore) keep masking absence, optional-component probes at open keep ok_not_found, and the object-store bridge already maps missing objects to structured NotFound. New test vanished_segment_classifies_not_found pins both legs end-to-end: opening a missing segment directory and live-reloading a segment whose directory was removed both classify via is_not_found(). Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1bd3ee9b98 |
[UIO] implement VectorStorageReadEnum::preopen (#9780)
* [UIO] implement VectorStorageReadEnum::preopen Schedule background prefetch of every file the read-only vector storages open, wired into the segment's first_preopen between the id tracker and the payload indexes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * polish PR - fully populate deleted bitslices - don't use `.ok_not_found()` - section comments * don't overwrite `populate` in `CachedFs` --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Luis Cossío <luis.cossio@outlook.com> |
||
|
|
048a04bb64 |
Fix payload index inconsistency after restart: flush-before-build (alternative to #9737) (#9767)
* Make payload index creation durably self-contained (alternative to #9737) Flush the segment (serialized with the flush pipeline) before building a field index, and flush the built index before saving the config, so the synchronously written config never durably outruns the state the build observed or the index data it describes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Derive wipe_field_dirs candidates from PayloadIndexType exhaustively A hardcoded directory list silently misses new index types. Map each PayloadIndexType variant to its storage directory in an exhaustive match and iterate the enum, so a new variant does not compile until its directory is declared and is then wiped on rebuilds automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Flush built index in build phase, not under the segment write lock apply_index runs under the segment write lock, and flushing a freshly built index there blocks reads for the duration of the flush. Move the flush into Segment::build_field_index, which runs under the upgradable read lock: searches keep flowing, durability ordering is unchanged (index data still becomes durable before the config lists it), and the not-yet-installed storages cannot overlap any captured flusher pass. Also validate the incompatible index type switch (keyword -> full-text) across a simulated crash. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
363a8fec38 |
Fix /readyz false-positive on freshly bootstrapped peer (#9774)
A bootstrapping peer seeds `conf_state` with just the first voter of the cluster (see `Consensus::init`) until it applies the configuration change entries of the Raft log. Since #9688, `member_peer_addresses()` filters known peer addresses by `conf_state` membership, so during that catch-up window the health checker saw a single member, took the single-node short-circuit in `cluster_commit_index()`, and latched /readyz to ready before the peer reached the cluster commit index. Only apply the `conf_state` filter when this peer is a member itself. This keeps the #9688 behavior for reinitialized peers (whose conf_state is reset to the peer itself) and genuine single-node clusters, while a catching-up peer falls back to all known peer addresses and properly waits for the cluster commit index. Fixes flaky test_replace_running_peer_without_shards_same_uri, where the test queried collection state right after /readyz passed, before the peer applied the CreateCollection entry. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e65a5c9ca5 |
Classify structured not-found errors as OperationError::FileNotFound (#9763)
Read-only edge-shard followers need to distinguish "essential segment file
missing because the leader removed the segment mid-reload" (re-check the
manifest, absorb) from real corruption (escalate). The classification
already exists at the universal-io layer (UniversalIoError::NotFound,
mmap MissingFile, both carrying the path), but died at the OperationError
boundary where everything collapsed into ServiceError strings.
- Add OperationError::FileNotFound { path } and route the structured
sources into it: UniversalIoError::NotFound, MmapError::MissingFile,
and GridstoreError::UniversalIo(NotFound) (the route payload-storage
live-reload errors take).
- Implement IsNotFound for OperationError so follower code can classify
(and OperationResult::ok_not_found() works where lazily-created files
are legitimately absent).
- Raw io::Error NotFound intentionally stays ServiceError: it has no
structured path; universal-io wraps not-found at the call site via
extract_not_found, so classification belongs at the source.
- CollectionError maps FileNotFound like a service error, keeping
external API behavior unchanged.
Groundwork for not-found handling in ReadOnlyEdgeShard live-reload.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
7e5bb25814 |
Lazy roaring flags bitmap and bool index counts (#9749)
* [AI] make ReadOnlyRoaringFlags bitmap and bool index counts lazy
Opening a read-only segment scanned every flags file end to end:
`ReadOnlyRoaringFlags::open` materialized the whole RoaringBitmap via
`iter_ones()`. Every payload field carries a null index, so this was paid
per field per segment, for bitmaps most queries never touch.
Make the bitmap a `OnceLock`, filled by a scan on first access. Open now
reads only the tiny status file. `ReadOnlyBoolIndex`'s three eager count
fields collapse into one lazily-derived, cached `BoolCounts`; its
`live_reload` refreshes them in place when present and leaves them unset
otherwise, so reloading an index nothing queries stays scan-free.
Propagate the resulting `OperationResult` through `RoaringFlagsRead`,
`PayloadFieldIndexRead::count_indexed_points`, `FieldIndexRead`,
`PayloadIndexRead::{indexed_points, get_telemetry_data}`, `build_info` /
`build_telemetry` and `SegmentEntry::{info, get_telemetry_data}`, out
into shard, edge and collection.
`ram_usage_bytes` stays infallible: an unmaterialized bitmap holds no
RAM, so it reports 0 via the new `bitmap_if_materialized`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* [AI] correct `preopen` comment: `open` no longer scans the flags file
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* [AI] fix edge examples for fallible `info()`
`EdgeShardRead::info` now returns `OperationResult<ShardInfo>`. The
examples live in their own workspace (lib/edge/publish), so the main
`cargo check --workspace` never saw them.
Every call site sits in `fn main() -> Result<(), Box<dyn Error>>`, so
propagate with `?`. `bm25-search` compiled either way but would have
printed the `Result` rather than the `ShardInfo`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
d094e795f3 |
test(consensus): de-flake replicate_points_stream_transfer_updates override case (#9755)
* test(consensus): de-flake replicate_points_stream_transfer_updates override case With override_points=True the background writer re-upserts points 9990-9999, re-rolling their city payload. Points flipping away from "London" legitimately drop out of the filtered count on both shards, so asserting dest_filtered_count >= original snapshot count is not a valid invariant. On a slow CI runner the sleep(1)+kill() stopped the writer right after the overrides, before new inserts could compensate, making a net-negative flip likely (observed: 4954 >= 4959 failure). Replace the blind kill with a bounded workload (60 points) joined cleanly before the ~10s transfer of 10k points can finish, assert the writer exit code, and allow the filtered count to drop by up to the number of overridden points. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(consensus): guard that writer finishes while transfer is running The exact count consistency check requires every concurrent write to go through the transfer proxy. Make that precondition explicit: if the transfer ever finishes before the writer, fail with a clear message instead of a confusing count mismatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(consensus): make replicate_points update consistency checks exact Assign the city payload deterministically by point ID parity so filter membership can never change under concurrent overwrites. All assertions become exact ID-set comparisons with no slack: random city re-rolls made count-based checks unsound, since the forward proxy filters forwarded updates by post-update state and a point flipping out of the filter legitimately goes stale or missing on the destination. Replace the background writer process (sleep/kill/join choreography) with synchronous wait=true upserts issued while the transfer streams the initial points. Leave low point IDs unoccupied and insert into them during the transfer: the stream cursor passes them immediately, so these points can only reach the destination through live update forwarding, which the previous layout (writes at the stream tail) never verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a976a92eb4 |
ci: build single wheel on dev push, test all stable Pythons on release (#9751)
Building the full qdrant-edge wheel matrix (15 build jobs + 8 test jobs
across Linux/macOS/Windows, glibc/musl, CPython/PyPy) on every push to
dev is too expensive. Dev pushes now build only the most popular flavor:
x86_64 manylinux_2_17 CPython. Thanks to abi3-py310 that single wheel
covers every CPython >= 3.10, and it is tested on the latest stable
Python (3.14) on ubuntu-latest.
The full platform matrix still runs on manual workflow_dispatch
releases, and their test matrix is extended from {3.10, pypy3.11} to
all stable CPythons (3.10-3.14) plus PyPy on all four OS runners.
Since macOS/Windows builds are skipped on push, edge-py-test and
merge-artifacts switch to !cancelled()-based conditions so they are not
skip-propagated.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
05dc7409ca |
[AI] Read SegmentState and PayloadConfig once per read-only segment open (#9746)
`first_preopen` and `open_via` each parsed `segment_state.json`, and
`ReadOnlyStructPayloadIndex::{preopen, open}` each parsed the payload
index `config.json`. Thread the configs the preopen pass already read
into the open pass instead, and factor the duplicated
`PayloadStorageType -> Populate` match into a helper.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
cf19e6bb0a |
[AI] implement ReadOnlyRoaringFlags::preopen (#9747)
Schedule background prefetch of the status and flags files that `ReadOnlyRoaringFlags::open` reads, probing existence through the status file the same way `open` does. Wire it up through the two roaring-flag backed leaves — bool and null index — whose arms in `ReadOnlyFieldIndex::preopen` were hardcoded `false` placeholders. Every field index carries a null index alongside it, so this puts the flags on the live segment-open prefetch path. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e87c603b00 |
perf(universal-io): serve known file length from CachedFs snapshot on plain open (#9741)
`CachedFs::cache_file_info` already snapshots every file's size from the `list_files` result, but that size was only threaded into opens on the prefetch path (`schedule_prefetch` -> `with_known_len`). Files opened directly through the fallback `open` — e.g. segments the loader opens lazily rather than prefetching — forwarded the caller's `OpenExtra` unchanged, so `known_len` was `None` and the backend later issued a remote `len`/HEAD to size the file. Thread the snapshot size into the fallback open too: when a snapshot exists and lists the path, apply `with_known_len(info.size)`. This lets `DiskCacheFs::open` go straight to `State::Ready`, eliminating a remote metadata round-trip per lazily-opened file on blob backends (S3). No-op when no snapshot was taken or for backends where `with_known_len` is meaningless. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ce3b1ceed8 |
debug logging of the s3 connection (#9597)
* debug logging of the s3 connection * refactor(io_bridge): make S3 latency logs opt-in and low-overhead Move the blob-backend latency traces onto a dedicated `io_bridge::latency` log target at `trace` level, so they are silent by default and can be toggled as one group at runtime without a rebuild (e.g. `RUST_LOG=io_bridge::latency=trace`). Guard the timing `Instant::now()` behind `log_enabled!` so there is no overhead when the target is disabled. Also add `list_files` timing, and switch the shard_query CLI logger to millisecond timestamp resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ca0beb9bff |
feat(edge): add --search-threads to shard_query tool (#9736)
Add a `--search-threads` option to the edge-shard-query tool so the number of threads in the shard's search thread pool can be specified. When set, it builds an EdgeConfig with `max_search_threads` and passes it to `ReadOnlyEdgeShard::open`, overriding the CPU-derived default used for both parallel segment reads at open and running searches. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
7ad57559a8 |
fix memory enum openapi (#9735)
* make sure Memory object is generated as enum in OpenAPI * fmt |
||
|
|
7cc755c225 |
Bound request snapshots in the slow requests log (#9726)
* Bound request snapshots in the slow requests log Entries in the slow requests log retain a full serde_json::Value snapshot of the (internal) shard request indefinitely. The distance matrix API internally generates a batch of `sample` queries, each carrying a has_id filter with all `sample` sampled ids, so a single log entry ballooned to sample^2 ids expanded into a JSON tree (~90 bytes/id): ~90MB per entry for sample=1000, ~2GB for sample=5000. Random sample ids give every request a fresh content hash, so each call added a new entry until the 32-slot queue filled — OOM long before that for larger samples. Truncate all arrays in logged request bodies to 64 elements plus an omission marker. Query batches are serialized per element up to the cap, so the full untruncated JSON tree is never materialized even transiently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix rustfmt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
428e196f7d |
CachedReadFs: prefetch-backed read-only segment opens (#9712)
* Add CachedReadFs: prefetch-backed read-only universal-io filesystem Snapshots the file listing at construction and serves opens from explicitly prefetched handles (take-once, shared across clones via Arc<Mutex>). A non-prefetched open falls back to a direct open on the inner filesystem, panicking in debug builds and warning in release. CachedFile is a transparent wrapper needed to satisfy the bidirectional UniversalReadFs<File = Self> pinning, following the ReadOnly pattern. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Serve read-only segment opens from CachedReadFs prefetch pool ReadOnlySegment::open builds a per-segment CachedReadFs: the files known in advance (version.info, segment.json) are scheduled before the listing snapshot is taken so their fetch overlaps the listing round-trip, then every remaining listed file is scheduled, running all fetches in parallel instead of serializing them inside component opens. Existence checks and format-detection probes are answered from the snapshot without touching the inner filesystem. Stored handles are taken out of the pool via the new CachedReadFs::take_file, which returns the raw inner file — component types stay over plain S, and CachedFile exists only transiently inside open-read-discard helpers (read_json_via etc.) through the trait impl. The read-only open path takes &CachedReadFs<S::Fs> concretely; storing wrappers gained from-file constructors (StoredBitSlice::from_file, UniversalHashMap::from_file, ReadOnly::from_file, gridstore Tracker::open_cached / Pages::open_cached, read_chunks_cached). Snapshot-less, CachedReadFs is a passthrough to the inner filesystem — used by reload paths, writable build paths that reuse the on-disk index opens, and tests, all of which keep their previous behavior. Components that retain a filesystem for later reloads store the raw inner backend (CachedReadFs::inner), never the stale snapshot. Also: local_list_files now recurses into subdirectories, matching the flat key-prefix semantics of object-store listings; the immutable id tracker probes its defining file via exists (free on the snapshot) instead of a probe-open that would consume the take-once handle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Group imports per nightly rustfmt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix sparse search bench for open_ro over CachedReadFs CI clippy runs --all-targets; the bench target was missed by the --tests sweep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Match listing prefixes by path component, not by string The recursive local_list_files compared whole path strings; on Windows a joined prefix mixes `/` and `\` (`shard\index/chunk_`) while walked entry paths use `\` throughout, so nothing ever matched (broke list_files_returns_paths_relative_to_shard_dir on Windows CI). Match the entry name at the prefix's final position against the prefix's final component instead, then walk matched directories exhaustively — same semantics, separator-agnostic. Apply the same component-based matching to the CachedReadFs snapshot filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * do not cache everything * fmt * dont unwrap files_info * fix clippy * relax debug assertion for now * [AI] refactor into extension trait, relax Fs<->File requirement (#9725) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Luis Cossío <luis.cossio@outlook.com> |
||
|
|
ad3abf7e45 |
Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components
Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:
- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure
The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.
New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
`GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
gain a `cached` tier (mmap + populate on open)
`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
* Fix gpu-gated tests for the new `memory` field
CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean
- OpenAPI tests: create/update collections with `memory` on every component,
assert the parameters are echoed in collection info, assert legacy-only
collections expose no new fields, and assert `pinned` is rejected (422)
for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
`sparse_index_config.json` instead of the legacy-resolved placement, so
configurations using only the deprecated `on_disk` flag keep byte-identical
files that older Qdrant versions load without unknown fields.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Validate collection meta ops at construction, not only in the API layer
The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.
Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.
`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
||
|
|
f5c5beb3ce |
Resolve filter-based update operations to point ids before WAL write (#9678)
* Resolve filter-based update operations to point ids before WAL write Filter/condition-resolving operations (delete-by-filter, conditional upsert, the *-by-filter payload/vector operations) stored their filter in the WAL and re-resolved it against live segment state on every apply. Replay-time state can differ from the original apply-time state (the optimizer drops deleted points and their version records during compaction), so WAL replay was not a deterministic function of the log and could resurrect filter-deleted points. Resolve such operations into concrete point ids at submit time, under a fence that guarantees the resolution sees exactly the operations that precede it in WAL order. The WAL now only ever contains id-based operations (pre-existing variants only — no format change), so replay applies the exact same point set as the original run. Fixes #9575 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW * Fix rustfmt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW * Drop coordinator-side resolution: every replica resolves locally Replicas holding the same data resolve the same filter to the same point set, and replicas that already diverged would not become consistent by agreeing on a filter's resolution. Forward the original filter operation as usual and let each replica's submit fallback resolve it under its own fence — one uniform path regardless of where the update lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DfVDMFQcy9Ww791x8sHobW * Guard against is_filter_resolving / resolve_operation drift A resolved operation must never still classify as filter-resolving, otherwise a filter-carrying record could reach the WAL again (#9575). Catch one direction of drift between the gate and the rewriter with a debug assertion right after resolution. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Dedup points-vs-filter precedence into resolve_points_or_filter The "explicit id list wins over the filter" rule was written twice on the resolver side (DeletePayload arm and resolve_set_payload); a future tweak landing in one copy only would make SetPayload and DeletePayload silently diverge in what gets persisted to the WAL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Assert rewritten WAL record reuses the incoming clock tag The single-record-reuses-the-tag property is what WAL-delta recovery and replica dedup rely on, but no test asserted it: submit the delete-by-filter with a real clock tag and check the resolved DeletePoints record carries it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Test replay of old-style filter records left in the WAL Upgraded nodes can still hold WALs with unresolved filter operations; the by-filter apply paths are kept so they replay one final time with the old semantics. No test covered that path (the new submit flow can no longer produce such WALs), so append a raw DeletePointsByFilter record at the WAL layer, reload, and assert the matched points are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add consensus test for per-replica filter-op resolution Exercises the replicated path for filter/condition-resolving updates: the coordinator forwards the original filter op and each replica resolves it locally (delete-by-filter, insert-only and update-filter conditional upserts, set-payload-by-filter, including per-shard empty resolutions on a 2-shard collection). Asserts both replicas hold identical state (reads prefer the local replica), then restarts the whole cluster and asserts each replica replays its id-based WAL to the same state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Arnaud Gourlay <arnaud.gourlay@gmail.com> |
||
|
|
2bb287d693 |
Make EdgeConfig tunables optional with a layered fallback chain (#9714)
Tunable EdgeConfig parameters (on_disk_payload, hnsw_config, optimizers) are now Option, and every tunable resolves through the fallback chain provided -> persisted -> derived from segments -> default when loading an existing shard. Leaving a parameter unspecified keeps the shard as it is; an explicit value overwrites it and existing segments converge to it through the optimizers. vectors/sparse_vectors are excluded from overwrite semantics: an empty map inherits the persisted/segment-derived definitions, a non-empty map is validated for compatibility against the loaded segments (size, distance, multivector, datatype, sparse modifier) and fails the load on mismatch. The derived layer folds over all segments in UUID order instead of taking an arbitrary first segment, so a plain appendable segment (which carries no HNSW parameters) can never mask an indexed segment's actual build parameters. Previously a lost edge_config.json could resolve unspecified HNSW params to compiled-in defaults and silently trigger a full re-index via ConfigMismatchOptimizer. The read-only follower accepts an optional config on open: provided tunables are applied once over the segment-derived config (vectors always come from the segments), and refresh re-derives from segments alone. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
30f00477c6 |
Add disk-resident id tracker (#9657)
* Add disk-resident id tracker Introduce a disk-resident id tracker family that keeps the point-id mapping on disk instead of loading it into RAM, so resident memory no longer scales with total point count. This removes the last component of a segment that forces a full RAM load, enabling object-storage / edge followers and cutting RAM for regular deployments that opt in. New on-disk mapping format (`disk_id_tracker/on_disk_format.rs`): random-access `id_tracker.i2e` (internal->external) + `id_tracker.e2i` (external->internal as sorted num/uuid runs with a resident sparse block index). `id_tracker.versions` and `id_tracker.deleted` are reused unchanged. Trackers (sharing a lazy `DiskMappingReader` core and a `DiskMappingsSource` trait): - `DiskIdTracker` — writable, deletion-only; a new `IdTrackerEnum` variant used by regular segments, keeping deleted+versions resident and the mapping on disk. - `ReadOnlyDiskIdTracker` — read-only live-reload mirror for followers; per-point `get_bit` deletion checks on read-by-id, full deleted set materialized lazily. Selection: created when the `serverless_compatible` feature flag is set (in `segment_builder`); loaded by attempting each format in `ReadOnlyIdTrackerEnum:: detect_and_load` (no per-file `exists` round-trips) and by file-presence detection unified in `IdTrackerFormat`. `PointMappingsRefEnum` is generic over the read backend so the disk variant is a concrete `DiskMappingsRef` (no trait object). The `DiskMappingsSource` read surface returns `OperationResult`; errors are only swallowed at the infallible `IdTrackerRead` boundary. `ImmutableIdTracker` is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * style: apply rustfmt Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix clippy: collapse nested if in DiskIdTracker::drop Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Align on-disk id tracker headers and sections to 16 bytes Pad the i2e header to 32 bytes and the e2i header to 48 bytes, and zero-pad between e2i sections so every section starts on a 16-byte boundary. This keeps the files mmap+transmute-friendly: the u128 arrays (i2e slots, e2i uuid sparse index) need 16-byte alignment in Rust. Add on_disk_sections_are_aligned test pinning the invariant and the store/parse padding agreement via exact file-length checks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fd86a18815 |
Fix /readyz of reinitialized peer waiting on a foreign consensus (#9688)
Applying `RemoveNode(self)` prunes all other peers from the removed peer's persisted address book, but the process may be stopped after the removal is committed and before the entry is applied. The old first peer's address then survives `--reinit`, and the readiness checker - which treated every `peer_address_by_id` entry as a cluster member - would wait for the reinitialized peer to reach the *old* cluster's commit index: a foreign consensus it can never catch up with, so `/readyz` never passed. Filter the address book by current `conf_state` membership instead, falling back to all known addresses while `conf_state` is still empty (a bootstrapping node that has not applied any configuration change yet). After `--reinit` the `conf_state` is reset to a single voter, so the readiness check correctly ignores peers of the old cluster. Fixes flaky `test_reinit_removed_peer`, which hit this race when the removed peer was killed before applying its own `RemoveNode`. The new regression test simulates that state and fails with the exact CI error without the fix. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2573869577 |
Add prefix matching option to keyword index (#9683)
* Add prefix matching option to keyword index
Introduce an opt-in `prefix` option for the keyword payload index and a
new `match: { "prefix": ... }` filter condition, enabling efficient
byte-wise prefix filtering over keyword values (e.g. URL prefixes,
web-ui value autocompletion via facet + prefix filter).
Index side: a new `prefix_index.bin` file stores a sorted, front-coded
key dictionary with a resident block index (cumulative counts per
block); it is an ordered view over the keys of `values_to_points.bin`
and stores no postings. Presence of the file signals prefix support at
load time, so legacy segments load unchanged and enabling the option
goes through the standard incompatible-schema rebuild. The mutable
variant keeps an in-RAM ordered key set (not persisted), the immutable
variant builds a sorted key vector at load, and the on-disk variant
reads the dictionary lazily (block index resident, 1-2 block reads per
prefix lookup; reader is generic over UniversalRead).
Query side: prefix conditions are served from the dictionary when
available (filter + cardinality estimation from per-block aggregates),
from the forward index as per-point checks, and degrade to the payload
full-scan fallback otherwise - same execution model as other match
conditions. Strict mode (`unindexed_filtering_*`) rejects prefix
queries on fields without a prefix-enabled keyword index via a new
KeywordPrefix capability.
HNSW payload blocks: prefix-enabled indexes additionally emit prefix
blocks for heavy branching trie nodes (single-child chains collapsed to
their longest common prefix, one block per distinct point set, emitted
largest-first) so filtered search with prefix conditions gets navigable
subgraphs without rebuilding the same subset repeatedly.
API: `prefix` flag on KeywordIndexParams (REST bool, gRPC empty
message for extensibility), `prefix` variant in the Match oneof, edge
python bindings, regenerated OpenAPI spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Split prefix index into a dedicated module, fix clippy in tests
Reorganize the flat prefix_index.rs / prefix_read.rs into a
map_index/prefix_index/ module: format.rs (on-disk layout primitives),
writer.rs, reader.rs (PrefixIndex), map_read.rs (StrMapIndexPrefixRead
with per-variant impls) and tests.rs, with a file-format diagram and a
read-path walkthrough in the module docs. No logic changes.
Also fix clippy --all-targets complaints in test code: replace a
wildcard Match arm with an exhaustive list and a field-reassign-with-
default with a struct literal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add OpenAPI test for prefix match and snapshot file-tracking test
- tests/openapi/test_prefix_match.py: index-less fallback, prefix index
creation with schema echo, scroll/count parity against ground truth,
facet + prefix filter (the autocompletion flow), strict-mode rejection
without the prefix capability.
- test_prefix_index_file_tracking: `prefix_index.bin` is listed in
`files()` / `immutable_files()` exactly when built with the option.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Replace hand-rolled varint parsing with bytemuck Pod records
Per review: the prefix index format now uses fixed-size little-endian
Pod records (BlockEntry 24 B, KeyEntry 12 B, Header 40 B) written with
bytemuck::bytes_of and read back by copy via pod_read_unaligned — no
manual varint encode/decode, no alignment requirement, one shared
read_record helper. Costs ~9 bytes per key on disk versus LEB128; the
raw key bytes dominate dictionary size, so the simplification wins.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Fetch the whole candidate block range with a single storage read
Candidate key blocks of a prefix lookup are contiguous in the file, so
enumerate them from one ranged read instead of one read per block; the
over-read versus the exact key range is bounded by the two boundary
blocks. Block decoding is split into a storage-free helper reused by
the per-block path of stats estimation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Align prefix payload blocks with the geo index granularity principle
Geo's large_hashes emits only the smallest geohash regions above the
threshold — a disjoint antichain, never a parent nested with its
children. Prefix payload blocks now follow the same rule: a heavy
collapsed trie node is emitted only if nothing heavy is nested inside
it, counting both deeper qualifying prefixes and single heavy values
(which already get their own exact-match blocks). Emitted blocks are
therefore mutually disjoint and disjoint from exact-value blocks; no
near-collection-sized ancestor subgraphs, no reliance on the HNSW
connectivity check to skip nested duplicates.
Implemented as a `covered` flag propagated through the existing
LCP-interval scan, still one O(total key bytes) pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Document block wire format and unaligned-read rationale in decode_block
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e784102f78 |
Fix flaky count check in snapshot transfer missing-point test (#9685)
Killing the background load processes does not cancel requests already executing server-side: a wait=true upsert accepted just before the kill can still be propagating to the second replica while the test counts points, so peers transiently observe different totals (e.g. [20120, 20118, 20118] on CI). Poll the exact counts until they converge instead of asserting on the first sample. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b185c46435 |
[UIO] Split UniversalReadFileOps into read and write traits (#9682)
UniversalReadFileOps mixed read-side operations (from_context, list_files, exists) with mutating ones (create, create_dir, remove, remove_dir, atomic_save). Move the mutating operations to a new UniversalWriteFileOps subtrait, mirroring UniversalRead/UniversalWrite. - UniversalWrite now requires Fs: UniversalWriteFileOps, so generic consumers (gridstore) keep reaching write ops through S::Fs. - ReadOnlyFs drops its runtime-erroring write stubs: read-only is now a compile-time property. - DiskCacheFs implements the write side only when the remote fs does. - BlobFs and the async AsyncRead trait are read-only: the async write methods are removed from AsyncRead and its backends (object store, uio-grpc) along with the tests that exercised them. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
11ef80468b |
Explicit HNSW links residency: cold / cached / pinned (#9669)
* Explicit HNSW links residency: cold / cached / pinned Introduce GraphLinksResidency to make graph links memory residency an explicit choice instead of a side effect of the IO backend: - Cold: mmap without populate, pages fault in on demand (on_disk: true) - Cached: mmap with blocking populate, evictable (on_disk: false) - Pinned: materialized into anonymous heap, page cache evicted after the read; kept internal for now (no production caller selects it), non-borrowable universal-IO backends (io_uring, object stores) fall back to it by necessity Fixes along the way: - Freshly built non-on_disk indexes no longer pin links in heap: the builder now always serializes to disk and re-loads as mmap (Cold/Cached by on_disk), so a just-built index has the same single-copy residency as one loaded after restart, instead of a heap copy plus the freshly written file in page cache. - Materializing fallbacks evict the page cache after copying to heap (same hygiene as read_whole_via), so links are never resident twice. - Memory reporter now reports heap-materialized links as RAM with files as persistence-only, instead of claiming page-cache intent for data that never touches the page cache (previously such links were invisible: 0 RAM, 0 cached, full size as "expected cache"). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update lib/segment/src/index/hnsw_index/graph_links/links.rs Co-authored-by: Tim Visée <tim+github@visee.me> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Tim Visée <tim+github@visee.me> |
||
|
|
20e099d233 |
Trace-log 'Missing request message.' internal gRPC errors (#9663)
Since the tonic 0.14 upgrade (hyper 1.x), a client cancelling a unary call between HEADERS and DATA surfaces as Internal "Missing request message." instead of Cancelled, because hyper hides stream resets during request body read (hyperium/hyper#3681). Cluster read fan-out generates these routinely, flooding logs with spurious ERROR lines. Log them at trace level, matching the existing Cancelled handling. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2c8a18571b |
Fix reinit failing with "removed all voters" on a removed peer (#9654)
* Fix reinit failing with "removed all voters" on a removed peer When `--reinit` is run on the first peer, `conf_state` is reset to a single voter (this peer), but the Raft log is left untouched. If this peer had been removed from consensus before reinit, its log still holds a committed-but-unapplied `RemoveNode(self)` conf-change. On startup that entry is replayed on top of the freshly reset single-voter config, and Raft aborts with "removed all voters", so the node can never start. Resetting only the apply-progress queue is not enough: a fresh single-node leader re-commits and re-applies any log entries still physically present beyond `commit`, re-triggering the failure. The stale tail has to be physically dropped from the WAL. On the first-peer reinit path, discard committed-but-unapplied entries inherited from the previous cluster: truncate the WAL to the last applied index, pin `commit` to it and clear the apply-progress queue. The entry at `commit` is retained as the snapshot anchor, so the first peer can still serve snapshots to bootstrapping peers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add regression test for reinit of a removed peer Starts a 2-node cluster, gracefully removes the second peer from consensus (so it commits `RemoveNode(self)` into its own WAL), then reinitializes it as a fresh first peer. Before the fix this panicked on startup with "removed all voters"; the test asserts the peer comes back online, elects itself leader, and can still seed a fresh bootstrapping peer. Verified the test fails against the pre-fix binary with exactly: Failed to apply configuration change entry Caused by: Error in Raft consensus: removed all voters Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |