Skip to content

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 ​

bash
pnpm --filter @playpals/ogun dev
pnpm --filter @playpals/ogun test

It 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:

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 ​

Rendering diagram…

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:

VariantBoxFormat
w320, w640, w1080, w1440that width, height capped at 2048WebP q80
full2048 on the long edgeWebP q80
og1200 on the long edgeJPEG, 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:

ts
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" with errorMessage, syncPostStatus, realtime emit, re-thrown into the failed set.
  • Permanent error (original missing — NoSuchKey/404 — or an image Sharp can't decode) — marked failed immediately and re-thrown as UnrecoverableError, 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 ​

VariablePurpose
DATABASE_URLPrisma
REDIS_URL or REDIS_HOST + REDIS_PORTQueue connection; REDIS_URL wins
SPACES_REGION, SPACES_ENDPOINT, SPACES_URL, SPACES_ACCESS_KEY, SPACES_SECRET_KEY, SPACES_BUCKETObject storage
MUX_ACCESS_TOKEN, MUX_SECRET_KEYVideo
REALTIME_SERVICE_URL, REALTIME_ADMIN_TOKENRealtime notification of completion
FFPROBE_PATH, FFMPEG_PATHOptional; default to ffprobe / ffmpeg on PATH (installed in the image)
OGUN_SKIP_MEDIA_TOOLS_CHECK1 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 ​

PropertyValue
Helm valuesinfra/helm/values/production/ogun.yaml (release playpalz-ogun)
Replicas2
ProbesNone
IngressNone
bash
./scripts/build-ogun.sh [tag]

Known issues ​

The realtime completion event never arrives in production

ogun's helper does not add the /admin prefix:

ts
fetch(`${process.env.REALTIME_SERVICE_URL}${path}`, …)   // ogun
fetch(`${process.env.REALTIME_SERVICE_URL}/admin${path}`, …)   // api

Both 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 ​

bash
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-01

Logs are structured per module (worker, processMedia, processImage, processVideo) and carry mediaId, so a single item is traceable end to end.

Internal documentation — PlayPalz platform