193 lines
7.1 KiB
Rust
193 lines
7.1 KiB
Rust
//! Content-hash derivation cache store (design 2026-05-31 §3.2 / §3.5).
|
|
//!
|
|
//! Backs the `derivation_cache` table (`V012`). The cache stores expensive
|
|
//! ingest derivations (embedding vectors, LLM aliases, optional Korean
|
|
//! tokens) keyed by `derivation_cache_key` (§3.1). It is a pure performance
|
|
//! layer: corruption / deletion only forces recomputation, never wrong
|
|
//! results (§3.5). Timestamps follow the same RFC3339 `OffsetDateTime`
|
|
//! formatting the asset / document / embedding writers use.
|
|
|
|
use anyhow::{Context, Result};
|
|
use rusqlite::{OptionalExtension, params};
|
|
use time::OffsetDateTime;
|
|
use time::format_description::well_known::Rfc3339;
|
|
|
|
use crate::error::StoreError;
|
|
use crate::store::SqliteStore;
|
|
|
|
impl SqliteStore {
|
|
/// Look up a cached derivation payload by its content-hash key.
|
|
///
|
|
/// Pure read — does **not** bump `last_used_at`. Callers that want LRU
|
|
/// freshness on a hit collect the hit keys and call [`Self::touch`] once
|
|
/// per batch (cheaper than a write per `get`).
|
|
pub fn derivation_cache_get(&self, cache_key: &str) -> Result<Option<Vec<u8>>> {
|
|
let conn = self.lock_conn();
|
|
let payload: Option<Vec<u8>> = conn
|
|
.query_row(
|
|
"SELECT payload FROM derivation_cache WHERE cache_key = ?",
|
|
params![cache_key],
|
|
|row| row.get::<_, Vec<u8>>(0),
|
|
)
|
|
.optional()
|
|
.map_err(StoreError::from)
|
|
.context("derivation_cache_get")?;
|
|
Ok(payload)
|
|
}
|
|
|
|
/// Insert (or overwrite) a cached derivation payload.
|
|
///
|
|
/// `INSERT OR REPLACE` so a re-computation of the same key (e.g. after a
|
|
/// manual cache clear, or a non-deterministic LLM regenerating) refreshes
|
|
/// `created_at` / `last_used_at` to the new attempt. The key already folds
|
|
/// every version-cascade input (§3.1), so an overwrite is always the same
|
|
/// logical derivation.
|
|
pub fn derivation_cache_put(&self, cache_key: &str, kind: &str, payload: &[u8]) -> Result<()> {
|
|
let now = OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.context("format derivation_cache.created_at")?;
|
|
let conn = self.lock_conn();
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO derivation_cache
|
|
(cache_key, kind, payload, created_at, last_used_at)
|
|
VALUES (?, ?, ?, ?, ?)",
|
|
params![cache_key, kind, payload, now, now],
|
|
)
|
|
.map_err(StoreError::from)
|
|
.context("derivation_cache_put")?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Bump `last_used_at` for the given hit keys (LRU freshness, §3.5).
|
|
///
|
|
/// Run in a single transaction. Missing keys are a no-op. Called once per
|
|
/// ingest batch with the keys that hit, so the GC pass keeps live chunks.
|
|
pub fn derivation_cache_touch(&self, keys: &[String]) -> Result<()> {
|
|
if keys.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let now = OffsetDateTime::now_utc()
|
|
.format(&Rfc3339)
|
|
.context("format derivation_cache.last_used_at")?;
|
|
let mut conn = self.lock_conn();
|
|
let tx = conn.transaction().map_err(StoreError::from)?;
|
|
{
|
|
let mut stmt = tx
|
|
.prepare("UPDATE derivation_cache SET last_used_at = ? WHERE cache_key = ?")
|
|
.map_err(StoreError::from)?;
|
|
for key in keys {
|
|
stmt.execute(params![now, key])
|
|
.map_err(StoreError::from)
|
|
.context("derivation_cache_touch")?;
|
|
}
|
|
}
|
|
tx.commit().map_err(StoreError::from)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete cache entries whose `last_used_at` is older than `ttl_days`
|
|
/// (§3.5 lightweight GC). Returns the number of rows removed.
|
|
///
|
|
/// `ttl_days <= 0` is a no-op guard (never wipe the whole cache by an
|
|
/// accidental zero TTL).
|
|
pub fn derivation_cache_gc(&self, ttl_days: i64) -> Result<usize> {
|
|
if ttl_days <= 0 {
|
|
return Ok(0);
|
|
}
|
|
let cutoff = (OffsetDateTime::now_utc() - time::Duration::days(ttl_days))
|
|
.format(&Rfc3339)
|
|
.context("format derivation_cache gc cutoff")?;
|
|
let conn = self.lock_conn();
|
|
let removed = conn
|
|
.execute(
|
|
"DELETE FROM derivation_cache WHERE last_used_at < ?",
|
|
params![cutoff],
|
|
)
|
|
.map_err(StoreError::from)
|
|
.context("derivation_cache_gc")?;
|
|
Ok(removed)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::store::SqliteStore;
|
|
|
|
fn open_store() -> (tempfile::TempDir, SqliteStore) {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut cfg = kebab_config::Config::defaults();
|
|
cfg.storage.data_dir = dir.path().to_string_lossy().into_owned();
|
|
let store = SqliteStore::open(&cfg.storage).unwrap();
|
|
store.run_migrations().unwrap();
|
|
(dir, store)
|
|
}
|
|
|
|
#[test]
|
|
fn put_then_get_roundtrips() {
|
|
let (_d, store) = open_store();
|
|
store
|
|
.derivation_cache_put("key1", "embedding", &[1, 2, 3, 4])
|
|
.unwrap();
|
|
let got = store.derivation_cache_get("key1").unwrap();
|
|
assert_eq!(got, Some(vec![1, 2, 3, 4]));
|
|
}
|
|
|
|
#[test]
|
|
fn get_miss_returns_none() {
|
|
let (_d, store) = open_store();
|
|
assert_eq!(store.derivation_cache_get("absent").unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn put_replaces_existing() {
|
|
let (_d, store) = open_store();
|
|
store.derivation_cache_put("k", "alias", b"old").unwrap();
|
|
store.derivation_cache_put("k", "alias", b"new").unwrap();
|
|
assert_eq!(
|
|
store.derivation_cache_get("k").unwrap(),
|
|
Some(b"new".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn touch_missing_keys_is_noop() {
|
|
let (_d, store) = open_store();
|
|
store
|
|
.derivation_cache_touch(&["nope".to_string()])
|
|
.unwrap();
|
|
assert_eq!(store.derivation_cache_get("nope").unwrap(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn gc_zero_ttl_is_noop() {
|
|
let (_d, store) = open_store();
|
|
store.derivation_cache_put("k", "embedding", b"x").unwrap();
|
|
assert_eq!(store.derivation_cache_gc(0).unwrap(), 0);
|
|
assert!(store.derivation_cache_get("k").unwrap().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn gc_removes_stale_entries() {
|
|
let (_d, store) = open_store();
|
|
store.derivation_cache_put("fresh", "embedding", b"x").unwrap();
|
|
// Backdate one row by 100 days via a direct UPDATE.
|
|
let old = (OffsetDateTime::now_utc() - time::Duration::days(100))
|
|
.format(&Rfc3339)
|
|
.unwrap();
|
|
{
|
|
let conn = store.lock_conn();
|
|
conn.execute(
|
|
"INSERT INTO derivation_cache (cache_key, kind, payload, created_at, last_used_at)
|
|
VALUES ('stale', 'embedding', ?, ?, ?)",
|
|
params![&b"y"[..], &old, &old],
|
|
)
|
|
.unwrap();
|
|
}
|
|
let removed = store.derivation_cache_gc(30).unwrap();
|
|
assert_eq!(removed, 1);
|
|
assert!(store.derivation_cache_get("stale").unwrap().is_none());
|
|
assert!(store.derivation_cache_get("fresh").unwrap().is_some());
|
|
}
|
|
}
|