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}