I Thought sync = true Protected My Cluster
Twelve pods, one Redis, and sync = true on the method. I read that as a protected endpoint, but it coordinates one JVM, so a cold key is still twelve calls to the same slow dependency. Why the clustered version is not on Maven Central, what it took to build one, and the case where it makes things worse.

Twelve pods behind a load balancer, one Redis in front of a slow dependency, and this on the method:
@Cacheable(value = "orders", sync = true)
public Order getOrder(String id) { ... }
I read that as a protected endpoint. Every pod shares the cache, and sync = true collapses concurrent callers on a cold key into a single execution. Both halves of that sentence are true. The conclusion I drew from them is not.
sync coordinates the callers inside one JVM. Twelve pods are twelve JVMs, so a cold key is twelve executions, and the annotation has been doing exactly what it says the whole time. I had read a guarantee about one cache instance as a guarantee about the cluster, which is an easy step to take when the cache that attribute is attached to really is shared by all twelve.
A key goes cold. Twelve pods get a request for it inside the same few hundred milliseconds, all twelve miss, and all twelve call the same slow dependency for the same value at the same instant.
Eleven of those calls are waste, aimed at the slowest thing I own, which is the reason there is a cache in front of it.
Autoscaling makes it worse in a way that is almost funny. Traffic climbs, the horizontal pod autoscaler adds pods, and every pod it adds is one more caller in the next stampede. The mechanism that exists to help you survive load is widening the herd. You scale out precisely when you can least afford the cost of scaling out.
Inside one JVM this was solved years ago, and solved well. Guava had it. Caffeine's AsyncLoadingCache has it. Spring compressed it into that one attribute, which is about as ergonomic as a concurrency primitive ever gets: one word, and concurrent callers on a cold key collapse into a single execution while the rest wait for its result. Nothing about it is wrong. It simply stops at the edge of the process.
So my question was narrow, and I assumed it was naive:
Why is the clustered version of that one-line fix not something I can pull off Maven Central?
I spent a while being annoyed at myself for not knowing the obvious library everyone else was using. Then I went looking properly.
Looking for the distributed version
Nobody built a bad library here. Each of these is correct at what it does, and each stops exactly one step short of the problem above, for reasons that turn out to be defensible.
| coalescing | cross-pod | SWR / refresh-ahead | reactive | |
|---|---|---|---|---|
| Caffeine | yes, AsyncLoadingCache | no, per JVM | yes, refreshAfterWrite | CompletableFuture only |
Spring cache 6.1+, sync = true | yes | no, inside the CacheManager | no | yes, since 6.1 |
| JetCache | yes, @CachePenetrationProtect | no, single-JVM by documentation | yes, @CacheRefresh, distributed-lock coordinated | no, blocking |
Reactor Extra CacheMono (deprecated) | no | n/a | no | yes |
| spring-single-flight (not on Central) | yes | no, Caffeine per JVM | no | no |
| Hystrix collapsing (maintenance since 2018) | batches distinct keys, different problem | no | no | no |
Redisson belongs in this conversation but not in that table. It gives you locks, buckets and topics: the ingredients rather than the dish. Same for the distributed-lock crowd, Sherlock and ShedLock and the various @DistributedLock starters. They coordinate who runs a job. They do not coordinate who waits for a result, and how that waiter finds out it is ready. The second half turned out to be most of the work.
Three of these deserve more than a table row.
Spring 6.1 has the right ergonomics already. It learned reactive types properly: cache annotations now adapt Mono and Flux through CompletableFuture rather than caching the Publisher itself, and that adaptation works for synchronised caching too. Turn on setAsyncCacheMode(true) with Caffeine and @Cacheable(sync = true) computes the value exactly once on a concurrent miss.
Once. Per JVM.
The coordination lives inside the CacheManager. A Redis-backed CacheManager shares the cache without sharing the in-flight state, so twelve pods means twelve CacheManager instances, twelve independent locks, twelve executions, all of them politely writing the identical value into one shared Redis. The annotation does exactly what it promises. The promise is scoped to a JVM, and nothing at the call site makes that scope visible.
JetCache got closest on features. @Cached plus @CacheRefresh plus @CachePenetrationProtect is these three ideas as three annotations, which is a genuinely good API, and its refresh is distributed-lock coordinated so only one node refreshes a key. That is the hard half and they did it. Then the documentation for penetration protection tells you the protection is process-level: one thread per JVM runs the loader and the rest wait.
They solved the half that looks harder and punted the half that looks easier. That inversion bothered me for a week.
Then the ecosystem admitting the gap inside its own API. Reactor Extra shipped CacheMono, a helper for caching a Mono. It is now deprecated, marked for removal in 3.6.0 at the earliest, and the deprecation notice gives the reason: there is no great solution for generic caches outside specific implementations with async support and cache stampede protection, naming Caffeine.
The Reactor team wrote a cache helper for the reactive world, concluded there was no good generic answer, deleted it, and pointed users at a library that is per-JVM and returns CompletableFuture.
Two footnotes on the table. spring-single-flight is the one project matching on both name and intent, and it is the right idea, but it is Caffeine-backed, per-JVM, and not on Maven Central; the repository ships a 0.0.1-SNAPSHOT jar you install locally. Hystrix has been in maintenance mode since 2018, so read that row as history.
What I am claiming. I could not find one, and I looked in September 2026 across Maven Central, GitHub, and the Spring and Reactor issue trackers. That is a statement about my search, not a proof about the world.
The short version, which took me embarrassingly long to see: every distributed cache I tried shares the result and not the work.
Why it is not on Maven Central
Four reasons. I think the second is the real answer and the third is the expensive one. Each of them comes back during the build.
1. A shared cache is not shared in-flight state
Distributing a cache is easy. A cache is a map with TTLs behind a network hop, and every Redis tutorial ends there.
Distributing pending state is a different object. "Somebody, somewhere, is currently computing this" needs an owner, a lease so the entry survives its owner dying, an expiry, and a way for a waiter on another machine to discover the work finished. That means a notification channel, or polling, and in practice both.
What that describes is a coordination protocol, not a cache. A cache library that grows one has become a distributed systems library without saying so, with every failure mode that implies. Caffeine's maintainers are not being lazy by stopping where they stop. They are declining to become something else.
2. Reactive removes the cheap way to wait
In blocking Java, waiting is lock.lock(). You burn a thread and move on. Inelegant, and it works completely.
I think that is the actual reason per-JVM coalescing has been good enough for as long as anyone cared. A thread is a few hundred kilobytes of stack and a scheduler entry, and if the alternative is another call to a slow dependency you spend it without thinking.
In WebFlux you cannot block. The waiter has to be a Mono that completes when work finishes on a different machine. There is no mutex for that. You need pub/sub for latency and polling for correctness, because pub/sub is fire-and-forget and a dropped message must not strand a caller.
That is why the reactive column is empty rather than thin. The moment you go reactive, the cheap answer stops existing and you are writing a protocol whether you wanted to or not.
3. The failure semantics are where the complexity lives
The leader pod can die mid-flight. That sentence is most of the work; everything after it is consequence.
The pending entry needs its own TTL, or a dead leader blocks the key forever. Waiters need their own timeout, or a stuck leader becomes a stuck request. And you need an answer for the lease expiring because the leader is merely slow rather than dead, which is not hypothetical: it is the normal state of a dependency under load, which is exactly when this code path matters.
4. Nobody wants to own the Redis dependency
Caffeine and JetCache are backend-agnostic on purpose. Being backend-agnostic is most of their appeal and why they are in everybody's dependency tree.
But this pattern only works if the coordination layer is real and singular. You cannot make leader election pluggable without making the guarantee meaningless, because the guarantee is "there is one Redis behind every pod and they all agree." An abstraction over that is an abstraction over the only thing holding the property up.
So the library has to be narrower than any general-purpose cache is willing to be. Which is the opening.
It is solved in Go, by a different design
"Nobody built this" is not quite my claim, so let me be precise.
Facebook's memcached deployment has had leases since at least the 2013 NSDI paper, for this exact purpose. And Go has groupcache, which genuinely does achieve cluster-wide deduplication: only one load in one process of an entire replicated set populates the cache, and the loaded value is multiplexed to all callers.
It gets there by a different route. Consistent hashing gives every key an owner peer. A pod that is not the owner does not load anything, it asks the owner over RPC. The owner runs ordinary in-process singleflight, and the hard problem dissolves into routing. No lock, no lease, no Redis.
(The singleflight package in x/sync is the per-process piece on its own, a different and much smaller thing.)
Two designs, one problem, and a clean trade:
| route-to-owner (groupcache) | shared coordinator (this) | |
|---|---|---|
| infrastructure | none beyond the pods | a Redis |
| per-call cost | one RPC when you are not the owner | round trips to Redis |
| pods must | address each other, and know the membership list | know only where Redis is |
| on a scale event | key ownership rebalances across the ring | nothing moves |
That last row is why I did not build groupcache for Java. The thing that makes stampedes hurt is elasticity, and route-to-owner puts membership on the critical path of correctness, so the environment creating the problem is also the one reshuffling your ring. Every scale event moves ownership, and mid-flight work moves with it.
A shared coordinator pays round trips to make that a non-event. Pods never address each other, membership is not a concept the library has, and a pod can appear or vanish mid-flight without a key changing hands.
Neither is better in the abstract. Pick the failure mode you would rather be on call for.
Building it
The annotation I wanted looked like this, and this is what shipped:
@Coalesce(
key = "#orderId",
headerKeys = {"X-Tenant-Id"},
freshTtlSeconds = "${orders.fresh-ttl:30}",
staleTtlSeconds = "300",
pendingTtlSeconds = "20",
waitTimeoutSeconds = "25"
)
public Mono<Order> getOrder(String orderId) { ... }
Everything below is what had to exist behind it.
Global-only, with no per-pod tier
The obvious optimisation is a small local cache in front of the Redis one, and every reviewer suggests it. I left it out deliberately.
A local tier means local pending state, which does not remove the stampede so much as push it down a level. Now twelve pods each coalesce internally and you still get twelve executions, or else you coordinate both tiers and the invariant stops being something you can state in one sentence.
Going global-only costs a round trip on every call. It buys a property you can say out loud: every pod is stateless with respect to coalescing, and interchangeable at any moment. That is constraint 1 paid for rather than argued with.
It is also an honest cost. Every follower pays a Redis round trip plus deserialisation even when the concurrency is same-pod, and a local tier would be a pure addition above the entry point if profiling ever justified it.
Three Redis keys, one hash tag
One deterministic key string names three separate Redis keys:
| role | key | type | purpose |
|---|---|---|---|
| lock | coalesce:lock:{ns:key} | Redisson lock (hash) | leader election |
| state | coalesce:state:{ns:key} | bucket (bytes) | cached status and payload |
| notify | coalesce:notify:{ns:key} | pub/sub topic | wake-up for waiting followers |
They must be three distinct keys, which I learned the direct way and will come back to.
They must share one {...} hash tag, placed before the role infix, so Redis Cluster hashes all three to the same slot. Apply the hash tags from the first deployment. Retrofitting them changes the key string, which makes every entry in flight unreachable.
The ordering rule that makes the flow correct
Every call reads the bucket before it ever touches the lock.
That looks like an optimisation and is actually a correctness requirement. A caller arriving one millisecond after the leader released the lock must find the cached result sitting next to it, not win an uncontested lock and re-execute from scratch. Get the order backwards and you have built something that coalesces beautifully during the stampede and then does redundant work forever afterwards.
Most calls end in the first few boxes. The bucket answers them and nothing else in the framework runs:
Only a miss, an entry still being computed, or a recorded failure reaches the lock. Exactly one caller wins it and executes:
Every other caller loses that lock and waits. Losing is the common case, and it is the path that has to survive a leader that never comes back:
The follower path is a subscription plus a jittered 200 to 320ms poll. The subscription is for latency; the poll is for correctness. Redis pub/sub is fire-and-forget, a notification can be dropped, and a dropped notification must not strand a caller until its timeout. The poll alone is sufficient to make progress. Pub/sub only makes it fast.
Two callers, one execution, and the follower never touches the downstream:
Keys are the part that looks trivial
Nothing makes a key "globally unique" on its own. One Redis stands behind every pod, so any string all pods agree on already names the same entry. The entire engineering problem is making every pod compute the byte-identical string for the same logical call.
The ways that fail are mundane and total:
- Never derive a key from
toString()orhashCode()of a DTO. Both are identity-based by default, so every pod computes a different key and the framework silently does nothing at all. Reference explicit fields through SpEL. headerKeysis sorted internally, so declaration order cannot change the key, and values are trimmed.- If you hash a whole payload, serialize it canonically first. Two JSON encodings of the same object with different property order hash differently, and you get one entry per encoder.
- The namespace defaults to the method's full signature,
com.acme.OrderService.getOrder(String). The shorterClassSimpleName.methodNamewould not be unique: overloads share a method name, and two classes in different packages share a simple name. Either would put two different results in one Redis entry, which is the worst failure this library can have. Package and parameter types make it unique by construction, so overloads work with no annotation changes.
The envelope, and a bug hiding in it
The bucket holds an explicit envelope rather than a bare payload:
byte 0 status 0 = DONE, 1 = FAILED
bytes 1..8 computedAt epoch millis, big-endian
bytes 9.. payload encoded result, or UTF-8 error message
Two reasons this is not a string prefix like FAILED:. The first is obvious once stated: a real payload can legitimately begin with those bytes.
The second cost me an entire feature. The age comparison behind stale-while-revalidate needs the time the value was written. Stamp computedAt at read time instead and every cached value looks freshly computed, forever, and background refresh never fires once.
Four clocks, and only one of them is a Redis TTL
They are easier to set when each is read as the question it answers rather than the value it holds.
freshTtl, how stale can this be before I go and get a new one? The load knob, not a correctness bound. Below it you save an execution. Above it you still serve instantly but pay for a refresh.
staleTtl, if the downstream is down, how long do I keep serving? A failed background refresh deliberately leaves the previous value in place, which makes this your outage ride-through window. It is also your Redis memory bill and the hard bound on the oldest data any caller can receive.
pendingTtl, what is the longest this call could legitimately take? P99 plus margin, never P50. It does not cancel a slow leader: the leader keeps running and the lock merely becomes claimable. Set it too low and you do not stop anything, you start a second leader alongside the first, doubling load on a dependency already slow enough to trip the lease. The failure mode gets worse exactly as the dependency gets sicker.
waitTimeout, how long will my caller tolerate waiting? Just under your gateway's timeout. Past that you are holding connections open for requests nobody is listening to.
Crash recovery, and the relationship that fails silently
waitTimeout must exceed pendingTtl. Recovery works by a waiter claiming the expired lease, so if every waiter has given up before that lease expires, nobody is left to take over and a crashed leader becomes a wave of errors instead of a blip. For the crash to be fully invisible you want waitTimeout > pendingTtl + p99(exec): the waiter has to outlive both the lease and the replacement execution.
One implementation detail is load-bearing here. The lease is passed to Redisson explicitly:
lock.tryLock(0, leaseTime.toSeconds(), TimeUnit.SECONDS, lockId);
When you pass a lease, Redisson's watchdog does not renew it. Most people assume Redisson keeps a held lock alive, and it does, but only when you acquire without a lease. Here the ceiling is hard, and the hard ceiling is what makes recovery possible: a dead leader's lock genuinely expires. If the watchdog were renewing, a crashed pod would hold that key until someone noticed.
The sixth round trip, and why I paid for it
coalesce.enabled: false removes the beans at startup, which is the wrong tool during an incident because it needs a deploy. A coalescing layer sits in front of a dependency precisely when that dependency is in trouble.
So there is a runtime switch. Turning it off makes annotated methods behave as if the annotation were not there: the aspect calls straight through and nothing touches Redis, not even to read.
toggle.setActive("com.acme.OrderService.getOrder(String)", false).subscribe();
Scoped to one method, because usually one dependency is sick and the rest are fine, and switching the whole application off strips the shield from every healthy downstream still being protected.
The switch lives in Redis, not in a field. A pod-local switch would only affect whichever pod the load balancer happened to route to, leaving every other pod coalescing while the operator believed otherwise.
The cost is that the switch is read through on every annotated invocation, so a cache hit is two round trips rather than one, and the cold path is six rather than five. That is a real per-call tax in exchange for agreement across pods with no propagation delay and nothing to reconcile. It is also the honest reason the overhead numbers further down are worse than the framework's own cost.
Here is the whole lifecycle:
t=0 leader wins lock, lease = pendingTtl, starts executing
other callers arrive, wait, giving up at waitTimeout
t=E execution done. bucket written, TTL = staleTtl, computedAt = E.
DONE published, lock released.
E to E+fresh cache hit, served instantly, NOTHING else happens
E+fresh to E+stale cache hit, served instantly, ONE background refresh fires
after E+stale key gone. next caller executes cold and waits for it.
Both clocks start when the value lands, not when execution started.
Four states, and what happens in each
The bucket is a small state machine, and the interesting transitions are the failure ones.
| state | meaning | what the next caller does |
|---|---|---|
ABSENT | nothing cached, nothing running | try the lock. Winner becomes leader, losers become followers |
DONE | a value is cached | serve it. Refresh in the background if older than freshTtl |
FAILED | the leader's execution threw | try the lock. Exactly one caller retries |
| gone | staleTtl expired | identical to ABSENT. Next caller executes cold |
FAILED frees the lock immediately for exactly one retry, and there is deliberately no backoff. That looks like an omission and is a scope decision: retry policy and circuit breaking belong in a layer wrapped around the annotated method, not inside a coalescing framework. A framework that grows its own backoff policy ends up fighting the Resilience4j config sitting two annotations above it.
The transition worth noticing is DONE to DONE on a failed refresh. A background refresh that throws does not clear the bucket; it leaves the previous value in place and logs. That single choice is what turns staleTtl into an outage ride-through window rather than merely a memory bound, and it is also what creates the observability blind spot further down.
The Spring AOP tax
Two proxying rules apply, and both bite people who have not hit them before.
Self-invocation is not intercepted. Calling an annotated method from another method of the same bean goes through this, not through the proxy, so the annotation does nothing. The call has to arrive from outside the bean.
State must be read through methods, not fields. Reading a field directly through a CGLIB proxy returns the proxy's own uninitialised copy of that field, not the target's. The proxy subclasses the target, so it has its own set of fields that nothing ever populated. This produces nulls and zeros that look like a data bug and are actually a proxying artefact, and it is worth knowing before you spend an afternoon on it.
Neither is specific to this library. Both are the price of anything annotation-driven in Spring, and both are easier to debug once you expect them.
Seven things that did not survive a running Redis
The design document I started from contained working sketch code, and I was fairly pleased with it. Seven things in it did not survive contact with an actual Redis, and the pattern in which ones failed is more interesting than any single bug.
Three were ordinary bugs the first run caught. An empty Mono threw NPE on both the write and read paths. A Flux method threw ClassCastException in the leader path. The topic listener was never removed, so every follower wait leaked a subscription. All found in minutes.
Two were only visible under concurrency. A cached FAILED state re-entered the top of the flow with no delay, so losing waiters spun on Redis at full rate for the whole retry. And waiters only ever re-read the bucket; they never re-attempted the lock. So when a leader died, the callers already waiting polled uselessly until timeout instead of taking over.
Read that second one again. Crash recovery worked for new callers arriving after the lease expired, and silently did not work for the ones actually affected by the crash. The feature I was most pleased with was working for the population that needed it least, and no amount of reading the code would have shown me that. Only killing a pod while people were waiting on it.
Two were design-level. The lock, the bucket and the notify topic all used the same key string. Redisson's lock stores a hash there; the bucket stores a plain value. The bucket write clobbers the lock, and every subsequent lock operation fails with WRONGTYPE. Hence three distinct keys sharing one hash tag.
And the staleness timestamp was stamped at read time rather than stored with the value, so the computed age was always approximately zero, every value always looked fresh, and stale-while-revalidate never fired once. The code ran. The tests passed. The feature was simply absent, and nothing reported its absence. That is the failure mode I now look for first: not code that breaks, but code that runs correctly while doing nothing.
The one that took the process down
java.lang.OutOfMemoryError: Cannot reserve 130023424 bytes of direct buffer memory
(allocated: 4282871655, limit: 4294967296)
... PSETEX coalesce:state:{...} 120000 UnpooledHeapByteBuf(widx: 126940610)
A single cache entry of 127MB.
Redisson buffers every command in Netty's direct arena before writing it to the socket. About 33 entries that size fit in a 4GiB MaxDirectMemorySize, and the 34th killed the process. The payload never reached Redis. It died in the encoder, which is why my first twenty minutes of debugging, spent staring at Redis latency graphs, found nothing wrong with Redis.
The framework grew a payload ceiling after that, and a payloadsTooLarge counter for results that are computed but deliberately not cached. "Cache whatever the method returned" is not a safe default when the thing doing the caching sits in front of your process memory.
Redisson's advice attached to that failure, increase nettyThreads, is boilerplate it prints on any write failure. Following it would have made things strictly worse: more threads means more concurrent oversized writes competing for the same fixed arena. Read the command parameters in the error, not the suggestion under them.
When to use it, and when not
This is the section I would read first if someone handed me this library, so it gets the measurements.
The one number that decides
The benefit is governed by one ratio: how many requests arrive for the same key inside one refresh window.
window = freshTtlSeconds + downstream_latency
ratio = requests_per_second_per_key × window
saving ≈ 1 − 1 / ratio
The window includes downstream latency because a key cannot go fresh again until the refresh actually lands. The value is in flight for the duration of the call, and every arrival during that flight is a caller you coalesce.
I did not derive this up front. I fitted it after being surprised twice, which is relevant to how much you should trust it, so here is the evidence rather than the assertion. Three runs against the same demo endpoint, same code, same request rate:
| distinct keys | req/s per key | window | ratio | predicted | measured |
|---|---|---|---|---|---|
| 10 | 12.35 | 2.3s | 28.4 | 96.5% | 96.48% |
| ~95 | 0.26 | 4.2s | 1.10 | 8.8% | 8.78% |
| ~1,000,000 | 0.000018 | 122.4s | 0.002 | ~0% | 0.25% |
The first run is the one that goes in a README. 96.48% less load on the dependency, and a latency improvement large enough that I stopped looking at it critically, which is the actual mistake. The conclusion I drew, that the framework worked, was true and almost useless, because it said nothing about when it would not.
The second run is the same code with more distinct keys and a slower dependency. 8.78%. Not a bug, not a regression, just a different point on the same curve. It was also the first time the formula predicted something before I measured it, which is when I started believing the formula rather than the framework.
The third run is the one worth publishing. A million distinct keys, and coalescing was negative. Mean latency 2,394ms became 2,427ms. P95 4,450 became 4,475. P99 5,070 became 5,160. Every percentile worse, in exchange for saving 27 executions out of 10,640.
Key cardinality, not traffic volume, decides. The request rate barely moved across those three runs. Widening the key space took the saving from 96% to nothing, and then past nothing into cost.
That is worth stating plainly, because everything about the way we discuss caching is volume-shaped. We say "high traffic endpoint" and reason in requests per second, because that is what dashboards show. The quantity that governs coalescing is requests per second per key, and those two numbers can differ by six orders of magnitude in the same service. An endpoint doing 12,000 requests per second across a million users has a per-key rate that rounds to zero.
A screening rule, before writing any code:
| ratio | verdict |
|---|---|
| < 2 | net loss. You are adding round trips to save nothing |
| 2 to 5 | marginal. Only if the dependency is genuinely expensive |
| > 10 | strong |
Two benefits, two different drivers
Those runs separated something most caching writing conflates. "Faster" and "less load" are not the same outcome and do not have the same driver.
A stale hit serves the caller instantly but still triggers a refresh. It buys latency and zero load reduction. One run had 7,665 cache hits produce only about 949 saved executions, because 6,716 of them were stale: 2.2× better latency alongside only 1.1× less load, from one config in one window.
Optimising downstream load, freshTtl is your knob. Optimising caller latency, stale hits have already done the job and freshTtl barely matters.
The resilience win that is also a blind spot
One run recorded 130 downstream failures and a 0.00% error rate to clients. Stale-while-revalidate served the last known-good value and swallowed every one of them, taking effective availability from roughly 95% to roughly 100%.
Those 130 failures existed only in a WARN line.
Alert on background refresh failures, or a sick dependency stays invisible right up until staleTtl expires, at which point every key falls over together with no warning and no gradual degradation to notice first. You do not get a slope, you get a cliff, and it arrives when your ride-through window runs out.
The general form is worth keeping well beyond this library. Any resilience mechanism that hides failure also hides information, and you pay that back in observability. Retries do this. Fallbacks do this. Circuit breakers do this.
When not to use it
Non-idempotent work. Never. Redis replication is asynchronous. A primary can acknowledge a lock, die before replicating, and a promoted replica will grant the same lock to a second pod. No configuration eliminates that window; WAIT narrows it at a latency cost on every acquire. This is acceptable only because annotated methods are expected to be idempotent: a rare double execution during failover means doing the work twice, which is the same outcome as running with no framework at all. It is a performance optimisation degrading, not a correctness violation. Payments and order placement need idempotency at the data layer regardless.
Per-user or per-request keys. The ratio collapses below 1 and every caller pays for a mechanism that shares nothing. These are also the most tempting keys, because they are the ones already in your hand at the call site.
Already-fast calls. The cold path is six sequential Redis round trips: GET for the switch, GET for state, tryLock, PSETEX, PUBLISH, unlock. A cache hit is two. The worst measured overhead was +33ms mean, measured before the switch existed, so add a round trip. Invisible against a 2.4s dependency, absurd against a 5ms indexed lookup. Note the shape: the endpoints where overhead is most visible are exactly the ones where benefit is smallest, so the error compounds in one direction.
Large payloads. That 127MB entry is the reason a ceiling exists, and the reason payloadsTooLarge is a counter rather than an exception. A cache that accepts an arbitrary object accepts an arbitrary-sized object.
Reads with side effects. Audit logging, quota decrements, "last viewed" tracking. Coalescing collapses those too, silently. A run that avoids 96% of executions avoids 96% of whatever was happening inside them.
Anything authorization-scoped where the key omits the principal. Not a bug in the framework. That is serving one tenant's data to another, out of cache, at speed.
Unbounded streams. Flux support is for bounded streams only. The leader collects the whole Flux into a List before caching, so there is no live multicast of an unbounded stream across pods.
What to instrument
The framework exposes its own counters, and they answer different questions:
| counter | what it tells you |
|---|---|
leaderExecutions | calls that actually ran the method |
cacheHits | served from the bucket, fresh or stale |
followerWaits | callers that waited on an in-flight leader |
meanFollowerWaitMillis | should cluster near the leader's execution time |
backgroundRefreshes | refreshes that won the lock and ran |
failedRetries | takeovers from a cached FAILED |
leaderTakeovers | waiters that claimed an expired lease: crash recovery firing |
timeouts | CoalesceTimeoutException raised |
payloadsTooLarge | results computed but deliberately not cached |
bypassed | calls that ran straight through with the switch off |
meanFollowerWaitMillis is the one I find most diagnostic. It should sit near the leader's execution time. If it is much higher, followers are being woken by the poll rather than by pub/sub, which points at dropped notifications. If it is near waitTimeout, they are not being woken at all.
The canary
After shipping it, watch (cacheHits + followerWaits) / requests.
Near zero means the framework is doing nothing for that endpoint except costing round trips. No interpretation needed and no dashboard literacy required. If it is flat at zero, remove the annotation.
The other counters worth an alert are timeouts, which should be rare, and leaderTakeovers, which is crash recovery actually firing and tells you leaders are dying or pendingTtl is set too low.
Published
The intersection is empty, and I think it is empty for reasons rather than by oversight. A shared cache is not shared in-flight state. Distributing the second makes you a distributed systems library. Reactive removes the cheap way to wait. And the coordination layer cannot be pluggable without the guarantee becoming meaningless.
Every library in that table stopped at a defensible line. The gap is the shape of those four constraints intersecting, and the fact that each one came back to bite me during the build is reasonable evidence they are real constraints rather than excuses.
The interesting counterfactual is that this closes the day Spring's cache abstraction grows an SPI for distributed sync = true: somewhere for a provider to plug in shared in-flight state rather than only a shared cache. sync = true is already the right ergonomics. It cannot see past the CacheManager. Until something like that exists, this is a library rather than a feature, and libraries are how features get prototyped.
implementation 'net.bitsar:coalesce-spring-boot-starter:0.2.5'
Apache 2.0. Source, benchmarks, the full parameter guide and the design docs: github.com/BitanSarkar/coalesce-java.
If your ratio is above 10, this will remove most of your stampede. If it is below 2, I have just spent several thousand words explaining why you should not install it, and I would rather have written that than the version I could have published after the first run.
What is still open
I built this for the runtime I actually work in. Everything I write is WebFlux, so the aspect accepts Mono and Flux and nothing else, the coordinator SPI returns reactive types, and the header capture reads from the Reactor Context. That is the shape of the problem I had rather than a judgement about blocking applications, and it is the largest gap in the library.
There are more, and I would rather list them than leave them looking like design decisions. The repo is open and I will review anything that lands.
-
Blocking and Spring MVC.
CoalesceAspectthrows on any return type that is notMonoorFlux, so an MVC application gets nothing today. The coordination itself is not deeply reactive: it is a handful of Redis operations and a wait loop, which makes a blocking facade over the same coordinator plausible. The open questions are what a blocking follower does while it waits, where a parked platform thread is expensive and a virtual thread is not, and where headers come from with no Reactor Context to read. -
A coordinator that is not Redisson.
CoalesceCoordinatoris six methods:tryAcquire,release,markDone,markFailed,fetchStateandlisten. Redisson is anapidependency, so every consumer inherits a second Redis client even when they already run Lettuce. A Lettuce implementation would make that dependency optional. The work is mostly Lua, because a lease with an owner id and a release only the owner can perform are exactly what Redisson hands you for free. -
Metrics are counters, not a Micrometer binding.
CoalesceMetricsis a set ofAtomicLongs read through/actuator/coalesce. There are no tags, so the ratio that decides whether the annotation is earning its round trips cannot be charted per namespace or per endpoint, and follower wait is a running total rather than a timer with percentiles. -
The follower poll interval is hardcoded. It is 200 to 320ms with jitter. Against a downstream with a 50ms p99 that interval dominates the wait. Against a 30 second batch job it is two orders of magnitude more Redis traffic than the case needs. It should be a property, and it should probably widen as a wait gets longer.
-
An unbounded
Fluxis collected, not multicast. The leader buffers the whole sequence into aListbefore caching, so streaming coalesces only when the stream is bounded and small. A real multicast, where followers attach to a leader's in-flight stream, is a different design, and it is worth writing down properly before anyone implements it. -
There is no same-pod tier. Two concurrent callers inside one JVM both pay a Redis round trip and a deserialisation. A local in-flight map above the entry point would fold them into one call. It is a pure addition, which is what makes it tempting, but it introduces a second source of truth that has to stay honest about the kill switch and the TTLs.
-
Oversized payloads get no coalescing at all. Past
maxPayloadBytesthe caller still gets its result and the entry is simply not cached, which is the safe behaviour and also means the largest responses, the ones most worth deduplicating, are the ones that fall through. Compression in the codec moves that line. Chunking moves it further and costs a great deal of complexity. -
Integration tests skip themselves when nothing is listening on
localhost:6379. They are the only tests that prove coalescing works, so a green local build can prove nothing on a machine without Redis. CI runs a service container, so this is a local development gap, and Testcontainers would close it. -
Duplicate leaders during failover are documented rather than solved. An asynchronous replica promoted after the primary acknowledged a lock will grant that lock a second time. The library accepts this because annotated methods are expected to be idempotent, and a rare double execution is exactly what running with no framework looks like. Fencing tokens would let the bucket reject a stale leader's write. I am genuinely undecided about whether that complexity pays for itself, and I would like to hear the argument in either direction.
Each of those nine is filed as an issue, #7 through #15, with the code it refers to named in the body, so picking one up does not start with archaeology. main is the released line, so work lands through a branch, and CI runs the whole suite against a real Redis on every pull request.
Leave a comment
No account needed. Leave the name blank and you'll get a random one.