Skip to main content

lean_ctx/core/context_ledger/
persistence.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Persist one completed read, never an MCP process's stale ledger snapshot.
4
5use std::path::Path;
6
7use super::{ContextLedger, LedgerEntry};
8use crate::core::context_field::ContextState;
9
10impl ContextLedger {
11    pub fn read_entry(&self, path: &str) -> Option<LedgerEntry> {
12        let path = crate::core::pathutil::normalize_tool_path(path);
13        self.entries
14            .iter()
15            .find(|entry| entry.path == path)
16            .cloned()
17    }
18
19    /// Complete persistence before acknowledging a read. No async state lock
20    /// may be held while this operation waits for the existing OS ledger lock.
21    pub async fn persist_read(entry: Option<LedgerEntry>) -> Result<(), String> {
22        let entry = entry.ok_or("completed read has no ledger entry")?;
23        tokio::task::spawn_blocking(move || {
24            let path = super::helpers::ledger_path("default")?;
25            persist_entry_at(&path, entry)
26        })
27        .await
28        .map_err(|error| format!("ledger persistence worker failed: {error}"))?
29    }
30}
31
32fn persist_entry_at(path: &Path, mut entry: LedgerEntry) -> Result<(), String> {
33    if let Some(parent) = path.parent() {
34        crate::config_io::ensure_dir(parent)?;
35    }
36    // fs2 uses the same advisory flock as the existing Unix ledger writer,
37    // and also provides a real lock on Windows. Lock failure prevents writes.
38    // Every completed read waits here before it is acknowledged, so N parallel
39    // reads queue N read-merge-write cycles. 750 ms (the FileLock default) was
40    // not enough for 16 on windows-latest; the wait is bounded, not removed.
41    const LEDGER_LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
42    let _lock = crate::core::agents::FileLock::acquire_with_timeout(
43        &path.with_extension("json.lock"),
44        LEDGER_LOCK_TIMEOUT,
45    )?;
46    let mut current: ContextLedger = match std::fs::symlink_metadata(path) {
47        Ok(meta) => {
48            if !meta.is_file() || meta.len() > 8 * 1024 * 1024 {
49                return Err("ledger must be a regular file of at most 8 MiB".into());
50            }
51            let bytes = std::fs::read(path).map_err(|error| format!("read ledger: {error}"))?;
52            serde_json::from_slice(&bytes).map_err(|error| format!("decode ledger: {error}"))?
53        }
54        Err(error) if error.kind() == std::io::ErrorKind::NotFound => ContextLedger::new(),
55        Err(error) => return Err(format!("inspect ledger: {error}")),
56    };
57    if let Some(previous) = current
58        .entries
59        .iter_mut()
60        .find(|old| old.path == entry.path)
61    {
62        // Merge observations only. Another writer may have changed source
63        // provenance, controls or identity since this MCP process loaded state.
64        previous.mode = entry.mode;
65        previous.original_tokens = entry.original_tokens;
66        previous.sent_tokens = entry.sent_tokens;
67        previous.timestamp = entry.timestamp;
68        previous.access_count = previous.access_count.saturating_add(1);
69        previous.active_view = entry.active_view;
70        previous.phi = entry.phi;
71        if previous.id.is_none() {
72            previous.id = entry.id;
73        }
74        if previous.state.is_none() || previous.state == Some(ContextState::Candidate) {
75            previous.state = Some(ContextState::Included);
76        }
77    } else {
78        // A reset followed by a new read restores only that new read, not the
79        // caller's other stale entries or counters.
80        entry.access_count = 1;
81        current.entries.push(entry);
82    }
83    current.total_tokens_sent = current.entries.iter().map(|item| item.sent_tokens).sum();
84    current.total_tokens_saved = current
85        .entries
86        .iter()
87        .map(|item| item.original_tokens.saturating_sub(item.sent_tokens))
88        .sum();
89    let json =
90        serde_json::to_string(&current).map_err(|error| format!("encode ledger: {error}"))?;
91    crate::config_io::write_atomic(path, &json)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn stale_readers_preserve_each_other_and_reset() {
100        let temp = tempfile::tempdir().unwrap();
101        let path = temp.path().join("context_ledger.json");
102        let mut first = ContextLedger::new();
103        first.record("/first.txt", "raw", 10, 8);
104        let mut second = ContextLedger::new();
105        second.record("/second.txt", "raw", 20, 12);
106        let a = first.read_entry("/first.txt").unwrap();
107        let b = second.read_entry("/second.txt").unwrap();
108        persist_entry_at(&path, a.clone()).unwrap();
109        persist_entry_at(&path, b.clone()).unwrap();
110        let mut saved: ContextLedger =
111            serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
112        assert_eq!(saved.entries.len(), 2);
113        assert_eq!(
114            (saved.total_tokens_sent, saved.total_tokens_saved),
115            (20, 10)
116        );
117        saved.entries[0].state = Some(ContextState::Stale);
118        saved.entries[0].source_hash = Some("concurrent-source-digest".into());
119        saved.entries[0].kind = Some(crate::core::context_field::ContextKind::Shell);
120        std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
121        persist_entry_at(&path, a).unwrap();
122        let saved: ContextLedger = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
123        assert_eq!(saved.entries[0].state, Some(ContextState::Stale));
124        assert_eq!(
125            saved.entries[0].source_hash.as_deref(),
126            Some("concurrent-source-digest")
127        );
128        assert_eq!(
129            saved.entries[0].kind,
130            Some(crate::core::context_field::ContextKind::Shell)
131        );
132        assert_eq!(saved.entries[0].access_count, 2);
133        std::fs::write(&path, serde_json::to_vec(&ContextLedger::new()).unwrap()).unwrap();
134        persist_entry_at(&path, b).unwrap();
135        let saved: ContextLedger = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
136        assert_eq!(saved.entries.len(), 1);
137        assert_eq!(saved.entries[0].path, "/second.txt");
138        assert_eq!((saved.total_tokens_sent, saved.total_tokens_saved), (12, 8));
139    }
140
141    #[test]
142    fn corrupt_or_locked_ledger_is_not_replaced() {
143        let temp = tempfile::tempdir().unwrap();
144        let path = temp.path().join("context_ledger.json");
145        let mut ledger = ContextLedger::new();
146        ledger.record("/read.txt", "raw", 10, 8);
147        let entry = ledger.read_entry("/read.txt").unwrap();
148        std::fs::write(&path, b"corrupt").unwrap();
149        assert!(persist_entry_at(&path, entry.clone()).is_err());
150        assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
151        let empty = serde_json::to_vec(&ContextLedger::new()).unwrap();
152        std::fs::write(&path, &empty).unwrap();
153        let _lock =
154            crate::core::agents::FileLock::acquire(&path.with_extension("json.lock")).unwrap();
155        assert!(persist_entry_at(&path, entry).is_err());
156        assert_eq!(std::fs::read(&path).unwrap(), empty);
157    }
158}