use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
pub(super) const MAX_SHARD_BYTES: u64 = 4 * 1024 * 1024;
const LOCK_WAIT: Duration = Duration::from_millis(250);
const SHARD_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy)]
pub struct Register {
pub name: &'static str,
pub shard_entries: usize,
pub max_age_seconds: i64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Shard<V> {
version: u8,
entries: BTreeMap<String, Stamped<V>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Stamped<V> {
recorded_at: i64,
value: V,
}
pub fn now() -> i64 {
chrono::Utc::now().timestamp()
}
pub(super) fn valid_key(key: &str) -> bool {
key.len() == 64
&& key
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
impl Register {
fn dir(&self) -> PathBuf {
super::archive_base_dir().join(self.name)
}
pub(super) fn shard_path(&self, key: &str) -> PathBuf {
self.dir().join(format!("{}.json", &key[..2]))
}
fn fresh<V>(&self, entry: &Stamped<V>, now: i64) -> bool {
now.checked_sub(entry.recorded_at)
.is_some_and(|age| (0..=self.max_age_seconds).contains(&age))
}
fn load<V: DeserializeOwned>(&self, key: &str) -> Option<Shard<V>> {
let path = self.shard_path(key);
match std::fs::symlink_metadata(&path) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Some(Shard {
version: SHARD_VERSION,
entries: BTreeMap::new(),
});
}
Err(_) => return None,
Ok(metadata) if metadata.len() > MAX_SHARD_BYTES => return None,
Ok(_) => {}
}
let raw =
crate::core::atomic_fs::read_store_file(&super::archive_base_dir(), &path).ok()?;
if raw.len() as u64 > MAX_SHARD_BYTES {
return None;
}
serde_json::from_str::<Shard<V>>(&raw)
.ok()
.filter(|shard| shard.version == SHARD_VERSION)
}
pub(super) fn read<V: DeserializeOwned>(&self, key: &str, now: i64) -> Option<V> {
if !valid_key(key) {
return None;
}
self.load::<V>(key)?
.entries
.remove(key)
.filter(|entry| self.fresh(entry, now))
.map(|entry| entry.value)
}
pub(super) fn update<V: Serialize + DeserializeOwned>(
&self,
key: &str,
now: i64,
merge: impl FnOnce(Option<V>) -> Option<V>,
) -> bool {
if !valid_key(key) {
return false;
}
let path = self.shard_path(key);
self.locked(|| {
let mut shard = self.load::<V>(key).unwrap_or(Shard {
version: SHARD_VERSION,
entries: BTreeMap::new(),
});
shard.entries.retain(|_, entry| self.fresh(entry, now));
let current = shard.entries.remove(key).map(|entry| entry.value);
let Some(value) = merge(current) else {
return false;
};
shard.entries.insert(
key.to_owned(),
Stamped {
recorded_at: now,
value,
},
);
while shard.entries.len() > self.shard_entries {
let Some(oldest) = shard
.entries
.iter()
.filter(|(candidate, _)| candidate.as_str() != key)
.min_by_key(|(candidate, entry)| (entry.recorded_at, (*candidate).clone()))
.map(|(candidate, _)| candidate.clone())
else {
break;
};
shard.entries.remove(&oldest);
}
let Ok(encoded) = serde_json::to_vec(&shard) else {
return false;
};
encoded.len() as u64 <= MAX_SHARD_BYTES && write_private(&path, &encoded)
})
.unwrap_or(false)
}
pub fn update_many<V: Serialize + DeserializeOwned>(
&self,
now: i64,
items: Vec<(String, V)>,
merge: impl Fn(&str, V, V) -> V,
) -> bool {
let mut by_shard: BTreeMap<String, Vec<(String, V)>> = BTreeMap::new();
for (key, value) in items {
if valid_key(&key) {
by_shard
.entry(key[..2].to_owned())
.or_default()
.push((key, value));
}
}
if by_shard.is_empty() {
return true;
}
self.locked(|| {
let mut written = true;
for values in by_shard.into_values() {
let Some((first, _)) = values.first() else {
continue;
};
let path = self.shard_path(first);
let mut shard = self.load::<V>(first).unwrap_or(Shard {
version: SHARD_VERSION,
entries: BTreeMap::new(),
});
shard.entries.retain(|_, entry| self.fresh(entry, now));
for (key, value) in values {
let value = match shard.entries.remove(&key) {
Some(current) => merge(&key, current.value, value),
None => value,
};
shard.entries.insert(
key,
Stamped {
recorded_at: now,
value,
},
);
}
let encoded = loop {
if shard.entries.len() <= self.shard_entries
&& let Ok(encoded) = serde_json::to_vec(&shard)
&& encoded.len() as u64 <= MAX_SHARD_BYTES
{
break Some(encoded);
}
let Some(oldest) = shard
.entries
.iter()
.min_by_key(|(candidate, entry)| (entry.recorded_at, (*candidate).clone()))
.map(|(candidate, _)| candidate.clone())
else {
break None;
};
shard.entries.remove(&oldest);
};
written &= encoded.is_some_and(|encoded| write_private(&path, &encoded));
}
written
})
.unwrap_or(false)
}
pub fn read_all<V: DeserializeOwned>(&self, now: i64) -> Vec<(String, i64, V)> {
let mut out = Vec::new();
for shard in 0..=u8::MAX {
let probe = format!("{shard:02x}{}", "0".repeat(62));
let Some(loaded) = self.load::<V>(&probe) else {
continue;
};
for (key, entry) in loaded.entries {
if valid_key(&key) && key[..2] == probe[..2] && self.fresh(&entry, now) {
out.push((key, entry.recorded_at, entry.value));
}
}
}
out
}
fn locked<T>(&self, operation: impl FnOnce() -> T) -> Option<T> {
use fs2::FileExt;
let directory = self.dir();
std::fs::create_dir_all(&directory).ok()?;
let mut options = std::fs::OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let lock = options.open(directory.join(".lock")).ok()?;
let deadline = Instant::now() + LOCK_WAIT;
loop {
if FileExt::try_lock_exclusive(&lock).is_ok() {
break;
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(5));
}
let result = operation();
let _ = FileExt::unlock(&lock);
Some(result)
}
}
fn write_private(path: &std::path::Path, bytes: &[u8]) -> bool {
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt;
Some(std::fs::Permissions::from_mode(0o600))
};
#[cfg(not(unix))]
let permissions: Option<std::fs::Permissions> = None;
crate::core::atomic_fs::try_atomic_write(path, bytes, permissions.as_ref()).is_ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_shard_over_the_byte_bound_evicts_oldest_until_it_fits() {
let _data = crate::core::data_dir::isolated_data_dir();
let store = Register {
name: "bound-test",
shard_entries: 64,
max_age_seconds: 3600,
};
let large = "x".repeat(1024 * 1024);
let key = |n: u8| format!("aa{n:062}");
let now = now();
let old: Vec<(String, String)> = (0..3).map(|n| (key(n), large.clone())).collect();
assert!(store.update_many(now - 10, old, |_, _, new| new));
let new: Vec<(String, String)> = (3..5).map(|n| (key(n), large.clone())).collect();
assert!(
store.update_many(now, new, |_, _, new| new),
"the write fits after eviction"
);
let kept: Vec<String> = store
.read_all::<String>(now)
.into_iter()
.map(|(key, _, _)| key)
.collect();
assert!(kept.contains(&key(3)) && kept.contains(&key(4)), "{kept:?}");
assert!(!kept.contains(&key(0)), "the oldest record went first");
}
}