//! 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" ); }