//! The content-cache index: one row per cached blob, carrying the access stamp //! the LRU eviction pass sorts on. use rusqlite::params; use super::Db; use crate::Result; pub struct CacheEntryInput<'a> { /// On-disk filename of the blob or block. pub key: &'a str, /// `'blob'` or `'block'` — the two byte budgets are counted separately. pub kind: &'a str, pub size: u64, /// Initial LRU key, in unix milliseconds. pub last_accessed: i64, } impl Db { pub fn cache_touch(&self, key: &str, kind: &str, size: u64, now: i64) -> Result<()> { let conn = self.conn.lock(); conn.execute( "INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(cache_key) DO UPDATE SET kind = excluded.kind, size_bytes = excluded.size_bytes, last_accessed = excluded.last_accessed", params![key, kind, size as i64, now], )?; Ok(()) } /// Bump only `last_accessed` on a cache hit (LRU ordering). A missing row is /// a no-op — best effort, mirroring the old best-effort mtime touch. pub fn cache_accessed(&self, key: &str, now: i64) -> Result<()> { let conn = self.conn.lock(); conn.execute( "UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1", params![key, now], )?; Ok(()) } /// Bump `last_accessed` for many keys in one transaction. /// /// The read path touches a key per 128 KiB the kernel asks for — 32 times /// per 4 MiB block — and each of those was its own write transaction under /// the single connection mutex, contending with every `lookup` and /// `readdir`. Callers accumulate touches in memory and flush them here. pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> { if touches.is_empty() { return Ok(()); } let mut conn = self.conn.lock(); let tx = conn.transaction()?; { let mut stmt = tx.prepare_cached( "UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1", )?; for (key, at) in touches { stmt.execute(params![key, at])?; } } tx.commit()?; Ok(()) } /// Drop a single cache-entry row (one evicted blob or block). pub fn cache_remove(&self, key: &str) -> Result<()> { let conn = self.conn.lock(); conn.execute( "DELETE FROM cache_entries WHERE cache_key = ?1", params![key], )?; Ok(()) } /// Drop the whole-file blob row for `key` plus every derived row for the /// same uid — blocks (`.b`) and thumbnails (`.t`). /// Called by `ContentCache::evict`, which removes the blob, all of a uid's /// cached blocks and all of its thumbnails at once; a row left behind here /// would keep counting bytes that are no longer on disk. pub fn cache_remove_all(&self, key: &str) -> Result<()> { let conn = self.conn.lock(); conn.execute( "DELETE FROM cache_entries WHERE cache_key = ?1 OR cache_key LIKE ?2 OR cache_key LIKE ?3", params![key, format!("{key}.b%"), format!("{key}.t%")], )?; Ok(()) } /// Total bytes held by entries of `kind`. /// /// The budget enforcers' first question, and almost always their last: the /// common case is "under budget, evict nothing". Answering it with a `SUM` /// costs one index range rather than materializing every row, which matters /// because `enforce_block_budget` runs on every cached block — once per /// 4 MiB of every cold read — while holding the shared connection. pub fn cache_total_bytes(&self, kind: &str) -> Result { let conn = self.read(); let total: i64 = conn.query_row( "SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1", params![kind], |r| r.get(0), )?; Ok(total.max(0) as u64) } /// The `limit` least-recently-accessed entries of `kind`, oldest first. /// /// Eviction wants a handful of victims, not the whole table. Bounded so a /// pass over a large cache does not read every row to drop a few, and so the /// `(kind, last_accessed)` index can answer it as a range scan with an early /// stop. pub fn cache_eviction_candidates( &self, kind: &str, limit: usize, ) -> Result> { let conn = self.read(); let mut stmt = conn.prepare_cached( "SELECT cache_key, size_bytes FROM cache_entries WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2", )?; let rows = stmt.query_map(params![kind, limit as i64], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) })?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Every cache entry of `kind`, ordered least-recently-accessed first, as /// `(cache_key, size_bytes)`. Used by the whole-cache passes /// (`clear_unpinned`); incremental eviction wants /// [`cache_eviction_candidates`](Self::cache_eviction_candidates). pub fn cache_entries_by_kind(&self, kind: &str) -> Result> { let conn = self.read(); let mut stmt = conn.prepare( "SELECT cache_key, size_bytes FROM cache_entries WHERE kind = ?1 ORDER BY last_accessed ASC", )?; let rows = stmt.query_map(params![kind], |r| { Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?.max(0) as u64)) })?; let mut out = Vec::new(); for r in rows { out.push(r?); } Ok(out) } /// Replace the whole cache index with `entries`, as one transaction. The /// daemon calls this on open, so a crash or an external file deletion can /// never leave a phantom row inflating the budget total. /// /// The clear and the refill are the same commit deliberately: they are one /// operation — "the index now describes what is on disk" — and a crash /// between them would otherwise leave an empty index against a full cache /// directory, i.e. a budget total of zero and nothing eviction can see. /// /// One commit also matters for speed. A mount is the only caller and it runs /// on the open path, so a cache of thousands of blobs used to pay thousands /// of autocommit fsyncs before the mountpoint appeared. pub fn cache_rebuild(&self, entries: &[CacheEntryInput<'_>]) -> Result<()> { let mut conn = self.conn.lock(); let tx = conn.transaction()?; tx.execute("DELETE FROM cache_entries", [])?; { let mut stmt = tx.prepare( "INSERT INTO cache_entries (cache_key, kind, size_bytes, last_accessed) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(cache_key) DO UPDATE SET kind = excluded.kind, size_bytes = excluded.size_bytes, last_accessed = excluded.last_accessed", )?; for e in entries { stmt.execute(params![e.key, e.kind, e.size as i64, e.last_accessed])?; } } tx.commit()?; Ok(()) } // --- Pins (P5) -------------------------------------------------------- // // The pin registry, formerly `pins.json`. One row per directly-pinned node, // keyed by uid display string. `recursive` marks a folder pin: its whole // subtree counts as pinned, resolved against `nodes` via a CTE so a new // descendant is covered the moment it lands in the node cache. }