Skip to main content

writerslogic_warm/core/
admin.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use 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    /// Manifests whose checksum sidecar was verified (`verify` only).
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub manifests_verified: Vec<String>,
36    /// Document cross-check against the opened store (`verify` only).
37    #[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    /// False when the store was not opened; `reason` says why.
45    pub checked: bool,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub reason: Option<String>,
48    /// Documents the transaction log says are live; absent when the frames are encrypted.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub expected: Option<usize>,
51    pub present: usize,
52    pub missing: Vec<String>,
53}
54
55/// Inspect storage framing without opening or changing the WARM engine.
56pub 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
141/// `inspect_store(path, true)` plus the manifest checksums and a document cross-check: the
142/// store is opened through the engine, which authenticates and replays every frame, and
143/// every document the log says is live must resolve there. Corruption fails the verify
144/// instead of being served as a shorter store.
145pub 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
222/// Document ids the plaintext transaction log leaves live: every `Document` id minus later
223/// `DeleteDocument`s. Frame bounds and checksums were validated by `inspect_store` first.
224fn 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
354/// Create a verified, point-in-time copy of a current-format store.
355/// The source is exclusively locked for the full copy and verification. The
356/// destination appears only after verification succeeds.
357pub 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    // Verify after acquiring the lock so the exact snapshot being copied is
379    // the snapshot that passed integrity validation.
380    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        // `copy_tree` synced every file and directory of the copy, so only the rename and
387        // the parent entry remain to be made durable.
388        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        // Best effort: the migration error is what the caller needs, not a cleanup failure.
403        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            // Unix only, as `sync_directory`: Windows flushes need a writable handle, which a
426            // copied read-only file refuses.
427            #[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
436/// Re-encode original JSONL source documents into a new, atomically published store.
437pub 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    /// `verify` cross-checks the opened store against the log: a healthy store reports every
505    /// live document present, and a logged document the store cannot resolve is named.
506    #[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}