mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
197 lines
7.8 KiB
Rust
197 lines
7.8 KiB
Rust
//! 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 (`<key>.b<idx>`) and thumbnails (`<key>.t<type>`).
|
|
/// 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<u64> {
|
|
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<Vec<(String, u64)>> {
|
|
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<Vec<(String, u64)>> {
|
|
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.
|
|
}
|