ogun — Media Worker
@playpals/ogun · no HTTP port · apps/ogun
Ogun symbolizes raw power, machinery, and transformation.
A pure BullMQ worker. It consumes the media-processing queue, generates image variants with Sharp, creates Mux assets for video, and writes results back to the database. It serves no HTTP traffic. Two replicas in production.
Running it
pnpm --filter @playpals/ogun dev
pnpm --filter @playpals/ogun testIt needs DATABASE_URL, Redis, and valid SPACES_* credentials. Without object storage access it will pick up every job and fail it.
The worker
apps/ogun/src/index.ts:
const mediaWorker = new Worker(
PROCESS_MEDIA_QUEUE,
async (job) => { await processMedia(job.data.mediaId); },
{ connection: redis, concurrency: 5 },
);Besides media processing, the same worker runs link-preview-image, media-revoke, delete-storage, content-sweep, and media-visibility ({ postId }: bring a post's media storage in line with its visibility; going private flips isPrivate first, then closes files, purges the CDN and swaps the Mux playback id for a signed one; going public opens files first, then flips the flag).
Five jobs in flight per process. The queue name and retry policy come from @playpals/queue, so the API and the worker cannot drift apart.
Job flow
Image variants
src/lib/imagePipeline.ts (pure buffer-in/buffer-out, tested against generated EXIF-orientation fixtures) plans and renders them; src/lib/variantStore.ts uploads and upserts:
| Variant | Box | Format |
|---|---|---|
w320, w640, w1080, w1440 | that width, height capped at 2048 | WebP q80 |
full | 2048 on the long edge | WebP q80 |
og | 1200 on the long edge | JPEG, for link previews / email / push |
Every output keeps the source aspect ratio (fit: "inside", never cover), is never upscaled, is auto-oriented and carries no EXIF. Width variants that would duplicate another output are skipped.
Uploaded public-read with Cache-Control: public, max-age=31536000, immutable, keyed variants/<mediaId>/v3/<name>.<ext>. Immutable is safe because the key contains the media id and VARIANT_VERSION — bump it whenever variant output changes, then run pnpm backfill-media-v3 (see Media Pipeline → Backfilling).
Variants are upserted on (mediaId, type, format) — a re-run updates existing rows rather than duplicating them, so replaying a job is harmless — and rows for types the run no longer produces are deleted.
Video
Before handing off to Mux, processVideo probes the original with ffprobe for its display size (rotation applied — phone video is usually stored landscape-coded with a rotation matrix) and duration, and renders an upright poster with ffmpeg. Neither is fatal. Then:
const asset = await mux.video.assets.create({
inputs: [{ url: media.originalUrl }],
playback_policies: ["public"],
video_quality: "basic",
});For a VIP post's media (Media.isPrivate), the original is read through a signed URL (by ffprobe and Mux), posters and variants are stored private, and the asset's playback policy is signed.
The job finishes as soon as Mux accepts the asset. Media.status becomes ready only when Mux calls the API's webhook. See Media Pipeline.
Failure handling
Both processors send errors through handleProcessingFailure() (src/jobs/handleFailure.ts). The worker passes isFinalAttempt, computed from the job's attemptsMade and opts.attempts:
- Transient error, attempts left — the row stays
pending, the error is re-thrown, BullMQ retries. The author keeps seeing a spinner. - Transient error, last attempt —
Media.status = "failed"witherrorMessage,syncPostStatus, realtime emit, re-thrown into the failed set. - Permanent error (original missing —
NoSuchKey/404 — or an image Sharp can't decode) — markedfailedimmediately and re-thrown asUnrecoverableError, which skips the remaining attempts.
Five attempts, exponential backoff from 5 s. The worker's failed listener logs attemptsMade, attempts and exhausted, so a retry (warn) is distinguishable from a terminal failure (error). Media.status and errorMessage tell you why without touching Redis.
Media that exhausted its retries is re-driven with retry-failed-media — see Media Pipeline → Re-driving failed media.
Configuration
| Variable | Purpose |
|---|---|
DATABASE_URL | Prisma |
REDIS_URL or REDIS_HOST + REDIS_PORT | Queue connection; REDIS_URL wins |
SPACES_REGION, SPACES_ENDPOINT, SPACES_URL, SPACES_ACCESS_KEY, SPACES_SECRET_KEY, SPACES_BUCKET | Object storage |
MUX_ACCESS_TOKEN, MUX_SECRET_KEY | Video |
REALTIME_SERVICE_URL, REALTIME_ADMIN_TOKEN | Realtime notification of completion |
FFPROBE_PATH, FFMPEG_PATH | Optional; default to ffprobe / ffmpeg on PATH (installed in the image) |
OGUN_SKIP_MEDIA_TOOLS_CHECK | 1 lets a local worker start without ffmpeg; never set it in a deployed environment |
At boot the worker runs ffprobe -version and ffmpeg -version and exits 1 if either is missing. Without them every video would quietly fall back to Mux's (possibly pre-rotation) dimensions and get no poster; failing at startup turns that into a crash-looping pod, so a bad image fails its rollout while the previous pods keep serving.
The Redis connection sets maxRetriesPerRequest: null, which BullMQ requires.
SPACES_ENDPOINT vs SPACES_URL
The S3 client must be built with SPACES_ENDPOINT (https://nyc3.digitaloceanspaces.com). The SDK prepends the bucket itself, so passing SPACES_URL (https://<bucket>.nyc3.digitaloceanspaces.com) sends requests to <bucket>.<bucket>.nyc3… and every job fails with Hostname/IP does not match certificate's altnames. SPACES_URL is only for public variant URLs.
Deployment
| Property | Value |
|---|---|
| Helm values | infra/helm/values/production/ogun.yaml (release playpalz-ogun) |
| Replicas | 2 |
| Probes | None |
| Ingress | None |
./scripts/build-ogun.sh [tag]Known issues
The realtime completion event never arrives in production
ogun's helper does not add the /admin prefix:
fetch(`${process.env.REALTIME_SERVICE_URL}${path}`, …) // ogun
fetch(`${process.env.REALTIME_SERVICE_URL}/admin${path}`, …) // apiBoth deployments set REALTIME_SERVICE_URL=http://playpalz-esu:4010, so ogun POSTs to /emit/media/processing/update — a route esu does not have. The .catch(() => {}) on the call hides the 404. Image-ready updates reach the app only through the polling fallback.
No health probe
Kubernetes cannot tell whether the worker is consuming jobs. A worker wedged on a Redis connection looks healthy. A minimal HTTP server reporting queue connectivity would make it restartable.
- Scaling is by replica count; the work is CPU-bound in Sharp. Watch Mux rate limits before scaling video volume.
- Originals are never deleted after processing. Storage grows without bound.
Operating
kubectl logs -l app=playpalz-ogun --tail=100 -f
# queue state
redis-cli -a <password> KEYS 'bull:media-processing:*'
redis-cli -a <password> LLEN 'bull:media-processing:wait'
redis-cli -a <password> ZRANGE 'bull:media-processing:failed' 0 -1
# re-enqueue media that exhausted its retries
kubectl exec deploy/playpalz-ogun -- node dist/scripts/retry-failed-media.js --dry-run
kubectl exec deploy/playpalz-ogun -- node dist/scripts/retry-failed-media.js --since 2026-09-01Logs are structured per module (worker, processMedia, processImage, processVideo) and carry mediaId, so a single item is traceable end to end.
