Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2de0d11a10
|
||
|
|
0f50ca15af
|
No files matched your search
@@ -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.
|
||||
@@ -137,7 +137,7 @@
|
||||
<dependency>
|
||||
<groupId>org.assertj</groupId>
|
||||
<artifactId>assertj-core</artifactId>
|
||||
<version>3.27.3</version>
|
||||
<version>3.27.7</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
+107
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user