541 lines
18 KiB
Rust
541 lines
18 KiB
Rust
//! Integration tests for Memvid mutation operations.
|
|
//! Tests: put, put_bytes_with_options, update, delete
|
|
|
|
use memvid_core::{
|
|
EmbeddingIdentitySummary, MEMVID_EMBEDDING_MODEL_KEY, MEMVID_EMBEDDING_PROVIDER_KEY, Memvid,
|
|
MemvidError, PutOptions, TimelineQuery, constants::HEADER_SIZE, io::header::HeaderCodec,
|
|
};
|
|
use std::fs::File;
|
|
use std::io::Read;
|
|
use std::num::NonZeroU64;
|
|
use std::path::Path;
|
|
use tempfile::TempDir;
|
|
|
|
fn read_wal_size(path: &Path) -> u64 {
|
|
let mut header_bytes = [0u8; HEADER_SIZE];
|
|
File::open(path)
|
|
.unwrap()
|
|
.read_exact(&mut header_bytes)
|
|
.unwrap();
|
|
HeaderCodec::decode(&header_bytes).unwrap().wal_size
|
|
}
|
|
|
|
/// Test basic put operation with bytes.
|
|
#[test]
|
|
fn put_bytes_basic() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://test".to_string()),
|
|
title: Some("Test Document".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let _frame_id = mem.put_bytes_with_options(b"Hello, World!", opts).unwrap();
|
|
mem.commit().unwrap();
|
|
|
|
// Verify frame was created
|
|
// Verify frame was created (FrameId is u64 so >= 0 is implied)
|
|
// assert!(frame_id >= 0);
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
assert_eq!(mem.stats().unwrap().frame_count, 1, "Should have 1 frame");
|
|
}
|
|
|
|
/// Test put with all options.
|
|
#[test]
|
|
fn put_bytes_with_all_options() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://docs/report.txt".to_string()),
|
|
title: Some("Annual Report".to_string()),
|
|
search_text: Some("Financial summary for 2024".to_string()),
|
|
tags: vec!["finance".to_string(), "annual".to_string()],
|
|
labels: vec!["Important".to_string()],
|
|
timestamp: Some(1700000000),
|
|
..Default::default()
|
|
};
|
|
|
|
mem.put_bytes_with_options(b"Report content here", opts)
|
|
.unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
// Verify frame was stored
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
let stats = mem.stats().unwrap();
|
|
assert_eq!(stats.frame_count, 1, "Should have 1 frame");
|
|
|
|
// Verify frame metadata by fetching via URI
|
|
let frame = mem.frame_by_uri("mv2://docs/report.txt").unwrap();
|
|
assert_eq!(frame.uri.as_deref(), Some("mv2://docs/report.txt"));
|
|
assert_eq!(frame.title.as_deref(), Some("Annual Report"));
|
|
assert!(frame.tags.contains(&"finance".to_string()));
|
|
assert!(frame.labels.contains(&"Important".to_string()));
|
|
assert_eq!(frame.timestamp, 1700000000);
|
|
}
|
|
|
|
/// Test multiple puts create frames.
|
|
#[test]
|
|
fn put_multiple_creates_frames() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
for i in 0..5 {
|
|
let opts = PutOptions {
|
|
uri: Some(format!("mv2://doc{}", i)),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(format!("Content {}", i).as_bytes(), opts)
|
|
.unwrap();
|
|
}
|
|
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
assert_eq!(mem.stats().unwrap().frame_count, 5, "Should have 5 frames");
|
|
}
|
|
|
|
/// Test frame_by_uri returns correct data.
|
|
#[test]
|
|
fn frame_by_uri_returns_data() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://test".to_string()),
|
|
title: Some("Test".to_string()),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(b"Test content", opts).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
let frame = mem.frame_by_uri("mv2://test").unwrap();
|
|
|
|
assert_eq!(frame.uri.as_deref(), Some("mv2://test"));
|
|
assert_eq!(frame.title.as_deref(), Some("Test"));
|
|
}
|
|
|
|
/// Test update_frame modifies frame metadata.
|
|
#[test]
|
|
fn update_frame_modifies_metadata() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
// Create with initial data
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://test".to_string()),
|
|
title: Some("Original Title".to_string()),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(b"Content", opts).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
// Update frame - look up by URI to get current frame_id
|
|
{
|
|
let mut mem = Memvid::open(&path).unwrap();
|
|
// Get the frame by URI to find its actual ID
|
|
let frame = mem.frame_by_uri("mv2://test").unwrap();
|
|
let frame_id = frame.id;
|
|
|
|
let update_opts = PutOptions {
|
|
title: Some("Updated Title".to_string()),
|
|
..Default::default()
|
|
};
|
|
// Pass new payload to trigger actual update
|
|
mem.update_frame(frame_id, Some(b"New Content".to_vec()), update_opts, None)
|
|
.unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
// Verify update
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
let frame = mem.frame_by_uri("mv2://test").unwrap();
|
|
|
|
assert_eq!(frame.title.as_deref(), Some("Updated Title"));
|
|
}
|
|
|
|
/// Test delete_frame marks frame as deleted.
|
|
#[test]
|
|
fn delete_frame_marks_deleted() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
// Create with content
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://test".to_string()),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(b"Content", opts).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
// Delete frame - look up by URI to get current frame_id
|
|
{
|
|
let mut mem = Memvid::open(&path).unwrap();
|
|
// Get the frame by URI to find its actual ID
|
|
let frame = mem.frame_by_uri("mv2://test").unwrap();
|
|
let frame_id = frame.id;
|
|
|
|
mem.delete_frame(frame_id).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
// Verify deletion - need to iterate or check stats since URI lookup may fail for deleted
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
// A deleted frame should still exist but with Deleted status
|
|
// The URI lookup might fail, so check stats instead
|
|
let stats = mem.stats().unwrap();
|
|
// After deletion, frame_count may still be 1 but the frame is marked deleted
|
|
// Or it may be 0 depending on implementation
|
|
// Both are valid - the key is no panic occurred
|
|
assert!(
|
|
stats.frame_count == 0 || stats.frame_count == 1,
|
|
"Frame count should be 0 or 1 after delete"
|
|
);
|
|
}
|
|
|
|
/// Test URI uniqueness is enforced.
|
|
#[test]
|
|
fn uri_uniqueness() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
// First put succeeds
|
|
let opts1 = PutOptions {
|
|
uri: Some("mv2://unique".to_string()),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(b"First", opts1).unwrap();
|
|
|
|
// Second put with same URI should fail or replace
|
|
let opts2 = PutOptions {
|
|
uri: Some("mv2://unique".to_string()),
|
|
..Default::default()
|
|
};
|
|
// The behavior depends on implementation - this tests that it doesn't panic
|
|
let result = mem.put_bytes_with_options(b"Second", opts2);
|
|
|
|
// Either it succeeds (replacing) or returns an error (enforcing uniqueness)
|
|
// Both are valid behaviors - the test ensures no panic
|
|
if result.is_ok() {
|
|
mem.commit().unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn put_rejects_mixed_embedding_dimensions() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
mem.enable_vec().unwrap();
|
|
|
|
mem.put_with_embedding(b"first", vec![0.0f32; 384]).unwrap();
|
|
let err = mem
|
|
.put_with_embedding(b"second", vec![0.0f32; 1536])
|
|
.unwrap_err();
|
|
match err {
|
|
MemvidError::VecDimensionMismatch { expected, actual } => {
|
|
assert_eq!(expected, 384);
|
|
assert_eq!(actual, 1536);
|
|
}
|
|
other => panic!("expected VecDimensionMismatch, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn embedding_identity_summary_unknown_when_missing() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
mem.put_bytes_with_options(b"hello", PutOptions::default())
|
|
.unwrap();
|
|
mem.commit().unwrap();
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
assert_eq!(
|
|
mem.embedding_identity_summary(1_000),
|
|
EmbeddingIdentitySummary::Unknown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn embedding_identity_summary_single() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
let mut options = PutOptions::default();
|
|
options.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_PROVIDER_KEY.to_string(),
|
|
"openai".to_string(),
|
|
);
|
|
options.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_MODEL_KEY.to_string(),
|
|
"text-embedding-3-small".to_string(),
|
|
);
|
|
mem.put_bytes_with_options(b"hello", options).unwrap();
|
|
mem.commit().unwrap();
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
match mem.embedding_identity_summary(1_000) {
|
|
EmbeddingIdentitySummary::Single(identity) => {
|
|
assert_eq!(identity.provider.as_deref(), Some("openai"));
|
|
assert_eq!(identity.model.as_deref(), Some("text-embedding-3-small"));
|
|
}
|
|
other => panic!("expected Single identity, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn embedding_identity_summary_mixed() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
let mut options_a = PutOptions::default();
|
|
options_a.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_PROVIDER_KEY.to_string(),
|
|
"fastembed".to_string(),
|
|
);
|
|
options_a.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_MODEL_KEY.to_string(),
|
|
"BAAI/bge-small-en-v1.5".to_string(),
|
|
);
|
|
mem.put_bytes_with_options(b"a", options_a).unwrap();
|
|
|
|
let mut options_b = PutOptions::default();
|
|
options_b.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_PROVIDER_KEY.to_string(),
|
|
"openai".to_string(),
|
|
);
|
|
options_b.extra_metadata.insert(
|
|
MEMVID_EMBEDDING_MODEL_KEY.to_string(),
|
|
"text-embedding-3-small".to_string(),
|
|
);
|
|
mem.put_bytes_with_options(b"b", options_b).unwrap();
|
|
|
|
mem.commit().unwrap();
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
match mem.embedding_identity_summary(1_000) {
|
|
EmbeddingIdentitySummary::Mixed(identities) => {
|
|
assert_eq!(identities.len(), 2);
|
|
let models: Vec<_> = identities
|
|
.iter()
|
|
.filter_map(|entry| entry.identity.model.as_deref())
|
|
.collect();
|
|
assert!(models.contains(&"BAAI/bge-small-en-v1.5"));
|
|
assert!(models.contains(&"text-embedding-3-small"));
|
|
}
|
|
other => panic!("expected Mixed identity, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// Test put with empty content.
|
|
#[test]
|
|
fn put_empty_content() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://empty".to_string()),
|
|
title: Some("Empty Document".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
mem.put_bytes_with_options(b"", opts).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
let frame = mem.frame_by_uri("mv2://empty").unwrap();
|
|
|
|
assert_eq!(frame.title.as_deref(), Some("Empty Document"));
|
|
}
|
|
|
|
/// Test put with moderately large content (50KB to avoid WAL limits).
|
|
#[test]
|
|
fn put_moderately_large_content() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
// Create 50KB of content (default new file gets 1MB WAL region)
|
|
let large_content = vec![b'x'; 50 * 1024];
|
|
|
|
let opts = PutOptions {
|
|
uri: Some("mv2://large".to_string()),
|
|
title: Some("Large Document".to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
mem.put_bytes_with_options(&large_content, opts).unwrap();
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
let mem = Memvid::open_read_only(&path).unwrap();
|
|
let frame = mem.frame_by_uri("mv2://large").unwrap();
|
|
|
|
assert_eq!(frame.title.as_deref(), Some("Large Document"));
|
|
// Frame was stored successfully - payload_length may be 0 if stored as blob
|
|
// The important thing is no panic/error occurred
|
|
}
|
|
|
|
/// Test timeline iteration.
|
|
#[test]
|
|
fn timeline_iteration() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("test.mv2");
|
|
|
|
{
|
|
let mut mem = Memvid::create(&path).unwrap();
|
|
|
|
// Add frames with different timestamps
|
|
for (i, ts) in [1700000000i64, 1700001000, 1700002000].iter().enumerate() {
|
|
let opts = PutOptions {
|
|
uri: Some(format!("mv2://doc{}", i)),
|
|
timestamp: Some(*ts),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(format!("Content {}", i).as_bytes(), opts)
|
|
.unwrap();
|
|
}
|
|
mem.commit().unwrap();
|
|
}
|
|
|
|
let mut mem = Memvid::open_read_only(&path).unwrap();
|
|
|
|
// Get timeline entries using TimelineQuery
|
|
let query = TimelineQuery::builder()
|
|
.limit(NonZeroU64::new(10).unwrap())
|
|
.build();
|
|
let entries = mem.timeline(query).unwrap();
|
|
|
|
assert_eq!(entries.len(), 3, "Should have 3 timeline entries");
|
|
}
|
|
|
|
/// Regression test for memvid/memvid#230 — sustained commit-per-put workloads
|
|
/// that span multiple WAL growth cycles must keep the embedded WAL intact.
|
|
///
|
|
/// Before the fix, `grow_wal_region` / `ensure_wal_capacity` updated
|
|
/// `header.footer_offset` and `self.data_end` after shifting the data region
|
|
/// but left the cached `payload_region_end()` value stale. The next call to
|
|
/// `rebuild_indexes` then sought to that pre-growth offset (which now lies
|
|
/// inside the grown WAL region) and overwrote WAL record payloads, producing
|
|
/// `Embedded WAL is corrupted at offset N: wal record checksum mismatch` on
|
|
/// the following commit.
|
|
#[test]
|
|
fn commit_per_put_survives_wal_growth() {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("wal_growth.mv2");
|
|
|
|
// Capture the initial WAL size from a freshly created, then closed, store.
|
|
// The header is read with no writer open so it never races the store's
|
|
// file lock — on Windows a held byte-range lock makes a second handle's
|
|
// read fail with "another process has locked a portion of the file".
|
|
let initial_wal_size = {
|
|
let mem = Memvid::create(&path).unwrap();
|
|
drop(mem);
|
|
read_wal_size(&path)
|
|
};
|
|
|
|
// Use text-indexable payloads of varying length so each commit drives the
|
|
// full Tantivy rebuild path (`rebuild_indexes` → `flush_tantivy`) that
|
|
// seeks to `payload_region_end()`. The mix of sizes ensures multiple
|
|
// WAL growth cycles occur across the run.
|
|
let words: &[&str] = &[
|
|
"alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
|
|
"juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo", "sierra",
|
|
"tango", "uniform", "victor", "whiskey", "x-ray", "yankee", "zulu",
|
|
];
|
|
|
|
let doc_count = 24u32;
|
|
let frames_written;
|
|
{
|
|
let mut mem = Memvid::open(&path).unwrap();
|
|
for i in 0..doc_count {
|
|
// Ramp the body size from ~4 KiB up past the initial 64 KiB WAL
|
|
// region so the run deterministically crosses several
|
|
// `grow_wal_region` cycles (rather than relying on incidental
|
|
// wrap/checkpoint timing). Each commit then runs `rebuild_indexes`,
|
|
// which seeks to `payload_region_end()` — the path #230 corrupted.
|
|
let body_len = 4096usize + (i as usize) * 6144;
|
|
let mut body = String::with_capacity(body_len + 16);
|
|
let mut idx = i as usize;
|
|
while body.len() < body_len {
|
|
body.push_str(words[idx % words.len()]);
|
|
body.push(' ');
|
|
idx = idx.wrapping_add(1);
|
|
}
|
|
let opts = PutOptions {
|
|
uri: Some(format!("mv2://wal-growth/doc-{i}")),
|
|
title: Some(format!("doc-{i}")),
|
|
search_text: Some(body.clone()),
|
|
..Default::default()
|
|
};
|
|
mem.put_bytes_with_options(body.as_bytes(), opts)
|
|
.unwrap_or_else(|e| panic!("put #{i} failed: {e}"));
|
|
mem.commit()
|
|
.unwrap_or_else(|e| panic!("commit #{i} failed: {e}"));
|
|
}
|
|
// Large `search_text` bodies are chunked, so each doc yields one or
|
|
// more frames; capture the true total before closing.
|
|
frames_written = mem.stats().unwrap().frame_count;
|
|
// mem dropped here, releasing the file lock before we read the header.
|
|
}
|
|
|
|
// The WAL region only ever grows, so reading the size once the writer is
|
|
// closed reflects the maximum it reached during the run.
|
|
let final_wal_size = read_wal_size(&path);
|
|
assert!(
|
|
final_wal_size > initial_wal_size,
|
|
"test must exercise WAL growth (initial={initial_wal_size}, final={final_wal_size})"
|
|
);
|
|
|
|
// Reopening forces a full WAL scan; checksum verification will fire here
|
|
// if any record payload was clobbered by a stale-offset index write.
|
|
assert!(
|
|
frames_written >= u64::from(doc_count),
|
|
"expected at least one frame per doc (got {frames_written} for {doc_count} docs)"
|
|
);
|
|
let reopened = Memvid::open_read_only(&path).unwrap();
|
|
assert_eq!(
|
|
reopened.stats().unwrap().frame_count,
|
|
frames_written,
|
|
"all frames should be durable after WAL growth"
|
|
);
|
|
}
|