mirror of
https://github.com/qdrant/qdrant.git
synced 2026-08-04 09:01:00 -05:00
* 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>
1624 lines
60 KiB
Rust
1624 lines
60 KiB
Rust
//! A collection of functions for updating points and payloads stored in segments
|
||
|
||
use std::sync::atomic::AtomicBool;
|
||
|
||
use ahash::{AHashMap, AHashSet};
|
||
use common::counter::hardware_counter::HardwareCounterCell;
|
||
use common::types::DeferredBehavior;
|
||
use parking_lot::RwLockWriteGuard;
|
||
use segment::common::operation_error::{OperationError, OperationResult};
|
||
use segment::data_types::build_index_result::BuildFieldIndexResult;
|
||
use segment::data_types::named_vectors::NamedVectors;
|
||
use segment::entry::entry_point::SegmentEntry;
|
||
use segment::json_path::JsonPath;
|
||
use segment::types::{
|
||
Condition, Filter, Payload, PayloadFieldSchema, PayloadKeyType, PayloadKeyTypeRef, PointIdType,
|
||
SeqNumberType, VectorNameBuf, WithPayload, WithVector,
|
||
};
|
||
|
||
use crate::operations::payload_ops::PayloadOps;
|
||
use crate::operations::point_ops::{
|
||
ConditionalInsertOperationInternal, PointOperations, PointStructPersisted, UpdateMode,
|
||
};
|
||
use crate::operations::vector_ops::{PointVectorsPersisted, UpdateVectorsOp, VectorOperations};
|
||
use crate::operations::{
|
||
CreateVectorName, DeleteVectorName, FieldIndexOperations, VectorNameOperations,
|
||
};
|
||
use crate::segment_holder::{SegmentHolder, SegmentId};
|
||
|
||
pub fn process_point_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
point_operation: PointOperations,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
match point_operation {
|
||
PointOperations::UpsertPoints(operation) => {
|
||
let points = operation.into_point_vec();
|
||
let res = upsert_points(segments, op_num, points.iter(), hw_counter)?;
|
||
Ok(res)
|
||
}
|
||
PointOperations::UpsertPointsConditional(operation) => {
|
||
conditional_upsert(segments, op_num, operation, hw_counter)
|
||
}
|
||
PointOperations::DeletePoints { ids } => delete_points(segments, op_num, &ids, hw_counter),
|
||
PointOperations::DeletePointsByFilter(filter) => {
|
||
delete_points_by_filter(segments, op_num, &filter, hw_counter)
|
||
}
|
||
PointOperations::SyncPoints(operation) => {
|
||
let (deleted, new, updated) = sync_points(
|
||
segments,
|
||
op_num,
|
||
operation.from_id,
|
||
operation.to_id,
|
||
&operation.points,
|
||
hw_counter,
|
||
)?;
|
||
Ok(deleted + new + updated)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "staging")]
|
||
pub fn process_staging_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
operation: crate::operations::staging::StagingOperations,
|
||
) -> OperationResult<usize> {
|
||
match operation {
|
||
crate::operations::staging::StagingOperations::Delay(delay_op) => {
|
||
delay_op.execute();
|
||
}
|
||
}
|
||
|
||
// This operation doesn't directly affect segment/point versions, so we bump it here
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
|
||
Ok(0)
|
||
}
|
||
|
||
pub fn process_vector_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
vector_operation: VectorOperations,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
match vector_operation {
|
||
VectorOperations::UpdateVectors(update_vectors) => {
|
||
update_vectors_conditional(segments, op_num, update_vectors, hw_counter)
|
||
}
|
||
VectorOperations::DeleteVectors(ids, vector_names) => {
|
||
delete_vectors(segments, op_num, &ids.points, &vector_names, hw_counter)
|
||
}
|
||
VectorOperations::DeleteVectorsByFilter(filter, vector_names) => {
|
||
delete_vectors_by_filter(segments, op_num, &filter, &vector_names, hw_counter)
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn process_payload_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
payload_operation: PayloadOps,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
match payload_operation {
|
||
PayloadOps::SetPayload(sp) => {
|
||
let payload: Payload = sp.payload;
|
||
if let Some(points) = sp.points {
|
||
set_payload(segments, op_num, &payload, &points, &sp.key, hw_counter)
|
||
} else if let Some(filter) = sp.filter {
|
||
set_payload_by_filter(segments, op_num, &payload, &filter, &sp.key, hw_counter)
|
||
} else {
|
||
// TODO: BadRequest (prev) vs BadInput (current)!?
|
||
Err(OperationError::validation_error(
|
||
"No points or filter specified",
|
||
))
|
||
}
|
||
}
|
||
PayloadOps::DeletePayload(dp) => {
|
||
if let Some(points) = dp.points {
|
||
delete_payload(segments, op_num, &points, &dp.keys, hw_counter)
|
||
} else if let Some(filter) = dp.filter {
|
||
delete_payload_by_filter(segments, op_num, &filter, &dp.keys, hw_counter)
|
||
} else {
|
||
// TODO: BadRequest (prev) vs BadInput (current)!?
|
||
Err(OperationError::validation_error(
|
||
"No points or filter specified",
|
||
))
|
||
}
|
||
}
|
||
PayloadOps::ClearPayload { ref points, .. } => {
|
||
clear_payload(segments, op_num, points, hw_counter)
|
||
}
|
||
PayloadOps::ClearPayloadByFilter(ref filter) => {
|
||
clear_payload_by_filter(segments, op_num, filter, hw_counter)
|
||
}
|
||
PayloadOps::OverwritePayload(sp) => {
|
||
let payload: Payload = sp.payload;
|
||
if let Some(points) = sp.points {
|
||
overwrite_payload(segments, op_num, &payload, &points, hw_counter)
|
||
} else if let Some(filter) = sp.filter {
|
||
overwrite_payload_by_filter(segments, op_num, &payload, &filter, hw_counter)
|
||
} else {
|
||
// TODO: BadRequest (prev) vs BadInput (current)!?
|
||
Err(OperationError::validation_error(
|
||
"No points or filter specified",
|
||
))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn process_field_index_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
field_index_operation: &FieldIndexOperations,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
match field_index_operation {
|
||
FieldIndexOperations::CreateIndex(index_data) => create_field_index(
|
||
segments,
|
||
op_num,
|
||
&index_data.field_name,
|
||
index_data.field_schema.as_ref(),
|
||
hw_counter,
|
||
),
|
||
FieldIndexOperations::DeleteIndex(field_name) => {
|
||
delete_field_index(segments, op_num, field_name)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Do not insert more than this number of points in a single update operation chunk
|
||
/// This is needed to avoid locking segments for too long, so that
|
||
/// parallel read operations are not starved.
|
||
const UPDATE_OP_CHUNK_SIZE: usize = 32;
|
||
|
||
/// Checks point id in each segment, update point if found.
|
||
/// All not found points are inserted into random segment.
|
||
/// Returns: number of updated points.
|
||
pub fn upsert_points<'a, T>(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: T,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize>
|
||
where
|
||
T: IntoIterator<Item = &'a PointStructPersisted>,
|
||
{
|
||
let points_map: AHashMap<PointIdType, _> = points.into_iter().map(|p| (p.id, p)).collect();
|
||
let ids: Vec<PointIdType> = points_map.keys().copied().collect();
|
||
|
||
let mut res = 0;
|
||
|
||
for ids_chunk in ids.chunks(UPDATE_OP_CHUNK_SIZE) {
|
||
// Update points in writable segments
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
ids_chunk,
|
||
|id, write_segment| {
|
||
let point = points_map[&id];
|
||
upsert_with_payload(
|
||
write_segment,
|
||
op_num,
|
||
id,
|
||
point.get_vectors(),
|
||
point.payload.as_ref(),
|
||
hw_counter,
|
||
)
|
||
},
|
||
|id, vectors, old_payload| {
|
||
let point = points_map[&id];
|
||
for (name, vec) in point.get_vectors() {
|
||
vectors.insert(name.into(), vec.to_owned());
|
||
}
|
||
if let Some(payload) = &point.payload {
|
||
*old_payload = payload.clone();
|
||
}
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
|
||
res += updated_points.len();
|
||
// Insert new points, which was not updated or existed
|
||
let new_point_ids = ids_chunk
|
||
.iter()
|
||
.copied()
|
||
.filter(|x| !updated_points.contains(x));
|
||
|
||
{
|
||
let default_write_segment =
|
||
segments.smallest_appendable_segment().ok_or_else(|| {
|
||
OperationError::service_error(
|
||
"No appendable segments exist, expected at least one",
|
||
)
|
||
})?;
|
||
|
||
let segment_arc = default_write_segment.get();
|
||
let mut write_segment = segment_arc.write();
|
||
for point_id in new_point_ids {
|
||
let point = points_map[&point_id];
|
||
res += usize::from(upsert_with_payload(
|
||
&mut write_segment,
|
||
op_num,
|
||
point_id,
|
||
point.get_vectors(),
|
||
point.payload.as_ref(),
|
||
hw_counter,
|
||
)?);
|
||
}
|
||
RwLockWriteGuard::unlock_fair(write_segment);
|
||
};
|
||
}
|
||
|
||
Ok(res)
|
||
}
|
||
|
||
pub fn conditional_upsert(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
operation: ConditionalInsertOperationInternal,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
// Find points, which do exist, but don't match the condition.
|
||
// Exclude those points from the upsert operation.
|
||
|
||
let ConditionalInsertOperationInternal {
|
||
mut points_op,
|
||
condition,
|
||
update_mode,
|
||
} = operation;
|
||
|
||
let point_ids = points_op.point_ids();
|
||
let update_mode = update_mode.unwrap_or_default();
|
||
|
||
match update_mode {
|
||
UpdateMode::Upsert => {
|
||
// Default behavior: insert new points, update existing points that match the condition
|
||
let points_to_exclude =
|
||
select_excluded_by_filter_ids(segments, point_ids, condition, hw_counter)?;
|
||
points_op.retain_point_ids(|idx| !points_to_exclude.contains(idx));
|
||
}
|
||
UpdateMode::InsertOnly => {
|
||
// Only insert new points, skip all existing points entirely
|
||
let existing_points = segments.select_existing_points(point_ids);
|
||
points_op.retain_point_ids(|idx| !existing_points.contains(idx));
|
||
}
|
||
UpdateMode::UpdateOnly => {
|
||
// Only update existing points that match the condition, don't insert new points
|
||
let points_to_exclude =
|
||
select_excluded_by_filter_ids(segments, point_ids.clone(), condition, hw_counter)?;
|
||
let existing_points = segments.select_existing_points(point_ids);
|
||
// Keep only points that exist AND are not excluded by the condition
|
||
points_op.retain_point_ids(|idx| {
|
||
existing_points.contains(idx) && !points_to_exclude.contains(idx)
|
||
});
|
||
}
|
||
}
|
||
|
||
let points = points_op.into_point_vec();
|
||
let upserted_points = upsert_points(segments, op_num, points.iter(), hw_counter)?;
|
||
|
||
if upserted_points == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(upserted_points)
|
||
}
|
||
|
||
/// Upsert to a point ID with the specified vectors and payload in the given segment.
|
||
///
|
||
/// If the payload is None, the existing payload will be cleared.
|
||
///
|
||
/// Returns
|
||
/// - Ok(true) if the operation was successful and point replaced existing value
|
||
/// - Ok(false) if the operation was successful and point was inserted
|
||
/// - Err if the operation failed
|
||
fn upsert_with_payload(
|
||
segment: &mut RwLockWriteGuard<dyn SegmentEntry>,
|
||
op_num: SeqNumberType,
|
||
point_id: PointIdType,
|
||
vectors: NamedVectors,
|
||
payload: Option<&Payload>,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<bool> {
|
||
let mut res = segment.upsert_point(op_num, point_id, vectors, hw_counter)?;
|
||
if let Some(full_payload) = payload {
|
||
res &= segment.set_full_payload(op_num, point_id, full_payload, hw_counter)?;
|
||
} else {
|
||
res &= segment.clear_payload(op_num, point_id, hw_counter)?;
|
||
}
|
||
debug_assert!(
|
||
segment.has_point(point_id, DeferredBehavior::WithDeferred),
|
||
"the point {point_id} should be present immediately after the upsert"
|
||
);
|
||
Ok(res)
|
||
}
|
||
|
||
/// Max amount of points to delete in a batched deletion iteration
|
||
const DELETION_BATCH_SIZE: usize = 512;
|
||
|
||
/// Tries to delete points from all segments, returns number of actually deleted points.
|
||
///
|
||
/// Iterates all segments directly (rather than going through `apply_points`) to ensure
|
||
/// that ALL copies of a point are deleted, including old non-deferred copies in optimized
|
||
/// segments when the latest version is deferred in an appendable segment.
|
||
pub fn delete_points(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
ids: &[PointIdType],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_deleted_points = 0;
|
||
|
||
for batch in ids.chunks(DELETION_BATCH_SIZE) {
|
||
for (_segment_id, segment) in segments.iter() {
|
||
let segment_arc = segment.get();
|
||
let mut write_segment = segment_arc.write();
|
||
for &id in batch {
|
||
if write_segment.delete_point(op_num, id, hw_counter)? {
|
||
total_deleted_points += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(total_deleted_points)
|
||
}
|
||
|
||
/// Deferred points corner case for filtered operations.
|
||
///
|
||
/// When a point has multiple copies across segments, the old non-deferred copy may match
|
||
/// a filter while the newest deferred copy does not. In this case, the operation should NOT
|
||
/// be applied to the point — the old copy will be cleaned up during optimization deduplication.
|
||
///
|
||
/// Given per-segment filter match results, this function returns the set of point IDs that
|
||
/// should be excluded from the operation because a newer deferred version exists that wasn't
|
||
/// matched by the filter.
|
||
fn deferred_points_to_exclude_by_filter(
|
||
segments: &SegmentHolder,
|
||
per_segment_points: &AHashMap<SegmentId, Vec<PointIdType>>,
|
||
) -> AHashSet<PointIdType> {
|
||
// Find the maximum version for each point across segments where the filter matched.
|
||
let mut max_versions: AHashMap<PointIdType, Option<SeqNumberType>> = Default::default();
|
||
for (segment_id, point_ids) in per_segment_points {
|
||
let segment = segments.get(*segment_id).unwrap().get().read();
|
||
for point_id in point_ids {
|
||
let version = segment.point_version(*point_id);
|
||
let entry = max_versions.entry(*point_id).or_insert(None);
|
||
*entry = std::cmp::max(*entry, version);
|
||
}
|
||
}
|
||
|
||
// Check if any deferred point has a newer version than the max matched version.
|
||
// Such a point was not matched by the filter (its deferred version has different data),
|
||
// so the operation should not be applied.
|
||
let mut to_exclude = AHashSet::new();
|
||
for (_segment_id, segment) in segments.iter() {
|
||
let segment = segment.get().read();
|
||
if !segment.has_deferred_points() {
|
||
continue;
|
||
}
|
||
for (point_id, max_version) in &max_versions {
|
||
if segment.has_point(*point_id, DeferredBehavior::WithDeferred)
|
||
&& segment.point_version(*point_id) > *max_version
|
||
&& segment.point_is_deferred(*point_id)
|
||
{
|
||
to_exclude.insert(*point_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
to_exclude
|
||
}
|
||
|
||
/// Deletes points from all segments matching the given filter
|
||
pub fn delete_points_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
filter: &Filter,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_deleted = 0;
|
||
// we don’t want to cancel this filtered read
|
||
let is_stopped = AtomicBool::new(false);
|
||
let mut has_deferred = false;
|
||
let mut points_to_delete: AHashMap<_, _> = segments
|
||
.iter()
|
||
.map(|(segment_id, segment)| {
|
||
let segment = segment.get().read();
|
||
let point_ids = segment.read_filtered(
|
||
None,
|
||
None,
|
||
Some(filter),
|
||
&is_stopped,
|
||
hw_counter,
|
||
// Include also deferred points.
|
||
DeferredBehavior::WithDeferred,
|
||
)?;
|
||
has_deferred |= segment.has_deferred_points();
|
||
Ok((segment_id, point_ids))
|
||
})
|
||
.collect::<OperationResult<_>>()?;
|
||
|
||
// Deferred points corner case.
|
||
// If the latest version of a point is deferred and does not match the filter,
|
||
// we need to skip deletion for all copies and let deduplication during optimization delete old points.
|
||
if has_deferred {
|
||
let points_to_keep = deferred_points_to_exclude_by_filter(segments, &points_to_delete);
|
||
|
||
// Expand per-segment lists to include all segments that have each matched point,
|
||
// so that ALL copies get deleted (not just the segment where the filter matched).
|
||
let all_matched_points: AHashSet<PointIdType> = points_to_delete
|
||
.values()
|
||
.flat_map(|v| v.iter().copied())
|
||
.collect();
|
||
for (segment_id, segment) in segments.iter() {
|
||
let segment = segment.get().read();
|
||
let present: Vec<_> = all_matched_points
|
||
.iter()
|
||
.copied()
|
||
.filter(|point_id| {
|
||
segment.has_point(*point_id, DeferredBehavior::WithDeferred)
|
||
&& !points_to_keep.contains(point_id)
|
||
})
|
||
.collect();
|
||
points_to_delete.insert(segment_id, present);
|
||
}
|
||
}
|
||
|
||
segments.apply_segments_batched(|s, segment_id| {
|
||
let Some(curr_points) = points_to_delete.get_mut(&segment_id) else {
|
||
return Ok(false);
|
||
};
|
||
if curr_points.is_empty() {
|
||
return Ok(false);
|
||
}
|
||
|
||
let mut deleted_in_batch = 0;
|
||
while let Some(point_id) = curr_points.pop() {
|
||
if s.delete_point(op_num, point_id, hw_counter)? {
|
||
total_deleted += 1;
|
||
deleted_in_batch += 1;
|
||
}
|
||
|
||
if deleted_in_batch >= DELETION_BATCH_SIZE {
|
||
break;
|
||
}
|
||
}
|
||
|
||
Ok(true)
|
||
})?;
|
||
|
||
if total_deleted == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(total_deleted)
|
||
}
|
||
|
||
/// Sync points within a given [from_id; to_id) range.
|
||
///
|
||
/// 1. Retrieve existing points for a range
|
||
/// 2. Remove points, which are not present in the sync operation
|
||
/// 3. Retrieve overlapping points, detect which one of them are changed
|
||
/// 4. Select new points
|
||
/// 5. Upsert points which differ from the stored ones
|
||
///
|
||
/// Returns:
|
||
/// (number of deleted points, number of new points, number of updated points)
|
||
pub fn sync_points(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
from_id: Option<PointIdType>,
|
||
to_id: Option<PointIdType>,
|
||
points: &[PointStructPersisted],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<(usize, usize, usize)> {
|
||
let id_to_point: AHashMap<PointIdType, _> = points.iter().map(|p| (p.id, p)).collect();
|
||
let sync_points: AHashSet<_> = points.iter().map(|p| p.id).collect();
|
||
// 1. Retrieve existing points for a range
|
||
let stored_point_ids: AHashSet<_> = segments
|
||
.iter()
|
||
.flat_map(|(_, segment)| segment.get().read().read_range(from_id, to_id))
|
||
.collect();
|
||
// 2. Remove points, which are not present in the sync operation
|
||
let points_to_remove: Vec<_> = stored_point_ids.difference(&sync_points).copied().collect();
|
||
let deleted = delete_points(segments, op_num, points_to_remove.as_slice(), hw_counter)?;
|
||
// 3. Retrieve overlapping points, detect which one of them are changed
|
||
let existing_point_ids: Vec<_> = stored_point_ids
|
||
.intersection(&sync_points)
|
||
.copied()
|
||
.collect();
|
||
|
||
let mut points_to_update: Vec<_> = Vec::new();
|
||
// we don’t want to cancel this filtered read
|
||
let is_stopped = AtomicBool::new(false);
|
||
let _num_updated = segments.read_points(
|
||
existing_point_ids.as_slice(),
|
||
&is_stopped,
|
||
DeferredBehavior::WithDeferred,
|
||
|ids, segment| {
|
||
let with_vector = WithVector::Bool(true);
|
||
let with_payload = WithPayload::from(true);
|
||
// Since we retrieve points, which we already know exist, we expect all of them to be found
|
||
let stored_records = segment.retrieve(
|
||
ids,
|
||
&with_payload,
|
||
&with_vector,
|
||
hw_counter,
|
||
&is_stopped,
|
||
DeferredBehavior::WithDeferred,
|
||
)?;
|
||
let mut updated = 0;
|
||
|
||
for (id, stored_record) in stored_records {
|
||
let point = id_to_point.get(&id).unwrap();
|
||
if !point.is_equal_to(&stored_record) {
|
||
points_to_update.push(*point);
|
||
updated += 1;
|
||
}
|
||
}
|
||
|
||
Ok(updated)
|
||
},
|
||
)?;
|
||
|
||
// 4. Select new points
|
||
let num_updated = points_to_update.len();
|
||
let mut num_new = 0;
|
||
sync_points.difference(&stored_point_ids).for_each(|id| {
|
||
num_new += 1;
|
||
points_to_update.push(*id_to_point.get(id).unwrap());
|
||
});
|
||
|
||
// 5. Upsert points which differ from the stored ones
|
||
let num_replaced = upsert_points(segments, op_num, points_to_update, hw_counter)?;
|
||
debug_assert!(
|
||
num_replaced <= num_updated,
|
||
"number of replaced points cannot be greater than points to update ({num_replaced} <= {num_updated})",
|
||
);
|
||
|
||
Ok((deleted, num_new, num_updated))
|
||
}
|
||
|
||
/// Batch size when modifying vector
|
||
const VECTOR_OP_BATCH_SIZE: usize = 32;
|
||
|
||
pub fn update_vectors_conditional(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: UpdateVectorsOp,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let UpdateVectorsOp {
|
||
mut points,
|
||
update_filter,
|
||
} = points;
|
||
|
||
let Some(filter_condition) = update_filter else {
|
||
return update_vectors(segments, op_num, points, hw_counter);
|
||
};
|
||
|
||
let point_ids: Vec<_> = points.iter().map(|point| point.id).collect();
|
||
|
||
let points_to_exclude =
|
||
select_excluded_by_filter_ids(segments, point_ids, filter_condition, hw_counter)?;
|
||
|
||
points.retain(|p| !points_to_exclude.contains(&p.id));
|
||
update_vectors(segments, op_num, points, hw_counter)
|
||
}
|
||
|
||
/// Update the specified named vectors of a point, keeping unspecified vectors intact.
|
||
fn update_vectors(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: Vec<PointVectorsPersisted>,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
// Build a map of vectors to update per point, merge updates on same point ID
|
||
let mut points_map: AHashMap<PointIdType, NamedVectors> = AHashMap::new();
|
||
for point in points {
|
||
let PointVectorsPersisted { id, vector } = point;
|
||
let named_vector = NamedVectors::from(vector);
|
||
|
||
let entry = points_map.entry(id).or_default();
|
||
entry.merge(named_vector);
|
||
}
|
||
|
||
let ids: Vec<PointIdType> = points_map.keys().copied().collect();
|
||
|
||
let mut total_updated_points = 0;
|
||
for batch in ids.chunks(VECTOR_OP_BATCH_SIZE) {
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
batch,
|
||
|id, write_segment| {
|
||
let vectors = points_map[&id].clone();
|
||
write_segment.update_vectors(op_num, id, vectors, hw_counter)
|
||
},
|
||
|id, owned_vectors, _| {
|
||
for (vector_name, vector_ref) in points_map[&id].iter() {
|
||
owned_vectors.insert(vector_name.to_owned(), vector_ref.to_owned());
|
||
}
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
check_unprocessed_points(batch, &updated_points)?;
|
||
total_updated_points += updated_points.len();
|
||
}
|
||
|
||
Ok(total_updated_points)
|
||
}
|
||
|
||
/// Delete the given named vectors for the given points, keeping other vectors intact.
|
||
pub fn delete_vectors(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: &[PointIdType],
|
||
vector_names: &[VectorNameBuf],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_deleted_points = 0;
|
||
|
||
for batch in points.chunks(VECTOR_OP_BATCH_SIZE) {
|
||
let modified_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
batch,
|
||
|id, write_segment| {
|
||
let mut res = true;
|
||
for name in vector_names {
|
||
res &= write_segment.delete_vector(op_num, id, name)?;
|
||
}
|
||
Ok(res)
|
||
},
|
||
|_, owned_vectors, _| {
|
||
for name in vector_names {
|
||
owned_vectors.remove_ref(name);
|
||
}
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
check_unprocessed_points(batch, &modified_points)?;
|
||
total_deleted_points += modified_points.len();
|
||
}
|
||
|
||
Ok(total_deleted_points)
|
||
}
|
||
|
||
/// Delete the given named vectors for points matching the given filter, keeping other vectors intact.
|
||
pub fn delete_vectors_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
filter: &Filter,
|
||
vector_names: &[VectorNameBuf],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let affected_points = points_by_filter(segments, filter, hw_counter)?;
|
||
let vectors_deleted =
|
||
delete_vectors(segments, op_num, &affected_points, vector_names, hw_counter)?;
|
||
|
||
if vectors_deleted == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(vectors_deleted)
|
||
}
|
||
|
||
/// Batch size when modifying payload
|
||
const PAYLOAD_OP_BATCH_SIZE: usize = 32;
|
||
|
||
pub fn set_payload(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
payload: &Payload,
|
||
points: &[PointIdType],
|
||
key: &Option<JsonPath>,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_updated_points = 0;
|
||
|
||
for chunk in points.chunks(PAYLOAD_OP_BATCH_SIZE) {
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
chunk,
|
||
|id, write_segment| write_segment.set_payload(op_num, id, payload, key, hw_counter),
|
||
|_, _, old_payload| match key {
|
||
Some(key) => old_payload.merge_by_key(payload, key),
|
||
None => old_payload.merge(payload),
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
|
||
check_unprocessed_points(chunk, &updated_points)?;
|
||
total_updated_points += updated_points.len();
|
||
}
|
||
|
||
Ok(total_updated_points)
|
||
}
|
||
|
||
pub fn set_payload_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
payload: &Payload,
|
||
filter: &Filter,
|
||
key: &Option<JsonPath>,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let affected_points = points_by_filter(segments, filter, hw_counter)?;
|
||
let points_updated = set_payload(segments, op_num, payload, &affected_points, key, hw_counter)?;
|
||
|
||
if points_updated == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(points_updated)
|
||
}
|
||
|
||
pub fn delete_payload(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: &[PointIdType],
|
||
keys: &[PayloadKeyType],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_deleted_points = 0;
|
||
|
||
for batch in points.chunks(PAYLOAD_OP_BATCH_SIZE) {
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
batch,
|
||
|id, write_segment| {
|
||
let mut res = true;
|
||
for key in keys {
|
||
res &= write_segment.delete_payload(op_num, id, key, hw_counter)?;
|
||
}
|
||
Ok(res)
|
||
},
|
||
|_, _, payload| {
|
||
for key in keys {
|
||
payload.remove(key);
|
||
}
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
|
||
check_unprocessed_points(batch, &updated_points)?;
|
||
total_deleted_points += updated_points.len();
|
||
}
|
||
|
||
Ok(total_deleted_points)
|
||
}
|
||
|
||
pub fn delete_payload_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
filter: &Filter,
|
||
keys: &[PayloadKeyType],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let affected_points = points_by_filter(segments, filter, hw_counter)?;
|
||
let points_updated = delete_payload(segments, op_num, &affected_points, keys, hw_counter)?;
|
||
|
||
if points_updated == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(points_updated)
|
||
}
|
||
|
||
pub fn clear_payload(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
points: &[PointIdType],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_updated_points = 0;
|
||
|
||
for batch in points.chunks(PAYLOAD_OP_BATCH_SIZE) {
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
batch,
|
||
|id, write_segment| write_segment.clear_payload(op_num, id, hw_counter),
|
||
|_, _, payload| payload.0.clear(),
|
||
hw_counter,
|
||
)?;
|
||
check_unprocessed_points(batch, &updated_points)?;
|
||
total_updated_points += updated_points.len();
|
||
}
|
||
|
||
Ok(total_updated_points)
|
||
}
|
||
|
||
/// Clear Payloads from all segments matching the given filter
|
||
pub fn clear_payload_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
filter: &Filter,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let points_to_clear = points_by_filter(segments, filter, hw_counter)?;
|
||
let points_cleared = clear_payload(segments, op_num, &points_to_clear, hw_counter)?;
|
||
|
||
if points_cleared == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(points_cleared)
|
||
}
|
||
|
||
pub fn overwrite_payload(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
payload: &Payload,
|
||
points: &[PointIdType],
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let mut total_updated_points = 0;
|
||
|
||
for batch in points.chunks(PAYLOAD_OP_BATCH_SIZE) {
|
||
let updated_points = segments.apply_points_with_conditional_move(
|
||
op_num,
|
||
batch,
|
||
|id, write_segment| write_segment.set_full_payload(op_num, id, payload, hw_counter),
|
||
|_, _, old_payload| {
|
||
*old_payload = payload.clone();
|
||
},
|
||
hw_counter,
|
||
)?;
|
||
|
||
total_updated_points += updated_points.len();
|
||
check_unprocessed_points(batch, &updated_points)?;
|
||
}
|
||
|
||
Ok(total_updated_points)
|
||
}
|
||
|
||
pub fn overwrite_payload_by_filter(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
payload: &Payload,
|
||
filter: &Filter,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let affected_points = points_by_filter(segments, filter, hw_counter)?;
|
||
let points_updated =
|
||
overwrite_payload(segments, op_num, payload, &affected_points, hw_counter)?;
|
||
|
||
if points_updated == 0 {
|
||
// In case we didn't hit any points, we suggest this op_num to the segment-holder to make WAL acknowledge this operation.
|
||
// If we don't do this, startup might take up a lot of time in some scenarios because of recovering these no-op operations.
|
||
segments.bump_max_segment_version_overwrite(op_num);
|
||
}
|
||
|
||
Ok(points_updated)
|
||
}
|
||
|
||
pub fn create_field_index(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
field_name: PayloadKeyTypeRef,
|
||
field_schema: Option<&PayloadFieldSchema>,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<usize> {
|
||
let Some(field_schema) = field_schema else {
|
||
return Err(OperationError::TypeInferenceError {
|
||
field_name: field_name.to_owned(),
|
||
});
|
||
};
|
||
|
||
segments.apply_segments(|write_segment| {
|
||
write_segment.with_upgraded(|segment| {
|
||
segment.delete_field_index_if_incompatible(op_num, field_name, field_schema)
|
||
})?;
|
||
|
||
let (schema, indexes) =
|
||
match write_segment.build_field_index(op_num, field_name, field_schema, hw_counter)? {
|
||
BuildFieldIndexResult::SkippedByVersion => {
|
||
return Ok(false);
|
||
}
|
||
BuildFieldIndexResult::AlreadyExists => {
|
||
return Ok(false);
|
||
}
|
||
BuildFieldIndexResult::IncompatibleSchema => {
|
||
// This is a service error, as we should have just removed the old index
|
||
// So it should not be possible to get this error
|
||
return Err(OperationError::service_error(format!(
|
||
"Incompatible schema for field index on field {field_name}",
|
||
)));
|
||
}
|
||
BuildFieldIndexResult::Built { schema, indexes } => (schema, indexes),
|
||
};
|
||
|
||
write_segment.with_upgraded(|segment| {
|
||
segment.apply_field_index(op_num, field_name.to_owned(), schema, indexes)
|
||
})
|
||
})
|
||
}
|
||
|
||
pub fn delete_field_index(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
field_name: PayloadKeyTypeRef,
|
||
) -> OperationResult<usize> {
|
||
segments.apply_segments(|write_segment| {
|
||
write_segment.with_upgraded(|segment| segment.delete_field_index(op_num, field_name))
|
||
})
|
||
}
|
||
|
||
pub fn process_vector_name_operation(
|
||
segments: &SegmentHolder,
|
||
op_num: SeqNumberType,
|
||
vector_name_operation: &VectorNameOperations,
|
||
) -> OperationResult<usize> {
|
||
match vector_name_operation {
|
||
VectorNameOperations::CreateVectorName(create_data) => {
|
||
let CreateVectorName {
|
||
vector_name,
|
||
config,
|
||
} = create_data;
|
||
|
||
segments.apply_segments(|write_segment| {
|
||
write_segment.with_upgraded(|segment| {
|
||
segment.create_vector_name(op_num, vector_name, config)
|
||
})
|
||
})
|
||
}
|
||
VectorNameOperations::DeleteVectorName(delete_data) => {
|
||
let DeleteVectorName { vector_name } = delete_data;
|
||
segments.apply_segments(|write_segment| {
|
||
write_segment
|
||
.with_upgraded(|segment| segment.delete_vector_name(op_num, vector_name))
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn select_excluded_by_filter_ids(
|
||
segments: &SegmentHolder,
|
||
point_ids: impl IntoIterator<Item = PointIdType>,
|
||
filter: Filter,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<AHashSet<PointIdType>> {
|
||
// Filter for points that doesn't match the condition, and have matching
|
||
let non_match_filter =
|
||
Filter::new_must_not(Condition::Filter(filter)).with_point_ids(point_ids);
|
||
|
||
Ok(points_by_filter(segments, &non_match_filter, hw_counter)?
|
||
.into_iter()
|
||
.collect())
|
||
}
|
||
|
||
fn points_by_filter(
|
||
segments: &SegmentHolder,
|
||
filter: &Filter,
|
||
hw_counter: &HardwareCounterCell,
|
||
) -> OperationResult<Vec<PointIdType>> {
|
||
// we don’t want to cancel this filtered read
|
||
let is_stopped = AtomicBool::new(false);
|
||
let mut has_deferred = false;
|
||
let per_segment_points: AHashMap<SegmentId, Vec<PointIdType>> = segments
|
||
.iter()
|
||
.map(|(segment_id, segment)| {
|
||
let segment = segment.get().read();
|
||
let point_ids = segment.read_filtered(
|
||
None,
|
||
None,
|
||
Some(filter),
|
||
&is_stopped,
|
||
hw_counter,
|
||
// Read operation used for updates, so we must handle all points
|
||
DeferredBehavior::WithDeferred,
|
||
)?;
|
||
has_deferred |= segment.has_deferred_points();
|
||
Ok((segment_id, point_ids))
|
||
})
|
||
.collect::<OperationResult<_>>()?;
|
||
|
||
let mut affected_points: Vec<PointIdType> = per_segment_points
|
||
.values()
|
||
.flat_map(|v| v.iter().copied())
|
||
.collect();
|
||
|
||
// Deferred points corner case: exclude points where the newest version is deferred
|
||
// and wasn’t matched by the filter (only an old stale copy matched).
|
||
if has_deferred {
|
||
let to_exclude = deferred_points_to_exclude_by_filter(segments, &per_segment_points);
|
||
if !to_exclude.is_empty() {
|
||
affected_points.retain(|id| !to_exclude.contains(id));
|
||
}
|
||
}
|
||
|
||
Ok(affected_points)
|
||
}
|
||
|
||
fn check_unprocessed_points(
|
||
points: &[PointIdType],
|
||
processed: &AHashSet<PointIdType>,
|
||
) -> OperationResult<usize> {
|
||
let first_missed_point = points.iter().copied().find(|p| !processed.contains(p));
|
||
|
||
match first_missed_point {
|
||
None => Ok(processed.len()),
|
||
Some(missed_point_id) => Err(OperationError::PointIdError { missed_point_id }),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod test {
|
||
use std::sync::Arc;
|
||
|
||
use common::counter::hardware_counter::HardwareCounterCell;
|
||
use parking_lot::RwLock;
|
||
use segment::data_types::vectors::{DEFAULT_VECTOR_NAME, only_default_vector};
|
||
use segment::entry::ReadSegmentEntry as _;
|
||
use segment::entry::entry_point::SegmentEntry as _;
|
||
use segment::payload_json;
|
||
use segment::types::{
|
||
Condition, FieldCondition, Filter, Match, MatchValue, PayloadKeyType, ValueVariants,
|
||
};
|
||
use tempfile::Builder;
|
||
|
||
use crate::fixtures::{
|
||
build_segment_1, build_segment_2, empty_segment, empty_segment_with_deferred,
|
||
};
|
||
use crate::segment_holder::SegmentHolder;
|
||
use crate::update::{
|
||
clear_payload_by_filter, delete_payload_by_filter, delete_points_by_filter,
|
||
delete_vectors_by_filter, overwrite_payload_by_filter, set_payload_by_filter,
|
||
};
|
||
|
||
#[test]
|
||
fn test_delete_by_filter_version_bump() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
|
||
let segment1 = build_segment_1(dir.path());
|
||
let segment2 = build_segment_2(dir.path());
|
||
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
|
||
let _sid1 = holder.add_new(segment1);
|
||
let _sid2 = holder.add_new(segment2);
|
||
|
||
const DELETE_OP_NUM: u64 = 16;
|
||
|
||
assert!(
|
||
holder
|
||
.iter()
|
||
.all(|i| i.1.get().read().version() < DELETE_OP_NUM)
|
||
);
|
||
|
||
let old_version = holder
|
||
.flush_all(true, false)
|
||
.expect("Failed to flush test segment holder");
|
||
|
||
let segments = Arc::new(RwLock::new(holder));
|
||
|
||
// A filter that matches no points.
|
||
let filter = Filter::new_must(Condition::Field(FieldCondition::new_match(
|
||
"color".parse().unwrap(),
|
||
Match::Value(MatchValue {
|
||
value: ValueVariants::String("white".to_string()),
|
||
}),
|
||
)));
|
||
|
||
let deleted_count =
|
||
delete_points_by_filter(&segments.read(), DELETE_OP_NUM, &filter, &hw_counter).unwrap();
|
||
assert_eq!(deleted_count, 0);
|
||
|
||
let new_version = segments
|
||
.read()
|
||
.flush_all(true, false)
|
||
.expect("Failed to flush test segment holder");
|
||
|
||
// Flushing again inrceases by 1 and is now equal to `DELETE_OP_NUM` as we want to acknowledge the empty
|
||
// delete operation in WAL.
|
||
assert_eq!(old_version + 1, new_version);
|
||
assert_eq!(new_version, DELETE_OP_NUM);
|
||
}
|
||
|
||
/// Helper: creates a non-appendable segment with a single point at the given version and city payload.
|
||
fn build_non_appendable_with_city(
|
||
path: &std::path::Path,
|
||
point_id: u64,
|
||
version: u64,
|
||
city: &str,
|
||
) -> segment::segment::Segment {
|
||
let hw_counter = HardwareCounterCell::new();
|
||
let mut seg = empty_segment(path);
|
||
seg.upsert_point(
|
||
version,
|
||
point_id.into(),
|
||
only_default_vector(&[1.0, 0.0, 0.0, 0.0]),
|
||
&hw_counter,
|
||
)
|
||
.unwrap();
|
||
let payload: segment::types::Payload = payload_json! {"city": city.to_owned()};
|
||
seg.set_payload(version, point_id.into(), &payload, &None, &hw_counter)
|
||
.unwrap();
|
||
seg.appendable_flag = false;
|
||
seg
|
||
}
|
||
|
||
/// Helper: creates an appendable segment with deferred threshold 0 (all points deferred),
|
||
/// containing a single point with the given city payload.
|
||
fn build_deferred_with_city(
|
||
path: &std::path::Path,
|
||
point_id: u64,
|
||
version: u64,
|
||
city: &str,
|
||
) -> segment::segment::Segment {
|
||
let hw_counter = HardwareCounterCell::new();
|
||
// threshold 0 => every point is deferred
|
||
let mut seg = empty_segment_with_deferred(path, 0);
|
||
seg.upsert_point(
|
||
version,
|
||
point_id.into(),
|
||
only_default_vector(&[1.0, 0.0, 0.0, 0.0]),
|
||
&hw_counter,
|
||
)
|
||
.unwrap();
|
||
let payload: segment::types::Payload = payload_json! {"city": city.to_owned()};
|
||
seg.set_payload(version, point_id.into(), &payload, &None, &hw_counter)
|
||
.unwrap();
|
||
assert!(
|
||
seg.point_is_deferred(point_id.into()),
|
||
"Point {point_id} should be deferred"
|
||
);
|
||
seg
|
||
}
|
||
|
||
fn city_filter(city: &str) -> Filter {
|
||
Filter::new_must(Condition::Field(FieldCondition::new_match(
|
||
"city".parse().unwrap(),
|
||
Match::Value(MatchValue {
|
||
value: ValueVariants::String(city.to_string()),
|
||
}),
|
||
)))
|
||
}
|
||
|
||
/// Delete by filter with deferred points corner case:
|
||
/// - Non-appendable segment: point 1 at version 1, city=Berlin
|
||
/// - Appendable+deferred segment: point 1 at version 2, city=Amsterdam
|
||
/// - Delete by filter on city=Amsterdam
|
||
///
|
||
/// The deferred point (newest) matches the filter, so both copies must be deleted.
|
||
#[test]
|
||
fn test_delete_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let deleted = delete_points_by_filter(&holder, 10, &filter, &hw_counter).unwrap();
|
||
|
||
// The deferred version matches the filter => both copies deleted.
|
||
assert!(deleted > 0, "Should have deleted at least one copy");
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
!app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy should be deleted (matches filter)"
|
||
);
|
||
assert!(
|
||
!non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy should also be deleted (deferred version matched filter)"
|
||
);
|
||
}
|
||
|
||
/// Delete by filter with deferred points corner case:
|
||
/// - Non-appendable segment: point 1 at version 1, city=Berlin
|
||
/// - Appendable+deferred segment: point 1 at version 2, city=Amsterdam
|
||
/// - Delete by filter on city=Berlin
|
||
///
|
||
/// The old copy matches the filter, but the newest version is deferred and does NOT
|
||
/// match city=Berlin. The delete must be skipped for all copies so that after
|
||
/// optimization deduplication we're left with the Amsterdam version.
|
||
#[test]
|
||
fn test_delete_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let _deleted = delete_points_by_filter(&holder, 10, &filter, &hw_counter).unwrap();
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
// The deferred version (Amsterdam) does NOT match the filter (Berlin),
|
||
// so both copies must be kept. Once the optimizer kicks in and deduplicates,
|
||
// only the Amsterdam version will remain.
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept (does not match filter, is newest)"
|
||
);
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept (deferred version is newer and does not match filter)"
|
||
);
|
||
}
|
||
|
||
// --- set_payload_by_filter deferred tests ---
|
||
|
||
/// Set payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Amsterdam (matches deferred copy)
|
||
///
|
||
/// The deferred point (newest) matches the filter, so the operation should be applied.
|
||
#[test]
|
||
fn test_set_payload_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
holder.add_new(non_appendable);
|
||
holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let payload: segment::types::Payload = payload_json! {"color": "red"};
|
||
let updated =
|
||
set_payload_by_filter(&holder, 10, &payload, &filter, &None, &hw_counter).unwrap();
|
||
|
||
assert!(updated > 0, "Should have updated at least one point");
|
||
}
|
||
|
||
/// Set payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Berlin (matches old copy only)
|
||
///
|
||
/// The deferred version does NOT match, so the operation must be skipped.
|
||
#[test]
|
||
fn test_set_payload_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let payload: segment::types::Payload = payload_json! {"color": "red"};
|
||
let updated =
|
||
set_payload_by_filter(&holder, 10, &payload, &filter, &None, &hw_counter).unwrap();
|
||
|
||
assert_eq!(
|
||
updated, 0,
|
||
"Operation should be skipped (deferred version does not match filter)"
|
||
);
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept"
|
||
);
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept"
|
||
);
|
||
}
|
||
|
||
// --- delete_payload_by_filter deferred tests ---
|
||
|
||
/// Delete payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Amsterdam (matches deferred copy)
|
||
///
|
||
/// The deferred point (newest) matches, so the payload key should be deleted.
|
||
#[test]
|
||
fn test_delete_payload_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
holder.add_new(non_appendable);
|
||
holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let keys: Vec<PayloadKeyType> = vec!["city".parse().unwrap()];
|
||
let updated = delete_payload_by_filter(&holder, 10, &filter, &keys, &hw_counter).unwrap();
|
||
|
||
assert!(updated > 0, "Should have updated at least one point");
|
||
}
|
||
|
||
/// Delete payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Berlin (matches old copy only)
|
||
///
|
||
/// The deferred version does NOT match, so the operation must be skipped.
|
||
#[test]
|
||
fn test_delete_payload_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let keys: Vec<PayloadKeyType> = vec!["city".parse().unwrap()];
|
||
let updated = delete_payload_by_filter(&holder, 10, &filter, &keys, &hw_counter).unwrap();
|
||
|
||
assert_eq!(
|
||
updated, 0,
|
||
"Operation should be skipped (deferred version does not match filter)"
|
||
);
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept"
|
||
);
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept"
|
||
);
|
||
}
|
||
|
||
// --- clear_payload_by_filter deferred tests ---
|
||
|
||
/// Clear payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Amsterdam (matches deferred copy)
|
||
///
|
||
/// The deferred point (newest) matches, so the payload should be cleared.
|
||
#[test]
|
||
fn test_clear_payload_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
holder.add_new(non_appendable);
|
||
holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let updated = clear_payload_by_filter(&holder, 10, &filter, &hw_counter).unwrap();
|
||
|
||
assert!(updated > 0, "Should have updated at least one point");
|
||
}
|
||
|
||
/// Clear payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Berlin (matches old copy only)
|
||
///
|
||
/// The deferred version does NOT match, so the operation must be skipped.
|
||
#[test]
|
||
fn test_clear_payload_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let updated = clear_payload_by_filter(&holder, 10, &filter, &hw_counter).unwrap();
|
||
|
||
assert_eq!(
|
||
updated, 0,
|
||
"Operation should be skipped (deferred version does not match filter)"
|
||
);
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept"
|
||
);
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept"
|
||
);
|
||
}
|
||
|
||
// --- overwrite_payload_by_filter deferred tests ---
|
||
|
||
/// Overwrite payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Amsterdam (matches deferred copy)
|
||
///
|
||
/// The deferred point (newest) matches, so the payload should be overwritten.
|
||
#[test]
|
||
fn test_overwrite_payload_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
holder.add_new(non_appendable);
|
||
holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let payload: segment::types::Payload = payload_json! {"color": "red"};
|
||
let updated =
|
||
overwrite_payload_by_filter(&holder, 10, &payload, &filter, &hw_counter).unwrap();
|
||
|
||
assert!(updated > 0, "Should have updated at least one point");
|
||
}
|
||
|
||
/// Overwrite payload by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Berlin (matches old copy only)
|
||
///
|
||
/// The deferred version does NOT match, so the operation must be skipped.
|
||
#[test]
|
||
fn test_overwrite_payload_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let payload: segment::types::Payload = payload_json! {"color": "red"};
|
||
let updated =
|
||
overwrite_payload_by_filter(&holder, 10, &payload, &filter, &hw_counter).unwrap();
|
||
|
||
assert_eq!(
|
||
updated, 0,
|
||
"Operation should be skipped (deferred version does not match filter)"
|
||
);
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept"
|
||
);
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept"
|
||
);
|
||
}
|
||
|
||
// --- delete_vectors_by_filter deferred tests ---
|
||
|
||
/// Delete vectors by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Amsterdam (matches deferred copy)
|
||
///
|
||
/// The deferred point (newest) matches, so the vector should be deleted.
|
||
#[test]
|
||
fn test_delete_vectors_by_filter_deferred_filter_matches_deferred() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
holder.add_new(non_appendable);
|
||
holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Amsterdam");
|
||
let vector_names = vec![DEFAULT_VECTOR_NAME.into()];
|
||
let deleted =
|
||
delete_vectors_by_filter(&holder, 10, &filter, &vector_names, &hw_counter).unwrap();
|
||
|
||
assert!(deleted > 0, "Should have deleted at least one vector");
|
||
}
|
||
|
||
/// Delete vectors by filter with deferred points:
|
||
/// - Non-appendable: point 1 v1, city=Berlin
|
||
/// - Deferred: point 1 v2, city=Amsterdam
|
||
/// - Filter: city=Berlin (matches old copy only)
|
||
///
|
||
/// The deferred version does NOT match, so the operation must be skipped.
|
||
#[test]
|
||
fn test_delete_vectors_by_filter_deferred_filter_matches_old_copy() {
|
||
let dir = Builder::new().prefix("segment_dir").tempdir().unwrap();
|
||
let hw_counter = HardwareCounterCell::new();
|
||
|
||
let non_appendable = build_non_appendable_with_city(dir.path(), 1, 1, "Berlin");
|
||
let appendable = build_deferred_with_city(dir.path(), 1, 2, "Amsterdam");
|
||
|
||
let mut holder = SegmentHolder::default();
|
||
let sid_non_app = holder.add_new(non_appendable);
|
||
let sid_app = holder.add_new(appendable);
|
||
|
||
let filter = city_filter("Berlin");
|
||
let vector_names = vec![DEFAULT_VECTOR_NAME.into()];
|
||
let deleted =
|
||
delete_vectors_by_filter(&holder, 10, &filter, &vector_names, &hw_counter).unwrap();
|
||
|
||
assert_eq!(
|
||
deleted, 0,
|
||
"Operation should be skipped (deferred version does not match filter)"
|
||
);
|
||
|
||
let non_app = holder.get(sid_non_app).unwrap().get();
|
||
let non_app = non_app.read();
|
||
let app = holder.get(sid_app).unwrap().get();
|
||
let app = app.read();
|
||
|
||
assert!(
|
||
non_app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Old copy must be kept"
|
||
);
|
||
assert!(
|
||
app.has_point(1.into(), common::types::DeferredBehavior::WithDeferred),
|
||
"Deferred copy must be kept"
|
||
);
|
||
}
|
||
}
|