Files
qdrant/lib/segment/tests/append_only_storages.rs
Andrey VasnetsovandClaude Opus 5.5 be8e20a613 Compact versions file for the disk id tracker in serverless mode (#10803)
* Compact versions file for the disk id tracker in serverless mode

In serverless-compatible deployments the disk-resident id tracker writes its
versions to `id_tracker.compact_versions` instead of the flat
`id_tracker.versions`: zigzag deltas, byte-shuffled, zstd-compressed. Readers
load it whole on open, so search result post-processing no longer issues a
round of per-point version reads against object storage.

The writable tracker keeps versions resident as before and rewrites the file
whole on flush, only after a live point's version changed: lookups skip
deleted points, so a deletion never dirties it. Both formats stay readable;
the flag only gates creation.

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

* Encapsulate the versions file format of the disk id trackers

The flat and compact versions formats are handled by `VersionsFile` and
`ReadOnlyVersions` themselves, so the trackers no longer match on them:
opening, creating, recording a change, flushing, cache clearing and the file
listing all go through the enums. The format of a newly created file is a
`VersionsFormat` rather than a boolean.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:26:59 +02:00

338 lines
12 KiB
Rust

//! End-to-end check of the `append_only_storages` feature flag: a segment
//! built with it holds Logstore-mode storages, and the ordinary write paths —
//! upserts, payload updates, multi-step same-operation writes, deletes, index
//! builds — run against them.
//!
//! A standalone test binary, because feature flags are process-global: this
//! process runs with `serverless_compatible` on from the start, the way a
//! serverless deployment would.
use std::path::Path;
use std::sync::atomic::AtomicBool;
use common::counter::hardware_counter::HardwareCounterCell;
use common::flags::{FeatureFlags, init_feature_flags};
use common::types::DeferredBehavior;
use segment::data_types::vectors::{VectorRef, only_default_vector};
use segment::entry::entry_point::{
NonAppendableSegmentEntry as _, ReadSegmentEntry as _, SegmentEntry as _,
StorageSegmentEntry as _,
};
use segment::payload_json;
use segment::segment_constructor::load_segment;
use segment::segment_constructor::simple_segment_constructor::build_simple_segment;
use segment::types::{Distance, PayloadFieldSchema, PayloadSchemaType};
use segment::vector_storage::VectorStorage as _;
use segment::vector_storage::sparse::mmap_sparse_vector_storage::MmapSparseVectorStorage;
use sparse::common::sparse_vector::SparseVector;
use tempfile::Builder;
use uuid::Uuid;
const DIM: usize = 4;
/// The persisted mode of the Blobstore at `dir`, from its own config file.
fn storage_mode(dir: &Path) -> String {
let config: serde_json::Value =
serde_json::from_str(&fs_err::read_to_string(dir.join("config.json")).unwrap()).unwrap();
config["mode"].as_str().unwrap_or("mutable").to_string()
}
#[test]
fn append_only_storages_serve_the_ordinary_write_paths() {
// The field is private, set through the same route a config file takes.
let flags: FeatureFlags = serde_json::from_str(r#"{ "serverless_compatible": true }"#).unwrap();
init_feature_flags(flags);
assert!(common::flags::feature_flags().append_only_storages);
let dir = Builder::new()
.prefix("append_only_storages")
.tempdir()
.unwrap();
let hw_counter = HardwareCounterCell::new();
// The segment lives in a uuid subdirectory of `dir`.
let mut segment = build_simple_segment(dir.path(), DIM, Distance::Dot).unwrap();
let segment_path = segment.data_path();
assert!(segment.append_only_mutations, "forced by the storages flag");
// The payload storage was created in the append-only mode.
assert_eq!(
storage_mode(&segment_path.join("payload_storage")),
"append_only"
);
// Inserts, and an update of an existing point.
for id in 1..=5u64 {
let vector: Vec<f32> = vec![id as f32; DIM];
segment
.upsert_point(1, id.into(), only_default_vector(&vector), &hw_counter)
.unwrap();
}
segment
.set_full_payload(
2,
3.into(),
&payload_json! { "kind": "updated" },
&hw_counter,
)
.unwrap();
// A multi-step write within one operation: a slot per step.
segment
.upsert_point(3, 6.into(), only_default_vector(&[6.0; DIM]), &hw_counter)
.unwrap();
segment
.set_full_payload(
3,
6.into(),
&payload_json! { "kind": "multi-step" },
&hw_counter,
)
.unwrap();
// A delete is a tombstone.
segment.delete_point(4, 5.into(), &hw_counter).unwrap();
// The index storage comes out append-only too.
let key = "kind".parse().unwrap();
segment
.create_field_index(
5,
&key,
Some(&PayloadFieldSchema::FieldType(PayloadSchemaType::Keyword)),
&hw_counter,
)
.unwrap();
// The index directory name carries a hash prefix, locate it by suffix.
let index_dir = fs_err::read_dir(segment_path.join("payload_index"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| {
path.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with("-kind-map"))
})
.expect("keyword index directory exists");
assert_eq!(storage_mode(&index_dir), "append_only");
// An integer index over a field only some points hold: indexing points
// without the field, then giving one the field, drives the numeric index's
// remove-before-add and empty-value paths against the append-only storage.
let num_key = "num".parse().unwrap();
segment
.create_field_index(
6,
&num_key,
Some(&PayloadFieldSchema::FieldType(PayloadSchemaType::Integer)),
&hw_counter,
)
.unwrap();
segment
.set_payload(7, 4.into(), &payload_json! { "num": 7 }, &None, &hw_counter)
.unwrap();
segment.flush(true).unwrap();
drop(segment);
// Everything survives a reload through the ordinary loader.
let mut segment = load_segment(
&segment_path,
Uuid::nil(),
None,
&AtomicBool::new(false),
false,
)
.unwrap();
assert!(segment.append_only_mutations);
// The repair every shard loader runs; deleting the leftovers it finds would
// fail here, leaving the segment permanently unopenable.
segment
.check_consistency_and_repair()
.expect("append-only leftovers must not be reported as inconsistencies");
assert_eq!(segment.available_point_count(), 5);
let payload = segment.payload(3.into(), &hw_counter).unwrap();
assert_eq!(payload, payload_json! { "kind": "updated" });
let payload = segment.payload(6.into(), &hw_counter).unwrap();
assert_eq!(payload, payload_json! { "kind": "multi-step" });
assert!(!segment.has_point(5.into(), DeferredBehavior::WithDeferred));
}
/// The sparse vector storage created under the flag is append-only, deleting
/// where nothing was stored is a no-op, and deleting a stored vector fails.
#[test]
fn sparse_storage_is_append_only() {
let flags: FeatureFlags = serde_json::from_str(r#"{ "serverless_compatible": true }"#).unwrap();
init_feature_flags(flags);
let dir = Builder::new()
.prefix("append_only_sparse")
.tempdir()
.unwrap();
let hw_counter = HardwareCounterCell::new();
let mut storage = MmapSparseVectorStorage::open_or_create(dir.path()).unwrap();
assert_eq!(storage_mode(&dir.path().join("store")), "append_only");
let vector = SparseVector::new(vec![1, 3], vec![1.0, 2.0]).unwrap();
storage
.insert_vector(0, VectorRef::from(&vector), &hw_counter)
.unwrap();
// Nothing was ever stored at key 5.
storage.delete_vector(5).unwrap();
// Key 0 holds a vector; an append-only storage cannot remove it.
assert!(storage.delete_vector(0).is_err());
}
/// Tracker files under `dir` with the given name, at any depth.
fn count_files(dir: &Path, name: &str) -> usize {
fs_err::read_dir(dir)
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
if entry.file_type().unwrap().is_dir() {
count_files(&entry.path(), name)
} else {
usize::from(entry.file_name() == name)
}
})
.sum()
}
/// The optimizer makes the append-only payload storage of a segment it builds non-appendable
/// immutable, which compacts its mapping file. Sparse vector storages, and every storage of an
/// appendable segment, keep the appendable layout.
#[test]
fn optimizer_makes_non_appendable_payload_storage_immutable() {
use std::collections::HashMap;
use segment::common::memory_usage::MemoryReporter as _;
use segment::data_types::named_vectors::NamedVectors;
use segment::data_types::vectors::DEFAULT_VECTOR_NAME;
use segment::index::sparse_index::sparse_index_config::{SparseIndexConfig, SparseIndexType};
use segment::segment_constructor::build_segment;
use segment::segment_constructor::segment_builder::SegmentBuilder;
use segment::types::{
HnswGlobalConfig, Indexes, SegmentConfig, SparseVectorDataConfig, SparseVectorStorageType,
VectorDataConfig, VectorStorageType,
};
let flags: FeatureFlags = serde_json::from_str(r#"{ "serverless_compatible": true }"#).unwrap();
init_feature_flags(flags);
assert!(common::flags::feature_flags().compact_logstore_tracker);
let dir = Builder::new().prefix("make_immutable").tempdir().unwrap();
let temp_dir = Builder::new()
.prefix("make_immutable_temp")
.tempdir()
.unwrap();
let hw_counter = HardwareCounterCell::new();
let stopped = AtomicBool::new(false);
let config = |storage_type, sparse_index_type| SegmentConfig {
vector_data: HashMap::from([(
DEFAULT_VECTOR_NAME.to_owned(),
VectorDataConfig {
size: DIM,
distance: Distance::Dot,
storage_type,
index: Indexes::Plain {},
quantization_config: None,
multivector_config: None,
datatype: None,
},
)]),
sparse_vector_data: HashMap::from([(
"sparse".to_owned(),
SparseVectorDataConfig {
index: SparseIndexConfig::new(None, sparse_index_type, None, None),
storage_type: SparseVectorStorageType::default(),
modifier: None,
},
)]),
payload_storage_type: Default::default(),
id_tracker_memory: None,
};
let appendable = config(VectorStorageType::default(), SparseIndexType::MutableRam);
let non_appendable = config(VectorStorageType::Mmap, SparseIndexType::Mmap);
assert!(appendable.is_appendable());
assert!(!non_appendable.is_appendable());
let (mut source, _token) = build_segment(dir.path(), &appendable, None, true).unwrap();
for id in 1..=5u64 {
let dense = vec![id as f32; DIM];
let sparse = SparseVector::new(vec![id as u32], vec![id as f32]).unwrap();
let mut vectors = NamedVectors::from_ref(DEFAULT_VECTOR_NAME, VectorRef::from(&dense));
vectors.insert_ref("sparse", VectorRef::Sparse(&sparse));
source
.upsert_point(1, id.into(), vectors, &hw_counter)
.unwrap();
source
.set_full_payload(2, id.into(), &payload_json! { "id": id }, &hw_counter)
.unwrap();
}
let build = |config: &SegmentConfig| {
let mut builder = SegmentBuilder::new(
temp_dir.path(),
config,
&HnswGlobalConfig::default(),
common::flags::feature_flags(),
)
.unwrap();
builder.update(&[&source], &stopped, &hw_counter).unwrap();
builder.build_for_test(dir.path())
};
let built = build(&non_appendable);
let built_path = built.data_path();
// The compacted mappings are reported as RAM, beyond the storage files
let payload_ram = built
.payload_storage
.borrow()
.memory_usage()
.extra_ram_bytes;
assert!(payload_ram.unwrap() >= 5 * 16, "{payload_ram:?}");
assert_eq!(count_files(&built_path, "compacted_tracker.dat"), 1);
assert_eq!(count_files(&built_path, "log_tracker.dat"), 1);
assert!(
built_path
.join("payload_storage/compacted_tracker.dat")
.exists()
);
assert!(built_path.join("id_tracker.compact_versions").exists());
assert!(!built_path.join("id_tracker.versions").exists());
drop(built);
let reloaded = load_segment(
&built_path,
Uuid::nil(),
None,
&AtomicBool::new(false),
false,
)
.unwrap();
assert_eq!(reloaded.available_point_count(), 5);
for id in 1..=5u64 {
assert_eq!(
reloaded.payload(id.into(), &hw_counter).unwrap(),
payload_json! { "id": id },
);
let sparse = reloaded.vector("sparse", id.into(), &hw_counter).unwrap();
assert!(sparse.is_some(), "sparse vector of point {id}");
assert_eq!(reloaded.point_version(id.into()), Some(2));
}
let built = build(&appendable);
let payload_ram = built
.payload_storage
.borrow()
.memory_usage()
.extra_ram_bytes;
assert_eq!(payload_ram, Some(0));
assert_eq!(count_files(&built.data_path(), "compacted_tracker.dat"), 0);
assert_eq!(count_files(&built.data_path(), "log_tracker.dat"), 2);
}