use std::path::Path;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use redb::{Database, ReadableDatabase, TableDefinition};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
pub const SCHEMA_VERSION: u64 = 10;
const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
const HTTP: TableDefinition<&str, &[u8]> = TableDefinition::new("http");
const QUERIES: TableDefinition<&str, &[u8]> = TableDefinition::new("queries");
pub mod reviews;
pub use reviews::Reviews;
const VERSION_KEY: &str = "schema_version";
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("cache database error: {0}")]
Db(#[from] redb::Error),
#[error("cache encoding error: {0}")]
Encoding(#[from] serde_json::Error),
#[error("cache file error: {0}")]
Io(#[from] std::io::Error),
}
macro_rules! from_redb {
($($ty:ty),*) => {$(
impl From<$ty> for StoreError {
fn from(err: $ty) -> Self {
StoreError::Db(err.into())
}
}
)*};
}
from_redb!(
redb::DatabaseError,
redb::TransactionError,
redb::TableError,
redb::StorageError,
redb::CommitError
);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HttpEntry {
pub etag: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cached<T> {
pub value: T,
pub fetched_at: u64,
}
#[derive(Serialize, Deserialize)]
struct QueryEntry {
fetched_at: u64,
value: serde_json::Value,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewState {
#[serde(default)]
pub last_reviewed_head: Option<String>,
#[serde(default)]
pub reviewed_hunks: Vec<String>,
#[serde(default)]
pub pending: Vec<DraftComment>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DraftSide {
Left,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DraftComment {
pub id: u64,
pub path: String,
pub body: String,
pub side: DraftSide,
pub line: Option<u32>,
pub start_line: Option<u32>,
pub start_side: Option<DraftSide>,
pub commit: String,
#[serde(default)]
pub error: Option<String>,
}
impl ReviewState {
pub fn next_draft_id(&self) -> u64 {
self.pending.iter().map(|d| d.id).max().map_or(1, |m| m + 1)
}
}
#[derive(Clone)]
pub struct Store {
db: Option<Arc<Database>>,
}
impl std::fmt::Debug for Store {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Store")
.field("enabled", &self.db.is_some())
.finish()
}
}
impl Store {
pub fn open(path: &Path) -> Self {
match open_versioned(path) {
Ok(db) => Self {
db: Some(Arc::new(db)),
},
Err(err) => {
tracing::warn!(path = %path.display(), %err, "cache disabled");
Self::disabled()
}
}
}
pub fn disabled() -> Self {
Self { db: None }
}
pub fn is_enabled(&self) -> bool {
self.db.is_some()
}
pub fn http_get(&self, key: &str) -> Option<HttpEntry> {
self.read_json(HTTP, key)
}
pub fn http_put(&self, key: &str, entry: &HttpEntry) {
self.write_json(HTTP, key, entry);
}
pub fn query_get<T: DeserializeOwned>(&self, key: &str) -> Option<Cached<T>> {
let entry: QueryEntry = self.read_json(QUERIES, key)?;
match serde_json::from_value(entry.value) {
Ok(value) => Some(Cached {
value,
fetched_at: entry.fetched_at,
}),
Err(err) => {
tracing::debug!(key, %err, "cached query no longer decodes; ignoring");
None
}
}
}
pub fn query_put<T: Serialize>(&self, key: &str, value: &T) {
let value = match serde_json::to_value(value) {
Ok(value) => value,
Err(err) => {
tracing::warn!(key, %err, "cannot encode query result for cache");
return;
}
};
self.write_json(
QUERIES,
key,
&QueryEntry {
fetched_at: now(),
value,
},
);
}
fn read_json<T: DeserializeOwned>(
&self,
table: TableDefinition<&str, &[u8]>,
key: &str,
) -> Option<T> {
let db = self.db.as_ref()?;
let result = (|| -> Result<Option<T>, StoreError> {
let txn = db.begin_read()?;
let table = match txn.open_table(table) {
Ok(table) => table,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
Err(err) => return Err(err.into()),
};
let Some(bytes) = table.get(key)? else {
return Ok(None);
};
Ok(Some(serde_json::from_slice(bytes.value())?))
})();
result.unwrap_or_else(|err| {
tracing::warn!(key, %err, "cache read failed");
None
})
}
fn write_json<T: Serialize + ?Sized>(
&self,
table: TableDefinition<&str, &[u8]>,
key: &str,
value: &T,
) {
let Some(db) = self.db.as_ref() else { return };
let result = (|| -> Result<(), StoreError> {
let bytes = serde_json::to_vec(value)?;
let txn = db.begin_write()?;
{
let mut table = txn.open_table(table)?;
table.insert(key, bytes.as_slice())?;
}
txn.commit()?;
Ok(())
})();
if let Err(err) = result {
tracing::warn!(key, %err, "cache write failed");
}
}
}
fn open_versioned(path: &Path) -> Result<Database, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if path.exists() {
match Database::create(path) {
Ok(db) if stored_version(&db)? == Some(SCHEMA_VERSION) => return Ok(db),
Ok(db) => {
tracing::info!("cache schema changed; discarding {}", path.display());
drop(db);
}
Err(err @ redb::DatabaseError::DatabaseAlreadyOpen) => return Err(err.into()),
Err(err) => tracing::warn!(%err, "cache unreadable; discarding {}", path.display()),
}
std::fs::remove_file(path)?;
}
let db = Database::create(path)?;
let txn = db.begin_write()?;
{
let mut meta = txn.open_table(META)?;
meta.insert(VERSION_KEY, SCHEMA_VERSION)?;
}
txn.commit()?;
Ok(db)
}
fn stored_version(db: &Database) -> Result<Option<u64>, StoreError> {
let txn = db.begin_read()?;
let meta = match txn.open_table(META) {
Ok(meta) => meta,
Err(redb::TableError::TableDoesNotExist(_)) => return Ok(None),
Err(err) => return Err(err.into()),
};
Ok(meta.get(VERSION_KEY)?.map(|v| v.value()))
}
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_queries_and_http() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&dir.path().join("cache.redb"));
assert!(store.is_enabled());
store.query_put("prs", &vec![1, 2, 3]);
let cached: Cached<Vec<u32>> = store.query_get("prs").unwrap();
assert_eq!(cached.value, vec![1, 2, 3]);
let entry = HttpEntry {
etag: "W/\"abc\"".into(),
body: "{}".into(),
};
store.http_put("/user", &entry);
assert_eq!(store.http_get("/user"), Some(entry));
assert_eq!(store.http_get("/missing"), None);
}
#[test]
fn persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.redb");
Store::open(&path).query_put("k", &7);
let store = Store::open(&path);
assert_eq!(store.query_get::<i32>("k").unwrap().value, 7);
}
#[test]
fn discards_cache_on_version_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.redb");
{
let store = Store::open(&path);
store.query_put("k", &1);
let db = store.db.as_ref().unwrap();
let txn = db.begin_write().unwrap();
txn.open_table(META)
.unwrap()
.insert(VERSION_KEY, SCHEMA_VERSION + 1)
.unwrap();
txn.commit().unwrap();
}
let store = Store::open(&path);
assert!(store.is_enabled());
assert!(store.query_get::<i32>("k").is_none());
}
#[test]
fn discards_corrupt_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.redb");
std::fs::write(&path, b"definitely not a redb file").unwrap();
let store = Store::open(&path);
assert!(store.is_enabled());
store.query_put("k", &1);
assert_eq!(store.query_get::<i32>("k").unwrap().value, 1);
}
#[test]
fn second_open_degrades_to_disabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("cache.redb");
let first = Store::open(&path);
first.query_put("k", &1);
let second = Store::open(&path);
assert!(!second.is_enabled());
assert!(second.query_get::<i32>("k").is_none());
assert_eq!(first.query_get::<i32>("k").unwrap().value, 1);
}
#[test]
fn disabled_store_is_inert() {
let store = Store::disabled();
store.query_put("k", &1);
assert!(store.query_get::<i32>("k").is_none());
}
}