fix: fold appended points' deletion into read-only live_reload

This commit is contained in:
Daniel Boros
2026-07-23 14:29:02 +02:00
parent 89cf9295a9
commit 43f7c0df54
8 changed files with 254 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
use common::bitvec::{BitSlice, BitVec};
use common::mmap::AdviceSetting;
@@ -14,10 +14,15 @@ use crate::common::operation_error::{OperationError, OperationResult};
/// In-memory counterpart of `BitvecFlags`: persisted flags materialized into an
/// owned `BitVec`, no write path.
///
/// Only consumer is the vector storages' `deleted` set, where the live-reload
/// delta is authoritative (deleted offsets set, appended offsets live so absent).
/// So unlike `ReadOnlyRoaringFlags` it keeps no file handle and never reopens —
/// the owned bitvec is the whole state.
/// The only consumer is the vector storages' `deleted` set. On live-reload the
/// id-tracker delta supplies whole-point deletions ([`Self::insert_all`]), but
/// an appended point can also carry a per-vector deletion recorded only in the
/// on-disk flags file — a missing named vector is stored as a placeholder and
/// its slot deleted. So the backing directory is retained and
/// [`Self::reload_appended`] reopens the flags file to read the persisted bit
/// of each appended offset. The set only ever grows, so a whole-point deletion
/// already folded in but not yet flushed to the flags file is never lost to a
/// re-read.
#[derive(Debug)]
#[allow(dead_code)] // pending: read-only vector storages will hold `deleted` as this
pub struct InMemoryBitvecFlags {
@@ -25,6 +30,9 @@ pub struct InMemoryBitvecFlags {
bitvec: BitVec,
/// Set-flag count, kept in sync with `bitvec`.
count: usize,
/// Backing directory of the dynamic flags file, so [`Self::reload_appended`]
/// can reopen it. `None` for flags built via [`Self::from_bitvec`].
directory: Option<PathBuf>,
}
/// Read-only mmap options: never writable, lazily paged, nothing populated.
@@ -90,7 +98,11 @@ impl InMemoryBitvecFlags {
})?;
let count = bitvec.count_ones();
Ok(Self { bitvec, count })
Ok(Self {
bitvec,
count,
directory: Some(directory.to_path_buf()),
})
}
/// Wrap an already-materialized deletion `bitvec`, computing the set-flag
@@ -98,7 +110,11 @@ impl InMemoryBitvecFlags {
/// flags read by [`Self::open`] (e.g. the immutable dense `deleted.dat`).
pub fn from_bitvec(bitvec: BitVec) -> Self {
let count = bitvec.count_ones();
Self { bitvec, count }
Self {
bitvec,
count,
directory: None,
}
}
/// Whether the flag at `key` is set; out-of-range keys read as unset.
@@ -129,6 +145,43 @@ impl InMemoryBitvecFlags {
}
}
}
/// Fold the persisted deletion bit of each appended offset into the set.
///
/// An appended point can carry a deleted vector slot whose flag lives only
/// in the on-disk flags file, not the id-tracker delta (see the type doc).
/// The flags file is reopened and the persisted bit of every `new_point` is
/// read back; live offsets read unset and change nothing. A no-op for flags
/// built via [`Self::from_bitvec`], which have no dynamic flags file.
pub fn reload_appended<S: UniversalRead>(
&mut self,
fs: &impl UniversalReadFs<File = S>,
new_points: &[PointOffsetType],
) -> OperationResult<()> {
let Some(directory) = self.directory.clone() else {
return Ok(());
};
if new_points.is_empty() {
return Ok(());
}
let flags = StoredBitSlice::<S>::open(
fs,
&directory.join(FLAGS_FILE),
bitslice_open_options(Populate::No),
Default::default(),
)?;
let mut deleted = Vec::new();
for &point in new_points {
if flags.get_bit(u64::from(point))?.unwrap_or(false) {
deleted.push(point);
}
}
self.insert_all(&deleted);
Ok(())
}
}
#[allow(clippy::default_constructed_unit_structs)]

View File

@@ -13,8 +13,9 @@ impl<T: PrimitiveVectorElement, S: UniversalRead> LiveReload
{
type Fs = S::Fs;
/// Reload the chunked vectors and apply `deleted_points`; appended points are
/// served from the refreshed chunks, so `new_points` is unused.
/// Reload the chunked vectors, apply `deleted_points`, and fold in the
/// persisted deletion of each appended offset — a live point may have a
/// deleted vector slot recorded only on disk.
fn live_reload(
&mut self,
fs: &S::Fs,
@@ -25,6 +26,7 @@ impl<T: PrimitiveVectorElement, S: UniversalRead> LiveReload
self.vectors
.live_reload(fs, deleted_points, new_points, hw_counter)?;
self.deleted.insert_all(deleted_points);
self.deleted.reload_appended::<S>(fs, new_points)?;
Ok(())
}

View File

@@ -194,4 +194,63 @@ mod tests {
}
assert!(!reader.is_deleted_vector(0));
}
/// A point can be appended live while one of its named vectors is missing:
/// the writer stores a placeholder and deletes that vector slot. The
/// deletion is recorded only in the on-disk flags file, never in the
/// id-tracker delta, so `live_reload` must read it back for the appended
/// offset instead of assuming every appended point is live.
#[test]
fn live_reload_picks_up_appended_vector_deletion() {
const DIM: usize = 4;
let dir = Builder::new()
.prefix("ro_dense_appended_deleted")
.tempdir()
.unwrap();
let hw = HardwareCounterCell::disposable();
let mut writer = open_appendable_memmap_vector_storage_impl::<VectorElementType>(
dir.path(),
DIM,
Distance::Dot,
AdviceSetting::Global,
false,
)
.unwrap();
writer
.insert_vector(0, VectorRef::from(&vec![1.0; DIM]), &hw)
.unwrap();
writer.flusher()().unwrap();
let mut reader = ReadOnlyChunkedDenseVectorStorage::<VectorElementType, MmapFile>::open(
&MmapFs,
dir.path(),
DIM,
Distance::Dot,
AdviceSetting::Global,
Populate::No,
)
.unwrap();
// Append offset 1 as a placeholder, then delete its vector slot.
writer
.insert_vector(1, VectorRef::from(&vec![0.0; DIM]), &hw)
.unwrap();
writer.delete_vector(1).unwrap();
writer.flusher()().unwrap();
let deleted_ids: Vec<PointOffsetType> = vec![];
let new_ids: Vec<PointOffsetType> = vec![1];
reader
.live_reload(
&MmapFs,
&SortedSlice::new(&deleted_ids).unwrap(),
&SortedSlice::new(&new_ids).unwrap(),
&hw,
)
.unwrap();
assert_eq!(reader.total_vector_count(), 2);
assert!(reader.is_deleted_vector(1));
}
}

View File

@@ -13,8 +13,9 @@ impl<T: PrimitiveVectorElement, S: UniversalRead> LiveReload
{
type Fs = S::Fs;
/// Reload the vectors and offsets and apply `deleted_points`; appended points
/// are served from the refreshed chunks, so `new_points` is unused.
/// Reload the vectors and offsets, apply `deleted_points`, and fold in the
/// persisted deletion of each appended offset — a live point may have a
/// deleted vector slot recorded only on disk.
fn live_reload(
&mut self,
fs: &S::Fs,
@@ -27,6 +28,7 @@ impl<T: PrimitiveVectorElement, S: UniversalRead> LiveReload
self.offsets
.live_reload(fs, deleted_points, new_points, hw_counter)?;
self.deleted.insert_all(deleted_points);
self.deleted.reload_appended::<S>(fs, new_points)?;
Ok(())
}

View File

@@ -257,4 +257,69 @@ mod tests {
}
assert!(!reader.is_deleted_vector(0));
}
/// An appended multivector point can have its slot deleted (missing named
/// vector → placeholder + delete); the deletion is recorded only in the
/// on-disk flags file, never the id-tracker delta, so `live_reload` must
/// read it back for the appended offset.
#[test]
fn live_reload_picks_up_appended_vector_deletion() {
const DIM: usize = 8;
let dir = Builder::new()
.prefix("ro_multi_appended_deleted")
.tempdir()
.unwrap();
let hw = HardwareCounterCell::disposable();
let multi = |value: VectorElementType| {
MultiDenseVectorInternal::try_from(vec![vec![value; DIM]]).unwrap()
};
let mut writer = open_appendable_memmap_multi_vector_storage_impl::<VectorElementType>(
dir.path(),
DIM,
Distance::Dot,
MultiVectorConfig::default(),
AdviceSetting::Global,
false,
)
.unwrap();
writer
.insert_vector(0, VectorRef::from(&multi(1.0)), &hw)
.unwrap();
writer.flusher()().unwrap();
let mut reader =
ReadOnlyChunkedMultiDenseVectorStorage::<VectorElementType, MmapFile>::open(
&MmapFs,
dir.path(),
DIM,
Distance::Dot,
MultiVectorConfig::default(),
AdviceSetting::Global,
Populate::No,
)
.unwrap();
// Append offset 1 as a placeholder, then delete its vector slot.
writer
.insert_vector(1, VectorRef::from(&multi(0.0)), &hw)
.unwrap();
writer.delete_vector(1).unwrap();
writer.flusher()().unwrap();
let deleted_ids: Vec<PointOffsetType> = vec![];
let new_ids: Vec<PointOffsetType> = vec![1];
reader
.live_reload(
&MmapFs,
&SortedSlice::new(&deleted_ids).unwrap(),
&SortedSlice::new(&new_ids).unwrap(),
&hw,
)
.unwrap();
assert_eq!(reader.total_vector_count(), 2);
assert!(reader.is_deleted_vector(1));
}
}

View File

@@ -10,18 +10,18 @@ use crate::common::operation_error::OperationResult;
impl<S: UniversalRead> LiveReload for ReadOnlySparseVectorStorage<S> {
type Fs = S::Fs;
/// Reload the Gridstore, apply `deleted_points`, and recompute
/// `next_point_offset`; appended points come from the Gridstore, so
/// `new_points` is unused.
/// Reload the Gridstore, apply `deleted_points`, fold in the persisted
/// deletion of each appended offset, and recompute `next_point_offset`.
fn live_reload(
&mut self,
fs: &S::Fs,
deleted_points: &SortedSlice<'_, PointOffsetType>,
_new_points: &SortedSlice<'_, PointOffsetType>,
new_points: &SortedSlice<'_, PointOffsetType>,
_hw_counter: &HardwareCounterCell,
) -> OperationResult<()> {
self.storage.live_reload(fs)?;
self.deleted.insert_all(deleted_points);
self.deleted.reload_appended::<S>(fs, new_points)?;
self.next_point_offset = self
.deleted

View File

@@ -235,4 +235,53 @@ mod tests {
}
assert!(!reader.is_deleted_vector(0));
}
/// An appended sparse point can have its slot deleted; the deletion is
/// recorded only in the on-disk flags file, never the id-tracker delta, so
/// `live_reload` must read it back for the appended offset.
#[test]
fn live_reload_picks_up_appended_vector_deletion() {
let dir = Builder::new()
.prefix("ro_sparse_appended_deleted")
.tempdir()
.unwrap();
let hw = HardwareCounterCell::disposable();
fn make(id: usize) -> SparseVector {
SparseVector {
indices: vec![1, 5, 9],
values: vec![id as f32 + 0.1, id as f32 + 0.2, id as f32 + 0.3],
}
}
let mut writer = MmapSparseVectorStorage::open_or_create(dir.path()).unwrap();
writer
.insert_vector(0, VectorRef::from(&make(0)), &hw)
.unwrap();
writer.flusher()().unwrap();
let mut reader =
ReadOnlySparseVectorStorage::<MmapFile>::open(&MmapFs, dir.path(), Populate::No)
.unwrap();
// Append offset 1, then delete it.
writer
.insert_vector(1, VectorRef::from(&make(1)), &hw)
.unwrap();
writer.delete_vector(1).unwrap();
writer.flusher()().unwrap();
let deleted_ids: Vec<PointOffsetType> = vec![];
let new_ids: Vec<PointOffsetType> = vec![1];
reader
.live_reload(
&MmapFs,
&SortedSlice::new(&deleted_ids).unwrap(),
&SortedSlice::new(&new_ids).unwrap(),
&hw,
)
.unwrap();
assert!(reader.is_deleted_vector(1));
}
}

View File

@@ -11,7 +11,9 @@ impl<S: UniversalRead> LiveReload for ReadOnlyTurboVectorStorage<S> {
type Fs = S::Fs;
/// Pick up vectors a writer appended (chunked backend only; the single-file
/// layout is immutable) and patch the in-memory deletion flags.
/// layout is immutable), patch the in-memory deletion flags, and fold in the
/// persisted deletion of each appended offset — a live point may have a
/// deleted vector slot recorded only on disk.
fn live_reload(
&mut self,
fs: &S::Fs,
@@ -23,6 +25,7 @@ impl<S: UniversalRead> LiveReload for ReadOnlyTurboVectorStorage<S> {
storage.live_reload(fs, deleted_points, new_points, hw_counter)?;
}
self.deleted.insert_all(deleted_points);
self.deleted.reload_appended::<S>(fs, new_points)?;
Ok(())
}
}
@@ -30,8 +33,10 @@ impl<S: UniversalRead> LiveReload for ReadOnlyTurboVectorStorage<S> {
impl<S: UniversalRead> LiveReload for ReadOnlyTurboMultiVectorStorage<S> {
type Fs = S::Fs;
/// Pick up multivectors a writer appended (records + offsets) and patch the
/// in-memory deletion flags.
/// Pick up multivectors a writer appended (records + offsets), patch the
/// in-memory deletion flags, and fold in the persisted deletion of each
/// appended offset — a live point may have a deleted vector slot recorded
/// only on disk.
fn live_reload(
&mut self,
fs: &S::Fs,
@@ -44,6 +49,7 @@ impl<S: UniversalRead> LiveReload for ReadOnlyTurboMultiVectorStorage<S> {
self.offsets
.live_reload(fs, deleted_points, new_points, hw_counter)?;
self.deleted.insert_all(deleted_points);
self.deleted.reload_appended::<S>(fs, new_points)?;
Ok(())
}
}