Sertaç Yıldırım field 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.
  • The system can stay broken after the trigger is gone. This is called a metastable failure; retry and stampede loops feed themselves. Removing the trigger is not enough, you need a limit that breaks the loop.
  • Without timeouts, a circuit breaker does nothing. Order matters: timeout first, then bulkhead, then breaker.
  • Failover loses data. With async replication the last seconds of writes are gone; critical state must not live only in a cache, and every order needs a client-generated ID.

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 * * *. (Six-field Spring format; in Quartz the same job is 0 0 9 * * ?, the all-star form is invalid there.)
  • 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, but with an upper bound
int index = readPodIndex();            // StatefulSet ordinal; a Deployment has NO ordinal
long wait = Math.min(index * 500L, 15_000L)               // with 200 pods the last one must not wait 100 s
          + ThreadLocalRandom.current().nextLong(500);    // + jitter
Thread.sleep(wait);

The third one has two traps. Leave index * 500 unbounded and with 200 gateways the last pod waits 100 seconds; you need an upper bound plus jitter. The ordinal only exists in a StatefulSet; in a Deployment you hash the pod name, and a hash may not spread evenly — 8 of 20 pods can land in the same slot. That is why, in the hashed version, jitter is not decoration but a requirement.

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.

Note: the TTL spread from section 1 does not help here. The spread distributes expiry across keys, not across the 200 readers of the same key. It stops 400 keys dying in the same second; it does not stop one hot key being computed 200 times. For that you need one of the three fixes below.

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;

String me = UUID.randomUUID().toString();        // who owns the lock?

// Only one takes the lock (Jedis 4+: SetParams; returns "OK" or null)
String result = redis.set(key + ":lock", me, SetParams.setParams().nx().px(5000));
if ("OK".equals(result)) {
    try {
        value = expensiveCompute();
        redis.setex(key, ttlWithSpread(), value);
        redis.set(key + ":previous", value);      // no TTL: the value the others will read
        return value;
    } finally {
        // ONLY the owner deletes the lock: if compute took > 5 s the lock expired and someone else holds it
        redis.eval(
            "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) end return 0",
            List.of(key + ":lock"), List.of(me));
    }
} else {
    // Others: serve the previous value
    value = redis.get(key + ":previous");
    if (value != null) return value;
    // Cold start: no previous value either. Wait briefly, retry the main key.
    Thread.sleep(50);
    return redis.get(key);                        // still null? the caller decides: wait or fail
}

Works, but three places need care. Do not forget to write the previous-value key: in the first version of this post :previous was only read, never written; with that code, on a cold start all 199 clients would get null. Only the owner deletes the lock: if the computation takes more than 5 seconds the lock expires by itself, someone else takes it, and a plain del deletes their lock; that is why the get == me then del Lua script exists, and it has to be atomic. finally is required: if the computation throws, the lock holds everyone on the previous value for 5 seconds. And you still have to answer "how stale may the previous value be"; 5 seconds is acceptable for a spread, not for a balance.

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.

Entry e = read(key);                 // value + computeMs + expiresAt are stored together
if (e == null) return computeAndStore(key);   // first read: no value yet

long remaining = e.expiresAt - now();
double u = 1.0 - ThreadLocalRandom.current().nextDouble();        // (0, 1]: no log(0) = -infinity trap
double threshold = -e.computeMs * BETA * Math.log(u);             // BETA ~ 1.0

if (remaining < threshold) {
    e = computeAndStore(key);         // early, spontaneously, alone
}
return e.value;                        // clients that do not refresh return the valid value too

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.

And the refresher must have a single owner. If each of the 200 gateways refreshes on its own 30-second timer, you have reinvented the stampede under another name: the same computation 200 times, and now without even waiting for expiry. The refresher is either a separate job with one replica, or it is handed to one instance at a time through leader election.

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.

The move from step 2 to step 3 is not intuition, it is arithmetic (Little's law): 200 threads, each busy for 400 ms → 200 / 0.4 = at most 500 requests per second. If 800 requests per second arrive at the open, 300 requests pile up in the queue every second. With an unbounded queue, growth is not a possibility, it is the result. With Redis at 2 ms the same 200 threads handled 100,000 requests per second; what cut capacity 200 times was Redis slowing down.

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.

This story has a name: metastable failure. The trigger (the opening herd) was over at 09:01; Redis was back to normal at 09:02. But the gateways did not recover, because the bad state was now feeding itself: a full queue slows requests, slow requests make clients retry, retries fill the queue further. The cache stampede loop is the same class: by the time the computation finishes, the key has expired again. This is the technical meaning of the opening line, "nobody deployed, traffic was normal, the system killed itself". The consequence: removing the trigger is not enough. You need a limit that breaks the loop — load shedding that drains the queue, a circuit breaker that stops the retries. The three defences below are for that.

Three defences, in order

a) Every external call gets a timeout
// The default is usually NONE or 30+ seconds. In trading, 30 s means infinity.
// Connect timeout and read timeout are SEPARATE things:
// if the TCP/TLS handshake hangs, the read timeout never fires.

// Redis (Jedis 4+): the timeout goes into the constructor via config; there is no setTimeout method
JedisClientConfig cfg = DefaultJedisClientConfig.builder()
    .connectionTimeoutMillis(100)       // connect
    .socketTimeoutMillis(200)           // read: measured p99 ~120 ms x 1.5
    .build();
Jedis jedis = new Jedis(new HostAndPort(host, 6379), cfg);

// MT5 bridge (HTTP)
HttpClient http = HttpClient.newBuilder()
    .connectTimeout(Duration.ofMillis(200))                    // connect
    .build();
HttpRequest request = HttpRequest.newBuilder(uri)
    .timeout(Duration.ofMillis(800))                           // read: p99 ~500 ms x 1.5
    .build();

// Database: the query timeout does NOT cover getting a connection from the pool
hikari.setConnectionTimeout(500);   // pool checkout
stmt.setQueryTimeout(3);            // the query itself (seconds)

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

How do you choose the value? Not from the air, from measurement: take the real p99 of that call and multiply by ~1.5. If Redis has a p99 of 120 ms, use 200 ms; if the bridge has 500 ms, use 800 ms. Second rule: it must be smaller than the caller's timeout. If the client gives up on the gateway after 1 second, the gateway's budget for Redis + bridge + DB must add up to less than 1 second; otherwise the gateway works on an answer nobody is waiting for. The next step is deadline propagation: if the client gave up at 800 ms, the layer below must not carry on. Pass the remaining time down in a header (X-Deadline, grpc-timeout in gRPC); if the deadline has passed, the lower layer does not even start. Otherwise, under load, you spend the most expensive work on requests whose answers go straight to the bin.

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
// Measure: the age of the request at the HEAD of the queue (CoDel logic). Utilisation is a late signal;
// a price request that has waited 5 s is already worthless, there is no point serving it.
if (queue.headAgeMs() > 500 && !request.isCritical()) {
    // A FIXED Retry-After brings every rejected client back at t+2 in the same second: the herd from section 1.
    int retryAfter = 2 + ThreadLocalRandom.current().nextInt(4);    // 2-5 s, with jitter
    return error(503, "Retry-After: " + retryAfter);
}

Rejecting a price lookup while accepting an order beats slowly losing both. Shedding load is not a failure, it is a prioritisation decision. Two details: in the first version of this post Retry-After: 2 was fixed — it called every rejected client back two seconds later, in the same second; I was producing in section 3 the very herd I described in section 1. And queue utilisation is a late signal: by the time the queue is 80% full, the request at its head has already been waiting for seconds. The age of the work at the head of the queue is both an earlier and a more honest signal.

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.

Let me correct one misunderstanding first: a circuit breaker does not make recovery instant. It only blocks new calls; the 200 threads hanging on the 30-second timeout stay where they are and are only released when the timeout expires. So recovery time is the value of the timeout, not of the breaker: with a 30 s timeout the pool empties in 30 seconds, with 800 ms in 1 second. This is the real reason the timeout has to be small; the breaker comes on top of it.

Bulkhead first: a separate pool per dependency

This is the defence that comes before the circuit breaker. Until the circuit opens (minimumNumberOfCalls=20 plus the window), the shared pool of 200 threads is already gone; the seven-second story happened exactly inside that window. Like the watertight compartments of a ship: each bridge gets its own pool or semaphore, and when one fills up the others keep working.

// A separate limit per bridge: one bridge cannot take all 200 threads
BulkheadConfig bh = BulkheadConfig.custom()
    .maxConcurrentCalls(25)                       // at most 25 calls to this bridge at once
    .maxWaitDuration(Duration.ofMillis(50))       // no room? reject within 50 ms, do not queue
    .build();
Bulkhead bridgeA = Bulkhead.of("mt5-bridge-A", bh);

Supplier<Order> guarded = Bulkhead.decorateSupplier(bridgeA, () -> sendToBridgeA(order));

When bridge A hangs, at most 25 threads get stuck there; 175 stay free for B, C and D. The circuit breaker comes next and rescues those 25 as well.

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()
    .slidingWindowType(SlidingWindowType.TIME_BASED)   // ← COUNT_BASED keeps hours-old calls under low traffic
    .slidingWindowSize(60)                              // last 60 seconds
    .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)
    .recordExceptions(IOException.class, TimeoutException.class)          // ← only infrastructure errors count
    .ignoreExceptions(InsufficientMarginException.class,                  // ← business errors do NOT open it
                      InvalidSymbolException.class)
    .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.
  • recordExceptions / ignoreExceptions is the setting that burns most in practice. The default: every exception is a failure. If a group of customers gets "insufficient margin" in the morning — the bridge's correct answer — the circuit opens against a perfectly healthy bridge and everyone's orders are rejected. Separate business errors from infrastructure errors; the circuit should only look at the second kind.
  • slidingWindowType gets forgotten. A count-based window (last 100 calls) keeps hours-old calls in the calculation under low traffic; 3 failures at 02:00 are still in the ratio at 09:00. A time-based window (last 60 s) answers the question "right now".
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, for display: return the last known price with its age. On a screen, "a price from 3 seconds ago" beats no price. For order entry, no: if the customer can place an order at that stale price you create execution disputes and an arbitrage gap. On the order side, no fresh price means no order.
  • 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
HTTP 503                          // ← kubelet and the LB look ONLY at the HTTP code, not the JSON
{
  "last_tick_age_ms": 4200,       // ← no tick processed for 4 seconds
  "expected_tick_interval_ms": 200, // for this symbol set, in this session
  "queue_depth": 48000,
  "gc_pause_p99_ms": 1800,
  "status": "DEGRADED"            // for humans and dashboards; machines read the 503
}

// Rule: while the session is open, last_tick_age > expected_tick_interval x 5 -> 503
//       while the session is closed no tick is expected -> this check is skipped, 200

This tells you whether it is genuinely doing its job.

But there are traps here

The first is the threshold itself. In the first version of this post the rule was fixed: "not ready if the last tick is older than 1 second." Applied as written, that rule is a new source of outages: on a symbol set with low liquidity, or outside the trading session, ticks simply do not arrive; at that moment every node declares itself not ready in the same second and the endpoint list empties. The threshold has to depend on the expected tick rate and the session calendar: 200 ms is normal on EURUSD, 5 seconds is normal on an exotic pair, and while the session is closed the check itself must be off. The second is the HTTP code: neither the kubelet nor most load balancers read the "status": "DEGRADED" body; they only look at the status code. If you have not written that DEGRADED returns 503, the example does nothing when implemented; the JSON just looks nice.

The third and biggest: 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. The fixed tick threshold is another face of the same trap: they all fall together.

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.
  • Startup: for a JVM that starts slowly. Without a startupProbe, liveness kills the pod before warm-up finishes; the pod restarts, cannot warm up again, dies again. When every pod enters this loop, what you have is exactly the bulk restart herd from section 1. failureThreshold × periodSeconds must cover the slowest start.

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. The load-balancer-side answer to the "they all fall together" trap above is here too: in Envoy, max_ejection_percent (default 10%) allows at most 10% of the pool to be ejected at the same time. If all of them slowed down, none is ejected; a slow system beats a dead one.

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.

There is also a window that is not lost but blind: those 8 seconds. Sentinel deciding the master is down (down-after-milliseconds, usually 5 s) + the vote + the promotion: during that time every write either fails or hangs. What happens to the orders that arrive in that window has to be decided in advance: either the client gets an explicit rejection ("try again") or the order is written to the database, the source of truth, and copied to Redis later. "Hold it in memory and write it quietly later" is not an option; that memory goes with the failover too. One more thing stretches the window: the client library keeps hitting the old address until it learns about the new master. In Lettuce and Jedis, topology refresh through Sentinel must be on; otherwise 30 seconds of connection errors are added to an 8-second promotion.

The second act of the loss plays when the old master comes back: Sentinel makes it a replica, it takes a full sync from the new master and throws away those 60 orders on its own disk. The loss is now irreversible; this is the moment the silent loss becomes final.

Concrete settings on the Redis side: narrow the window
# redis.conf (master)
min-replicas-to-write 1      # unless at least 1 replica is connected and ...
min-replicas-max-lag 2       # ... its lag is under 2 s, WRITES ARE REJECTED
appendfsync everysec         # even on a single node this means ~1 s of loss; always = fsync per write, expensive
// Application: after a critical write, wait for at least 1 replica's ack (at most 200 ms)
redis.set("order:" + orderId, state);
long acked = redis.waitReplicas(1, 200);   // WAIT 1 200
if (acked < 1) {
    // the replica did not catch up: treat the order as unconfirmed, do not tell the client "done"
}

WAIT is not synchronous replication; it says "this write reached at least one replica", it does not guarantee that replica is the one promoted on failover. But it shrinks the 1–2 second window to milliseconds, and above all: when it does not make it, it tells you. A silent loss becomes a loud one.

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. Every order needs a unique ID generated on the client side. A client order ID or idempotency key: created before the order even reaches the gateway, sent to the bridge with that ID, written to the database with that ID. Without it the next item cannot work; you have no shared key to match the orders pulled from the bridge against local records, and the "60 orders in unknown state" stay unknown forever.
  3. 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 by client order ID and reports the differences. Anything meant to be done manually does not get done.
  4. 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 (a fencing token) to critical writes and reject older ones. One warning: Sentinel has its own config epoch, but it does not leak into the application layer; the Redis client will not reject a single write on your behalf. Generating the token, attaching it to the write and comparing it on the receiving side is the application's job.

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 timeout or no bulkheadTimeouts on external calls, a pool per dependency; the circuit breaker is the second defence
Health green but customers complainingGray failureDoes readiness look at real work
Inconsistent data after a short outageFailover lossIs replication sync, is there reconciliation

Checklist

Check today
  • Do all external calls have timeouts? Connect and read separately?
  • Did the timeout values come from the p99, and are they smaller than the caller's timeout?
  • Do the database and HTTP pools have a checkout timeout, or only a query timeout?
  • In load shedding, does Retry-After carry jitter, or does it call everyone back in the same second?
  • Do all queues have an upper bound?
  • Is there a separate pool (bulkhead) per dependency, or one shared pool?
  • Do scheduled jobs all start in the same second?
  • Do TTL values have a spread, or are they all fixed? Does the background refresher have a single owner?
  • Does the circuit breaker count slow calls as failures? Does it ignore business errors?
  • What do you return while the circuit is open? Is it written down?
  • Does readiness check the real work, or only the process? Does DEGRADED return 503?
  • If readiness depends on a dependency or a fixed threshold, can they all fail together?
  • Does critical state live only in a cache?
  • Does every order have a client order ID; is there automatic reconciliation after a failover?

Where do they show up? Not in unit tests, but in three places: a load test with latency injection (add an artificial 400 ms to Redis, replay the opening traffic), fault injection (shut down one bridge, kill one pod) and a scheduled game day — trigger the failover by hand, in production, during working hours, with everyone watching. If none of the three happens, the list stays on paper.

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; they show up in a load test, in fault injection, or on a bad morning. Which one is your choice.

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.