This is an automated email from the ASF dual-hosted git repository.
viirya pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-connect-gateway.git
The following commit(s) were added to refs/heads/main by this push:
new cf716c0 [SPARK-59046] Add scg-store-redis and scg-healthcheck crates
cf716c0 is described below
commit cf716c0b8d05d40270997501cbb4e54f0b341cbe
Author: Liang-Chi Hsieh <[email protected]>
AuthorDate: Thu Aug 27 23:02:29 2026 -0700
[SPARK-59046] Add scg-store-redis and scg-healthcheck crates
### What changes were proposed in this pull request?
Part of importing the Spark Connect Gateway reference implementation into
`apache/spark-connect-gateway` as a series of small, bottom-up PRs (tracked
in
the do-not-merge reference PR #1).
This adds two more crates that implement/wrap the `scg-routing` traits,
grouped
into one PR:
- `scg-store-redis` — a Redis-backed `AffinityStore` for cross-replica HA
(the
multi-replica counterpart to the in-process `scg-store-memory`).
- `scg-healthcheck` — active gRPC health probing wrapped around a `Pool`,
evicting unhealthy backends from routing.
Both depend on the `scg-routing` crate. They compile and test on their own;
store-redis's Redis integration tests are `#[ignore]` so they compile but
don't
require Docker in CI. Both are appended to the workspace `members` list and
are
consumed later by the `proxy` crate (both are proxy dev-dependencies used
by its
examples/tests). With this PR, all four proxy dev-dependency crates
(store-memory, pool-static, store-redis, healthcheck) are in place, so the
proxy
PR can follow.
### Why are the changes needed?
Implements the reference gateway from the approved SPIP (SPARK-58455),
imported
one reviewable piece at a time.
### Does this PR introduce _any_ user-facing change?
No.
### How was this patch tested?
`cargo fmt --all --check`, `cargo clippy --workspace --all-targets -- -D
warnings`,
`cargo build --workspace`, and `cargo test --workspace` all pass locally
(Redis integration tests are `#[ignore]`, so no Docker is required in CI).
### Was this patch authored or co-authored using generative AI tooling?
Yes, co-authored with Claude Code.
Closes #12 from viirya/import-09-store-redis-healthcheck.
Authored-by: Liang-Chi Hsieh <[email protected]>
Signed-off-by: Liang-Chi Hsieh <[email protected]>
---
Cargo.lock | 42 +++
Cargo.toml | 2 +
crates/healthcheck/Cargo.toml | 40 +++
crates/healthcheck/src/lib.rs | 377 ++++++++++++++++++++++++++
crates/healthcheck/tests/probe_eviction.rs | 141 ++++++++++
crates/store-redis/Cargo.toml | 39 +++
crates/store-redis/examples/smoke.rs | 125 +++++++++
crates/store-redis/src/lib.rs | 233 ++++++++++++++++
crates/store-redis/tests/redis_integration.rs | 183 +++++++++++++
9 files changed, 1182 insertions(+)
diff --git a/Cargo.lock b/Cargo.lock
index c59f45e..856f3d0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2304,6 +2304,20 @@ dependencies = [
"tonic-prost-build",
]
+[[package]]
+name = "scg-healthcheck"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "parking_lot",
+ "scg-routing",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tonic-health",
+ "tracing",
+]
+
[[package]]
name = "scg-observability"
version = "0.1.0"
@@ -2370,6 +2384,20 @@ dependencies = [
"tokio",
]
+[[package]]
+name = "scg-store-redis"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "redis",
+ "scg-routing",
+ "testcontainers-modules",
+ "thiserror 2.0.20",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "scg-tenant"
version = "0.1.0"
@@ -2932,6 +2960,7 @@ dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
+ "tokio-util",
]
[[package]]
@@ -2991,6 +3020,19 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "tonic-health"
+version = "0.14.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082"
+dependencies = [
+ "prost",
+ "tokio",
+ "tokio-stream",
+ "tonic",
+ "tonic-prost",
+]
+
[[package]]
name = "tonic-prost"
version = "0.14.6"
diff --git a/Cargo.toml b/Cargo.toml
index c527304..cc94747 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,8 @@ members = [
"crates/tenant",
"crates/store-memory",
"crates/pool-static",
+ "crates/store-redis",
+ "crates/healthcheck",
]
[workspace.package]
diff --git a/crates/healthcheck/Cargo.toml b/crates/healthcheck/Cargo.toml
new file mode 100644
index 0000000..94e52a8
--- /dev/null
+++ b/crates/healthcheck/Cargo.toml
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-healthcheck"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "Active gRPC health-check probing wrapper for the Spark Connect
Gateway's backend pools."
+publish = false
+
+[dependencies]
+scg-routing = { path = "../routing" }
+tokio = { workspace = true }
+tonic = { workspace = true }
+tonic-health = { workspace = true }
+parking_lot = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tokio-stream = { workspace = true }
+async-trait = { workspace = true }
+tonic-health = { workspace = true }
diff --git a/crates/healthcheck/src/lib.rs b/crates/healthcheck/src/lib.rs
new file mode 100644
index 0000000..e8722b7
--- /dev/null
+++ b/crates/healthcheck/src/lib.rs
@@ -0,0 +1,377 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Active gRPC health probing wrapped around a [`Pool`].
+//!
+//! The base [`scg_routing::Pool`] trait is purely round-robin: it
+//! hands out backends without knowing whether they actually answer.
+//! A backend pod that has wedged but not crashed (process alive,
+//! gRPC server not responding) will keep getting traffic until K8s
+//! kills the pod or the pool's source-of-truth (e.g. K8s
+//! Endpoints) reflects the failure.
+//!
+//! [`HealthAwarePool`] wraps any [`Pool`] and adds the
+//! [gRPC Health Check Protocol]:
+//!
+//! 1. A background task probes `grpc.health.v1.Health/Check` against
+//! every backend in `inner.all_healthy()` every `interval`.
+//! 2. After `unhealthy_threshold` consecutive failures, the backend
+//! is removed from `pick()` results.
+//! 3. Probes continue against the unhealthy set; after
+//! `healthy_threshold` consecutive successes the backend is
+//! re-admitted.
+//! 4. `pick()` retries up to `inner.all_healthy().len()` times if it
+//! draws an unhealthy backend, so traffic keeps flowing as long
+//! as anything is up.
+//!
+//! [gRPC Health Check Protocol]:
https://github.com/grpc/grpc/blob/master/doc/health-checking.md
+
+use std::collections::HashMap;
+use std::sync::Arc;
+use std::time::Duration;
+
+use parking_lot::RwLock;
+use scg_routing::{BackendMember, Pool};
+use tonic_health::pb::health_client::HealthClient;
+use tonic_health::pb::HealthCheckRequest;
+use tonic_health::ServingStatus;
+use tracing::{debug, info, warn};
+
+/// Per-backend health-check tuning. Defaults are chosen for the
+/// "thousands of backends, sub-second tail" world we're not in:
+/// fast enough to evict a wedged pod within ~15s, slow enough not to
+/// pummel the backends.
+#[derive(Debug, Clone)]
+pub struct HealthCheckConfig {
+ pub interval: Duration,
+ pub timeout: Duration,
+ /// Consecutive failures required to mark a backend unhealthy.
+ pub unhealthy_threshold: u32,
+ /// Consecutive successes required to re-admit a backend.
+ pub healthy_threshold: u32,
+}
+
+impl Default for HealthCheckConfig {
+ fn default() -> Self {
+ Self {
+ interval: Duration::from_secs(5),
+ timeout: Duration::from_secs(2),
+ unhealthy_threshold: 3,
+ healthy_threshold: 2,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum HealthState {
+ Healthy,
+ Unhealthy,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct BackendStatus {
+ state: HealthState,
+ /// Counter resets on transition: while Healthy, counts consecutive
+ /// failures; while Unhealthy, counts consecutive successes.
+ streak: u32,
+}
+
+impl BackendStatus {
+ fn fresh() -> Self {
+ // New backends are presumed healthy. We only stop trusting
+ // them once we've seen `unhealthy_threshold` failures —
+ // otherwise a momentary connection blip during the *first*
+ // probe would falsely evict every backend on startup.
+ Self {
+ state: HealthState::Healthy,
+ streak: 0,
+ }
+ }
+}
+
+/// `Pool` adapter that filters its inner pool's `members()` by an
+/// actively-probed health view. Placement over the filtered members
+/// stays with the selection strategy — this wrapper only decides who
+/// is a candidate at all.
+pub struct HealthAwarePool {
+ inner: Arc<dyn Pool>,
+ cfg: HealthCheckConfig,
+ /// Map of `host:port` -> current health status. Backends that
+ /// the inner pool no longer reports are GC'd on each probe round.
+ statuses: RwLock<HashMap<String, BackendStatus>>,
+}
+
+impl HealthAwarePool {
+ /// Wrap `inner` with active health checking using `cfg`. The
+ /// returned pool is immediately usable; spawn the probe task with
+ /// [`HealthAwarePool::spawn_probe`] to start mutating the health
+ /// view.
+ pub fn new(inner: Arc<dyn Pool>, cfg: HealthCheckConfig) -> Arc<Self> {
+ Arc::new(Self {
+ inner,
+ cfg,
+ statuses: RwLock::new(HashMap::new()),
+ })
+ }
+
+ /// Spawn the background probe loop. Returns a `JoinHandle` that
+ /// callers can `abort()` on shutdown; dropping the handle does
+ /// *not* stop the probes (the task holds an `Arc<Self>`).
+ pub fn spawn_probe(self: &Arc<Self>) -> tokio::task::JoinHandle<()> {
+ let me = Arc::clone(self);
+ tokio::spawn(async move {
+ let mut tick = tokio::time::interval(me.cfg.interval);
+ // Burst-tolerant: skip ticks if a probe round took longer
+ // than `interval` rather than queueing them up.
+
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ loop {
+ tick.tick().await;
+ me.probe_all().await;
+ }
+ })
+ }
+
+ /// One probe round: fetch the inner pool's full backend list,
+ /// dial each in parallel, update each status entry, and GC
+ /// backends the inner pool no longer knows about.
+ async fn probe_all(&self) {
+ let backends: Vec<String> = self.inner.members().into_iter().map(|m|
m.addr).collect();
+ if backends.is_empty() {
+ // Nothing to probe; clear the status map so a restart
+ // doesn't carry over stale entries.
+ self.statuses.write().clear();
+ return;
+ }
+
+ let mut tasks = Vec::with_capacity(backends.len());
+ for addr in &backends {
+ let addr = addr.clone();
+ let timeout = self.cfg.timeout;
+ tasks.push(tokio::spawn(async move {
+ let ok = probe_one(&addr, timeout).await;
+ (addr, ok)
+ }));
+ }
+
+ let mut results = Vec::with_capacity(tasks.len());
+ for t in tasks {
+ if let Ok(r) = t.await {
+ results.push(r);
+ }
+ }
+
+ // Now apply. Hold the write lock for the whole apply so
+ // pick() / all_healthy() see a consistent view.
+ let mut g = self.statuses.write();
+ // GC: drop entries no longer present in `backends`.
+ let live: std::collections::HashSet<&str> =
backends.iter().map(String::as_str).collect();
+ g.retain(|k, _| live.contains(k.as_str()));
+
+ for (addr, ok) in results {
+ let entry =
g.entry(addr.clone()).or_insert_with(BackendStatus::fresh);
+ apply_probe(entry, ok, &self.cfg, &addr);
+ }
+ }
+
+ fn healthy_members(&self) -> Vec<BackendMember> {
+ let g = self.statuses.read();
+ // If a backend is in `inner.members()` but not yet in our
+ // status map, treat it as healthy (default). This avoids a
+ // gap on the first probe round where everything would look
+ // unhealthy.
+ let inner = self.inner.members();
+ let mut out = Vec::with_capacity(inner.len());
+ for m in inner {
+ match g.get(&m.addr) {
+ Some(s) if s.state == HealthState::Unhealthy => continue,
+ _ => out.push(m),
+ }
+ }
+ out
+ }
+}
+
+impl Pool for HealthAwarePool {
+ fn members(&self) -> Vec<BackendMember> {
+ self.healthy_members()
+ }
+
+ fn mark_unhealthy(&self, addr: &str) {
+ // Caller observed a forward error against `addr`. We don't
+ // immediately evict — that would race with the probe loop
+ // and risk oscillation — but we *do* prime the streak so the
+ // next probe failure flips us over the threshold faster.
+ let mut g = self.statuses.write();
+ if let Some(status) = g.get_mut(addr) {
+ if status.state == HealthState::Healthy {
+ status.streak = status.streak.saturating_add(1);
+ debug!(%addr, streak = status.streak, "healthcheck: handler
reported error");
+ }
+ }
+ self.inner.mark_unhealthy(addr);
+ }
+}
+
+/// Open a one-shot connection and call `Health.Check`. We don't
+/// reuse channels here on purpose — a wedged backend may have an
+/// open TCP connection that hangs forever; tearing it down per probe
+/// keeps us honest.
+async fn probe_one(addr: &str, timeout: Duration) -> bool {
+ let url = if addr.starts_with("http://") || addr.starts_with("https://") {
+ addr.to_string()
+ } else {
+ format!("http://{}", addr)
+ };
+ let endpoint = match tonic::transport::Endpoint::from_shared(url) {
+ Ok(e) => e.connect_timeout(timeout).timeout(timeout),
+ Err(e) => {
+ warn!(%addr, error = %e, "healthcheck: invalid endpoint URL");
+ return false;
+ }
+ };
+ let channel = match endpoint.connect().await {
+ Ok(c) => c,
+ Err(e) => {
+ debug!(%addr, error = %e, "healthcheck: connect failed");
+ return false;
+ }
+ };
+ let mut client = HealthClient::new(channel);
+ // Empty service name = "the entire backend." Spark Connect's
+ // tonic server registers Health under the standard convention.
+ let req = HealthCheckRequest {
+ service: String::new(),
+ };
+ match client.check(req).await {
+ Ok(resp) => {
+ let status = resp.into_inner().status;
+ // ServingStatus::Serving == healthy.
+ status == ServingStatus::Serving as i32
+ }
+ Err(e) => {
+ // Backend may be a Spark Connect server without Health
+ // registered. We treat NOT_FOUND / UNIMPLEMENTED as an
+ // ambiguous signal and keep the backend healthy — the
+ // alternative is evicting every backend that doesn't ship
+ // grpc.health.v1, which would be a regression for older
+ // Spark versions.
+ match e.code() {
+ tonic::Code::NotFound | tonic::Code::Unimplemented => {
+ debug!(%addr, "healthcheck: backend has no Health service;
treating as healthy");
+ true
+ }
+ _ => {
+ debug!(%addr, error = %e, "healthcheck: probe failed");
+ false
+ }
+ }
+ }
+ }
+}
+
+/// Update one `BackendStatus` based on the latest probe outcome.
+fn apply_probe(status: &mut BackendStatus, ok: bool, cfg: &HealthCheckConfig,
addr: &str) {
+ match (status.state, ok) {
+ (HealthState::Healthy, true) => {
+ status.streak = 0;
+ }
+ (HealthState::Healthy, false) => {
+ status.streak = status.streak.saturating_add(1);
+ if status.streak >= cfg.unhealthy_threshold {
+ info!(%addr, "healthcheck: backend marked UNHEALTHY");
+ status.state = HealthState::Unhealthy;
+ status.streak = 0;
+ }
+ }
+ (HealthState::Unhealthy, true) => {
+ status.streak = status.streak.saturating_add(1);
+ if status.streak >= cfg.healthy_threshold {
+ info!(%addr, "healthcheck: backend back to HEALTHY");
+ status.state = HealthState::Healthy;
+ status.streak = 0;
+ }
+ }
+ (HealthState::Unhealthy, false) => {
+ status.streak = 0;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn cfg(unhealthy: u32, healthy: u32) -> HealthCheckConfig {
+ HealthCheckConfig {
+ unhealthy_threshold: unhealthy,
+ healthy_threshold: healthy,
+ ..Default::default()
+ }
+ }
+
+ #[test]
+ fn streak_to_unhealthy_then_back() {
+ let mut s = BackendStatus::fresh();
+ let c = cfg(3, 2);
+
+ // Two failures: still healthy.
+ apply_probe(&mut s, false, &c, "x:1");
+ apply_probe(&mut s, false, &c, "x:1");
+ assert_eq!(s.state, HealthState::Healthy);
+
+ // Third: flip.
+ apply_probe(&mut s, false, &c, "x:1");
+ assert_eq!(s.state, HealthState::Unhealthy);
+
+ // One success: not enough.
+ apply_probe(&mut s, true, &c, "x:1");
+ assert_eq!(s.state, HealthState::Unhealthy);
+
+ // Second success: re-admitted.
+ apply_probe(&mut s, true, &c, "x:1");
+ assert_eq!(s.state, HealthState::Healthy);
+ }
+
+ #[test]
+ fn intermittent_failure_resets_streak() {
+ let mut s = BackendStatus::fresh();
+ let c = cfg(3, 2);
+ apply_probe(&mut s, false, &c, "x:1");
+ apply_probe(&mut s, false, &c, "x:1");
+ // One success in the middle resets the failure streak.
+ apply_probe(&mut s, true, &c, "x:1");
+ apply_probe(&mut s, false, &c, "x:1");
+ assert_eq!(s.state, HealthState::Healthy);
+ }
+
+ #[test]
+ fn intermittent_success_resets_recovery_streak() {
+ let mut s = BackendStatus {
+ state: HealthState::Unhealthy,
+ streak: 0,
+ };
+ let c = cfg(3, 3);
+ apply_probe(&mut s, true, &c, "x:1");
+ apply_probe(&mut s, true, &c, "x:1");
+ // One failure resets the recovery streak.
+ apply_probe(&mut s, false, &c, "x:1");
+ apply_probe(&mut s, true, &c, "x:1");
+ apply_probe(&mut s, true, &c, "x:1");
+ // Still need one more success to re-admit (threshold=3).
+ assert_eq!(s.state, HealthState::Unhealthy);
+ apply_probe(&mut s, true, &c, "x:1");
+ assert_eq!(s.state, HealthState::Healthy);
+ }
+}
diff --git a/crates/healthcheck/tests/probe_eviction.rs
b/crates/healthcheck/tests/probe_eviction.rs
new file mode 100644
index 0000000..d997641
--- /dev/null
+++ b/crates/healthcheck/tests/probe_eviction.rs
@@ -0,0 +1,141 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Integration test for [`HealthAwarePool`] against real gRPC
+//! servers. Verifies that:
+//!
+//! 1. A backend whose `Health` server is alive stays in
+//! `members()`.
+//! 2. A backend that goes down (server task dropped) is evicted
+//! after `unhealthy_threshold` consecutive failed probes.
+//! 3. `members()` keeps reporting only the surviving backend.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use scg_healthcheck::{HealthAwarePool, HealthCheckConfig};
+use scg_routing::{BackendMember, Pool};
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio_stream::wrappers::TcpListenerStream;
+use tonic::transport::Server;
+use tonic_health::server::health_reporter;
+
+/// Static pool stub: returns whatever vec we feed it. We rebuild it
+/// for each test so we control which backends "exist" from the
+/// inner pool's perspective; the wrapper's job is to filter by
+/// health.
+struct StubPool {
+ backends: parking_lot::RwLock<Vec<String>>,
+}
+
+impl StubPool {
+ fn new(backends: Vec<String>) -> Arc<Self> {
+ Arc::new(Self {
+ backends: parking_lot::RwLock::new(backends),
+ })
+ }
+}
+
+impl Pool for StubPool {
+ fn members(&self) -> Vec<BackendMember> {
+ self.backends
+ .read()
+ .iter()
+ .map(BackendMember::new)
+ .collect()
+ }
+}
+
+/// Spawn a gRPC server with the standard `Health` service registered
+/// and reporting `Serving`. Returns its host:port and a shutdown
+/// sender.
+async fn spawn_health_server() -> (String, oneshot::Sender<()>) {
+ let (reporter, health_svc) = health_reporter();
+ // Empty service name = "the entire backend"; our probe code uses
+ // the same convention.
+ reporter
+ .set_service_status("", tonic_health::ServingStatus::Serving)
+ .await;
+
+ let lis = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = lis.local_addr().unwrap().to_string();
+ let (tx, rx) = oneshot::channel();
+ tokio::spawn(async move {
+ Server::builder()
+ .add_service(health_svc)
+ .serve_with_incoming_shutdown(TcpListenerStream::new(lis), async {
+ let _ = rx.await;
+ })
+ .await
+ .ok();
+ });
+ (addr, tx)
+}
+
+#[tokio::test]
+async fn evicts_dead_backend_then_keeps_alive_one() {
+ let (alive, _alive_kill) = spawn_health_server().await;
+ let (dying, dying_kill) = spawn_health_server().await;
+
+ let stub = StubPool::new(vec![alive.clone(), dying.clone()]);
+ // Aggressive: 200ms interval, 200ms timeout, 2-failure eviction
+ // → eviction of `dying` should land within ~600ms after kill.
+ let cfg = HealthCheckConfig {
+ interval: Duration::from_millis(200),
+ timeout: Duration::from_millis(300),
+ unhealthy_threshold: 2,
+ healthy_threshold: 2,
+ };
+ let wrapper = HealthAwarePool::new(stub.clone() as Arc<dyn Pool>, cfg);
+ let _probe = wrapper.spawn_probe();
+
+ // Let one probe round complete; both should still be healthy.
+ tokio::time::sleep(Duration::from_millis(400)).await;
+ let healthy: Vec<String> = wrapper.members().into_iter().map(|m|
m.addr).collect();
+ assert!(healthy.contains(&alive), "alive backend should be present");
+ assert!(
+ healthy.contains(&dying),
+ "dying backend should still be present pre-kill"
+ );
+
+ // Kill `dying`. Give the OS a moment to actually tear the
+ // listener down before probes start failing.
+ let _ = dying_kill.send(());
+ tokio::time::sleep(Duration::from_millis(200)).await;
+
+ // Wait for the wrapper to evict it. With 200ms interval and
+ // unhealthy_threshold=2, we expect eviction within ~600ms.
+ let mut evicted = false;
+ for _ in 0..30 {
+ let healthy: Vec<String> = wrapper.members().into_iter().map(|m|
m.addr).collect();
+ if !healthy.contains(&dying) && healthy.contains(&alive) {
+ evicted = true;
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ assert!(
+ evicted,
+ "dying backend should be evicted within the test window"
+ );
+
+ // members() must keep reporting exactly the surviving backend —
+ // any selection strategy applied on top can only choose it.
+ for _ in 0..5 {
+ let m: Vec<String> = wrapper.members().into_iter().map(|x|
x.addr).collect();
+ assert_eq!(m, vec![alive.clone()]);
+ }
+}
diff --git a/crates/store-redis/Cargo.toml b/crates/store-redis/Cargo.toml
new file mode 100644
index 0000000..0f646e2
--- /dev/null
+++ b/crates/store-redis/Cargo.toml
@@ -0,0 +1,39 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+[package]
+name = "scg-store-redis"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+repository.workspace = true
+readme.workspace = true
+keywords.workspace = true
+categories.workspace = true
+description = "Redis-backed affinity store for cross-replica Spark Connect
Gateway HA."
+publish = false
+
+[dependencies]
+scg-routing = { path = "../routing" }
+async-trait = { workspace = true }
+redis = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+thiserror = { workspace = true }
+anyhow = { workspace = true }
+
+[dev-dependencies]
+testcontainers-modules = { workspace = true }
diff --git a/crates/store-redis/examples/smoke.rs
b/crates/store-redis/examples/smoke.rs
new file mode 100644
index 0000000..bc3ac31
--- /dev/null
+++ b/crates/store-redis/examples/smoke.rs
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Smoke test against a real local Redis. Same scenarios as the
+//! testcontainers-backed integration tests but without Docker — when
+//! Docker Hub is unreachable, run a Redis on `:6399` (e.g.
+//! `redis-server --port 6399 --daemonize yes`) and execute:
+//!
+//! ```bash
+//! cargo run -p scg-store-redis --example smoke
+//! ```
+//!
+//! Exits with non-zero status if any invariant fails so CI / scripts
+//! can wrap it.
+
+use std::time::Duration;
+
+use scg_routing::{AffinityStore, SessionKey};
+use scg_store_redis::{RedisStore, RedisStoreConfig};
+
+#[tokio::main]
+async fn main() {
+ let url = std::env::var("REDIS_URL").unwrap_or_else(|_|
"redis://127.0.0.1:6399".into());
+ eprintln!("[smoke] connecting to {}", url);
+
+ // Use a unique key prefix per run so re-runs don't pollute each other.
+ let prefix = format!("scg-smoke-{}", std::process::id());
+ let cfg = RedisStoreConfig {
+ url,
+ key_prefix: prefix,
+ session_ttl: Duration::from_secs(30),
+ op_ttl: Duration::from_secs(30),
+ };
+ let store = RedisStore::connect(cfg.clone())
+ .await
+ .expect("connect to redis");
+
+ // 1. Bind / resolve / forget roundtrip.
+ let k = SessionKey::new("alice", "sess-1");
+ assert!(store.lookup_session(&k).await.is_none(), "fresh key");
+ let bound = store
+ .bind_session_if_absent(k.clone(), "be-a:15002".into())
+ .await;
+ assert_eq!(bound, "be-a:15002");
+ assert_eq!(
+ store.lookup_session(&k).await.as_deref(),
+ Some("be-a:15002")
+ );
+ eprintln!("[smoke] ok: bind+resolve roundtrip");
+
+ // 2. Atomicity / stickiness invariant.
+ let second = store
+ .bind_session_if_absent(k.clone(), "be-b:1".into())
+ .await;
+ assert_eq!(
+ second, "be-a:15002",
+ "stickiness invariant: first write wins"
+ );
+ eprintln!("[smoke] ok: bind_session_if_absent stickiness");
+
+ // 3. Op-id reverse index.
+ assert!(store.lookup_op("op-1").await.is_none());
+ store.bind_op("op-1".into(), "be-a:15002".into()).await;
+ assert_eq!(store.lookup_op("op-1").await.as_deref(), Some("be-a:15002"));
+ store.forget_op("op-1").await;
+ assert!(store.lookup_op("op-1").await.is_none());
+ eprintln!("[smoke] ok: op-id reverse index");
+
+ // 4. Two stores against the same Redis observe each other's writes
+ // — the multi-replica claim.
+ let store_b = RedisStore::connect(cfg.clone()).await.expect("connect b");
+ let k2 = SessionKey::new("alice", "shared");
+ let _ = store
+ .bind_session_if_absent(k2.clone(), "be-x:1".into())
+ .await;
+ assert_eq!(
+ store_b.lookup_session(&k2).await.as_deref(),
+ Some("be-x:1"),
+ "replica B must see replica A's binding"
+ );
+ let race = store_b
+ .bind_session_if_absent(k2.clone(), "be-y:1".into())
+ .await;
+ assert_eq!(race, "be-x:1", "replica A's earlier write wins");
+ eprintln!("[smoke] ok: cross-replica state visibility + atomicity");
+
+ // 5. TTL expiry — short TTL store.
+ let short_cfg = RedisStoreConfig {
+ session_ttl: Duration::from_secs(1),
+ op_ttl: Duration::from_secs(1),
+ key_prefix: format!("scg-smoke-ttl-{}", std::process::id()),
+ ..cfg.clone()
+ };
+ let short = RedisStore::connect(short_cfg).await.expect("connect short");
+ let k3 = SessionKey::new("u", "ttl");
+ short
+ .bind_session_if_absent(k3.clone(), "be-z:1".into())
+ .await;
+ assert!(short.lookup_session(&k3).await.is_some());
+ tokio::time::sleep(Duration::from_millis(1500)).await;
+ assert!(
+ short.lookup_session(&k3).await.is_none(),
+ "TTL-bound key must expire"
+ );
+ eprintln!("[smoke] ok: TTL expiry");
+
+ // Cleanup — forget the persistent keys so the Redis instance stays
+ // tidy for the next smoke run on a shared host.
+ store.forget_session(&k).await;
+ store.forget_session(&k2).await;
+
+ println!("[smoke] all invariants passed");
+}
diff --git a/crates/store-redis/src/lib.rs b/crates/store-redis/src/lib.rs
new file mode 100644
index 0000000..5889834
--- /dev/null
+++ b/crates/store-redis/src/lib.rs
@@ -0,0 +1,233 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Redis-backed [`AffinityStore`] for cross-replica HA.
+//!
+//! The sibling [`scg-store-memory::MemoryStore`] only works when the
+//! gateway runs as a single replica. When operators want to scale
+//! out (Helm chart with `replicas > 1` and `kind: Deployment`), the
+//! affinity table needs to live somewhere all replicas can see —
+//! otherwise a Spark Connect session pinned to backend `B` by replica
+//! 1 will be re-pinned to a different backend by replica 2 the next
+//! time the client lands on it, breaking the
+//! `(user_id, session_id) -> backend` invariant.
+//!
+//! Schema in Redis:
+//!
+//! * `{prefix}:s:{user_id}|{session_id}` -> backend address. TTL
+//! refreshed on every successful resolve, so idle sessions
+//! eventually fall out and let the gateway re-pick.
+//! * `{prefix}:o:{op_id}` -> backend address. TTL is independent and
+//! typically shorter — operations are bounded by the lifetime of an
+//! `ExecutePlan` invocation.
+//!
+//! Concurrency invariant for sticky routing relies on `SET ... NX
+//! EX`: if two gateway replicas race to bind the same `(user_id,
+//! session_id)`, exactly one `SET` succeeds; the other reads the
+//! winner's value back and returns it from `bind_session_if_absent`.
+//!
+//! Error policy: this layer does not propagate Redis failures up the
+//! `AffinityStore` trait (the trait is intentionally infallible to
+//! keep `Router` simple). On a Redis outage, lookups return `None`
+//! and binds quietly drop, which makes the gateway degrade to
+//! pool-only routing — sessions land on whatever backend the pool
+//! picks each time, the same behaviour you'd see with no affinity
+//! cache at all. We log a `warn!` so the outage shows up in logs.
+
+use std::time::Duration;
+
+use async_trait::async_trait;
+use redis::aio::ConnectionManager;
+use redis::AsyncCommands;
+use scg_routing::{AffinityStore, SessionKey};
+use tokio::sync::Mutex;
+use tracing::warn;
+
+#[derive(Debug, thiserror::Error)]
+pub enum RedisStoreError {
+ #[error("opening redis client: {0}")]
+ Open(#[from] redis::RedisError),
+}
+
+/// Configuration for [`RedisStore`].
+#[derive(Debug, Clone)]
+pub struct RedisStoreConfig {
+ /// `redis://` URL (e.g. `redis://localhost:6379` or
+ /// `redis://:password@host:6379/2`).
+ pub url: String,
+ /// Key prefix; all keys live under `{prefix}:s:` and `{prefix}:o:`.
+ /// Lets multiple gateway deployments share a Redis without
+ /// colliding.
+ pub key_prefix: String,
+ /// TTL for session bindings. Refreshed on every read. Pick a value
+ /// noticeably longer than typical client idle timeouts so
+ /// reconnecting clients keep their stickiness.
+ pub session_ttl: Duration,
+ /// TTL for op-id bindings. Operations are bounded by their
+ /// `ExecutePlan` lifetime; a few minutes is usually plenty.
+ pub op_ttl: Duration,
+}
+
+impl Default for RedisStoreConfig {
+ fn default() -> Self {
+ Self {
+ url: "redis://127.0.0.1:6379".into(),
+ key_prefix: "scg".into(),
+ session_ttl: Duration::from_secs(60 * 60), // 1h
+ op_ttl: Duration::from_secs(15 * 60), // 15min
+ }
+ }
+}
+
+pub struct RedisStore {
+ conn: Mutex<ConnectionManager>,
+ cfg: RedisStoreConfig,
+}
+
+impl RedisStore {
+ /// Open a connection to Redis. On a successful return the
+ /// `ConnectionManager` is alive but lazy — actual TCP work
+ /// happens on the first command. Failures here are usually
+ /// configuration / DNS errors, not "Redis is down".
+ pub async fn connect(cfg: RedisStoreConfig) -> Result<Self,
RedisStoreError> {
+ let client = redis::Client::open(cfg.url.clone())?;
+ let conn = ConnectionManager::new(client).await?;
+ Ok(Self {
+ conn: Mutex::new(conn),
+ cfg,
+ })
+ }
+
+ fn session_key(&self, k: &SessionKey) -> String {
+ // {prefix}:s:{tenant}|{user_id}|{session_id}
+ format!(
+ "{}:s:{}|{}|{}",
+ self.cfg.key_prefix, k.tenant, k.user_id, k.session_id
+ )
+ }
+
+ fn op_key(&self, op_id: &str) -> String {
+ format!("{}:o:{}", self.cfg.key_prefix, op_id)
+ }
+}
+
+#[async_trait]
+impl AffinityStore for RedisStore {
+ async fn lookup_session(&self, key: &SessionKey) -> Option<String> {
+ let k = self.session_key(key);
+ let mut conn = self.conn.lock().await;
+ // GET + EXPIRE in two round-trips. Could be combined with
+ // GETEX (Redis 6.2+) once we want to drop the round-trip.
+ match conn.get::<_, Option<String>>(&k).await {
+ Ok(Some(v)) => {
+ let _: Result<bool, _> =
+ conn.expire(&k, self.cfg.session_ttl.as_secs() as
i64).await;
+ Some(v)
+ }
+ Ok(None) => None,
+ Err(e) => {
+ warn!(error = %e, key = %k, "redis: lookup_session failed;
treating as miss");
+ None
+ }
+ }
+ }
+
+ async fn bind_session_if_absent(&self, key: SessionKey, backend: String)
-> String {
+ let k = self.session_key(&key);
+ let mut conn = self.conn.lock().await;
+ let ttl_secs = self.cfg.session_ttl.as_secs();
+ // SET NX EX — atomic create-if-absent with TTL.
+ match redis::cmd("SET")
+ .arg(&k)
+ .arg(&backend)
+ .arg("NX")
+ .arg("EX")
+ .arg(ttl_secs)
+ .query_async::<Option<String>>(&mut *conn)
+ .await
+ {
+ Ok(Some(_)) => backend, // SET returned "OK" → we won the race.
+ Ok(None) => {
+ // SET returned nil → key already existed; read it.
+ match conn.get::<_, Option<String>>(&k).await {
+ Ok(Some(existing)) => existing,
+ // The race-tie window where the existing binding
+ // expired between SET NX and GET is rare enough to
+ // accept the rebind; just return our intended value.
+ Ok(None) => backend,
+ Err(e) => {
+ warn!(error = %e, key = %k, "redis: GET after SET-NX
miss failed; rebinding");
+ backend
+ }
+ }
+ }
+ Err(e) => {
+ warn!(error = %e, key = %k, "redis: bind_session_if_absent
failed; degrading to pool pick");
+ // Redis is down. The router will use the backend we
+ // were handed (the pool's pick); subsequent calls
+ // will pick again until Redis recovers.
+ backend
+ }
+ }
+ }
+
+ async fn forget_session(&self, key: &SessionKey) {
+ let k = self.session_key(key);
+ let mut conn = self.conn.lock().await;
+ if let Err(e) = conn.del::<_, i64>(&k).await {
+ warn!(error = %e, key = %k, "redis: forget_session failed");
+ }
+ }
+
+ async fn lookup_op(&self, op_id: &str) -> Option<String> {
+ let k = self.op_key(op_id);
+ let mut conn = self.conn.lock().await;
+ match conn.get::<_, Option<String>>(&k).await {
+ Ok(Some(v)) => {
+ let _: Result<bool, _> = conn.expire(&k,
self.cfg.op_ttl.as_secs() as i64).await;
+ Some(v)
+ }
+ Ok(None) => None,
+ Err(e) => {
+ warn!(error = %e, key = %k, "redis: lookup_op failed; treating
as miss");
+ None
+ }
+ }
+ }
+
+ async fn bind_op(&self, op_id: String, backend: String) {
+ let k = self.op_key(&op_id);
+ let mut conn = self.conn.lock().await;
+ let ttl_secs = self.cfg.op_ttl.as_secs();
+ if let Err(e) = redis::cmd("SET")
+ .arg(&k)
+ .arg(&backend)
+ .arg("EX")
+ .arg(ttl_secs)
+ .query_async::<()>(&mut *conn)
+ .await
+ {
+ warn!(error = %e, key = %k, "redis: bind_op failed");
+ }
+ }
+
+ async fn forget_op(&self, op_id: &str) {
+ let k = self.op_key(op_id);
+ let mut conn = self.conn.lock().await;
+ if let Err(e) = conn.del::<_, i64>(&k).await {
+ warn!(error = %e, key = %k, "redis: forget_op failed");
+ }
+ }
+}
diff --git a/crates/store-redis/tests/redis_integration.rs
b/crates/store-redis/tests/redis_integration.rs
new file mode 100644
index 0000000..4371d22
--- /dev/null
+++ b/crates/store-redis/tests/redis_integration.rs
@@ -0,0 +1,183 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You under the Apache License, Version 2.0
+// (the "License"); you may not use this file except in compliance with
+// the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Integration tests for [`scg_store_redis::RedisStore`] against a
+//! real Redis spun up via testcontainers.
+//!
+//! These tests require a working Docker daemon. CI environments
+//! without Docker can either skip them (cargo test will simply error
+//! on container start, surfaced as an `Err` from `start()`) or run
+//! `cargo test -p scg-store-redis --features ...` once we add an
+//! explicit feature gate. For now we mark them `#[ignore]` so a
+//! plain `cargo test --workspace` doesn't fail in environments where
+//! Docker isn't around. Run them deliberately with:
+//!
+//! ```bash
+//! cargo test -p scg-store-redis -- --ignored
+//! ```
+
+use std::time::Duration;
+
+use scg_routing::{AffinityStore, SessionKey};
+use scg_store_redis::{RedisStore, RedisStoreConfig};
+use testcontainers_modules::{
+ redis::{Redis, REDIS_PORT},
+ testcontainers::{runners::AsyncRunner, ContainerAsync},
+};
+
+/// Spin up a Redis container and connect a fresh `RedisStore` to it.
+/// The returned `_node` keeps the container alive for the test
+/// duration; drop it (end of test) and the container goes away.
+async fn rig() -> (RedisStore, ContainerAsync<Redis>) {
+ let node = Redis::default().start().await.expect("start redis");
+ let host = node.get_host().await.expect("host");
+ let port = node
+ .get_host_port_ipv4(REDIS_PORT)
+ .await
+ .expect("host port");
+ let cfg = RedisStoreConfig {
+ url: format!("redis://{}:{}", host, port),
+ // Keep TTLs comfortable so the test doesn't race the clock.
+ session_ttl: Duration::from_secs(30),
+ op_ttl: Duration::from_secs(30),
+ ..Default::default()
+ };
+ let store = RedisStore::connect(cfg).await.expect("connect redis");
+ (store, node)
+}
+
+#[tokio::test]
+#[ignore = "requires Docker"]
+async fn session_bind_resolve_forget_roundtrip() {
+ let (store, _node) = rig().await;
+ let k = SessionKey::new("alice", "sess-1");
+
+ assert!(store.lookup_session(&k).await.is_none());
+ let bound = store
+ .bind_session_if_absent(k.clone(), "be-a:15002".into())
+ .await;
+ assert_eq!(bound, "be-a:15002");
+ assert_eq!(
+ store.lookup_session(&k).await.as_deref(),
+ Some("be-a:15002")
+ );
+
+ store.forget_session(&k).await;
+ assert!(store.lookup_session(&k).await.is_none());
+}
+
+#[tokio::test]
+#[ignore = "requires Docker"]
+async fn bind_session_if_absent_is_atomic() {
+ let (store, _node) = rig().await;
+ let k = SessionKey::new("alice", "sticky");
+
+ let first = store
+ .bind_session_if_absent(k.clone(), "be-a:1".into())
+ .await;
+ assert_eq!(first, "be-a:1");
+ // A second concurrent caller racing to bind a different value must
+ // observe the first winner — this is the stickiness invariant.
+ let second = store
+ .bind_session_if_absent(k.clone(), "be-b:1".into())
+ .await;
+ assert_eq!(second, "be-a:1");
+}
+
+#[tokio::test]
+#[ignore = "requires Docker"]
+async fn op_reverse_index_roundtrip() {
+ let (store, _node) = rig().await;
+ assert!(store.lookup_op("op-1").await.is_none());
+ store.bind_op("op-1".into(), "be-a:1".into()).await;
+ assert_eq!(store.lookup_op("op-1").await.as_deref(), Some("be-a:1"));
+ store.forget_op("op-1").await;
+ assert!(store.lookup_op("op-1").await.is_none());
+}
+
+#[tokio::test]
+#[ignore = "requires Docker"]
+async fn two_stores_against_same_redis_share_state() {
+ // The whole point of Redis state: replica A binds, replica B
+ // resolves to the same backend. Without this we have no HA.
+ let node = Redis::default().start().await.expect("start redis");
+ let host = node.get_host().await.expect("host");
+ let port = node
+ .get_host_port_ipv4(REDIS_PORT)
+ .await
+ .expect("host port");
+ let url = format!("redis://{}:{}", host, port);
+
+ let store_a = RedisStore::connect(RedisStoreConfig {
+ url: url.clone(),
+ ..Default::default()
+ })
+ .await
+ .expect("connect a");
+ let store_b = RedisStore::connect(RedisStoreConfig {
+ url,
+ ..Default::default()
+ })
+ .await
+ .expect("connect b");
+
+ let k = SessionKey::new("alice", "shared");
+ let a_bound = store_a
+ .bind_session_if_absent(k.clone(), "be-a:1".into())
+ .await;
+ assert_eq!(a_bound, "be-a:1");
+
+ // Replica B must see the same binding without ever calling bind.
+ assert_eq!(store_b.lookup_session(&k).await.as_deref(), Some("be-a:1"));
+
+ // And if replica B races to bind a different backend, replica A's
+ // earlier write wins.
+ let b_bound = store_b
+ .bind_session_if_absent(k.clone(), "be-b:1".into())
+ .await;
+ assert_eq!(b_bound, "be-a:1");
+}
+
+#[tokio::test]
+#[ignore = "requires Docker"]
+async fn session_ttl_expires() {
+ let node = Redis::default().start().await.expect("start redis");
+ let host = node.get_host().await.expect("host");
+ let port = node
+ .get_host_port_ipv4(REDIS_PORT)
+ .await
+ .expect("host port");
+ let store = RedisStore::connect(RedisStoreConfig {
+ url: format!("redis://{}:{}", host, port),
+ // 1-second TTL so this test stays under a few seconds.
+ session_ttl: Duration::from_secs(1),
+ op_ttl: Duration::from_secs(1),
+ ..Default::default()
+ })
+ .await
+ .expect("connect");
+
+ let k = SessionKey::new("u", "ttl");
+ store
+ .bind_session_if_absent(k.clone(), "be-a:1".into())
+ .await;
+ assert!(store.lookup_session(&k).await.is_some());
+
+ // Wait long enough for Redis to expire the key. Each lookup also
+ // refreshes the TTL, so we deliberately don't call lookup during
+ // the wait.
+ tokio::time::sleep(Duration::from_millis(1500)).await;
+ assert!(store.lookup_session(&k).await.is_none());
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]