Add LRU image cache eviction to headless exporter

This commit is contained in:
Elena Torro 2026-07-23 15:13:28 +02:00
parent d634c6b44e
commit 432de87a7e
6 changed files with 117 additions and 13 deletions

View File

@ -77,6 +77,7 @@ export PENPOT_NITRATE_BACKEND_URI=http://localhost:3000/admin-console
# defaulting to ../frontend/resources/public/js (render-wasm.js/.wasm).
# export PENPOT_WASM_HEADLESS=true
# export PENPOT_WASM_DIR=../frontend/resources/public/js
# export PENPOT_WASM_IMAGE_CACHE_MB=256
export JAVA_OPTS="\
-Djava.util.logging.manager=org.apache.logging.log4j.jul.LogManager \

View File

@ -47,7 +47,10 @@
;; via the in-process Skia/WASM pipeline. `:wasm-dir` points at the built
;; render-wasm artifact (render-wasm.js/.wasm).
[:wasm-headless {:optional true} :boolean]
[:wasm-dir {:optional true} :string]])
[:wasm-dir {:optional true} :string]
;; Byte budget (in MB) for the WASM image cache; least-recently-used
;; images are evicted between requests once the store exceeds it.
[:wasm-image-cache-mb {:optional true} ::sm/int]])
(def ^:private decode-config
(sm/decoder schema:config sm/string-transformer))

View File

@ -423,6 +423,14 @@
;; thenable so `p/mcat` doesn't throw "expected thenable".
(p/do (on-object (assoc object :path path)))))
objects))))))
(p/fmap (fn [result]
;; Trim the image store AFTER the request (never mid-render, so
;; an image can't disappear under a running export). Images the
;; next request needs again are simply re-provisioned.
(let [evicted (wasm/evict-images! (cf/get :wasm-image-cache-mb 256))]
(when (pos? evicted)
(l/info :hint "wasm render: evicted cached images" :count evicted)))
result))
(p/merr (fn [cause]
(l/error :hint "wasm render: failed" :cause cause)
;; A panic/abort can leave the shared module's buffer allocated

View File

@ -181,6 +181,14 @@
(.set heap img-u8 (+ ptr 36))
(h/call module "_store_image")))
(defn evict-images!
"Evicts least-recently-used images until the module's image store retains at
most `max-mb` megabytes. Called between requests, so an image can never
disappear under a running render; evicted images are re-provisioned by any
later request that needs them. Returns the number of evicted images."
[max-mb]
(h/call wasm/internal-module "_evict_images_to_budget" max-mb))
(defn provision-fonts!
"Resolves and uploads every font needed by `shape-id`. `resolve-font` is an
injected fn of the family map -> promise of TTF bytes (or nil to skip). This

View File

@ -724,6 +724,19 @@ pub extern "C" fn is_image_cached(
Ok(result)
}
/// Evicts least-recently-used images until the store retains at most
/// `max_mb` megabytes of image data. Called by the headless exporter between
/// requests — never mid-render, so an image can't disappear under a running
/// export; evicted images are re-provisioned by later requests that need
/// them. Returns the number of evicted images.
#[no_mangle]
#[wasm_error]
pub extern "C" fn evict_images_to_budget(max_mb: u32) -> Result<u32> {
let max_bytes = (max_mb as usize) * 1024 * 1024;
let evicted = get_resources().images.evict_to_budget(max_bytes);
Ok(evicted as u32)
}
#[no_mangle]
#[wasm_error]
pub extern "C" fn set_shape_svg_raw_content() -> Result<()> {

View File

@ -6,6 +6,7 @@ use crate::error::Result;
use crate::get_gpu_state;
use skia_safe::gpu::{surfaces, Budgeted, DirectContext};
use skia_safe::{self as skia, Codec, ISize, Size};
use std::cell::Cell;
use std::collections::HashMap;
pub type Image = skia::Image;
@ -66,8 +67,19 @@ enum StoredImage {
},
}
struct StoredEntry {
image: StoredImage,
/// Approximate retained cost: encoded byte length (raw/svg) or the
/// decoded RGBA size for images registered from a GL texture.
bytes: usize,
/// LRU tick; `Cell` so read paths can touch it without `&mut self`.
last_used: Cell<u64>,
}
pub struct ImageStore {
images: HashMap<(Uuid, bool), StoredImage>,
images: HashMap<(Uuid, bool), StoredEntry>,
total_bytes: usize,
tick: Cell<u64>,
/// gpu-only
context: Option<Box<DirectContext>>,
}
@ -212,6 +224,8 @@ impl ImageStore {
let context = &gpu_state.context;
Self {
images: HashMap::with_capacity(2048),
total_bytes: 0,
tick: Cell::new(0),
context: Some(Box::new(context.clone())),
}
}
@ -222,10 +236,57 @@ impl ImageStore {
pub fn new_without_gpu() -> Self {
Self {
images: HashMap::with_capacity(16),
total_bytes: 0,
tick: Cell::new(0),
context: None,
}
}
/// Bumps the LRU clock and returns the new tick.
fn next_tick(&self) -> u64 {
let t = self.tick.get() + 1;
self.tick.set(t);
t
}
fn insert_entry(&mut self, key: (Uuid, bool), image: StoredImage, bytes: usize) {
let last_used = Cell::new(self.next_tick());
self.total_bytes += bytes;
self.images.insert(
key,
StoredEntry {
image,
bytes,
last_used,
},
);
}
/// Evicts least-recently-used images until the store retains at most
/// `max_bytes`. Meant to be called by the headless exporter *between*
/// requests, so an image can never disappear under a running render;
/// evicted images are simply re-provisioned by a later request that
/// needs them (`is_image_cached` reports them as missing). Returns the
/// number of evicted images.
pub fn evict_to_budget(&mut self, max_bytes: usize) -> usize {
let mut evicted = 0;
while self.total_bytes > max_bytes {
let Some(key) = self
.images
.iter()
.min_by_key(|(_, entry)| entry.last_used.get())
.map(|(key, _)| *key)
else {
break;
};
if let Some(entry) = self.images.remove(&key) {
self.total_bytes -= entry.bytes;
evicted += 1;
}
}
evicted
}
pub fn add(
&mut self,
id: Uuid,
@ -239,40 +300,43 @@ impl ImageStore {
}
let raw_data = image_data.to_vec();
let bytes = raw_data.len();
match self.context.as_mut() {
Some(context) => {
if let Some(gpu_image) = decode_image(context, &raw_data) {
self.images.insert(key, StoredImage::Gpu(gpu_image));
self.insert_entry(key, StoredImage::Gpu(gpu_image), bytes);
} else if let Some((dom, size)) = parse_svg(&raw_data) {
self.images.insert(
self.insert_entry(
key,
StoredImage::Svg {
dom,
size,
raster: None,
},
bytes,
);
} else {
// The lazy re-decode in `get_internal` only retries raster codecs,
// so SVGs that fail to parse here stay raw.
self.images.insert(key, StoredImage::Raw(raw_data));
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
}
}
// GPU-free: keep the encoded bytes; decoded on the CPU at draw time.
// SVGs still get parsed up front since that needs no GPU context.
None => {
if let Some((dom, size)) = parse_svg(&raw_data) {
self.images.insert(
self.insert_entry(
key,
StoredImage::Svg {
dom,
size,
raster: None,
},
bytes,
);
} else {
self.images.insert(key, StoredImage::Raw(raw_data));
self.insert_entry(key, StoredImage::Raw(raw_data), bytes);
}
}
}
@ -303,7 +367,8 @@ impl ImageStore {
));
};
let image = create_image_from_gl_texture(context, texture_id, width, height)?;
self.images.insert(key, StoredImage::Gpu(image));
let bytes = (width as usize) * (height as usize) * 4;
self.insert_entry(key, StoredImage::Gpu(image), bytes);
Ok(())
}
@ -337,7 +402,9 @@ impl ImageStore {
}
fn decode_raw_cpu_image(&self, id: &Uuid, is_thumbnail: bool) -> Option<Image> {
match self.images.get(&(*id, is_thumbnail))? {
let entry = self.images.get(&(*id, is_thumbnail))?;
entry.last_used.set(self.next_tick());
match &entry.image {
StoredImage::Raw(raw_data) => {
let data = unsafe { skia::Data::new_bytes(raw_data) };
Image::from_encoded(&data)
@ -361,7 +428,8 @@ impl ImageStore {
.images
.get(&(*id, false))
.or_else(|| self.images.get(&(*id, true)))?;
match entry {
entry.last_used.set(self.next_tick());
match &entry.image {
StoredImage::Svg { dom, size, .. } => Some((dom, *size)),
_ => None,
}
@ -369,16 +437,19 @@ impl ImageStore {
fn get_internal(&mut self, id: &Uuid, is_thumbnail: bool) -> Option<&Image> {
let key = (*id, is_thumbnail);
let tick = self.tick.get() + 1;
self.tick.set(tick);
// Use entry API to mutate the HashMap in-place if needed
if let Some(entry) = self.images.get_mut(&key) {
match entry {
entry.last_used.set(tick);
match &mut entry.image {
StoredImage::Gpu(ref img) => Some(img),
StoredImage::Raw(raw_data) => {
let context = self.context.as_mut()?;
let gpu_image = decode_image(context, raw_data)?;
*entry = StoredImage::Gpu(gpu_image);
entry.image = StoredImage::Gpu(gpu_image);
if let StoredImage::Gpu(ref img) = entry {
if let StoredImage::Gpu(ref img) = entry.image {
Some(img)
} else {
None