Sertaç Yıldırımfield notes

Home → Engineering

Failure Modes

09:00:00 — the market opened. 09:00:02 — Redis at 100% CPU. 09:03 — three of the gateways are dead. Nobody deployed, no code changed, traffic was within expectations. The system killed itself.

Summary
  • Slowness is more dangerous than crashing. A crashed node leaves the pool; a slow node slows everyone down.
  • Anything that starts at the same moment produces a herd. Cron jobs, TTLs, restarts. The answer is jitter again.
  • Without timeouts, a circuit breaker does nothing. Order matters: timeout first, breaker second.
  • Failover loses data. With async replication the last seconds of writes are gone; critical state must not live only in a cache.

1. The thundering herd: everyone at once

The market opens at 09:00. In that second:

  • Every gateway pulls the symbol list from Redis.
  • Every bridge connects to its provider.
  • Every scheduled job fires on 0 0 9 * * *.
  • Clients idle overnight reconnect.

None of these is heavy on its own. But all of them land in the same second. The system cannot handle in one second the load it carries comfortably for the rest of the day.

What produces the synchronisation?
  • Cron expressions. Everyone loves :00.
  • TTLs set at the same moment. Write 400 keys with a 300-second TTL at open and all 400 die in the same second.
  • Bulk restarts. After a deploy every pod comes up together and warms its cache together.
  • Retries without jitter. The wave effect from the previous post.
The cure: a bit of randomness everywhere
// 1) Scheduled job: offset by 0-30s instead of a fixed second
long offset = ThreadLocalRandom.current().nextLong(30_000);
scheduler.schedule(job, openTime + offset);

// 2) TTL: not fixed, ±20% spread
int ttl = 300;
int spread = ThreadLocalRandom.current().nextInt(-60, 61);
redis.setex(key, ttl + spread, value);

// 3) Staggered startup: wait based on pod index
int index = readPodIndex();            // StatefulSet ordinal or a hash
Thread.sleep(index * 500L);            // 0, 500, 1000, 1500 ms...

Ten lines in total. For us, peak Redis usage at market open dropped from 100% to 40% without adding capacity anywhere.

2. Cache stampede: the spread computed 200 times at once

A special and very common form of the herd. The symbol:EURUSD:spread key expires. At that instant 200 gateways read it, all 200 get a miss, and all 200 start computing it.

The computation is not expensive — but 200× is. And by the time it completes the key has expired again, so the loop feeds itself.

Three fixes

a) One computes: a lock
value = redis.get(key);
if (value != null) return value;

// Only one takes the lock
if (redis.set(key + ":lock", me, "NX", "PX", 5000)) {
    value = expensiveCompute();
    redis.setex(key, ttlWithSpread(), value);
    redis.del(key + ":lock");
    return value;
} else {
    // Others: serve the previous value (separate, longer-lived key)
    return redis.get(key + ":previous");
}

Works, but you manage two keys and have to answer "how stale may the previous value be".

b) Probabilistic early expiration — the elegant one
// Store, alongside the value, how long computing it took and when it expires.
// As expiry approaches, the PROBABILITY of refreshing rises.

long remaining = expiresAt - now();
double threshold = -computeMs * BETA * Math.log(Math.random());   // BETA ~ 1.0

if (remaining < threshold) {
    value = expensiveCompute();      // early, spontaneously, alone
    store(value);
}
return value;

The beauty: no locks, no coordination. Only a handful of clients refresh before expiry while the rest read a valid value. The key never expires "for everyone at once". And the more expensive the computation, the earlier the refresh starts — the formula handles that by itself.

c) Never expire, refresh in the background

The key never dies; a separate job recomputes and overwrites it every 30 seconds. Readers always find a value. The cost: if the refresher dies, the value goes stale silently. So store the computation timestamp alongside the value and check its age when reading.

3. Cascading failure: slowness spreads

Now the main event. The opening story continued like this:

StepWhat happened
1Redis slowed down because of the opening herd. Queries went from 2 ms to 400 ms.
2Gateways wait on Redis for every request. Threads are busy.
3Incoming requests cannot be served, the internal queue grows.
4The queue is unbounded, so memory fills.
5One gateway dies with an OOM.
6The load balancer spreads its traffic to the rest.
7The rest were already struggling; with more load they die too.

Note that in step 1 Redis did not crash, it only slowed down. Had it crashed, the gateways would have received instant errors and responded quickly. Slowness is worse, because it makes everyone wait.

Crashing is honest: you know immediately. Slowness is insidious: it turns the whole system into itself.

Three defences, in order

a) Every external call gets a timeout
// The default is usually NONE or 30+ seconds.
// In trading, 30 seconds means infinity.
jedis.setTimeout(200);                        // Redis: 200 ms
httpClient.timeout(Duration.ofMillis(800));   // MT5 bridge
db.setQueryTimeout(3);                        // seconds

Skip this step and nothing else works. Neither circuit breakers nor queue bounds can engage without timeouts.

b) Every queue gets a bound

This is what cuts step 4. A bounded queue rejects requests when full — bad, but the process survives. An unbounded queue accepts until memory runs out and then loses all of it at once.

c) Load shedding
// If the queue is 80% full, return 503 immediately for non-critical requests
if (queue.utilisation() > 0.8 && !request.isCritical()) {
    return error(503, "Retry-After: 2");
}

Rejecting a price lookup while accepting an order beats slowly losing both. Shedding load is not a failure, it is a prioritisation decision.

4. Circuit breaker: stop calling what does not answer

The concrete incident: the MT5 bridge stopped responding. Every order held a thread until its 30-second timeout. The pool had 200 threads. In seven seconds the pool was exhausted and the gateway could no longer send orders even to healthy bridges.

So one bridge's failure stopped all order flow.

Three states
  • Closed (normal): requests pass, failures are counted.
  • Open: the failure rate crossed the threshold; requests are rejected instantly without being attempted.
  • Half-open: after a while a few trial requests are let through. Success closes it, failure opens it again.
What to watch when tuning
CircuitBreakerConfig.custom()
    .slidingWindowSize(100)              // last 100 calls
    .minimumNumberOfCalls(20)            // ← do not decide before 20 calls
    .failureRateThreshold(50)            // 50% failures -> open
    .slowCallDurationThreshold(Duration.ofMillis(800))
    .slowCallRateThreshold(50)           // ← SLOW calls count as failures
    .waitDurationInOpenState(Duration.ofSeconds(10))
    .permittedNumberOfCallsInHalfOpenState(5)
    .build();
  • Without minimumNumberOfCalls, the first two failing calls of the morning open the circuit and the service is never really tried.
  • slowCallRateThreshold matters a lot. This is where gray failure gets caught: the bridge is not returning errors, it is returning in 5 seconds. If slow calls do not count as failures, the circuit never opens.
The most skipped question: what do you return while open?

Adding a circuit breaker is not enough; you have to design the fallback behaviour. On the trading side the answer depends on the data:

  • Price lookup: return the last known price with its age. "A price from 3 seconds ago" beats no price.
  • Placing an order: reject fast. Do not quietly queue it and say "we will send it later" — the customer thinks it went through.
  • Position list: read from the database, skip the cache. Slow but correct.

A breaker added without thinking this through only makes the error message arrive faster.

5. Gray failure: alive but dead

The hardest failure class to diagnose. The streaming engine is up, /health returns 200 OK, the process is running. But because of a long garbage collection pause it cannot do the actual work: it is not processing prices, it is publishing the 4-second-old quote it already had.

The load balancer sees a healthy node and keeps sending traffic. Result: a quarter of orders open on stale prices.

A shallow health check
GET /health
→ 200 {"status":"UP"}

// The code:
return ok();      // ← the process is alive, that is all

This only says "the process is running". Of very limited use.

A health check that looks at the work
GET /ready
{
  "last_tick_age_ms": 4200,     // ← no tick processed for 4 seconds
  "queue_depth": 48000,
  "gc_pause_p99_ms": 1800,
  "status": "DEGRADED"
}

// Rule: if last_tick_age > 1000 then NOT READY

This tells you whether it is genuinely doing its job.

But there is a trap here

Tie the health check to dependencies and you invent a new cascade: when Redis slows down, every gateway declares itself unhealthy, the load balancer removes them all, and instead of a slow system you have a completely dead one.

Keep the distinction sharp:

  • Liveness: process health only. Failing it restarts the pod. Dependencies are not checked.
  • Readiness: can I take traffic? It looks at internal state (queue, tick age). If it looks at dependencies, they must not all fail together.

On top of that, outlier detection at the load balancer is very effective: a rule like "this node is 5× slower than the others" pulls it out temporarily even when its health check says OK. It is the most practical defence against gray failure, because it measures health relatively rather than absolutely.

6. Failover: it took over, but what did it lose?

The Redis master died, Sentinel promoted a replica, and the system came back in 8 seconds. Everyone relaxes. But:

The silent loss

Replication is asynchronous. The master accepts a write, tells the client "done", and then sends it to the replica. At the moment it died, the last 1–2 seconds of writes had not been sent yet.

What was in those seconds? Maybe the state of 60 orders. The new master has never seen them. The application says "no such order" while the bridge may already have processed them. You now have 60 orders in unknown state.

The only real fix is architectural:

  1. Critical state must not live only in a cache. The source of truth for orders and positions has to be a durable database, with Redis as a fast copy. The "let us keep it in Redis, it is fast" decision looks right until failover day.
  2. Post-failover reconciliation must be automatic. On detecting a promotion, trigger a job that pulls the last N minutes of orders from the bridge, compares them with local state and reports the differences. Anything meant to be done manually does not get done.
  3. Record the promotion as an event. "A failover happened at this time" is the only answer to "why is this order missing" three days later.
And one more: two masters at once

During a network partition the old master may still believe it is the master and keep accepting writes. This is the same split-brain problem as in leader election, and the remedy is the same: attach an epoch number to critical writes and reject older ones.

All together: which mode is which

SymptomLikely modeLook at first
Load spikes at particular timesThundering herdCron times, TTL distribution
CPU jumps at regular intervalsCache stampedeTTL values, keys expiring together
One service slowed, everything diedCascading failureAre there timeouts, are queues bounded
Thread pool exhaustionNo circuit breakerTimeouts on external calls
Health green but customers complainingGray failureDoes readiness look at real work
Inconsistent data after a short outageFailover lossIs replication sync, is there reconciliation

Checklist AI agent task list

Check today
  • Do all external calls have timeouts? (Usually there is no default.)
  • Do all queues have an upper bound?
  • Do scheduled jobs all start in the same second?
  • Do TTL values have a spread, or are they all fixed?
  • Does the circuit breaker count slow calls as failures?
  • What do you return while the circuit is open? Is it written down?
  • Does readiness check the real work, or only the process?
  • If readiness depends on a dependency, can they all fail together?
  • Does critical state live only in a cache?
  • Is there automatic reconciliation after a failover?

Conclusion

What these six modes have in common: none of them comes from a coding mistake. They all emerge from correctly written parts feeding each other under load. That is why they never show up in unit tests, only on a bad morning.

And the cure for all of them gathers in one place: set a limit. A limit on timeouts, on queues, on retries, on how much starts at the same moment. Anything without a limit will eventually have one set for it — usually by memory running out.