Kept bytes against the limit was a flat line once the cache filled. Now a percentage of the limit beside the share served from disk, and the images each sweep evicts. The counter shipped minutes ago as ipx_art_trimmed_total is renamed ipx_art_evicted_total before anything came to read it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
139 lines
5.5 KiB
Rust
139 lines
5.5 KiB
Rust
//! Artwork kept on disk, so the page loads it from iPX: fast after the first time, nothing asked
|
|
//! of each publisher's server for every visit, and still there when a publisher is not.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::collections::HashMap;
|
|
use std::sync::{LazyLock, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
|
|
pub fn dir() -> PathBuf {
|
|
crate::config::data_dir().join("art")
|
|
}
|
|
|
|
/// Where an image's bytes are kept; its content type is beside it, in `<name>.type`.
|
|
// ponytail: std's hasher, 64 bits, keyed by the URL. Its algorithm may change with Rust, which
|
|
// only makes the cache miss once and refill; a collision among a few thousand images is about
|
|
// one in 10^12. Move to a SHA if either ever matters.
|
|
pub fn path(url: &str) -> PathBuf {
|
|
use std::hash::{Hash, Hasher};
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
url.hash(&mut h);
|
|
dir().join(format!("{:016x}", h.finish()))
|
|
}
|
|
|
|
/// A kept image and its type, marked as just used, which is what keeps it from being trimmed.
|
|
pub fn read(file: &Path) -> Option<(String, Vec<u8>)> {
|
|
let kind = std::fs::read_to_string(file.with_extension("type")).ok()?;
|
|
let body = std::fs::read(file).ok()?;
|
|
if let Ok(f) = std::fs::File::options().write(true).open(file) {
|
|
let _ = f.set_modified(std::time::SystemTime::now());
|
|
}
|
|
Some((kind, body))
|
|
}
|
|
|
|
/// Keeps an image, written aside and renamed into place, so a page asking for it meanwhile
|
|
/// never reads half of one.
|
|
pub fn write(file: &Path, kind: &str, body: &[u8]) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(file.parent().unwrap_or(Path::new(".")))?;
|
|
let tmp = file.with_extension("part");
|
|
std::fs::write(&tmp, body)?;
|
|
std::fs::write(file.with_extension("type"), kind)?;
|
|
std::fs::rename(&tmp, file)
|
|
}
|
|
|
|
/// How long an address that could not be fetched is answered from memory before it is tried again.
|
|
const RETRY: Duration = Duration::from_secs(60 * 60);
|
|
|
|
/// Addresses that could not be fetched lately, and why. Without it a page shown again fetched
|
|
/// them again: five covers too big to keep were downloaded from Patreon on every load (#169).
|
|
// ponytail: in memory, and an entry goes only when it is asked for after RETRY. It holds at most
|
|
// one per artwork address a feed or item names; move it to disk or prune it on trim if that grows.
|
|
static FAILED: LazyLock<Mutex<HashMap<String, (Instant, String)>>> = LazyLock::new(Default::default);
|
|
|
|
/// Why `url` could not be fetched, if it failed less than an hour ago.
|
|
pub fn failed(url: &str) -> Option<String> {
|
|
failed_as_of(url, Instant::now())
|
|
}
|
|
|
|
fn failed_as_of(url: &str, now: Instant) -> Option<String> {
|
|
let mut map = FAILED.lock().unwrap_or_else(|e| e.into_inner());
|
|
match map.get(url) {
|
|
Some((at, why)) if now.duration_since(*at) < RETRY => Some(why.clone()),
|
|
Some(_) => {
|
|
map.remove(url);
|
|
None
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
pub fn fail(url: &str, why: &str) {
|
|
FAILED.lock().unwrap_or_else(|e| e.into_inner()).insert(url.to_owned(), (Instant::now(), why.to_owned()));
|
|
}
|
|
|
|
/// Removes the least recently used images until what is kept fits in `limit` bytes; 0 empties it.
|
|
/// Returns how many went.
|
|
pub fn trim(dir: &Path, limit: u64) -> usize {
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
|
let mut kept: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
|
.flatten()
|
|
.map(|e| e.path())
|
|
.filter(|p| p.extension().is_none())
|
|
.filter_map(|p| {
|
|
let m = p.metadata().ok()?;
|
|
Some((m.modified().ok()?, m.len(), p))
|
|
})
|
|
.collect();
|
|
let mut total: u64 = kept.iter().map(|(_, n, _)| n).sum();
|
|
kept.sort();
|
|
let mut gone = 0;
|
|
for (_, n, p) in kept {
|
|
if total <= limit {
|
|
break;
|
|
}
|
|
let _ = std::fs::remove_file(p.with_extension("type"));
|
|
if std::fs::remove_file(&p).is_ok() {
|
|
total -= n;
|
|
gone += 1;
|
|
}
|
|
}
|
|
crate::metrics::STATS.art_cache_bytes.store(total, std::sync::atomic::Ordering::Relaxed);
|
|
crate::metrics::STATS.art_evicted.fetch_add(gone as u64, std::sync::atomic::Ordering::Relaxed);
|
|
gone
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_least_recently_used_go_first_until_it_fits() {
|
|
let d = std::env::temp_dir().join(format!("ipx-art-test-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
for (name, age) in [("old", 300), ("mid", 200), ("new", 100)] {
|
|
let f = d.join(name);
|
|
write(&f, "image/png", &[0; 1000]).unwrap();
|
|
let t = std::time::SystemTime::now() - std::time::Duration::from_secs(age);
|
|
std::fs::File::options().write(true).open(&f).unwrap().set_modified(t).unwrap();
|
|
}
|
|
// Shown just now: no longer the oldest.
|
|
assert_eq!(read(&d.join("old")).unwrap().0, "image/png");
|
|
assert_eq!(trim(&d, 2000), 1);
|
|
assert!(!d.join("mid").exists() && !d.join("mid.type").exists());
|
|
assert!(d.join("old").exists() && d.join("new").exists());
|
|
assert_eq!(trim(&d, 0), 2);
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
}
|
|
|
|
#[test]
|
|
fn a_failure_is_remembered_for_an_hour() {
|
|
let u = "https://example.com/a-failure-is-remembered.png";
|
|
assert_eq!(failed(u), None);
|
|
fail(u, "larger than 5 MB");
|
|
assert_eq!(failed(u).as_deref(), Some("larger than 5 MB"));
|
|
assert_eq!(failed_as_of(u, Instant::now() + RETRY), None);
|
|
// Asked for after the hour, it is forgotten, not just ignored.
|
|
assert_eq!(failed(u), None);
|
|
}
|
|
}
|