Skip to main content

writerslogic_warm/core/engine/
memory_access.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Read and delete access for the memory API (`core::memory_api`). Memory facts are ordinary
5//! documents, so nothing here adds a persisted format; it only exposes what the API needs
6//! from the document store without widening its visibility.
7
8use anyhow::Result;
9use serde_json::Value;
10
11use super::{mutation::Mutation, WarmCore};
12
13/// Most deletions one journal transaction carries.
14pub(crate) const DELETE_BATCH: usize = 4096;
15
16/// Everything needed to store a document again unchanged: its first chunk's text and
17/// embedding, its metadata, source and version.
18pub(crate) struct DocumentParts {
19    pub metadata: Value,
20    pub text: Option<String>,
21    pub source_uri: Option<String>,
22    pub version: String,
23    pub embedding: Option<Vec<f64>>,
24}
25
26/// A document as the memory API sees it: its metadata and its first chunk's text. Memory
27/// facts are stored as a single chunk.
28pub(crate) struct DocumentView<'a> {
29    pub id: &'a str,
30    pub version: &'a str,
31    pub metadata: &'a Value,
32    text: &'a dyn Fn() -> Option<String>,
33}
34
35impl DocumentView<'_> {
36    pub(crate) fn text(&self) -> Option<String> {
37        (self.text)()
38    }
39}
40
41impl WarmCore {
42    pub(crate) fn validate_document_search(
43        &self,
44        text: &str,
45        options: &crate::SearchOptions,
46    ) -> Result<()> {
47        crate::core::documents::validate_search(
48            text,
49            options,
50            self.config
51                .embedding_space
52                .as_ref()
53                .map(|space| space.dimensions),
54        )
55        .map(drop)
56    }
57
58    /// Advances by one per write transaction this process commits or replays, so equal values
59    /// bracket an unchanged store and the difference of two readings counts the transactions
60    /// between them. It starts at a random value per open, so one reading does not reveal how
61    /// many transactions (inserts, deletes) the store has seen.
62    pub fn revision(&self) -> u64 {
63        self.revision.load(std::sync::atomic::Ordering::Acquire)
64    }
65
66    /// Visits every document under one consistent snapshot and returns that snapshot's
67    /// revision.
68    pub(crate) fn scan_documents(&self, mut visit: impl FnMut(DocumentView<'_>)) -> u64 {
69        self.try_scan_documents(None, |document| {
70            visit(document);
71            Ok(())
72        })
73        .expect("infallible document visitor")
74    }
75
76    pub(crate) fn try_scan_documents(
77        &self,
78        ids: Option<&std::collections::BTreeSet<String>>,
79        mut visit: impl FnMut(DocumentView<'_>) -> Result<()>,
80    ) -> Result<u64> {
81        let _transaction = self.mutation_gate.read();
82        let revision = self.revision();
83        self.hydrate_documents()?;
84        let store = self.documents.read();
85        let mut visit_document =
86            |document: std::sync::Arc<crate::core::documents::StoredDocument>| {
87                let text = || {
88                    document
89                        .chunks
90                        .first()
91                        .and_then(|chunk| store.chunk_text(chunk))
92                };
93                visit(DocumentView {
94                    id: &document.id,
95                    version: &document.version,
96                    metadata: &document.metadata,
97                    text: &text,
98                })
99            };
100        if let Some(ids) = ids {
101            for id in ids {
102                if let Some(document) = store.get(id) {
103                    visit_document(std::sync::Arc::clone(document))?;
104                }
105            }
106        } else {
107            for document in store.shared_values() {
108                visit_document(document)?;
109            }
110        }
111        Ok(revision)
112    }
113
114    /// Metadata and text of one document.
115    pub(crate) fn document_snapshot(&self, id: &str) -> Option<(String, Value, Option<String>)> {
116        self.document_parts(id)
117            .map(|parts| (parts.version, parts.metadata, parts.text))
118    }
119
120    pub(crate) fn document_parts(&self, id: &str) -> Option<DocumentParts> {
121        let _transaction = self.mutation_gate.read();
122        let store = self.documents();
123        let document = store.get(id)?;
124        let chunk = document.chunks.first();
125        Some(DocumentParts {
126            metadata: document.metadata.clone(),
127            text: chunk.and_then(|chunk| store.chunk_text(chunk)),
128            source_uri: document.source_uri.clone(),
129            version: document.version.clone(),
130            embedding: chunk.and_then(|chunk| chunk.embedding.as_ref().map(|e| e.to_f64())),
131        })
132    }
133
134    pub(crate) fn memory_revision_matches(&self, revision: u64) -> bool {
135        let _writer = self.mutation_gate.maintenance();
136        self.revision() == revision
137    }
138
139    pub(crate) fn change_memory_documents(
140        &self,
141        inputs: Vec<crate::DocumentInput>,
142        deleted: &[String],
143        revision: u64,
144    ) -> Result<bool> {
145        anyhow::ensure!(
146            inputs
147                .len()
148                .checked_add(deleted.len())
149                .is_some_and(|n| n <= DELETE_BATCH),
150            "memory transaction requires at most 4096 document changes"
151        );
152        let mut mutations = if inputs.is_empty() {
153            Vec::new()
154        } else {
155            self.prepare_batch(inputs, &Default::default())?.0
156        };
157        mutations.extend(
158            deleted
159                .iter()
160                .cloned()
161                .map(|id| Mutation::DeleteDocument { id }),
162        );
163        if mutations.is_empty() {
164            return Ok(self.memory_revision_matches(revision));
165        }
166        let payload = super::document_batch::serialize_transaction(self, &mutations)?;
167        self.commit_serialized_if_revision(&mutations, &payload, revision)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn fallible_scoped_scan_stops_at_the_first_error() -> Result<()> {
177        let directory = tempfile::tempdir()?;
178        let core = WarmCore::new(256, Some(directory.path().display().to_string()), None)?;
179        for id in ["a", "b", "c"] {
180            core.memorize_document(serde_json::from_value(
181                serde_json::json!({"id": id, "text": "evidence"}),
182            )?)?;
183        }
184        let scope = std::collections::BTreeSet::from(["b".into(), "c".into()]);
185        let mut visited = Vec::new();
186        let result = core.try_scan_documents(Some(&scope), |document| {
187            visited.push(document.id.to_owned());
188            anyhow::bail!("budget exhausted")
189        });
190        assert!(result.is_err());
191        assert_eq!(visited, ["b"]);
192        Ok(())
193    }
194}