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. Five defences, in order: timeout → bounded queue → load shedding → bulkhead → breaker; retry on the outside, and in one layer only. Each one rests on the previous one.
  • The node that looks healthy is the most expensive failure. In a gray failure the process is up, the health check is green, and no work gets done. Measure health relatively, not absolutely: count slow calls as failures, compare the node with its neighbours.
  • 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. Jitter alone is not enough: a 200 ms timeout + 3 attempts triples the load on a dependency that is already struggling. The modern answer is jitter + a retry budget: no more than 10% of all requests may be retries; once the budget is spent, no retry, just an error. Without it, timeouts do not slow the cascade down, they speed it up.
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);
long delay = openTimeMs - System.currentTimeMillis() + offset;
scheduler.schedule(job, delay, TimeUnit.MILLISECONDS);   // ScheduledExecutorService: delay + unit

// 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 120 pods the last one must not wait 60 s
          + ThreadLocalRandom.current().nextLong(500);    // + jitter
Thread.sleep(wait);

The third one has two traps. Leave index * 500 unbounded and with 120 gateways the last pod waits 60 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. And this wait delays startup by up to 15 seconds: the startupProbe in section 5 (failureThreshold × periodSeconds) has to cover those 15 seconds too, otherwise liveness reads the wait you added yourself as "failed to start" and kills the pod.

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

The fourth source: clients you do not control

The three cures above fix three of the four sources in the list: cron, TTL, bulk restart. The bridges connecting to their providers is your code too, and goes into the same staggered startup. But clients reconnecting after the night is not your code; you cannot fix, from the client side, every terminal sending TLS handshake + login + subscription in the same second at 09:00:00. There is a server-side answer, in four parts:

  • Accept rate limit. Accept new connections at N per second (at the load balancer or in the application); the excess is rejected, not queued. Accepting a connection and then making it wait 20 seconds means a timeout for the client and a half-finished handshake for you.
  • Reject with a jittered Retry-After. The connection-level form of the load shedding in section 3: a rejection that says "come back in 3–8 seconds" spreads connections piled into one second over 5 seconds.
  • Staged work per connection. Accept the handshake, then limit login and the first subscription separately; the most expensive step (sending the symbol list) comes last and is limited the most.
  • A limit per IP / account. Stops one client stuck in a reconnect loop from eating the whole accept budget.

If you ship the client SDK, there is a fifth: build jittered exponential backoff into the SDK; do not leave "reconnect at once when the connection drops" to the client. An SDK that waits a fixed 1 second brings every terminal to the door in the same second after every drop.

The herd's twin: shutdown (graceful shutdown)

The other half of a bulk restart is how the pods stop. If a pod receives SIGTERM during a deploy and exits at once, two things happen: its in-flight requests are cut off, and the load balancer keeps sending it requests for a few more seconds, because leaving the endpoint list is not instant. Those requests fail, the client retries, the retries land on the remaining pods. The same herd, this time on the way out; and on every deploy.

# Deployment
lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 5"] }   # wait to leave the endpoint list; SIGTERM comes after this
terminationGracePeriodSeconds: 30                 # preStop (5) + finishing in-flight work + shutdown; too small = SIGKILL

# Application (Spring): on SIGTERM stop accepting, finish what you have, then exit
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s

The order: first leave the list (a preStop wait, or readiness switched to 503), then stop accepting new connections, then finish what is in flight, then exit. If terminationGracePeriodSeconds is smaller than that total, the kubelet kills the process with SIGKILL and all the care is wasted.

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

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

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

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

Before jumping to a distributed lock there is a cheaper layer: in-process deduplication (single-flight). Each of the 120 gateways has dozens of threads asking for the same key; if the process first says "a computation for this key is already in flight, join it", the requests going to Redis drop from 120 × N to 120. In Java, Caffeine / Guava LoadingCache does this by itself; in Go, singleflight. Caffeine goes one step further with refreshAfterWrite: instead of making the reader wait when the entry expires, it returns the old value at once and refreshes in the background on a single thread; the in-process combination of b and c below, with zero coordination. For the remaining 120, one of the three fixes below.

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 119 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.

:previous having no TTL is intentional, but not free: 400 symbols means 400 permanent keys. Under allkeys-lru they get evicted when memory is tight; under volatile-* they are never evicted and pile up forever as the symbol list grows. If the symbol set is fixed, no problem; if the key space grows, give :previous a long but finite TTL, say 10× the main TTL.

The standard answer to "what if the computation takes more than 5 seconds" is missing above: lock extension. While the computation runs, the owner refreshes the lock's TTL every 1–2 seconds (PEXPIRE if get == me, again in Lua, again atomic); if the process dies, the refreshing stops and the lock expires by itself. Redisson does this for you under the name watchdog: a lock taken without a lease time starts at 30 seconds and, as long as the owner is alive, is reset to 30 every 10 seconds. If you write your own, keep the same ratio: the refresh interval is a third of the TTL. If a GC pause makes you miss a refresh, the lock expires; the Lua script is already there so that the owner, waking up late, does not delete someone else's lock.

Also look at where this lock lives: in Redis itself. Section 6 explains that this Redis loses the last 1–2 seconds of writes on failover; the lock is a write. So at the moment of failover the lock is not on the new master, a second node takes it too, and two computations run at the same time. For a cache stampede the result is harmless: at worst the spread is computed twice, and both results are correct. But carry this pattern as it is to debiting a balance or sending an order, and the same window means withdrawing money twice. That place needs a lock with a fencing token, not this one.

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
// NOTE: remaining, computeMs and threshold must be in the SAME unit (ms); one in seconds silently breaks the formula
if (e == null) return computeAndStore(key).value;   // first read: no value yet (returns the value, not the Entry)

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.

All three fixes share one assumption: the key is still in the cache. When Redis comes under memory pressure and drops the key through maxmemory eviction, the TTL spread, the probabilistic refresh and the background refresher all fall through; the key disappears before it expires and you fall straight into the stampede. The eviction policy works against you here: allkeys-lru can evict a hot key too; volatile-lru only considers keys that have a TTL — which means the keys of 2a and 2b, exactly those. Under that policy the only protected key is the TTL-less key of 2c. noeviction is the third way: no key is evicted, but once memory is full every write returns an OOM error; instead of a stampede you get a write outage, and that means you cannot even write :previous. The practical answer is a separate instance for critical keys, or 2c + volatile-*. In every case, alarm on used_memory at 80% of maxmemory; the moment the evicted_keys counter leaves zero, the stampede is at the door.

And the refresher must have a single owner. If each of the 120 gateways refreshes on its own 30-second timer, you have reinvented the stampede under another name: the same computation 120 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.

There is also a stampede that runs on the "not found" answer. Ask for a symbol that does not exist (misspelled, delisted, invented by a client) and there is nothing in the cache, so the request goes to the database; because "not found" is never cached, the next request goes to the database too. What the stampede is for a hot key, it is for a key that never existed; and you do not even need load to trigger it, one client asking for the wrong symbol in a loop is enough. Two cheap remedies: negative caching — store the "not found" answer too, with a short TTL (5–30 s); and if the key space is known (the symbol list), put a bloom filter in front, so a key that never existed does not even reach Redis.

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 theoretical ceiling for the same 200 threads was 100,000 requests per second (in practice CPU and sockets run out long before that, but the ceiling was far above 500); 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 five defences below are for that; the first three are in this section. And the practical consequence during the incident is even sharper: waiting does not work. Saying "it will settle in a moment" while the system is in this state gives the loop more time; you have to cut the load completely and do a controlled cold start. The order is at the end of the post, in the recovery section.

Five defences, in order: the first three here

This order is the spine of the post: timeout → bounded queue → load shedding → bulkhead → circuit breaker, with retry on the outside. The order is not arbitrary; each one rests on the previous one. Without a timeout the work in the queue never finishes, without a queue bound the age that shedding measures means nothing, without a bulkhead the pool is gone before the breaker opens. The first three are in this section, four and five in the next.

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();
JedisPoolConfig poolCfg = new JedisPoolConfig();
poolCfg.setMaxWait(Duration.ofMillis(100));   // pool checkout: the default is -1 = UNLIMITED
JedisPool pool = new JedisPool(poolCfg, 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))                           // the WHOLE exchange, until the response completes: p99 ~500 ms x 1.5
    .build();

// Database: the query timeout does NOT cover getting a connection from the pool
hikari.setConnectionTimeout(500);   // checkout: Hikari default is 30 s; DBCP2 (maxWaitMillis=-1) is UNLIMITED
stmt.setQueryTimeout(3);            // the query itself (seconds); driver-dependent, on PostgreSQL statement_timeout is more reliable

There are three separate timeouts, and the invisible one is the third: waiting for a connection from the pool. Connect and read get configured, checkout gets forgotten. The defaults depend on the library: in HikariCP it is 30 seconds (the same as infinity on the trading side); in DBCP2, and in the GenericObjectPool underneath JedisPool, it is maxWaitMillis = -1, truly unlimited. Note where that leaves you: the pool with an unlimited checkout is on this post's own Redis path; the socketTimeoutMillis(200) above only starts after you have a connection from the pool. When all 20 connections in the pool are stuck in slow queries, the 21st request quietly starts waiting — it has no timeout, because it does not even have a connection yet. This is very often the real starting point of the cascade: step 2 of the table in section 3 can just as well begin with threads waiting for the pool, not for Redis.

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-Remaining-Ms: 450; gRPC's grpc-timeout carries exactly this); if the deadline has passed, the lower layer does not even start. Do not put an absolute timestamp in the header: "deadline 09:00:01.800" assumes the two machines have the same clock. A 200 ms clock skew between machines is a quarter of an 800 ms budget, and depending on its direction it either kills a live request or keeps a dead one alive. Send the remaining time and let each layer count down on its own clock. 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.

"The queue" is not one thing; a gateway has at least five, and first you have to find which one is unbounded:

  • The accept queue. In Tomcat, acceptCount (default 100) and maxConnections (8,192 with NIO). Already bounded; when full, new connections are refused.
  • The thread pool's work queue. Tomcat's own pool is bounded. But every pool you open with Executors.newFixedThreadPool(n) has an unbounded LinkedBlockingQueue behind it, and newCachedThreadPool() does not limit the number of threads at all. This is Java's most classic unbounded queue.
  • The in-app async queue. Your own BlockingQueue that carries orders to the bridge or processes ticks. No capacity given means unbounded.
  • The Netty / socket outbound buffer. Keep writing to a slow reader and the outbound buffer grows; WRITE_BUFFER_WATER_MARK and isWritable() exist for this.
  • The client-side buffer. Subscription data piling up in the terminal; it kills the client, not your server, but when it drops it turns into the reconnect herd from section 1.

Since the accept queue is already bounded, the source of step 4 in the table (memory fills) is most likely the second or third item: your own pool or your own queue. The fix is three lines:

// WRONG: Executors.newFixedThreadPool(50) -> an UNBOUNDED LinkedBlockingQueue behind it
ExecutorService pool = new ThreadPoolExecutor(
    50, 50, 0L, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(1_000),                       // bound: 1000 waiting tasks
    new ThreadPoolExecutor.AbortPolicy());                   // when full: RejectedExecutionException -> 503

// NOT CallerRunsPolicy: it runs the task on the thread that submitted it (Tomcat worker, Netty event loop),
// that thread blocks, accepting stops too; it looks like "graceful backpressure", it speeds up the cascade.
c) Load shedding
// Measure: time spent waiting in the queue, not utilisation. Utilisation is a late signal;
// a price request that has waited 5 s is already worthless, there is no point serving it.
// A simplification inspired by CoDel: real CoDel does not look at the INSTANT value but at whether
// the MINIMUM waiting time over an interval (100 ms) exceeds the target. In a short burst the minimum
// stays low and nothing is dropped; under sustained backlog the minimum rises too, and then it drops.
if (queue.minWaitLastWindowMs(100) > 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 Response.status(503)
                   .header("Retry-After", String.valueOf(retryAfter))   // a HEADER, not the body
                   .build();
}

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. Waiting time in the queue is both an earlier and a more honest signal — but its minimum over a window, not its instant value: in the first version of this post the rule was "if the request at the head is older than 500 ms", and in that form it returned 503 on every single 600 ms burst. The whole point of CoDel is not to do that: if for 100 ms even the best request has waited 500 ms, the backlog is real, then drop.

Two more rules follow naturally. LIFO under overload. With a backlog, FIFO serves the oldest request first — the one whose client has long given up. 100% CPU, 0% useful work. Taking from the tail (LIFO) under load at least answers someone who is still waiting. Drop dead requests before dequeuing them. If the request carries a deadline (the deadline propagation above) and it has passed, drop it before starting; nobody will read the answer. Both recover capacity without adding any, because the capacity was always there, it was just going to the bin.

One more gap: in the code above the only distinction is isCritical(). Load shedding does not look at whose request it is; one client's retry storm (a broken bot, an integration stuck in a loop) eats the whole queue, and the ones rejected are the other 5,000 customers. The real-life companion of load shedding is fairness per customer: a per-tenant rate limit (nobody gets more than N requests per second) or weighted fair queuing (when the queue is full, the biggest consumer is dropped first). Next to "how full is the queue" you need "who filled it".

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 (the Tomcat default). At the open that bridge was getting ~29 orders per second; 29 × 7 ≈ 200, so 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.

Defence 4 — bulkhead: a separate pool per dependency

This is the defence that comes before the circuit breaker; the fourth in the order from section 3. 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. The same rule applies to the breaker itself: one breaker per dependency. Put all bridges behind a single global breaker and bridge A's failures also close the path to B, C and D; in this story a global breaker is useless.

maxConcurrentCalls(25) is a guess; right for the bridge's capacity today, wrong when it slows down tomorrow. The next step is an adaptive concurrency limit: instead of setting the limit by hand, derive it from latency. Netflix's concurrency-limits library (the Gradient and Vegas algorithms; the same idea as TCP congestion control) lowers the limit as soon as p50 latency starts to rise and raises it when latency falls; in Envoy the same thing is the adaptive_concurrency filter. Start with a fixed 25, but put the latency graph next to the limit: if p99 doubles before you ever reach 25, the limit is too high.

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.
Defence 5 — breaker: what to watch when tuning
// Breaker for the MT5 bridge (synchronous HttpClient.send)
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(400))  // ← must be BELOW the timeout (800 ms), ~50% of it
    .slowCallRateThreshold(50)                          // ← SLOW calls count as failures
    .waitDurationInOpenState(Duration.ofSeconds(10))
    .automaticTransitionFromOpenToHalfOpenEnabled(true)  // ← default false: the transition only happens when a CALL arrives
    .permittedNumberOfCallsInHalfOpenState(5)
    .recordExceptions(IOException.class)                  // ← only infrastructure errors count; HttpTimeoutException extends IOException
    .ignoreExceptions(InsufficientMarginException.class,  // ← business errors do NOT open it
                      InvalidSymbolException.class)       //   (one of the two is enough, see below)
    .build();

// Do NOT copy the same config to the Redis path: Jedis does not throw IOException.
// JedisConnectionException -> JedisException -> RuntimeException. In Lettuce, RedisConnectionException
// and RedisCommandTimeoutException are RuntimeExceptions too. For the Redis breaker:
//   .recordExceptions(JedisConnectionException.class)      // both connection loss and socket timeout come from here
  • 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 threshold must be below the timeout: in the first version of this post both were 800 ms, and with that there was no such category as "slow but successful" — every call reaching 800 ms was already counted as a timeout failure, so the mechanism was dead on arrival. The practical ratio is 50–60% of the timeout: 400–500 ms for an 800 ms timeout.
  • 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. In the example above you do not need both: the moment you set recordExceptions, nothing outside that list counts as a failure, and ignoreExceptions has priority even over that. In practice, narrowing recordExceptions is the safer choice, because a new business exception added tomorrow does not open the circuit by default; if you go with ignoreExceptions, you have to remember to add every new business error to the list. Narrowing has its own trap, and it is the sneakiest form of this item: check the exception hierarchy of the client library. The first version of this post listed IOException and java.util.concurrent.TimeoutException. The second never comes out of a synchronous HttpClient.send(); that throws HttpTimeoutException, which is an IOException. TimeoutException only appears on the sendAsync / CompletableFuture path; I was showing synchronous code and recording an async exception, harmless but wrong. The harmful part is this: copy the same list to the Redis breaker and the circuit never opens, because Jedis does not throw IOException; a lost connection and a socket timeout are both JedisConnectionException, and that is a RuntimeException. It is not on the list, so it does not count as a failure, and the breaker stays closed even if Redis stops answering for days. Do it once per dependency: trigger the failure in a test, see which class the client really throws, and write that class.
  • 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".
  • automaticTransitionFromOpenToHalfOpenEnabled is off by default. While it is off, the OPEN→HALF_OPEN transition does not happen when waitDurationInOpenState ends; it happens on the first call after that. In the recovery scenario below, where you cut the traffic and wait, the circuit shows OPEN on the dashboard forever; you cannot answer "have the circuits recovered" without opening traffic. Set it to true and the transition happens on a timer by itself, you see HALF_OPEN on the dashboard, and the first 5 calls of the first 10% of traffic go through as trial calls. The cost is one timer per breaker; unless you have hundreds of breakers, it does not matter.
Where does retry sit in the chain? On the outside, and in one layer only

The first version of this post never said where retry goes; but if the wrapping order is wrong, neither the circuit breaker nor the bulkhead does its job. The order Resilience4j recommends, from the outside in, is Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(call))))):

// With Decorators, the first one written is the INNERMOST; retry is written last and sits on the OUTSIDE
Supplier<Order> s = Decorators.ofSupplier(() -> sendToBridgeA(order))   // the timeout lives on the HttpRequest itself (800 ms)
    .withBulkhead(bridgeA)         // 1) is there room? if not, reject within 50 ms
    .withCircuitBreaker(cbA)       // 2) is the circuit open? if so, CallNotPermittedException, the bridge is never called
    .withRetry(retryA)             // 3) OUTERMOST: retry only if the breaker let it through and an infrastructure error came back
    .decorate();

RetryConfig retryCfg = RetryConfig.custom()
    .maxAttempts(2)                                   // 2 attempts in total, not 3
    .intervalFunction(IntervalFunction.ofExponentialRandomBackoff(100, 2.0))   // with jitter
    .retryExceptions(IOException.class)               // synchronous HttpClient: the timeout is an IOException too (HttpTimeoutException)
    // The breaker rule applies here too: once the list is given, CallNotPermittedException, BulkheadFullException
    // and business errors are ALREADY not retried; no extra ignoreExceptions needed. The default (no list)
    // retries EVERY exception -- even while the circuit is open.
    .build();

This is the build order; at run time the chain works from the outside in, that is, in the reverse of the spine from section 3: retry → breaker → bulkhead → call. Both are right; do not mix them up when reading them side by side.

Why this order? Put retry inside the breaker and every attempt is recorded by the breaker as a separate failure: 3 attempts = 3 failures, the circuit opens three times faster. Put retry outside the breaker but leave the retryExceptions list empty, and the default retries every exception, CallNotPermittedException included: the retry waits out its backoff even while the circuit is open; no call is made, but the thread waits. The second rule is less known and more expensive: retries multiply across layers. The client retries 3 times, the gateway 3 times, the bridge client 3 times: the load reaching the source is not 3×, it is 3 × 3 × 3 = 27×. The retry budget from section 1 caps this at 10% in one layer, but if each of the three layers retries within its own budget, the multiplication is still there. The rule: only the outermost layer retries; the layers in between pass the error straight up.

And what the TimeLimiter in that order is for: the library that does not accept a timeout. This is the escape hatch of the "without timeouts nothing works" thesis. If the old SDK from the provider has no time parameter on send(), you run the call in a separate pool and cut it from the outside:

// The SDK knows no timeout: the call runs in a separate thread pool, the limit comes from outside
ThreadPoolBulkhead tpb = ThreadPoolBulkhead.of("bridge-B", ThreadPoolBulkheadConfig.custom()
    .maxThreadPoolSize(25).queueCapacity(1).build());      // 25 threads, practically no queue (minimum is 1): no room = reject
TimeLimiter tl = TimeLimiter.of(Duration.ofMillis(800));

CompletableFuture<Order> f = Decorators.ofSupplier(() -> oldSdk.send(order))
    .withThreadPoolBulkhead(tpb)              // 1) separate pool
    .withTimeLimiter(tl, scheduler)           // 2) cut the future at 800 ms -> java.util.concurrent.TimeoutException
    .withCircuitBreaker(cbB)                  // 3) on this path the breaker DOES record TimeoutException.class
    .withRetry(retryB, scheduler)             // 4) outermost
    .get().toCompletableFuture();

Two honest notes. TimeLimiter cancels the future, not the thread: if the SDK ignores interrupts, the cancelled call keeps holding that thread until the answer arrives. That is exactly why the pool is limited to 25; at most 25 zombies, then rejection. And above I said "a synchronous call never throws TimeoutException"; this path is asynchronous, here it does, and the breaker's record list is written accordingly. Whichever path you are on, that path's exception.

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
}

// Rules (any one of them -> 503):
//   while the session is open, last_tick_age > expected_tick_interval x 5   (skipped while closed)
//   queue_depth > 10,000             (normally < 500; 10k = 20 s of backlog)
//   gc_pause_p99_ms > 500            (1800 ms = 9x the tick interval, prices are not being processed)

This tells you whether it is genuinely doing its job.

But there are three 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 (including the 15-second staggered wait from section 1).
The fourth trap: flapping

Applied as written, the rules above make a node enter and leave the list every second: tick arrived → ready, GC pause → not ready, tick arrived → ready. Every transition is an endpoint update at the load balancer and a disconnect for the attached clients; a flapping node costs more than a slow one. You need hysteresis in two places:

# kubelet side: 3 failures in a row -> not ready (15 s), 2 successes in a row -> ready (10 s; ~13 s with the app layer, see below)
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5
  failureThreshold: 3        # do not leave the list on one bad sample
  successThreshold: 2        # do not rejoin on one good sample (MUST be 1 for liveness, not for readiness)

# application side: separate entry and exit thresholds
#   enter DEGRADED : last_tick_age > 5x the expected interval
#   leave DEGRADED : last_tick_age < 2x the expected interval, 3 samples in a row (one sample per second -> 3 s)

If the entry threshold and the exit threshold are the same, the node vibrates around it. The gap between them (5× / 2×) and the "N samples in a row" condition stop a single GC pause from removing the node from the list; a node that is really broken still drops out in 15 seconds, which is fast enough for the 4-second stale-price story. The two layers stack, so give the number as a total: the application must first see 3 good seconds, and only then does the kubelet collect its 2 × 5-second good answers; recovery is not 10 but ~13 seconds. The same on the way down: the application entering DEGRADED + 3 × 5 seconds. When tuning thresholds, think about the total, not each layer on its own.

What readiness does not see: connections that are already open

This was the biggest practical gap in this section. The scenario is a price streaming engine; clients do not send it HTTP requests, they open long-lived WebSocket / TCP streams. When readiness returns 503, the pod leaves the endpoint list and takes no new connections. But the clients that are already connected stay where they are and keep receiving the 4-second-old quote. The gray failure story happened exactly on those clients; readiness did nothing for them.

On the streaming side what you need is active closing: when the node enters DEGRADED it closes the open streams itself and pushes the clients to another node. In WebSocket, a close frame with 1012 Service Restart or 1013 Try Again Later; in HTTP/2 and gRPC, GOAWAY; on raw TCP, the protocol's own "reconnect" message. The client comes back with jittered backoff, and the load balancer gives it a healthy node, not this one that is no longer on the list. Two rules: close gradually too (cutting every stream in the same second is the reconnect herd from section 1; N connections per second, with jitter), and put a server timestamp on every quote so the client can see its age itself. Even if the server forgets to close, a client that can say "this price is 4 seconds old" does not place an order at it.

GC: diagnosis is not enough, remove the pause

Catching the 1.8-second GC pause in the health check is diagnosis; in a price engine the real work is making sure that pause does not happen. The order matters, from cheap to expensive:

  • Measure. Read the reason for the pause with -Xlog:gc*: humongous allocation, full GC, promotion failure? The treatment depends on the cause; changing the collector without measuring is guessing.
  • Lower the allocation rate. Do not create new objects per tick: reuse tick objects, stop boxing, use primitive collections, take string building out of the hot path. This is usually the most effective step, and it does not depend on the collector.
  • Choose the collector. G1's pauses grow with the heap; ZGC (its generational mode arrives in JDK 21 behind -XX:+ZGenerational, and is the default from JDK 23) and Shenandoah keep pauses in the millisecond range independent of heap size, at the cost of some CPU and throughput. For a price engine that is the right trade; for a batch job it is not.
  • Move large data off the heap. Big, long-lived structures like symbol history or order book snapshots go off-heap (Chronicle, Agrona, a direct ByteBuffer) or into a separate process, so the collector has fewer live objects to scan.

On top of that, outlier detection at the load balancer is very effective: it judges a node against its neighbours, not against its own health check, and pulls it out temporarily even when the health check says OK. It is the most practical defence against gray failure, because it measures health relatively rather than absolutely. One correction: the first version of this post described the rule as "this node is 5× slower than the others"; Envoy has no such latency rule. Its ejection types are consecutive 5xx, consecutive gateway failures, success_rate (the node's success rate is clearly below the cluster average) and failure_percentage. In Envoy a slow node is only caught when it hits the timeout and counts as a local-origin failure — so the thesis of section 3 holds here too: without a timeout, outlier detection does not see the gray failure either. The mechanisms that measure latency directly live on the load balancer's selection side: the peak-EWMA balancer in Finagle and Linkerd sends fewer requests to a slow node; it does not eject the node, it quietly starves it. 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.

Gray failure also has a measurement side: coordinated omission. The reason the p99 looks clean may not be that the system is fine, but that the slow requests were never measured. Requests that arrive during a 1.8-second GC pause wait in the measuring tool's own queue; the tool sends them after the pause ends and starts the clock from that moment. The pause never reaches the graph. The load tool has to generate requests at a constant rate and account for the waiting time (wrk2, Gatling's constantUsersPerSec mode, HdrHistogram's correction), and server-side latency has to be watched next to client-side latency. The gap between the two is the gray failure the measuring tool is hiding.

Two limits need to be written down too. The first is outlier detection itself: because it measures relatively, it finds nothing when all nodes degrade together — if every node's success rate dropped to 60%, none of them is "below the average". It is the most practical defence for the gray failure of a single node; it is blind to a cluster-wide slowdown. The second is the load balancer's last fuse against readiness failing everywhere at once: the panic threshold / fail-open. In Envoy, healthy_panic_threshold (default 50%): if the share of healthy nodes drops below it, the load balancer ignores health information and spreads traffic across all of them. This is the standard antidote to the "dead system instead of a slow one" scenario; setting it to 0% turns it off, so check who turned it off.

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) + the vote + the promotion: during that time every write either fails or hangs. 8 seconds is not possible with the Sentinel default; the default down-after-milliseconds is 30,000, and failover does not even start before 30 seconds. That number comes from the setting lowered to 5,000 — faster detection, more false alarms; a 3-second network hiccup triggers an unnecessary failover. On the trading side this trade-off was made in favour of 5 seconds. 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. Two more things stretch the window, and both are on the client side. The first is the old connections in the pool. Learning about the new master is not the problem: Jedis's JedisSentinelPool already subscribes to the +switch-master event, and Lettuce listens to the same events on its Sentinel connection. The problem is the idle connections in the pool that are still open to the old master, and the half-open TCP you get when the master disappears silently: the connection looks open, your write goes out, the reply never comes, and you only get an error when socketTimeoutMillis runs out. That is why, in a Sentinel-backed pool, testOnBorrow (a PING when a connection is borrowed) and a short socket timeout are not decoration; without them, one timeout per connection is added to the 8-second promotion. The second is the JVM's DNS cache. If you reach the master by hostname (an ElastiCache primary endpoint, a DNS alias, announce-hostnames in Sentinel), networkaddress.cache.ttl gets in the way: Java's own default is 30 seconds, but with a SecurityManager enabled, and in some older images, it is -1 = forever. That JVM never forgets the old IP after the failover; it keeps connecting to a dead address until it is restarted. Set -Dnetworkaddress.cache.ttl=5 explicitly; do not trust the default.

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.

Third act: the cold cache. The promoted replica carries the data, but if the application nodes behind it reconnect with empty local caches — or if the pods restarted together with the failover — in the first second every request skips Redis and goes to the database. The herd from section 1, the stampede from section 2 and the cascade from section 3 are born out of the failover in section 6. This is the clearest example of the six modes triggering each other; a failover plan does not end with "data loss", it ends with "is the cache warm".

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

# sentinel.conf (on every sentinel)
sentinel monitor mymaster 10.0.0.5 6379 2      # the last number is the QUORUM: how many sentinels must say "down" for ODOWN
sentinel down-after-milliseconds mymaster 5000

The cost of min-replicas-to-write 1 that the post never mentioned: with a single replica you create your own outage. The moment you restart that replica for maintenance, the master stops accepting writes; no failover, no crash, just a "NOREPLICAS" error. This setting makes sense with at least two replicas; with one, either do not use it or turn it off before maintenance with CONFIG SET min-replicas-to-write 0 and turn it back on after. This is the side of the setting that bites most often.

Going this deep into down-after-milliseconds and skipping the quorum was a gap too. A failover has two separate conditions: at least quorum sentinels must consider the master down (ODOWN), and a majority of all sentinels must authorise the promotion. The second does not depend on the quorum: many people run two sentinels with quorum 1; when one of them dies together with the master on the same machine, the majority (2) cannot be reached and the failover never happens. At least three sentinels, on three separate machines; quorum 2. If you use Redis Cluster instead of Sentinel, the same story plays per shard (cluster-node-timeout, a vote of the majority of masters), WAIT exists there too, and the loss of asynchronous replication is exactly the same.

// 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: reached the replica's MEMORY
if (acked < 1) {
    // the replica did not catch up: treat the order as unconfirmed, do not tell the client "done"
}

// Redis 7.2+ and Jedis 5+: ask for the disk too (1 fsync locally + 1 fsync on a replica, at most 200 ms)
KeyValue<Long, Long> result = redis.waitAOF(1, 1, 200);   // WAITAOF 1 1 200 -> (local, replicas)
if (result.getValue() < 1) { /* the replica did not fsync */ }
// with appendonly no on the master the command returns an ERROR; off on a replica, that replica never counts, the result stays 0

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. One gap remains: WAIT says the write reached the replica's memory, not its disk. With appendfsync everysec, if master and replica lose power together (same rack, same power line), that 1 second is still gone. Redis 7.2's WAITAOF completes this leg: it asks for an fsync acknowledgement. Not for every write; for writes you cannot take back, like order state.

This has a cost, and it cannot go unsaid: WAIT blocks that connection until the replica's ack arrives. In the same data centre, with the replica up to date, that is 1–2 ms (one round trip); when the replica falls behind, up to the 200 ms ceiling. So you are adding one round trip to every critical write on a normal day and 200 ms on a bad one, and no other command passes over that connection meanwhile. Do not apply it to writes like price, spread or session; keep a separate connection in the pool for critical writes, so a waiting WAIT does not block the price path. And measure it: the p99 of WAIT time is the most honest indicator of replica lag.

One more setting, relevant to both section 5 and section 6: replica-serve-stale-data yes (the default). A replica cut off from its master keeps serving old data, knowing it is cut off; if you read from replicas, this is a gray failure with a green health check. Set it to no and a cut-off replica answers reads with a MASTERDOWN error; the reader gets an error instead of wrong data, which is the right trade for the order side. For price display yes can stay — if you show the age.

The fix is in the architecture; four things are needed:

  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. The first defence on the Redis side is the min-replicas-to-write above: in a partition the old master loses its replicas and can no longer accept writes. Not a full guarantee (it stays open for the length of the lag window), but a richer picture than "either Redis or the DB": min-replicas narrows the window, WAIT gives a semi-synchronous acknowledgement, the fencing token rejects the rest.

If you are already in the bad state: the recovery order

By definition, a metastable state does not end by itself; someone has to break the loop. In the moment, everyone's first reflex is "restart everything" — and that triggers the bulk-restart herd from section 1 at the worst possible time: a cold cache, 120 pods warming up together, Redis at 100% again.

  1. Cut the traffic. Drop incoming requests at the load balancer or close the non-critical paths; let the queues drain. Incoming requests are what feed the loop; nothing you do holds until they are cut. "Close the non-critical paths" requires that kill switch to be coded in advance: a feature flag that turns price lookups, history lists and reports into 503 with one switch. Writing it down is not enough; at 09:03 nobody writes code, they only flip a flag.
  2. Stop the retries. Turn retries off in your own clients or hold the circuit breakers open by hand. External customer terminals do not have this lever; for them the server-side equivalent is the toolset from section 3: reject fast, a jittered Retry-After, a rate limit per client. The aim is the same: the first thing to hit a recovering system is the accumulated retry wave. If you plan to watch the circuit states while opening traffic, automaticTransitionFromOpenToHalfOpenEnabled must be on; if it is off, the circuit does not leave OPEN until traffic arrives, and the dashboard lies to you.
  3. Warm the cache. Fill the hot keys (symbol list, spreads) with one job before the traffic arrives. The refresher from 2c is useful exactly here; not 120 pods each warming up on their own.
  4. Bring pods up in waves. Not all at once, but in groups of 10–25%; the next group only after the previous one passes readiness. maxSurge / maxUnavailable exist for this.
  5. Open the traffic gradually. 10%, 25%, 50%, 100%; at each step look at the p99 and the age of the work at the head of the queue. If a step breaks, roll back, do not go forward.

The order matters: a restart without cutting traffic is the same herd again. And these steps must be written down before the incident; they cannot be invented at 09:03 with three gateways dead.

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 fifth 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?
  • Is there a pool checkout timeout on the Redis side too (JedisPool maxWait, unlimited by default)?
  • In load shedding, does Retry-After carry jitter, or does it call everyone back in the same second? Can one customer fill the whole queue; is there a per-customer limit?
  • On deploy, does a pod exit before it has left the endpoint list? Do preStop + terminationGracePeriodSeconds cover the in-flight work?
  • Is the "not found" answer cached (negative caching), or does a missing key hit the database every time?
  • Do all queues have an upper bound? Is there an Executors.newFixedThreadPool or a BlockingQueue without capacity left?
  • Does the breaker's record list contain the class the client really throws (Jedis: RuntimeException, not IOException)?
  • Does readiness have hysteresis? When a node enters DEGRADED, are its open streams closed?
  • At least 3 sentinels, on separate machines, quorum 2? Is min-replicas-to-write running with a single replica?
  • 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? Is the slow threshold below the timeout? Is there one per dependency?
  • Do retries have a retry budget next to the jitter?
  • Where in the chain is retry; is it outside the breaker? How many layers retry, and what is the multiplier?
  • While the circuit is open, does the OPEN→HALF_OPEN transition happen by itself, or does it wait for the first call?
  • 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?
  • Is the GC pause only measured, or is it reduced through allocation rate, collector choice and off-heap?
  • 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.

Sources