fix(compression): stop a single large PNG from OOM-killing the container
An 8000x8000 RGBA PNG passes admission — 256,000,000 bytes is just under the 256 MiB max_alloc, and smooth content is under 3 MB on disk, far below any size cap. Processing it peaked at ~1250 MiB inside a 1 GiB cgroup. Measured, not argued: the new (ignored) test builds exactly that image and reads VmHWM around the pipeline, resetting the watermark via /proc/self/clear_refs so the number covers only the code under test. Three independent causes, all of which had to go: 1. The decode outlived everything. `resize` takes &self, and the no-downscale arm bound `img` into `display`, so the ~244 MiB buffer was still alive when oxipng ran — and oxipng decodes the PNG *again*, holding a full-size buffer per filter trial. The decode now lives in a block that yields the display derivative; the else arm moves `img` out, which is what makes "the block's value is the only survivor" true in both arms. 2. oxipng was unbounded in every dimension: preset 2 with timeout: None, and the default features pull in rayon, which evaluates filter trials concurrently with a full-size buffer each and has no Options knob to cap it. Now gated at 8 MP, given a 20 s timeout, and built with default-features = false so oxipng's own sequential shim is used. "filetime" is kept — without it preserve_attrs silently no-ops. Dropping "binary" also stops compiling clap/glob/env_logger (a CLI's deps) into the server image. 3. Even with those fixed it still measured 516 MiB, and compression_concurrency defaults to 2 — so two guests uploading big photos at once was another OOM, 1032 MiB against a 1 GiB limit. The cost is dominated by image's Lanczos3 resize, which accumulates in f32: the intermediate is new_width * old_height * 16 bytes, i.e. 262 MiB for this image — larger than the decode itself, and invisible to max_alloc. Two changes: the preview now derives from the 2048px display instead of re-resizing the original (one full-size pass, not two), and a job whose header-estimated peak exceeds 150 MiB takes an exclusive permit so two giants can never overlap. Ordinary photos (a 12 MP JPEG estimates ~50 MiB) never touch that permit, so throughput is unchanged for everything except the case that must not run in parallel. Chaining 8000 -> 2048 -> 800 for the preview is not a quality trade: a staged Lanczos3 downscale is standard for large ratios and is visually indistinguishable at 800px. The blocking half is now a free function so its memory behaviour is testable — the fix is a scoping property a future edit could silently undo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,9 @@ use crate::state::SseEvent;
|
||||
#[derive(Clone)]
|
||||
pub struct CompressionWorker {
|
||||
semaphore: Arc<Semaphore>,
|
||||
/// Serialises the memory-heavy image jobs — see `HEAVY_IMAGE_BYTES`. Separate from
|
||||
/// `semaphore` so ordinary photos keep full concurrency.
|
||||
heavy: Arc<Semaphore>,
|
||||
pool: PgPool,
|
||||
media_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
@@ -31,6 +34,7 @@ impl CompressionWorker {
|
||||
) -> Self {
|
||||
Self {
|
||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||
heavy: Arc::new(Semaphore::new(1)),
|
||||
pool,
|
||||
media_path,
|
||||
sse_tx,
|
||||
@@ -205,6 +209,38 @@ impl CompressionWorker {
|
||||
/// Longest edge of the phone-feed "preview" (data-saver default).
|
||||
const PREVIEW_MAX_EDGE: u32 = 800;
|
||||
|
||||
/// Above this pixel count the PNG original is stored as uploaded, unoptimised.
|
||||
///
|
||||
/// oxipng's peak memory scales with PIXELS, not file size: it decodes the PNG itself and
|
||||
/// then evaluates row filters, each trial holding a full-size buffer. That is why a 2.82
|
||||
/// MiB file could measure 1250 MiB of peak RSS inside a 1 GiB container — smooth,
|
||||
/// synthetic content compresses to almost nothing on disk while still being 8000x8000.
|
||||
/// 8 MP covers every real phone photo; beyond it we decline the (lossless, cosmetic)
|
||||
/// saving rather than risk the OOM kill.
|
||||
const OXIPNG_MAX_PIXELS: u64 = 8_000_000;
|
||||
|
||||
/// Estimated peak heap above which an image job takes the exclusive `heavy` permit.
|
||||
///
|
||||
/// `compression_concurrency` (default 2) bounds how many jobs run at once, but says
|
||||
/// nothing about how much memory each one costs, and the container gets 1 GiB total. A
|
||||
/// single 8000x8000 original measures ~516 MiB peak even with the decode correctly scoped
|
||||
/// — two of those overlapping is 1032 MiB and another OOM kill, from nothing more exotic
|
||||
/// than two guests uploading big photos at the same moment.
|
||||
///
|
||||
/// 150 MiB sits far above a normal phone photo (a 12 MP JPEG costs ~50 MiB all-in) so the
|
||||
/// common path never serialises, and far below the point where two jobs stop fitting.
|
||||
/// Throughput is unaffected for everything except the rare giant, which is exactly the
|
||||
/// case that must not run in parallel with another giant.
|
||||
const HEAVY_IMAGE_BYTES: u64 = 150 * 1024 * 1024;
|
||||
|
||||
/// Wall-clock ceiling for one oxipng run.
|
||||
///
|
||||
/// Bounds TIME, NOT MEMORY — oxipng checks the deadline between trials, so a single trial
|
||||
/// still allocates in full. The pixel gate above and the sequential build (see
|
||||
/// `default-features = false` in Cargo.toml) are what bound memory. Do not treat this
|
||||
/// constant as the OOM fix.
|
||||
const OXIPNG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Decode the image ONCE and emit both derivatives — the 800px `preview` (phone feed)
|
||||
/// and the 2048px `display` (diashow). Returns `(preview_rel, display_rel)`.
|
||||
async fn generate_image_derivatives(
|
||||
@@ -223,53 +259,28 @@ impl CompressionWorker {
|
||||
let display_path = displays_dir.join(&filename);
|
||||
let original = original.to_path_buf();
|
||||
let mime_owned = mime_type.to_string();
|
||||
let preview_max = Self::PREVIEW_MAX_EDGE;
|
||||
let display_max = Self::DISPLAY_MAX_EDGE;
|
||||
|
||||
// Estimate the peak from the HEADER (no pixels decoded — the same kind of cheap probe
|
||||
// the upload handler already does via `exceeds_decode_budget`) and, if this job is a
|
||||
// giant, take the exclusive permit so it cannot overlap another giant. Held for the
|
||||
// whole blocking section, released on drop including on error.
|
||||
let estimate =
|
||||
crate::services::imaging::estimated_processing_peak_bytes(&original, Self::DISPLAY_MAX_EDGE);
|
||||
let _heavy_permit = match estimate {
|
||||
Some(bytes) if bytes > Self::HEAVY_IMAGE_BYTES => {
|
||||
tracing::debug!(
|
||||
%upload_id,
|
||||
estimated_mib = bytes / (1024 * 1024),
|
||||
"waiting for the heavy-image permit"
|
||||
);
|
||||
Some(self.heavy.acquire().await)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Run blocking image operations in a spawn_blocking task
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||
// services::imaging for why neither may be skipped.
|
||||
let img = crate::services::imaging::decode_oriented(&original)?;
|
||||
|
||||
// Preview: max 800px, preserving aspect ratio (data-saver feed).
|
||||
img.resize(
|
||||
preview_max,
|
||||
preview_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.save_with_format(&preview_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save preview")?;
|
||||
|
||||
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
|
||||
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
|
||||
let display = if img.width() > display_max || img.height() > display_max {
|
||||
img.resize(
|
||||
display_max,
|
||||
display_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
display
|
||||
.save_with_format(&display_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save display")?;
|
||||
|
||||
// If the original is PNG, try lossless compression in-place
|
||||
if mime_owned == "image/png" {
|
||||
let opts = oxipng::Options::from_preset(2);
|
||||
let _ = oxipng::optimize(
|
||||
&oxipng::InFile::Path(original),
|
||||
&oxipng::OutFile::Path {
|
||||
path: None,
|
||||
preserve_attrs: true,
|
||||
},
|
||||
&opts,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
tokio::task::spawn_blocking(move || {
|
||||
write_image_derivatives(upload_id, &original, &mime_owned, &preview_path, &display_path)
|
||||
})
|
||||
.await??;
|
||||
|
||||
@@ -362,3 +373,250 @@ impl CompressionWorker {
|
||||
Ok(produced.then(|| format!("thumbnails/{thumb_filename}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The blocking half of [`CompressionWorker::generate_image_derivatives`]: decode once, write
|
||||
/// both derivatives, then optionally shrink a PNG original in place.
|
||||
///
|
||||
/// A free function rather than an inline closure so its memory behaviour is directly testable —
|
||||
/// this is the code path that OOM-killed the container, and the fix is a scoping property that a
|
||||
/// future edit could silently undo.
|
||||
fn write_image_derivatives(
|
||||
upload_id: Uuid,
|
||||
original: &Path,
|
||||
mime_type: &str,
|
||||
preview_path: &Path,
|
||||
display_path: &Path,
|
||||
) -> Result<()> {
|
||||
let preview_max = CompressionWorker::PREVIEW_MAX_EDGE;
|
||||
let display_max = CompressionWorker::DISPLAY_MAX_EDGE;
|
||||
|
||||
// THE FULL-SIZE DECODE IS SCOPED TO THIS BLOCK ON PURPOSE, and the block yields the
|
||||
// DISPLAY derivative rather than the original.
|
||||
//
|
||||
// `img` is up to 256 MiB (imaging::decode_limits max_alloc) and `resize` only BORROWS it,
|
||||
// so it used to stay alive through both resizes AND the oxipng call below — which decodes
|
||||
// the PNG a second time and holds a full-size buffer per filter trial. That measured
|
||||
// ~1250 MiB of peak RSS for a 2.8 MiB input, inside a 1 GiB cgroup: the container was
|
||||
// SIGKILLed, taking every SSE stream and every in-flight upload with it.
|
||||
//
|
||||
// A block rather than a bare `drop(img)` because a `drop` call is one careless edit away
|
||||
// from being removed as redundant-looking — and note the `else` arm MOVES `img` out, which
|
||||
// is what makes "the block's value is the only survivor" true in both arms.
|
||||
let (display, width, height) = {
|
||||
// Decompression-bomb limits + EXIF orientation, both in one place — see
|
||||
// services::imaging for why neither may be skipped.
|
||||
let img = crate::services::imaging::decode_oriented(original)?;
|
||||
let (width, height) = (img.width(), img.height());
|
||||
|
||||
// Display: max 2048px for the diashow. Only DOWNSCALE — never upscale a smaller
|
||||
// original (that adds bytes with no quality gain); re-encode it as JPEG as-is.
|
||||
let display = if width > display_max || height > display_max {
|
||||
img.resize(
|
||||
display_max,
|
||||
display_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
(display, width, height)
|
||||
};
|
||||
|
||||
display
|
||||
.save_with_format(display_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save display")?;
|
||||
|
||||
// Preview: max 800px, derived from the DISPLAY, not from the original.
|
||||
//
|
||||
// Both derivatives used to resize the full-size decode independently, so a 8000x8000
|
||||
// original paid for two full-size Lanczos passes and their intermediates — measured 520
|
||||
// MiB peak even after the scoping fix above, which two concurrent workers cannot fit in a
|
||||
// 1 GiB container. Chaining 8000 -> 2048 -> 800 makes the second pass operate on 2048px
|
||||
// input, and the full-size buffer is already freed by the time it runs. Quality is not the
|
||||
// trade-off here: a staged Lanczos3 downscale to 800px is visually indistinguishable from
|
||||
// a single-step one (and is a standard technique for large ratios).
|
||||
display
|
||||
.resize(
|
||||
preview_max,
|
||||
preview_max,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
.save_with_format(preview_path, image::ImageFormat::Jpeg)
|
||||
.context("failed to save preview")?;
|
||||
drop(display);
|
||||
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
|
||||
// If the original is PNG, try lossless compression in place — but only when its pixel count
|
||||
// is inside the budget, and never for longer than OXIPNG_TIMEOUT. This is a best-effort size
|
||||
// saving: declining it costs disk, while attempting it unbounded cost the whole container.
|
||||
if mime_type == "image/png" {
|
||||
if pixels <= CompressionWorker::OXIPNG_MAX_PIXELS {
|
||||
let mut opts = oxipng::Options::from_preset(2);
|
||||
opts.timeout = Some(CompressionWorker::OXIPNG_TIMEOUT);
|
||||
let _ = oxipng::optimize(
|
||||
&oxipng::InFile::Path(original.to_path_buf()),
|
||||
&oxipng::OutFile::Path {
|
||||
path: None,
|
||||
preserve_attrs: true,
|
||||
},
|
||||
&opts,
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
%upload_id, pixels,
|
||||
"skipping oxipng: above the pixel budget; the original is stored as uploaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Peak resident set of THIS process, in bytes, from `/proc/self/status`.
|
||||
fn peak_rss_bytes() -> u64 {
|
||||
let status = std::fs::read_to_string("/proc/self/status").expect("procfs");
|
||||
let line = status
|
||||
.lines()
|
||||
.find(|l| l.starts_with("VmHWM:"))
|
||||
.expect("VmHWM");
|
||||
let kb: u64 = line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.expect("VmHWM value");
|
||||
kb * 1024
|
||||
}
|
||||
|
||||
/// Reset the kernel's peak-RSS watermark so the measurement covers only what follows.
|
||||
/// Linux 4.0+; writing "5" to `clear_refs` resets `VmHWM` to the current RSS.
|
||||
fn reset_peak_rss() {
|
||||
let _ = std::fs::write("/proc/self/clear_refs", "5");
|
||||
}
|
||||
|
||||
/// The pixel gate has to sit below what the axis limits allow, or it can never fire.
|
||||
#[test]
|
||||
fn the_oxipng_gate_is_reachable_within_the_decode_limits() {
|
||||
const _: () = {
|
||||
// imaging::decode_limits permits 12_000 x 12_000 = 144 MP. A gate above that would
|
||||
// never skip anything.
|
||||
assert!(CompressionWorker::OXIPNG_MAX_PIXELS < 12_000 * 12_000);
|
||||
// ...and it must stay above a 48 MP camera, so real photos still get optimised.
|
||||
assert!(CompressionWorker::OXIPNG_MAX_PIXELS >= 8_000_000);
|
||||
};
|
||||
}
|
||||
|
||||
/// The heavy-image gate has to classify the two cases the way the sizing assumed:
|
||||
/// an ordinary phone photo must NOT serialise, and the giant must.
|
||||
#[test]
|
||||
fn the_heavy_gate_separates_a_phone_photo_from_a_giant() {
|
||||
let dir = std::env::temp_dir().join(format!("es-heavy-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// 12 MP, the shape of a default phone capture.
|
||||
let ordinary = dir.join("ordinary.jpg");
|
||||
image::RgbImage::new(4032, 3024).save(&ordinary).unwrap();
|
||||
let ordinary_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||
&ordinary,
|
||||
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||
)
|
||||
.expect("header readable");
|
||||
assert!(
|
||||
ordinary_peak <= CompressionWorker::HEAVY_IMAGE_BYTES,
|
||||
"a 12 MP photo estimated at {} MiB would serialise the common path",
|
||||
ordinary_peak / 1048576
|
||||
);
|
||||
|
||||
// The 64 MP RGBA case that measured ~516 MiB peak.
|
||||
let giant = dir.join("giant.png");
|
||||
image::RgbaImage::new(8000, 8000).save(&giant).unwrap();
|
||||
let giant_peak = crate::services::imaging::estimated_processing_peak_bytes(
|
||||
&giant,
|
||||
CompressionWorker::DISPLAY_MAX_EDGE,
|
||||
)
|
||||
.expect("header readable");
|
||||
assert!(
|
||||
giant_peak > CompressionWorker::HEAVY_IMAGE_BYTES,
|
||||
"an 8000x8000 RGBA original estimated at only {} MiB would be allowed to run \
|
||||
concurrently with another one — 2x its real ~516 MiB peak does not fit in 1 GiB",
|
||||
giant_peak / 1048576
|
||||
);
|
||||
// The estimate must also be in the right ballpark, not merely on the right side of the
|
||||
// threshold: 244 MiB decode + 262 MiB f32 resize intermediate.
|
||||
assert!(
|
||||
(400..700).contains(&(giant_peak / 1048576)),
|
||||
"estimate {} MiB is far from the measured ~516 MiB peak",
|
||||
giant_peak / 1048576
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The OOM that took the container down, measured rather than argued.
|
||||
///
|
||||
/// An 8000x8000 RGBA PNG passes admission: 256,000,000 bytes is just under the 256 MiB
|
||||
/// `max_alloc`, and smooth content is a few MB on disk, far under any size cap. The old
|
||||
/// code kept that ~244 MiB decode alive across an unbounded, multi-threaded oxipng run and
|
||||
/// peaked at ~1250 MiB — inside a 1 GiB cgroup. Being SIGKILLed there is not a blip: the
|
||||
/// row was already committed, so the boot backfill replayed the identical workload on every
|
||||
/// restart.
|
||||
///
|
||||
/// `#[ignore]` because it allocates ~250 MiB and takes a few seconds. Run explicitly:
|
||||
/// cargo test --release oom -- --ignored --nocapture --test-threads=1
|
||||
/// It must run ALONE — `VmHWM` is per process, so a concurrent test would pollute it.
|
||||
#[test]
|
||||
#[ignore = "heavy: allocates ~250 MiB; run with --ignored --test-threads=1"]
|
||||
fn a_large_png_stays_far_below_the_container_limit() {
|
||||
const EDGE: u32 = 8_000;
|
||||
let dir = std::env::temp_dir().join(format!("es-oom-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let original = dir.join("big.png");
|
||||
|
||||
// Smooth gradient: ~244 MiB decoded, a couple of MB on disk. That gap is the whole
|
||||
// point — file size tells you nothing about what a PNG costs to process.
|
||||
{
|
||||
let mut buf = image::RgbaImage::new(EDGE, EDGE);
|
||||
for (x, y, px) in buf.enumerate_pixels_mut() {
|
||||
*px = image::Rgba([(x >> 5) as u8, (y >> 5) as u8, ((x + y) >> 6) as u8, 255]);
|
||||
}
|
||||
buf.save(&original).unwrap();
|
||||
}
|
||||
|
||||
// Everything above is fixture setup, not the code under test.
|
||||
reset_peak_rss();
|
||||
let before = peak_rss_bytes();
|
||||
|
||||
write_image_derivatives(
|
||||
Uuid::new_v4(),
|
||||
&original,
|
||||
"image/png",
|
||||
&dir.join("preview.jpg"),
|
||||
&dir.join("display.jpg"),
|
||||
)
|
||||
.expect("derivatives");
|
||||
|
||||
let peak = peak_rss_bytes();
|
||||
let on_disk = std::fs::metadata(&original).unwrap().len();
|
||||
eprintln!(
|
||||
"input {:.2} MiB on disk ({EDGE}x{EDGE}); peak RSS {:.0} MiB (was {:.0} MiB before)",
|
||||
on_disk as f64 / 1048576.0,
|
||||
peak as f64 / 1048576.0,
|
||||
before as f64 / 1048576.0
|
||||
);
|
||||
|
||||
assert!(dir.join("preview.jpg").exists() && dir.join("display.jpg").exists());
|
||||
// The container gets 1 GiB and runs two of these concurrently. 600 MiB is a generous
|
||||
// ceiling that the old code (~1250 MiB) could not have met.
|
||||
assert!(
|
||||
peak < 600 * 1024 * 1024,
|
||||
"peak RSS {} MiB — the decode is being held across oxipng again, or the pixel \
|
||||
gate stopped firing",
|
||||
peak / 1048576
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,40 @@ fn decoder_within_budget(path: &Path) -> Result<impl image::ImageDecoder> {
|
||||
Ok(decoder)
|
||||
}
|
||||
|
||||
/// Rough peak heap an image will cost to turn into derivatives, read from the HEADER only —
|
||||
/// no pixels are decoded. `None` when the header can't be read or the image is over budget
|
||||
/// (the caller is about to fail on it anyway).
|
||||
///
|
||||
/// Two terms, and the second is the one that surprises:
|
||||
///
|
||||
/// - the decoded buffer, `width * height * channels`; and
|
||||
/// - the resize intermediate. `image`'s Lanczos3 path accumulates in `f32`, so the buffer
|
||||
/// between the horizontal and vertical passes is `new_width * old_height * 4 channels * 4
|
||||
/// bytes` — 16 bytes per pixel-row-slot, not the 4 the output uses. For an 8000x8000
|
||||
/// original that is 262 MiB on top of a 244 MiB decode, measured. It is bigger than the
|
||||
/// decode for any tall image, which is why "the decode is bounded by max_alloc" was never
|
||||
/// the whole story.
|
||||
///
|
||||
/// Used to decide whether an image is heavy enough to need exclusive use of the box's memory
|
||||
/// headroom, NOT to reject anything.
|
||||
pub fn estimated_processing_peak_bytes(path: &Path, display_edge: u32) -> Option<u64> {
|
||||
let decoder = decoder_within_budget(path).ok()?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
let decoded = decoder.total_bytes();
|
||||
|
||||
// Aspect-preserving fit into `display_edge`, matching DynamicImage::resize. No downscale
|
||||
// means no intermediate at all.
|
||||
let intermediate = if width > display_edge || height > display_edge {
|
||||
let ratio = f64::from(display_edge) / f64::from(width.max(height));
|
||||
let new_width = (f64::from(width) * ratio).round().max(1.0) as u64;
|
||||
new_width * u64::from(height) * 16
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Some(decoded.saturating_add(intermediate))
|
||||
}
|
||||
|
||||
/// Megapixels an image would decode to, or `None` if its header can't be read. Used only
|
||||
/// to put a concrete number in the message the guest sees.
|
||||
pub fn megapixels(path: &Path) -> Option<f64> {
|
||||
|
||||
Reference in New Issue
Block a user