Commit Graph
654 Commits
Author SHA1 Message Date
xzfc 19d4da9988 UIO tracing and visualizer (#10742)
* Add uio_trace module and visualizer

* Handle object store, not just gRPC

* Improve visualizer

* Improve visualizer [2]
2026-09-23 21:56:24 +00:00
Luis Cossío 87dc39bfdc [Diskcache] piggyback remote reads across pipelines (#10690)
* AI: Share reads across different diskcache pipelines

* AI: Unify scheduled reads into single ScheduledRead struct

* AI: Implement follower promotion on fetch abandonment

* AI: self-promote if waiting for an abandoned placeholder

* AI: simplify

* AI: use Weak<Placeholder> instead of manually counting strong refs

* AI: track abandoned waiters

* AI + manual: park with timeout

* AI: fix barrier in test

* fmt with latest nightly

* expect remote pipeline
2026-09-22 19:30:48 -03:00
陈志谦 651ca78d2d Fix small typos in comments across segment and storage crates (#10681)
* Fix small typos in comments across segment and storage crates

- AddLearnerPeer -> AddLearnerNode (the ConfChangeType name used by the
  matched WAL entries)
- 'adding adding' -> 'adding' in the progress_tracker debug assertion
- CanellationToken -> CancellationToken in the snapshots recovery
  comment
- 'any of of' -> 'any of' and 'valuse_set' -> 'value_set' in json_path
  comments
- 'is not not changed' -> 'is not changed' in the mutable null index

* Style: collapse the debug_assert to one line per rustfmt

Nightly rustfmt prefers the single-line form now that the message
fits (follow-up to the typo fix).
2026-09-18 11:16:10 +02:00
xzfc 20229f99ba [combined-storage] Combined storage write (#10669)
* HNSWIndex::build(): add `inline_vectors` arg

Let the caller decide whether to use `inline_vectors` format.

* SegmentBuilder::build: finalize GraphInline vector storage

Instead of old "graph-with-vectors plus regular vector storage",
keep only the graph-with-vectors storage.

* Gate the GraphInline segment build behind a feature flag
2026-09-17 15:31:31 +00:00
Tim Visée cc7a209c76 Persist proxy segment changes across restart, don't stall WAL ack's (#10349)
* segment: move proxy pending change types into segment crate

Move the types describing the changes a proxy segment buffers — point
deletes (`ProxyDeletedPoint`), payload index changes (`ProxyIndexChange`,
`ProxyIndexChanges`) and vector name changes (`IntendedVector`,
`ProxyVectorNameChanges`) — from `shard::proxy_segment` into a new
`segment::pending_changes` module.

Pure move, no behavior change: the proxy segment re-exports them from
their old location. Having them in the segment crate lets both the proxy
segment and the segment load path share them, in preparation for
persisting pending proxy changes to disk and replaying them on restart.

* segment: add PendingChange describing a persisted proxy operation

Add the `PendingChange` enum with one variant per operation type a proxy
segment buffers — point delete, payload index change, vector name
change — each carrying the operation version it was issued with. This
is the shape in which pending proxy changes are persisted to disk.

Derive serde on it and on the buffered change types it embeds, so
entries can be serialized into a log file and read back. `PartialEq` on
those types lets a persisted batch be matched against the in-memory
pending buffer after a flush.

* segment: add PendingChanges component persisting proxy changes to a log

Add `PendingChanges`, the component that manages the operations a proxy
segment buffers for one proxy layer, and persists them to disk so they
no longer only live in memory.

It keeps the same per-type buffers the proxy segment served its reads
from (point deletes, payload index changes, vector name changes), plus a
single registration-ordered buffer of everything not yet persisted.
`flusher()` writes that buffer into an append-only log file inside the
wrapped segment's directory: `pending_changes.log` for the inner most
proxy layer, with the layer number as a suffix for each layer above it.

Appends follow the mutable ID tracker: all new entries are serialized
into one buffer and written with a single call on an append-mode file,
then fsynced, so a crash can only leave a torn entry at the very end.
Loading truncates such an entry — its operations were never durable and
thus never acknowledged in the WAL — but fails hard on a malformed entry
in the middle, which cannot be explained by a torn append.

The component tracks the highest operation version the log covers.
Every registered operation at or below it is either durable in the log
or was a no-op that does not need recovery; a flusher advances it to the
proxy's version even when there is nothing to write. The pending buffer
is deliberately not cleared when the proxy propagates its changes to
the wrapped segment, as that only makes them durable once the wrapped
segment flushes. Replaying an entry twice is a version-gated no-op.

A log file left behind by a previous proxy on the same segment is
adopted by `open()`: new entries are appended after it and its highest
version is taken over, while its entries are not loaded into the
buffers as they are already applied to the segment. `load()` also
reconstructs the buffers, for callers that do want the buffered state.

* segment: replay persisted pending proxy changes onto a segment on load

Add `recover_pending_changes`, to be called when a segment is loaded on
restart, before regular WAL replay. If the segment directory holds
pending changes log files, the proxies that wrote them did not
propagate their buffered state into the segment before the process
stopped. Instead of reconstructing the proxies, replay all logged
operations directly onto the segment: inner most proxy layer first,
each file in append order, through the regular version-gated segment
operations (`apply_change`). Entries the segment already applied are
silently skipped, so a stale file is harmless.

The segment is force-flushed before the files are removed; a crash in
between merely replays the files once more.

* segment: test PendingChanges component

Cover the pending changes component: registering and flushing each
operation type and reconstructing the buffers from the log, log file
naming per proxy layer and gap-tolerant listing, covering the proxy
version without entries, operations registered while a flusher is
captured, flushers of a dropped component, torn-tail truncation versus
mid-file corruption, adoption of an existing log, and replaying logs
onto a real segment: fresh, stale (already applied), multi-layer, and
vector name changes.

* segment: include pending changes logs in segment snapshots

Register the pending changes log files of a segment in its snapshot:
add them to `snapshot_files` next to the segment state and version
files, existence-guarded, and to the segment manifest as unversioned
files. Full, partial and streamed snapshots therefore all carry them.

The recovery side needs no changes: a restored segment is loaded like
any other, which replays and removes the logs.

* shard: back proxy segment pending changes by PendingChanges component

Replace the proxy segment's separate `deleted_points`, `changed_indexes`
and `changed_vector_names` fields with a single `PendingChanges`
component. Reads keep going through the same per-type buffers, now
behind accessors; writes go through the component's `register_*`
methods, which additionally queue every operation for persistence.

Opening the component is fallible, as it adopts a pending changes log
a previous proxy may have left in the wrapped segment's directory, so
`UnsyncedProxySegment::new` now returns a result. Wrapping another proxy
opens the next proxy layer up, writing to its own dedicated log file.

No behavior change yet: the proxy still flushes and reports persistence
exactly as before, nothing is written to the log.

* shard: persist proxy pending changes on flush, stop holding back WAL ack

Hook the pending changes component into the proxy segment's flush: the
proxy flusher first persists the buffered operations into the pending
changes log, then passes the flush along to the wrapped segment. The
proxy's `persistent_version` now covers what the log durably holds on
top of what the wrapped segment persisted itself.

That is what lifts the WAL cap proxies imposed so far. `flush_all`
compares each segment's version against its persistent version; a
proxy used to report only the wrapped segment's persisted version while
its own version climbed with every buffered operation, so the WAL could
never be acknowledged past the point the proxy was created at, and a
restart replayed all of it — potentially very expensive operations,
such as an update by filter, all over again. With the buffered state
durable on disk the generic rule acknowledges the full version, and a
restart recovers it from the log instead.

Dropping a proxy's data drops the component first, which waits for any
in-flight pending changes flusher so it cannot append to the segment
directory while that is being deleted.

Update the proxy flush test to the new semantics, add a segment holder
test asserting the acknowledged version advances past a proxied delete,
and update the ack pin rationale in `finish_optimization`: the pin is
still needed after the proxies leave the holder, it just snapshots a
persistent version that now includes the log.

* shard: propagate proxy changes when unwrapping on optimizer cancel

When an optimization is cancelled or fails, `unwrap_proxy` puts the
wrapped segments back into the segment holder. Propagate the changes
buffered in each proxy into its wrapped segment first, as the snapshot
unproxy path already does, instead of dropping them with the proxy.

The pending changes log is deliberately left in place when unwrapping:
deleting it before the wrapped segment has flushed the propagated
changes would not be crash safe. It is cleaned up on restart and when
the segment directory is dropped, and a new proxy on the same segment
adopts and appends to it; replaying a stale file is safe because all
operations are version gated.

* shard: test persisted proxy pending changes

Test the proxy segment against its persisted pending changes: buffered
changes survive dropping the proxy without propagation and are replayed
onto the segment when it is loaded again; unwrapping leaves the log in
place and a new proxy on the same segment adopts and appends to it;
layered proxies each persist into their own log file and a restart
replays both; and a persisted log is part of the segment manifest and
snapshot.

* collection, edge: recover persisted proxy changes on segment load

Replay the pending changes logs left behind by proxy segments onto each
segment when a shard loads its segments, right after consistency
repair and before the payload index rebuild, vector name reconciliation
and WAL replay. Proxy state that made it to disk no longer holds back
the WAL acknowledge, so this is where it must be recovered from.

Proxies are not reconstructed: the segment holder starts with plain
segments carrying the replayed operations, and the logs are removed
once the segment flushed them.

* collection: test crash recovery through persisted proxy changes

End-to-end test of the persisted pending changes: wrap every segment of
a local shard in a proxy, delete points so the deletes are only
buffered, flush, and assert the acknowledgeable version covers them.
Then acknowledge the WAL up to that version, drop the shard without
ever propagating the proxies, and load it again: the deletes are gone
from the WAL and must come back through the pending changes logs.

The delete under test is deliberately not the last WAL entry, as the
acknowledge never passes the last entry and that one is always
replayed.

* segment: make replaying persisted proxy changes on load an explicit mode

Add `PersistedProxyChanges` to state whether persisted pending proxy
changes are replayed onto a segment when it is loaded. `Replay`, the
default, recovers them and removes the logs as before. `Ignore` leaves
both the segment and the log files untouched and logs at debug level
that replaying was skipped; it is for segment files that mirror those
of another writer, where replaying would make the local copy diverge
from what the writer's manifest describes.

All callers pass `Replay` for now, no behavior change.

* collection: do not replay persisted proxy changes on partial snapshot recovery

Partial snapshots are recovered by read replicas in a read/write
segregation setup. A read replica must not mutate its segments, so it
cannot replay the persisted proxy segment changes on load and must
ignore them instead: its segment files are a local copy of the writer's
that must stay a faithful mirror of them, as later partial snapshots are
diffed against what the writer's manifest describes. Replaying would
mutate the segment files and remove the logs, making the copy diverge.

Thread the replay mode through `LocalShard::load` as a dedicated
`PersistedProxyChanges` argument, derived from the recovery type:
`RecoveryType::Full` replays as before, `RecoveryType::Partial` ignores
the persisted changes and leaves the logs in place. Regular shard loads
replay.

Extend the crash recovery test with an ignoring load first: the delete
under test must not come back and the logs must survive, before a
replaying load recovers it.

* Persist wrapped segment before pending changes

Prevents raising version of proxy segment too early

* Fix comment

* Fix crash window, only ready optimized segment after propagating changes

The optimizer renamed a newly built segment into segments_path and wrote
its version file before finish_optimization propagated the proxies'
buffered changes into it. A crash in that window left the segment
restart-loadable but stale, permanently losing or resurrecting points.

Defer the version file save until finish_optimization has fully
reconciled proxy changes into the segment, including the post-swap dedup
pass, so it stays invisible to restart and snapshot recovery until then.
SegmentBuilder::build() gains a `ready` flag; load_segment gains
`ignore_missing_version` for the one caller reloading before that point.

Incidentally also closes the crash-unsafe cancellation-orphan cleanup
gap noted in #9217, since a cancelled build is discarded on restart the
same way.

* Force flush optimized segment, otherwise we may lose proxy changes

* Don't force flush after replay, defer deleting log files until flush

* Include persisted proxy changes log file in segment manifest

* Add random ID to proxy log files, prevent instance conflicts

* Rename proxy log file, always include level

* Delete proxy log file on unproxy, defer until next flush cycle

* Fix truncation

* Reformat

* Lock persisted segments behind runtime feature flag

* Enable necessary feature flags in tests

* Fix linters
2026-09-15 15:45:37 +02:00
Andrey Vasnetsov 8f56a945f1 Add shared DiskCache statistics and latency histogram (#10637)
* [AI] Add shared disk cache statistics and latency histogram

* [AI] Document disk cache statistics observer identity

* [AI] Simplify disk cache statistics by removing pipeline error counters

* [AI] Limit disk cache statistics to remote fetches and trim redundant tests

* [AI] Close remote append handle before reload statistics snapshot
2026-09-14 19:19:04 +02:00
Andrey VasnetsovandClaude Opus 5 ddbc6cab0c fix(uio): carry the failing object in UniversalIoError::S3 (#10626)
`map_get_err` is handed the key it was reading, but only the `NotFound`
arm kept it; every other error boxed the underlying failure and dropped
the key on the floor.

Callers that report such a failure are then unable to say what it was
reading. The read-only segment open is the clearest case: it logs one
warning per skipped segment, so a failure anywhere among a segment's
objects — state file, id tracker, payload storage, per-vector storage
and index, payload indexes — produces the same line, naming only the
segment uuid. A recent load test hit exactly this: 667 warnings, all
byte-identical, none of them saying which file failed.

Give the `S3` variant an explicit `path` beside its source error, rather
than folding the key into the message, so the object stays a field
callers can read. It is optional because most construction sites are not
about one particular object — a short or overlapping read from the
scatter buffer, the append context's protocol errors — and those keep
using `s3()` unchanged. `s3_at()` sets it, and the object-store read
surface (every `map_get_err` caller, plus `list_files` and `exists`) now
does.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:22:44 +02:00
Andrey VasnetsovandClaude Opus 5 e97bd7d1e5 fix(uio): don't fetch a zero-length object on a populating async open (#10625)
* fix(uio): don't fetch a zero-length object on a populating async open

The `PreferBackground`/`Blocking` arm of the disk cache's `open_async` built
its prefetch range by hand as `0..len`. For a zero-length object that is
`0..0`, which `object_store` rejects client-side with
`InvalidGetRange::Inconsistent` ("Range started at 0 and ended at 0") rather
than answering with an empty body.

The failure is not contained to the file: a single empty object anywhere
under a segment prefix fails the whole segment open, and a read-only
follower then logs "skipping unloadable segment" and serves the shard
without it — silently returning results computed over a subset of the data.
It is also deterministic, so the segment stays dropped on every retry.

The sync path already handles this correctly via `schedule_whole`
(`read_from_into_byte_buffer` disambiguates the unsatisfiable-range error
with a `len` call and yields an empty buffer); the async arm was the only
place assembling the range itself. Create the local mirror first and skip
the fetch entirely when there is nothing to read.

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

* ci: stop using the minio/mc image, which no longer exists

`minio/mc` has been withdrawn from Docker Hub — pulling it now fails with
"repository does not exist or may require 'docker login'". The readiness
loop ran it 60 times with output redirected to /dev/null, so the pull
error was invisible and the job failed as "rustfs did not become ready in
60s", pointing at the wrong component. Bucket creation used the same
image and would have failed next.

Use the AWS CLI that ships with the runner image instead: no third-party
container to pull for either step. The readiness probe stays an
authenticated call (`s3api list-buckets`), so it still waits out
credential setup and not just the port opening, and it now reports the
final failure instead of swallowing it.

Also pin the rustfs service image by digest. That was not the cause here
— rc.5 and rc.6 both work — but this workflow already pins its actions by
SHA, and a service tracking `latest` is how a green suite turns red with
no change to the repo.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:58:39 +02:00
Daniel Boros 4eb92f3392 feat: copy a fresh appendable segment in one async save wave (#10503)
* feat: copy a fresh appendable segment in one async save wave

* fix: read each segment file inside the save wave

* refactor: let the backend own the write executor and depth

* refactor: drop the write semaphore and bound the wave in copy_dir
2026-09-08 22:48:43 +02:00
Luis CossíoandClaude Fable 5 690d92e751 [updater] genericize fs to use UniversalAppendFs (#10451)
* introduce UniversalAppendFs helper

* AI: migrate to UniversalAppendFs bound

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

* don't use it in Gridstore

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 16:56:34 -03:00
Mohd Quamar Tyagi 835285dd6c test(common): add unit tests for defaults module calculations (#10490)
Cover thread_count_for_hnsw and default_cpu_budget_unallocated boundary
thresholds on current dev.
2026-09-08 15:22:12 +02:00
qdrant-cloud-bot dec9cd9310 Rename MmapFlusher to Flusher (#10496)
The type is used by RAM and other non-mmap storages (often as a no-op),
so the Mmap-prefixed name was misleading.
2026-09-07 14:56:46 +00:00
Luis Cossío 99dda83df0 gate imports for linux-only code (#10488) 2026-09-07 13:50:28 +02:00
Tim Visée 7b466c60aa Bump dev version to 1.19.2-dev (#10464) 2026-09-03 14:36:29 +02:00
tellet-q fdfe5b8bd5 Revert "feat(consensus): warn when applying a single entry stalls the consensus thread (#10215)" (#10241)
This reverts commit dfca67f5ed.
2026-09-03 13:19:11 +02:00
Andrey Vasnetsov de1db19c09 Use cachestat for memory residency probes (#10455) 2026-09-03 09:42:57 +00:00
61ffec42ab Fall back to per-vector TQ scoring for scattered ids (#10381)
* Fall back to per-vector TQ scoring when runs are short

Run-batched scoring pays off on plain and dense filtered scans but
regresses HNSW, where neighbor ids rarely form consecutive runs and batch
setup dominates. Gate both EncodedVectorsTQ::score_points and Turbo
score_query_batch on offsets_worth_batch_scoring, which takes the run path
only when the ids split into runs averaging BATCH_SCORE_MIN_MEAN_RUN
vectors or more.

The average decides, not the longest run: a sorted id list -- what a
filtered scan hands the scorer -- already contains adjacent pairs at ~1%
density while its runs still average one vector, so a "contains a run of
>= 2" test sends those down the run path to pay setup per vector, measured
at up to +75% against the better path. An average also stays independent
of the batch size the driver slices ids into, which a longest-run test does
not. The threshold comes from the measured crossover -- mean run 2.0-3.6,
stable across dims 128/512/1024, both RAM storages and the 1/2/4-bit
widths -- and a fully contiguous block is recognized in O(1), so a plain
scan pays nothing for the gate.

* io_uring: never gate run-batched scoring

The gate exists because run batching costs setup that short runs do not
repay on RAM and mmap storages. io_uring is the opposite: one run-granular
read beats the batched per-vector path at every density measured -- 27% on
HNSW-shaped id lists, 43% at 25% filter density, 93% on a full scan --
because per-request submission and completion bookkeeping dominates once
the data sits in the page cache. Gating it costs 36% on HNSW-shaped lists.

Add EncodedStorage::prefers_run_reads, defaulting to false so every storage
keeps its current routing, and override it for the single-file quantized
storage when its backend is io_uring. Remote backends (object stores, a
gRPC peer) deliberately keep the per-vector path: their reads pipeline
across a batch, while run-granular reads would serialize the round trips.

QuantizedStorage::is_in_ram_or_mmap() still reports true for every backend,
which is what routes io_uring into the gate in the first place. Correcting
that would also change how the multivector storage picks between its
in-memory and uring scoring paths, so it is left to a separate change.

* Rename prefers_run_reads to prefers_contiguous_reads

"Run reads" is easy to misread as "execute reads"; contiguous makes the
storage I/O preference explicit.

* QuantizedStorage::for_each_run: pipeline run reads on async backends

With `prefers_contiguous_reads()` true for io_uring, every batch goes
through `for_each_run`, which read each run synchronously: a scattered
id list (HNSW neighbours) waited on one disk read per vector, where
`for_each_in_batch` kept the whole batch in flight through `read_batch`.
Submit all runs of a batch together, still one read per run, so
scattered reads stay pipelined while a scan still reads each run in one
request.  Backends without async reads keep the sequential loop.

`turbo_vector_search` (dim 1024, 200k vectors, 4096 shuffled ids per
iteration) against dev: cold scattered io_uring 187 ms -> 26.5 ms
(dev 29.5 ms); the warm scan keeps 198 ms -> 21 ms.  Warm scattered
lands at 5.06 ms (dev 4.67 ms), giving up the 3.68 ms of synchronous
reads, which only holds with the data already in the page cache.

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

* Iterate consecutive runs and share the run-scoring gate

`for_each_consecutive_run` becomes `consecutive_runs()`, a lazy iterator
over `Run { first, start, len }`, so a storage can feed runs straight into
a read pipeline. `QuantizedStorage::for_each_run` loses the intermediate
`Vec` and the duplicated `ReadRange` construction: the pipelined branch
maps the iterator into `read_batch`, the synchronous branch keeps the
per-run `Sequential`/`Random` hint that picks between mmap's two mappings.

The routing condition duplicated at both scoring call sites moves into
`EncodedStorage::prefers_run_scoring`: same expression, one place.

`for_each_run`'s contract no longer promises run order: pipelined
backends report reads as they complete, so callers address results by
`first`. Add a contract test over the mmap and disk-cache backends; the
latter is the async-capable backend that runs on every platform and
covers the `read_batch` branch io_uring takes on Linux, which no test
exercised before.

Measured on Apple M3 against 1f2d1264e, interleaved A/B/B/A: the run
path is unchanged on all four storages (-0.3%, +0.0%, +0.9%, -1.1%,
within replicate noise).

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

* Rename Run to ConsecutiveRun

`Run` on its own reads as "execute", the same ambiguity that got
`prefers_run_reads` renamed earlier in this branch. `ConsecutiveRun`
names what the value is and pairs with `consecutive_runs()`, the iterator
that yields it.

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

* io_uring pipeline: submit eagerly only while reads are outstanding

`IoUringPipeline::wait()` called `submit_and_wait(0)` whenever a completion
was ready and anything was enqueued. `read_batch` enqueues one entry per
consumed completion, so on a warm page cache — where reads complete inline
at submission — that was one `io_uring_enter` per read. Scattered quantized
scoring over io_uring ran at ~630 ns/point warm against ~460 for the same
reads issued synchronously (which, without direct_io, are plain `pread`s).

Now the eager submission happens only while the kernel still has reads
outstanding (`in_progress` minus the completions already waiting in the
queue). When everything submitted so far has completed — the warm case —
the enqueued entries wait and go down together once the ready completions
run out. On a cold device nothing changes: a completion is answered with a
submission as before, so the in-flight depth never sags. Two fixed rules
tried first (submit only when nothing is ready; submit once half the queue
piled up) both cost the cold path, +6 % and +3 %, in proportion to how long
the device sat idle while ready completions were drained.

turbo_vector_search / turbo_uring_ab, Zen 4, `taskset -c 7`, prebuilt
binaries run alternately, cold rows with the page cache dropped:

  warm scattered, uring hnsw:   634 -> 472 ns/point  (-25 %)
  warm scattered, uring p0.25:  477 -> 369 ns/point  (-23 %)
  warm sequential, uring p1.00:  45 ->  45           (flat)
  cold scattered, uring:        29.9 -> 29.9 ms/iter (flat, 4 reps each within 0.7 %)
  mmap rows (control):          within ±2 %

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

---------

Co-authored-by: Ivan Pleshkov <pleshkov.ivan@gmail.com>
Co-authored-by: timvisee <tim@visee.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 17:57:27 +02:00
Luis Cossío 81d27d9baf [live-reload] hotfix: infallible to_owned in DiskCache (#10429) 2026-09-01 14:12:02 -04:00
Andrey VasnetsovandClaude Fable 5 01ddb5cc65 [UIO] Split async into extension traits, implement only where genuinely async (#10424)
* Split async IO into extension traits; only async-capable backends implement them

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

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

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

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

* Decouple UniversalReadExt from the async filesystem requirement

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 19:32:46 +02:00
Luis CossíoandTim Visée 44e54f4539 [edge] open and reload IO don't block search pool (#10366)
* existing segments: wait for IO outside of search pool

* new segments: wait for IO outside of search pool

* extract reload into separate function

* Update lib/edge/Cargo.toml

---------

Co-authored-by: Tim Visée <tim+github@visee.me>
2026-09-01 10:06:29 -04:00
Luis Cossío 0798695099 [UIO] Segment live_preload waits for all IO before returning (#10357)
* `LiveReload::live_preload` returns futures

* await reopens and reloads concurrently
2026-09-01 10:06:29 -04:00
Luis CossíoandClaude Fable 5 515e8ace69 [UIO] make UniversalRead::live_preload async (#10356)
* rename `reopen`->`live_reload` and `schedule_reopen`->`live_preload`

* `UniversalRead::live_preload` returns a shared future

* assert snapshot-miss eagerly on `live_preload`

`live_reload` cannot see the failed preload: its blocking fallback
re-resolves the length from the remote and succeeds. The error
surfaces at preload time, as callers (`ok_not_found`) expect.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:06:28 -04:00
Luis Cossío 83311bc243 [UIO] CachedFs waits for scheduled files to resolve + misc (#10353)
* [CachedFs] new `schedule` and `wait_all` primitives

* [AppendableIdTracker] don't reopen if just opened

* eager NotFound in `schedule_open`

* add traces for async reads

* finish `preopen`/`preload` with `wait_all`

* lock all segments in parallel for `live_reload`

* LIST before everything

to do: we don't have whole-fetch in async mode. to prevent sequential
`len`, we won't overlap static files with LIST.

* `wait_all` returns nothing
2026-09-01 10:06:28 -04:00
e91206e71a Skip prefetch for small vector storages (#10420)
* Skip prefetch for small vector storages (they fit in L2)

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

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

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

Co-authored-by: Andrey Vasnetsov <andrey@vasnetsov.com>

* clippy

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Andrey Vasnetsov <andrey@vasnetsov.com>
2026-09-01 12:21:20 +02:00
qdrant-cloud-bot 9199eff03f Fix debug-tools musl build: gate tokio-uring on non-musl targets (#10419)
The debug-tools workflow cross-compiles for x86_64-unknown-linux-musl,
but tokio-uring 0.5.0 requires libc::statx which is unavailable on musl.
Skip the tokio-uring dependency on musl and fall back to sync io_uring reads.
2026-09-01 11:22:57 +02:00
Luis Cossío e9cc3b8673 schedule_open returns nothing (#10355) 2026-08-31 12:35:51 -04:00
Luis CossíoandClaude Fable 5 2279c4a79b [UIO] UniversalReadFs::open_async (#10352)
* `UniversalReadFs::open_async`

* `schedule_open` polls once

Scheduled opens must start eagerly: sync backends complete their
`open_async` on the first poll, preserving the prefetch contract
(handles outlive later file deletions/replacements). Moved down from
the integration branch so this PR stays green.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 12:35:50 -04:00
Luis Cossío 4e4aca8893 [UIO] renames + enforce LiveReload::live_preload (#10351)
* make LiveReload::live_preload required

* rename `schedule_prefetch`->`schedule_open`

* rename `reschedule_prefetch`->`reschedule_open`
2026-08-31 12:35:50 -04:00
Luis Cossío dc3b888075 [UIO] impl IoUringFile::read_bytes_async (#10288)
* bridge async with a dedicated `tokio_uring` thread

* impl `IoBufMut` for `AVec`

* [AI] Add tests

* [AI] handle O_DIRECT

odirect test
2026-08-31 12:35:49 -04:00
Luis Cossío 9e9edcdcd7 [UIO] impl DiskCache::read_bytes_async (#10259)
* [AI] TDD: new tests

* impl `read_bytes_async` for simplediskcache
2026-08-27 22:26:00 -04:00
Luis Cossío 491fe1a1f4 [UIO] UniversalRead::read_bytes_async stubs (#10258)
* impl `read_bytes_async` stubs

* clippy

* map err to `TaskPanicked` when appropriate
2026-08-27 22:26:00 -04:00
Jojii c4963e7a0a Hide DRAM latency in quantized scoring with software prefetch (#10342)
* Add prefetch to quantization storage

* Gated prefetch

* Add support for ARM

* Update doc strings

* T1 Prefetching

* Update stale doc strings + Tests
2026-08-27 16:24:11 +02:00
Arnaud GourlayandClaude Opus 5 384cb79b15 build(deps): disable unused default features (#10331)
Drops 6 crates from the release build and 7 from the workspace test
build, with no source changes.

- geo: no triangulation, only Contains/Intersects/Haversine (spade, earcut)
- jsonwebtoken: HS256 from_secret only, no PEM keys (pem, simple_asn1)
- tar: nothing sets unpack_xattrs, which defaults to false (xattr)
- duplicate: every duplicate_item names its module (proc-macro2-diagnostics)
- pprof: no C++ frames to demangle (cpp_demangle)

Also promotes duplicate to a workspace dependency.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 09:25:08 +02:00
Jojii aea5def9da Optimization: Skipping items before pushing into PriorityQueue. (#10099)
* Optimization: Skipping items before pushing into PriorityQueue.

* Apply suggestion from top-k-update branch

* Stabilize order of equal scored items in tests
2026-08-25 11:44:23 +02:00
dependabot[bot]andqdrant-cloud-bot 88196be010 build(deps): bump tango-bench from 0.7.2 to 0.8.0 (#10317)
* build(deps): bump tango-bench from 0.7.2 to 0.8.0

Bumps [tango-bench](https://github.com/bazhenov/tango) from 0.7.2 to 0.8.0.
- [Release notes](https://github.com/bazhenov/tango/releases)
- [Commits](https://github.com/bazhenov/tango/compare/v0.7.2...v0.8.0)

---
updated-dependencies:
- dependency-name: tango-bench
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

* fix: drop deprecated tango_main for tango-bench 0.8.0

tango_benchmarks! now generates main(); tango_main! is a deprecated no-op.

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-08-25 11:42:42 +02:00
bf4e7b7740 [LiveReload] Preload ReadOnlyFlags (#10269)
* impl `live_preload` for `ReadOnlyFlags`

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 1 unresolved review comment.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-20 11:07:49 -04:00
Arnaud GourlayandClaude Opus 5 896deaeeef Fix clippy warnings from Rust 1.98 beta (#10265)
* Fix clippy warnings from Rust 1.98 beta

* drop a redundant trait import in an io_bridge test module, it already
  arrives through `use super::*`
* rewrite two `chunks_exact(CONST)` sites as `as_chunks::<{ CONST }>()`
  for the new `chunks_exact_to_as_chunks` lint
* return `bool` from `wait_for_consensus_commit` instead of
  `Result<(), ()>`, which `result_unit_err` now flags on `async fn`. Its
  only caller did `.is_ok()` on it
* allow `result_large_err` on `QueueProxyShard::new_from_version`, which
  hands the `LocalShard` back to the caller on failure. Mirrors the allow
  already on `ForwardProxyShard::new`
* migrate three `Atomic::fetch_update` calls to `try_update`, the name it
  is renamed to in 1.99. The new name already exists at our 1.97 MSRV

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

* keep guarantee on caller

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 16:10:47 +02:00
Tim ViséeandRoman Titov ec3589f8b2 Reject .. in collection names (#10242)
* Reject dot segments in collection names

Collection names are used as directory components on disk
(storage/collections/<name>, snapshots/<name>). Apply the same
plain-file-name predicate already used for snapshot names, so names
like ".." and "." no longer resolve outside the parent directory.
Applied to the legacy validator too: dot segments were never usable as
directory names, so no existing collection can be locked out by this.

* Fix collection name validation test on Windows, backslash disallowed

* Apply suggestions from code review

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

---------

Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>
2026-08-19 15:02:39 +02:00
Tim Visée 9f01222ccf Integrate new bitflags structure (#10123)
* Add `FlagsMode::from_feature_flags`, the mode for newly created flags

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

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

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

* Create flags through mode selection in storages and indexes

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

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

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

* Add TODO to not lock bitmask structure during flush

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

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

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

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

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

* Rewrite `UpdateOnlyStoredFlags` onto the compact bitmask

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

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

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

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

* Run edge tests with serverless feature flags

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

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

* Don't use sequencial mode for one shot reads
2026-08-18 17:51:21 +02:00
Arnaud GourlayandClaude Opus 5 f3ffc65531 fix(shard): flush CoW destinations before the payload-index pre-build flush (#10201)
* fix(shard): flush CoW destinations before the payload-index pre-build flush

create_field_index force-flushes each segment before building an index on it
(flush-before-build, #9767), one segment at a time, outside flush_all's
all-segment lock capture and copy-on-write dependency ordering. That flush
durably advances a CoW source past the delete halves of its pending moves.
The appendable-first iteration order usually flushes the destination before
the source, but not always: a destination proxy-wrapped by a running
optimization is classified non-appendable and can skip its flush entirely
through the already_indexed short-circuit (the proxy reports the field as
present), and a move landing mid-pass is ordered behind nothing. Once the
source flushes, the move's WAL entry stops being replayable: the pre-image
is durably deleted while the only current copy sits in the unflushed
destination, and a graceful close then loses the point.

This is the root cause of the nightly model-testing reload divergence
(#10095), traced end-to-end in CI runs 31583878492 and 31583871346: cow move
op 5197 into a freshly proxied destination, index op ~5252 flushing every
source past it while skipping the proxy, destination reloading at 5181,
replay declining with 'No point with id'.

The fix mirrors flush_all's invariant at the only per-segment flush site:
before flushing a segment, flush the destinations of its pending
flush_dependency edges (one hop suffices, destinations are appendable and
never CoW sources). Destination guards are taken before the flush lock to
keep the documented [segment locks -> flush lock] ordering.

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

* test(shard): regression test for the CoW-destination flush in create_field_index

Reproduces the #10095 loss shape deterministically: a pending copy-on-write
move out of a non-appendable source, a destination whose own pre-build flush
is skipped by the already_indexed short-circuit, then a holder-wide
create_field_index. Verified failing with the dependency-aware flush
neutralized (destination stays behind the move while the source flushes past
it) and passing with it.

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

* refactor(shard): move the CoW-aware single-segment flush into SegmentHolder

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:08:27 +02:00
xzfc 74dd4b71e3 Integrate batched HNSW (#10194)
* [14] HnswGraph: wrapper enum over in-RAM and batched graphs

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

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

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

* [16] HNSW healing: reopen as direct

* [17] Add async_hnsw_graph feature flag

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

* impl live_preload for ReadOnlySegment

* split edge refresh into preload and apply passes

* only rotate file infos after successful reload
2026-08-17 21:43:24 -04:00
3836a0bfc4 Add writer for stored bitmask type (#10107)
* Extract stored bitmask encoding into `bitmask_file_bytes`

Also single-source the u32 position-space bound as `MAX_LOGICAL_LEN`.

* Add `MutableStoredBitmask`, collecting bitmask changes in RAM

Materializes via the existing reader without keeping the file handle open, tracks diverged positions, and atomically rewrites the whole file on save - skipping the write when nothing changed.

* Rename payload to bits

* Use changed boolean

* Remove now obsolete test

* Borrow the bitmap in bitmask encoding via Cow, avoiding a clone on save

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

---------

Co-authored-by: generall <andrey@vasnetsov.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 18:15:05 -04:00
tellet-q dfca67f5ed feat(consensus): warn when applying a single entry stalls the consensus thread (#10215)
* feat(consensus): warn when applying a single entry stalls the consensus thread longer than a threshold
2026-08-17 13:23:10 +02:00
Andrey VasnetsovandClaude Fable 5 5f8cef9ebd [UpdateOnly] Writer over object storage (#10214)
* Drop the vestigial UniversalWrite bound from the update-only writer

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

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

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

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

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

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

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

* CachedBlobFile: latency tracing for append_bytes

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

* UpdateOnlyEdgeShard: sequential batches through one writer

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

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

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

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

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

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

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

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

* UpdateBatchOutcome: per-point records of retired slots

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 20:06:00 +02:00
Andrey VasnetsovandClaude Fable 5 06ffcb881f Add CachedBlobFile: cached reads + write-through appends for object stores (#10206)
* Add CachedBlobFile: cached reads + write-through appends for object stores

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

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

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

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

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

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

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

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

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

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

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

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

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

* DiskCache tracks the remote object's etag

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Drop unfulfilled too_many_arguments expectation

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 11:28:50 +02:00
Yash Singh 73504cc85f fix(common): use checked u32 conversion for simple_disk_cache block range (#10184)
to_block_range cast block indices with a raw as u32, silently truncating
past 64 TiB and reading the wrong region. Mirror the sibling guard in
disk_cache/cached_slice.rs and fail loudly with u32::try_from(...).expect.
2026-08-12 13:50:47 +02:00
Luis Cossío 500eed65b1 add etag to FileInfo (#10190) 2026-08-12 11:34:25 +02:00
Andrey VasnetsovandClaude Fable 5 b43f70a6b6 [UpdateOnly] tombstone points in immutable segments via whole-mask rewrite (#10196)
* [UpdateOnly] tombstone points in immutable segments via whole-mask rewrite

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:56:23 +02:00