1use anyhow::{anyhow, bail, ensure, Context, Result};
5use crc32fast::Hasher;
6use memmap2::Mmap;
7use serde::Serialize;
8use serde_json::Value;
9use std::collections::BTreeSet;
10use std::fs::File;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use super::storage::{
15 is_log_end, list_segments, FormatManifest, FORMAT_MAGIC, FORMAT_MANIFEST, HEADER_SIZE,
16 LEGACY_SEGMENT_SIZE, MAX_RAW_FRAME_SIZE, SEGMENT_SIZE, STORAGE_FORMAT_VERSION,
17};
18use super::store_lock::{StoreLock, LOCK_FILE};
19use super::{WarmConfig, WarmCore};
20
21const ARENA_DIRECTORY: &str = "vectors_data.bin";
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct StoreInspection {
26 pub store_path: String,
27 pub format_version: u32,
28 pub segment_count: usize,
29 pub frame_count: usize,
30 pub logical_bytes: u64,
31 pub allocated_bytes: u64,
32 pub checksums_verified: bool,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub manifests_verified: Vec<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub documents: Option<DocumentCheck>,
39}
40
41#[derive(Debug, Clone, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct DocumentCheck {
44 pub checked: bool,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub reason: Option<String>,
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub expected: Option<usize>,
51 pub present: usize,
52 pub missing: Vec<String>,
53}
54
55pub fn inspect_store(path: impl AsRef<Path>, verify_checksums: bool) -> Result<StoreInspection> {
57 let store_path = path.as_ref();
58 let arena_path = store_path.join(ARENA_DIRECTORY);
59 let manifest_path = arena_path.join(FORMAT_MANIFEST);
60 let manifest: FormatManifest = serde_json::from_slice(
61 &std::fs::read(&manifest_path)
62 .with_context(|| format!("missing storage manifest {}", manifest_path.display()))?,
63 )
64 .with_context(|| format!("invalid storage manifest {}", manifest_path.display()))?;
65
66 if manifest.magic != FORMAT_MAGIC {
67 bail!("unknown storage magic {}", manifest.magic);
68 }
69 if manifest.version > STORAGE_FORMAT_VERSION {
70 bail!(
71 "storage format {} is newer than supported format {}",
72 manifest.version,
73 STORAGE_FORMAT_VERSION
74 );
75 }
76 let expected = if manifest.version == 3 {
77 SEGMENT_SIZE
78 } else {
79 LEGACY_SEGMENT_SIZE
80 };
81 if manifest.segment_size != expected {
82 bail!(
83 "storage segment size mismatch: manifest={}, runtime={}",
84 manifest.segment_size,
85 expected
86 );
87 }
88
89 let arena_path = super::durable_file::arena_generation(&arena_path)?;
90 let ids = list_segments(&arena_path)?;
91 ensure!(!ids.is_empty(), "store contains no arena segments");
92 ensure!(
93 ids.iter().enumerate().all(|(expected, &id)| expected == id),
94 "arena segments are not contiguous from 0: {ids:?}"
95 );
96 let segment_count = ids.len();
97 let mut frame_count = 0usize;
98 let mut logical_bytes = 0u64;
99 let mut allocated_bytes = 0u64;
100 for id in ids {
101 let segment_path = arena_path.join(format!("seg_{id}.bin"));
102 let file = File::open(&segment_path)?;
103 let metadata = file.metadata()?;
104 let final_segment = id + 1 == segment_count;
105 let valid_size = if manifest.version >= 4 && final_segment {
106 (SEGMENT_SIZE as u64..=manifest.segment_size as u64).contains(&metadata.len())
107 && metadata.len() % SEGMENT_SIZE as u64 == 0
108 } else {
109 metadata.len() == manifest.segment_size as u64
110 };
111 if !valid_size {
112 bail!(
113 "segment {} has size {}, expected {}",
114 segment_path.display(),
115 metadata.len(),
116 manifest.segment_size
117 );
118 }
119 allocated_bytes = allocated_bytes
120 .checked_add(metadata.len())
121 .context("arena allocated byte count overflow")?;
122 let mmap = unsafe { Mmap::map(&file)? };
123 let (frames, bytes) = inspect_segment(&mmap, &segment_path, verify_checksums)?;
124 frame_count += frames;
125 logical_bytes += bytes as u64;
126 }
127
128 Ok(StoreInspection {
129 store_path: store_path.display().to_string(),
130 format_version: manifest.version,
131 segment_count,
132 frame_count,
133 logical_bytes,
134 allocated_bytes,
135 checksums_verified: verify_checksums,
136 manifests_verified: Vec::new(),
137 documents: None,
138 })
139}
140
141pub fn verify_store(
146 path: impl AsRef<Path>,
147 encryption_passphrase_env: Option<String>,
148) -> Result<StoreInspection> {
149 let store = path.as_ref();
150 let mut inspection = inspect_store(store, true)?;
151 let arena_path = store.join(ARENA_DIRECTORY);
152 inspection.manifests_verified = super::durable_file::verify_manifests(store, &arena_path)?;
153 let schema_path = store.join("store.json");
154 let schema: Value = serde_json::from_slice(
155 &std::fs::read(&schema_path)
156 .with_context(|| format!("missing store schema {}", schema_path.display()))?,
157 )
158 .with_context(|| format!("invalid store schema {}", schema_path.display()))?;
159 let dimensions = u32::try_from(
160 schema["dimensions"]
161 .as_u64()
162 .context("store.json lacks dimensions")?,
163 )?;
164 let mut config = WarmConfig::default();
165 if let Some(name) = schema["text_encoder"].as_str() {
166 config.text_encoder = name
167 .parse()
168 .map_err(|e| anyhow!("store.json text encoder {name}: {e}"))?;
169 }
170 config.meaning.enabled = schema["meaning_enabled"].as_bool().unwrap_or(false);
171 config.embedding_space = serde_json::from_value(schema["embedding_space"].clone())
172 .context("store.json embedding_space")?;
173 let expected = if schema["encrypted"].as_bool().unwrap_or(false) {
174 let Some(env) = encryption_passphrase_env else {
175 inspection.documents = Some(DocumentCheck {
176 checked: false,
177 reason: Some(
178 "encrypted store: pass --encryption-passphrase-env to open it and \
179 authenticate every frame"
180 .into(),
181 ),
182 expected: None,
183 present: 0,
184 missing: Vec::new(),
185 });
186 return Ok(inspection);
187 };
188 config.security.encryption_enabled = true;
189 config.security.encryption_passphrase_env = Some(env);
190 None
191 } else {
192 Some(live_document_ids(&arena_path)?)
193 };
194 let core = WarmCore::new(dimensions, Some(store.display().to_string()), Some(config))
195 .context("opening the store for the document cross-check")?;
196 inspection.documents = Some(match expected {
197 Some(ids) => {
198 let missing = missing_documents(&ids, &core);
199 DocumentCheck {
200 checked: true,
201 reason: None,
202 expected: Some(ids.len()),
203 present: ids.len() - missing.len(),
204 missing,
205 }
206 }
207 None => DocumentCheck {
208 checked: true,
209 reason: Some(
210 "every frame authenticated on open; the presence cross-check needs plaintext \
211 frames"
212 .into(),
213 ),
214 expected: None,
215 present: 0,
216 missing: Vec::new(),
217 },
218 });
219 Ok(inspection)
220}
221
222fn live_document_ids(arena_path: &Path) -> Result<BTreeSet<String>> {
225 let generation = super::durable_file::arena_generation(arena_path)?;
226 let mut live = BTreeSet::new();
227 for segment in 0usize.. {
228 let segment_path = generation.join(format!("seg_{segment}.bin"));
229 if !segment_path.exists() {
230 break;
231 }
232 let mmap = unsafe { Mmap::map(&File::open(&segment_path)?)? };
233 let mut offset = 0usize;
234 while offset + HEADER_SIZE <= mmap.len() {
235 let header = &mmap[offset..offset + HEADER_SIZE];
236 if is_log_end(header) {
237 break;
238 }
239 let raw_len = u32::from_le_bytes(header[4..8].try_into()?) as usize;
240 let comp_len = u32::from_le_bytes(header[8..12].try_into()?) as usize;
241 let frame_end = offset + HEADER_SIZE + comp_len;
242 ensure!(
243 frame_end <= mmap.len(),
244 "truncated frame in {} at byte {offset}",
245 segment_path.display()
246 );
247 let payload = &mmap[offset + HEADER_SIZE..frame_end];
248 let decoded = if comp_len < raw_len {
249 lz4_flex::decompress(payload, raw_len)?
250 } else {
251 payload.to_vec()
252 };
253 let json = super::engine::journal::payload_json(&decoded)
254 .with_context(|| {
255 format!(
256 "frame at byte {offset} of {} is not a transaction frame",
257 segment_path.display()
258 )
259 })?
260 .with_context(|| {
261 format!(
262 "frame at byte {offset} of {} is not a transaction",
263 segment_path.display()
264 )
265 })?;
266 let mutations: Vec<Value> = serde_json::from_slice(&json).with_context(|| {
267 format!(
268 "frame at byte {offset} of {} is not a transaction",
269 segment_path.display()
270 )
271 })?;
272 for mutation in &mutations {
273 let id_of = |variant: &str| {
274 mutation
275 .get(variant)
276 .and_then(|m| m.get("id"))
277 .and_then(Value::as_str)
278 };
279 if let Some(id) = id_of("Document") {
280 live.insert(id.to_string());
281 } else if let Some(id) = id_of("DeleteDocument") {
282 live.remove(id);
283 }
284 }
285 offset = frame_end;
286 }
287 }
288 Ok(live)
289}
290
291fn missing_documents(expected: &BTreeSet<String>, core: &WarmCore) -> Vec<String> {
292 expected
293 .iter()
294 .filter(|id| !core.has_document(id))
295 .cloned()
296 .collect()
297}
298
299pub(crate) fn inspect_segment(
300 data: &[u8],
301 path: &Path,
302 verify_checksums: bool,
303) -> Result<(usize, usize)> {
304 let mut offset = 0usize;
305 let mut frames = 0usize;
306 while offset + HEADER_SIZE <= data.len() {
307 let expected_crc = u32::from_le_bytes(data[offset..offset + 4].try_into()?);
308 let raw_len = u32::from_le_bytes(data[offset + 4..offset + 8].try_into()?) as usize;
309 let comp_len = u32::from_le_bytes(data[offset + 8..offset + 12].try_into()?) as usize;
310 if raw_len == 0 && comp_len == 0 {
311 if is_log_end(&data[offset..offset + HEADER_SIZE]) {
312 return Ok((frames, offset));
313 }
314 bail!(
315 "invalid end-of-log header in {} at byte {}",
316 path.display(),
317 offset
318 );
319 }
320 if raw_len == 0 || raw_len > MAX_RAW_FRAME_SIZE || comp_len == 0 || comp_len > data.len() {
321 bail!(
322 "invalid frame header in {} at byte {}",
323 path.display(),
324 offset
325 );
326 }
327 let frame_end = offset
328 .checked_add(HEADER_SIZE + comp_len)
329 .ok_or_else(|| anyhow!("frame length overflow in {}", path.display()))?;
330 if frame_end > data.len() {
331 bail!("truncated frame in {} at byte {}", path.display(), offset);
332 }
333 if verify_checksums {
334 let payload = &data[offset + HEADER_SIZE..frame_end];
335 let decoded = if comp_len < raw_len {
336 lz4_flex::decompress(payload, raw_len).with_context(|| {
337 format!("invalid LZ4 frame in {} at byte {}", path.display(), offset)
338 })?
339 } else {
340 payload.to_vec()
341 };
342 let mut hasher = Hasher::new();
343 hasher.update(&decoded);
344 if hasher.finalize() != expected_crc {
345 bail!("checksum mismatch in {} at byte {}", path.display(), offset);
346 }
347 }
348 frames += 1;
349 offset = frame_end;
350 }
351 Ok((frames, offset))
352}
353
354pub fn migrate_store(
358 source: impl AsRef<Path>,
359 destination: impl AsRef<Path>,
360) -> Result<StoreInspection> {
361 let source = source.as_ref();
362 let destination = destination.as_ref();
363 if destination.exists() {
364 bail!(
365 "migration destination already exists: {}",
366 destination.display()
367 );
368 }
369 let source_canonical = source.canonicalize()?;
370 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
371 std::fs::create_dir_all(parent)?;
372 let parent_canonical = parent.canonicalize()?;
373 if parent_canonical.starts_with(&source_canonical) {
374 bail!("migration destination cannot be inside the source store");
375 }
376
377 let _source_lock = StoreLock::acquire(source)?;
378 inspect_store(source, true)?;
381 let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
382 let temp = parent.join(format!(".warm-migrate-{}-{nonce}", std::process::id()));
383 let result = (|| -> Result<StoreInspection> {
384 copy_tree(source, &temp)?;
385 let inspection = inspect_store(&temp, true)?;
386 std::fs::rename(&temp, destination).with_context(|| {
389 format!(
390 "failed to atomically publish migration {} -> {}",
391 temp.display(),
392 destination.display()
393 )
394 })?;
395 super::durable_file::sync_directory(parent)?;
396 Ok(StoreInspection {
397 store_path: destination.display().to_string(),
398 ..inspection
399 })
400 })();
401 if result.is_err() && temp.exists() {
402 let _ = std::fs::remove_dir_all(&temp);
404 }
405 result
406}
407
408pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
409 std::fs::create_dir(destination)?;
410 for entry in std::fs::read_dir(source)? {
411 let entry = entry?;
412 let file_type = entry.file_type()?;
413 if file_type.is_symlink() {
414 bail!("migration refuses symbolic link {}", entry.path().display());
415 }
416 if entry.file_name() == LOCK_FILE {
417 continue;
418 }
419 let target = destination.join(entry.file_name());
420 if file_type.is_dir() {
421 copy_tree(&entry.path(), &target)?;
422 } else if file_type.is_file() {
423 std::fs::copy(entry.path(), &target)?;
424 std::fs::set_permissions(&target, entry.metadata()?.permissions())?;
425 #[cfg(unix)]
428 std::fs::File::open(&target)?.sync_all()?;
429 } else {
430 bail!("migration refuses special file {}", entry.path().display());
431 }
432 }
433 super::durable_file::sync_directory(destination)
434}
435
436pub fn reencode_documents(
438 input: impl AsRef<Path>,
439 destination: impl AsRef<Path>,
440 dimensions: u32,
441 config: super::WarmConfig,
442) -> Result<StoreInspection> {
443 use std::io::{BufRead, BufReader, Read};
444 let destination = destination.as_ref();
445 if destination.exists() {
446 bail!("re-encoding destination already exists");
447 }
448 let parent = destination
449 .parent()
450 .filter(|p| !p.as_os_str().is_empty())
451 .unwrap_or_else(|| Path::new("."));
452 std::fs::create_dir_all(parent)?;
453 let temporary = tempfile::Builder::new()
454 .prefix(".warm-reencode-")
455 .tempdir_in(parent)?;
456 let mut source = BufReader::new(File::open(input.as_ref())?);
457 {
458 let warm = super::WarmCore::new(
459 dimensions,
460 Some(temporary.path().display().to_string()),
461 Some(config),
462 )?;
463 let mut line_number = 0;
464 loop {
465 let mut line = String::new();
466 let bytes = source
467 .by_ref()
468 .take(16 * 1024 * 1024 + 1)
469 .read_line(&mut line)?;
470 if bytes == 0 {
471 break;
472 }
473 line_number += 1;
474 if bytes > 16 * 1024 * 1024 {
475 bail!("JSONL line {line_number} exceeds 16 MiB");
476 }
477 if line.trim().is_empty() {
478 continue;
479 }
480 let document: super::documents::DocumentInput = serde_json::from_str(&line)
481 .with_context(|| format!("invalid source document on line {line_number}"))?;
482 warm.memorize_document(document)
483 .with_context(|| format!("cannot re-encode line {line_number}"))?;
484 }
485 warm.flush()?;
486 }
487 let inspection = inspect_store(temporary.path(), true)?;
488 super::durable_file::sync_directory(temporary.path())?;
489 std::fs::rename(temporary.path(), destination)?;
490 super::durable_file::sync_directory(parent)?;
491 Ok(StoreInspection {
492 store_path: destination.display().to_string(),
493 ..inspection
494 })
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500 use crate::core::documents::DocumentInput;
501 use crate::core::entangled::EntangledHVec;
502 use crate::core::WarmCore;
503
504 #[test]
507 fn verify_cross_checks_logged_documents() -> Result<()> {
508 let root = tempfile::tempdir()?;
509 let store = root.path().join("store");
510 {
511 let warm = WarmCore::new(256, Some(store.display().to_string()), None)?;
512 for n in 0..3 {
513 warm.memorize_document(DocumentInput {
514 id: format!("doc{n}"),
515 text: format!("document {n} about harbors and ledgers"),
516 source_uri: None,
517 version: None,
518 metadata: None,
519 chunk_words: None,
520 overlap_words: None,
521 store_text: None,
522 embeddings: None,
523 title_weight: None,
524 })?;
525 }
526 assert!(warm.delete_document("doc1")?);
527 warm.flush()?;
528 }
529 let verified = verify_store(&store, None)?;
530 assert!(verified
531 .manifests_verified
532 .iter()
533 .any(|m| m == "store.json"));
534 assert!(verified
535 .manifests_verified
536 .iter()
537 .any(|m| m == "format.json"));
538 let documents = verified
539 .documents
540 .expect("plaintext stores are cross-checked");
541 assert_eq!(
542 (documents.checked, documents.expected, documents.present),
543 (true, Some(2), 2)
544 );
545 assert!(documents.missing.is_empty());
546
547 let core = WarmCore::new(256, Some(store.display().to_string()), None)?;
548 let expected: BTreeSet<String> = ["doc0", "doc2", "doc9"]
549 .into_iter()
550 .map(String::from)
551 .collect();
552 assert_eq!(
553 missing_documents(&expected, &core),
554 vec!["doc9".to_string()]
555 );
556 Ok(())
557 }
558
559 #[test]
560 fn inspect_verify_and_migrate_roundtrip() -> Result<()> {
561 let root = tempfile::tempdir()?;
562 let source = root.path().join("source");
563 {
564 let warm = WarmCore::new(256, Some(source.display().to_string()), None)?;
565 warm.memorize("one".into(), EntangledHVec::new_deterministic(256, 1))?;
566 }
567
568 let inspected = inspect_store(&source, false)?;
569 assert_eq!(inspected.frame_count, 1);
570 assert!(!inspected.checksums_verified);
571 let verified = inspect_store(&source, true)?;
572 assert!(verified.checksums_verified);
573 let destination = root.path().join("destination");
574 let migrated = migrate_store(&source, &destination)?;
575 assert_eq!(migrated.frame_count, 1);
576 let reopened = WarmCore::new(256, Some(destination.display().to_string()), None)?;
577 assert_eq!(reopened.index_status().vector_count, 1);
578 Ok(())
579 }
580
581 #[test]
582 fn verification_rejects_corruption() -> Result<()> {
583 use std::fs::OpenOptions;
584 use std::io::{Seek, SeekFrom, Write};
585 let root = tempfile::tempdir()?;
586 let source = root.path().join("source");
587 {
588 let warm = WarmCore::new(256, Some(source.display().to_string()), None)?;
589 warm.memorize("one".into(), EntangledHVec::new_deterministic(256, 1))?;
590 }
591 let segment = source.join(ARENA_DIRECTORY).join("seg_0.bin");
592 let mut file = OpenOptions::new().write(true).open(segment)?;
593 file.seek(SeekFrom::Start(HEADER_SIZE as u64))?;
594 file.write_all(&[0xff])?;
595 file.sync_all()?;
596 assert!(inspect_store(&source, true).is_err());
597 Ok(())
598 }
599}