fix(rueidisaside): prevent canceled cache fills from leaking locks - #1010
fix(rueidisaside): prevent canceled cache fills from leaking locks#1010Makarechi wants to merge 6 commits into
Conversation
| // Wait for the lock response even if the caller is canceled. A canceled | ||
| // write can still reach Redis, so ownership must be known before cleanup. | ||
| acquireCtx := context.WithoutCancel(ctx) | ||
| if c.useLuaLock { | ||
| val, err = acquireLock.Exec(ctx, c.client, []string{key}, []string{id, strconv.FormatInt(ttl.Milliseconds(), 10)}).ToString() | ||
| val, err = acquireLock.Exec(acquireCtx, c.client, []string{key}, []string{id, strconv.FormatInt(ttl.Milliseconds(), 10)}).ToString() | ||
| } else { | ||
| val, err = c.client.Do(ctx, c.client.B().Set().Key(key).Value(id).Nx().Get().Px(ttl).Build()).ToString() | ||
| val, err = c.client.Do(acquireCtx, c.client.B().Set().Key(key).Value(id).Nx().Get().Px(ttl).Build()).ToString() |
There was a problem hiding this comment.
I don't think ignoring the user's context is a good idea, as we should respect the deadline in the user's ctx as much as possible. Can't we just do delkey.Exec(context.Background(), c.client, []string{key}, []string{id}) regardlessly if the returned error from acquireLock or c.client.Do is a context error?
There was a problem hiding this comment.
Respecting the caller's deadline is a fair concern. The reason I did not run delkey on every context error is that id belongs to the whole CacheAsideClient, not to one acquisition.
For example, two concurrent Get calls on the same missing key can both reach lock acquisition. If A acquires the lock and starts the loader while B returns a context error, delkey(key, id) from B also matches A's lock and removes it. The same race is possible when B is canceled before its command is enqueued and A acquires the lock between B's cache miss and B's cleanup. A's later setkey CAS can then no longer populate the cache, and another client may start a duplicate load.
WithoutCancel is used only for the acquisition round trip so the result tells us whether this specific attempt acquired the lock (RedisNil) before cleanup. I agree that returning by the caller's deadline would be preferable. A safe alternative would be to run the acquire as an in-flight operation, return when ctx.Done() fires, and let the in-flight goroutine wait for the actual Redis result and clean up only if that attempt acquired the lock. Would you prefer that direction?
There was a problem hiding this comment.
For example, two concurrent Get calls on the same missing key can both reach lock acquisition. If A acquires the lock and starts the loader while B returns a context error, delkey(key, id) from B also matches A's lock and removes it. The same race is possible when B is canceled before its command is enqueued and A acquires the lock between B's cache miss and B's cleanup. A's later setkey CAS can then no longer populate the cache, and another client may start a duplicate load.
Oh, that makes sense; then can we do singleflight for the acquire-fn-setkey-delkey flow instead?
There was a problem hiding this comment.
Or we can do make the id cantains a value from an atomic counter and make sure the delkey only delete the key matching the value.
There was a problem hiding this comment.
Thanks, both suggestions address the shared client ID race.
A singleflight around the whole acquire -> fn -> setkey/delkey flow, scoped per cache key and client ID generation, seems like the smaller backward-compatible option. Using a per-attempt counter is trickier because the placeholder value currently also names the client heartbeat key: a suffixed value would have no corresponding heartbeat key, and old clients during a rolling upgrade could treat a live owner as dead.
There is one remaining edge case that I do not think singleflight alone covers. With pipeline multiplexing, the canceled acquire and its cleanup can run on different connections. If Do returns a context error while the SET is still queued, delkey may reach Redis first; the SET can then execute later and leave the placeholder behind.
Would you be open to combining the singleflight with a detached acquire worker? Get would return as soon as the caller context is done, while the worker would wait for the actual acquire result. If that attempt acquired the lock, it would run compare-and-delete, and only then release the singleflight. Waiters would keep their own contexts and retry the cache after the flight completes.
This keeps the current Redis format and respects caller deadlines while covering both the shared-ID deletion race and the late-SET cleanup race. If this direction sounds reasonable, I can update the PR and add regression coverage for the canceled leader and live follower cases.
There was a problem hiding this comment.
There is one remaining edge case that I do not think singleflight alone covers. With pipeline multiplexing, the canceled acquire and its cleanup can run on different connections. If Do returns a context error while the SET is still queued, delkey may reach Redis first; the SET can then execute later and leave the placeholder behind.
Wouldn't SET later fail because the placeholder has been deleted? What's wrong with this?
Using a per-attempt counter is trickier because the placeholder value currently also names the client heartbeat key: a suffixed value would have no corresponding heartbeat key
Yes, it is trickier, but I think we can overcome that by doing some tricks in Lua.
Would you be open to combining the singleflight with a detached acquire worker?
I'd like to avoid additional goroutines here because they would be costly. I currently prefer making the whole per-key acquire -> fn -> setkey/delkey singleflight since concurrently acquiring the same key from one client is just not optimal. But let me know if it alone can't solve the problem.
There was a problem hiding this comment.
Implemented in 51940d3.
On the late SET question: after cleanup deletes the placeholder, SET ... NX sees an absent key, so it can succeed when the queued command eventually reaches Redis. That cleanup-before-acquire ordering is the race this revision now covers.
I followed your preferred no-extra-goroutine direction:
- the complete
acquire -> fn -> set/deleteflow is singleflighted per cache key and client-ID generation; - every leader registers a unique attempt guard before sending acquire;
- acquire Lua checks that exact guard and uses its remaining TTL for the placeholder;
- the final write checks both the guard and placeholder ownership;
- cleanup revokes the guard and compare-and-deletes the placeholder before releasing the flight.
Therefore, if cleanup reaches Redis first, the delayed acquire/final write sees a revoked guard and does nothing. If acquire reaches Redis first, cleanup removes its placeholder. Cleanup is bounded; an unconfirmed cleanup rotates the local client-ID generation so an old command cannot match the next flight.
The main placeholder remains the exact existing rueidisid:* heartbeat key for rolling-upgrade compatibility. Guard keys are placed in the same Redis Cluster slot as the cache key. Both the Redis 7 and legacy UseLuaLock paths have regression coverage, including delayed acquire, delayed final write, live follower, disconnect generation change, and cleanup failure.
There was a problem hiding this comment.
On the late SET question: after cleanup deletes the placeholder, SET ... NX sees an absent key, so it can succeed when the queued command eventually reaches Redis. That cleanup-before-acquire ordering is the race this revision now covers.
I see. I thought you were talking about the setKey previously. Yes, if we do delkey regardlessly, it is possible to reach Redis before the acquireLock or the SET key id NX.
Therefore, if cleanup reaches Redis first, the delayed acquire/final write sees a revoked guard and does nothing. If acquire reaches Redis first, cleanup removes its placeholder. Cleanup is bounded; an unconfirmed cleanup rotates the local client-ID generation so an old command cannot match the next flight.
But I think your latest approach is overkill. We have a couple of ways to make sure delkey and acquireLock or the SET key id NX land into the same connection:
- Enforce
PipelineMultiplexto -1. This is what we do in the rueidislock. - Use client.Dedicate/Dedicated for the acquire -> fn -> setkey/delkey flow.
- Add a new command tag to let the
slotfnin the mux.go choose a pipe based on the key hash.
I would prefer 1 for now and explore option 3 later. 2 is less preferable because it can create more connections and has unnecessary overhead.
There was a problem hiding this comment.
Implemented your preferred option in 1c592a6.
NewClientnow enforcesPipelineMultiplex = -1, keeping the acquire and cleanup commands ordered on the same connection.- The complete
acquire -> fn -> setkey/delkeyflow remains singleflighted per cache key, so one client does not acquire the same key concurrently. - I removed the attempt guards, counter, and additional Lua complexity from the previous revision. The Redis 7 path is back to
SET NX GET, while the legacy path keeps the existing Lua acquire. - The caller context is respected by acquire, the loader, and the final write. If cancellation requires cleanup, compare-and-delete runs with an independent context and finishes before the flight is released.
Regression tests cover cancellation cleanup for both Redis 7 and legacy paths, and go test, go test -race, and go vet pass. This should now match option 1; please take another look when convenient.
12c0c25 to
51940d3
Compare
| if id, err = c.keepalive(); err == nil { | ||
| f, leader := c.beginFlight(key, id) | ||
| if f == nil { // the client id changed while preparing the flight | ||
| goto retry |
There was a problem hiding this comment.
Even if the client id changed here, why can't we just wait for the flight to finish?
There was a problem hiding this comment.
If we jump to retry, we will have a busy retry loop until the existing flight acquires the lock, which is not that good.
There was a problem hiding this comment.
Good point — we can wait for the existing per-key flight here. Fixed in 81fe858.
The flight map is now keyed strictly by cache key, and beginFlight looks up an active flight before validating the client ID. Active flights are no longer reset on invalidation, so a caller that observed an old or new client ID joins the same in-process flow and waits with its own context instead of repeatedly jumping to retry.
The client-ID check remains only when no flight exists, which prevents a stale caller from becoming a new leader. In that narrow case one retry is still needed to obtain the current ID.
I also updated the Redis 7 and legacy disconnect tests to verify that an ID change does not start a second loader while the old flight is active, and added a unit regression for joining a flight across generations. go test, go test -race, and go vet pass.
There was a problem hiding this comment.
Follow-up in 1854a98: I removed the remaining stale-ID/no-flight retry as well. The per-key flight is now created before keepalive, so the whole local miss flow starts with strict key-based singleflight. The leader resolves the current client ID inside that flight, while every same-key follower waits with its own context. This removes the retry branch entirely and still keeps different keys independent.
A new regression test blocks client-marker creation and verifies that a follower neither creates another marker nor runs another loader. Full tests, race tests, and go vet pass.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 81fe858. Configure here.

Problem
CacheAsideClient.Getcan returncontext.Canceledwhile a side-effecting lock command is still queued in rueidis. If cleanup uses another multiplexed connection, it can reach Redis first and do nothing; the delayed acquire can then run later and leave a liverueidisid:*placeholder behind.Blind cleanup is also unsafe when concurrent fills from the same
CacheAsideClientshare the same client marker ID.Fix
This change follows the same connection-ordering approach used by
rueidislock:PipelineMultiplexis forced to-1before constructing the rueidis client, so lock mutations for a Redis node use one FIFO pipeline.client marker -> acquire -> loader -> set/deleteflow is singleflighted strictly per cache key, including across client-ID changes.flowchart TD A["Cache miss"] --> B{"Active local flight for key?"} B -- "Yes" --> C["Wait with caller context"] C --> A B -- "No" --> K["Ensure current client marker"] K -- "Error" --> I["Release flight"] K -- "Ready" --> D["Acquire lock with caller context"] D -- "Key occupied" --> F["Release flight and follow existing value/placeholder"] D -- "Canceled or error" --> E["Compare-and-delete cleanup"] D -- "Acquired" --> G["Run loader"] G --> H["Compare-and-set ready value with caller context"] H -- "Success" --> I H -- "Canceled or error" --> E E --> IThe FIFO pipeline closes the late-command race:
Compatibility
rueidisid:*heartbeat key, so the Redis data format is unchanged.SET NX GET.UseLuaLock=trueretains the Redis < 7 compatible acquire script.PipelineMultiplexvalue is intentionally overridden with-1, matchingrueidislock.Tests
Regression coverage includes:
ClientBuilderreceiving the enforced pipeline option.Local validation:
go test . -count=1go test -race . -count=1go vet ./...-count=50)git diff --check