make up
make migrate
make down
make restart
make logs
make psmake env-check copies .env.example to .env and deploy/.env.infra.example to deploy/.env.infra if either is missing.
make kind-up
make k8s-deployIterate on changes:
make k8s-redeployTail logs for a component:
make k8s-logs C=api
make k8s-logs C=workerhelm repo add async-proxy oci://ghcr.io/exec-unit/charts
helm install async-proxy async-proxy/async-long-request-proxy \
--set secret.databaseUrl="postgres://..." \
--set secret.redisHost="redis-host" \
--set secret.proxyApiKey="your-key"Helm values reference:
| Path | Default | Description |
|---|---|---|
image.tag |
latest |
Docker image tag |
api.replicaCount |
2 |
API pod count |
api.hpa.enabled |
true |
Enable HPA for API (scales by CPU) |
api.hpa.maxReplicas |
10 |
Max API replicas |
worker.replicaCount |
1 |
Worker pod count |
worker.keda.enabled |
true |
Enable KEDA for worker (scales by queue depth) |
worker.keda.redisQueueLength |
50 |
Queue depth threshold per worker replica |
secret.existingSecret |
"" |
Use an existing k8s Secret (Vault / ESO) |
The Helm chart includes a pre-install migrate Job that runs DB migrations before pods start, separate Deployment manifests for API and Worker, and a KEDA ScaledObject for the Worker.
See deploy/helm/async-long-request-proxy/values.yaml for the full reference.
CI (.github/workflows/ci.yml): runs on every push/PR to main - lint, typecheck, unit tests, E2E tests, and Trivy security scan.
Publish (.github/workflows/publish.yml): triggered on v* tags - builds and pushes Docker images to GHCR, packages and pushes the Helm chart as an OCI artifact, creates a GitHub Release with a conventional changelog.
Release process:
- Bump version in
package.json git tag v1.x.x && git push --tags
The publish workflow validates that the tag is on main and matches package.json version before building.
| Endpoint | Use case | Returns |
|---|---|---|
GET /health/live |
Kubernetes liveness probe | Always 200 { status: "ok" } if process is alive |
GET /health/ready |
Kubernetes readiness probe | 200 if Postgres + Redis are reachable; 503 otherwise |
GET /metrics |
Prometheus scrape target | text/plain Prometheus format |
Configure the liveness probe to use /health/live and the readiness probe to use /health/ready. The proxy is removed from the load balancer endpoint set when readiness fails.
The proxy exposes default Node.js process metrics via prom-client at /metrics.
Metrics to alert on:
process_heap_used_byteson the Worker process: indicates SSE client accumulation if rising steadilyhttp_request_duration_secondsonPOST /v1/tasksp99: should be under 500ms under normal load- BullMQ queue depth (via Redis
LLENor KEDA metrics): triggers Worker autoscaling
The API process is stateless and scales horizontally. Use the Kubernetes HPA:
api:
hpa:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 80For SSE-heavy workloads, consider using targetMemoryUtilizationPercentage alongside CPU, since each active SSE connection holds an ioredis subscriber.
The Worker scales based on BullMQ queue depth using KEDA:
worker:
keda:
enabled: true
minReplicaCount: 1
maxReplicaCount: 10
redisQueueLength: 50When the combined queue depth of dispatch + webhook + cancel queues exceeds redisQueueLength x currentReplicas, KEDA adds a replica.
The Worker can scale to 0 in low-traffic periods with KEDA, but the maintenance cron jobs (timeout sweeper, data retention) require at least 1 replica. Set minReplicaCount: 1 for production.
Migrations are managed by drizzle-kit and run from the migrate init container in Kubernetes:
# Local
make migrate
# Docker Compose
docker compose run --rm migrate
# Kubernetes (runs automatically as a pre-install Helm Job)
helm install async-proxy ... --waitMigration files are in src/database/drizzle/migrations/. They are append-only - never edit an existing migration.
Inspects schema changes and generates a new .sql migration file:
pnpm db:generateApplies pending migrations to the DB:
pnpm db:migrateThe schema includes purpose-built indexes:
| Index | Purpose |
|---|---|
tasks_status_idx |
General status queries (get all PENDING) |
tasks_sweeper_idx |
Partial index on expires_at WHERE status IN ('PENDING','PROCESSING') - hot path for the timeout sweeper |
tasks_idempotency_key_idx |
Unique partial index (only for non-null keys) |
task_events_task_id_seq_unique_idx |
SSE replay queries, prevents duplicates |
task_events_created_at_idx |
Data retention range queries |
Do not drop tasks_sweeper_idx - without it, the sweeper performs a full table scan on every cron cycle.
Terminal tasks and their events are deleted by the data retention cron job:
- Controlled by
DATA_RETENTION_DAYS(default: 30) - Schedule controlled by
DATA_RETENTION_CRON(default:0 3 * * *- 03:00 UTC daily) - Deletes in batches of 500 rows to prevent lock contention
- Only terminal tasks (
COMPLETED,FAILED,CANCELLED) are pruned - in-flight tasks are never deleted
To adjust:
DATA_RETENTION_DAYS=7
DATA_RETENTION_CRON=0 2 * * *Standalone (default):
REDIS_HOST=my-redis.example.com
REDIS_PORT=6379
REDIS_PASSWORD=secretCluster mode:
REDIS_CLUSTER_MODE=true
REDIS_CLUSTER_NODES=redis-1:6379,redis-2:6379,redis-3:6379Cross-field validation: REDIS_CLUSTER_MODE=true without REDIS_CLUSTER_NODES aborts startup with an error.
- Redis >= 6.0 (for
KEEPTTLsupport in idempotency commits) maxRetriesPerRequest: nullis set on BullMQ connections - required by BullMQ
| Key pattern | Purpose | TTL |
|---|---|---|
idempotency:{key} |
Idempotency slot | 24 hours |
bull:{queue}:* |
BullMQ job data | Managed by BullMQ |
task:{taskId} |
Redis Pub/Sub channel (ephemeral) | None (channel, not key) |
BullMQ retains completed jobs for 1 hour and keeps the last 100 failed jobs (configurable in BullMqAdapter).
The sweeper is a BullMQ repeatable job registered by TimeoutSweeperProcessor at Worker startup.
- Default schedule: every minute (
* * * * *UTC) - Configurable via
TIMEOUT_SWEEPER_CRON - Safe for multi-replica Workers:
upsertJobScheduleris idempotent; BullMQ elects one runner per cycle - Batch size: 500 tasks per sweep; loops until no expired tasks remain
If frequent timeout sweeper warnings appear in logs, executors may be failing silently. Check:
- Is
executorUrlreachable from the Worker? - Is
timeoutSecondsappropriate for the workload? - Are executors pushing results back within the timeout?
Logs are structured JSON (Pino). Fields:
| Field | Description |
|---|---|
level |
trace, debug, info, warn, error |
context |
NestJS component name (e.g., TasksService) |
msg |
Human-readable message |
taskId |
Appears in task-related log lines |
In development mode, pino-pretty is available for human-readable output:
pnpm start:dev | pnpm pino-prettyLog levels to alert on:
error- always alertwarnwithTimeout sweep: expired- executors may be failingwarnwithWebhook delivery failed- the webhook endpoint may be downerrorwithFailed to revert task- DB outage during dispatch
The dispatch job may have failed before the task transitioned to PROCESSING. Check:
- Worker logs for
Failed to enqueue dispatch - BullMQ failed jobs (via Redis CLI:
LRANGE bull:dispatch:failed 0 -1) - Executor connectivity from Worker: can the Worker reach
executorUrl?
The timeout sweeper will eventually fail the task (within timeoutSeconds + sweep_interval).
The executor received the dispatch but never pushed a result. Possibilities:
- Executor crashed without sending a result
- Executor's result POST is failing (check executor logs)
- Network partition between executor and proxy
The timeout sweeper will fail the task after expiresAt.
- Is the reverse proxy stripping SSE headers? Check that
X-Accel-Buffering: noandCache-Control: no-cachepass through. - Is the proxy idle timeout shorter than the heartbeat interval (25 s)? Increase the proxy timeout or reduce
HEARTBEAT_INTERVAL_MS. - Is
Last-Event-IDbeing passed on reconnect? The browser handles this automatically, but custom clients must send it.
- Is
idempotencyKeyexactly the same string in both requests (case-sensitive)? - Is the first request completing within 24 hours? The TTL window is fixed.
- Check Redis for the key:
GET idempotency:{your-key}. If empty, the slot expired.
REDIS_CLUSTER_MODE=true requires REDIS_CLUSTER_NODES to be set. Missing nodes aborts startup. Verify the env var format: host1:port1,host2:port2.