From 2de0d11a10901d6d9d754dda448aa5d6d754fe3e Mon Sep 17 00:00:00 2001 From: James Maes Date: Mon, 26 Oct 2026 07:58:17 -0500 Subject: [PATCH] test: verify shared sessions after Redis promotion Co-Authored-By: GPT-6 (Codex) --- docs/REDIS-PROMOTION-ACCEPTANCE.md | 50 ++++++++ .../SharedRedisSessionStoreProviderIT.java | 107 ++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 docs/REDIS-PROMOTION-ACCEPTANCE.md diff --git a/docs/REDIS-PROMOTION-ACCEPTANCE.md b/docs/REDIS-PROMOTION-ACCEPTANCE.md new file mode 100644 index 0000000..327ea5c --- /dev/null +++ b/docs/REDIS-PROMOTION-ACCEPTANCE.md @@ -0,0 +1,50 @@ +# Shared sessions during controlled Redis promotion + +Tracks [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301). This is a test of +the actual shared session provider, complementary to the connection and Pub/Sub +promotion fixture in Redis draft PR4. No production provider behavior changes. + +`SharedRedisSessionStoreProviderIT.replicatedSessionsAndRevocationsSurviveControlledPromotion` +uses a real Redis primary/replica pair and three separately configured QQQ +instances. One writes to the primary; two use the replica endpoint, with fixed +and sliding expiry settings. The test verifies: + +- Session data reaches the replica and writes to the read-only replica fail. +- A removed session is absent after a subsequent write reaches the replica. + This observation establishes that this test's deletion was replicated. +- After the old primary stops, its provider raises an infrastructure failure + rather than reporting a normal session miss. +- After promotion, the original session remains readable and the replicated + revocation remains absent. Touch/read-and-touch cannot recreate it. +- New sessions, cross-node deletion, sliding expiry and natural expiry work on + the promoted endpoint. Touch cannot recreate an expired session. + +Verification passed on October 26, 2026: 67 unit tests and 30 integration tests, +no failures/errors/skips, zero Checkstyle violations, and coverage gates passed. +The first full run encountered a replica read timeout during initial loading; +the fixture now retries connection failures only inside its bounded 15-second +initial replication wait. Post-startup assertions and runtime timeouts are +unchanged. The focused test also passed before that startup adjustment. +Containers, network, provider registrations and runtime pools belong to the +fixture and are cleaned up by it. The existing Maven wrapper from the core +checkout can run this repository's POM on Windows, where this checkout has no +`mvnw.cmd`. + +## Limits and remaining qualification + +The test deliberately waits for replication before stopping the primary. It +does not prove that unreplicated session creation, permission changes or logout +survive failover. Redis replication is asynchronous; recovered connectivity +alone does not establish durable authentication state. See the official +[replication documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/replication/). + +Before production failover support, qualify the actual host endpoint routing, +old-primary fencing, TLS/authentication, and the policy for lost or restored +authentication state. In particular, a logout lost before replication can leave +an older session copy on the promoted node; this fixture must not be cited as +proof that such a session cannot return. Presence generation rotation alone +does not invalidate session-store data. + +This uses an unchanged explicit replica endpoint and controlled promotion. It +does not implement native Sentinel discovery, Cluster routing or automatic +application reconfiguration, and does not qualify a deployed Kof22 cluster. diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java index 09f5cdc..9b2627c 100644 --- a/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java +++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java @@ -45,6 +45,113 @@ import static org.junit.jupiter.api.Assertions.*; *******************************************************************************/ class SharedRedisSessionStoreProviderIT { + /*************************************************************************** + ** Observed replicated sessions and revocations survive controlled promotion. + ** This does not claim survival of writes lost before asynchronous replication. + ***************************************************************************/ + @Test + void replicatedSessionsAndRevocationsSurviveControlledPromotion() throws Exception + { + QInstance original = QContext.getQInstance(); + try(var network = org.testcontainers.containers.Network.newNetwork(); + var primary = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withNetworkAliases("primary").withExposedPorts(6379); + var replica = new GenericContainer<>("redis:7.4-alpine").withNetwork(network).withExposedPorts(6379) + .withCommand("redis-server", "--replicaof", "primary", "6379")) + { + primary.start(); + replica.start(); + QInstance first = host(primary, "promotion", true); + QInstance second = host(replica, "promotion", false); + QInstance third = host(replica, "promotion", true); + var firstRuntime = new RedisRuntimeService(); + var secondRuntime = new RedisRuntimeService(); + var thirdRuntime = new RedisRuntimeService(); + try + { + firstRuntime.start(first); + secondRuntime.start(second); + thirdRuntime.start(third); + var a = QSessionStoreQBitContext.getProvider(first); + var b = QSessionStoreQBitContext.getProvider(second); + var c = QSessionStoreQBitContext.getProvider(third); + a.store("active", new QSession().withValue("owner", "alice"), Duration.ofSeconds(60)); + a.store("revoked", new QSession().withValue("owner", "bob"), Duration.ofSeconds(60)); + assertTimeoutPreemptively(Duration.ofSeconds(15), () -> + { + while(true) + { + try + { + if(b.load("revoked").isPresent()) + { + break; + } + } + catch(redis.clients.jedis.exceptions.JedisConnectionException initialSync) + { + // Initial replica loading can temporarily block reads; the outer deadline stays bounded. + } + Thread.sleep(25); + } + }); + assertThrows(RuntimeException.class, () -> b.store("replica-write", new QSession(), null)); + a.remove("revoked"); + // Seeing a later write proves this test's deletion reached the replica. + a.store("barrier", new QSession(), Duration.ofSeconds(60)); + assertTimeoutPreemptively(Duration.ofSeconds(5), () -> + { + while(b.load("barrier").isEmpty()) + { + Thread.sleep(25); + } + }); + assertTrue(b.load("revoked").isEmpty()); + primary.stop(); + assertThrows(RuntimeException.class, () -> a.load("active")); + var promoted = replica.execInContainer("redis-cli", "REPLICAOF", "NO", "ONE"); + assertEquals(0, promoted.getExitCode()); + assertEquals("OK", promoted.getStdout().trim()); + assertEquals("alice", b.load("active").orElseThrow().getValue("owner")); + assertTrue(c.loadAndTouch("revoked").isEmpty()); + c.touch("revoked"); + assertTrue(b.load("revoked").isEmpty()); + b.store("new", new QSession().withValue("owner", "carol"), Duration.ofSeconds(1)); + assertEquals("carol", c.loadAndTouch("new").orElseThrow().getValue("owner")); + var namespace = new RedisNamespace("test", "promotion", "authentication", "session-store"); + var client = RedisRuntimeService.connection(second, "sessions").client(); + assertTrue(client.pttl(namespace.key("new")) > 20000); + c.remove("active"); + assertTrue(b.load("active").isEmpty()); + b.store("expires", new QSession(), Duration.ofMillis(200)); + assertTimeoutPreemptively(Duration.ofSeconds(3), () -> + { + while(b.load("expires").isPresent()) + { + Thread.sleep(25); + } + }); + c.touch("expires"); + assertTrue(c.loadAndTouch("expires").isEmpty()); + } + finally + { + for(var runtime : List.of(firstRuntime, secondRuntime, thirdRuntime)) + { + runtime.stop(); + runtime.afterApplicationStop(); + } + for(var instance : List.of(first, second, third)) + { + QSessionStoreQBitContext.clear(instance); + QSessionStoreRegistry.getInstance(instance).clear(); + } + QContext.setQInstance(original); + } + } + } + + + /*************************************************************************** ** Separate nodes share one app's sessions while other apps remain isolated. ***************************************************************************/ -- 2.54.0