Multi-instance runtime
The Wrkbelt API runs as a horizontally-scaled cluster — multiple ECS tasks behind an ALB, sharing state through Redis, SQS, and MongoDB. Anything that mutates shared state has to coordinate; anything that fans out side-effects has to dedup. This page is the architectural reference: topology, primitives, and how they fit together. For the decision guide on which coordination primitive to pick for a new feature, see Cluster coordination pattern. For operational procedures — /health/ready triage, manual lock release, reading structured-log keys, capacity tripwires — see the Multi-instance runtime runbook.
Production topology
Production autoscales between 1 and 3 tasks on CPU (primary signal, 60% target) and memory (co-signal, 80% target — sits above V8's typical 60–75% steady-state heap so GC laziness doesn't trip scale-out or block scale-in). See copilot/api/manifest.yml for the per-environment count blocks (develop inherits the default; staging pins min=max=1; production runs 1–3).
Deploys auto-rollback on the four cluster-coordination correctness alarms (redis.adapter.disconnected, redis.subscription.dropped, redis.lock.unavailable, redis.resource_lease.remove_failed) — deployment.rollback_alarms is wired per environment in the manifest, referencing the alarms defined in copilot/api/addons/observability.yml. A bad deploy that trips any of these reverts to the previous task definition without operator intervention.
The ALB uses sticky-session cookies (lb_cookie) so a single client lands consistently on one task — important for Socket.IO long-lived connections. Backend coordination assumes any task can handle any state-mutating request; sticky sessions are an optimization, not a correctness contract.
OpenSpec contracts: openspec/changes/add-multi-instance-runtime-readiness/proposal.md, openspec/changes/add-multi-instance-runtime-readiness/design.md, openspec/changes/add-multi-instance-runtime-readiness/specs/multi-instance-runtime/spec.md.
The four ioredis clients
Each task owns four ioredis connections. They are separated by purpose, not for performance — pub/sub blocks the connection it uses, and Socket.IO's Redis adapter requires its own publisher and subscriber.
| Client | Purpose | Source |
|---|---|---|
RedisCommandClient | General commands (GET/SET/DEL/SCAN, etc.) | libs/api/services-api/src/lib/shared/redis/redis-connection.factory.ts |
RedisAppSubscriber | Application pub/sub (e.g. ancestry-invalidate) | same factory |
RedisSocketIoPublisher | Socket.IO Redis adapter — outbound | same factory |
RedisSocketIoSubscriber | Socket.IO Redis adapter — inbound | same factory |
The factory and connection module live at libs/api/services-api/src/lib/shared/redis/redis.module.ts and redis-connection.factory.ts. Direct client injection is not allowed outside RedisModule and the Socket.IO adapter factory — see the boundary rule in Cluster coordination pattern.
The five semantic Redis services
Application code consumes Redis through one of five semantic interfaces. Each owns its primitive's error policy, observability emits, and contract semantics. New code injects these — not the raw clients.
| Service | Backing | Contract | Source |
|---|---|---|---|
RedisCacheService | Redis key/value + TTL | RemoteCacheProvider (get/set/delete/getOrFetch) | libs/api/services-api/src/lib/shared/redis/redis-cache.service.ts |
RedisRateLimiterService | Atomic counter + window | RateLimiter (acquire/tryAcquire) | libs/api/services-api/src/lib/shared/redis/redis-rate-limiter.service.ts |
RedisDistributedLockService | SET NX EX + Lua compare-and-delete | DistributedLock (acquire/release/withLock) | libs/api/services-api/src/lib/shared/redis/redis-distributed-lock.service.ts |
RedisResourceLeaseService | Same primitive as DistributedLock, richer RemoveResult | ResourceLease (claim/release) | libs/api/services-api/src/lib/shared/redis/redis-resource-lease.service.ts |
RedisBroadcastChannelService | Pub/sub with auto-resubscribe | BroadcastChannel (publish/subscribe) | libs/api/services-api/src/lib/shared/redis/redis-broadcast-channel.service.ts |
Cluster coordination pattern is the decision guide for picking among them. Don't reach for a sixth primitive without reading it first.
Socket.IO fanout
Real-time scheduler events (the embedded widget) and operator-facing updates flow through Socket.IO. With multiple API tasks, a message published on task A must reach a client connected to task B. The Redis adapter solves this.
- Adapter factory:
libs/api/services-api/src/lib/core/socket/socket-redis-adapter.factory.ts— consumes theRedisSocketIoPublisherandRedisSocketIoSubscriberclients. - The factory is the only consumer of
RedisSocketIoPublisher/RedisSocketIoSubscriberoutsideRedisModule.
When task A emits to a Socket.IO room, the adapter publishes on a Redis channel. All tasks subscribe; whichever task holds the matching client delivers the message.
Ancestry cache invalidation broadcast
Org-ancestry data is cached per process with a 60-second TTL (Multi-tenant hierarchy → Ancestry cache). On every mutation that affects ancestry — membership change, role change, reparent — the org service publishes on the ancestry-invalidate Redis channel via RedisBroadcastChannelService. Every task subscribes and evicts the affected entry locally.
- Source:
libs/api/services-api/src/lib/core/organization/ancestry-cache.service.ts - Subscriber resilience:
RedisBroadcastChannelServiceauto-resubscribes on TCP reconnect; gaps emitredis.subscription.dropped+redis.subscription.resubscribedwithgap_msfor observability.
Graceful shutdown
ECS sends SIGTERM to a task on deploy or scale-in. The API drains in-flight work cleanly:
- HTTP server stops accepting new connections.
- In-flight requests complete (bounded by ALB drain timeout).
- SQS consumers drain their current batch and stop polling.
- Cron locks held via
RedisDistributedLockServiceare released; lock TTL is the safety net for non-graceful exits. - Socket.IO clients receive a
transport closeand reconnect to a surviving task.
Cron + SQS fanout
The cluster has periodic scans that fanout per-record work (process pending media attachments, retry invoices, etc.). The pattern is cron-tickle + SQS fanout:
- A cron handler runs once per tick on exactly one task per
(jobType, orgId)pair (gated byRedisDistributedLockServicekeyed oncron-fanout:<jobType>:<orgId>). The lock's TTL — derived from the cron interval — is the cooldown; the key itself carries no time component. - The handler scans for eligible records and enqueues one SQS message per record.
- SQS FIFO with
MessageDeduplicationIdper record ensures at-most-once delivery per dedup window. - The SQS consumer processes each record idempotently.
Source: libs/api/services-api/src/lib/scheduler/booking/booking.jobs-service.ts (the canonical reference implementation — tryEnqueueOrgJob).
For why this is a pattern rather than a sixth primitive, see Decisions → Deferred → Generic fanout abstraction.
Operational signals
Every primitive emits structured-log keys consumed by CloudWatch metric filters and alarms. The contract is enforced by a test at libs/api/services-api/src/lib/shared/redis/observability-contract.spec.ts — adding a new redis.* key without an alarm or UNMONITORED_KEYS declaration fails CI. See the full alarm table in Cluster coordination pattern → Operational signals.
CloudWatch alarms ship in copilot/api/addons/observability.yml.
Related
- Decision guide for primitives: Cluster coordination pattern — read this before adding cross-instance coordination
- Operational runbook: Operations → Multi-instance runtime runbook —
/health/readytriage, manual lock release, drain timing, structured-log keys, capacity tripwires - Production hardening: Future state → Multi-instance runtime — dashboards, threshold validation
- OpenSpec:
openspec/changes/add-multi-instance-runtime-readiness/{proposal,design,tasks}.mdand the spec atspecs/multi-instance-runtime/spec.md - Cluster-coord rationale (RFC log decisions): Decisions → Deferred — per-env Redis (D17), generic fanout (D15), API e2e CI (D14)