581 Commits

Author SHA1 Message Date
xzfc
48e710ce0b Cleanup UniversalRead methods interface (#9934)
* use common::generic_consts::{Random, Sequential};

* UniversalRead::read_batch: generic over E

* UniversalRead::read_batch: pass `AccessPattern` as ZST arg

* UniversalRead::read_bytes_iter: pass `AccessPattern` as ZST arg

* UniversalRead::read_iter: pass `AccessPattern` as ZST arg

* UniversalRead::read: pass `AccessPattern` as ZST arg

* UniversalRead::read_bytes: pass `AccessPattern` as ZST arg
2026-07-22 15:28:07 +00:00
xzfc
7e99cdd86a UioResult (#9933) 2026-07-22 14:34:33 +00:00
xzfc
9bb75ea2d1 Batched ConditionChecker (#9740)
* ConditionChecker::check_batched: trait + ConditionCheckerEnum

* check_batched for OptimizedFilter

* OnDiskPointToValues::values_iter_batch: improve performance

* OnDiskPointToValues::values_iter_batch: update interface

Accept bitvec, call on every point, pass UserData.

* check_batched for geo index

* geo_index tests: add same_geo_index_between_points_with_dups_test

Catches broken load_from_on_disk/for_all_points_values.

* check_batched for map index

* check_batched for numeric index

* check_batched for full-text index

* tests for check_batched

* Review fixups

- default_check_batched: avoid running pred twice at boundary
- condition_checker: use explicit match over matches!/if-else
- Partitioner: use assert! over debug_assert!

Co-authored-by: Luis Cossío <luis.cossio@outlook.com>

* ConditionChecker::check_batched: &mut self -> &self

* ConditionChecker::check_batched: make mandatory

---------

Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-07-21 19:19:59 +00:00
Luis Cossío
23321085e8 [io-bridge] split large reads into unordered chunks (#9896)
* read large files in unordered chunks

* use S3 error

* use a vec of ranges to track scattering

* self nits

* use less concurrent chunks
2026-07-21 11:21:32 -04:00
Arnaud Gourlay
6ac465dfda Tests: use SmallRng for RNG-bound test data generation in common (#9888)
The persisted_hashmap and disk_cache tests generate their datasets with
StdRng (ChaCha12 in rand 0.10). String keys draw one random_range call
per character, so the crypto generator dominated the test runtime:
test_k_str_* drop from ~3.3s to ~1.4s each with SmallRng (Xoshiro256++).

Assertions are self-consistency checks (write then read back), so the
changed sequences carry no retuning risk.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:26:08 -04:00
Luis Cossío
5c269b9525 Misc nits (#9894)
* use `WithVector::is_enabled`

* suppress unused var lint

* fix non linux "useless mut" lint
2026-07-17 15:53:04 -04:00
Arnaud Gourlay
c196d2eb1a Benches: use SmallRng instead of ChaCha12-based generators (#9887)
* Benches: use SmallRng instead of ChaCha12-based generators

All benchmarks used StdRng or rand::rng() (ThreadRng), both backed by the
ChaCha12 block cipher in rand 0.10. Benchmarks do not need crypto-strength
randomness, and several draw random values inside the timed closure, so
cipher work was included in the measurement itself.

Switch every bench target to SmallRng (Xoshiro256++), and key the HNSW
graph cache and sparse index cache by RNG algorithm so stale caches built
from the old generator are not reused against newly generated vectors.

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

* Benches: replace free-function rand::random with local SmallRng

Addresses review: rand::random draws from the thread RNG (ChaCha12),
including inside the timed loop of the pq score benchmark.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:22:27 +02:00
Andrey Vasnetsov
0eedad2e38 DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split (#9878)
* DiskIdTracker: RAM-resident is_uuid stored-bitmask sidecar + module split

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

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

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

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

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

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

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

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

* Tighten disk_id_tracker docstrings around guarantees

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:31:14 +02:00
Andrey Vasnetsov
a6ea303e4b Compact stored bitmask for on-disk field index deleted masks (#9871)
* Compact stored bitmask for on-disk field index deleted masks

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

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

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

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

* Split save_bitmask into named helpers

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

* Regenerate OpenAPI spec for compact_bitmask feature flag

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

* Address review findings on stored bitmask

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:47:08 +02:00
Luis Cossío
2a92bcf3a3 [UIO] Increase PHF prefetch (#9842)
* increase phf prefetch

* no duplicate comment

Co-authored-by: xzfc <5121426+xzfc@users.noreply.github.com>

---------

Co-authored-by: xzfc <5121426+xzfc@users.noreply.github.com>
2026-07-15 09:54:08 -04:00
Tim Visée
71e6c70458 Universal IO: append to file (#9720)
* universal_io: add UniversalAppend for atomic single-operation appends

Growing a file previously took a separate set_len + reopen + write dance
(bypassing universal_io and leaving a zero-filled window on crash), and
there was no way to express appends for backends without random-offset
writes.

UniversalAppend::append grows the file by writing at the current end of
file in one atomic grow+write operation and returns the offset at which
the data landed; append_batch lands multiple buffers contiguously in as
few operations as the backend allows. flusher() moves from
UniversalWrite into a new UniversalFlush supertrait so append-only
handles can require it without duplicating the method.

Local backends, both single-syscall:
- MmapFile appends through a dedicated O_APPEND fd (every write(2) /
  writev(2) is an atomic grow+write at EOF), then remaps via reopen().
  Its flusher also fdatasyncs after appends, since msync alone does not
  persist file-size metadata.
- IoUringFile appends via pwritev2(RWF_APPEND). O_APPEND is not an
  option there: on Linux, pwrite on an O_APPEND fd appends regardless
  of the given offset, which would break positioned writes on clones
  sharing the fd.

Concurrent appenders are out of contract (single logical writer);
object-store backends surface the new AppendOffsetConflict error and
recover via reopen() + retry.

* io_bridge: add AsyncWrite/AsyncAppend and appendable BlobFile

Add the write-side backend traits reserved next to AsyncRead: AsyncWrite
(create/remove/save) powers a UniversalWriteFileOps impl on BlobFs
(create-or-truncate put, delete, atomic whole-object save; directory ops
are no-ops), and AsyncAppend — a single-request append where the offset
must equal the current object size, acting as a compare-and-swap token —
powers UniversalAppend on BlobFile.

BlobFile caches the object size across appends (one HEAD for N appends;
a missing object counts as empty so the first append creates it),
concatenates batches into a single request, and drops the cache on
reopen() — the documented recovery path after AppendOffsetConflict. Its
flusher is a no-op: appends are durable once the backend acknowledges
them.

* io_bridge_object_store: native single-request S3 append

object_store has no append support, so issue the PutObject +
x-amz-write-offset-bytes request ourselves, reusing the store's
credential chain (AmazonS3::credentials) and object_store's SigV4
AwsAuthorizer, which signs every header present on the request — no
hand-rolled signing and no direct reqwest dependency. The offset doubles
as a compare-and-swap token: a mismatch (400 InvalidWriteOffset, or 412
on some S3-compatibles) maps to AppendOffsetConflict.

The write-offset append API exists on AWS S3 Express One Zone directory
buckets and compatible stores (e.g. MinIO AiStor) — plain S3 Standard
buckets reject it, and real Express zonal endpoints / session auth are
not verified yet; MinIO-AiStor-compatible stores are the primary target
for now. GCS and Azure sources simply do not implement AsyncAppend.

ObjectStoreSource carries an AppendContext (HTTP client + object URL
base + signing region) built per backend from its config, and gains a
generic AsyncWrite impl (single-put create/save, delete). A test-only
multi-request CAS emulation over InMemory exercises the BlobFile append
stack hermetically; an end-to-end flow against a real append-capable
store is gated behind S3_APPEND_INTEGRATION_TEST=1.

* simple_disk_cache: write-through UniversalAppend for DiskCache

Append to the remote (the single grow+write operation), then write the
same bytes through into the local mirror so tail reads do not re-fetch
what was just uploaded. LocalState::append_local keeps the fetched
bitmap accurate: blocks fully covered by the appended range are marked
fetched, and the pre-append partial tail block — which resize() drops
because set_len zero-fills its gap — is re-marked only when its prefix
was already fetched. If the mirror turns out stale (the remote grew
behind our back), append falls back to resize-only and lazy fetches
heal the gap on the next read.

Writeable opens are now allowed on DiskCacheFs solely to enable append;
DiskCache still never implements UniversalWrite. The writeable flag
propagates to the remote handle, which is opened buffered instead of
O_DIRECT: appends write through the page cache, which O_DIRECT reads on
the same fd would fight (and IoUringFile rejects appends on
prevent_caching handles). The remote-immutability docs are relaxed to
append-only with an immutable prefix, matching what reopen() already
assumed.

Includes a full-stack composition test: DiskCache write-through over
BlobFile offset tracking over an in-memory object store.

* io_bridge_object_store: build the append HTTP client lazily

Opening a source from an AwsConfig eagerly built the reqwest client
(TLS setup, connection pool) even when append was never used. Keep the
AppendContext construction to pure config (allow_http flag, object URL
base, signing region) and build the client on first append instead,
cached in an Arc<OnceLock> shared across clones of the source — and
thus across the file handles opened from it. Sources that never append
now pay nothing; client-construction errors surface on the first append
instead of at open.

* universal_io: test that append grows the regular file on disk

The conformance suite reads appended bytes back through universal-io
handles; also assert the underlying regular file itself — created
outside universal_io, verified with plain fs reads — for both local
backends.

* Mention why we use custom HTTP client, object_store crate has no support

* io_bridge_object_store: reject appends unconfirmed by the size header

A store without write-offset support may accept the signed PutObject as
a plain put — replacing the object with just the appended bytes — and
return 2xx (community MinIO did exactly this before 2025-05, commit
minio/minio@6d18dba9). The old success path fabricated the new length
when x-amz-object-size was missing, so the destruction stayed invisible
while every subsequent append repeated it.

Require the x-amz-object-size response header (returned by AWS and
MinIO AiStor appends) for any append at offset > 0 and fail loudly
without it. Offset-0 appends are equivalent to a whole-object write, so
they remain valid either way — a misconfigured store now fails on the
second append instead of never.

* io_bridge_object_store: honor endpoint/region env vars for appends

With AwsCredentials::Default the store is built via
AmazonS3Builder::from_env, which honors AWS_ENDPOINT_URL_S3,
AWS_ENDPOINT_URL, AWS_ENDPOINT, AWS_REGION and AWS_DEFAULT_REGION — but
append_context derived the append URL and SigV4 region only from the
typed config fields. An env-configured deployment would read from one
host while signing and sending appends to
https://{bucket}.s3.us-east-1.amazonaws.com.

Resolve the append endpoint and region the same way build_store does:
explicit config first, then (default credential chain only) the same
environment variables, with AWS_ENDPOINT_URL_S3 taking precedence as in
from_env. The resolution is a pure function over an injected env lookup
so the test does not touch process-global environment state.

* simple_disk_cache: delegate the append flusher to the remote

DiskCache's UniversalFlush impl was an unconditional no-op, justified by
object-store appends being durable on acknowledgement — but the impl is
generic over any appendable remote, and for local remotes (MmapFile,
IoUringFile, exactly the compositions the tests instantiate) that
silently dropped the fdatasync the UniversalAppend contract requires:
append, flush Ok, power loss, appended bytes gone.

Delegate to the remote's flusher once the cache is materialized: local
remotes get their sync, object-store flushers remain no-ops, and a
never-materialized cache has made no appends so a no-op stays correct.

* io_bridge_object_store: retry transient append failures

The append RPC was a single unretried HTTP attempt, while every other
request in this stack goes through object_store's retry layer — a
routine transient 503 SlowDown or connection reset failed the append
hard where a concurrent read would have silently recovered.

Retry connection errors, 5xx and 429 up to three attempts with a short
linear backoff, re-signing per attempt (the SigV4 signature embeds the
request date). Retrying is safe because the write offset is a
compare-and-swap; the one ambiguity — an attempt that landed but whose
acknowledgement was lost — surfaces as a write-offset conflict on the
retry, which is reconciled with a HEAD: under the single-writer
contract, an object size of exactly offset + data_len proves the tail
is ours, so the append reports success instead of a spurious conflict
(whose reopen-and-retry recovery would duplicate the record).

* universal_io: forward TypedStorage::flusher for any UniversalFlush

The flusher forwarding lived in TypedStorage's S: UniversalWrite impl
block, so append-only storages (DiskCache, BlobFile — UniversalAppend +
UniversalFlush but not UniversalWrite) offered append through the
wrapper while the durability flusher the append contract mandates was
unreachable without going through .inner.

Move it to an S: UniversalFlush block: UniversalWrite implies
UniversalFlush, so existing callers resolve unchanged, and duplicating
the method instead would have hit E0592 on backends implementing both —
the very ambiguity UniversalFlush was extracted to avoid.

* io_bridge_object_store: surface unbuildable append requests as errors

Request building could panic on two reachable paths: url accepts URIs
the http crate rejects (IPv6 zone identifiers, URIs beyond u16::MAX
bytes), and AppendContext::new is public so the object URL base is not
guaranteed to be a base URL. Both expects become S3Config errors, so a
configuration edge case fails the append instead of panicking the
thread driving the bridge runtime.

* simple_disk_cache: don't fail appends the remote already committed

append_impl committed to the remote first and returned Err when the
subsequent local-mirror update failed (e.g. ENOSPC on the cache volume)
— indistinguishable from "nothing was appended", so a retrying caller
would duplicate the record on the remote.

The mirror is cache maintenance, not part of the append: on a failed
write-through, log and degrade to bare growth so lazy fetches heal the
unmarked blocks (safe — blocks are only marked fetched after their
bytes landed). Only an unresizable mirror still surfaces an error, and
the UniversalAppend contract now documents that an append Err does not
guarantee nothing was appended: reopen() and re-check the length before
retrying.

* universal_io: bounds-check positioned io_uring writes against EOF

The UniversalAppend contract states that UniversalWrite::write beyond
the end-of-file fails and append is the only growth path — mmap
enforces it, but IoUringFile's write/write_batch/write_multi were
unchecked pwrites that silently extended the file with a zero-filled
hole, inflating subsequent append offsets.

Check every positioned write against the file length (fstat once per
call), matching mmap's OutOfBounds semantics, and generalize the
regression test to run on both local backends including the batched
path.

* io_bridge: reject appends on handles opened without writeable

BlobFs::open dropped OpenOptions entirely, so a BlobFile opened with
writeable: false still accepted appends — mmap and DiskCache enforce
the writeable requirement, the blob backend silently didn't, and a
stray append through a nominally read-only handle would mutate a shared
object.

Thread OpenOptions::writeable into BlobFile and reject appends with
PermissionDenied when it is unset, mirroring the other backends.
Directly-constructed handles (BlobFile::new/open, which take no
OpenOptions) remain writeable. With every backend now enforcing the
flag, the UniversalAppend contract drops its 'where the backend
enforces open modes' hedge.

* simple_disk_cache: answer empty appends from the mirror

An empty append still went through remote.append_batch, so it returned
the remote's live end-of-file — which can diverge from what this
handle's len() and reads observe when the remote grew behind our back —
while leaving the stale mirror unhealed (unlike a non-empty append in
the same state, which resizes).

Accept empty appends early: return the mirror length without touching
the remote at all, keeping the answer consistent with the handle's own
view. The trait contract now spells out that empty appends return the
handle's view of the end of file without growth I/O.

* universal_io: grow the mmap in place after appends

Every mmap append ended in a full reopen(): an open+fstat+close by path
just to learn the new length, and — on the non-Linux fallback, which
rebuilds the mapping with the open-time populate flag — a re-population
of the ENTIRE file per append, making appends O(file size) for handles
opened with Populate::Blocking. Populating after an append is pointless
anyway: we just touched the data we wrote.

Extract the remap machinery into remap_to() (reopen() keeps its exact
semantics, populate included) and add grow_mapping(): a stat-free grow
that never re-populates. Appends learn the new length from a single
fstat on the already-open O_APPEND fd — kept rather than trusting the
mapping length, which is stale exactly in the externally-grown-remote
scenario the disk cache heals through lazy fetches (the foreign-growth
tests catch the difference). The mirror's resize() passes the length it
just set_len'd, dropping its stat round-trip entirely.

Per small append this is write+fstat+mremap, down from
write+open+fstat+close+mremap, with no populate anywhere.

* universal_io: share the mmap append fd across clones

The flusher captured the per-clone append_file at flusher-creation
time, so a flusher obtained from a sibling clone — or created before
the handle's first append — msynced the shared mapping's data pages but
skipped the fdatasync that persists the appended file size: a
half-persist where a crash loses the acknowledged tail even though a
flusher ran after the appends (writer thread + long-lived flush-worker
clone is exactly the natural WAL shape).

Store the fd in an Arc<OnceLock> shared by all clones and read it at
flush time instead of capture time: any clone's append makes every
handle's flusher sync the size metadata, whatever the clone/flusher
creation order. Initialization races between clones keep exactly one
fd. No hot-path cost: reads and positioned writes never touch the cell,
and the append path pays one atomic load next to its syscalls. The
interior mutability also lets append_fd take &self.

* universal_io: document the clone remap hazard truthfully

The remap SAFETY comment claimed moving is safe "since we are holding
&mut self" — which says nothing about clones: they share the mapping
but keep their own raw ptr/len copies, so after a moving (or, on
non-Linux, replacing) remap a sibling clone's next read dereferences an
unmapped address. The trait contract understated the same hazard as a
concurrent-read constraint, while the UB persists after append returns.

State the contract once on MmapFile (clones must reopen before reading
after any growth; a stale clone read is undefined behavior, not a stale
view), correct the SAFETY argument to rely on it explicitly, annotate
the as_bytes unsafe blocks that depend on it, and sharpen the
UniversalAppend contract bullet accordingly. Making clones structurally
safe (resolving ptr/len through the shared Arc) is deliberately left as
a separate change.

* universal_io: share the vectored append machinery between backends

The IOV_MAX-chunking / EINTR-retry / WriteZero / advance_slices loop
existed twice — as local_file_ops::write_all_vectored (mmap) and
inlined around pwritev2 in IoUringFile::append_slices — along with a
verbatim collect/cast/filter-empties preamble in both append_batch
impls. Two copies of subtle short-write handling introduced by one
branch will diverge the first time only one of them gets a fix.

Add an io::Write adapter whose write_vectored issues
pwritev2(RWF_APPEND), letting the io_uring append delegate to the
shared write_all_vectored, and hoist the slice collection into
local_file_ops::collect_append_slices. IOV_MAX becomes private to the
one function enforcing it. No behavior change; the existing conformance
tests (including the beyond-IOV_MAX batch) cover both backends through
the shared path.

* io_bridge_object_store: add s3_express to the test config helper

The AwsConfig struct gained the s3_express field; update the
resolve-endpoint test helper accordingly.

* universal_io: run the append conformance suite over the S3 stack

Promote the backend-generic UniversalAppend battery (offsets, batches
across IOV_MAX, empty appends, read-after-append, reopen visibility,
flusher) from a private test into universal_io::conformance, exposed
under the testing feature so backend crates can run the identical
suite. mmap and io_uring keep running it as before; the object-store
bridge now runs it too, over BlobFs/BlobFile with the in-memory
offset-CAS append emulation — so local file system and S3 append
behavior are asserted by the same test. The real write-offset RPC
remains covered by the gated test_native_append_flow integration test.

* Swap order

* universal_io: disambiguate the io_uring crate import

The import reorder dropped the leading `::`, making `io_uring`
ambiguous with this very module (pulled into scope by the
`use super::*` glob) and breaking the build.

* io_bridge: don't materialize the mock object on rejected appends

MutableMockSource::append called get_or_insert_with before validating
the offset, so a rejected stale append against a missing object left an
empty entry behind (exists() flipping true) — a fidelity gap versus the
real backends, where a rejected append has no side effects. Check the
offset against the current length first and only materialize the buffer
on a match.

* universal_io: disambiguate the io_uring crate import

Restore the leading `::` on the io_uring crate import — without it the
name is ambiguous with this very module, which the `use super::*` glob
pulls into scope, and the crate fails to compile. Matches the sibling
files (pool.rs, runtime.rs), which already import via `::io_uring`.

* io_bridge_object_store: treat 404 under a nonzero append offset as a conflict

A missing object while the handle expected a nonzero end-of-file is a
stale view (the object was deleted behind our back) — the same
situation as an offset mismatch, with the same reopen-and-retry
recovery, and it is exactly what the in-memory emulation and the
io_bridge mock already report. The RPC path mapped every 404 to
NotFound instead, so the three implementations disagreed on the same
logical case. Keep NotFound for offset-0 appends, where a 404 is a
genuine missing-target error (e.g. a missing bucket) that retrying
cannot heal.

* Preallocate vector

* universal_io: disallow appends through the disk cache

Appends must go directly to the backing storage (mmap, io_uring, S3) —
the disk cache is strictly read-only again. Remove DiskCache's
UniversalAppend/UniversalFlush impls and the mirror write-through
machinery (LocalState::append_local), reject writeable opens at
DiskCacheFs::open, and drop the writeable/prevent_caching plumbing that
existed solely for cached appends, restoring read-only remote handles.

Attempting to append through the cache is now a compile-time error (the
trait impl no longer exists), and opening a cached handle writeable is
rejected at runtime, covered by a test in each backend variant.

* universal_io: make append idempotent via caller-supplied offset

Append now takes the byte offset where the data must land:
append(offset, data) -> Result<()>. Every backend validates that the
offset equals the current end of file before writing (mmap and io_uring
fstat the fd, object stores validate server-side via
x-amz-write-offset-bytes); on mismatch nothing is written and the append
fails with AppendOffsetConflict. Retrying an already-landed append
therefore conflicts instead of appending twice, and recovery is
re-deriving the offset from len().

BlobFile no longer tracks the object length locally; the store's own
offset check is the compare-and-swap.

* Review remarks

* Validate file length in S3 append response

* Fix linting, we don't mind a large enum variant on index builder

* universal_io: conformance-test stale-handle append conflict recovery

Promote the two-handle conflict scenario from the in-memory BlobFile
test into the backend-generic conformance battery: a second writeable
handle grows the file, the stale handle's append conflicts cleanly (the
offset check runs against the file, not the handle's view), and the
contract's documented recovery — reopen, re-check the length, append at
the real end — lands the data exactly once. Now exercised over mmap,
io_uring, and the object-store stack instead of only the in-memory
emulation.

* io_bridge_object_store: stub-server tests for append response handling

The native append's HTTP state machine was only exercised by the gated
live-store integration test (S3_APPEND_INTEGRATION_TEST=1), so none of
its branches ran in CI. Cover them hermetically against a minimal local
HTTP stub — one connection per canned response, no new dependencies:

- the signed write-offset PUT, and the new-size validation on success
  (matching, mismatching, unparseable, and absent size headers — the
  absent case at offset zero and past it);
- conflict mapping for 400 InvalidWriteOffset, 412, and 404 under a
  nonzero offset, with 404 at offset zero staying NotFound, and a 400
  without the conflict code staying a plain error;
- 429/5xx retries re-sending the same offset, giving up after
  MAX_ATTEMPTS, and the lost-acknowledgement reconciliation via HEAD
  (accepted when the object ends at offset + len, rejected otherwise);
- the status + body excerpt on unexpected failures.

* simple_disk_cache: statically assert the cache stays read-only

Disallowing appends through the disk cache made them a compile-time
error by removing the impls; pin that with assert_not_impl_any so the
UniversalAppend/UniversalFlush/UniversalWrite impls cannot quietly
return. Runtime rejection of writeable opens stays covered per backend
variant.
2026-07-15 13:55:08 +02:00
Arnaud Gourlay
0d2f5c7e85 Miscellaneous cleanups (#9849) 2026-07-15 13:44:51 +02:00
Luis Cossío
cb256a801d [DiskCache] Dedup overlapping remote requests (#9838)
* [AI] piggyback on in-flight requests

* cleanup tests

* Insert in-flight fetch through Slab's VacantEntry (#9840)

Replace the peek-key-then-insert pattern with slab's vacant_entry() API:
inserting through the reserved entry guarantees the fetch lands on the
key the remote read was tagged with, instead of relying on nothing
touching the slab between the peek and the insert. A failed remote
schedule still leaves no trace, as VacantEntry allocates nothing until
insert.

get_or_init_remote_pipeline now takes the field instead of &mut self so
the VacantEntry can hold a disjoint borrow of in_flight across the
schedule call.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Andrey Vasnetsov <andrey@vasnetsov.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:42:50 -04:00
Luis Cossío
6ac3c828d4 exhaustive match on async-like backends (#9837) 2026-07-14 13:08:13 -04:00
Andrey Vasnetsov
c3ff8ee44b Add optional block-index sidecar to accelerate on-disk binary searches (#9810)
* Add optional block-index sidecar to accelerate on-disk binary searches

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

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

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

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

* Validate block-index sidecar snapshot recovery and preopen coverage

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 15:34:16 +02:00
Andrey Vasnetsov
43e3d6ea8d [UIO] Request-specific load profile for read-only opens (#9797)
* Introduce request-specific LoadProfile with per-component placement

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

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

* Thread LoadProfile through the read-only segment open

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

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

* Open ReadOnlyEdgeShard under a request-derived load profile

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

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

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

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

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

* Add LoadProfile::merge for composite queries

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

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

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

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

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

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

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

* Defer vector index open entirely under a cold load profile

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Serialize the deferred graph load via once_cell's get_or_try_init

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:03:14 +02:00
Andrey Vasnetsov
1a61d0b09c Fix live-reload staleness of in-place-mutated files on caching backends (#9812)
* Mutate same-operation slots in place in append-only mode

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

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

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

* Stamp payload storage version in write_point_parts

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

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

* Resync ReadOnlyRoaringFlags from disk on live-reload

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

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

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

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

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

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

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

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

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

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

Same staleness as the immutable tracker: the deleted file is a
fixed-size bitmap mutated in place, so reopen() — append-only-growth on
caching backends — served the pre-deletion state forever.

live_reload now takes the fs, opens a fresh StoredBitSlice, and swaps
it into deleted_file, so the per-point get_bit lookups read fresh state
from then on too. The deleted_full take/diff/set baseline logic is
unchanged. The enum's DiskResident arm passes the fs through.

The regression test now covers both trackers over DiskCacheFs; the disk
leg was verified to fail under the old reopen-based behavior. The disk
tracker's versions file staleness is a separate, still-open case.

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

* Introduce header-stateless ReadOnlyTracker for gridstore live-reload

The gridstore tracker file is preallocated and mutated in place (header
rewrites, slot updates), which the reader's held handle never picked up
on caching backends: Tracker::live_reload read the header through the
stale handle — before reopen(), and reopen() would not have refreshed
cached blocks anyway — concluded "no new pointers", and never reloaded
pages. New points' payloads read as empty on shard followers.

Replace the reader's tracker with a dedicated ReadOnlyTracker that holds
nothing but the storage handle: reads don't need header state (slot
addressing is positional, unwritten slots read as None in the
zero-initialized file) and readers have no pending-updates buffer. Its
live_reload opens a fresh handle and swaps it in. Tracker::live_reload
is deleted.

A new TrackerRead trait (max_point_offset/get/iter) is implemented by
both the writable Tracker and ReadOnlyTracker, and GridstoreView is
generic over it, so writer and reader share the read logic.

max_point_offset reads the stored header count as plain data through the
current handle (fresh as of the last reload) rather than deriving it
from slot capacity: sparse vector storage builds total_vector_count from
it, and the capacity of the preallocated file (1MB -> 65536 slots) would
inflate every follower segment's sparse count. The method is now
fallible; consumers propagate the error.

GridstoreReader::live_reload refreshes tracker and pages unconditionally
— the tracker carries no reliable cheap change signal. Making this
incremental again is a deferred follow-up.

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

* Re-open last held chunk on chunked-vectors live-reload

Chunk files are preallocated to full size, so appended vectors are
in-place writes within the existing file length. On caching backends a
held handle keeps serving blocks fetched earlier — and a block fetched
near the old tail extends into then-unwritten space — so vectors
appended into that block read back as stale bytes after a reload.

Mirror Pages::live_reload: on a len change (the status file is read
through a fresh open every reload, so it stays a reliable gate), drop
and re-open the last held chunk — the only one that can have gained
vectors — alongside adopting newly created chunk files. Earlier,
fully-filled chunks keep their handles; their contents cannot change.

Covers dense, multi-dense, and quantized-chunked storages, which all
delegate to ChunkedVectorsRead::live_reload. Avoiding the whole-chunk
refetch per reload is a deferred follow-up, together with the analogous
gridstore pages/tracker case.

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

* Fix lint: fs_err::create_dir_all in tests, drop unnecessary cast

CI clippy runs with --all-targets and disallows std::fs methods in
favor of fs_err; the new live-reload regression tests used
std::fs::create_dir_all directly.

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

* Tombstone-only deletes on non-appendable segments in append-only mode

The tombstone-only delete path existed but was gated on
is_append_only(), which requires the segment to be appendable. Deletes
landing on non-appendable segments — notably the CoW update deleting the
point's old copy — still went through delete_point_internal, which
clears the payload row in place before the id-tracker drop.

Clearing the payload destroys committed state of an offset that stays
visible to live-reload followers until the drop is flushed: a follower
refreshing in that window resolves the point through its (unchanged)
id-tracker view and reads an empty payload — observed as a one-refresh
{} payload flicker on a point update. Writer-side flush ordering cannot
close the window, since the follower samples the id tracker and the
payload storage at different instants; the versions commit protocol
protects inserts exactly because the marker is read first, and deletes
have no marker. This is the same failure class for which vector-deletion
propagation was disabled (see the comment in delete_point_internal).

Gate deletes on the new is_append_only_delete() — append_only_mutations
alone, no appendability requirement: tombstoning needs nothing from the
segment but the id tracker. Normal deployments keep eager payload
clearing and prompt space reuse; clone-based mutations keep requiring
appendability via is_append_only().

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

* fix: chunked vectors reload

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Daniel Boros <dancixx@gmail.com>
2026-07-13 19:43:38 +02:00
Andrey Vasnetsov
3a9bff3e5b [UIO] implement ReadOnlyQuantizedVectors::preopen (#9781)
* [UIO] implement ReadOnlyQuantizedVectors::preopen

Schedule background prefetch of the quantization config, per-method
metadata, quantized data and multivector offsets, wired into the
segment's first_preopen for every dense vector with quantization
configured.

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

* additional stage for quantized config preopen

* better quantized preopening

* Partially populate the first quantized vector in preopen

The load reads the first vector off quantized.data (and off the first
chunk in the chunked layout) to validate the stored vector size — on a
cold open that was a round-trip. Use Populate::Partial (#9769) to
prefetch exactly that prefix; the exact size comes from the quantized
config the preopen already read.

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

* Restore the layout-probing quantized preopen

Recovers the pre-rewrite design: preopen never reads the quantization
config — it probes both layout candidates against the listing snapshot
(a segment only contains the layout its real config selects), derives
warmth from the segment-side quantization config (placement resolution
incl. cached and low-memory), iterates storage types exhaustively for
layout coverage, and schedules the config for open to parse once off
the parked handle — no threading, no second preopen stage.

Keeps the first-vector partial populate, with the size now derived
from the segment config via construct_vector_parameters, and keeps
VectorStorageType::is_pinned. Restores the full preopen test matrix
and OneshotFile::preopen.

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

* review fixes

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-07-13 18:55:06 +02:00
Andrey Vasnetsov
5593bc5564 Add optional last-modified timestamp to ListedFile and CachedFs FileInfo (#9803)
* Add optional last-modified timestamp to ListedFile and CachedFs FileInfo

Filled where the listing backend exposes one: local filesystems
(entry metadata) and object stores (ObjectMeta::last_modified). The
uio-grpc backend reports None since the RPC does not carry mtimes.

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

* Carry last-modified over the StorageRead ListFiles RPC

Extend ListFilesEntry with an optional google.protobuf.Timestamp, fill
it on the server from the listing metadata, and convert it back to
SystemTime in the uio-grpc client.

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

* Update lib/common/io_bridge_object_store/src/source.rs

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Fix missing SystemTime import in io_bridge_object_store

The CodeRabbit-suggested last_modified mapping used SystemTime::from
without importing std::time::SystemTime, breaking compile and CI.

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

* Retrigger CI after flaky integration-tests-consensus timeout

The compile fix is in; the prior run failed on an unrelated 30s timeout
in test_collection_recovery, not on PR changes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 09:18:14 +02:00
Andrey Vasnetsov
ef02126ba0 Preserve not-found classification on read-only reload/open paths (#9777)
Follow-up to #9763: an audit of the component live-reload and read-only
open paths found four places where a not-found error lost its structured
classification before reaching OperationError::FileNotFound:

- MmapFile::reopen opened the file with a bare `?`, producing an
  unstructured Io(NotFound) — and this is the central call on the local
  follower's live-reload path, so no mid-reload segment removal would
  ever have classified on the mmap backend. Wrapped with
  extract_not_found.
- BlockCacheFs::open and CachedSlice::reopen had the same bare-`?`
  pattern over CachedSlice::open's io::Result. Both wrapped.
- ReadOnlySegment::open_via hand-rolled a service_error for a missing
  version file. The version file is written last, so its absence is
  exactly the vanished-mid-open signal; now FileNotFound { path }.
- ReadOnlyRoaringFlags::read_status_len masked NotFound internally, so
  live_reload silently kept a stale len if the status file vanished
  mid-reload. The raw reader now propagates; open masks it explicitly
  with ok_not_found() (works on OperationResult since #9763), and
  live_reload requires the file.

Everything else audited came back correct: lazily-created files
(mutable id tracker, mutable-index gridstore) keep masking absence,
optional-component probes at open keep ok_not_found, and the
object-store bridge already maps missing objects to structured
NotFound.

New test vanished_segment_classifies_not_found pins both legs
end-to-end: opening a missing segment directory and live-reloading a
segment whose directory was removed both classify via is_not_found().

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 09:23:56 +02:00
Luis Cossío
1299aa6dae [UIO] Add and use Populate::Partial variant (#9769)
* add (and handle) `Populate::Partial`

* use partial populate

* no OOB error
2026-07-10 23:20:52 +02:00
Andrey Vasnetsov
6c5221ff4c [UIO] implement VectorStorageReadEnum::preopen (#9780)
* [UIO] implement VectorStorageReadEnum::preopen

Schedule background prefetch of every file the read-only vector
storages open, wired into the segment's first_preopen between the id
tracker and the payload indexes.

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

* polish PR

- fully populate deleted bitslices
- don't use `.ok_not_found()`
- section comments

* don't overwrite `populate` in `CachedFs`

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-07-10 20:14:38 +02:00
Luis Cossío
197ee72c62 [UIO] implement ReadOnlyFullTextIndex::preopen (#9765)
* prepare universalhashmap for partial populate

* [AI] implement `ReadOnlyFullTextIndex::preopen`
2026-07-09 18:58:22 -04:00
Daniel Boros
78688733cb feat: support S3 Express One Zone buckets in the S3 bridge (#9757) 2026-07-09 15:02:28 +02:00
Andrey Vasnetsov
b2140e051d perf(universal-io): serve known file length from CachedFs snapshot on plain open (#9741)
`CachedFs::cache_file_info` already snapshots every file's size from the
`list_files` result, but that size was only threaded into opens on the
prefetch path (`schedule_prefetch` -> `with_known_len`). Files opened
directly through the fallback `open` — e.g. segments the loader opens
lazily rather than prefetching — forwarded the caller's `OpenExtra`
unchanged, so `known_len` was `None` and the backend later issued a
remote `len`/HEAD to size the file.

Thread the snapshot size into the fallback open too: when a snapshot
exists and lists the path, apply `with_known_len(info.size)`. This lets
`DiskCacheFs::open` go straight to `State::Ready`, eliminating a remote
metadata round-trip per lazily-opened file on blob backends (S3). No-op
when no snapshot was taken or for backends where `with_known_len` is
meaningless.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:36:13 +02:00
Andrey Vasnetsov
02cacfe192 debug logging of the s3 connection (#9597)
* debug logging of the s3 connection

* refactor(io_bridge): make S3 latency logs opt-in and low-overhead

Move the blob-backend latency traces onto a dedicated `io_bridge::latency`
log target at `trace` level, so they are silent by default and can be
toggled as one group at runtime without a rebuild (e.g.
`RUST_LOG=io_bridge::latency=trace`). Guard the timing `Instant::now()`
behind `log_enabled!` so there is no overhead when the target is disabled.

Also add `list_files` timing, and switch the shard_query CLI logger to
millisecond timestamp resolution.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:16:16 +02:00
xzfc
5af493d90e Refactor: add DeletedBitVec (#9738) 2026-07-08 16:04:45 +00:00
Luis Cossío
fc44cc28fa Pass file size to DiskCacheFs::open (#9730) 2026-07-08 12:01:19 +02:00
Luis Cossío
7db2ba3c2e [AI + manual] cleanup (#9727)
- get rid of `from_file` abstractions
- renames:
  - `CachedFs` to be the implementation
  - `CachedReadFs` to be the trait
2026-07-07 22:15:34 +02:00
Andrey Vasnetsov
e1d901647e CachedReadFs: prefetch-backed read-only segment opens (#9712)
* Add CachedReadFs: prefetch-backed read-only universal-io filesystem

Snapshots the file listing at construction and serves opens from
explicitly prefetched handles (take-once, shared across clones via
Arc<Mutex>). A non-prefetched open falls back to a direct open on the
inner filesystem, panicking in debug builds and warning in release.

CachedFile is a transparent wrapper needed to satisfy the bidirectional
UniversalReadFs<File = Self> pinning, following the ReadOnly pattern.

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

* Serve read-only segment opens from CachedReadFs prefetch pool

ReadOnlySegment::open builds a per-segment CachedReadFs: the files known
in advance (version.info, segment.json) are scheduled before the listing
snapshot is taken so their fetch overlaps the listing round-trip, then
every remaining listed file is scheduled, running all fetches in parallel
instead of serializing them inside component opens. Existence checks and
format-detection probes are answered from the snapshot without touching
the inner filesystem.

Stored handles are taken out of the pool via the new
CachedReadFs::take_file, which returns the raw inner file — component
types stay over plain S, and CachedFile exists only transiently inside
open-read-discard helpers (read_json_via etc.) through the trait impl.
The read-only open path takes &CachedReadFs<S::Fs> concretely; storing
wrappers gained from-file constructors (StoredBitSlice::from_file,
UniversalHashMap::from_file, ReadOnly::from_file, gridstore
Tracker::open_cached / Pages::open_cached, read_chunks_cached).

Snapshot-less, CachedReadFs is a passthrough to the inner filesystem —
used by reload paths, writable build paths that reuse the on-disk index
opens, and tests, all of which keep their previous behavior. Components
that retain a filesystem for later reloads store the raw inner backend
(CachedReadFs::inner), never the stale snapshot.

Also: local_list_files now recurses into subdirectories, matching the
flat key-prefix semantics of object-store listings; the immutable id
tracker probes its defining file via exists (free on the snapshot)
instead of a probe-open that would consume the take-once handle.

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

* Group imports per nightly rustfmt

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

* Fix sparse search bench for open_ro over CachedReadFs

CI clippy runs --all-targets; the bench target was missed by the
--tests sweep.

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

* Match listing prefixes by path component, not by string

The recursive local_list_files compared whole path strings; on Windows a
joined prefix mixes `/` and `\` (`shard\index/chunk_`) while walked
entry paths use `\` throughout, so nothing ever matched (broke
list_files_returns_paths_relative_to_shard_dir on Windows CI).

Match the entry name at the prefix's final position against the
prefix's final component instead, then walk matched directories
exhaustively — same semantics, separator-agnostic. Apply the same
component-based matching to the CachedReadFs snapshot filter.

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

* do not cache everything

* fmt

* dont unwrap files_info

* fix clippy

* relax debug assertion for now

* [AI] refactor into extension trait, relax Fs<->File requirement (#9725)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Luis Cossío <luis.cossio@outlook.com>
2026-07-07 19:27:23 +02:00
Arnaud Gourlay
f4e863321c Remove dead code (#9719) 2026-07-07 16:25:57 +02:00
Arnaud Gourlay
cad112bb1c Fix Clippy 1.97 (#9716)
* Remove from_iter_instead_of_collect from workspace lints

The lint was removed from clippy (beta) and now triggers
renamed_and_removed_lints warnings in every crate.

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

* Fix clippy::chunks_exact_to_as_chunks

Replace chunks_exact with a constant chunk size by as_chunks.

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

* Fix clippy::needless_late_init

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

* Fix clippy::useless_borrows_in_formatting

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

* Fix clippy::uninlined_format_args

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

* Fix clippy::for_kv_map

Iterate map values directly instead of discarding keys.

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

* Allow clippy::result_large_err on QueueProxyShard::new_from_version

The Err variant intentionally hands the LocalShard back to the caller.
Same pattern as the existing allow on ForwardProxyShard::new.

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

* Allow clippy::result_unit_err on wait_for_consensus_commit

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:53:15 +02:00
Andrey Vasnetsov
8a5d9ac1fc Add disk-resident id tracker (#9657)
* Add disk-resident id tracker

Introduce a disk-resident id tracker family that keeps the point-id mapping
on disk instead of loading it into RAM, so resident memory no longer scales
with total point count. This removes the last component of a segment that
forces a full RAM load, enabling object-storage / edge followers and cutting
RAM for regular deployments that opt in.

New on-disk mapping format (`disk_id_tracker/on_disk_format.rs`): random-access
`id_tracker.i2e` (internal->external) + `id_tracker.e2i` (external->internal as
sorted num/uuid runs with a resident sparse block index). `id_tracker.versions`
and `id_tracker.deleted` are reused unchanged.

Trackers (sharing a lazy `DiskMappingReader` core and a `DiskMappingsSource`
trait):
- `DiskIdTracker` — writable, deletion-only; a new `IdTrackerEnum` variant used
  by regular segments, keeping deleted+versions resident and the mapping on disk.
- `ReadOnlyDiskIdTracker` — read-only live-reload mirror for followers; per-point
  `get_bit` deletion checks on read-by-id, full deleted set materialized lazily.

Selection: created when the `serverless_compatible` feature flag is set (in
`segment_builder`); loaded by attempting each format in `ReadOnlyIdTrackerEnum::
detect_and_load` (no per-file `exists` round-trips) and by file-presence
detection unified in `IdTrackerFormat`.

`PointMappingsRefEnum` is generic over the read backend so the disk variant is a
concrete `DiskMappingsRef` (no trait object). The `DiskMappingsSource` read
surface returns `OperationResult`; errors are only swallowed at the infallible
`IdTrackerRead` boundary. `ImmutableIdTracker` is unchanged.

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

* style: apply rustfmt

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

* fix clippy: collapse nested if in DiskIdTracker::drop

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

* Align on-disk id tracker headers and sections to 16 bytes

Pad the i2e header to 32 bytes and the e2i header to 48 bytes, and
zero-pad between e2i sections so every section starts on a 16-byte
boundary. This keeps the files mmap+transmute-friendly: the u128 arrays
(i2e slots, e2i uuid sparse index) need 16-byte alignment in Rust.

Add on_disk_sections_are_aligned test pinning the invariant and the
store/parse padding agreement via exact file-length checks.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:46:45 +02:00
Daniel Boros
94d1344379 fix: resolve AWS S3 default credentials from env (IRSA); object_store 0.14 (#9692)
* chore: update object_store to 0.14.0

* fix: resolve AWS S3 default credentials from the environment

The default path built the client with AmazonS3Builder::new(), which reads nothing, so object_store's resolver always fell through to EC2 IMDS (the node role) — AWS_WEB_IDENTITY_TOKEN_FILE / AWS_ROLE_ARN (EKS IRSA), ECS and EKS Pod Identity were silently ignored.

Seed the builder with from_env() for the default chain so web identity (IRSA), container credentials and AWS_* keys are honoured; explicit bucket/region/endpoint from config still override anything from_env picks up. Static credentials keep using new() with the provided keys.
2026-07-06 15:01:43 +02:00
Andrey Vasnetsov
fa3d756914 [UIO] Split UniversalReadFileOps into read and write traits (#9682)
UniversalReadFileOps mixed read-side operations (from_context,
list_files, exists) with mutating ones (create, create_dir, remove,
remove_dir, atomic_save). Move the mutating operations to a new
UniversalWriteFileOps subtrait, mirroring UniversalRead/UniversalWrite.

- UniversalWrite now requires Fs: UniversalWriteFileOps, so generic
  consumers (gridstore) keep reaching write ops through S::Fs.
- ReadOnlyFs drops its runtime-erroring write stubs: read-only is now
  a compile-time property.
- DiskCacheFs implements the write side only when the remote fs does.
- BlobFs and the async AsyncRead trait are read-only: the async write
  methods are removed from AsyncRead and its backends (object store,
  uio-grpc) along with the tests that exercised them.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:15:33 +02:00
Luis Cossío
ab3c8dc2a1 [DiskCache] refactor State into single enum (#9651)
* [AI + manual] refactor State into single enum

* use `let .. else`

* use explicit matches
2026-07-03 19:11:34 -04:00
Luis Cossío
80c9454141 [UIO] Include file size in UniversalReadFileOps::list_files (#9675)
* [AI + manual] Include file size in `UniversalReadFileOps::list_files`

* Use dedicated `ListedFile` struct instead of `(PathBuf, u64)` pair

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

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 00:19:17 +02:00
xzfc
8f55757b56 Tidy up Debug impls (#9653) 2026-07-02 09:50:07 +02:00
qdrant-cloud-bot
7d9aef4c7c feat: add serverless_compatible feature flag (#9655)
* feat: add serverless feature flag

Introduce a composite `serverless` feature flag that automatically enables
`write_segment_manifest` and `append_only_mutations` during initialization.

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

* refactor: rename serverless flag to serverless_compatible

Rename the composite feature flag for clarity and consistency with
serverless deployment terminology.

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

* fix: satisfy clippy field_reassign_with_default in flag tests

Use struct update syntax instead of mutating Default::default() fields.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 08:34:36 +02:00
Andrey Vasnetsov
5dec49802e Split io_bridge core out of io_bridge_object_store; add uio backend (#9634)
* Split io_bridge core out of io_bridge_object_store; add uio backend

Separate the backend-agnostic sync<->async bridge from the object-store
backends so a second backend can plug in, and add a uio-client backend.

- io_bridge (new): the bridge core (AsyncRead, BridgeRuntime, BlobFile,
  BlobFs, pipeline), with no object_store/tonic deps. BridgeRuntime::block_on
  is now pub so sibling crates can drive it.
- io_bridge_object_store (slimmed): keeps BlobBackend + S3/GCS/Azure. The
  blanket `AsyncRead for Arc<S>` would now break the orphan rule, so it moves
  onto a local newtype `ObjectStoreSource<S>`. Re-exports the bridge core.
- io_bridge_uio (new): UioSource implements AsyncRead over uio_client::Client,
  pinned to one (collection, shard_id) replica. Streams gRPC chunks into the
  aligned buffer; read_from = FileLength + tail stream; writes are Unsupported
  (read-only service). The client is built lazily on first use inside the
  bridge runtime, because tonic's connect_lazy captures the reactor at
  construction and would panic in the runtime-free AsyncRead::open.
- uio-client: add sync connect_lazy, read_bytes_stream_raw (BoxStream<Bytes>),
  NOT_FOUND -> UniversalIoError::NotFound mapping, and an api-key auth
  interceptor (UioConfig::api_key) injecting the `api-key` gRPC header.
- common: add UniversalKind::Uio.
- Consumers (segment, edge-shard-query): Arc<AmazonS3> -> ObjectStoreSource<_>.

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

* Rename uio crates/types to uio-grpc

Clarify that this "uio" backend is the gRPC transport variant (the "uio"
shorthand for Universal IO is used elsewhere in the tree and is left alone).

- crate uio-client -> uio-grpc-client (dir lib/uio-client -> lib/uio-grpc-client)
- crate io_bridge_uio -> io_bridge_uio_grpc
- UniversalKind::Uio -> UniversalKind::UioGrpc
- UioConfig/UioSource/UioFile/UioFs -> UioGrpc{Config,Source,File,Fs}
- doc/import references updated accordingly

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:51:15 +02:00
Andrey Vasnetsov
8c8a72d120 Remove read_multi_iter to fix macOS linker symbol overflow (#9643)
* remove unused iter_offsets

* Replace MultivectorOffsetsStorage::iter_offsets with callback-based for_each_offset

First step of removing the iterator-returning read API (whose deep,
composable generic types blow up mangled symbol size). Convert the
offsets read from an iterator to a callback the caller pushes into:

- trait method iter_offsets -> for_each_offset(ids, FnMut(usize, MultivectorOffset))
  returning common::universal_io::Result<()>
- Mmap impl now uses the callback read_batch (drops one read_iter use)
- Ram / Chunked impls push into the callback; Chunked still goes through
  iter_vectors for now (converted in a later step)
- the single caller (for_each_in_multi_batch) passes a closure

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

* Implement read_batch directly on ReadPipeline, not via read_iter

read_batch now drives the pipeline itself (refill-then-wait loop, like
read_multi_iter) and invokes the callback per result, instead of
consuming the iterator returned by read_iter. A step toward removing the
iterator-returning read API.

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

* Remove the ReadMulti RPC from the StorageRead gRPC service

ReadMulti was the only real consumer of UniversalRead::read_multi (which
itself relies on read_multi_iter). Removing the RPC end-to-end clears the
path to dropping that read API. StorageReadService keeps all its other
RPCs (ListFiles, FileExists, FileLength, ReadBytes, ReadBytesStream,
ReadWhole, ReadBatch).

- proto: drop `rpc ReadMulti` + ReadMulti{Entry,Request,Response}
- regenerated lib/api + uio-client generated code; drop ReadMulti
  validation rules in lib/api/build.rs
- tonic: delete the read_multi handler + its 2 tests
- uio-client: delete Client::read_multi, the mock-server impl, and 2 tests

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

* Remove UniversalRead::read_multi

Its only real consumer was the StorageRead ReadMulti gRPC handler (removed
in the previous commit); the two wrapper forwarders had no callers. Drop
the trait method and both forwarders (typed/read_only), and remove the
io_uring test that only existed to compare read_multi vs read_multi_iter
(read_multi_iter stays covered by the other tests). Another step toward
removing the iterator-returning read API.

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

* Drive ReadPipeline directly in gridstore read_from_pages

Replace the read_multi_iter call in Pages::read_from_pages with a direct
pipeline loop (refill-then-drain), scheduling each multi-page read on its
own page file. Behavior unchanged; another step toward removing the
iterator-returning read_multi_iter.

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

* Drive ReadPipeline directly in gridstore read_batch_from_pages

Replace the second read_multi_iter call (in Pages::read_batch_from_pages)
with a direct pipeline loop, scheduling each (ReadMeta, page, range) on its
own page file and propagating errors via GridstoreError. Single/multi-page
buffering and out-of-order reassembly are unchanged. No more read_multi_iter
in gridstore.

Measured overhead on warm mmap (both paths are zero-copy borrows): ~0.3 ns
per read of fixed control cost, flat across read sizes — well under 0.1% of
a real payload read.

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

* Drive ReadPipeline directly in on-disk postings with_posting_views

Replace read_iter in OnDiskPostings::with_posting_views with a direct
pipeline loop. wait_bytemuck yields a file-borrowed Cow, so postings are
still stored zero-copy in raw_postings (a read_batch swap would have forced
an owned copy of every posting list per query on the mmap backend).

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

* Read on-disk posting headers via read_batch, drop the HeadersBatch iterator

headers_iter now reads headers with the callback read_batch API: each header
is parsed (copied) out of the read bytes, so nothing borrows the file past the
read — no pipeline needed. Since the read is now eager, HeadersBatch holds the
collected Vec<HeaderResult> directly instead of a Box<dyn Iterator>, dropping
the boxing, the dynamic dispatch, and the struct's lifetime parameter.
with_posting_views takes the Vec and still pipelines the posting reads.

Removes the last read_iter use in this file.

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

* Use read_batch in simple_disk_cache populate_from

populate_from reads one byte per block only to fault blocks into the local
cache, discarding the bytes — a no-op-callback read_batch fits exactly. Drives
the same DiskCachePipeline as before; one less read_iter caller.

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

* Implement read_iter directly on ReadPipeline, not via read_multi_iter

read_iter now drives the pipeline itself (refill-then-wait loop, mirroring
read_bytes_iter) instead of mapping its ranges onto self and calling
read_multi_iter. Same signature and iterator contract, so all callers are
unchanged. Leaves iter_vectors as read_multi_iter's only remaining caller.

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

* Revert "Drive ReadPipeline directly in on-disk postings with_posting_views"

This reverts commit c02c41f17b.

* Add callback-based for_each_vector next to iter_vectors

for_each_vector drives the ReadPipeline directly across chunk files and
invokes a fallible callback per flattened multi-vector, returning
OperationResult, instead of returning an iterator built on read_multi_iter.
Callers will migrate onto it so iter_vectors (read_multi_iter's last caller)
can be removed.

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

* Make dense for_each_in_batch / for_each_in_dense_batch fallible

Thread OperationResult up the dense batch-read path so io_uring read
errors propagate instead of being .expect()ed deep inside the storage.
The for_each_in_dense_batch scorer path (custom/metric query scorers)
now carries the Result to the infallible score() boundary where it is
.expect()ed; read_vectors keeps its () signature and .expect()s locally.

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

* Convert dense read_vectors to for_each_vector

The read-only and appendable dense storage read_vectors impls drove
ChunkedVectors::iter_vectors directly; switch them to the callback-based
for_each_vector and .expect() the result at the (infallible) read_vectors
boundary. Removes the last dense-path iter_vectors callers.

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

* Convert quantized for_each_offset to for_each_vector + OperationResult

The two chunked MultivectorOffsetsStorage impls drove iter_vectors to read
the offset table; switch them to the callback-based for_each_vector.
for_each_vector returns OperationResult, so upgrade the for_each_offset
trait (and all four impls) from universal_io::Result to OperationResult
(the universal_io -> Operation direction, via ?). No error downgrade.

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

* Convert EncodedStorage/EncodedVectors iter_batch to callback for_each_batch

The two chunked-mmap EncodedStorage impls drove ChunkedVectors::iter_vectors
to back iter_batch. Replace the iterator-returning iter_batch on both the
EncodedStorage and EncodedVectors traits (quantization crate) with a callback
for_each_batch(FnMut(usize, &[u8])), and switch the chunked impls to
for_each_vector. The callback is infallible: the chunked impls .expect() the
read internally, matching iter_vectors' prior panic-on-read-error behavior,
so no OperationError is downgraded. Scorers and the multivector readers adopt
the callback; the accumulating multivector path owns (to_vec) only when it
must buffer across components.

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

* Convert multivector read paths to for_each_vector

The read_only multivector free fn chained two iter_vectors (offsets feeding
vectors) - the recursive iterator nesting behind the worst symbol bloat.
Replace it with a callback for_each_vector that resolves the per-point
offsets into a Vec first, then drives ChunkedVectors::for_each_vector over
the flattened vectors. Migrate both multivector storages' read_vectors and
for_each_in_batch_multi accordingly, .expect()ing at their infallible
boundaries.

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

* Remove read_multi_iter and ChunkedVectors::iter_vectors

With every caller migrated to callback-based for_each_vector/for_each_batch,
delete the last iterator-returning multi-read APIs: ChunkedVectors::iter_vectors
(segment) and the read_multi_iter trait method plus its mmap override, the
TypedStorage/ReadOnly wrapper forwarders, and the two io_uring unit tests.

These deeply-nested monomorphized iterator types (read_multi_iter feeding
read_multi_iter) produced >1 MiB mangled drop_in_place symbols that overflowed
the macOS ld symbol-name limit; the callback rewrite eliminates them.

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

* Pass owned Cow through for_each_vector/for_each_batch to avoid a copy

The callback-based readers handed the callback a borrowed `&[u8]`/`&[T]`,
forcing the quantized multivector batch read to `to_vec()` each sub-vector
into its per-point buffer. But io_uring-like backends already return a freshly
owned buffer per read (`ACow::Owned`), so that was a redundant second copy.

Change `ChunkedVectorsRead::for_each_vector` and the `EncodedStorage` /
`EncodedVectors` `for_each_batch` callbacks to receive `Cow<[..]>` by value.
The buffering path now `into_owned()`s it — a move when the backend returned
owned (the case this path targets), a copy only for a borrowed Cow (mmap),
which never reaches this path. Immediate-use callers (scorers,
score_point_max_similarity, the dense/multivector readers) just deref the Cow;
the dense readers drop their now-redundant `Cow::Borrowed` wraps.

Also clarifies the multivector reader: `SubVectorOwner`/`owners`/
`sub_vector_offsets` naming, docs, and a corrected comment noting the per-point
buffer is what makes regrouping order-independent under out-of-order completion.

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

* Drop the removed ReadMulti JWT access test; fix clippy unwrap_or_default

The ReadMulti StorageRead RPC was removed earlier in this branch, so the
consensus JWT-access test (and its registry entry) for it must go too. Also
switch the multivector buffer's `or_insert_with(SmallVec::new)` to
`or_default()` per clippy::unwrap_or_default.

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

* Add MmapFile::read_batch

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: xzfc <xzfcpw@gmail.com>
2026-07-01 14:03:32 +02:00
Luis Cossío
56e6926912 simplify DiskCache::read_whole (#9642)
Rely on the existing initialization logic when prefilling for
`read_whole`
2026-07-01 00:01:30 +02:00
dependabot[bot]
7341f2a6f9 build(deps): bump quick_cache from 0.6.23 to 0.7.0 (#9622)
* build(deps): bump quick_cache from 0.6.23 to 0.7.0

Bumps [quick_cache](https://github.com/arthurprs/quick-cache) from 0.6.23 to 0.7.0.
- [Release notes](https://github.com/arthurprs/quick-cache/releases)
- [Commits](https://github.com/arthurprs/quick-cache/compare/v0.6.23...v0.7.0)

---
updated-dependencies:
- dependency-name: quick_cache
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* fix: adapt disk cache lifecycle to quick_cache 0.7 API

quick_cache 0.7 removed begin_request/end_request in favor of a
RequestState type that implements Default and finalizes evictions on Drop.

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

* fix: use DefaultHashBuilder in trififo quick_cache benchmark

quick_cache 0.7 defaults to DefaultHashBuilder; the benchmark wrapper
type did not specify a custom hasher generic.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 10:29:55 -04:00
Evan Harris
5d58fecb43 fix: bound TopK reservation by client search limit (#9637)
* [AI] fix: bound TopK reservation by client search limit

TopK::new reserved its backing vector directly from `k`, the
client-supplied search limit, as Vec::with_capacity(2 * k). On a large
limit this reserves an oversized buffer that aborts the process on
allocation failure (handle_alloc_error), and the `2 * k` doubling itself
overflows for a limit near usize::MAX. Sparse vector search reaches this
path with no default cap, so an unauthenticated query with a huge limit
can crash the server.

Cap the reservation at LARGEST_REASONABLE_ALLOCATION_SIZE (1_048_576),
matching SearchResultAggregator and FixedLengthPriorityQueue, and use
saturating_mul so the doubling cannot wrap. `k` itself is unchanged, so a
result set larger than the cap still grows the buffer on demand and
legitimate queries behave exactly as before. Add a regression test that
constructs TopK with usize::MAX.

* [AI] fix: saturate TopK push threshold and cover push in the huge-k test

The push threshold compared element count against `self.k * 2`, which
overflows for a client search limit above usize::MAX / 2: a panic in
debug/test builds, and a wrap in release that can trip the median-prune
path early. Use saturating_mul so a huge limit cannot overflow the
threshold, and extend the regression test to push after constructing with
usize::MAX.
2026-06-30 14:58:09 +02:00
Roman Titov
11abd4bed5 Unify Borrowed and Owned read pipeline implementations (#9498) 2026-06-29 20:23:48 +02:00
qdrant-cloud-bot
fea740220c fix: add missing UserData bound on OwnedIoUringPipeline impls (#9596)
The Debug requirement on UserData (#9588) left two impl blocks without
the trait bound, breaking compilation on dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 16:36:47 +02:00
Andrey Vasnetsov
9636944402 feat: require UserData to implement Debug (#9588)
Add a `Debug` supertrait bound to the `UserData` marker trait and propagate
it through the universal-io read pipelines and the read APIs built on them.

This lets pipeline user-data (request ids, point ids, internal read metadata)
be formatted for diagnostics. Wrapper types that carry user data through the
pipelines (`RemoteMeta`, gridstore `ReadMeta`, hashmap `Entry`) gain `Debug`,
and the `U: UserData` bound is threaded through `read_vectors`/`read_payloads`/
`read_values`/`iter_vectors` and their implementors in gridstore and segment.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:53:30 +02:00
Daniel Boros
6749bec717 perf: read whole objects in a single GET instead of HEAD+GET (#9587)
* perf: read whole objects in a single GET instead of HEAD+GET

* feat: read object tail from offset to EOF in a single GET

Generalize the whole-object single-GET read so a tail read from a known
offset also avoids the separate len()/HEAD round-trip. The backend
primitive becomes `read_from(path, from)`, issuing an open-ended
`GetRange::Offset(from)` GET (or a plain GET for from == 0) and reporting
the object's total size from the response; `read_whole` is now a thin
wrapper over it.

The owned blob pipeline's `schedule_whole` no longer HEADs the remote to
size a tail read. An offset at/past EOF is an unsatisfiable range (HTTP
416) rather than an empty body, so the buffer builder disambiguates with
a single len() only on the error path, yielding an empty read when the
tail is genuinely empty. The disk cache's reopen prefiller is made
tolerant of that empty tail so it no longer truncates the local mirror.

Also split the now-hard-to-follow simple_disk_cache `file.rs` into a
`file/` module (type/state, init state machine, reopen, read surface)
and document the FromScratch vs Prefiller init sources.

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

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 13:20:28 +02:00
Roman Titov
99b730fe84 Simplify IoUringRuntime implementation (#9512) 2026-06-26 20:53:46 +02:00
xzfc
ad7a3f7129 Generalize BorrowCow (#9556) 2026-06-24 16:10:42 +00:00