Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
1e7500b
fix: clustered inserts must respect hash slots
carodewig Aug 26, 2025
1ceca95
fix: record insert errors
carodewig Aug 26, 2025
05e13bb
fix: correct name in histogram
carodewig Aug 26, 2025
9269b70
fix: don't return anything
carodewig Sep 2, 2025
add83e4
fix: increment error metric on error_rx
carodewig Aug 27, 2025
d7a4e6b
feat: add counter for reconnections
carodewig Aug 27, 2025
a120373
feat: add counter for 'unresponsive' redis cli events
carodewig Aug 27, 2025
683dd74
maint: record errors while closing the pool
carodewig Aug 27, 2025
2ef316d
maint: rename metric for consistency
carodewig Sep 2, 2025
ab774a6
docs: create changeset entry
carodewig Sep 2, 2025
2f961dd
fix: don't double-count errors
carodewig Sep 3, 2025
de7d98c
Merge branch 'dev' into caroline/redis-fixes
carodewig Sep 3, 2025
22bc15b
feat: add redis-cluster to local docker and circleci
carodewig Sep 11, 2025
bb2b671
fix: don't need to separate paths if cluster
carodewig Sep 11, 2025
b893332
test: add test for insert_multiple and get_multiple behavior in a clu…
carodewig Sep 11, 2025
19c6bf1
docs: update changeset
carodewig Sep 11, 2025
99b78f0
Merge branch 'dev' into caroline/redis-fixes
carodewig Sep 11, 2025
bf7a440
style: revert rustrover's sneaky formatting
carodewig Sep 11, 2025
1958e2f
test: use replicas in circleci
carodewig Sep 11, 2025
187eca7
test: update expected command count
carodewig Sep 11, 2025
b595901
Revert "docs: update changeset"
carodewig Sep 12, 2025
5709690
Merge branch 'dev' into caroline/redis-fixes
carodewig Sep 12, 2025
cc5a210
chore: better debugging information
carodewig Sep 15, 2025
ab7253b
doc: better test name and description
carodewig Sep 15, 2025
16ebadc
Merge branch 'dev' into caroline/redis-fixes
carodewig Sep 19, 2025
373e537
Merge branch 'dev' into caroline/redis-fixes
carodewig Sep 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changesets/feat_caroline_redis_fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
### Respect Redis cluster slots when inserting multiple items ([PR #8185](https://github.com/apollographql/router/pull/8185))

The existing `insert` code will silently fail rather than reporting an error. This PR fixes that behavior and adds new
metrics to track Redis client health.

New metrics:
* `apollo.router.cache.redis.unresponsive`: counter for 'unresponsive' events raised by the Redis library
* `kind`: Redis cache purpose (`APQ`, `query planner`, `entity`)
Comment thread
carodewig marked this conversation as resolved.
* `server`: Redis server that became unresponsive
* `apollo.router.cache.redis.reconnection`: counter for 'reconnect' events raised by the Redis library
* `kind`: Redis cache purpose (`APQ`, `query planner`, `entity`)
* `server`: Redis server that required client reconnection

By [@carodewig](https://github.com/carodewig) in https://github.com/apollographql/router/pull/8185
15 changes: 15 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ executors:
POSTGRES_DB: root
- image: openzipkin/zipkin:3.5.1
- image: ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.33.1
# redis cluster - 3 primaries, 3 replicas
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7000", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7001", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7002", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7003", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7004", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7005", "--cluster-enabled", "yes" ]
- image: cimg/redis:7.4.5
command: [ "sh", "-c", "sleep 30; echo yes | redis-cli --cluster create --cluster-replicas 1 localhost:7000 localhost:7001 localhost:7002 localhost:7003 localhost:7004 localhost:7005" ]
resource_class: 2xlarge
environment:
MISE_ENV: ci
Expand Down
174 changes: 141 additions & 33 deletions apollo-router/src/cache/redis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ where
// when each clone is dropped, only when the last instance is dropped.
struct DropSafeRedisPool {
pool: Arc<RedisPool>,
caller: &'static str,
heartbeat_abort_handle: AbortHandle,
// Metrics collector handles its own abort and gauges
_metrics_collector: RedisMetricsCollector,
Expand All @@ -133,10 +134,12 @@ impl Deref for DropSafeRedisPool {
impl Drop for DropSafeRedisPool {
fn drop(&mut self) {
let inner = self.pool.clone();
let caller = self.caller;
tokio::spawn(async move {
let result = inner.quit().await;
if let Err(err) = result {
tracing::warn!("Caught error while closing unused Redis connections: {err:?}");
record_redis_error(&err, caller);
}
});
self.heartbeat_abort_handle.abort();
Expand All @@ -151,7 +154,6 @@ pub(crate) struct RedisCacheStorage {
pub(crate) ttl: Option<Duration>,
is_cluster: bool,
reset_ttl: bool,
caller: &'static str,
}

fn get_type_of<T>(_: &T) -> &'static str {
Expand Down Expand Up @@ -335,6 +337,7 @@ impl RedisCacheStorage {
// spawn tasks that listen for connection close or reconnect events
let mut error_rx = client.error_rx();
let mut reconnect_rx = client.reconnect_rx();
let mut unresponsive_rx = client.unresponsive_rx();

i64_up_down_counter_with_unit!(
"apollo.router.cache.redis.connections",
Expand All @@ -348,22 +351,53 @@ impl RedisCacheStorage {
loop {
match error_rx.recv().await {
Ok((error, Some(server))) => {
tracing::error!(
"Redis client disconnected from {server:?} with error: {error:?}",
)
tracing::error!("Redis client ({server:?}) error: {error:?}",);
record_redis_error(&error, caller);
}
Ok((error, None)) => {
tracing::error!("Redis client disconnected with error: {error:?}",)
tracing::error!("Redis client error: {error:?}",);
record_redis_error(&error, caller);
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
});

tokio::spawn(async move {
loop {
match unresponsive_rx.recv().await {
Ok(server) => {
tracing::debug!("Redis client ({server:?}) unresponsive");
u64_counter_with_unit!(
"apollo.router.cache.redis.unresponsive",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth distinguishing between the server and client? maybe as a tag or something, not sure; mostly, it'd be nice to know whether the client is struggling (eg, too many buffered commands while the server is still chomping away as expected) or the server is struggling (client is happy but the server has ground to a halt for some reason)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you'd be able to get the client via your metrics ingest engine - for example, IIRC prometheus adds the 'target' to metrics it scrapes.

Or do you mean having a tag for the specific client within the router, if you've got multiple clients in the pool?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more the ingestion bit; just some way to distinguish between server and client unresponsiveness, which if there's already some way to figure that out with defaults, then that'd be great

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly I don't think there's a good way to make that distinction; the unresponsive event is published by fred when it's gone a certain amount of time since hearing from the server per this config.

But the other metrics Bryn added around command queue length etc might be a good way to diagnose this live -- if you see a bunch of unresponsive events while the command queue length is high, that would be a good indicator of a troublesome Redis server.

"Counter for Redis client unresponsive events",
"{event}",
1,
kind = caller,
server = server.to_string()
);
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
});

tokio::spawn(async move {
loop {
match reconnect_rx.recv().await {
Ok(server) => tracing::info!("Redis client connected to {server:?}"),
Ok(server) => {
u64_counter_with_unit!(
"apollo.router.cache.redis.reconnection",
"Counter for Redis client reconnection events",
"{reconnection}",
1,
kind = caller,
server = server.to_string()
);
tracing::info!("Redis client connected to {server:?}")
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
Expand All @@ -382,10 +416,8 @@ impl RedisCacheStorage {
});
}

let _handle = pooled_client.init().await.inspect_err(|e| {
// Record connection failure as metrics even when initial setup fails
record_redis_error(e, caller);
})?;
// NB: error is not recorded here as it will be observed by the task following `client.error_rx()`
let _handle = pooled_client.init().await?;
let heartbeat_clients = pooled_client.clone();
let heartbeat_handle = tokio::spawn(async move {
heartbeat_clients
Expand All @@ -401,14 +433,14 @@ impl RedisCacheStorage {
Ok(Self {
inner: Arc::new(DropSafeRedisPool {
pool: pooled_client_arc,
caller,
heartbeat_abort_handle: heartbeat_handle.abort_handle(),
_metrics_collector: metrics_collector,
}),
namespace: namespace.map(Arc::new),
ttl,
is_cluster,
reset_ttl,
caller,
})
}

Expand All @@ -418,7 +450,7 @@ impl RedisCacheStorage {

/// Helper method to record Redis errors for metrics
fn record_error(&self, error: &RedisError) {
record_redis_error(error, self.caller);
record_redis_error(error, self.inner.caller);
}

fn preprocess_urls(urls: Vec<Url>) -> Result<Url, RedisError> {
Expand Down Expand Up @@ -649,6 +681,9 @@ impl RedisCacheStorage {
.set::<(), _, _>(key, value, expiration, None, false)
.await;
tracing::trace!("insert result {:?}", r);
if let Err(err) = r {
self.record_error(&err);
}
}

pub(crate) async fn insert_multiple<K: KeyType, V: ValueType>(
Expand All @@ -657,29 +692,25 @@ impl RedisCacheStorage {
ttl: Option<Duration>,
) {
tracing::trace!("inserting into redis: {:#?}", data);
let expiration = ttl
.or(self.ttl)
.map(|ttl| Expiration::EX(ttl.as_secs() as i64));

let r = match ttl.as_ref().or(self.ttl.as_ref()) {
None => self.inner.mset(data.to_owned()).await,
Some(ttl) => {
let expiration = Some(Expiration::EX(ttl.as_secs() as i64));
let pipeline = self.inner.next().pipeline();

for (key, value) in data {
let _ = pipeline
.set::<(), _, _>(
self.make_key(key.clone()),
value.clone(),
expiration.clone(),
None,
false,
)
.await;
}
// NB: if we were using MSET here, we'd need to split the keys by hash slot. however, fred
// seems to split the pipeline by hash slot in the background.
let pipeline = self.inner.next().pipeline();
for (key, value) in data {
let key = self.make_key(key.clone());
let _ = pipeline
.set::<(), _, _>(key, value.clone(), expiration.clone(), None, false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if I understand this right, previously when we had no ttl, we'd use mset to blast away a chonky set of writes; now we just use a sequential set--do we understand the performance differences between the two? I'm assuming so, but figured I'd ask just in case (sounds like the mset was failing when multiple hashslots were targeted, but I'm wondering about the case where it wasn't silently failing)

not a blocker, I don't think, because a working mset for a good number of cases is better than a performant-but-broken mset for a smaller number of a cases

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct!

I haven't profiled the MSET vs sequential SET behavior; I suspect which one is better would depend on environment, data size, etc. (ie if the payload is large enough, it's better to send multiple SETs).

But most users will not have been hitting the MSET path previously - insert_multiple is only used in the entity caching plugin and the vast majority of users will have a TTL set for that.

.await;
}

pipeline.last().await
}
};
tracing::trace!("insert result {:?}", r);
Comment thread
carodewig marked this conversation as resolved.
let result: Result<Vec<()>, _> = pipeline.all().await;
Comment thread
carodewig marked this conversation as resolved.
if let Err(err) = result {
tracing::trace!("caught error during insert: {err:?}");
self.record_error(&err);
}
}

/// Delete keys *without* adding the `namespace` prefix because `keys` is from
Expand Down Expand Up @@ -726,10 +757,20 @@ impl RedisCacheStorage {

#[cfg(test)]
mod test {
use std::collections::HashMap;
use std::time::SystemTime;

use fred::types::cluster::ClusterRouting;
use itertools::Itertools;
use rand::Rng;
use rand::RngCore;
use rand::distr::Alphanumeric;
use serde_json::json;
use tower::BoxError;
use url::Url;

use crate::cache::redis::RedisKey;
use crate::cache::redis::RedisValue;
use crate::cache::storage::ValueType;

#[test]
Expand Down Expand Up @@ -836,4 +877,71 @@ mod test {
let urls = vec![url, url_1];
assert!(super::RedisCacheStorage::preprocess_urls(urls).is_err());
}

#[tokio::test(flavor = "multi_thread")]
async fn test_redis_cluster_insert_get_mget() -> Result<(), BoxError> {
let config_json = json!({
"urls": ["redis-cluster://localhost:7000"],
"namespace": "test_redis_cluster",
"required_to_start": true,
"ttl": "60s"
});
let config = serde_json::from_value(config_json).unwrap();
let storage = super::RedisCacheStorage::new(config, "test_redis_cluster").await;

// only error for lack of storage when running in CI. otherwise, skip this test.
#[cfg(not(all(feature = "ci", all(target_arch = "x86_64", target_os = "linux"))))]
if storage.is_err() {
return Ok(());
}
let storage = storage?;

// insert values which reflect different cluster slots to properly test cluster behavior
Comment thread
carodewig marked this conversation as resolved.
Outdated
let mut data = HashMap::default();
let expected_value = rand::rng().next_u32() as usize;
let unique_cluster_slot_count = |data: &HashMap<RedisKey<String>, _>| {
data.keys()
.map(|key| ClusterRouting::hash_key(key.0.as_bytes()))
.unique()
.count()
};

while unique_cluster_slot_count(&data) < 50 {
// NB: include {} around key so that this key is what determines the cluster hash slot - adding
// the namespace will otherwise change the slot
let key = rand::rng()
.sample_iter(&Alphanumeric)
.take(10)
.map(char::from)
.collect::<String>();
data.insert(RedisKey(format!("{{{}}}", key)), RedisValue(expected_value));
}

// insert values
let keys: Vec<_> = data.keys().cloned().collect();
let data: Vec<_> = data.into_iter().collect();
storage.insert_multiple(&data, None).await;

// make a `get` call for each key and ensure that it has the expected value. this tests both
// the `get` and `insert_multiple` functions
for key in &keys {
let value: RedisValue<usize> = storage
.get(key.clone())
.await
.ok_or("unable to get value")?;
assert_eq!(value.0, expected_value);
}

// test the `mget` functionality
let values = storage
.get_multiple(keys)
.await
.ok_or("unable to get_multiple")?;
for value in values {
let value: RedisValue<usize> = value.ok_or("missing value")?;
assert_eq!(value.0, expected_value);
}
Comment on lines +949 to +952

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just to doublecheck because I tripped over it when trying to understand the test: we're setting the same value for all keys and then getting all keys (first as a set of gets and then as one big mget) to check their values, which is just the same int?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! The idea with the 'same value' bit is if this test is somehow running twice against the same redis cluster, we're actually getting the value for this test. Probably unnecessary, but didn't add much complexity so I thought it was worth it as a backup.


Ok(())
}
}
4 changes: 2 additions & 2 deletions apollo-router/tests/integration/entity_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -498,10 +498,10 @@ async fn test_cache_metrics() {

// Assert Redis commands executed metric (counter)
// We executed 7 queries (1 initial + 1 second + 5 more), each with cache operations
// Based on actual test run, we expect 17 Redis commands to be executed
// Based on actual test run, we expect 16 Redis commands to be executed
router
.assert_metrics_contains(
r#"apollo_router_cache_redis_commands_executed_total{kind="entity",otel_scope_name="apollo/router"} 17"#,
r#"apollo_router_cache_redis_commands_executed_total{kind="entity",otel_scope_name="apollo/router"} 16"#,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how'd we lose a command invocation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent a while on this and couldn't figure it out 💀

I think the 17 was deduced by running the test and seeing what value it spit out, as I'm not sure how you'd get 17 from 7 queries either?

I ended up deciding to set it aside since the other tests show the insert is working, but I definitely would love others' hypotheses on this.

None,
)
.await;
Expand Down
31 changes: 31 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,34 @@ services:
read_only: true
ports:
- 8126:8126

# redis cluster

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the only part of the pr that I'm sort of hesitant about; I don't think it's something to solve here (maybe I can take what's here and iterate on it), but it'd be super nice to not run both standalone redis and clustered redis (my office is in the attic and gets too warm already without having docker run more stuff)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! Personally I think it'd be better to only use clustered Redis, but I didn't want to make that change everywhere as part of this PR in case others disagree.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whew, spent some time on this and everything is messy apart from just running both!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it might be good to do something like this in the future, where we test against both clustered and non-clustered redis! ie parameterize the tests and use rstest to run against both:

#[tokio::test]
#[rstest::rstest]
async fn multiple_documents(
    #[values(true, false)] clustered: bool,
) -> Result<(), BoxError> {
    let config = redis_config(clustered);
    todo!()
}

redis-cluster-7000:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7000", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-7001:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7001", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-7002:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7002", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-7003:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7003", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-7004:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7004", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-7005:
image: cimg/redis:7.4.5
command: [ "redis-server", "--protected-mode", "no", "--port", "7005", "--cluster-enabled", "yes" ]
network_mode: host
redis-cluster-startup:
image: cimg/redis:7.4.5
command: [ "sh", "-c", "sleep 30; echo yes | redis-cli --cluster create --cluster-replicas 1 localhost:7000 localhost:7001 localhost:7002 localhost:7003 localhost:7004 localhost:7005" ]
network_mode: host