Rindle models media as three primary entities, each governed by an explicit finite-state machine that controls valid transitions:
MediaAsset— a single uploaded file (the canonical "original")MediaVariant— a derived output (thumbnail, large, format-converted, etc.)MediaUploadSession— a presigned upload session in flight
Treating lifecycle as a first-class FSM (rather than a JSON column or a
filesystem convention) is what makes Rindle queryable for Day-2 operations:
admins, SREs, and cleanup jobs need to know which assets are quarantined,
which variants are stale, and which sessions never completed. Each entity
also has a corresponding Ecto schema (see Rindle.Domain.MediaAsset,
Rindle.Domain.MediaVariant, Rindle.Domain.MediaUploadSession).
The Mermaid diagrams below mirror the @allowed_transitions declarations
in the FSM modules verbatim — if a transition isn't in the diagram, the FSM
will reject it with {:error, {:invalid_transition, from, to}}.
Asset Lifecycle
A MediaAsset represents one uploaded original. The FSM governs its journey
from "the upload session staged a row" through to "the asset is fully ready
for delivery" — and the off-ramps for quarantine and deletion.
stateDiagram-v2
[*] --> staged
staged --> validating
validating --> analyzing
analyzing --> promoting
promoting --> available
available --> processing
available --> quarantined
processing --> ready
processing --> quarantined
ready --> degraded
ready --> deleted
degraded --> quarantined
degraded --> deleted
quarantined --> deleted
deleted --> [*]State meanings:
| State | Meaning |
|---|---|
staged | Row created; upload session in flight (no bytes verified yet) |
validating | Upload completion verified; about to validate MIME / size |
analyzing | Validation passed; analyzer extracting metadata |
promoting | Analysis complete; about to flip to available |
available | Original is durable and queryable; variant generation can begin |
processing | One or more variant jobs running |
ready | All declared variants are in ready (or failed) state |
degraded | Some variants failed but original remains deliverable |
quarantined | MIME mismatch, scanner verdict, or other policy rejection |
deleted | Soft-deleted; storage purge enqueued |
The table and diagram above are the canonical transition map.
Variant Lifecycle
A MediaVariant is one derived output declared by a profile (e.g., the
thumb variant on MyApp.MediaProfile). Each variant gets its own row
with its own state — variants are first-class records, not hidden filenames.
This is what makes regeneration, stale detection, and storage verification
queryable.
stateDiagram-v2
[*] --> planned
planned --> queued
queued --> processing
processing --> ready
processing --> failed
ready --> stale
ready --> missing
ready --> purged
stale --> queued
stale --> purged
missing --> queued
missing --> purged
failed --> queued
failed --> purged
purged --> [*]State meanings:
| State | Meaning |
|---|---|
planned | Row exists; processing not yet enqueued |
queued | Oban job enqueued via the internal variant-processing pipeline |
processing | Worker actively running the variant pipeline |
ready | Variant object exists in storage; deliverable |
stale | Profile recipe digest changed; variant predates the new recipe |
missing | mix rindle.verify_storage confirmed the storage object is gone |
failed | Processing exhausted retries |
purged | Variant intentionally removed (post-detach, post-cleanup) |
Stale variants can be re-queued via
mix rindle.regenerate_variants; missing variants are detected by
mix rindle.verify_storage and similarly re-enqueued.
Upload Session Lifecycle
A MediaUploadSession is the durable handle on a presigned upload in flight.
The session's state machine encodes the direct-upload protocol — initiate,
sign, observe upload progress, verify completion, transition to terminal —
plus the off-ramps for abort, expiry, and failure.
stateDiagram-v2
[*] --> initialized
initialized --> signed
initialized --> aborted
initialized --> expired
initialized --> failed
signed --> uploading
signed --> uploaded
signed --> verifying
signed --> aborted
signed --> expired
signed --> failed
uploading --> uploaded
uploading --> verifying
uploading --> aborted
uploading --> expired
uploading --> failed
uploaded --> verifying
verifying --> completed
verifying --> failed
completed --> [*]
aborted --> [*]
expired --> [*]
failed --> [*]State meanings:
| State | Meaning |
|---|---|
initialized | Session row created; presigned URL not yet issued |
signed | Presigned PUT URL issued; client may upload |
uploading | Optional: client reported progress (not all clients do) |
uploaded | Optional: client reported completion (not all clients do) |
verifying | Server is HEAD-checking the storage object |
completed | Object verified in storage; asset transitioned to validating |
aborted | Adopter explicitly cancelled the session |
expired | TTL elapsed before completion (mix rindle.abort_incomplete_uploads) |
failed | Verification failed (object missing, integrity check failed, etc.) |
Note that the session lifecycle is more
permissive than the asset/variant FSMs: from initialized you can jump
directly to verifying (clients that don't report intermediate progress),
and abort/expire/fail are reachable from any non-terminal state.
How These Connect
A user-initiated upload threads through all three entities:
- The adopter calls
Rindle.Upload.Broker.initiate_session/2— this creates astagedMediaAssetand aninitializedMediaUploadSessionin a single DB transaction. - The adopter calls
Rindle.Upload.Broker.sign_url/2— this transitions the session tosignedand returns a presigned PUT URL. - The client PUTs the file bytes directly to storage. Rindle never sees the bytes during this step.
- The adopter calls
Rindle.Upload.Broker.verify_completion/2— Rindle HEAD-checks storage, transitions the session tocompleted, and the asset tovalidating. An ObanPromoteAssetjob is enqueued inside the same Ecto transaction (transactional enqueueing — see Background Processing). - An internal promote worker advances the asset through
validating → analyzing → promoting → availableand enqueues internal variant-processing jobs for each variant declared on the profile. - Each variant-processing job runs the configured processor and transitions
its
MediaVariantrow fromqueued → processing → ready. - Once attached via
Rindle.attach/4, the asset is delivered throughRindle.Delivery.url/3(signed by default — see Secure Delivery).
The state machines also enforce key safety properties:
- Atomic promote —
MediaVariantrecords are reloaded and re-checked before a worker writes areadyoutcome, so a stale background job can't overwrite a newer attachment. - Async purge — detach commits immediately in a DB transaction; the storage delete is enqueued as an internal purge worker and runs outside the transaction (so storage I/O never blocks or fails a DB write).
- No silent transitions — every transition emits
[:rindle, :asset, :state_change]or[:rindle, :variant, :state_change]telemetry withfrom,to,profile,adaptermetadata.
Schema Reference
| Schema | Module |
|---|---|
media_assets | Rindle.Domain.MediaAsset |
media_variants | Rindle.Domain.MediaVariant |
media_upload_sessions | Rindle.Domain.MediaUploadSession |
media_attachments (polymorphic) | Rindle.Domain.MediaAttachment |
media_processing_runs (audit) | Rindle.Domain.MediaProcessingRun |
All five are normalized Ecto tables with indexed state columns, so admin
LiveViews, dashboards, and cleanup jobs can query lifecycle state directly
without scanning JSON columns.