Files
classroomio/apps/jobs/README.md
T
Rotimi Best d5b07d356e feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)
New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
2026-06-07 18:43:05 +01:00

187 lines
9.9 KiB
Markdown

# @cio/jobs-worker
BullMQ worker process for ClassroomIO. Runs every background job the
`@cio/api` enqueues — emails, lesson video post-processing (probe / thumbnail /
extract-audio / transcribe), and maintenance tasks.
The worker is a separate Node process from the API. They communicate **only**
through Redis. Stop the worker, restart the worker, redeploy the worker — the
API keeps serving requests; queued work just waits in Redis until a worker
picks it up.
## How it fits together
```
┌──────────┐ enqueue(payload) ┌───────┐ pop job ┌─────────────────┐
│ @cio/api │ ──────────────────▶ │ Redis │ ─────────▶ │ @cio/jobs-worker │
└──────────┘ └───────┘ └─────────────────┘
▲ │
│ HTTP provider │
│ call ▼
browser ┌─────────────────┐
│ SMTP / S3 / etc │
└─────────────────┘
```
| Layer | Responsibility |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@cio/api` services | Validate input, write domain rows, then enqueue jobs via `enqueueTransactionalEmail` / `enqueueRawEmail` / `startMediaJob`. Returns to the client immediately. |
| Redis (BullMQ) | Persistent job queue. Tracks state (`waiting`, `active`, `completed`, `failed`), retries with exponential backoff, retention. |
| `@cio/jobs-worker` (this app) | Pops jobs, calls the provider (`@cio/email`, `ffmpeg`, OpenAI, …), updates `media_job` rows where applicable, dead-letters terminal failures. |
### Heads-up: the API still sends a few emails directly
Auth-flow emails are sent synchronously by `@cio/api` — **not** queued.
They're the four templates Better Auth needs in-line during sign-up / password
recovery (the user is waiting for a link to arrive in their inbox):
- `forgotPassword`
- `onPasswordReset`
- `verifyEmail` (sign-up + email-change)
So **both processes need SMTP credentials**. They can point at the same SMTP
server. Everything else (welcome, invites, newsfeed, course completion,
payment requests, the public `/mail` route, submission notifications) goes
through this worker.
### Queues
| Queue | Jobs | Where the payload comes from |
| ------------------ | ------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `emails` | `send` | `enqueueTransactionalEmail` / `enqueueRawEmail` in `apps/api/src/services/jobs/email-jobs.ts` |
| `media` | `probe-metadata`, `generate-thumbnail`, `extract-audio` | `startMediaJob` after a video asset upload |
| `media-transcribe` | `transcribe-audio` | Same flow; only enqueued when `OPENAI_API_KEY` is set |
| `maintenance` | `retention-compact`, `dead-letter-cleanup` | Repeatable scheduled jobs |
### Persisted state (Postgres)
The worker writes to three tables:
- `media_job` — user-visible processing state (queued → probing → thumbnailing → extracting → completed). Surfaces as the upload progress bar in the dashboard.
- `job_step` — per-step idempotency ledger so retries skip already-finished work (e.g. don't re-upload a thumbnail).
- `dead_letter_job` — terminal failures after BullMQ exhausts retries. Used for operator triage.
Email jobs intentionally don't have a parallel DB ledger - BullMQ's own job
state (visible via bull-board, see below) is the source of truth for "did this
email send?", and `dead_letter_job` captures final failures.
## Running locally
### Prerequisites
1. Redis on `localhost:6379` (e.g. `docker run --rm -d -p 6379:6379 redis:7-alpine`)
2. Postgres reachable via `DATABASE_URL`
3. `ffmpeg` + `ffprobe` on `$PATH` (only required for the media worker —
emails and maintenance workers boot fine without them; the worker logs a
warning at startup if missing and media jobs will fail until it's installed)
- macOS: `brew install ffmpeg`
- Debian/Ubuntu: `apt install ffmpeg`
- Override with `FFMPEG_PATH` / `FFPROBE_PATH` for non-standard locations.
### Env
The worker reuses most of `apps/api/.env`. The fastest setup is to symlink:
```bash
cd apps/jobs
ln -s ../api/.env .env
```
Or copy `.env.example` and fill in the values. The bare minimum to boot is
`REDIS_URL`. Add `DATABASE_URL` + `SMTP_*` for emails to actually send, and
`OBJECT_STORAGE_*` for media jobs to read source video files.
See [`.env.example`](./.env.example) for the full list with comments.
### Start
From the repo root:
```bash
# In one terminal — API
pnpm api:dev
# In another terminal — worker (runs media + emails + maintenance in parallel)
pnpm jobs:dev
```
Or run a single worker directly:
```bash
cd apps/jobs
pnpm dev:emails # just the emails queue
pnpm dev:media # just the media queue
pnpm dev:maintenance # just retention / dead-letter cleanup
```
The worker prints `email-worker-ready`, `media-worker-ready`, etc. once it
connects to Redis. After that, every enqueue from the API logs an
`email-job-start` → `email-sent` (or `email-job-failed`) line.
## Smoke test
```bash
# Hit the API mail route — accepts immediately, returns 202 + jobIds
curl -X POST http://localhost:8787/mail \
-H 'Content-Type: application/json' \
-H 'x-api-key: <api key>' \
-d '[{"to":"you@example.com","subject":"BullMQ test","content":"<p>hi</p>"}]'
# Watch the worker terminal for:
# [email-job-start] jobName=send bullmqJobId=… attempt=1
# [email-sent] kind=raw recipient=you@example.com providerId=…
```
### Manually re-trigger transcription (uploaded lesson videos)
After a video upload, transcription usually runs as part of the media pipeline. You can **enqueue only extract-audio → Whisper** for an existing S3-backed video asset:
```bash
# Replace <org header> and Bearer token with a normal member session for the org.
curl -X POST "http://localhost:8787/jobs/media/asset/<assetId>/transcribe" \
-H "Authorization: Bearer <jwt>" \
-H "cio-org-id: <organizationUuid>" \
-i
```
| HTTP | Meaning |
| ------- | ---------------------------------------------------------------------------------- |
| **202** | Job created and enqueued (`success: true`, `data` is the new `media_job` row). |
| **503** | `OPENAI_KEY_MISSING` — `OPENAI_API_KEY` is not set on the API. |
| **400** | `ASSET_NOT_TRANSCRIBABLE` — not an upload video with storage, or wrong asset kind. |
| **409** | Another active `media_job` already exists for this asset. |
To inspect the queue without a UI:
```bash
redis-cli LLEN bull:emails:wait # waiting jobs
redis-cli LLEN bull:emails:active # currently processing
redis-cli LLEN bull:emails:failed # exhausted retries
```
For a real UI, ClassroomIO mounts [Workbench](https://github.com/pontusab/workbench)
in `apps/api` at `/admin/queues`.
## Behaviour matrix when the worker is down
| Scenario | API behaviour | Job state |
| ------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Worker stopped, Redis up | API enqueues normally, returns success | Sits in `waiting`. Drains as soon as the worker starts. |
| Worker + Redis both down | Email helper logs once, returns empty result; media falls back to a `media_job` row with no BullMQ id | Domain action still succeeds; work is dropped (email) or stuck in `queued` (media). |
| Worker crashes mid-job | Other workers / next worker boot picks up retry | BullMQ moves the job back to `waiting` after the visibility timeout. |
| Job exhausts all retries | API unaffected | Row in `dead_letter_job` for triage. |
## Production
Run the worker as its own service alongside the API. They scale independently
- typically you want N API instances for HTTP concurrency and a smaller fixed
number of worker instances tuned via `*_WORKER_CONCURRENCY` env vars.
Use `node dist/index.js` to run all queues in one process, or
`node dist/workers/<name>.js` to run them as separate services (recommended for
prod so a slow media job can't starve email throughput).
The included `Dockerfile` installs `ffmpeg` and ships the all-in-one entry
point.