197 Commits

Author SHA1 Message Date
Andrey Vasnetsov
aa6c5d8403 Global quota API (#10035)
* feat: global quota API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: drop trivial and duplicated quota tests

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

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

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

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

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

Two CI failures, both from this branch.

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

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

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

Also renames an_optimization_is_sized_against_the_disk_not_the_quota, which
needed explaining to be understood.

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

* feat: report the quota config in telemetry

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

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

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

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

Two CI failures from the previous commit.

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

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

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

* feat: remove `max_disk_usage_percent` from strict mode

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

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

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

`max_resident_memory_percent` was documented and stays for now.

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: deprecate `max_resident_memory_percent` in strict mode

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

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

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

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

* fix: reconcile the quota readers with #9891

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: report the quota metric per resource

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: make the quota release margin configurable

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 11:20:34 +02:00
Jojii
75293e5990 [Payload bytes] decode raw payload directly (#10032)
* Deserialize raw payload directly to Payload during conversion

* Treat empty payloads equally

* Fix edge
2026-07-31 15:45:43 +02:00
xzfc
75385df69f Remove dead code (#10030)
* Remove dead code

* Remove unused dependencies

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

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

For brevity.

* ast-grep: forbid allow(dead_code)
2026-07-30 20:59:31 +00:00
Jojii
ec1aec5f4c Payload bytes gRPC (#9949)
* Prepare GRPC for raw payload bytes

* Make RawPayload a separate protobuf message
2026-07-30 09:04:43 +02:00
Andrey Vasnetsov
0a16a62f99 feat: io_uring setting to control which components use the io_uring backend (#10008)
* feat: `io_uring` setting to control which components use the io_uring backend

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

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

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

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

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

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

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

* Trim comments, drop trivial tests

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

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

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

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

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

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

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

* Update lib/segment/src/types.rs

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

* Update lib/segment/src/types.rs

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

* Update lib/segment/src/types.rs

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

* Update lib/segment/src/types.rs

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

* upd openapi schema

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

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

* Require kernel io_uring support in the async-scorer fallback

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

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

* upd openapi schema

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Roman Titov <ffuugoo@users.noreply.github.com>
2026-07-28 21:17:36 +02:00
xzfc
ba3043b343 ConditionChecker::check_batched: use in hnsw (#9845) 2026-07-21 23:07:53 +00:00
Andrey Vasnetsov
446d140c2d Slice filtering condition: sliced scroll / deterministic sampling (#9899)
* feat: slice filtering condition for sliced scroll and deterministic sampling

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

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

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

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

* tests: minimal OpenAPI test for slice filter condition

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 17:45:17 +02:00
Daniel Boros
0982e8699c feat: segment manifest optimizing state with lease (#9873)
* feat: segment manifest optimizing state with lease

* fix: clippy

* fix: exhaustive manifest state matching

* fix: merge manifest rebuilds under the write lock

* refactor: named state predicates, drop redundant enumerator test

Review follow-up: move the enumerator's filter into
SegmentManifestState::is_usable, name the preserving predicate
is_optimizer_mark, delete the enumerator test that re-tested serde.

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-07-18 04:29:03 +02: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
Arnaud Gourlay
247dbbbb07 Return sorted Vec from SegmentEntry::vector_names (#9870)
The HashSet bought nothing: names are unique by construction (map keys)
and all callers only iterate. A sorted Vec skips the hashing and set
allocation, and makes the iteration order deterministic.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:42:43 +02:00
Andrey Vasnetsov
8cbd061b84 Deduplicate plain and raw upsert/sync drivers via point traits (#9877)
* Deduplicate plain and raw upsert drivers via PointToUpsert trait

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

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

No behavior changes.

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

* Deduplicate plain and raw sync drivers via PointToSync trait

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

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

No behavior changes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:27:20 +02:00
Andrey Vasnetsov
fe7ca8443a Split shard update module into per-operation submodules (#9876)
lib/shard/src/update.rs grew to 2200+ lines. Split it into an update/
directory by operation kind, moving code verbatim:

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

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 19:02:37 +02:00
Ivan Pleshkov
db2a135203 raw vector grpc send (#9843) 2026-07-16 11:16:55 +02:00
Ivan Pleshkov
9a20fc49e4 [TQDT] TQ roundtrip: raw vectors in grpc, WAL and apply internal operation (#9813)
* raw vector grpc apply

are you happy fmt

clean up

are you happy clippy

review remarks

review remarks

* fix after rebase
2026-07-15 15:10:47 +02:00
Arnaud Gourlay
0d2f5c7e85 Miscellaneous cleanups (#9849) 2026-07-15 13:44:51 +02:00
Jojii
40332d271d [TQDT] Roundtrip fix for clone_and_mutate_point (#9761)
* Fix TQDT roundtrip issue for `clone_and_mutate_point`

* Use TinyMap

* Fix doc string

* Rebase docstring fixes

* [TQDT] Reduce allocations (#9833)

* Reduce allocations

* Fix edge
2026-07-15 09:31:56 +02:00
Andrey Vasnetsov
1d4d6f02da Per-query IDF corpus for sparse vector search (#9661)
* Add per-query IDF corpus for sparse vector search

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

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

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

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

* Apply rustfmt

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

* Fix clippy manual_is_multiple_of in sparse IDF corpus test.

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

* Allow any filter as IDF corpus

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

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

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

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 11:16:47 +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
Daniel Boros
f982ae63fc feat: read-only edge grouping/matrix search + object-storage read path (#9691)
* feat: read-only edge grouping/matrix search + object-storage read path

Add query_groups (group by a payload field) and search_matrix (single-shard distance matrix over a random sample) to the read-only edge shard's EdgeShardRead API, in new grouping and matrix modules plus edge test helpers.

Make the object-storage read path available outside tests: drop the #[cfg(test)] gate on the BlobFile UniversalReadExt impl and move io_bridge_object_store/object_store to segment's normal dependencies, so a ReadOnlyEdgeShard can serve segments read from S3.

* Share group-by building blocks between server and edge

Move GroupsAggregator, group candidate query shaping (is-empty filter,
group_by payload selector, prefetch limit scaling) and result-order
derivation into shard::grouping / shard::query, so the collection and
edge grouping implementations cannot silently diverge.

Edge grouping now handles multi-valued group keys, u64 keys, wildcard
group_by paths, prefetch limits and score-ordered groups the same way
as the server.

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

* Drive group-by through a shared sans-IO state machine

Extract the multi-request collect/fill loop into
shard::grouping::GroupByDriver: next_request() yields shaped backend
queries, add_points() advances the state, distill() returns the groups.
Query execution stays with the caller, so the async server path and the
sync edge path drive the same machine, and the request shaping helpers
become private to shard::grouping.

Edge now uses the same request budget (5 collect + 5 fill requests) and
per-request candidates limit (groups * group_size, computed inside the
driver) as the server, replacing its single 4x-oversampled request.

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-07-13 18:17:58 +02:00
Andrey Vasnetsov
da7eedeaf3 Allocate a single slot per operation in append-only mutations (#9804)
* Fuse CoW point move into a single destination write

The CoW arm of apply_points_with_conditional_move wrote a moved point in
three SegmentEntry steps: upsert_point_raw, update_vectors,
set_full_payload. With append_only_mutations enabled every step clones
the whole point to a fresh internal id, so one moved point burned three
slots (the first holding an empty point for plain upserts, which clear
raw_vectors) and left two immediately-dead clones behind, tripling the
id-tracker changelog and vector writes.

Add SegmentEntry::upsert_moved_point, which writes raw vectors, the
decoded overlay, and the payload in one operation — allocating exactly
one slot — and use it on the CoW move path.

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

* Mutate same-operation slots in place in append-only mode

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

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

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

* Stamp payload storage version in write_point_parts

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 14:00:21 +02:00
Andrey Vasnetsov
0668a52a6e Fix payload index inconsistency after restart: flush-before-build (alternative to #9737) (#9767)
* Make payload index creation durably self-contained (alternative to #9737)

Flush the segment (serialized with the flush pipeline) before building a
field index, and flush the built index before saving the config, so the
synchronously written config never durably outruns the state the build
observed or the index data it describes.

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

* Derive wipe_field_dirs candidates from PayloadIndexType exhaustively

A hardcoded directory list silently misses new index types. Map each
PayloadIndexType variant to its storage directory in an exhaustive match
and iterate the enum, so a new variant does not compile until its
directory is declared and is then wiped on rebuilds automatically.

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

* Flush built index in build phase, not under the segment write lock

apply_index runs under the segment write lock, and flushing a freshly
built index there blocks reads for the duration of the flush. Move the
flush into Segment::build_field_index, which runs under the upgradable
read lock: searches keep flowing, durability ordering is unchanged
(index data still becomes durable before the config lists it), and the
not-yet-installed storages cannot overlap any captured flusher pass.

Also validate the incompatible index type switch (keyword -> full-text)
across a simulated crash.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:48:18 +02:00
Jojii
e99d433fba [TQDT] fix lossy roundtrip in apply_points_with_conditional_move (#9734)
* TQDT fix lossy roundtrip in apply_points_with_conditional_move

* Reduce allocations + Fix debug_assert

* Refactor _raw methods to reduce allocations

* Rename refactored function

* Drop omitted named vectors in the CoW upsert path.

* Revert refactor of *_raw functions

* Avoid allocations

* Review remarks
2026-07-10 16:08:31 +02:00
Arnaud Gourlay
35bbf0487a Remove unnecessary clippy allow attributes (#9775)
Remove 8 `#[allow(clippy::...)]` attributes that no longer suppress any
lint. Each was verified redundant by rewriting it to `#[expect(...)]` and
confirming the workspace stays clippy-clean under the CI config
(`cargo clippy --workspace --all-targets --all-features -- -D warnings`).

Attribute-only deletions, no behavior change.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 11:47:59 +02:00
Andrey Vasnetsov
fb681b7d9d Lazy roaring flags bitmap and bool index counts (#9749)
* [AI] make ReadOnlyRoaringFlags bitmap and bool index counts lazy

Opening a read-only segment scanned every flags file end to end:
`ReadOnlyRoaringFlags::open` materialized the whole RoaringBitmap via
`iter_ones()`. Every payload field carries a null index, so this was paid
per field per segment, for bitmaps most queries never touch.

Make the bitmap a `OnceLock`, filled by a scan on first access. Open now
reads only the tiny status file. `ReadOnlyBoolIndex`'s three eager count
fields collapse into one lazily-derived, cached `BoolCounts`; its
`live_reload` refreshes them in place when present and leaves them unset
otherwise, so reloading an index nothing queries stays scan-free.

Propagate the resulting `OperationResult` through `RoaringFlagsRead`,
`PayloadFieldIndexRead::count_indexed_points`, `FieldIndexRead`,
`PayloadIndexRead::{indexed_points, get_telemetry_data}`, `build_info` /
`build_telemetry` and `SegmentEntry::{info, get_telemetry_data}`, out
into shard, edge and collection.

`ram_usage_bytes` stays infallible: an unmaterialized bitmap holds no
RAM, so it reports 0 via the new `bitmap_if_materialized`.

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

* [AI] correct `preopen` comment: `open` no longer scans the flags file

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

* [AI] fix edge examples for fallible `info()`

`EdgeShardRead::info` now returns `OperationResult<ShardInfo>`. The
examples live in their own workspace (lib/edge/publish), so the main
`cargo check --workspace` never saw them.

Every call site sits in `fn main() -> Result<(), Box<dyn Error>>`, so
propagate with `?`. `bm25-search` compiled either way but would have
printed the `Result` rather than the `ShardInfo`.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 17:48:40 +02:00
Arnaud Gourlay
bd35dc1189 Replace flush_all sync bool with FlushMode enum (#9750)
* refactor: replace flush_all sync bool with FlushMode enum

SegmentHolder::flush_all took two adjacent bools (sync, force), and call
sites read as bare literal pairs like flush_all(true, false). Swapping
the arguments compiles and silently changes flush semantics: a swapped
pair at the snapshot site would make snapshots skip flushing entirely
when a background flush is running.

Introduce FlushMode { Sync, Background } for the first parameter so the
pair is no longer transposable and the behavior is named at each call
site. The force flag stays a bool since it feeds the
SegmentEntry::flusher(force) trait in lib/segment. No behavior change.

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

* review: exhaustive match on FlushMode instead of equality check

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:34:52 +02:00
Jojii
68e7efa6ee Rename ambiguous build_multivec_segment test fixture (#9713) 2026-07-08 11:52:10 +02:00
Ivan Pleshkov
42c5509c80 [TQDT] Segment entry raw upsert (#9717)
* tqdt segment enty raw upsert

* review remarks
2026-07-08 11:01:21 +02:00
Ivan Pleshkov
bcfffc5d92 [TQDT] Segment retrieves a serialized vector (#9690)
* tqdt segment retrieve as raw bytes

* fix ci

* review remarks

* fix colbert test
2026-07-07 16:37:31 +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
ab0d3ecc62 Add unified memory: cold|cached|pinned placement parameter for collection components (#9684)
* Add unified `memory: cold|cached|pinned` placement parameter for collection components

Introduce a single `memory` parameter that controls how each collection
component's data is held in RAM, replacing the inconsistent zoo of
`on_disk` / `always_ram` / `on_disk_payload` flags:

- `cold`: not pre-loaded from disk, cached with usage
- `cached`: pre-populated into page cache on load, evictable under pressure
- `pinned`: materialized on heap, never evicted by cache pressure

The parameter is available on dense vectors, HNSW config, all quantization
configs, the sparse index, all payload field index types, and payload
storage (as a new `payload: { memory }` sub-object on collection params).
When set, it overrides the deprecated legacy flag; when unset, behavior is
unchanged. Legacy flags are marked deprecated (Rust + proto) but keep
working; conflicts are resolved in favor of `memory` with a warning.

New capabilities enabled by the tri-state model:
- HNSW graph links can be pinned (first production caller of the existing
  `GraphLinksResidency::Pinned`)
- sparse mmap index, quantized vectors and on-disk payload field indexes
  gain a `cached` tier (mmap + populate on open)

`pinned` is rejected by API validation for components without a heap
variant (dense vector storage, payload storage). Low-memory mode degrades
placements at load time via `Memory::clamp_to_low_memory`, matching the
existing `prefer_disk`/`skip_populate` behavior. Effective-placement
comparison in the config-mismatch optimizer avoids spurious rebuilds when
the same placement is expressed through the new parameter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix gpu-gated tests for the new `memory` field

CI clippy runs with --all-features, which compiles the gpu-gated tests
that were missed locally: add the `memory` field to config literals and
allow deprecated placement params, same as in the rest of the tests.

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

* Add OpenAPI tests for memory placement, keep sparse config downgrade-clean

- OpenAPI tests: create/update collections with `memory` on every component,
  assert the parameters are echoed in collection info, assert legacy-only
  collections expose no new fields, and assert `pinned` is rejected (422)
  for dense vector storage and payload storage on both create and update.
- Persist only the explicitly requested `memory` parameter in
  `sparse_index_config.json` instead of the legacy-resolved placement, so
  configurations using only the deprecated `on_disk` flag keep byte-identical
  files that older Qdrant versions load without unknown fields.

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

* Validate collection meta ops at construction, not only in the API layer

The `memory: pinned` rejection for dense vectors and payload storage
lived in `Validate` impls on the internal request types, which only ran
through the REST actix extractor. gRPC validates just the proto message,
so a gRPC client could persist `pinned` where it is not supported and
have it silently treated as `cached`.

Run the derived validation in `CreateCollectionOperation::new` and
`UpdateCollectionOperation::new` instead: the constructors are the
common chokepoint for all API paths, before the operation is proposed
to consensus. This covers every validator on these types, not just the
`memory` checks, and keeps consensus-apply unaffected so mixed-version
clusters never reject already-committed operations.

`UpdateCollectionOperation::new` becomes fallible; `remove_replica` now
uses `new_empty` since it carries no user config. Regression tests drive
the gRPC conversion path and assert `InvalidArgument` for `pinned` on
create and update, with `cold`/`cached` accepted as a control.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 14:32:51 +02:00
Andrey Vasnetsov
7b339f4643 Resolve filter-based update operations to point ids before WAL write (#9678)
* Resolve filter-based update operations to point ids before WAL write

Filter/condition-resolving operations (delete-by-filter, conditional
upsert, the *-by-filter payload/vector operations) stored their filter
in the WAL and re-resolved it against live segment state on every
apply. Replay-time state can differ from the original apply-time state
(the optimizer drops deleted points and their version records during
compaction), so WAL replay was not a deterministic function of the log
and could resurrect filter-deleted points.

Resolve such operations into concrete point ids at submit time, under a
fence that guarantees the resolution sees exactly the operations that
precede it in WAL order. The WAL now only ever contains id-based
operations (pre-existing variants only — no format change), so replay
applies the exact same point set as the original run.

Fixes #9575

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

* Fix rustfmt

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

* Drop coordinator-side resolution: every replica resolves locally

Replicas holding the same data resolve the same filter to the same point
set, and replicas that already diverged would not become consistent by
agreeing on a filter's resolution. Forward the original filter operation
as usual and let each replica's submit fallback resolve it under its own
fence — one uniform path regardless of where the update lands.

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

* Guard against is_filter_resolving / resolve_operation drift

A resolved operation must never still classify as filter-resolving,
otherwise a filter-carrying record could reach the WAL again (#9575).
Catch one direction of drift between the gate and the rewriter with a
debug assertion right after resolution.

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

* Dedup points-vs-filter precedence into resolve_points_or_filter

The "explicit id list wins over the filter" rule was written twice on
the resolver side (DeletePayload arm and resolve_set_payload); a future
tweak landing in one copy only would make SetPayload and DeletePayload
silently diverge in what gets persisted to the WAL.

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

* Assert rewritten WAL record reuses the incoming clock tag

The single-record-reuses-the-tag property is what WAL-delta recovery
and replica dedup rely on, but no test asserted it: submit the
delete-by-filter with a real clock tag and check the resolved
DeletePoints record carries it.

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

* Test replay of old-style filter records left in the WAL

Upgraded nodes can still hold WALs with unresolved filter operations;
the by-filter apply paths are kept so they replay one final time with
the old semantics. No test covered that path (the new submit flow can
no longer produce such WALs), so append a raw DeletePointsByFilter
record at the WAL layer, reload, and assert the matched points are
gone.

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

* Add consensus test for per-replica filter-op resolution

Exercises the replicated path for filter/condition-resolving updates:
the coordinator forwards the original filter op and each replica
resolves it locally (delete-by-filter, insert-only and update-filter
conditional upserts, set-payload-by-filter, including per-shard empty
resolutions on a 2-shard collection). Asserts both replicas hold
identical state (reads prefer the local replica), then restarts the
whole cluster and asserts each replica replays its id-based WAL to the
same state.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Arnaud Gourlay <arnaud.gourlay@gmail.com>
2026-07-07 12:14:06 +02:00
Arnaud Gourlay
0346ea66cd fix: unblock optimizer after deleting a named vector (#9641)
* fix: unblock optimizer after deleting a named vector

Deleting a named vector could permanently block the config-mismatch
optimizer. The source-superset check in SegmentBuilder::update cancelled
every rebuild that found the deleted vector still in old segment files,
and each retry cancelled again, so optimizations got stuck forever.

Removing the check (as in #9609) would fix delete but reintroduce data
loss for the CreateVectorName race. Instead, tell the two cases apart
with the live collection schema: prune a source vector that is gone from
the schema (a real deletion), but cancel when it is still present (a
freshly created vector this optimizer has not yet seen). This is safe
because the schema is persisted before the op reaches segments, and the
live schema is read after the source segments are frozen.

The live set covers dense and sparse vectors, since a segment stores both
together. When no live source is wired in, the conservative always-cancel
behavior is kept.

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

* fix: wire live vector names into edge optimizers

Deleting a named vector left the edge path with the pre-fix behavior:
segment_optimizer_config hardcoded live_vector_names to None, so a merge
touching a segment that still carried the deleted vector cancelled, and
EdgeShard::optimize() propagated the cancellation as a hard error forever.

Share the shard config behind an Arc and hand the blocking optimizers a
provider that reads the current vector names on every call. Same safety
argument as the server wiring: update() holds the segments read guard
across both the segment application and the config update, so any name a
frozen source segment carries is visible to the live read.

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

* refactor: share vector-name enumeration via CollectionParams::vector_names

The optimizer's live-schema set and the WAL-recovery valid-name set are
the same dense+sparse enumeration and must stay in lockstep; a drift
between them would reintroduce a wrong prune/cancel decision. Replace
the private helper in optimizers_builder and the inline block in WAL
recovery with a single CollectionParams method.

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

* refactor: drop SegmentOptimizer::live_vector_names forwarding hop

The default trait method only forwarded to the config getter and had a
single caller; ShardOptimizationStrategy now reads the config directly,
removing one layer of indirection.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 12:41:26 +02:00
xzfc
8f55757b56 Tidy up Debug impls (#9653) 2026-07-02 09:50:07 +02:00
Andrey Vasnetsov
f70a1462fb feat: read-only edge shard follower (ReadOnlyEdgeShard) (#9529)
* feat: read-only edge shard follower (ReadOnlyEdgeShard)

Add a read-only follower of EdgeShard, mirroring the segment-level
ReadOnlySegment + live_reload design one level up at the shard. A leader
process owns a read-write EdgeShard; one or more followers open the same
on-disk directory as a ReadOnlyEdgeShard, serve reads, and periodically
refresh() to pick up the leader's flushed writes and optimizations.

Shared read logic lives once in a crate-internal EdgeReadView<H>, generic
over a ReadSegmentHandle: the follower is monomorphized over the concrete
ReadOnlySegment<S> (no dynamic dispatch), while the read-write shard's
heterogeneous Segment/ProxySegment holder uses the LockedSegment enum
(the only dyn ReadSegmentEntry left, mirroring LockedSegment::get_read).

EdgeShardRead is the public read API: it exposes the read methods
(search/query/retrieve/count/facet/scroll/info/...) as default methods,
requiring implementors only to provide read_segments() + config_snapshot().
EdgeShard keeps its inherent read methods for backwards compatibility.

Segment discovery is injected via a SegmentEnumerator (temporary seam):
LocalSegmentEnumerator scans the segments/ directory for the local/mmap
case; S3 followers supply their own. This will be replaced by an on-disk
segment manifest in follow-up work.

shard: add LockedSegment::get_read_arc(); split retrieve_blocking into a
handle-collecting wrapper + generic retrieve_over; generalize the private
_read_points over the segment type (public signatures unchanged); remove
the now-unused read_points_locked.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emj8TFxdtrf3K32eWhGgor

* feat: manifest-based segment loading for ReadOnlyEdgeShard

Replace the read-only follower's directory-scan discovery with the segment
manifest from the leader, the proper mechanism the SegmentEnumerator seam
was a placeholder for.

shard: add SegmentsManifest::load so readers can read manifest.json.

edge (leader): EdgeShard now writes segments/manifest.json (gated by the
write_segment_manifest feature flag), initialized from the live segment
set on new/load and refreshed after optimize() swaps segments.

edge (follower): ManifestSegmentEnumerator reads the manifest's `active`
segments instead of scanning, and open_mmap uses it. It requires a manifest
and errors when none is present (no silent scan fallback); discover by
scanning explicitly via open() with a LocalSegmentEnumerator instead.

Since the manifest only reports ready segments (and future versions will
mark segments retiring/under-construction rather than removing them from
active immediately), the read path no longer races with the leader, so the
defensive `is_transient_open_error` skip handling is removed — a failure to
open a reported segment is now a genuine error and propagates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emj8TFxdtrf3K32eWhGgor

* feat: edge-s3-scroll experiment binary for ReadOnlyEdgeShard over S3

Adds a standalone `edge-s3-scroll` binary that opens a ReadOnlyEdgeShard
directly over an S3 (or S3-compatible) bucket and runs a single scroll
request with a hard-coded filter, for experimentation.

Takes the bucket endpoint, credentials and key prefix as CLI flags/env
vars, builds an S3-backed BlobFs, and discovers segments via a custom
enumerator that reads the segment manifest over object storage. The
edge_config.json is fetched to a local temp dir because
ReadOnlyEdgeShard::open reads config from the local filesystem.

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

* filter config in s3 scroll tool

* fix: read segment manifest from new location after dev rebase

dev #9564 moved the segment manifest next to (rather than inside) the
segments/ directory and named it segments_manifest.json. Adapt the
read-only follower enumerators accordingly so the follower reads the
manifest where the leader now writes it:

- ManifestSegmentEnumerator reads via segment_manifest_path() (shard root)
  while still resolving segment dirs under segments/<uuid>.
- The S3 scroll tool's enumerator mirrors the same layout.

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

* feat: read-only edge shard works over object storage without edge_config

Make ReadOnlyEdgeShard self-sufficient over arbitrary backends (S3/GCS),
and harden the read-only segment loading it depends on.

- ReadOnlyEdgeShard derives its config from the segments via
  EdgeConfig::from_segment_config instead of requiring edge_config.json
  (open and refresh both derive); a follower never has that file.
- ReadOnlyEdgeShard::open(fs, path) no longer takes an enumerator: a
  read-only follower always discovers segments through the manifest.
  open_with_enumerator remains as a pub(crate) seam for tests.
- ManifestSegmentEnumerator is generic over UniversalReadFs (reads the
  manifest via read_json_via), so it works over local mmap or a blob/S3
  backend; the tool's bespoke enumerator is removed.
- Read-only mutable ID tracker tolerates absent mappings/versions files
  (they are not written while empty), matching MutableIdTracker::open:
  files are opened lazily and NotFound is treated as empty, avoiding an
  extra exists() round-trip on object storage.

edge-s3-scroll experiment tool:
- Supports AWS S3 / S3-compatible and GCS backends (--backend), with a
  hard-coded-free --filter-key/--filter-value scroll filter.
- Reads segment data through a DiskCache so remote blocks are fetched
  once and served from a local mirror afterwards.

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

* refactor: remove unused SegmentsManifest::load

Its only caller (edge's ManifestSegmentEnumerator) now reads the manifest
via read_json_via over UniversalReadFs, so the local-only load helper is
dead. Drop it and its now-unused read_json/Path imports.

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-06-25 13:04:29 +02:00
qdrant-cloud-bot
12be3c3488 feat: move segment manifest next to segments/ directory (#9564)
Follow-up to #9530 and #9558.

The shard-level segment manifest was written inside the `segments/`
directory (`segments/manifest.json`). Older versions of Qdrant choke on
an unknown file inside `segments/`, so move it next to the directory as
`segments_manifest.json` instead.

- `SEGMENT_MANIFEST_FILE` is now `segments_manifest.json` and
  `segment_manifest_path()` points at the shard root.
- The manifest is added to `ShardDataFiles` so clear/move handle it.
- Snapshots write the manifest to the snapshot root (next to
  `segments/`), and restore/partial-snapshot loaders no longer need to
  skip it inside `segments/`.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:44:36 +02:00
qdrant-cloud-bot
8db721a492 feat: include segment manifest in shard snapshots (#9558)
Follow-up to #9530. When the `write_segment_manifest` feature flag is
enabled, a shard maintains a `segments/manifest.json` listing its
segments so out-of-process readers can discover them without scanning
the filesystem. That manifest was not included in shard snapshots.

Include the segment manifest in the snapshot when the shard maintains
one, capturing it from the live segment holder before proxying (proxies
preserve the wrapped segments' UUIDs, which are the directories written
into the snapshot, so the manifest matches the snapshot contents).

On restore, the snapshot's `segments/manifest.json` is skipped while
restoring segment directories in place; the manifest is regenerated from
the loaded segments when the segment holder is built. The partial
snapshot manifest loader also skips this file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 08:59:16 +02:00
Arnaud Gourlay
fbfd8e956d fix(optimizer): defer source segment destruction until durable (post-flush actions) (#9536)
* fix(optimizer): defer source segment destruction until durable

Optimizations copy-on-write move points out of their source segments in
memory, and WAL replay can only re-derive those moves from the sources'
on-disk pre-images. Dropping the sources immediately in finish_optimization
is unsafe: the moved copies may still sit unflushed in appendable segments,
so a restart before they are persisted loses the points ("No point with id"
during replay).

Replace the immediate proxy.drop_data() with a generic post-flush action
mechanism on SegmentHolder:

- register_post_flush_action(ready_at, ack_pin, action) queues a retryable
  closure (FnMut returning PostFlushOutcome) to run once a flush proves its
  data durable.
- flush_all runs every action whose ready_at is covered by the durable
  waterline, and caps the returned version (and thus the WAL acknowledge) at
  the minimum ack_pin of the actions still pending, so every operation the
  not-yet-cleaned data contradicts (deletions in particular) stays replayable
  until the files are gone.
- finish_optimization registers each source's drop via register_segment_drop,
  pinned at the source's persisted version.

A crash before an action runs loads the old files next to their replacement;
load-time deduplication resolves the overlap.

Robustness:

- LockedSegment::try_drop_data hands the segment back on a StillInUse failure
  (data untouched) with a short timeout, so a failed drop is retried on a
  later flush instead of leaking its ack pin or blocking the flush for up to
  an hour; drop_data keeps its long timeout for callers without a retry path.
- run_ready_post_flush_actions records the ack-pin floor of the actions it is
  running (briefly out of the queue) so a concurrent flush on the background
  early-return path cannot advance the WAL acknowledge past them.

Optimizer tests that assert source files are gone now flush_all first to run
the deferred action before counting dirs / asserting. New SegmentHolder unit
tests cover the retry, hard-failure, and in-flight-pin-visibility paths.

The perf caveat (a fresh appendable segment reporting persistent_version()
== 0 drags the waterline down) is documented inline as a follow-up.

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

* capture knowledge

* Update lib/shard/src/segment_holder/mod.rs

Co-authored-by: Tim Visée <tim+github@visee.me>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Tim Visée <tim+github@visee.me>
2026-06-23 17:25:22 +02:00
Andrey Vasnetsov
852ca200b5 feat: feature-flagged segment manifest for LocalShard (#9530)
* feat: feature-flagged segment manifest for LocalShard

Add an on-disk segment manifest (`segments/manifest.json`) that lists a
shard's segments and their state, so out-of-process readers (e.g. a
read-only follower, possibly over object storage) can discover segments
without scanning the filesystem. Gated by the new `write_segment_manifest`
feature flag (off by default).

shard: define the structure + helpers (`SegmentsManifest`,
`SegmentManifestState`, `from_segment_holder`) in a new `segment_manifest`
module, plus the `SEGMENT_MANIFEST_FILE` constant and path helper. The
manifest is a flat `{ "<uuid>": "<state>" }` map; only `active` is written
today, with `under_construction`/`retiring` defined so the format can be
extended without breaking compatibility.

collection: LocalShard owns the writing logic. The manifest is persisted
via `SaveOnDisk<SegmentsManifest>`, initialized from the live segment set
on load/build and refreshed by the optimization worker whenever the
segment set changes (the helper re-derives from the holder and no-ops when
unchanged). No changes to `lib/shard/src/optimize.rs` internals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emj8TFxdtrf3K32eWhGgor

* feat: make append_only_mutations a proper feature flag

Replace the debug-only `QDRANT_APPEND_ONLY_MUTATIONS=1` env-var escape
hatch in segment construction with a `FeatureFlags::append_only_mutations`
flag, so it works in release builds and is configurable like the other
flags (config / `QDRANT__FEATURE_FLAGS__APPEND_ONLY_MUTATIONS`).

Deliberately left out of `FeatureFlags::all()`: it changes mutation
semantics and `all` is enabled in dev and e2e configs, so it stays
explicit opt-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Emj8TFxdtrf3K32eWhGgor

* upd openapi schema

* feat: register segments in manifest via must-use token, holder builder

Wire segment-manifest maintenance through the segment lifecycle so a
newly created segment is registered as soon as it exists on disk, and
construction can't silently skip it:

- build_segment now returns a #[must_use] NewSegmentToken carrying the
  new segment UUID; the lint forces callers to register or drop it.
- SegmentHolder owns the manifest and reconciles it on sync; new
  segments are registered ASAP (even before being added to the holder)
  via the token, before they can receive writes.
- SegmentHolderBuilder is the only way to obtain a shard's holder; its
  build() wires up the manifest, so it can't be forgotten. init/set
  manifest helpers are now private / test-only.
- Optimization registers the optimized segment before dropping the
  superseded segments' data; deletion is intentionally lenient.
- Document the consistency assumptions on SegmentsManifest: it is a
  superset-biased view that may list not-yet-finalized or already-deleted
  segments, which readers must tolerate.

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-06-22 12:51:57 +02:00
Andrey Vasnetsov
7b52f04dae refactor: move version() from ReadSegmentEntry to StorageSegmentEntry (#9527)
`version()` is the segment's update version — only meaningful for the
mutable/storage path. Every real caller already reaches it through
`StorageSegmentEntry` (flush), `SegmentEntry` (update), or a concrete
`Segment`/`ProxySegment`; none use it through the read-only base trait.

Move the declaration down to `StorageSegmentEntry` and relocate the
`Segment`/`ProxySegment` impls accordingly. `ReadOnlySegment` no longer
needs it, so drop the method and the write-only `version`/`initial_version`
fields it carried (`live_reload` never refreshed `version`, and neither
field was ever read).

This leaves `ReadSegmentEntry` a clean read-only surface and stops a
read-only segment from exposing a meaningless (stale) version.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:05:29 +02:00
Tim Visée
122ef1595c Enable the single_file_mmap_vector_storage flag by default (#9332)
* Enable the `single_file_mmap_vector_storage` flag by default

* Update comment on when flag is enabled by default

* Update OpenAPI spec

* Fix tests
2026-06-18 16:24:03 +02:00
qdrant-cloud-bot
e044543b0d fix(shard): read deferred source point in copy-on-write move (#9377)
A plain upsert could fail with a spurious "No point with id ... found"
(HTTP 404) when it raced a `prevent_unoptimized` optimization. This
surfaced as flaky CI failures of
`test_shard_transfer_includes_deferred_points[snapshot]`.

Root cause: `apply_points_with_conditional_move` reads the source point's
vectors and payload before relocating it into an appendable segment. Those
reads used the default `DeferredBehavior::VisibleOnly` accessors
(`all_vectors`/`payload`). When the source point is deferred — invisible to
ordinary reads, e.g. a point whose internal id is beyond the deferred
threshold under `prevent_unoptimized` while an optimization wraps its
segment in a proxy — `VisibleOnly` cannot resolve it: `payload`/`vector`
raise `PointIdError`, which propagates out as the user-facing 404 on a plain
upsert that internally takes the copy-on-write move path.

Fix: add deferred-aware read accessors (`vector_with_behavior`,
`all_vectors_with_behavior`, `payload_with_behavior`) to `ReadSegmentEntry`,
implemented on both `Segment` and `ProxySegment` (the proxy forwards the
behavior to its wrapped segment). The existing accessors delegate with
`VisibleOnly`, preserving current behavior. The CoW move path now reads with
`WithDeferred`, so deferred points are relocated with their real data
instead of failing.

Adds a deterministic regression test asserting `VisibleOnly` hides a
deferred point while `WithDeferred` resolves its real vectors/payload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 10:30:12 +02:00
Arnaud Gourlay
ad574a7acf Fix WAL replay dropping points after delete_named_vector (#9105)
* WAL replay reproducer for delete_named_vector

* fix

* Update lib/shard/src/operations/mod.rs

Co-authored-by: Tim Visée <tim+github@visee.me>

* add vector name created

* track best-effort behavior

---------

Co-authored-by: Tim Visée <tim+github@visee.me>
2026-06-09 14:06:12 +02:00
Andrey Vasnetsov
ba3472ea49 feat(id_tracker): deferred-aware mutable id tracker (#9249)
* feat(debug): QDRANT_APPEND_ONLY_MUTATIONS env override

Debug-only escape hatch so newly built segments default to append-only
mutation routing when QDRANT_APPEND_ONLY_MUTATIONS=1 (or true/yes) is
set in the environment. Lets us run the existing test suites against
the append-only path without wiring a collection-level config knob
first.

Release builds compile this out — the function is a const false.
Logs a single warn-level message the first time the override fires.

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

* fix(append-only): skip deleted vectors on snapshot, always write payload

Two correctness fixes to clone_and_mutate_point uncovered by running
the openapi suite with QDRANT_APPEND_ONLY_MUTATIONS=1:

1. The snapshot loop read every named vector via get_vector_opt, which
   returns slot bytes even when the per-vector deletion bit is set.
   For sparse-only points (or any point that had update_vector(_, None)
   applied) this materialised the default-zero vector as if it were
   real data, wrote it at new_id, and never re-tombstoned the slot —
   so dense search started scoring phantom vectors. Now we check
   is_deleted_vector(old_id) and skip the read, letting the writer
   loop emit update_vector(new_id, None) and re-mark the slot deleted.

2. The payload write was skipped when the snapshot ended up empty.
   That dropped two side effects the field indexes rely on:
   payload_storage.overwrite(new_id, empty), and the remove_point
   fan-out across configured field indexes that bumps each index's
   total_point_count to cover new_id. Without the bump the null index
   doesn't see new_id, so is_empty / is_null filters lose the point
   even though its mapping is live in the id tracker. The skip was an
   optimisation, not a contract; remove it so the field indexes get
   the same registration they'd get from the standard clear_payload
   path.

Also collapses the debug env override to an inline cfg!()-gated
check at the struct literal — the helper with one-time logging and
multi-value matching was disproportionate for a debug-only escape
hatch.

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

* refactor(id_tracker): split active vs deferred maps in PointMappings (PR A)

First step of moving the mutable id tracker to a "two-track" model so
append-only mutations into a deferred segment can keep both the
visible (active) version and the latest mutated (deferred) version of
a point at the same time.

This PR is purely structural. On-disk format is unchanged: the loader
still produces a single combined map, and `PointMappings::new`
partitions it at construction time based on `deferred_internal_id`.
Every observable behaviour at the existing API surface is preserved.

Concretely:

- New fields: `external_to_internal_num_deferred`,
  `external_to_internal_uuid_deferred`, and a `shadowed: BitVec` for
  future use by PR B (lazy-grown, default-false).
- `internal_id(ext)` checks active first, falls through to deferred —
  matches the pre-split "any matching id" contract for ext ids whose
  internal id sat above the cutoff.
- `set_link(ext, new_id)` now routes by cutoff: writes below the
  cutoff land in active, writes at or above land in deferred. Any
  prior head in the other track is tombstoned, so each ext still owns
  exactly one slot — same observable result as the pre-split
  single-map insert. PR B replaces the cross-track tombstone with a
  shadow-bit flip; PR A keeps current semantics on purpose.
- `drop(ext)` clears entries from both tracks and tombstones each
  one, again matching the prior single-map behaviour.
- `iter_external` and `iter_from` merge the active and deferred
  BTreeMap views into one sorted-by-key stream (dedup'd in case an
  ext exists in both tracks).
- `available_point_count` counts distinct external ids across both
  tracks — preserves the prior observable count for segments where
  some entries used to sit above the cutoff in the single map.

No write-path or read-path behaviour change. Reads still filter
`internal_id >= cutoff` exactly as before; mutations still tombstone
prior heads. The shadow bit and the deferred-aware lookup wiring
land in PR B and PR C.

All existing id_tracker tests pass against the split layout.

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

* feat(id_tracker): shadow active head on deferred writes (PR B)

Step 2 of the deferred-aware mutable id tracker. Replaces PR A's
"tombstone the other track" cross-track cleanup with shadow-bit logic
so a deferred mutation no longer hides the visible active version.

Behaviour by case:

- Active write (`internal_id < cutoff`, or no cutoff): unchanged
  semantically. Any prior deferred head for the same ext is dropped
  and tombstoned (the new active is the single visible head). Same
  observable result as PR A.
- Deferred write (`internal_id >= cutoff`): the prior deferred head
  (if any) is dropped and tombstoned. The active head, if it exists,
  is **shadowed** — its bit is set in `shadowed: BitVec` but its slot
  stays alive in the active map. Read paths in `Exclude` mode
  continue to return that active version; PR C will teach
  `IncludeAll` paths (the optimiser) to skip shadowed actives and
  prefer the deferred head.

Also adds `is_shadowed(internal_id)` and `shadowed_bitslice()`
accessors (the latter for PR C's filter pipeline). New unit tests
cover the routing matrix:

- no cutoff — active replacement, no shadow,
- below-cutoff replacement — active path, no shadow,
- deferred-on-top-of-active — shadow set, active retained,
- two deferred writes — prior deferred tombstoned, shadow persists,
- fresh insert above cutoff — no shadow,
- `drop(ext)` clears both tracks plus the shadow bit.

WAL replay reuses the same `set_link`, so deferred routing on
replay falls out of this change with no extra wiring.

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

* feat(id_tracker): IncludeAll skips shadowed actives, deferred-aware lookups (PR C)

Step 3 of the deferred-aware id tracker stack. PR B introduced the
shadow bit on `set_link`; this PR teaches the read paths to honour it
so the optimiser (and any other `DeferredBehavior::IncludeAll`
consumer) sees each external id exactly once, with the deferred head
winning over the now-shadowed active.

Concretely:

- `PointMappings::internal_id_with_behavior(ext, behavior)`:
  * `Exclude` returns the active head only — `None` for a
    deferred-only ext, so query paths never see a deferred mutation;
  * `IncludeAll` prefers the deferred head and falls back to active
    for points that never crossed the cutoff. Yields at most one
    internal id per ext.
- `IdTrackerRead::internal_id_with_behavior` mirrors the new method
  with a default impl that delegates to `internal_id` for trackers
  that don't carry deferred mutations.
- `PointMappingsRefEnum::iter_internal_with_behavior(IncludeAll)`
  now filters shadowed actives via the new
  `PointMappings::shadowed_bitslice()` accessor.
- `PointMappingsRefEnum::filter_deferred_and_deleted(IncludeAll)`
  also filters shadowed actives — same single-yield-per-external
  guarantee for external iterator sources like field-index outputs.
- `IdTrackerRead::resolve_external_ids` switches to the deferred-aware
  lookup. No more post-lookup `id >= cutoff` filter — the behaviour
  enum lookup gets it right at the source.

New unit tests cover the two new entry points:

- IncludeAll prefers the deferred head when an active is shadowed;
- Exclude returns None for deferred-only ext ids;
- `filter_deferred_and_deleted` over a mixed candidate list yields
  the expected per-mode result (Exclude: actives below cutoff;
  IncludeAll: every visible head, no shadowed actives).

No queries observable behaviour change today — production Exclude
paths still resolve via `internal_id` and the active head. The
optimiser will start using `IncludeAll` (and reap the dedup) in
follow-up work.

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

* feat(id_tracker): expose shadowed_point_count in SegmentInfo (PR D)

Final step of the deferred-aware id tracker stack. Adds a single new
counter — number of active heads currently shadowed by a deferred
mutation — and plumbs it through the id-tracker trait, the segment
read-view helper, and `SegmentInfo` for telemetry.

Preserves existing semantics:

- `available_point_count` is unchanged. Each distinct external id
  still counts once, regardless of which track holds its head.
- `deferred_point_count`, `deferred_internal_id`,
  `num_deleted_deferred_points` keep their current values.
- Non-appendable trackers default `shadowed_point_count()` to `0`,
  so the new `SegmentInfo.num_shadowed_points` is `None` for them.

Concrete changes:

- `PointMappings::shadowed_count()` — popcount of the shadowed
  bitslice.
- `IdTrackerRead::shadowed_point_count()` trait method with a `0`
  default; wired through `MutableIdTracker`, `InMemoryIdTracker`,
  the mutable read-only tracker, and both enum dispatchers.
- `SegmentReadView::shadowed_point_count()` helper.
- New `SegmentInfo.num_shadowed_points: Option<usize>`, populated
  with `Some(_)` for appendable segments and proxied through
  `ProxySegment` from the wrapped segment's value.

New unit test covers the counter lifecycle:

- active-only writes don't grow it,
- a deferred write over an active adds one shadow,
- a second deferred write supersedes the prior deferred head but
  the shadow stays put (still one active being shadowed),
- `drop(ext)` clears the shadow bit,
- a fresh deferred insert with no active prior doesn't add a shadow.

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

* refactor(id_tracker): rename DeferredBehavior, push behavior down to PointMappings

Three connected pieces of polish on top of the deferred-aware id
tracker stack:

1. Rename `DeferredBehavior::Exclude` → `VisibleOnly` and
   `IncludeAll` → `WithDeferred`. The pre-PR-C semantics of
   "include/exclude the deferred cutoff" became misleading once
   `IncludeAll` started skipping shadowed actives — it doesn't
   "include all" anymore, it yields one slot per external (deferred
   head preferred, active fallback). New names describe what each
   variant returns instead of how it relates to the cutoff. The
   helper method `include_all_points` becomes
   `with_deferred_points`. Updates ~90 call sites across the
   workspace; behaviour is unchanged.

2. Push `iter_internal_with_behavior` down from
   `PointMappingsRefEnum` into `PointMappings`. The per-mode
   logic (cutoff `take_while`, shadowed `filter`) now lives next
   to the data it consults; the enum layer becomes a two-arm
   `Either` dispatcher. `CompressedPointMappings` short-circuits
   to `iter_internal()` since compressed mappings can't carry
   deferred mutations.

3. Add a short docstring on `internal_to_external` describing the
   two-track model: no active-vs-deferred bias, shadowed pairs
   occupy two slots with the same value, and reads must gate on
   `deleted` because `set_link`'s same-track replacement leaves a
   real-looking stale ext id in place.

Returning `impl Iterator` instead of `Box<dyn Iterator>` for
`iter_internal`, `iter_internal_excluding`, `iter_internal_visible`,
and `iter_internal_with_behavior` removes the double-Box at the
enum boundary. The original branching structure is preserved with
`itertools::Either` instead of restructured into one big filter
chain.

All existing `id_tracker` tests still pass (47 total).
`cargo check --all-targets` + `cargo clippy --all-targets` clean.

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

* refactor(id_tracker): drop shadowed API surface, unbox iter_from/iter_random

Cleanup pass on top of the deferred-aware id tracker stack.

API surface:
- Drop SegmentInfo.num_shadowed_points + its trait method, read-view
  helper, tracker impls and ProxySegment passthrough. Cross-segment
  shadow already exists in the appendable update flow (the source
  segment keeps its copy when the new write lands above the cutoff,
  see SegmentHolder::apply_points_with_conditional_move) and is
  handled at the aggregation layer, not reported per-tracker.
- Keep PointMappings::shadowed_count() as a private popcount helper
  guarded by expect(dead_code) so a future caller can opt back into
  the dedup'd count without re-plumbing the trait.

available_point_count:
- Replace the cross-track dedup (active + deferred-only via
  contains_key filter) with a straight 4-term sum. A shadowed ext
  contributes two slots, matching the two non-tombstoned internal
  ids it actually occupies. Old dedup broke the invariant that
  deleted_point_count == deleted_bitslice.count_ones(): for each
  shadow it overcounted deletions by one without any tombstone
  actually being set.

DeferredBehavior pushdown:
- iter_random_with_behavior: caps the sampling range at the
  deferred threshold in VisibleOnly mode (no wasted samples above
  cutoff), filters shadowed actives via the bit in WithDeferred.
- iter_from_with_behavior: VisibleOnly walks the active maps only
  (no merge with deferred); WithDeferred delegates to iter_from's
  existing merge.
- scroll.rs read_by_id_stream / filtered_read_by_id_stream collapse
  their manual if-deferred-behavior branching into a single
  iter_from_with_behavior call.
- Old iter_random (no behavior) at PointMappings + ref enum was
  unused after the migration, deleted.

Unboxing iter_from / iter_random / iter_from_with_behavior:
- PointMappings::iter_from returns impl Iterator + '_ via Either
  inside the merged_num/merged_uuid closures (BTreeMap::iter vs
  range), Either at the outer match (num+uuid chain vs uuid-only).
- PointMappings::iter_from_with_behavior unboxed with a triple
  Either (behavior, external-id arm, closure start).
- CompressedPointMappings::iter_from unboxed (Either over None/Some).
- PointMappingsRefEnum::{iter_from, iter_from_with_behavior,
  iter_from_visible} all return impl Iterator + 'a via Either on
  the Plain/Compressed dispatch.

Other:
- Update outdated PR-A/PR-B comment on PointMappings::drop.
- Make the max_internal match in iter_random_with_behavior
  exhaustive (VisibleOnly+None | WithDeferred+_ instead of `_`).

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

* refactor(id_tracker): unbox iter_external

Return impl Iterator from PointMappings::iter_external,
CompressedPointMappings::iter_external and the PointMappingsRefEnum
wrapper, matching the style of the other iter_* helpers. The wrapper
dispatches via Either. The remaining Box::new at Segment::iter_points
stays because self_cell's BoxedPointIdIterator alias needs a sized
type.

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

* fix(id_tracker): count set_link tombstones in deferred_deleted_count

PointMappings::set_link tombstones the prior slot at two sites — when
an active write supersedes a deferred head (cross-track) and when any
write replaces a same-track head — using a bare deleted.set(old, true).
Neither path updated deferred_deleted_count, so tombstones above the
cutoff weren't reflected in the counter.

The append-only flow exposes this constantly: every set_full_payload
after upsert_point routes through clone_and_mutate_point, which
re-issues set_link with a fresh internal id and tombstones the prior
one. Most tombstones land above the cutoff, so deferred_point_count
(total - cutoff - deferred_deleted_count) over-reports by the missing
count. The openapi test_deferred_points integration test caught this
as `num_points - num_deferred_points = -1800` across two segments.

Extracted the tombstone bookkeeping into PointMappings::tombstone_slot
and routed both set_link sites + drop's loop through it. Behaviour on
drop is unchanged; set_link now bumps the counter on the
live → tombstoned transition for slots at or above the cutoff.

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

* style(id_tracker): collapse set_link tombstone if-let chains

Clippy's collapsible_if on the two if-let blocks added by the
deferred_deleted_count fix.

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

* Update lib/segment/src/id_tracker/point_mappings.rs

Co-authored-by: Tim Visée <tim+github@visee.me>

* refactor(id_tracker): size shadowed BitVec once instead of growing lazily

Collect the shadowed active ids up front and allocate the BitVec to the
highest offset in a single resize, avoiding repeated reallocations while
marking shadows. Addresses review feedback.

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

* revert: restore lazily-grown shadowed BitVec

Roll back the up-front sizing of the shadowed BitVec; last_entry doesn't
fit here and swapping one allocation for another isn't worthwhile.

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

* fix(id_tracker): prefer deferred head in iter_from merge

When an external id has both an active and a deferred head, iter_from's
merge collapsed the pair to the active (stale) offset. That contradicts
the WithDeferred contract used everywhere else: internal_id_with_behavior
and iter_random_with_behavior both surface the deferred head (the latest
mutation) over the shadowed active. Consumers that use the returned
internal id (payload-filter checks, the optimizer's version merge, HNSW
old->new mapping) therefore saw the stale copy.

Flip the Both arm to take the deferred operand and document the rule.

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

* fix(id_tracker): preserve active+deferred heads when loading mappings

read_mappings replayed the persisted change log into a single flat
external -> internal map per id-type, then split it by the deferred
cutoff in PointMappings::new. A flat map holds one head per external id,
so it collapsed the active+deferred coexistence case (an external id
linked first to an active slot, then to a deferred one via sequential
set_link) down to the last write — silently dropping the other head,
orphaning its slot, and leaving the shadowed bit unset. The split in
new() could not recover what was already lost before it.

Replay the log through the canonical set_link/drop mutators on a
PointMappings seeded with the cutoff instead. The log is the sequence of
set_link/drop calls that produced the live in-memory state, so this
reconstructs that state exactly — both heads, the shadowed bit, and
deferred_deleted_count — with no logic duplication. Drops the
debug_assert-guarded corruption-recovery branch (subsumed by set_link's
re-link handling) and the now-unused Uuid/PointIdType imports.

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

* refactor(id_tracker): make deferred behavior explicit at point resolution

Replace the ambiguous "any matching head" point resolution with an
explicit DeferredBehavior at every resolution boundary, so callers no
longer rely on a hidden active-vs-deferred policy.

- Remove PointMappings::internal_id and the bare IdTrackerRead::internal_id
  (the active-first-else-deferred hybrid). internal_id_with_behavior is now
  the single required resolution method; immutable/compressed trackers
  implement it by ignoring the behavior (they never carry deferred heads).
- Migrate every caller to an explicit behavior, audit-driven:
  - writes (upsert/delete/payload/vectors), point_version, point_is_deferred,
    get_internal_id, drop, consistency + builder dedup -> WithDeferred (the
    latest/live head);
  - single-point payload/vector retrieval and formula rescore -> VisibleOnly;
  - HasId/CustomIdChecker/cardinality resolution -> the request's behavior,
    threaded through the filter chain from iter_filtered_points (other entry
    points default to VisibleOnly).
- lookup_internal_id takes an explicit DeferredBehavior instead of assuming
  VisibleOnly internally.
- has_point takes an explicit DeferredBehavior (drop the has_point_with_behavior
  wrapper). Thread it through read_points/_read_points/read_points_locked so
  retrieve_blocking passes its request behavior to the existence filter; all
  other existence/dedup callers pass WithDeferred (unchanged behavior).
- set_link now detaches a stale live occupant of a reused internal id, keeping
  the forward and reverse maps consistent when recovering a corrupted log.

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

* test(id_tracker): failing tests for two correctness findings in #9249 (#9311)

* test(id_tracker): failing tests for two review findings

Two intentionally-failing tests pinning correctness gaps in the
deferred-aware id tracker (#9249). Both fail on the final assertion;
the earlier assertions establish the expected/consistent behavior.

1. iter_from_with_behavior(WithDeferred) resolves an active/deferred
   shadow collision to the stale ACTIVE internal id, while its siblings
   internal_id_with_behavior and iter_internal_with_behavior correctly
   surface the DEFERRED (latest) head. Consumers that use the yielded
   internal id (optimizer merge via for_each_unique_point,
   filtered_read_by_id_stream) therefore observe the pre-mutation
   version.
     left: [(NumId(7), 2)]   right: [(NumId(7), 9)]

2. The PR-B shadow/visible invariant is not durable: the on-disk single
   combined map cannot represent a shadowed ext, so a plain mappings
   flush + reload collapses the shadow to deferred-only and the visible
   (active) head is lost (VisibleOnly resolves None where it resolved
   Some(2) live). Restoration then depends entirely on WAL replay, i.e.
   on flush-vs-WAL-truncate ordering.
     left: None   right: Some(2)

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

* test(id_tracker): strengthen shadow tests + merge-primitive proof

Follow-up to the two failing tests, addressing self-review:

- Add for_each_unique_point_keeps_deferred_head_for_shadowed_point: the
  optimizer merge primitive (used by segment_builder::update_from) yields
  the stale active copy (internal 2, version 5) for a shadowed point and
  drops the deferred latest (internal 9, version 8). This directly
  exercises the data-loss consequence of finding #1 at the merge layer.
    left: [(NumId(7), 2, 5)]   right: [(NumId(7), 9, 8)]

- Tighten shadow_visible_head_survives_mapping_flush_reload: pin the exact
  reload failure mode. After flush+reload the mapping collapses to
  deferred-only (internal_id == Some(9)) and the active slot survives as a
  live orphan in the inverse map (external_id(2) == Some(7), not deleted) —
  a torn state where a VisibleOnly scroll still surfaces the stale copy
  while by-id VisibleOnly resolution breaks. Reframe as the live-vs-reload
  divergence the PR introduces (Some(2) live -> None reload; dev is
  consistently None).

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

* fix test

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: generall <andrey@vasnetsov.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Tim Visée <tim+github@visee.me>
Co-authored-by: root <111755117+qdrant-cloud-bot@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Arnaud Gourlay <arnaud.gourlay@gmail.com>
2026-06-08 15:03:03 +02:00
Marcelo Machuca
1456417bb6 fix(shard): use saturating_add for query limit + offset to avoid overflow (#9321)
`PlannedQuery::add` folds the query `offset` into the fetch `limit` with a
plain `usize` addition (`let limit = limit + offset;`) over unbounded,
user-supplied values. With a large `limit`/`offset` this overflows: a panic
in debug builds (overflow checks on, as in CI) and a silent wraparound to a
tiny limit in release.

Use `saturating_add` so an over-large limit/offset is clamped to `usize::MAX`
instead, matching the saturating arithmetic already used elsewhere in the
codebase.

Includes a regression test asserting that a query with `limit = usize::MAX`
and a non-zero `offset` plans a saturated fetch limit instead of overflowing.
2026-06-05 13:00:47 +02:00
Tim Visée
7041b81f76 Use assert_matches! (#9231)
* Use assert_matches!

* Add trailing commas

* Use more assert_matches!

Also, drop now redundant `expected blah but got blah` messages because
`assert_matches!` will print these.

* Use debug_assert_matches!

---------

Co-authored-by: xzfc <xzfcpw@gmail.com>
2026-06-04 11:46:46 +02:00
Tim Visée
cf969b217c Hotfix: prevent optimizer infinite loop with deferred points and multi vectors (#9285)
* Always optimize deferred points

* Add test (#9288)

* Hotfix: test indexing of deferred multivector under indeixing threshold (#9286)

---------

Co-authored-by: Arnaud Gourlay <arnaud.gourlay@gmail.com>
2026-06-03 14:35:01 +02:00
Andrey Vasnetsov
4ba6f43e4d Log slow operations during local shard WAL recovery (#9282)
* feat: log slow operations during local shard WAL recovery

Warn when applying a single WAL operation during recovery of a local
shard takes longer than 30s, including the operation type (e.g.
PointOperation::UpsertPoints) so slow recoveries can be diagnosed.

Adds CollectionUpdateOperations::label() returning a human-readable
label including the inner variant.

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

* refactor: reuse audit operation names for slow WAL recovery logging

Move the canonical operation-name mapping next to the
CollectionUpdateOperations definition in the shard crate as an inherent
operation_name() method, and have the audit AuditableOperation impl
delegate to it. The slow WAL recovery warning now reuses these same
names (e.g. upsert_points) instead of a duplicated label mapping.

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-06-03 10:44:32 +02:00
Arnaud Gourlay
abc53d717f Remove unecesssary Clippy allows (#9267) 2026-06-02 16:54:27 +02:00
Arnaud Gourlay
ddf7ebcbd2 Cleanup cancelled optimized segment (#9217)
* Cleanup cancelled optimized segment

* crash safety comment
2026-06-01 13:55:00 +02:00
Arnaud Gourlay
c82a476d06 Fix proxy deleted_mask race dropping live points from filtered search (#9116)
* Fix proxy deleted_mask race dropping live points from scored search

ProxySegment::new snapshots the wrapped segment's deleted_mask while the
optimizer holds only the upgradable-read lock, before the write lock freezes
the segment. An upsert racing onto the still-appendable wrapped segment in that
window lands at an internal offset past the snapshot. The scored search path
(PlainVectorIndex::search) consults the proxy mask in place of the segment's
live deleted state, and check_deleted_condition defaults any out-of-range
offset to deleted (unwrap_or(true)) — so the live point is silently dropped
from filtered KNN while scroll/count/retrieve still return it.

Re-snapshot deleted_mask in the optimizer once the holder write lock is held
(segment frozen) and before the proxy goes live, so the mask covers the
segment's full final point range. The fresh read also captures any deletes
that raced in, closing the ghost direction too.

Adds a proxy-level regression test that reproduces the race without the
model-testing harness.

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

* Make proxy deleted_mask sync a type-state, read once

ProxySegment::new now returns UnsyncedProxySegment instead of a
ready-to-use ProxySegment. The deleted_mask snapshot is deferred to
UnsyncedProxySegment::finalize(), which reads the wrapped segment's
deleted bitvec exactly once. The only way to obtain a ProxySegment
(and thus put it in a SegmentHolder) is via finalize(), so the sync
under the holder write lock can no longer be forgotten, and the mask
is no longer read twice (once in new, once in resync).

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

* Allow clippy::new_ret_no_self on ProxySegment::new

new deliberately returns the unsynced UnsyncedProxySegment stage rather
than Self, since a usable ProxySegment only exists once deleted_mask is
synced via finalize().

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

* Name the real constructor UnsyncedProxySegment::new, keep ProxySegment::new for tests

Instead of allowing clippy::new_ret_no_self on a ProxySegment::new that
returned UnsyncedProxySegment, give the two-phase constructor its natural
home: UnsyncedProxySegment::new returns Self and is what production code
(optimize, snapshot) uses, finalizing under the holder write lock.

ProxySegment::new becomes a #[cfg(feature = "testing")] convenience that
builds and finalizes in one step (returns Self), so existing test call
sites stay terse and don't need an explicit .finalize(). The shard
testing feature is enabled for both shard's own tests and collection's
dev-dependency, and excluded from production builds.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: generall <andrey@vasnetsov.com>
2026-05-22 10:58:33 +02:00