Skip to main content
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-service replaces the entire runtime-environment-variables object on the service — passing only the new vars would silently delete DATABASE_URL, REDIS_URL, all your secrets. Always read-modify-write.

3.2 — Schedule the drain endpoint

You may already have in-process drain. When CRON_SECRET (or CRON_TOKEN) is set, the in-process maintenance scheduler runs by default and self-invokes POST /api/v1/cron/drain-queues every 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.
Pick one scheduler. All work; pick by ergonomics + cost.
  1. Sign in at https://cron-job.org.
  2. Create cronjob:
    • Title: Kaireon drain queues
    • URL: https://<your-domain>/api/v1/cron/drain-queues
    • Schedule: Every 5 minutes
    • Save responses: ✅ on
  3. Advanced tab:
    • Method: POST
    • Headers: X-Cron-Token: <DRAIN_QUEUES_TOKEN-value>
    • Timeout: 60 seconds
First execution fires within 5 min. Verify in cron-job.org’s “Execution history” tab that you see 200 OK.

Option B — AWS EventBridge (cleanest if all-in on AWS)

Cost: ~$0.002/month at 5-min cadence (well under EventBridge’s 14M-invocation free tier).

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 ~53/monthat5mincadence(eachtick=1minuterounded×53/month at 5-min cadence (each tick = 1 minute rounded × 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_TOKENCRON_SECRETCRON_TOKEN. The fallback chain lets you rotate without downtime.
The fallback chain means there’s never a window where the cron is rejected by the endpoint.

5. Token blast radius (reference)

What an attacker with DRAIN_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

Reverse the value to go cron-driven → always-on. If you run a dedicated 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-queues returns totalProcessed: 0 for 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:
  1. Docker build (~2 min)
  2. ECR push (~30 sec for incremental layers)
  3. App Runner roll-out (~2 min — container restart + health check)
Verify with:

8. Incident: “Upstash quota exhausted”

Symptoms: every Redis-backed feature returns ERR max requests limit exceeded (rate limit, cache, BullMQ enqueue). Email from Upstash announcing free-tier limit reached.

Diagnosis

If real /recommend traffic × ~5-8 ops/call < 500K, the burn is from idle worker polling — apply the fix in §3.

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:
The pattern shipped on 2026-05-06 closed 7 Dependabot alerts (4 high @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.