feat(quotas): Support shadow (dual) writes for the quotas Redis pool - #6294
feat(quotas): Support shadow (dual) writes for the quotas Redis pool#6294dmajere wants to merge 1 commit into
Conversation
Adds an optional `shadow` list of cluster nodes to a Redis pool config. When set, every command is fanned out to the primary and, best-effort, to the shadow cluster(s) concurrently. Only the primary result is returned and shadow errors are logged and swallowed (fail-open). This is used to dual-write quota data while migrating between Redis deployments. - config: add `shadow: Option<Vec<String>>` to cluster Redis config - relay-redis: add `MultiWrite` variant to `AsyncRedisClient`/`AsyncRedisConnection` with concurrent fan-out on the write path - service: build the shadow client and wrap the quotas pool via `multi_write`
0ce08ab to
9b96fa6
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9b96fa6. Configure here.
| } | ||
| } | ||
| primary_result | ||
| }), |
There was a problem hiding this comment.
Shadow waits gate primary latency
High Severity
req_packed_command and req_packed_commands use futures::join so the caller only returns after every shadow command finishes. A slow or timing-out shadow therefore delays the primary result by up to response_timeout (default 30s), coupling quota latency to shadow health despite comments claiming otherwise.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 9b96fa6. Configure here.
There was a problem hiding this comment.
Since its a wait for all, latency of write is latency of the slowest + cycles for managing context switches etc.
this should not be a big issue since we plan to similarly performant clusters.
Dav1dde
left a comment
There was a problem hiding this comment.
Implementation/dispatch looks good.
Mostly need a change to how the configuration works, I'd mirror to multi write approach of the client, like the original PR also had. That also means double writes work independently of whether it is a cluster connection or a single node connection.
And please do a human de-slopify of the comments, other people actually read this and I see shadows everywhere now.
| /// | ||
| /// This is used to dual-write quota data while migrating between Redis deployments. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| shadow: Option<Vec<String>>, |
There was a problem hiding this comment.
Why are you using shadow here but in the Redis client itself you model it via MultiWrite?
| /// Commands executed against the returned client are sent to the primary and, best-effort, to | ||
| /// every shadow client. Only the primary result is returned. |
There was a problem hiding this comment.
Result is returned from where? I think you want to say that the commands sent to the secondaries are fire and forget, it just reads weird, because this function definitely doesn't fail.
| pub fn multi_write( | ||
| primary: AsyncRedisClient, | ||
| secondaries: Vec<AsyncRedisClient>, | ||
| ) -> Result<Self, RedisError> { |
There was a problem hiding this comment.
Why did you make this fallible?
There was a problem hiding this comment.
followed same code pattern as in above single function.
| secondaries, | ||
| } => { | ||
| // Acquiring the primary connection must succeed, otherwise the whole operation | ||
| // fails, just like a non-shadowed client. |
There was a problem hiding this comment.
This shadow terminology really should just be secondary, that's what it is called in code.
Also some of the comments are redundant with the code like:
// The recursive `get_connection` calls are boxed to break the infinitely sized
// future that async recursion would otherwise create.
| AsyncRedisClient::MultiWrite { secondaries, .. } => { | ||
| write!( | ||
| f, | ||
| "AsyncRedisPool::MultiWrite({} shadows)", |
There was a problem hiding this comment.
| "AsyncRedisPool::MultiWrite({} shadows)", | |
| "AsyncRedisPool::MultiWrite({} secondaries)", |
| let secondaries = futures::future::join_all( | ||
| secondaries | ||
| .iter_mut() | ||
| .map(|secondary| secondary.req_packed_command(cmd)), |
There was a problem hiding this comment.
This also sends reads to the secondaries while only writes need to be sent. This is relevant for project config requests (technically we can just never configure the multi write for project configs, but if everything is backed by a single redis it's a bit of a footgun).
I think the old impl had the same issue.
Not sure how to compat that, maybe just checking for some well known read commands (GET, MGET, EXISTS) is enough.


Summary
Reintroduces dual ("shadow") writes for the quotas Redis pool so quota data can be duplicated to a second Redis/Valkey deployment during a migration. This capability existed previously as the
MultiWriteclient (#4064) but was lost during the async Redis migration (#4552) and formally removed from config (#4656). This re-implements it on the current async (AsyncRedisClient) stack, scoped to quotas.Config
Add an optional
shadowlist of cluster nodes next tocluster_nodeson the quotas pool:When
shadowis set, every command executed against the pool is sent to the primary and, best-effort, to the shadow cluster.Implementation
relay-config: optionalshadow: Option<Vec<String>>on the cluster Redis config, threaded throughRedisConfigRefandbuild_redis_config.relay-redis:MultiWrite { primary, secondaries }variant onAsyncRedisClientandAsyncRedisConnection. TheConnectionLikeimpl dispatches the command to the primary and all shadows concurrently (viafutures::join/join_all), returns only the primary's result, and logs + swallows shadow errors. Connection acquisition for shadows is best-effort so a down shadow never blocks the primary path.stats()reports the primary only;retain()(pool maintenance) fans out to all pools. Addedrelay-logas an optional dep under theimplfeature.relay-server:create_async_redis_clientbuilds the shadow cluster (same options, metrics tagquotas_shadow) and wraps the pool viamulti_writewhenshadowis set. Script preloading needs no change —SCRIPT LOADis fanned out through the same connection, so the rate-limiting Lua script is loaded on the primary and shadow at startup.Behavior / trade-offs (fail-open)
SCRIPT LOADis skipped; laterEVALSHAon the shadow returnsNOSCRIPT(logged/dropped) until the next relay restart re-primes it. Acceptable for a migration shadow.Tests
shadowfield.relay-config/relay-redis/relay-quotasbuild & tests pass;relay-serverbuilds with and withoutprocessing; clippy + fmt clean.