docs: defer media processing to a background job #165

Merged
rosa merged 9 commits from docs/defer-media-processing into main 2026-08-10 00:09:03 +00:00
Owner

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} answers
404 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

  1. As an author uploading a photo through the web editor, I want the submit to
    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.
  2. As an author publishing a Post with a Photo, I want the publish to complete
    promptly even though the image is not yet optimized, so that composing and
    posting stays fast.
  3. As a Micropub client calling POST /media, I want a 201 with the media
    capability URL in the Location header immediately, so that I can reference
    the URL in a subsequent create without blocking on processing.
  4. As a Micropub client, I want the media URL to be usable as a photo value
    right away even before the rendition exists, so that the create-then-reference
    flow the endpoint already supports keeps working unchanged.
  5. As a reader loading a Post whose Photo is still Processing, I want the page to
    render cleanly with the image simply absent, so that I never see a broken image
    icon.
  6. As a reader who reloads a Post after its image finished processing, I want the
    Photo to now appear, so that the page becomes complete without any action on my
    part.
  7. As a feed subscriber, I want an entry whose Photo is not yet Ready to omit the
    image enclosure rather than link a URL that 404s, so that my reader does not
    fetch a missing rendition.
  8. As a feed subscriber, I want the image to appear in the entry once it is Ready,
    consistent with how the page renders it.
  9. As an author, I want to learn when an upload of mine failed permanently, so that
    I can re-upload a different file instead of assuming a broken image is a bug.
  10. As an operator, I want AVIF encoding to run at a low, fixed worker concurrency,
    so that a burst of uploads drains at a capped rate instead of pinning every
    core and starving request-serving threads.
  11. As an operator, I want a hostile or malformed image to fail a background job
    rather than tie up an HTTP connection, so that the request-path denial-of-
    service surface shrinks rather than grows.
  12. As an operator, I want the upload request to still reject the obvious bad cases
    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.
  13. As an operator, I want a crash between staging the bytes, writing the Media
    row, and enqueuing the job to be recovered automatically, so that an in-flight
    upload becomes a delayed image, not lost data.
  14. As an operator, I want the background job to be safe to run more than once, so
    that a retry or a reconciler re-enqueue cannot corrupt an already-processed
    Media.
  15. As an operator, I want staged originals from crashed or abandoned uploads to be
    swept off the volume, so that the staging directory does not accumulate orphans.
  16. As an operator, I want my cp vernier.db backup to still capture everything
    that must survive, so that the new on-disk staging directory does not silently
    become un-backed-up durable state I must worry about.
  17. As an author, I want the privacy guarantee to be unchanged — GPS and other EXIF
    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.
  18. As an operator, I want a Media that is Processing forever (its job never
    completed) to be picked up and retried on startup and periodically, so that a
    lost enqueue self-heals without manual intervention.
  19. As a maintainer, I want the two upload adapters to keep calling one media
    creation op, so that the deferral does not fork optimization logic across the
    web handler and the Micropub endpoint.
  20. As a maintainer, I want the queue mechanism to stay apalis, so that the app has
    exactly one background-work system to reason about, retry, and trace.

Implementation Decisions

  • The create_media domain op keeps its interface, changes its behavior.
    MediaService::create_media(author, bytes) -> Result<Media, …> remains the one
    op both adapters call (ADR-0019). It no longer optimizes inline. It performs the
    cheap request-path checks, mints the MediaId, stages the bytes, inserts a
    Processing Media row, enqueues an optimize job, and returns the (Processing)
    Media. The web editor and Micropub POST /media handlers are untouched — they
    still hand raw bytes to this op and get a Media back.

  • 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 status of Processing / Ready / Failed is
    added to the Media aggregate and the media table. On insert the row is
    Processing; bytes, byte_size, width, and height are 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 MediaId that exists only once Ready, or values read only through the
    status. 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_media job type and handler
    join the existing five, following the mailer/webmention shape: a serde payload
    carrying the MediaId, a handler taking the image-processor, repository, and
    staging ports via Data<…>, registered on the Monitor in main.rs with the
    shared 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 JobError
    Terminal/Retryable → Error::Abort/Error::Failed classification.

  • The worker runs at low, fixed concurrency. Unlike the I/O-bound mail and
    webmention workers, this one is CPU-bound; its WorkerBuilder caps concurrency
    to 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 404
    unless 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,
    Location headers, whether an image appears in a page or feed, whether a job
    returns 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 existing
    micropub_media_endpoint_uploads_and_serves harness (enable_background_jobs: false, fresh SQLite per #[sqlx::test]). New coverage: after upload, the Media
    is Processing and GET /media/{id} returns 404; after the optimize job runs
    (driven deterministically, since no Monitor runs in tests), GET returns 200
    with image/avif. A Post published against a Processing Media renders without
    the 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_job directly with Data<…> ports and a seeded staged
    file, asserting the classification — a valid image yields Ok and a Ready Media;
    a decompression bomb / undecodable / off-allowlist input yields Error::Abort
    and a Failed Media with the staged file gone; a transient failure yields
    Error::Failed. Assert idempotency: running the handler against an already-Ready
    Media is a no-op Ok, as is a Media whose staged file is missing.

  • Domain op, crates/domain/src/media/service.rs. Extend the existing
    create_media unit test (in-memory repo + NoOpImageProcessor): create_media
    now 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_adapters suite for the new status column: create-as-Processing
    round-trips, the Processing→Ready and →Failed transitions persist, and a
    status-filtered lookup returns only Ready media.

Out of Scope

  • Moving renditions (or originals) out of SQLite onto the volume as durable, served
    storage — deferred with ADR-0020; renditions stay BLOBs.
  • A dedicated user-facing "your upload failed" notification channel beyond
    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.
  • Changing the encoder's quality/speed knobs, adding multiple renditions or
    responsive sizes, or supporting formats beyond the current JPEG/PNG/WebP-in →
    AVIF-out allowlist. Deferral changes when the encode runs, not its shape.
  • Micropub inline-multipart-in-create (a third upload surface) remains deferred per
    ADR-0019.
  • Metrics on queue depth / job success rates (tracked separately, e.g. #164).

Further Notes

  • The MediaId stays a random v4 token (ADR-0018): the capability URL is returned
    before the rendition exists, and unguessability is what keeps a not-yet-Ready URL
    from being probed.
  • The staging directory is the one piece of durable state outside vernier.db, and
    it holds nothing that must be recovered — restoring only vernier.db loses at
    most a handful of in-flight uploads, which re-upload. The cp vernier.db backup
    story is intact.
  • apalis pins sqlx 0.8 while the app is on sqlx 0.9 (ADR-0016); the job store is a
    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.
## 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}` answers 404 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 1. As an author uploading a photo through the web editor, I want the submit to 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. 2. As an author publishing a Post with a Photo, I want the publish to complete promptly even though the image is not yet optimized, so that composing and posting stays fast. 3. As a Micropub client calling `POST /media`, I want a `201` with the media capability URL in the `Location` header immediately, so that I can reference the URL in a subsequent create without blocking on processing. 4. As a Micropub client, I want the media URL to be usable as a `photo` value right away even before the rendition exists, so that the create-then-reference flow the endpoint already supports keeps working unchanged. 5. As a reader loading a Post whose Photo is still Processing, I want the page to render cleanly with the image simply absent, so that I never see a broken image icon. 6. As a reader who reloads a Post after its image finished processing, I want the Photo to now appear, so that the page becomes complete without any action on my part. 7. As a feed subscriber, I want an entry whose Photo is not yet Ready to omit the image enclosure rather than link a URL that 404s, so that my reader does not fetch a missing rendition. 8. As a feed subscriber, I want the image to appear in the entry once it is Ready, consistent with how the page renders it. 9. As an author, I want to learn when an upload of mine failed permanently, so that I can re-upload a different file instead of assuming a broken image is a bug. 10. As an operator, I want AVIF encoding to run at a low, fixed worker concurrency, so that a burst of uploads drains at a capped rate instead of pinning every core and starving request-serving threads. 11. As an operator, I want a hostile or malformed image to fail a background job rather than tie up an HTTP connection, so that the request-path denial-of- service surface shrinks rather than grows. 12. As an operator, I want the upload request to still reject the obvious bad cases 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. 13. As an operator, I want a crash between staging the bytes, writing the Media row, and enqueuing the job to be recovered automatically, so that an in-flight upload becomes a delayed image, not lost data. 14. As an operator, I want the background job to be safe to run more than once, so that a retry or a reconciler re-enqueue cannot corrupt an already-processed Media. 15. As an operator, I want staged originals from crashed or abandoned uploads to be swept off the volume, so that the staging directory does not accumulate orphans. 16. As an operator, I want my `cp vernier.db` backup to still capture everything that must survive, so that the new on-disk staging directory does not silently become un-backed-up durable state I must worry about. 17. As an author, I want the privacy guarantee to be unchanged — GPS and other EXIF 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. 18. As an operator, I want a Media that is Processing forever (its job never completed) to be picked up and retried on startup and periodically, so that a lost enqueue self-heals without manual intervention. 19. As a maintainer, I want the two upload adapters to keep calling one media creation op, so that the deferral does not fork optimization logic across the web handler and the Micropub endpoint. 20. As a maintainer, I want the queue mechanism to stay apalis, so that the app has exactly one background-work system to reason about, retry, and trace. ## Implementation Decisions - **The `create_media` domain op keeps its interface, changes its behavior.** `MediaService::create_media(author, bytes) -> Result<Media, …>` remains the one op both adapters call (ADR-0019). It no longer optimizes inline. It performs the cheap request-path checks, mints the `MediaId`, stages the bytes, inserts a Processing Media row, enqueues an optimize job, and returns the (Processing) Media. The web editor and Micropub `POST /media` handlers are untouched — they still hand raw bytes to this op and get a `Media` back. - **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 `status` of Processing / Ready / Failed is added to the Media aggregate and the `media` table. On insert the row is Processing; `bytes`, `byte_size`, `width`, and `height` are 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 `MediaId` that exists only once Ready, or values read only through the status. 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_media` job type and handler join the existing five, following the mailer/webmention shape: a serde payload carrying the `MediaId`, a handler taking the image-processor, repository, and staging ports via `Data<…>`, registered on the `Monitor` in `main.rs` with the shared 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 `JobError` Terminal/Retryable → `Error::Abort`/`Error::Failed` classification. - **The worker runs at low, fixed concurrency.** Unlike the I/O-bound mail and webmention workers, this one is CPU-bound; its `WorkerBuilder` caps concurrency to 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 404 unless 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, `Location` headers, whether an image appears in a page or feed, whether a job returns 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 existing `micropub_media_endpoint_uploads_and_serves` harness (`enable_background_jobs: false`, fresh SQLite per `#[sqlx::test]`). New coverage: after upload, the Media is Processing and `GET /media/{id}` returns 404; after the optimize job runs (driven deterministically, since no Monitor runs in tests), `GET` returns 200 with `image/avif`. A Post published against a Processing Media renders without the 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_job` directly with `Data<…>` ports and a seeded staged file, asserting the classification — a valid image yields `Ok` and a Ready Media; a decompression bomb / undecodable / off-allowlist input yields `Error::Abort` and a Failed Media with the staged file gone; a transient failure yields `Error::Failed`. Assert idempotency: running the handler against an already-Ready Media is a no-op `Ok`, as is a Media whose staged file is missing. - **Domain op, `crates/domain/src/media/service.rs`.** Extend the existing `create_media` unit test (in-memory repo + `NoOpImageProcessor`): `create_media` now 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_adapters` suite for the new status column: create-as-Processing round-trips, the Processing→Ready and →Failed transitions persist, and a status-filtered lookup returns only Ready media. ## Out of Scope - Moving renditions (or originals) out of SQLite onto the volume as durable, served storage — deferred with ADR-0020; renditions stay BLOBs. - A dedicated user-facing "your upload failed" notification channel beyond 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. - Changing the encoder's quality/speed knobs, adding multiple renditions or responsive sizes, or supporting formats beyond the current JPEG/PNG/WebP-in → AVIF-out allowlist. Deferral changes *when* the encode runs, not its shape. - Micropub inline-multipart-in-create (a third upload surface) remains deferred per ADR-0019. - Metrics on queue depth / job success rates (tracked separately, e.g. #164). ## Further Notes - The `MediaId` stays a random v4 token (ADR-0018): the capability URL is returned before the rendition exists, and unguessability is what keeps a not-yet-Ready URL from being probed. - The staging directory is the one piece of durable state outside `vernier.db`, and it holds nothing that must be recovered — restoring only `vernier.db` loses at most a handful of in-flight uploads, which re-upload. The `cp vernier.db` backup story is intact. - apalis pins sqlx 0.8 while the app is on sqlx 0.9 (ADR-0016); the job store is a 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.
docs: defer media processing to a background job
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
384f75dd59
Records the design for moving AVIF optimization off the upload request
onto the apalis queue. Adds the Media Processing/Ready/Failed lifecycle
to the glossary, ADR-0020 (deferred processing, failure states, worker
concurrency, cross-pool reconciler), and ADR-0021 (originals staged on
the volume until processed). Notes in ADR-0017 that the original is now
staged transiently rather than discarded within the request.
rosa force-pushed docs/defer-media-processing from 384f75dd59
Some checks failed
ci/woodpecker/push/clippy Pipeline was canceled
ci/woodpecker/push/test Pipeline was canceled
to 9616d6b3d1
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-09 19:03:41 +00:00
Compare
feat: thread a Media lifecycle status end-to-end
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
a45abf56d5
Give 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 #166
feat: stage originals and defer image optimization to a job
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
521c8aac70
Uploads no longer block on the image pipeline. create_media now runs
only the cheap, safety-critical request-path check — the pre-optimization
size ceiling and the JPEG/PNG/WebP magic-byte allowlist, no decode — then
mints the MediaId, stages the raw original on the durable data volume
beside the SQLite file, records a Processing Media, and enqueues an
optimize job. The heavy pipeline (bomb guard, decode, EXIF orientation,
metadata strip, downscale, AVIF re-encode) runs on a low-concurrency
apalis worker, which stores the rendition, flips the Media to Ready, and
discards the staged original.

The ImageProcessor port exposes `accept` (the request-path gate) and
`optimize` (the deferred pipeline); both funnel through one `sniff`
helper so the admission policy lives in a single place. The job is
idempotent: an already-Ready Media or a gone staged original is a no-op,
so a redelivered or reconciler-requeued job never double-processes.

New ports: MediaStaging (FsMediaStaging) and MediaProcessingQueue
(ApalisMediaQueue). Media reads gate on is_ready, so a Processing upload
404s at its capability URL and is omitted from pages and feeds until the
rendition lands.

Closes #167
feat: make media-processing failure a terminal state
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
a8de46f4c8
The optimize_media job now classifies its failures instead of retrying
every one forever. A permanent rejection — undecodable bytes that passed
the request-path sniff, a decode-bound decompression bomb, or an
off-allowlist type — drives the Media to Failed, discards the staged
original, and aborts without retry. A transient failure (an
infrastructure error like OOM or a full disk) stays retryable and backs
off; once its attempt budget is spent it too falls to Failed rather than
lingering Processing after the queue gives up.

The split maps onto the existing JobError classification: permanent and
retry-exhausted outcomes become Error::Abort, transient ones Error::Failed.
A new mark_media_failed repository op takes the terminal transition,
guarded on the Media still being Processing so a late or redelivered job
never clobbers a stored rendition. The handler owns the give-up bound
(MAX_OPTIMIZE_ATTEMPTS), and the worker's retry policy is sized to it so
apalis never aborts the job first and strands the Media.

Because the upload is fire-and-forget, a Failed Media is surfaced to its
author on their own post view — never a silently missing image — while
other viewers see nothing. A still-Processing Media renders a placeholder
during its eventual-consistency window.

Closes #168
feat: reconcile the media cross-pool enqueue gap
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
90a9172e25
An upload's three writes — the staged original, the Media row (app sqlx
0.9 pool), and the optimize job (apalis sqlx 0.8 pool) — cannot share one
transaction, so a crash between any two strands work (ADR-0020, ADR-0021).

Add a reconciler on the Media service that, on startup and a periodic
tick, re-enqueues any Processing Media the queue has no live job for and
sweeps staged originals no Processing Media still needs. It reuses the
idempotent optimize job rather than a second queue, so a redundant
enqueue is a harmless no-op and terminal Media are left untouched.

Three new port reads back it: AppRepository::list_processing_media,
MediaStaging::list_staged, and MediaProcessingQueue::live_optimizations
(which queries apalis's own Jobs table for pending/running/retryable
jobs). Wired into main.rs beside the worker Monitor.

Tested at the repository/service seam (re-enqueue, sweep, no-op on
Ready/Failed), the FS and apalis adapters, and end-to-end (a lost job is
re-enqueued and reaches Ready; an orphaned original is swept).

Closes #169
rosa force-pushed docs/defer-media-processing from 90a9172e25
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
to 50df5a84f1
All checks were successful
ci/woodpecker/push/clippy Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
2026-08-09 23:54:10 +00:00
Compare
rosa changed title from WIP: docs: defer media processing to a background job to docs: defer media processing to a background job 2026-08-09 23:55:39 +00:00
rosa merged commit cf53673965 into main 2026-08-10 00:09:03 +00:00
rosa deleted branch docs/defer-media-processing 2026-08-10 00:09:03 +00:00
Sign in to join this conversation.
No description provided.