add cache ttl

This commit is contained in:
slonkazoid 2024-12-29 13:31:38 +03:00
parent 74b61e67f5
commit 9b96b09ee0
Signed by: slonk
SSH key fingerprint: SHA256:tbZfJX4IOvZ0LGWOWu5Ijo8jfMPi78TU7x1VoEeCIjM
9 changed files with 168 additions and 77 deletions

View file

@ -43,8 +43,12 @@ port = 3000 # port to listen on
[cache]
enable = true # save metadata and rendered posts into RAM
# highly recommended, only turn off if absolutely necessary
#ttl = 5 # how long should and item persist in cache,
# in milliseconds
# uncomment to enable
cleanup = true # clean cache, highly recommended
#cleanup_interval = 86400000 # clean the cache regularly instead of just at startup
#cleanup_interval = 86400000 # clean the cache regularly instead of
# just at startup, value in milliseconds
# uncomment to enable
persistence = true # save the cache to on shutdown and load on startup
file = "cache" # file to save the cache to

View file

@ -1,14 +1,15 @@
use std::env;
use std::net::{IpAddr, Ipv6Addr};
use std::num::NonZeroU64;
use std::path::PathBuf;
use color_eyre::eyre::{bail, Context, Result};
use color_eyre::eyre::{self, bail, Context};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tracing::{error, info, instrument};
use url::Url;
use crate::ranged_i128_visitor::RangedI128Visitor;
use crate::de::*;
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
#[serde(default)]
@ -31,8 +32,11 @@ pub struct RenderConfig {
#[serde(default)]
pub struct CacheConfig {
pub enable: bool,
#[serde(deserialize_with = "check_millis")]
pub ttl: Option<NonZeroU64>,
pub cleanup: bool,
pub cleanup_interval: Option<u64>,
#[serde(deserialize_with = "check_millis")]
pub cleanup_interval: Option<NonZeroU64>,
pub persistence: bool,
pub file: PathBuf,
pub compress: bool,
@ -198,6 +202,7 @@ impl Default for CacheConfig {
fn default() -> Self {
Self {
enable: true,
ttl: None,
cleanup: true,
cleanup_interval: None,
persistence: true,
@ -215,7 +220,7 @@ impl Default for BlagConfig {
}
#[instrument(name = "config")]
pub async fn load() -> Result<Config> {
pub async fn load() -> eyre::Result<Config> {
let config_file = env::var(format!(
"{}_CONFIG",
env!("CARGO_BIN_NAME").to_uppercase().replace('-', "_")
@ -267,6 +272,13 @@ fn check_zstd_level_bounds<'de, D>(d: D) -> Result<i32, D::Error>
where
D: serde::Deserializer<'de>,
{
d.deserialize_i32(RangedI128Visitor::<1, 22>)
d.deserialize_i32(RangedI64Visitor::<1, 22>)
.map(|x| x as i32)
}
fn check_millis<'de, D>(d: D) -> Result<Option<NonZeroU64>, D::Error>
where
D: serde::Deserializer<'de>,
{
d.deserialize_option(MillisVisitor)
}

86
src/de.rs Normal file
View file

@ -0,0 +1,86 @@
use std::num::NonZeroU64;
use serde::de::Error;
use serde::{
de::{Unexpected, Visitor},
Deserializer,
};
pub struct RangedI64Visitor<const START: i64, const END: i64>;
impl<const START: i64, const END: i64> serde::de::Visitor<'_> for RangedI64Visitor<START, END> {
type Value = i64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "an integer between {START} and {END}")
}
fn visit_i32<E>(self, v: i32) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
self.visit_i64(v as i64)
}
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
if v >= START && v <= END {
Ok(v)
} else {
Err(E::custom(format!(
"integer is out of bounds ({START}..{END})"
)))
}
}
fn visit_i128<E>(self, v: i128) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
self.visit_i64(v as i64)
}
}
pub struct U64Visitor;
impl Visitor<'_> for U64Visitor {
type Value = u64;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a non-negative integer")
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(v)
}
fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
u64::try_from(v).map_err(|_| E::invalid_value(Unexpected::Signed(v), &self))
}
}
pub struct MillisVisitor;
impl<'de> Visitor<'de> for MillisVisitor {
type Value = Option<NonZeroU64>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a positive integer")
}
fn visit_some<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
where
D: Deserializer<'de>,
{
let n = deserializer.deserialize_i64(U64Visitor)?;
NonZeroU64::new(n)
.ok_or(D::Error::invalid_value(Unexpected::Unsigned(n), &self))
.map(Some)
}
}

View file

@ -2,12 +2,12 @@
mod app;
mod config;
mod de;
mod error;
mod helpers;
mod markdown_render;
mod platform;
mod post;
mod ranged_i128_visitor;
mod serve_dir_included;
mod systemtime_as_secs;
mod templates;
@ -32,7 +32,7 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::{util::SubscriberInitExt, EnvFilter};
use crate::app::AppState;
use crate::post::cache::{load_cache, CacheGuard, CACHE_VERSION};
use crate::post::cache::{load_cache, Cache, CacheGuard, CACHE_VERSION};
use crate::post::{Blag, MarkdownPosts, PostManager};
use crate::templates::new_registry;
use crate::templates::watcher::watch_templates;
@ -89,17 +89,17 @@ async fn main() -> eyre::Result<()> {
let mut cache = load_cache(&config.cache).await.unwrap_or_else(|err| {
error!("failed to load cache: {}", err);
info!("using empty cache");
Default::default()
Cache::new(config.cache.ttl)
});
if cache.version() < CACHE_VERSION {
warn!("cache version changed, clearing cache");
cache = Default::default();
cache = Cache::new(config.cache.ttl);
};
Some(cache)
} else {
Some(Default::default())
Some(Cache::new(config.cache.ttl))
}
} else {
None
@ -122,7 +122,7 @@ async fn main() -> eyre::Result<()> {
let token = cancellation_token.child_token();
debug!("setting up cleanup task");
tasks.spawn(async move {
let mut interval = tokio::time::interval(Duration::from_millis(millis));
let mut interval = tokio::time::interval(Duration::from_millis(millis.into()));
loop {
select! {
_ = token.cancelled() => break Ok(()),

View file

@ -248,7 +248,7 @@ impl PostManager for Blag {
return Err(PostError::NotFound(name));
}
let mtime = as_secs(&stat.modified()?);
let mtime = as_secs(stat.modified()?);
let query_json = serde_json::to_string(&query).expect("this should not fail");
let mut hasher = DefaultHasher::new();
@ -314,7 +314,7 @@ impl PostManager for Blag {
)
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(|mtime| as_secs(&mtime));
.map(as_secs);
match mtime {
Some(mtime) => mtime <= value.mtime,

View file

@ -1,7 +1,9 @@
use std::fmt::Debug;
use std::io::{Read, Write};
use std::num::NonZeroU64;
use std::ops::Deref;
use std::sync::Arc;
use std::time::SystemTime;
use crate::config::CacheConfig;
use crate::post::PostMetadata;
@ -14,20 +16,24 @@ use tracing::{debug, info, instrument, trace, Span};
/// do not persist cache if this version number changed
pub const CACHE_VERSION: u16 = 5;
fn now() -> u128 {
crate::systemtime_as_secs::as_millis(SystemTime::now())
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CacheValue {
pub meta: PostMetadata,
pub body: Arc<str>,
pub mtime: u64,
/// when the item was inserted into cache, in milliseconds since epoch
pub cached_at: u128,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Cache(HashMap<CacheKey, CacheValue>, u16);
impl Default for Cache {
fn default() -> Self {
Self(Default::default(), CACHE_VERSION)
}
pub struct Cache {
map: HashMap<CacheKey, CacheValue>,
version: u16,
ttl: Option<NonZeroU64>,
}
#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Clone, Debug)]
@ -38,15 +44,30 @@ pub struct CacheKey {
}
impl Cache {
pub fn new(ttl: Option<NonZeroU64>) -> Self {
Cache {
map: Default::default(),
version: CACHE_VERSION,
ttl,
}
}
fn up_to_date(&self, cached: &CacheValue, mtime: u64) -> bool {
mtime <= cached.mtime
&& self
.ttl
.is_some_and(|ttl| cached.cached_at + u64::from(ttl) as u128 >= now())
}
#[instrument(level = "debug", skip(self), fields(entry_mtime))]
pub async fn lookup(&self, name: Arc<str>, mtime: u64, extra: u64) -> Option<CacheValue> {
trace!("looking up in cache");
match self.0.get_async(&CacheKey { name, extra }).await {
match self.map.get_async(&CacheKey { name, extra }).await {
Some(entry) => {
let cached = entry.get();
Span::current().record("entry_mtime", cached.mtime);
trace!("found in cache");
if mtime <= cached.mtime {
if self.up_to_date(cached, mtime) {
trace!("entry up-to-date");
Some(cached.clone())
} else {
@ -67,11 +88,11 @@ impl Cache {
extra: u64,
) -> Option<PostMetadata> {
trace!("looking up metadata in cache");
match self.0.get_async(&CacheKey { name, extra }).await {
match self.map.get_async(&CacheKey { name, extra }).await {
Some(entry) => {
let cached = entry.get();
Span::current().record("entry_mtime", cached.mtime);
if mtime <= cached.mtime {
if self.up_to_date(cached, mtime) {
trace!("entry up-to-date");
Some(cached.meta.clone())
} else {
@ -96,13 +117,14 @@ impl Cache {
trace!("inserting into cache");
let r = self
.0
.map
.upsert_async(
CacheKey { name, extra },
CacheValue {
meta: metadata,
body: rendered,
mtime,
cached_at: now(),
},
)
.await;
@ -123,7 +145,7 @@ impl Cache {
pub async fn remove(&self, name: Arc<str>, extra: u64) -> Option<(CacheKey, CacheValue)> {
trace!("removing from cache");
let r = self.0.remove_async(&CacheKey { name, extra }).await;
let r = self.map.remove_async(&CacheKey { name, extra }).await;
debug!(
"item {} cache",
@ -138,12 +160,12 @@ impl Cache {
#[instrument(level = "debug", name = "cleanup", skip_all)]
pub async fn retain(&self, predicate: impl Fn(&CacheKey, &CacheValue) -> bool) {
let old_size = self.0.len();
let old_size = self.map.len();
let mut i = 0;
// TODO: multithread
// not urgent as this is run concurrently anyways
self.0
self.map
.retain_async(|k, v| {
if predicate(k, v) {
true
@ -160,12 +182,12 @@ impl Cache {
}
pub fn len(&self) -> usize {
self.0.len()
self.map.len()
}
#[inline(always)]
pub fn version(&self) -> u16 {
self.1
self.version
}
}

View file

@ -125,7 +125,7 @@ impl MarkdownPosts {
.insert(
name.clone(),
metadata.clone(),
as_secs(&modified),
as_secs(modified),
Arc::clone(&post),
self.render_hash,
)
@ -184,7 +184,7 @@ impl PostManager for MarkdownPosts {
let stat = fs::metadata(&path).await?;
if stat.is_file() && path.extension().is_some_and(|ext| ext == "md") {
let mtime = as_secs(&stat.modified()?);
let mtime = as_secs(stat.modified()?);
let name: Arc<str> =
String::from(path.file_stem().unwrap().to_string_lossy()).into();
@ -262,7 +262,7 @@ impl PostManager for MarkdownPosts {
}
}
};
let mtime = as_secs(&stat.modified()?);
let mtime = as_secs(stat.modified()?);
if let Some(cache) = &self.cache
&& let Some(CacheValue { meta, body, .. }) =
@ -311,7 +311,7 @@ impl PostManager for MarkdownPosts {
)
.ok()
.and_then(|metadata| metadata.modified().ok())
.map(|mtime| as_secs(&mtime));
.map(as_secs);
match mtime {
Some(mtime) => mtime <= value.mtime,

View file

@ -1,37 +0,0 @@
pub struct RangedI128Visitor<const START: i128, const END: i128>;
impl<const START: i128, const END: i128> serde::de::Visitor<'_>
for RangedI128Visitor<START, END>
{
type Value = i128;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "an integer between {START} and {END}")
}
fn visit_i32<E>(self, v: i32) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
self.visit_i128(v as i128)
}
fn visit_i64<E>(self, v: i64) -> std::prelude::v1::Result<Self::Value, E>
where
E: serde::de::Error,
{
self.visit_i128(v as i128)
}
fn visit_i128<E>(self, v: i128) -> std::prelude::v1::Result<Self::Value, E>
where
E: serde::de::Error,
{
if v >= START && v <= END {
Ok(v)
} else {
Err(E::custom(format!(
"integer is out of bounds ({START}..{END})"
)))
}
}
}

View file

@ -1,9 +1,13 @@
use std::time::SystemTime;
pub fn as_secs(t: &SystemTime) -> u64 {
match t.duration_since(SystemTime::UNIX_EPOCH) {
Ok(duration) => duration,
Err(err) => err.duration(),
}
.as_secs()
pub fn as_secs(t: SystemTime) -> u64 {
t.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_else(|err| err.duration())
.as_secs()
}
pub fn as_millis(t: SystemTime) -> u128 {
t.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_else(|err| err.duration())
.as_millis()
}