Skip to main content

flint_lean/
chunkcache.rs

1//! A local copy of manifest chunks, kept by content address (latency plan
2//! 2026-10-23, lever A: `docs/plans/flint-lean-latency-plan-2026-10-23.md`).
3//!
4//! Every manifest load used to fetch every chunk the pointer names, one
5//! sequential GET per 4,096 entries: 49 GETs at 200k files and 220 at 1M,
6//! on every pointer move, in the consume AND the commit, and twice per UI
7//! save in the gateway. Consecutive documents differ in one or two chunks,
8//! and a chunk never changes once written (its key IS the hash of its
9//! body), so a reader that kept the bodies it already has needs to fetch
10//! only the chunks that are new.
11//!
12//! What makes keeping them safe:
13//!
14//! - **Content, not trust.** A cached body is used only after it hashes to
15//!   the address the pointer names (`manifest::load_from`). A damaged,
16//!   truncated or planted file is a miss: dropped, and fetched. A forged
17//!   body would need a preimage of the 128-bit address.
18//! - **Existence is still checked.** The cache can vouch for a chunk's
19//!   bytes, never for the chunk still being in the bucket. A load that
20//!   served any chunk from here also LISTs the chunk prefix, once, and a
21//!   chunk the live pointer names that the bucket no longer holds is
22//!   decided exactly as a 404 was: the pointer moved (a sweep took a
23//!   superseded generation) restarts the load, an unchanged pointer is a
24//!   hole and refuses. A document is never served short, and a live hole
25//!   is never papered over.
26//! - **Remote names never become local paths unchecked.** An address
27//!   comes from the bucket; `chunk::is_chunk_address` admits 32 lowercase
28//!   hex digits and nothing else, so no pointer can name `../x`.
29//!
30//! Two implementations: [`DiskChunkCache`] for the syncer (a directory in
31//! its state dir, so it survives a container restart and costs no memory
32//! between barriers), and [`MemoryChunkCache`] for the gateway (no local
33//! tree; one per workspace, beside its manifest cache). Both keep only the
34//! chunks of the document loaded last (`retain`), so either holds about
35//! one manifest: ~277 bytes per entry, 66 MiB at 250k files.
36
37use std::collections::{HashMap, HashSet};
38use std::io::Read;
39use std::path::PathBuf;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::sync::Mutex;
42
43use bytes::Bytes;
44
45use super::chunk::is_chunk_address;
46
47/// The largest body either cache keeps. A chunk is at most
48/// `CHUNK_MAX` entries, a few MiB; the tombstones object is bounded by
49/// `TOMBSTONE_KEEP_SEQS`. Anything larger is fetched every time rather than
50/// read into memory from a file the app could have grown.
51pub const MAX_CACHED_BODY: u64 = 64 * 1024 * 1024;
52
53/// Chunk bodies by content address. Every method is best effort: a cache
54/// that cannot read or write costs requests, never correctness, so nothing
55/// here returns an error.
56pub trait ChunkCache: Send + Sync {
57    /// The body kept under `addr`, if any. NOT verified here: the loader
58    /// hashes every body once, whichever way it arrived.
59    fn get(&self, addr: &str) -> Option<Bytes>;
60    /// Keep `body` under `addr`. The caller has verified it.
61    fn put(&self, addr: &str, body: &[u8]);
62    /// Drop `addr` (it failed verification).
63    fn remove(&self, addr: &str);
64    /// Keep only these addresses.
65    fn retain(&self, live: &HashSet<String>);
66}
67
68/// The gateway's cache: bodies in memory, one map per workspace.
69#[derive(Default)]
70pub struct MemoryChunkCache {
71    map: Mutex<HashMap<String, Bytes>>,
72}
73
74impl MemoryChunkCache {
75    pub fn len(&self) -> usize {
76        self.map.lock().unwrap().len()
77    }
78    pub fn is_empty(&self) -> bool {
79        self.len() == 0
80    }
81}
82
83impl ChunkCache for MemoryChunkCache {
84    fn get(&self, addr: &str) -> Option<Bytes> {
85        self.map.lock().unwrap().get(addr).cloned()
86    }
87    fn put(&self, addr: &str, body: &[u8]) {
88        if !is_chunk_address(addr) || body.len() as u64 > MAX_CACHED_BODY {
89            return;
90        }
91        self.map.lock().unwrap().insert(addr.to_string(), Bytes::copy_from_slice(body));
92    }
93    fn remove(&self, addr: &str) {
94        self.map.lock().unwrap().remove(addr);
95    }
96    fn retain(&self, live: &HashSet<String>) {
97        self.map.lock().unwrap().retain(|k, _| live.contains(k));
98    }
99}
100
101/// The syncer's cache: one file per chunk under the state dir
102/// (`.flint-sync/chunk-cache/<addr>`). The state dir sits in the
103/// app-writable tree, so every access is the state dir's discipline: no
104/// symlink is followed at the directory or the file, a FIFO or a
105/// directory at a chunk's name is a miss, writes go through
106/// `safefs::write_via_tmp_fast`, and every body read is verified by its
107/// caller before use.
108pub struct DiskChunkCache {
109    dir: PathBuf,
110    warned: AtomicBool,
111}
112
113impl DiskChunkCache {
114    pub fn new(dir: impl Into<PathBuf>) -> Self {
115        DiskChunkCache { dir: dir.into(), warned: AtomicBool::new(false) }
116    }
117
118    pub fn dir(&self) -> &std::path::Path {
119        &self.dir
120    }
121
122    /// The directory exists and is a directory, not a link to one.
123    fn usable(&self) -> bool {
124        std::fs::symlink_metadata(&self.dir).is_ok_and(|m| m.is_dir())
125    }
126
127    /// Make the directory if it is not there: refused when the state dir
128    /// above it is a symlink (`check_parent`), and never through one.
129    fn ensure(&self) -> bool {
130        if self.usable() {
131            return true;
132        }
133        if super::safefs::check_parent(&self.dir).is_err() {
134            return false;
135        }
136        match std::fs::create_dir(&self.dir) {
137            Ok(()) => {}
138            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
139            Err(_) => return false,
140        }
141        self.usable()
142    }
143
144    fn warn_once(&self, what: &str) {
145        if !self.warned.swap(true, Ordering::Relaxed) {
146            eprintln!(
147                "flint-sync: the chunk cache at {} is not usable ({what}); manifest loads fetch \
148                 every chunk from the bucket",
149                self.dir.display()
150            );
151        }
152    }
153}
154
155impl ChunkCache for DiskChunkCache {
156    fn get(&self, addr: &str) -> Option<Bytes> {
157        if !is_chunk_address(addr) || !self.usable() {
158            return None;
159        }
160        // No link at the file either, a FIFO refused rather than waited on,
161        // and only a regular file returned.
162        let f = super::safefs::open_beneath_nofollow(&self.dir, addr).ok()?;
163        let len = f.metadata().ok()?.len();
164        if len > MAX_CACHED_BODY {
165            return None;
166        }
167        let mut buf = Vec::with_capacity(len as usize);
168        (&f).take(MAX_CACHED_BODY + 1).read_to_end(&mut buf).ok()?;
169        Some(Bytes::from(buf))
170    }
171
172    fn put(&self, addr: &str, body: &[u8]) {
173        if !is_chunk_address(addr) || body.len() as u64 > MAX_CACHED_BODY {
174            return;
175        }
176        if !self.ensure() {
177            self.warn_once("its directory cannot be made");
178            return;
179        }
180        let path = self.dir.join(addr);
181        // Per process: a `flint-sync status` beside the running syncer
182        // shares this directory, and two writers of one temp name would
183        // tear each other's file (harmlessly: it would fail verification).
184        let tmp = self.dir.join(format!("{addr}.{}{}", std::process::id(), super::scan::TMP_SUFFIX));
185        let wrote = super::safefs::check_parent(&path)
186            .and_then(|_| super::safefs::write_via_tmp_fast(&path, &tmp, body, None).map(|_| ()));
187        if let Err(e) = wrote {
188            self.warn_once(&e.to_string());
189        }
190    }
191
192    fn remove(&self, addr: &str) {
193        if is_chunk_address(addr) && self.usable() {
194            let _ = std::fs::remove_file(self.dir.join(addr));
195        }
196    }
197
198    fn retain(&self, live: &HashSet<String>) {
199        if !self.usable() {
200            return;
201        }
202        let Ok(rd) = std::fs::read_dir(&self.dir) else { return };
203        for entry in rd.flatten() {
204            let name = entry.file_name();
205            let Some(name) = name.to_str() else { continue };
206            // Only names this cache writes are ever removed: a chunk no
207            // longer named, or a temp file a crash left behind.
208            let stale = if is_chunk_address(name) {
209                !live.contains(name)
210            } else {
211                name.ends_with(super::scan::TMP_SUFFIX)
212                    && name.get(..32).is_some_and(is_chunk_address)
213            };
214            if stale {
215                let _ = std::fs::remove_file(entry.path());
216            }
217        }
218    }
219}