mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-23 11:11:00 -05:00
feat: add open for read only map index (#9223)
* feat: add open methods for map * fix: review comments * feat: detect not-found via error instead of path.exists in map index open Replace the path.exists() pre-check in the read-only appendable map index open path with error-driven detection through ok_not_found(). Generalize OkNotFound over an IsNotFound trait, implemented for io::Error, mmap::Error, UniversalIoError, and GridstoreError so a NotFound surfacing through any layer (including mmap's inner io::Error) maps to Ok(None). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * review: revert internal error conversion --------- Co-authored-by: generall <andrey@vasnetsov.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -469,6 +469,16 @@ pub enum Error {
|
||||
MissingFile(String),
|
||||
}
|
||||
|
||||
impl crate::universal_io::IsNotFound for Error {
|
||||
fn is_not_found(&self) -> bool {
|
||||
match self {
|
||||
Self::Io(err) => err.is_not_found(),
|
||||
Self::MissingFile(_) => true,
|
||||
Self::SizeExact(..) | Self::SizeLess(..) | Self::SizeMultiple(..) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a second mutable reference for type `T` from the given mmap
|
||||
///
|
||||
/// # Warning
|
||||
|
||||
@@ -4,17 +4,29 @@ use std::path::PathBuf;
|
||||
/// `Result` extension for treating `NotFound` as `Ok(None)`
|
||||
pub trait OkNotFound {
|
||||
type Ok;
|
||||
|
||||
type Error;
|
||||
|
||||
/// Treat the not found error as `Ok(None)`
|
||||
fn ok_not_found(self) -> Result<Option<Self::Ok>, Self::Error>;
|
||||
}
|
||||
|
||||
impl<T> OkNotFound for Result<T, UniversalIoError> {
|
||||
type Ok = T;
|
||||
type Error = UniversalIoError;
|
||||
pub trait IsNotFound {
|
||||
fn is_not_found(&self) -> bool;
|
||||
}
|
||||
|
||||
fn ok_not_found(self) -> Result<Option<T>, UniversalIoError> {
|
||||
impl IsNotFound for io::Error {
|
||||
fn is_not_found(&self) -> bool {
|
||||
self.kind() == io::ErrorKind::NotFound
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E: IsNotFound> OkNotFound for Result<T, E> {
|
||||
type Ok = T;
|
||||
|
||||
type Error = E;
|
||||
|
||||
fn ok_not_found(self) -> Result<Option<T>, E> {
|
||||
match self {
|
||||
Ok(t) => Ok(Some(t)),
|
||||
Err(err) if err.is_not_found() => Ok(None),
|
||||
@@ -23,6 +35,26 @@ impl<T> OkNotFound for Result<T, UniversalIoError> {
|
||||
}
|
||||
}
|
||||
|
||||
impl IsNotFound for UniversalIoError {
|
||||
fn is_not_found(&self) -> bool {
|
||||
match self {
|
||||
Self::NotFound { .. } => true,
|
||||
Self::Io(err) | Self::IoUringNotSupported(err) => err.is_not_found(),
|
||||
Self::Mmap(err) => err.is_not_found(),
|
||||
Self::Bincode(_)
|
||||
| Self::BytemuckCast(_)
|
||||
| Self::ZerocopySize(_)
|
||||
| Self::OutOfBounds { .. }
|
||||
| Self::InvalidFileIndex { .. }
|
||||
| Self::Uninitialized { .. }
|
||||
| Self::QueueIsFull
|
||||
| Self::S3(_)
|
||||
| Self::S3Config { .. }
|
||||
| Self::TaskPanicked(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum UniversalIoError {
|
||||
#[error(transparent)]
|
||||
@@ -84,10 +116,6 @@ impl UniversalIoError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_not_found(&self) -> bool {
|
||||
matches!(self, Self::NotFound { .. })
|
||||
}
|
||||
|
||||
pub fn uninitialized(description: impl Into<String>) -> Self {
|
||||
Self::Uninitialized {
|
||||
description: description.into(),
|
||||
|
||||
@@ -11,7 +11,7 @@ mod traits;
|
||||
mod types;
|
||||
mod wrappers;
|
||||
|
||||
pub use self::error::{OkNotFound, UniversalIoError};
|
||||
pub use self::error::{IsNotFound, OkNotFound, UniversalIoError};
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use self::io_uring::{IoUringFile, IoUringFs, IoUringOpenExtra};
|
||||
pub use self::mmap::{MmapFile, MmapFs};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use common::mmap;
|
||||
use common::universal_io::UniversalIoError;
|
||||
use common::universal_io::{IsNotFound, UniversalIoError};
|
||||
|
||||
use crate::tracker::{PageId, PointOffset};
|
||||
|
||||
@@ -38,3 +38,19 @@ impl GridstoreError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IsNotFound for GridstoreError {
|
||||
fn is_not_found(&self) -> bool {
|
||||
match self {
|
||||
GridstoreError::UniversalIo(err) => err.is_not_found(),
|
||||
GridstoreError::Io(err) => err.is_not_found(),
|
||||
GridstoreError::Mmap(err) => err.is_not_found(),
|
||||
GridstoreError::SerdeJson(_)
|
||||
| GridstoreError::ServiceError { .. }
|
||||
| GridstoreError::FlushCancelled
|
||||
| GridstoreError::ValidationError { .. }
|
||||
| GridstoreError::PageNotFound { .. }
|
||||
| GridstoreError::ValueNotFound { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,11 +171,7 @@ where
|
||||
{
|
||||
let config_path = base_path.join(CONFIG_FILENAME);
|
||||
let config: StorageConfig =
|
||||
read_json_via::<Fs, StorageConfig>(fs, &config_path).map_err(|err| {
|
||||
GridstoreError::service_error(format!(
|
||||
"Failed to read config from '{config_path:?}': {err}"
|
||||
))
|
||||
})?;
|
||||
read_json_via::<Fs, StorageConfig>(fs, &config_path).map_err(GridstoreError::from)?;
|
||||
|
||||
let tracker = Tracker::<S>::open(fs, base_path, writeable)?;
|
||||
|
||||
|
||||
@@ -291,6 +291,8 @@ impl<S: UniversalRead> Tracker<S> {
|
||||
fn open_storage(fs: &S::Fs, path: &Path, writeable: bool) -> Result<S> {
|
||||
let storage = match fs.open(path, tracker_open_options(writeable), Default::default()) {
|
||||
Err(UniversalIoError::NotFound { .. }) => {
|
||||
// If config exists and storage doesn't,
|
||||
// it should be treated as inconsistent storage rather than a missing one
|
||||
return Err(GridstoreError::service_error(format!(
|
||||
"Tracker file does not exist: {}",
|
||||
path.display()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use common::counter::hardware_counter::HardwareCounterCell;
|
||||
use common::universal_io::{OkNotFound, UniversalRead};
|
||||
use gridstore::error::GridstoreError;
|
||||
use gridstore::{Blob, GridstoreReader};
|
||||
|
||||
use super::super::MapIndexKey;
|
||||
use super::super::inner::MutableMapIndexInner;
|
||||
use super::ReadOnlyAppendableMapIndex;
|
||||
use crate::common::operation_error::OperationResult;
|
||||
|
||||
impl<N: MapIndexKey + ?Sized, S: UniversalRead> ReadOnlyAppendableMapIndex<N, S>
|
||||
where
|
||||
Vec<<N as MapIndexKey>::Owned>: Blob + Send + Sync,
|
||||
{
|
||||
/// Open the appendable (Gridstore) map index read-only, threading every
|
||||
/// file open through the filesystem handle `fs`.
|
||||
///
|
||||
/// Opens a [`GridstoreReader`] over the generic filesystem object, then
|
||||
/// rebuilds the in-memory state by feeding every stored value through
|
||||
/// [`MutableMapIndexInner::ingest`] — the exact reconstruction the writable
|
||||
/// [`MutableMapIndex::open_gridstore`][1] performs over a writable
|
||||
/// [`gridstore::Gridstore`]. No write path; the reader is retained for
|
||||
/// later `files` / `clear_cache` use.
|
||||
///
|
||||
/// Returns [`Ok(None)`] when the on-disk directory doesn't exist, matching
|
||||
/// the `create_if_missing == false` branch of the writable counterpart —
|
||||
/// the read path never creates.
|
||||
///
|
||||
/// [1]: super::super::MutableMapIndex::open_gridstore
|
||||
pub fn open(fs: &S::Fs, path: PathBuf) -> OperationResult<Option<Self>> {
|
||||
let Some(storage) =
|
||||
GridstoreReader::<Vec<<N as MapIndexKey>::Owned>, S>::open(fs, path).ok_not_found()?
|
||||
else {
|
||||
// Files don't exist, cannot load
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut inner = MutableMapIndexInner::<N>::empty();
|
||||
let hw_counter = HardwareCounterCell::disposable();
|
||||
storage.iter::<_, GridstoreError>(
|
||||
storage.max_point_offset(),
|
||||
|idx, values: Vec<_>| {
|
||||
for value in values {
|
||||
inner.ingest(idx, value);
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
hw_counter.ref_payload_index_io_write_counter(),
|
||||
)?;
|
||||
|
||||
Ok(Some(Self { inner, storage }))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use gridstore::{Blob, GridstoreReader};
|
||||
use super::super::MapIndexKey;
|
||||
use super::inner::MutableMapIndexInner;
|
||||
|
||||
mod lifecycle;
|
||||
mod read_ops;
|
||||
|
||||
/// Read-only counterpart to [`super::MutableMapIndex`].
|
||||
@@ -14,15 +15,18 @@ mod read_ops;
|
||||
/// [`super::super::read_ops::MapIndexRead`] by forwarding to the inner;
|
||||
/// provides no mutation surface.
|
||||
///
|
||||
/// Loading / lifecycle (constructor, `files`, `populate`, `clear_cache`, …)
|
||||
/// will be added in a follow-up.
|
||||
/// Constructed via [`Self::open`] (see [`lifecycle`]); the parent
|
||||
/// [`super::super::read_only::ReadOnlyMapIndex`] dispatches into this type
|
||||
/// through [`super::super::read_only::ReadOnlyMapIndex::open_appendable`].
|
||||
pub struct ReadOnlyAppendableMapIndex<N: MapIndexKey + ?Sized, S: UniversalRead>
|
||||
where
|
||||
Vec<<N as MapIndexKey>::Owned>: Blob + Send + Sync,
|
||||
{
|
||||
pub(super) inner: MutableMapIndexInner<N>,
|
||||
// Read once the lifecycle layer lands; until then the field is held to
|
||||
// pin the on-disk layout of the type.
|
||||
/// Backing Gridstore reader, populated by [`Self::open`]. Held to keep the
|
||||
/// storage mapped; the `files` / `populate` / `clear_cache` wiring that
|
||||
/// reads it lands with the parent dispatcher (it isn't part of the
|
||||
/// [`MapIndexRead`](super::super::read_ops::MapIndexRead) surface).
|
||||
#[allow(dead_code)]
|
||||
pub(super) storage: GridstoreReader<Vec<<N as MapIndexKey>::Owned>, S>,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use common::bitvec::BitSlice;
|
||||
use common::universal_io::UniversalRead;
|
||||
use gridstore::Blob;
|
||||
|
||||
use super::super::MapIndexKey;
|
||||
use super::super::mutable_map_index::read_only::ReadOnlyAppendableMapIndex;
|
||||
use super::super::universal_map_index::UniversalMapIndex;
|
||||
use super::ReadOnlyMapIndex;
|
||||
use crate::common::operation_error::OperationResult;
|
||||
use crate::index::payload_config::IndexMutability;
|
||||
|
||||
impl<N: MapIndexKey + ?Sized, S: UniversalRead> ReadOnlyMapIndex<N, S>
|
||||
where
|
||||
Vec<<N as MapIndexKey>::Owned>: Blob + Send + Sync,
|
||||
{
|
||||
/// Read-only mirror of [`MapIndex::new_gridstore`][1]: open the appendable
|
||||
/// (Gridstore-backed) map index read-only, threading every file open
|
||||
/// through the filesystem handle `fs`.
|
||||
///
|
||||
/// Thin dispatcher over [`ReadOnlyAppendableMapIndex::open`] — wraps the
|
||||
/// leaf in [`Self::Appendable`] so callers can hold the parent enum
|
||||
/// uniformly. No `create_if_missing`: the read path never creates;
|
||||
/// [`Ok(None)`] propagates from the leaf when the on-disk directory
|
||||
/// doesn't exist.
|
||||
///
|
||||
/// [1]: super::super::MapIndex::new_gridstore
|
||||
pub fn open_appendable(fs: &S::Fs, dir: PathBuf) -> OperationResult<Option<Self>> {
|
||||
Ok(ReadOnlyAppendableMapIndex::open(fs, dir)?.map(Self::Appendable))
|
||||
}
|
||||
|
||||
/// Read-only mirror of [`MapIndex::new_mmap`][1]: open the immutable
|
||||
/// (mmap-format) map index read-only through [`UniversalMapIndex::open`],
|
||||
/// threading every file open through the filesystem handle `fs`.
|
||||
///
|
||||
/// The writable enum has two mmap variants (`Immutable` for in-RAM with
|
||||
/// mmap backing, `Mmap` for on-disk lazy); the read-only side collapses
|
||||
/// to a single [`Self::Immutable`] arm because `is_on_disk` (→ populate)
|
||||
/// already covers the lazy/eager distinction inside [`UniversalMapIndex`].
|
||||
/// `Ok(None)` propagates from the leaf when the on-disk index doesn't
|
||||
/// exist.
|
||||
///
|
||||
/// [1]: super::super::MapIndex::new_mmap
|
||||
pub fn open_immutable(
|
||||
fs: &S::Fs,
|
||||
path: &Path,
|
||||
is_on_disk: bool,
|
||||
deleted_points: &BitSlice,
|
||||
) -> OperationResult<Option<Self>> {
|
||||
let effective_is_on_disk =
|
||||
is_on_disk || common::low_memory::low_memory_mode().prefer_disk();
|
||||
|
||||
Ok(
|
||||
UniversalMapIndex::open(fs, path, effective_is_on_disk, deleted_points)?
|
||||
.map(Self::Immutable),
|
||||
)
|
||||
}
|
||||
|
||||
/// Reports the on-disk format's mutability, mirroring
|
||||
/// [`MapIndex::get_mutability_type`][1].
|
||||
///
|
||||
/// The read-only enum has two variants where the writable side has three:
|
||||
/// `Appendable` corresponds to the writable `Mutable` arm, `Immutable`
|
||||
/// covers both writable `Immutable` (in-RAM with mmap backing) and
|
||||
/// writable `Mmap` (on-disk lazy) — both already report
|
||||
/// [`IndexMutability::Immutable`] on the writable side, so the read-only
|
||||
/// label matches even after the collapse.
|
||||
///
|
||||
/// [1]: super::super::MapIndex::get_mutability_type
|
||||
pub fn get_mutability_type(&self) -> IndexMutability {
|
||||
match self {
|
||||
Self::Appendable(_) => IndexMutability::Mutable,
|
||||
Self::Immutable(_) => IndexMutability::Immutable,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,27 @@ use crate::index::field_index::map_index::MapIndexKey;
|
||||
use crate::index::field_index::map_index::mutable_map_index::read_only::ReadOnlyAppendableMapIndex;
|
||||
use crate::index::field_index::map_index::universal_map_index::UniversalMapIndex;
|
||||
|
||||
mod lifecycle;
|
||||
mod read_ops;
|
||||
|
||||
/// Read-only counterpart of [`MapIndex`][1], parameterised by a
|
||||
/// [`UniversalRead`] storage.
|
||||
///
|
||||
/// Dispatches the [`MapIndexRead`][2] / [`PayloadFieldIndexRead`][3] read
|
||||
/// surface to one of two backing formats:
|
||||
/// - [`Appendable`][Self::Appendable] — the in-RAM appendable index loaded
|
||||
/// from the gridstore (write) format;
|
||||
/// - [`Immutable`][Self::Immutable] — reads directly from the immutable mmap
|
||||
/// format.
|
||||
///
|
||||
/// Constructed via [`Self::open_appendable`] and [`Self::open_immutable`]
|
||||
/// (both generic over `S`); the upstream [`ReadOnlyFieldIndex`][4] wiring
|
||||
/// follows in a separate PR.
|
||||
///
|
||||
/// [1]: super::MapIndex
|
||||
/// [2]: super::read_ops::MapIndexRead
|
||||
/// [3]: crate::index::field_index::PayloadFieldIndexRead
|
||||
/// [4]: crate::index::field_index::field_index_base::read_only::ReadOnlyFieldIndex
|
||||
pub enum ReadOnlyMapIndex<N: MapIndexKey + ?Sized, S: UniversalRead>
|
||||
where
|
||||
Vec<<N as MapIndexKey>::Owned>: Blob + Send + Sync,
|
||||
@@ -16,3 +35,87 @@ where
|
||||
/// Directly reads from storage in immutable format
|
||||
Immutable(UniversalMapIndex<N, S>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use common::counter::hardware_counter::HardwareCounterCell;
|
||||
use common::types::PointOffsetType;
|
||||
use common::universal_io::{MmapFile, ReadOnly, UniversalRead, UniversalReadFileOps};
|
||||
use itertools::Itertools as _;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::super::MapIndex;
|
||||
use super::ReadOnlyMapIndex;
|
||||
use crate::index::field_index::{FieldIndexBuilderTrait, PayloadFieldIndexRead};
|
||||
use crate::json_path::JsonPath;
|
||||
use crate::types::{FieldCondition, Match};
|
||||
|
||||
/// Build an appendable (Gridstore) string map index on disk, then open it
|
||||
/// via the parent enum's [`ReadOnlyMapIndex::open_appendable`] over the
|
||||
/// write-enforced `ReadOnly<MmapFile>` backend. Verifies the dispatcher
|
||||
/// wraps into [`ReadOnlyMapIndex::Appendable`] and that the trait
|
||||
/// forwarders deliver the same hit set as the values inserted.
|
||||
#[test]
|
||||
fn parent_open_appendable_round_trip() {
|
||||
let dir = TempDir::with_prefix("ro_map_parent_gridstore").unwrap();
|
||||
let hw_counter = HardwareCounterCell::new();
|
||||
|
||||
// Build via the writable gridstore builder (matches the existing map
|
||||
// tests' `IndexType::MutableGridstore` path).
|
||||
{
|
||||
let mut builder = MapIndex::<str>::builder_gridstore(dir.path().to_path_buf());
|
||||
builder.init().unwrap();
|
||||
let entries: &[(PointOffsetType, &[&str])] = &[
|
||||
(0, &["red", "green"]),
|
||||
(1, &["green"]),
|
||||
(2, &["blue", "red"]),
|
||||
];
|
||||
for (idx, values) in entries {
|
||||
let values: Vec<Value> = values.iter().map(|v| Value::from(*v)).collect();
|
||||
let values_ref: Vec<_> = values.iter().collect();
|
||||
builder.add_point(*idx, &values_ref, &hw_counter).unwrap();
|
||||
}
|
||||
builder.finalize().unwrap();
|
||||
}
|
||||
|
||||
// `S = ReadOnly<MmapFile>` → `S::Fs = ReadOnlyFs<MmapFs>`, named via the
|
||||
// associated-type projection since the wrapper type isn't exported.
|
||||
// The read-only filesystem context is `Default`.
|
||||
type RoFs = <ReadOnly<MmapFile> as UniversalRead>::Fs;
|
||||
let fs = RoFs::from_context(Default::default()).unwrap();
|
||||
let index: ReadOnlyMapIndex<str, ReadOnly<MmapFile>> =
|
||||
ReadOnlyMapIndex::open_appendable(&fs, dir.path().to_path_buf())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Dispatcher wraps the leaf into the right variant.
|
||||
assert!(matches!(index, ReadOnlyMapIndex::Appendable(_)));
|
||||
|
||||
// Trait dispatch on the parent enum forwards into the leaf:
|
||||
// every point with at least one value is counted, and `red` matches
|
||||
// the two that contain it while `blue` matches only the third.
|
||||
assert_eq!(index.count_indexed_points(), 3);
|
||||
|
||||
let key = JsonPath::new("color");
|
||||
let red = FieldCondition::new_match(key.clone(), Match::from("red".to_string()));
|
||||
let blue = FieldCondition::new_match(key, Match::from("blue".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
index
|
||||
.filter(&red, &hw_counter)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.collect_vec(),
|
||||
vec![0, 2],
|
||||
);
|
||||
assert_eq!(
|
||||
index
|
||||
.filter(&blue, &hw_counter)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.collect_vec(),
|
||||
vec![2],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user