docs: defer media processing to a background job #165
No reviewers
Labels
No labels
kind
bug
kind
enhancement
wayfinder
grilling
wayfinder
map
wayfinder
prototype
wayfinder
research
wayfinder
task
workflow
needs-info
workflow
needs-triage
workflow
ready-for-agent
workflow
ready-for-human
workflow
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rosa/vernier!165
Loading…
Reference in a new issue
No description provided.
Delete branch "docs/defer-media-processing"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem Statement
When an author (or a Micropub client) uploads an image, they wait for the whole
optimization — decode, resize, strip metadata, re-encode to one AVIF rendition —
to finish before the request returns. That encode runs for hundreds of
milliseconds to seconds on a bounded-but-large image, and it is CPU-bound, so a
burst of uploads competes directly with the threads serving every other request.
Publishing a Post that carries a Photo inherits the same wait. The upload feels
slow, and a heavy upload burst can pin the box.
Solution
The upload request stops doing the processing. It validates cheaply, mints the
Media id, stages the raw bytes on disk, records the Media as Processing,
enqueues a background job, and returns immediately — the capability URL comes back
before the rendition exists. A low-concurrency background worker on the existing
apalis queue runs the full pipeline, writes the AVIF rendition into SQLite, marks
the Media Ready, and deletes the staged original.
GET /media/{id}answers404 until the Media is Ready. A Post may publish while its Media is still
Processing: the Photo is omitted from the rendered page and from feeds until the
rendition lands, then appears on the next load. Failure is a first-class state:
undecodable or oversized or off-allowlist bytes mark the Media Failed with no
retry; transient failures (out of memory, full disk) retry with backoff a bounded
number of times, then fall to Failed, and a Failed Media is surfaced to its author
rather than left as a silently broken image.
Both the upload and the publish return fast, at the cost of a brief
eventual-consistency window on the image. That is the deliberate trade.
User Stories
return as soon as the upload is accepted, so that I am not made to wait out a
multi-second AVIF encode before I can keep working.
promptly even though the image is not yet optimized, so that composing and
posting stays fast.
POST /media, I want a201with the mediacapability URL in the
Locationheader immediately, so that I can referencethe URL in a subsequent create without blocking on processing.
photovalueright away even before the rendition exists, so that the create-then-reference
flow the endpoint already supports keeps working unchanged.
render cleanly with the image simply absent, so that I never see a broken image
icon.
Photo to now appear, so that the page becomes complete without any action on my
part.
image enclosure rather than link a URL that 404s, so that my reader does not
fetch a missing rendition.
consistent with how the page renders it.
I can re-upload a different file instead of assuming a broken image is a bug.
so that a burst of uploads drains at a capped rate instead of pinning every
core and starving request-serving threads.
rather than tie up an HTTP connection, so that the request-path denial-of-
service surface shrinks rather than grows.
cheaply — a non-image, a wrong-typed file, an over-ceiling upload — so that the
queue is not fed work that is certain to fail.
row, and enqueuing the job to be recovered automatically, so that an in-flight
upload becomes a delayed image, not lost data.
that a retry or a reconciler re-enqueue cannot corrupt an already-processed
Media.
swept off the volume, so that the staging directory does not accumulate orphans.
cp vernier.dbbackup to still capture everythingthat must survive, so that the new on-disk staging directory does not silently
become un-backed-up durable state I must worry about.
stripped from what is served — so that deferring the encode by a few seconds
does not leak metadata that the inline pipeline used to remove.
completed) to be picked up and retried on startup and periodically, so that a
lost enqueue self-heals without manual intervention.
creation op, so that the deferral does not fork optimization logic across the
web handler and the Micropub endpoint.
exactly one background-work system to reason about, retry, and trace.
Implementation Decisions
The
create_mediadomain op keeps its interface, changes its behavior.MediaService::create_media(author, bytes) -> Result<Media, …>remains the oneop both adapters call (ADR-0019). It no longer optimizes inline. It performs the
cheap request-path checks, mints the
MediaId, stages the bytes, inserts aProcessing Media row, enqueues an optimize job, and returns the (Processing)
Media. The web editor and Micropub
POST /mediahandlers are untouched — theystill hand raw bytes to this op and get a
Mediaback.What stays in the request is only the cheap, safety-critical work: the
magic-byte type sniff against the JPEG/PNG/WebP allowlist and the pre-
optimization size ceiling. What moves into the job: the decompression-bomb
guard, the decode, EXIF-orientation application, metadata stripping, the
downscale, and the AVIF re-encode.
Media grows a lifecycle status. A
statusof Processing / Ready / Failed isadded to the Media aggregate and the
mediatable. On insert the row isProcessing;
bytes,byte_size,width, andheightare absent until Ready,as ADR-0020 requires. How that absence is stored is an open schema decision, not
settled by the ADR — candidates: nullable columns, a separate rendition row
keyed by
MediaIdthat exists only once Ready, or values read only through thestatus. The reconciler and the render paths read status; nothing outside the
media module sets it after creation except the job.
Originals are staged on disk, keyed by Media id. A staging directory on the
durable data volume (beside
vernier.db, never/tmp, never a SQLite table)holds the raw upload from acceptance until the job stores the rendition or marks
the Media Failed, at which point the staged file is deleted (ADR-0021). A new
narrow port owns staging (write bytes for an id, read bytes for an id, delete for
an id, list orphans) so the domain op and the job depend on an interface, not the
filesystem directly — mirroring how the image processor and mailer are ports.
A new apalis job runs the pipeline. An
optimize_mediajob type and handlerjoin the existing five, following the mailer/webmention shape: a serde payload
carrying the
MediaId, a handler taking the image-processor, repository, andstaging ports via
Data<…>, registered on theMonitorinmain.rswith theshared retry policy. The job: no-ops if the Media is already Ready
(idempotent); loads the staged bytes (a missing file is treated as
already-done); runs the pipeline; on success writes the rendition + dimensions,
flips the Media to Ready, deletes the staged file; on permanent error flips to
Failed and deletes the staged file; on transient error returns the retryable
variant. Permanent-vs-transient maps onto the existing
JobErrorTerminal/Retryable →
Error::Abort/Error::Failedclassification.The worker runs at low, fixed concurrency. Unlike the I/O-bound mail and
webmention workers, this one is CPU-bound; its
WorkerBuildercaps concurrencyto a small fixed number so a burst drains at a bounded rate.
A reconciler closes the cross-pool enqueue gap. An upload writes to three
places that cannot share a transaction — the staged file (volume), the Media row
(app sqlx 0.9 pool), and the apalis job (its own sqlx 0.8 pool, ADR-0016). On
startup and on a periodic tick, a reconciler re-enqueues any Processing Media
that has no live job and deletes orphaned staged files that belong to no
Processing Media. Combined with the idempotent job, a crash between any two
writes becomes a recoverable delay. No second, hand-rolled queue is introduced.
Serving and rendering become status-aware.
GET /media/{id}returns 404unless the Media is Ready (it already 404s on missing bytes; the change is to
404 on Processing/Failed as well). The Post HTML photo element and the RSS/Atom
feed-photo lookup include a Photo only when its Media is Ready — Processing and
Failed Media are omitted, which the feed-photo fetch already does structurally
for absent media (the change is to filter on status, not just presence).
Renditions stay a SQLite BLOB. Moving processed AVIF onto the volume was
considered and explicitly deferred: it is a coupled backup-strategy change
(single-file
cp vernier.db), out of scope here.Testing Decisions
Good tests here assert externally observable behavior — HTTP status codes,
Locationheaders, whether an image appears in a page or feed, whether a jobreturns Ok/Abort/Failed — not the internal status transitions or the on-disk
staging layout. State is observed through the same surfaces a client or reader
uses.
Full-stack flow,
crates/server/tests/app_test.rs. Extend the existingmicropub_media_endpoint_uploads_and_servesharness (enable_background_jobs: false, fresh SQLite per#[sqlx::test]). New coverage: after upload, the Mediais Processing and
GET /media/{id}returns 404; after the optimize job runs(driven deterministically, since no Monitor runs in tests),
GETreturns 200with
image/avif. A Post published against a Processing Media renders withoutthe photo and omits it from the feed; after processing, a re-fetch includes it.
Job handler,
crates/infra/src/jobs/. Follow the mailer test pattern:invoke
optimize_media_jobdirectly withData<…>ports and a seeded stagedfile, asserting the classification — a valid image yields
Okand a Ready Media;a decompression bomb / undecodable / off-allowlist input yields
Error::Abortand a Failed Media with the staged file gone; a transient failure yields
Error::Failed. Assert idempotency: running the handler against an already-ReadyMedia is a no-op
Ok, as is a Media whose staged file is missing.Domain op,
crates/domain/src/media/service.rs. Extend the existingcreate_mediaunit test (in-memory repo +NoOpImageProcessor):create_medianow returns a Processing Media, enqueues one job, and stages the bytes, without
invoking the processor inline.
Reconciler. Test at the repository/service seam with the in-memory repo: a
Processing Media with no live job is re-enqueued; an orphaned staged file (no
matching Processing Media) is swept; a Ready Media is left alone.
Repository conformance,
crates/infra/tests/media_repository_conformance.rs.Extend the
both_adapterssuite for the new status column: create-as-Processinground-trips, the Processing→Ready and →Failed transitions persist, and a
status-filtered lookup returns only Ready media.
Out of Scope
storage — deferred with ADR-0020; renditions stay BLOBs.
surfacing the Failed status on the author's own views; the mechanism of that
surfacing is left to the media/author UI and is not specified here.
responsive sizes, or supporting formats beyond the current JPEG/PNG/WebP-in →
AVIF-out allowlist. Deferral changes when the encode runs, not its shape.
ADR-0019.
Further Notes
MediaIdstays a random v4 token (ADR-0018): the capability URL is returnedbefore the rendition exists, and unguessability is what keeps a not-yet-Ready URL
from being probed.
vernier.db, andit holds nothing that must be recovered — restoring only
vernier.dbloses atmost a handful of in-flight uploads, which re-upload. The
cp vernier.dbbackupstory is intact.
separate pool against the same file. The reconciler exists precisely because the
Media write and the job enqueue cannot share one transaction across that split.
384f75dd599616d6b3d1Give Media a Processing / Ready / Failed lifecycle status, threaded through the aggregate, the media schema, and every read path, without changing any observable behavior yet. This is the prefactor for deferring media processing to a background job (ADR-0020, ADR-0021): all media is still optimized inline and recorded Ready, but a Media that is *not* Ready is now correctly hidden, so a later slice can create Processing media safely. The absent-until-Ready rendition (bytes, size, dimensions) is modelled structurally rather than as nullable columns: a separate media_rendition table whose row exists exactly when the Media is Ready, mirrored in the domain by MediaStatus::Ready(Rendition). Absence becomes a property of the type and the schema, not an invariant to enforce. The migration also avoids rebuilding the media table (and its inbound posts foreign key). Read paths gate on readiness: GET /media/{id} 404s a non-Ready Media, and the Post photo render and RSS/Atom feed lookups omit a Photo whose Media is not Ready. Existing upload/serve/feed tests pass unchanged; new tests hand-insert Processing/Failed media and assert the 404 and the page/feed omission. Closes #16690a9172e2550df5a84f1WIP: docs: defer media processing to a background jobto docs: defer media processing to a background job