Audience: On-call engineers, SREs, platform operators
First written: 2026-05-06 (after a free-tier Upstash quota exhaustion incident)
Related: /api/v1/cron/drain-queues reference, Env vars, Incident response
This runbook captures everything you need to operate KaireonAI’s BullMQ worker queues on free-tier or low-budget Redis. It exists because, on 2026-05-06, the playground hit Upstash’s 500K-commands/month limit purely from idle worker polling — zero queued jobs, ~1.3M ops/month wasted on BRPOPLPUSH polls. The fix took ~30 minutes; the patterns below prevent it from happening again.
1. The two worker modes
KaireonAI ships with two execution modes:
For the queue mix on a typical deployment (
batch-jobs, dsar-jobs, journey-jobs, retrain-jobs, seed-jobs), a 5-min cron sustains low-latency batch + DSAR + retrain workloads while burning ~170K ops/month idle — comfortably under Upstash free tier 500K. A 30-min cron drops to ~29K ops/month idle but adds up to 30 min of latency for batch / DSAR / retrain (still fine — these aren’t real-time).
When to pick which
2. Upstash quota math (at a glance)
Free-tier Upstash gives 500,000 Redis commands per month. Here’s where the budget goes:
Rule of thumb: with
WORKER_INPROCESS=0 + 5-min cron, you can sustain ~50K /recommend calls/month on free-tier Upstash before any rate-limit/cache cost becomes the binding factor. Above that, upgrade Redis.
3. Setup checklist (new deployment)
If you’re standing up a new App Runner service or migrating an existing one to cron-driven mode:3.1 — Set the env vars on App Runner
Why the snapshot-merge dance:update-servicereplaces the entire runtime-environment-variables object on the service — passing only the new vars would silently deleteDATABASE_URL,REDIS_URL, all your secrets. Always read-modify-write.
3.2 — Schedule the drain endpoint
You may already have in-process drain. WhenPick one scheduler. All work; pick by ergonomics + cost.CRON_SECRET(orCRON_TOKEN) is set, the in-process maintenance scheduler runs by default and self-invokesPOST /api/v1/cron/drain-queuesevery 15 minutes (alongside the other/api/**/cron/*jobs). On a single-container deployment that is often enough on its own. An external scheduler (below) is only needed if you have disabled the maintenance scheduler (MAINTENANCE_SCHEDULER_ENABLED=false), run multiple API replicas, or want a tighter cadence than 15 minutes. Verify which is firing with the log check in §3.3.
Option A — cron-job.org (recommended, free, zero AWS resources)
- Sign in at https://cron-job.org.
- Create cronjob:
- Title:
Kaireon drain queues - URL:
https://<your-domain>/api/v1/cron/drain-queues - Schedule:
Every 5 minutes - Save responses: ✅ on
- Title:
- Advanced tab:
- Method: POST
- Headers:
X-Cron-Token: <DRAIN_QUEUES_TOKEN-value> - Timeout:
60seconds
200 OK.
Option B — AWS EventBridge (cleanest if all-in on AWS)
Option C — GitHub Actions cron (simplest if your repo is public)
.github/workflows/drain-queues.yml:
Cost note: free for public repos. Private repos incur ~0.008/min × 8,640 ticks/month minus 2,000 free min). Use cron-job.org or EventBridge for private repos.
Option D — UptimeRobot or other uptime-monitor
Same shape as Option A. Most uptime monitors support custom HTTP headers on free tiers.3.3 — Verify
4. Token rotation procedure
Periodic rotation reduces the blast radius of a leaked token. The drain endpoint accepts (in priority order):DRAIN_QUEUES_TOKEN → CRON_SECRET → CRON_TOKEN. The fallback chain lets you rotate without downtime.
5. Token blast radius (reference)
What an attacker withDRAIN_QUEUES_TOKEN can do:
Realistic worst case: cost amplification on compute. Rate-limit blocks abuse beyond 12 req/min per IP. Even with a leaked token, no user data is exposed and the app cannot be destroyed.
If a leak is suspected: rotate (above) and the leaked token becomes invalid on the next App Runner restart (~5 min).
6. Common operations
6.1 — Switch between worker modes
kaireon-worker container (the standalone src/worker/index.ts entrypoint, which always runs the five workers regardless of its own env), set WORKER_INPROCESS=0 on the API container so the two don’t both consume the same queues (double-counted metrics + job-ordering risk). Only run the API with WORKER_INPROCESS=1 when there is no separate worker container.
6.2 — Trigger a one-shot drain manually
maxDurationMs caps wall-clock at 1 min (default) up to 10 min (hard cap). maxConcurrentQueues=2 (default) means at most 2 BullMQ workers run inside one invocation.
6.3 — Move to a new Upstash database
Caveat: switching Redis instances abandons any in-flight queued jobs in the old database. For playground / dev environments this is fine; for production, wait for queues to drain (drain-queuesreturnstotalProcessed: 0for several consecutive ticks) before switching.
7. Deploy flow (this app)
Standard deploy path for code or env-var changes:
Always read-modify-write env vars via the snapshot pattern above —
update-service replaces the entire runtime-environment-variables block on the service.
Each deploy takes ~4-5 minutes end-to-end:
- Docker build (~2 min)
- ECR push (~30 sec for incremental layers)
- App Runner roll-out (~2 min — container restart + health check)
8. Incident: “Upstash quota exhausted”
Symptoms: every Redis-backed feature returnsERR max requests limit exceeded (rate limit, cache, BullMQ enqueue). Email from Upstash announcing free-tier limit reached.
Diagnosis
Resolution paths
The first option is recommended for free-tier deployments; it removes the burn at its source. The second is the “panic-restart” version when you also want a clean Redis with no abandoned queue state.
9. Dependency-vulnerability triage process
When GitHub Dependabot opens alerts on the repo:@xmldom/xmldom, 2 medium postcss/fast-xml-parser, 1 low @tootallnate/once) plus a bonus axios HIGH via npm overrides — see commit 86eff3f for the exact change.