Xelma Backend is safe to run on more than one replica, but only because every critical cron job elects a single leader per tick. This document is the reference for that mechanism: which jobs are protected, how to configure it, what to watch, and how to deploy it on Render.
The one hard requirement: every replica must point at the same Redis instance via
REDIS_URL. Without shared Redis there is no leader election, and two replicas will create duplicate rounds and resolve the same round twice.
Every replica boots the same schedulers, so every replica's cron fires on the same tick. Unguarded, that means:
| Job | Damage from a duplicate run |
|---|---|
create-round |
Two ACTIVE rounds for one mode; users bet on a round that is not the live one |
oracle-resolve-rounds |
The same round resolved twice, with two payout passes |
auto-resolve-rounds |
As above, on the legacy resolution path |
reconcile-bets |
The same stranded bet reconciled twice against Soroban |
outbox-poll |
Duplicate notifications and duplicate WebSocket emits |
| retention / cleanup | Wasted duplicate DB sweeps; contention on large deletes |
src/utils/distributed-lock.ts makes
exactly one replica the leader for the duration of each run.
Acquire. SET xelma:lock:<job> <lockId> NX PX <ttl>. The single replica
that wins runs the job; the others log a debug line and skip the tick. This is
normal — on a 3-replica deploy you should see 1 acquired and 2 denied per
tick, forever.
Heartbeat. While the job runs, a background timer re-PEXPIREs the key
every ttl/3. This is the important part: the TTL bounds failure detection,
not job duration. A short TTL no longer means a long job loses its lock.
Before this mechanism existed, oracle-resolve-rounds held a fixed 60 s lock
while looping N rounds with up to 3 attempts and 5 s/10 s backoff sleeps each —
a busy tick ran for minutes, the key expired mid-run, a second replica acquired
the "free" lock, and both replicas resolved the same rounds.
Fail closed. A heartbeat that finds the key missing (expired) or owned by
a different id (stolen) marks the lock lost and fires the handle's
AbortSignal. Long jobs call lock.assertHeld() between units of work — per
round, per batch — so they stop before the next unsafe write rather than
racing the new leader. withDistributedLock also re-verifies ownership with
Redis when the job returns, closing the window where a job finishes between two
heartbeats.
Watchdog. maxHoldSeconds caps how long the heartbeat will keep a lock
alive, so a hung job cannot renew forever and starve every other replica. When
it fires, the job is aborted and the key is released immediately.
In-process guard. A second tick of the same job in the same process is denied locally without touching Redis. This is the overlapping-cron case, which a distributed lock alone does not cover.
| Variable | Default | Purpose |
|---|---|---|
REDIS_URL |
unset | Required for multi-instance. Must be the same Redis for every replica. |
SCHEDULER_LOCK_REQUIRED |
false |
Set to true to refuse to run any locked job unless Redis is reachable. |
ROUND_SCHEDULER_ENABLED |
false |
Enables round creation/close crons. |
AUTO_RESOLVE_ENABLED |
false |
Enables the legacy auto-resolution cron. |
The behaviour on a Redis problem depends on whether Redis was configured, because that is the only signal of operator intent available at runtime:
| Situation | Behaviour | Rationale |
|---|---|---|
REDIS_URL set, Redis reachable |
Normal leader election | — |
REDIS_URL set, Redis unreachable |
Fail closed — job skipped, error logged | A configured Redis means multiple replicas are intended; running unlocked would cause exactly the duplicate work the lock prevents |
REDIS_URL unset |
Runs behind the in-process guard, with a warning per run | Skipping would mean a single-replica deploy never creates or resolves a round at all |
REDIS_URL unset + SCHEDULER_LOCK_REQUIRED=true |
Fail closed | Opt-in hard guarantee |
If you run more than one replica, set
REDIS_URL. The unlocked fallback exists only so single-instance and local deploys work; it is not safe with two replicas, and it warns loudly on every run.
TTLs were reviewed against actual job duration. With the heartbeat in place a
TTL only answers "how long does a crashed leader block the next one", so it
is deliberately kept short; maxHoldSeconds is the bound on a live run.
| Lock | TTL | Max hold | Cron |
|---|---|---|---|
create-round |
30 s | 180 s | every 4 min |
close-eligible-rounds |
30 s | 180 s | every 30 s |
oracle-resolve-rounds |
60 s | 600 s | ORACLE_RESOLVE_INTERVAL_SECONDS (default 30 s) |
auto-resolve-rounds |
30 s | 600 s | AUTO_RESOLVE_INTERVAL_SECONDS (default 30 s) |
reconcile-bets |
70 s | 600 s | every 60 s |
outbox-poll |
poll interval + 5 s | 300 s | OUTBOX_POLL_INTERVAL_SECONDS (default 10 s) |
cleanup-old-notifications |
60 s | 900 s | daily 02:00 |
run-retention-policies |
60 s | 1800 s | daily 03:00 |
outbox-cleanup |
60 s | 900 s | daily 03:30 |
When adding a job, pick the TTL from how long you can tolerate a crashed leader
blocking it, and maxHoldSeconds from a generous upper bound on a healthy run.
The renewal interval is derived (ttl/3) and clamped below the TTL, so a
misconfiguration cannot make the lock race its own expiry.
await withDistributedLock(
'my-job',
async lock => {
for (const item of items) {
// Stop before the next write if leadership was lost.
lock.assertHeld();
await doUnsafeWork(item);
}
},
{ ttlSeconds: 30, maxHoldSeconds: 600 }
);Rules:
- Call
assertHeld()before every unsafe write, not once at the start. The gaps that matter are the ones aroundawait— DB round-trips, Soroban calls, and especially retry backoff sleeps. - Let
LockLostErrorpropagate. Atry/catcharound the work must re-throw it (if (isLockLostError(error)) throw error;), or the abort is silently swallowed and the job keeps writing. - Handle it at the top level with
isLockLostErrorand record the run asaborted, notfailure— losing a lock is a correct, safe outcome. - Do not assume the job ran.
withDistributedLockreturnsnullwhen the lock was not acquired or when the run aborted.
All metrics are on GET /metrics. The lock label is the fixed job name, so
cardinality is bounded by the number of scheduled jobs.
| Metric | Labels | Meaning |
|---|---|---|
distributed_lock_acquisitions_total |
lock, outcome |
acquired, denied (another replica), denied_local (overlapping tick), unavailable (fail-closed), unlocked (no Redis), error |
distributed_lock_renewals_total |
lock, outcome |
renewed, stolen, expired, error |
distributed_lock_lost_total |
lock, reason |
stolen, expired, redis_error, max_hold_exceeded |
distributed_locks_held |
lock |
Locks currently held by this instance (0 or 1 per job) |
distributed_lock_held_seconds |
lock |
Hold duration histogram — compare against the TTL when tuning |
Scheduler jobs also report scheduler_runs_total{job,outcome} with an
aborted outcome when a run stopped because the lock was lost.
On an N-replica deploy, per tick: exactly one acquired, N-1 denied, a flat
stream of renewed, and zero stolen or expired.
groups:
- name: xelma-scheduler-locks
rules:
# Two instances briefly believed they were the leader. Investigate
# immediately: duplicate resolution may have occurred.
- alert: XelmaSchedulerLockStolen
expr: increase(distributed_lock_lost_total{reason="stolen"}[10m]) > 0
labels:
severity: critical
annotations:
summary: "Scheduler lock {{ $labels.lock }} was stolen mid-job"
# Redis is down and every replica is now skipping this job.
- alert: XelmaSchedulerLockUnavailable
expr: increase(distributed_lock_acquisitions_total{outcome="unavailable"}[10m]) > 0
for: 5m
labels:
severity: critical
annotations:
summary: "Scheduler job {{ $labels.lock }} is not running (Redis unreachable)"
# REDIS_URL is missing. Fine on one replica, unsafe on several.
- alert: XelmaSchedulerRunningUnlocked
expr: increase(distributed_lock_acquisitions_total{outcome="unlocked"}[15m]) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "Scheduler job {{ $labels.lock }} is running without a distributed lock"
# A job is hanging long enough for the watchdog to kill it.
- alert: XelmaSchedulerJobHung
expr: increase(distributed_lock_lost_total{reason="max_hold_exceeded"}[30m]) > 0
labels:
severity: warning
annotations:
summary: "Scheduler job {{ $labels.lock }} exceeded its max hold time"
# No leader is being elected at all — the job has stopped running.
- alert: XelmaSchedulerNoLeader
expr: sum by (lock) (increase(distributed_lock_acquisitions_total{outcome="acquired"}[15m])) == 0
for: 15m
labels:
severity: critical
annotations:
summary: "No instance is acquiring the {{ $labels.lock }} lock"See prometheus-alerts-cookbook.md for the oracle and Soroban alert rules.
- Provision one Redis instance and give every backend service the same
REDIS_URL. A per-service Redis defeats the entire mechanism. - Set the scheduler flags identically on every replica. Leader election handles the duplication; disabling the crons on all but one instance instead removes the redundancy that scaling was for.
- Scale the web service to N instances. No further changes are needed.
- Confirm after deploy that
distributed_lock_acquisitions_totalshows oneacquiredand N-1deniedper tick, and thatoutcome="unlocked"is absent.
A rolling deploy can stop the leader mid-job. The heartbeat stops with the
process, the key expires within its TTL, and the next tick elects a new leader.
Because the leader aborts at its next assertHeld() checkpoint, worst case is a
partially-processed batch that the next run picks up — never a double write.
If you run a separate worker service, keep API_ONLY=true on the API replicas
so only the worker service boots the schedulers. The locks still apply and cost
nothing extra; keep REDIS_URL set on every service either way, since the
outbox poller and bet-idempotency locks need it regardless.
Real-time fanout across replicas requires the same REDIS_URL.
-
Module:
src/utils/socket-adapter.ts—initializeSocketAdapter(io)creates two Redis clients (pub/sub) and callsio.adapter(createAdapter(pub, sub, { key: 'xelma:socket.io' })). -
Fallback: If
REDIS_URLis unset or Redis is unreachable, Socket.IO keeps the in-memory adapter. Broadcasts then reach only clients on the same instance (safe for single-instance/dev, not safe for multi-instance notifications/price ticks). -
Required config:
Variable Default Purpose REDIS_URLunset Required for multi-instance WebSocket. Same Redis as locks. Socket adapter keyPrefixxelma:socket.ioRedis key namespace for adapter pub/sub. Override via SocketAdapterConfig.keyPrefixif you run multiple environments on one Redis.connectTimeout2000 ms Redis client connect timeout. -
Verify multi-node emit:
REDIS_URL=redis://localhost:6379 npm run dev # on two terminals on different ports # Join both clients to the same room (e.g. round:abc) and emit via websocketService; both should receive the event
-
ACL hardening:
src/socket.ts:isAuthorizedPrivateRoomJoinensuresuser:<id>rooms are only joinable by the owninguserId. Unauthorizedjoin:notificationswith a foreignuser:<otherId>is rejected with anerrorevent and logged as a warning. Auto-join on connect still runs (socket.join(user:<own>)) sowebsocketService.emitNotification(userId, …)reaches the correct tenant even across adapters. -
Monitoring:
GET /metricsexposeswebsocket_connection_events_totalandsocket:iometrics. After scale-up,isUsingRedisAdapter(io)should betrueon every replica; otherwise broadcasts are instance-local.
Rate limits are enforced from the same shared Redis so throttles hold across replicas. Without this, each replica runs its own in-process counter and an attacker can simply rotate across instances to bypass a limit.
-
Module:
src/middleware/rateLimiter.middleware.tsbuilds oneRedisRateLimitStore(src/lib/redis.ts) per limiter with a unique prefix (xelma:rl:<limiter-name>:), all over the app's single shared Redis client. Each counter is a fixed window stored in a Redis hash, incremented atomically with Lua so concurrent replicas can never double-count or race a window reset. -
No Redis configured? When
REDIS_URLis unset every limiter keeps express-rate-limit's default in-processMemoryStore, so local single-node development is completely unchanged. -
Availability policy: when Redis is configured but unreachable:
Situation Behaviour Rationale REDIS_URLunsetIn-process MemoryStore(per instance)Single-node dev/demo; nothing to share REDIS_URLset, Redis reachableShared Redis counters Throttles hold across replicas REDIS_URLset, Redis unreachable,RATE_LIMIT_REDIS_FAIL_OPEN=true(default)Fail open — per-process fallback window, warning + metric per request The API stays up; throttling briefly degrades to per-instance until Redis recovers REDIS_URLset, Redis unreachable,RATE_LIMIT_REDIS_FAIL_OPEN=falseFail closed — request rejected (HTTP 500) Shared throttling treated as a hard requirement; the outage is loud -
Required config:
Variable Default Purpose REDIS_URLunset Required for shared rate limits. Same Redis as locks and WebSocket. RATE_LIMIT_REDIS_PREFIXxelma:rlRedis key namespace (each limiter appends its name). RATE_LIMIT_REDIS_FAIL_OPENtrueOutage policy — see the table above. -
Verify multi-process throttling:
# Terminal 1 and 2: two replicas, same Redis REDIS_URL=redis://localhost:6379 PORT=3000 npm run dev REDIS_URL=redis://localhost:6379 PORT=3001 npm run dev # Hammer one endpoint from a single IP; both replicas must observe the same # counter and start answering 429 at the same request count. for i in $(seq 1 15); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/rounds/active; done # Then check the shared keyspace: redis-cli keys 'xelma:rl:*'
-
Monitoring:
rate_limit_store_fallbacks_total{limiter}counts requests served from the per-process fallback because Redis was unreachable — alert on it to catch a Redis outage that has silently degraded shared throttling.
src/tests/distributed-lock.spec.ts— acquire/release/extend semantics, availability policy, metrics.src/tests/distributed-lock-multiworker.spec.ts— the real safety properties. Each "worker" is a separately-loaded copy of the lock module, so it has its own in-process guard exactly like a separate replica, and the only thing workers share is the Redis keyspace. Covers non-overlap under contention, heartbeat renewal past the TTL, abort on a stolen or expired key, the watchdog, and Redis-outage behaviour.src/tests/rate-limit-redis-store.integration.spec.ts— shared counters across store instances, per-prefix independence, and window expiry against a real Redis.
npx jest --selectProjects unit --testPathPattern="distributed-lock"