diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02fbeed..c20b215 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [Unreleased] - 0.1.0-SNAPSHOT
+## [Unreleased] - 0.1.0
+
+First published release. Version numbers are assigned by the shared Gitea
+Maven workflow from the branch or tag that publishes (see README "Releasing");
+the `revision` property on `develop` stays `0.1.0-SNAPSHOT` until the release
+merges back.
### Added
- In-memory session store provider with LRU eviction
@@ -15,3 +20,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Sliding expiration (configurable per-provider)
- Scheduled cleanup process for expired sessions
- QBit producer for declarative configuration
+- `SHARED_REDIS` provider: application-scoped shared sessions on the pool owned
+ by the optional `qbit-redis` QBit, with explicit deployment/application/
+ authentication-domain namespacing, bounded payloads, atomic GETEX touch, and a
+ runtime service that releases registrations on stop and rebuilds them on
+ restart (`docs/SESSION-REGISTRATION.md`)
+- Explicit `QInstance` overloads on `QSessionStoreQBitContext` and
+ application-scoped registration through
+ `QSessionStoreRegistry.getInstance(qInstance)`; two applications in one JVM
+ no longer see each other's sessions
+- Health check reports the target application selected by `HealthCheckInput`
+
+### Changed
+- Depends on published QQQ core `4.1.0-RC.22` (the first release-line core with
+ `QSessionStoreRegistry.getInstance(QInstance)`) and `qbit-redis 0.1.0-RC.1`;
+ the snapshot coordinates used while the shared-session work was in flight
+ (`4.1.0-collab-33464d37e-SNAPSHOT`, `0.1.0-redis-7fe5492-SNAPSHOT`) are gone
+- Jedis 7.2.0, Testcontainers 2.0.3, AssertJ 3.27.7 (GHSA-rqfh-9r24-8c9r)
+- Hosts that register a provider with core outside a request context must use
+ the explicit `QSessionStoreRegistry.getInstance(qInstance)` overload
diff --git a/README.md b/README.md
index 6a42cc0..03b0ecf 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,21 @@ Every QQQ request authenticates and builds a `QSession` containing security keys
## Project Status
-**0.1.0-SNAPSHOT** -- under active development. API may change before 1.0.
+**0.1.0** is the first published release line (release candidates publish as
+`0.1.0-RC.N`). API may change before 1.0. See [CHANGELOG.md](CHANGELOG.md).
+
+## Compatibility
+
+| qbit-session-store | QQQ core (`qqq-bom-pom`) | qbit-redis (optional, `SHARED_REDIS` only) |
+|--------------------|--------------------------|--------------------------------------------|
+| 0.1.0 line | 4.1.0-RC.22 or later 4.1 | 0.1.0-RC.1 or later 0.1 |
+
+The `SHARED_REDIS` provider and application-scoped registration call
+`QSessionStoreRegistry.getInstance(QInstance)` and `clearIfSame`, which core
+added after 4.1.0-RC.21; this QBit does not compile against RC.21 or earlier.
+Artifacts publish to the Gitea Maven registry
+(`https://git.kof22.com/api/packages/QRun-IO/maven`), which requires an
+authenticated read.
## Features
@@ -35,7 +49,7 @@ Add the dependency to your project:
com.kingsrook.qbits
qbit-session-store
- 0.1.0-SNAPSHOT
+ 0.1.0
```
@@ -138,6 +152,53 @@ new QSessionStoreQBitProducer()
.produce(qInstance);
```
+### Shared Redis infrastructure
+
+Use `SHARED_REDIS` when the application installs `qbit-redis`. Include that optional
+artifact explicitly in the host and produce its named connection metadata before
+session-store. The Redis runtime owns TLS, credentials, bounded pools and health.
+Session-store owns JSON serialization, session expiry and payload limits.
+
+```java
+new QSessionStoreQBitProducer()
+ .withConfig(new QSessionStoreQBitConfig()
+ .withProviderType(QSessionStoreProviderType.SHARED_REDIS)
+ .withSharedRedis(new SharedRedisSessionStoreSettings(
+ "sessions", "production", "orders-app", "authentication", 65536))
+ .withDefaultTtl(Duration.ofHours(1))
+ .withEnableSlidingExpiration(true))
+ .produce(qInstance);
+```
+
+Settings are connection name, deployment, application, authentication-domain scope,
+and maximum serialized bytes. Nodes serving the same application/domain must use
+identical scope; distinct applications/domains must use distinct scope. Resolve it
+from host configuration, never an unauthenticated request. Redis namespacing encodes
+each segment independently. This provider does not interpret per-user tenant claims.
+
+Operations require the owning Redis runtime to be started. The provider never opens
+or closes a pool and never falls back to another connection. `loadAndTouch` uses
+atomic GETEX when sliding is enabled, plain GET otherwise; Redis 6.2+ is required.
+Missing values are cache misses; infrastructure, size and decoding failures throw.
+Failed remove also throws so logout can retry. TTLs must be 1 ms through 365 days;
+serialized payload bounds may be configured up to 1 MiB. No cleanup job is produced
+because Redis owns expiry. Framework Redis and session-store health checks report
+availability; the provider's status string is only descriptive.
+
+The existing `REDIS` provider retains its independent legacy pool/configuration.
+Selecting `SHARED_REDIS` is explicit and uses a new namespace; it does not migrate
+existing cache entries. A framework runtime service releases its owned registry/context entries in
+`afterApplicationStop`, after dependent services have stopped; it never closes
+the Redis pool. Repeated cleanup is harmless and preserves replacement providers.
+Restart reconstructs registration from metadata after Redis starts. Manual hosts
+must honor the same runtime callbacks. Shared providers weakly reference their
+application so an abandoned pre-start registration cannot retain the owner.
+
+Local acceptance covers real HTTP authentication/logout and Redis outage/retry
+through the core sample's `redis-acceptance` profile. Coordinated artifact
+publication and clean host adoption remain release gates. Redis failover topology
+qualification is future work tracked in [QQQ #1301](https://github.com/QRun-IO/qqq/issues/1301).
+
### Custom Provider
```java
@@ -150,10 +211,18 @@ new QSessionStoreQBitProducer()
## Accessing the Provider
-After producing the QBit, retrieve the provider via context:
+After producing the QBit, retrieve the provider for its owning application.
+Within a request, `getProvider()` uses `QContext.getQInstance()`. Startup and
+background callers should use the explicit overload below. The qbit automatically
+registers against the QInstance supplied to `produce`; it never installs a
+JVM-wide fallback for other applications.
+
+Hosts registering directly with core must use
+`QSessionStoreRegistry.getInstance(qInstance).register(provider)` outside request
+context. See [registration migration](docs/SESSION-REGISTRATION.md).
```java
-QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider();
+QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider(qInstance);
// Store a session
provider.store(session.getUuid(), session, Duration.ofHours(1));
@@ -172,6 +241,31 @@ provider.remove(sessionUuid);
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
+## Releasing
+
+Versions are computed by the shared `KofTwentyTwo/ci-workflows` Maven workflow
+(`.gitea/workflows/build.yaml`) from what is pushed; the `revision` property on
+`develop` stays `0.1.0-SNAPSHOT` and is never hand-edited for a release.
+
+| Push | Published version | Side effects |
+|----------------------------|--------------------------|---------------------------------------------------------------|
+| `develop` | `0.1.0-SNAPSHOT` | none |
+| branch `release/0.1` | `0.1.0-RC.1`, `RC.2`, .. | commits "Bump version to X [skip ci]" to the branch, tags `vX` |
+| tag `v0.1.0` | `0.1.0` | pushes the version bump to `main` |
+| tag `publish-` | `0.1.0---SNAPSHOT` | none |
+
+To cut the first release: merge this release preparation into `develop`, create
+`release/0.1` from `develop` and push it (publishes `0.1.0-RC.1`), qualify the
+candidate, then tag the bumped `release/0.1` head `v0.1.0` and push the tag
+(publishes `0.1.0`). Merge `release/0.1` back into `develop`; the workflow then
+advances `develop` to `0.2.0-SNAPSHOT`. Record the date in `CHANGELOG.md` when
+tagging. GitHub is a read-only shadow; never push there.
+
## License
Apache-2.0 - See [LICENSE](LICENSE)
+
+Launcher acceptance covers normal shutdown, failure of a later runtime, and Redis
+authentication failure before session-runtime startup followed by retry. In that
+early failure case no Redis pool is published; the weak pre-start registration
+remains configured but cannot execute session operations until Redis starts.
diff --git a/docs/SESSION-REGISTRATION.md b/docs/SESSION-REGISTRATION.md
new file mode 100644
index 0000000..623c96c
--- /dev/null
+++ b/docs/SESSION-REGISTRATION.md
@@ -0,0 +1,34 @@
+# Application-scoped session store registration
+
+The candidate core registry resolves the QInstance in QContext. Application
+startup must register explicitly, even when no request context exists:
+
+```java
+QSessionStoreRegistry.getInstance(qInstance).register(provider);
+```
+
+Calls to `getInstance()` within a request use that request's application. Without
+a QInstance, that call accesses a separate standalone registry. An application
+never inherits that standalone provider. Existing hosts that register outside
+QContext must migrate to the explicit overload alongside this core candidate.
+Deploy matching session-store/core versions; this is not a transparent change for
+old context-free startup integrations.
+
+Session-store's producer now supplies its target QInstance to both core registration
+and qbit context. Context getters/setters/clear operate on the current application,
+with explicit QInstance overloads for metadata production/background operations.
+Health metadata selects its target application and the health probe uses the
+QInstance in HealthCheckInput (falling back to current context only if omitted).
+
+Regression reproduced before the fix: after application A stored a session,
+unconfigured application B loaded that same session through QSessionStoreHelper.
+The test now checks independent same-ID sessions, isolation of clearing, and health
+checks for an explicit target while a different application is on the caller thread.
+
+Hosts sharing a Redis server must configure separate application/authentication
+domain namespaces. The `SHARED_REDIS` provider uses those explicit settings and
+the owning Redis QBit's pool. Its runtime releases owned registrations on stop
+and reconstructs them on restart. Local acceptance covers launcher lifecycle,
+HTTP authentication/logout and Redis outage/retry; coordinated published-artifact
+and clean-host verification remain release gates. See the shared Redis setup in
+[README](../README.md).
diff --git a/pom.xml b/pom.xml
index b993a0a..df42eb8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -21,7 +21,14 @@
0.1.0-SNAPSHOT
21
21
- 5.1.0
+ 7.2.0
+
+ 4.1.0-RC.22
+
+ 0.1.0-RC.1
@@ -78,7 +85,7 @@
com.kingsrook.qqq
qqq-bom-pom
- 4.1.0-RC.18
+ ${qqq.version}
pom
import
@@ -91,6 +98,13 @@
qqq-backend-core
+
+ com.kingsrook.qbits
+ qbit-redis
+ ${qbit-redis.version}
+ true
+
+
redis.clients
@@ -100,6 +114,12 @@
+
+ com.kingsrook.qqq
+ qqq-middleware-javalin
+ ${qqq.version}
+ test
+
com.kingsrook.qqq
qqq-backend-module-rdbms
@@ -123,7 +143,7 @@
org.assertj
assertj-core
- 3.27.3
+ 3.27.7
test
@@ -138,13 +158,13 @@
org.testcontainers
testcontainers
- 1.19.3
+ 2.0.3
test
org.testcontainers
- junit-jupiter
- 1.19.3
+ testcontainers-junit-jupiter
+ 2.0.3
test
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderFactory.java b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderFactory.java
index 86eacea..cd8dfb1 100644
--- a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderFactory.java
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderFactory.java
@@ -18,9 +18,12 @@ package com.kingsrook.qbits.sessionstore;
import com.kingsrook.qbits.sessionstore.providers.InMemorySessionStoreProvider;
import com.kingsrook.qbits.sessionstore.providers.RedisSessionStoreProvider;
+import com.kingsrook.qbits.sessionstore.providers.SharedRedisSessionStoreProvider;
import com.kingsrook.qbits.sessionstore.providers.TableBasedSessionStoreProvider;
import com.kingsrook.qqq.backend.core.actions.customizers.QCodeLoader;
+import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
/*******************************************************************************
@@ -33,12 +36,23 @@ public class QSessionStoreProviderFactory
** Create a provider instance based on the configured type.
***************************************************************************/
public static QSessionStoreProviderInterface createProvider(QSessionStoreQBitConfig config) throws QException
+ {
+ return createProvider(QContext.getQInstance(), config);
+ }
+
+
+
+ /***************************************************************************
+ ** Bind providers needing managed infrastructure to their owning application.
+ ***************************************************************************/
+ public static QSessionStoreProviderInterface createProvider(QInstance instance, QSessionStoreQBitConfig config) throws QException
{
QSessionStoreProviderInterface provider = switch(config.getProviderType())
{
case IN_MEMORY -> new InMemorySessionStoreProvider();
case TABLE_BASED -> new TableBasedSessionStoreProvider();
case REDIS -> new RedisSessionStoreProvider();
+ case SHARED_REDIS -> new SharedRedisSessionStoreProvider(instance);
case CUSTOM -> QCodeLoader.getAdHoc(QSessionStoreProviderInterface.class, config.getCustomProviderCodeReference());
};
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderType.java b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderType.java
index 5787f47..9cdde2a 100644
--- a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderType.java
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreProviderType.java
@@ -24,5 +24,6 @@ public enum QSessionStoreProviderType
IN_MEMORY,
TABLE_BASED,
REDIS,
+ SHARED_REDIS,
CUSTOM
}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitConfig.java b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitConfig.java
index 62854c4..80b85ae 100644
--- a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitConfig.java
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitConfig.java
@@ -30,6 +30,7 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils;
public class QSessionStoreQBitConfig implements QBitConfig
{
private QSessionStoreProviderType providerType;
+ private SharedRedisSessionStoreSettings sharedRedis;
private Duration defaultTtl = Duration.ofHours(1);
private Boolean enableSlidingExpiration = true;
private String backendName;
@@ -77,6 +78,18 @@ public class QSessionStoreQBitConfig implements QBitConfig
}
}
+ if(providerType == QSessionStoreProviderType.SHARED_REDIS)
+ {
+ if(sharedRedis == null)
+ {
+ errors.add("sharedRedis settings are required for SHARED_REDIS provider");
+ }
+ if(defaultTtl == null || defaultTtl.isNegative() || defaultTtl.compareTo(Duration.ofMillis(1)) < 0 || defaultTtl.compareTo(Duration.ofDays(365)) > 0)
+ {
+ errors.add("Shared Redis default TTL must be between one millisecond and 365 days");
+ }
+ }
+
if(providerType == QSessionStoreProviderType.CUSTOM)
{
if(customProviderCodeReference == null)
@@ -534,4 +547,23 @@ public class QSessionStoreQBitConfig implements QBitConfig
return (this);
}
+ /***************************************************************************
+ ** Explicit connection and namespace for the shared Redis provider.
+ ***************************************************************************/
+ public SharedRedisSessionStoreSettings getSharedRedis()
+ {
+ return sharedRedis;
+ }
+
+
+
+ /***************************************************************************
+ ** Select shared infrastructure without retaining Redis credentials here.
+ ***************************************************************************/
+ public QSessionStoreQBitConfig withSharedRedis(SharedRedisSessionStoreSettings settings)
+ {
+ sharedRedis = settings;
+ return this;
+ }
+
}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitContext.java b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitContext.java
index e9bbaaa..e65f064 100644
--- a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitContext.java
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitContext.java
@@ -16,63 +16,144 @@
package com.kingsrook.qbits.sessionstore;
+import java.util.Map;
+import java.util.WeakHashMap;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+
+
/*******************************************************************************
- ** Static context holder for the Session Store QBit configuration and provider.
+ ** Application-scoped configuration and provider access for session-store actions.
*******************************************************************************/
public class QSessionStoreQBitContext
{
- private static QSessionStoreQBitConfig config;
- private static QSessionStoreProviderInterface provider;
+ private static final Map CONFIGS = new WeakHashMap<>();
+ private static final Map PROVIDERS = new WeakHashMap<>();
/***************************************************************************
- ** Getter for config
+ ** Get configuration for the current application.
***************************************************************************/
public static QSessionStoreQBitConfig getConfig()
{
- return config;
+ return getConfig(QContext.getQInstance());
}
/***************************************************************************
- ** Setter for config
+ ** Get configuration for an explicitly selected application.
+ ***************************************************************************/
+ public static synchronized QSessionStoreQBitConfig getConfig(QInstance instance)
+ {
+ return CONFIGS.get(instance);
+ }
+
+
+
+ /***************************************************************************
+ ** Set configuration for the current application.
***************************************************************************/
public static void setConfig(QSessionStoreQBitConfig config)
{
- QSessionStoreQBitContext.config = config;
+ setConfig(QContext.getQInstance(), config);
}
/***************************************************************************
- ** Getter for provider
+ ** Register configuration during application metadata production.
+ ***************************************************************************/
+ public static synchronized void setConfig(QInstance instance, QSessionStoreQBitConfig config)
+ {
+ if(config == null)
+ {
+ CONFIGS.remove(instance);
+ }
+ else
+ {
+ CONFIGS.put(instance, config);
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Get the current application's provider; never use another application's.
***************************************************************************/
public static QSessionStoreProviderInterface getProvider()
{
- return provider;
+ return getProvider(QContext.getQInstance());
}
/***************************************************************************
- ** Setter for provider
+ ** Get an application's provider explicitly without changing thread context.
+ ***************************************************************************/
+ public static synchronized QSessionStoreProviderInterface getProvider(QInstance instance)
+ {
+ return PROVIDERS.get(instance);
+ }
+
+
+
+ /***************************************************************************
+ ** Set the current application's provider.
***************************************************************************/
public static void setProvider(QSessionStoreProviderInterface provider)
{
- QSessionStoreQBitContext.provider = provider;
+ setProvider(QContext.getQInstance(), provider);
}
/***************************************************************************
- ** Clear all context (useful for testing).
+ ** Register a provider for its owning application during metadata production.
+ ***************************************************************************/
+ public static synchronized void setProvider(QInstance instance, QSessionStoreProviderInterface provider)
+ {
+ if(provider == null)
+ {
+ PROVIDERS.remove(instance);
+ }
+ else
+ {
+ PROVIDERS.put(instance, provider);
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Clear only the current application's context.
***************************************************************************/
public static void clear()
{
- config = null;
- provider = null;
+ clear(QContext.getQInstance());
}
+
+
+ /***************************************************************************
+ ** Clear only the explicitly selected application's context.
+ ***************************************************************************/
+ public static synchronized void clear(QInstance instance)
+ {
+ CONFIGS.remove(instance);
+ PROVIDERS.remove(instance);
+ }
+ /***************************************************************************
+ ** Clear shutdown registrations only if they still belong to this provider.
+ ***************************************************************************/
+ public static synchronized boolean clearIfSame(QInstance instance, QSessionStoreProviderInterface expected)
+ {
+ if(PROVIDERS.get(instance) != expected)
+ {
+ return false;
+ }
+ clear(instance);
+ return true;
+ }
}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducer.java b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducer.java
index 104046d..37dc090 100644
--- a/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducer.java
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducer.java
@@ -60,6 +60,13 @@ public class QSessionStoreQBitProducer implements QBitProducer
throw new QException("SessionStore QBit configuration errors: " + String.join(", ", errors));
}
+ if(qInstance.getQBits().values().stream().anyMatch(bit -> GROUP_ID.equals(bit.getGroupId())
+ && ARTIFACT_ID.equals(bit.getArtifactId())))
+ {
+ throw new QException("SessionStore QBit is already installed on this application");
+ }
+ QSessionStoreProviderInterface provider = QSessionStoreProviderFactory.createProvider(qInstance, config);
+
///////////////////////////////
// Register QBit identity //
///////////////////////////////
@@ -75,7 +82,6 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Create provider instance //
///////////////////////////////
- QSessionStoreProviderInterface provider = QSessionStoreProviderFactory.createProvider(config);
LOG.info("Created session store provider", logPair("type", config.getProviderType()));
///////////////////////////////
@@ -90,7 +96,8 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Produce cleanup process //
///////////////////////////////
- if(Boolean.TRUE.equals(config.getEnableCleanupProcess()))
+ if(Boolean.TRUE.equals(config.getEnableCleanupProcess())
+ && config.getProviderType() != QSessionStoreProviderType.SHARED_REDIS)
{
CleanExpiredSessionsProcessMetaDataProducer.produce(qInstance, config);
LOG.info("Produced cleanup process");
@@ -99,13 +106,17 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Store in context //
///////////////////////////////
- QSessionStoreQBitContext.setConfig(config);
- QSessionStoreQBitContext.setProvider(provider);
+ QSessionStoreQBitContext.setConfig(qInstance, config);
+ QSessionStoreQBitContext.setProvider(qInstance, provider);
///////////////////////////////
// Register with core registry //
///////////////////////////////
- QSessionStoreRegistry.getInstance().register(provider);
+ QSessionStoreRegistry.getInstance(qInstance).register(provider);
+ if(config.getProviderType() == QSessionStoreProviderType.SHARED_REDIS)
+ {
+ qInstance.withRuntimeService(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(SharedRedisSessionStoreRuntimeService.class));
+ }
LOG.info("Registered session store provider with core registry");
}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreRuntimeService.java b/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreRuntimeService.java
new file mode 100644
index 0000000..2c02986
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreRuntimeService.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 Kingsrook, LLC
+ *
+ * Licensed 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 com.kingsrook.qbits.sessionstore;
+
+
+import java.util.Map;
+import java.util.WeakHashMap;
+import com.kingsrook.qbits.redis.api.RedisAccess;
+import com.kingsrook.qbits.redis.api.RedisNamespace;
+import com.kingsrook.qbits.sessionstore.providers.SharedRedisSessionStoreProvider;
+import com.kingsrook.qqq.backend.core.exceptions.QException;
+import com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
+
+
+/*******************************************************************************
+ ** Own shared session registrations; Redis alone owns the backing connection pool.
+ *******************************************************************************/
+public class SharedRedisSessionStoreRuntimeService implements QRuntimeServiceInterface
+{
+ private static final Map OWNERS = new WeakHashMap<>();
+ private final Object token = new Object();
+ private QInstance instance;
+ private QSessionStoreProviderInterface provider;
+
+
+
+ /***************************************************************************
+ ** Framework lifecycle identity.
+ ***************************************************************************/
+ @Override
+ public String getName()
+ {
+ return "shared-redis-session-store";
+ }
+
+
+
+ /***************************************************************************
+ ** Require the named Redis pool first; reconstruct registrations on restart.
+ ***************************************************************************/
+ @Override
+ public synchronized void start(QInstance owner) throws QException
+ {
+ if(owner == null)
+ {
+ throw new QException("Shared Redis session runtime requires an application");
+ }
+ synchronized(OWNERS)
+ {
+ if(instance != null || OWNERS.containsKey(owner))
+ {
+ throw new QException("Shared Redis session runtime is already started");
+ }
+ OWNERS.put(owner, token);
+ }
+ QSessionStoreProviderInterface candidate = QSessionStoreQBitContext.getProvider(owner);
+ try
+ {
+ if(candidate != null && !(candidate instanceof SharedRedisSessionStoreProvider))
+ {
+ throw new QException("Application has another session provider");
+ }
+ var config = owner.getQBits().values().stream().map(bit -> bit.getConfig())
+ .filter(QSessionStoreQBitConfig.class::isInstance)
+ .map(QSessionStoreQBitConfig.class::cast)
+ .filter(value -> value.getProviderType() == QSessionStoreProviderType.SHARED_REDIS)
+ .findFirst().orElseThrow();
+ var settings = config.getSharedRedis();
+ RedisAccess.cache(owner, settings.connectionName(), new RedisNamespace(settings.deployment(),
+ settings.application(), settings.tenant(), "session-store"), settings.maximumValueBytes());
+ var registered = QSessionStoreRegistry.getInstance(owner).getProvider().orElse(null);
+ if(registered != null && registered != candidate)
+ {
+ throw new QException("Application has another session provider");
+ }
+ if(candidate == null)
+ {
+ candidate = QSessionStoreProviderFactory.createProvider(owner, config);
+ }
+ QSessionStoreQBitContext.setConfig(owner, config);
+ QSessionStoreQBitContext.setProvider(owner, candidate);
+ QSessionStoreRegistry.getInstance(owner).register(candidate);
+ instance = owner;
+ provider = candidate;
+ }
+ catch(Exception e)
+ {
+ if(candidate instanceof SharedRedisSessionStoreProvider)
+ {
+ QSessionStoreRegistry.getInstance(owner).clearIfSame(candidate);
+ QSessionStoreQBitContext.clearIfSame(owner, candidate);
+ }
+ synchronized(OWNERS)
+ {
+ OWNERS.remove(owner, token);
+ }
+ throw new QException("Shared Redis session startup failed; check connection startup and provider ownership");
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Keep registrations available until dependent HTTP and scheduler work stops.
+ ***************************************************************************/
+ @Override
+ public void stop()
+ {
+ }
+
+
+
+ /***************************************************************************
+ ** Release only our registrations, with no QContext requirement or pool closure.
+ ***************************************************************************/
+ @Override
+ public synchronized void afterApplicationStop()
+ {
+ if(instance == null)
+ {
+ return;
+ }
+ QSessionStoreRegistry.getInstance(instance).clearIfSame(provider);
+ QSessionStoreQBitContext.clearIfSame(instance, provider);
+ synchronized(OWNERS)
+ {
+ OWNERS.remove(instance, token);
+ }
+ provider = null;
+ instance = null;
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreSettings.java b/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreSettings.java
new file mode 100644
index 0000000..10d8be5
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/SharedRedisSessionStoreSettings.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 Kingsrook, LLC
+ *
+ * Licensed 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 com.kingsrook.qbits.sessionstore;
+
+
+import java.nio.charset.StandardCharsets;
+
+
+/*******************************************************************************
+ ** Explicit shared Redis ownership and namespace; credentials stay in Redis runtime.
+ *******************************************************************************/
+public record SharedRedisSessionStoreSettings(String connectionName, String deployment, String application, String tenant, Integer maximumValueBytes)
+{
+ /***************************************************************************
+ ** Bound namespace inputs and serialized session payloads before registration.
+ ***************************************************************************/
+ public SharedRedisSessionStoreSettings
+ {
+ for(String value : new String[] {connectionName, deployment, application, tenant})
+ {
+ if(value == null || value.isBlank() || value.getBytes(StandardCharsets.UTF_8).length > 256)
+ {
+ throw new IllegalArgumentException("Shared Redis connection and scope must be nonblank and at most 256 bytes");
+ }
+ }
+ if(maximumValueBytes == null || maximumValueBytes < 1 || maximumValueBytes > 1048576)
+ {
+ throw new IllegalArgumentException("Shared Redis session size must be between 1 and 1048576 bytes");
+ }
+ }
+}
diff --git a/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java b/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java
new file mode 100644
index 0000000..a841c49
--- /dev/null
+++ b/src/main/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProvider.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2024 Kingsrook, LLC
+ *
+ * Licensed 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 com.kingsrook.qbits.sessionstore.providers;
+
+
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Optional;
+import com.kingsrook.qbits.redis.api.RedisAccess;
+import com.kingsrook.qbits.redis.api.RedisCache;
+import com.kingsrook.qbits.redis.api.RedisNamespace;
+import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
+import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
+import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.utils.JsonUtils;
+
+
+/*******************************************************************************
+ ** Session rules over application-owned Redis infrastructure. Never owns a pool.
+ *******************************************************************************/
+public final class SharedRedisSessionStoreProvider implements QSessionStoreProviderInterface
+{
+ private final WeakReference instance;
+ private SharedRedisSessionStoreSettings settings;
+ private RedisNamespace namespace;
+ private Duration defaultTtl;
+ private boolean sliding;
+
+
+
+ /***************************************************************************
+ ** Bind to the explicit owner, independent of the request thread's context.
+ ***************************************************************************/
+ public SharedRedisSessionStoreProvider(QInstance instance)
+ {
+ this.instance = new WeakReference<>(Objects.requireNonNull(instance, "Shared Redis requires an owning QInstance"));
+ }
+
+
+
+ /***************************************************************************
+ ** Do not retain an abandoned application through a static registry value.
+ ***************************************************************************/
+ private QInstance owner()
+ {
+ QInstance owner = instance.get();
+ if(owner == null)
+ {
+ throw new IllegalStateException("Owning application is no longer available");
+ }
+ return owner;
+ }
+
+
+
+ /***************************************************************************
+ ** Validate metadata without creating infrastructure; install Redis first.
+ ***************************************************************************/
+ @Override
+ public void configure(QSessionStoreQBitConfig config)
+ {
+ if(settings != null)
+ {
+ throw new IllegalStateException("Shared Redis session provider is already configured");
+ }
+ var configured = Objects.requireNonNull(config.getSharedRedis());
+ Duration ttl = validTtl(config.getDefaultTtl());
+ if(!RedisAccess.isConfigured(owner(), configured.connectionName()))
+ {
+ throw new IllegalArgumentException("Shared Redis session connection is not configured on the owning application");
+ }
+ namespace = new RedisNamespace(configured.deployment(), configured.application(), configured.tenant(), "session-store");
+ defaultTtl = ttl;
+ sliding = Boolean.TRUE.equals(config.getEnableSlidingExpiration());
+ settings = configured;
+ }
+
+
+
+ /***************************************************************************
+ ** Resolve only started owner infrastructure; never allocate a fallback pool.
+ ***************************************************************************/
+ private RedisCache cache()
+ {
+ if(settings == null)
+ {
+ throw new IllegalStateException("Shared Redis session provider is not configured");
+ }
+ return RedisAccess.cache(owner(), settings.connectionName(), namespace, settings.maximumValueBytes());
+ }
+
+
+
+ /***************************************************************************
+ ** Serialize within session-store and atomically set value plus expiry.
+ ***************************************************************************/
+ @Override
+ public void store(String id, QSession session, Duration ttl)
+ {
+ cache().put(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
+ }
+
+
+
+ /***************************************************************************
+ ** Absence is a cache miss; infrastructure and decoding errors propagate.
+ ***************************************************************************/
+ @Override
+ public Optional load(String id)
+ {
+ return decode(cache().get(id));
+ }
+
+
+
+ /***************************************************************************
+ ** Failed eviction must propagate so logout can retry rather than claim success.
+ ***************************************************************************/
+ @Override
+ public void remove(String id)
+ {
+ cache().remove(id);
+ }
+
+
+
+ /***************************************************************************
+ ** Explicit renewal never recreates an expired or deleted session.
+ ***************************************************************************/
+ @Override
+ public void touch(String id)
+ {
+ cache().touch(id, defaultTtl);
+ }
+
+
+
+ /***************************************************************************
+ ** Respect sliding-expiration configuration with one atomic read/renew command.
+ ***************************************************************************/
+ @Override
+ public Optional loadAndTouch(String id)
+ {
+ return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id));
+ }
+
+
+
+ /***************************************************************************
+ ** Bound decoded payloads and never include session contents in error messages.
+ ***************************************************************************/
+ private Optional decode(Optional value)
+ {
+ return value.map(bytes ->
+ {
+ if(bytes.length > settings.maximumValueBytes())
+ {
+ throw new IllegalStateException("Stored session exceeds its configured size limit");
+ }
+ try
+ {
+ return Objects.requireNonNull(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class));
+ }
+ catch(IOException | RuntimeException e)
+ {
+ throw new IllegalStateException("Stored session could not be decoded");
+ }
+ });
+ }
+
+
+
+ /***************************************************************************
+ ** Bound TTL arithmetic and prevent immortal or truncated sub-millisecond data.
+ ***************************************************************************/
+ private static Duration validTtl(Duration ttl)
+ {
+ if(ttl == null || ttl.compareTo(Duration.ofMillis(1)) < 0 || ttl.compareTo(Duration.ofDays(365)) > 0)
+ {
+ throw new IllegalArgumentException("Session TTL must be between one millisecond and 365 days");
+ }
+ return ttl;
+ }
+
+
+
+ /***************************************************************************
+ ** Report the configured session lifetime.
+ ***************************************************************************/
+ @Override
+ public Duration getDefaultTtl()
+ {
+ return defaultTtl;
+ }
+
+
+
+ /***************************************************************************
+ ** Redis owns expiration; no scans or wildcard deletes are needed.
+ ***************************************************************************/
+ @Override
+ public void cleanExpired()
+ {
+ }
+
+
+
+ /***************************************************************************
+ ** Health truth belongs to the shared runtime's framework health checks.
+ ***************************************************************************/
+ @Override
+ public String status()
+ {
+ return "Shared Redis: health is reported by the Redis runtime";
+ }
+}
diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/OptionalRedisSessionConsumerTest.java b/src/test/java/com/kingsrook/qbits/sessionstore/OptionalRedisSessionConsumerTest.java
new file mode 100644
index 0000000..42c9ccb
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/sessionstore/OptionalRedisSessionConsumerTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2024 Kingsrook, LLC
+ *
+ * Licensed 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 com.kingsrook.qbits.sessionstore;
+
+
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Path;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+import static org.junit.jupiter.api.Assertions.*;
+
+
+/*******************************************************************************
+ ** Prove memory consumers can start without either optional Redis artifact.
+ *******************************************************************************/
+class OptionalRedisSessionConsumerTest
+{
+ /***************************************************************************
+ ** Platform parent prevents optional classes leaking from the Maven runner.
+ ***************************************************************************/
+ @Test
+ void memoryConsumerWithoutRedis() throws Exception
+ {
+ String classpath = System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"));
+ URL[] urls = Arrays.stream(classpath.split(java.io.File.pathSeparator))
+ .filter(entry -> !entry.replace('\\', '/').contains("/qbit-redis/")
+ && !Path.of(entry).getFileName().toString().startsWith("jedis-"))
+ .map(entry ->
+ {
+ try
+ {
+ return Path.of(entry).toUri().toURL();
+ }
+ catch(Exception e)
+ {
+ throw new IllegalArgumentException(e);
+ }
+ }).toArray(URL[]::new);
+ ClassLoader previous = Thread.currentThread().getContextClassLoader();
+ try(var loader = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()))
+ {
+ assertThrows(ClassNotFoundException.class,
+ () -> loader.loadClass("com.kingsrook.qbits.redis.api.RedisAccess"));
+ assertThrows(ClassNotFoundException.class,
+ () -> loader.loadClass("redis.clients.jedis.RedisClient"));
+ Thread.currentThread().setContextClassLoader(loader);
+ loader.loadClass(Probe.class.getName()).getMethod("exercise").invoke(null);
+ }
+ finally
+ {
+ Thread.currentThread().setContextClassLoader(previous);
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Loaded with session-store and core but without Redis classes.
+ ***************************************************************************/
+ public static class Probe
+ {
+ /************************************************************************
+ ** Producer, registry and in-memory storage remain usable independently.
+ ************************************************************************/
+ public static void exercise() throws Exception
+ {
+ var instance = new com.kingsrook.qqq.backend.core.model.metadata.QInstance();
+ try
+ {
+ new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
+ .withProviderType(QSessionStoreProviderType.IN_MEMORY).withEnableCleanupProcess(false))
+ .produce(instance, "session-store");
+ var provider = QSessionStoreQBitContext.getProvider(instance);
+ provider.store("id", new com.kingsrook.qqq.backend.core.model.session.QSession()
+ .withValue("marker", "memory"), java.time.Duration.ofSeconds(30));
+ assertEquals("memory", provider.load("id").orElseThrow().getValue("marker"));
+ provider.remove("id");
+ assertTrue(provider.load("id").isEmpty());
+ }
+ finally
+ {
+ QSessionStoreQBitContext.clear(instance);
+ com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry.getInstance(instance).clear();
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducerTest.java b/src/test/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducerTest.java
index 26582f1..ecd01dc 100644
--- a/src/test/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducerTest.java
+++ b/src/test/java/com/kingsrook/qbits/sessionstore/QSessionStoreQBitProducerTest.java
@@ -18,6 +18,7 @@ package com.kingsrook.qbits.sessionstore;
import java.time.Duration;
import com.kingsrook.qbits.sessionstore.providers.InMemorySessionStoreProvider;
+import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
@@ -63,14 +64,19 @@ class QSessionStoreQBitProducerTest
assertThat(qInstance.getQBits()).hasSize(1);
// Verify provider was created and stored in context
- assertThat(QSessionStoreQBitContext.getProvider()).isInstanceOf(InMemorySessionStoreProvider.class);
- assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(config);
+ assertThat(QSessionStoreQBitContext.getProvider(qInstance)).isInstanceOf(InMemorySessionStoreProvider.class);
+ assertThat(QSessionStoreQBitContext.getConfig(qInstance)).isSameAs(config);
// Verify cleanup process was created
assertThat(qInstance.getProcess("cleanExpiredSessions")).isNotNull();
// Verify registered with core registry
- assertThat(QSessionStoreRegistry.getInstance().getProvider()).isPresent();
+ assertThat(QSessionStoreRegistry.getInstance(qInstance).getProvider()).isPresent();
+ var originalProvider = QSessionStoreQBitContext.getProvider(qInstance);
+ assertThatThrownBy(() -> new QSessionStoreQBitProducer().withConfig(config).produce(qInstance, "duplicate"))
+ .isInstanceOf(QException.class).hasMessageContaining("already installed");
+ assertThat(QSessionStoreQBitContext.getProvider(qInstance)).isSameAs(originalProvider);
+ assertThat(qInstance.getQBits()).hasSize(1);
}
@@ -143,4 +149,41 @@ class QSessionStoreQBitProducerTest
assertThat(QSessionStoreQBitProducer.VERSION).isEqualTo("0.1.0");
}
+ /***************************************************************************
+ ** Metadata production must target the supplied instance despite caller context.
+ ***************************************************************************/
+ @Test
+ void isolatesApplicationProviders() throws QException
+ {
+ QInstance original = QContext.getQInstance();
+ QInstance first = new QInstance();
+ QInstance second = new QInstance();
+ var firstConfig = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.IN_MEMORY);
+ var secondConfig = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.IN_MEMORY);
+ try
+ {
+ QContext.setQInstance(second);
+ new QSessionStoreQBitProducer().withConfig(firstConfig).produce(first, "first");
+ assertThat(QSessionStoreQBitContext.getProvider()).isNull();
+ assertThat(QSessionStoreRegistry.getInstance().getProvider()).isEmpty();
+ new QSessionStoreQBitProducer().withConfig(secondConfig).produce(second, "second");
+ var secondProvider = QSessionStoreQBitContext.getProvider();
+ assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(secondConfig);
+ QContext.setQInstance(first);
+ assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(firstConfig);
+ assertThat(QSessionStoreQBitContext.getProvider()).isNotSameAs(secondProvider);
+ assertThat(QSessionStoreRegistry.getInstance().getProvider()).contains(QSessionStoreQBitContext.getProvider());
+ QSessionStoreQBitContext.clear();
+ QContext.setQInstance(second);
+ assertThat(QSessionStoreQBitContext.getProvider()).isSameAs(secondProvider);
+ }
+ finally
+ {
+ QSessionStoreQBitContext.clear(first);
+ QSessionStoreQBitContext.clear(second);
+ QSessionStoreRegistry.getInstance(first).clear();
+ QSessionStoreRegistry.getInstance(second).clear();
+ QContext.setQInstance(original);
+ }
+ }
}
diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProviderIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProviderIT.java
index de21a8e..0f64a45 100644
--- a/src/test/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProviderIT.java
+++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/RedisSessionStoreProviderIT.java
@@ -27,57 +27,33 @@ import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assumptions.assumeTrue;
/*******************************************************************************
** Integration tests for RedisSessionStoreProvider using Testcontainers.
- ** These tests require Docker to be running. If Docker is not available,
- ** the tests will be skipped.
+ ** These tests require Docker; unavailable infrastructure fails verification.
*******************************************************************************/
class RedisSessionStoreProviderIT
{
private static final int REDIS_PORT = 6379;
- private static boolean dockerAvailable = false;
private static GenericContainer> redis;
private RedisSessionStoreProvider provider;
/***************************************************************************
- ** Check Docker availability and start container if available.
+ ** Start required Redis infrastructure; failures must fail verification.
***************************************************************************/
@BeforeAll
static void startContainer()
{
- try
- {
- dockerAvailable = DockerClientFactory.instance().isDockerAvailable();
- }
- catch(Exception e)
- {
- dockerAvailable = false;
- }
-
- if(dockerAvailable)
- {
- try
- {
- redis = new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
- .withExposedPorts(REDIS_PORT);
- redis.start();
- }
- catch(Exception e)
- {
- dockerAvailable = false;
- redis = null;
- }
- }
+ redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
+ .withExposedPorts(REDIS_PORT);
+ redis.start();
}
@@ -99,9 +75,6 @@ class RedisSessionStoreProviderIT
@BeforeEach
void setUp()
{
- assumeTrue(dockerAvailable && redis != null && redis.isRunning(),
- "Docker is not available or Redis container is not running - skipping test");
-
provider = new RedisSessionStoreProvider();
QSessionStoreQBitConfig config = new QSessionStoreQBitConfig()
diff --git a/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java
new file mode 100644
index 0000000..09f5cdc
--- /dev/null
+++ b/src/test/java/com/kingsrook/qbits/sessionstore/providers/SharedRedisSessionStoreProviderIT.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright 2024 Kingsrook, LLC
+ *
+ * Licensed 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 com.kingsrook.qbits.sessionstore.providers;
+
+
+import java.time.Duration;
+import java.util.List;
+import com.kingsrook.qbits.redis.RedisConnectionConfig;
+import com.kingsrook.qbits.redis.RedisQBitConfig;
+import com.kingsrook.qbits.redis.RedisQBitProducer;
+import com.kingsrook.qbits.redis.RedisRuntimeService;
+import com.kingsrook.qbits.redis.api.RedisAccess;
+import com.kingsrook.qbits.redis.api.RedisNamespace;
+import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
+import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
+import com.kingsrook.qbits.sessionstore.QSessionStoreQBitContext;
+import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
+import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreRuntimeService;
+import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
+import com.kingsrook.qqq.backend.core.context.QContext;
+import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
+import com.kingsrook.qqq.backend.core.model.session.QSession;
+import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
+import org.junit.jupiter.api.Test;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.utility.DockerImageName;
+import static org.junit.jupiter.api.Assertions.*;
+
+
+/*******************************************************************************
+ ** Real shared infrastructure proves namespace, lifecycle and expiry ownership.
+ *******************************************************************************/
+class SharedRedisSessionStoreProviderIT
+{
+ /***************************************************************************
+ ** Separate nodes share one app's sessions while other apps remain isolated.
+ ***************************************************************************/
+ @Test
+ void sharedPoolsIsolationExpiryAndFailure() throws Exception
+ {
+ QInstance original = QContext.getQInstance();
+ try(GenericContainer> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
+ {
+ redis.start();
+ QInstance first = host(redis, "app-a", true);
+ QInstance second = host(redis, "app-a", false);
+ QInstance other = host(redis, "app-b", true);
+ var a = QSessionStoreQBitContext.getProvider(first);
+ var b = QSessionStoreQBitContext.getProvider(second);
+ var c = QSessionStoreQBitContext.getProvider(other);
+ var firstRuntime = new RedisRuntimeService();
+ var secondRuntime = new RedisRuntimeService();
+ var otherRuntime = new RedisRuntimeService();
+ try
+ {
+ assertThrows(IllegalStateException.class, () -> a.load("session"));
+ firstRuntime.start(first);
+ secondRuntime.start(second);
+ otherRuntime.start(other);
+ QContext.setQInstance(other);
+ a.store("session", new QSession().withValue("owner", "app-a"), null);
+ assertEquals("app-a", b.load("session").orElseThrow().getValue("owner"));
+ assertTrue(c.load("session").isEmpty());
+ c.store("session", new QSession().withValue("owner", "app-b"), null);
+ a.remove("session");
+ assertTrue(b.load("session").isEmpty());
+ assertEquals("app-b", c.load("session").orElseThrow().getValue("owner"));
+ a.touch("session");
+ assertTrue(a.loadAndTouch("session").isEmpty());
+
+ var scope = new RedisNamespace("test", "app-a", "authentication", "session-store");
+ var client = RedisRuntimeService.connection(first, "sessions").client();
+ a.store("sliding", new QSession(), Duration.ofSeconds(1));
+ assertTrue(a.loadAndTouch("sliding").isPresent());
+ assertTrue(client.pttl(scope.key("sliding")) > 20000);
+ a.store("fixed", new QSession(), Duration.ofMillis(200));
+ assertTrue(b.loadAndTouch("fixed").isPresent());
+ assertTrue(client.pttl(scope.key("fixed")) <= 200);
+ long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
+ while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
+ {
+ Thread.sleep(10);
+ }
+ assertTrue(b.load("fixed").isEmpty());
+ assertThrows(IllegalArgumentException.class,
+ () -> a.store("bad", new QSession(), Duration.ZERO));
+ assertThrows(IllegalArgumentException.class,
+ () -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
+ RedisAccess.cache(first, "sessions", scope, 65536)
+ .put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
+ var malformed = assertThrows(IllegalStateException.class, () -> a.load("broken"));
+ assertFalse(malformed.toString().contains("test-secret"));
+ assertNull(malformed.getCause());
+ a.cleanExpired();
+ assertTrue(a.status().contains("health"));
+ assertEquals(Duration.ofSeconds(30), a.getDefaultTtl());
+
+ firstRuntime.stop();
+ firstRuntime.afterApplicationStop();
+ assertThrows(IllegalStateException.class, () -> a.load("sliding"));
+ assertTrue(b.load("sliding").isPresent());
+ firstRuntime.start(first);
+ assertTrue(a.load("sliding").isPresent());
+ redis.stop();
+ assertThrows(RuntimeException.class, () -> a.remove("sliding"));
+ assertThrows(RuntimeException.class, () -> a.load("sliding"));
+ }
+ finally
+ {
+ for(var runtime : List.of(firstRuntime, secondRuntime, otherRuntime))
+ {
+ runtime.stop();
+ runtime.afterApplicationStop();
+ }
+ for(var instance : List.of(first, second, other))
+ {
+ QSessionStoreQBitContext.clear(instance);
+ QSessionStoreRegistry.getInstance(instance).clear();
+ }
+ QContext.setQInstance(original);
+ }
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Startup failures and shutdown must release only the owning registrations.
+ ***************************************************************************/
+ @Test
+ void registrationLifecycle() throws Exception
+ {
+ QInstance original = QContext.getQInstance();
+ try(GenericContainer> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
+ {
+ redis.start();
+ QInstance first = host(redis, "app-a", true);
+ QInstance second = host(redis, "app-b", true);
+ var firstPool = new RedisRuntimeService();
+ var secondPool = new RedisRuntimeService();
+ var firstService = new SharedRedisSessionStoreRuntimeService();
+ var secondService = new SharedRedisSessionStoreRuntimeService();
+ try
+ {
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> firstService.start(first));
+ assertNull(QSessionStoreQBitContext.getProvider(first));
+ assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
+ firstService.afterApplicationStop();
+ firstPool.start(first);
+ secondPool.start(second);
+ firstService.start(first);
+ secondService.start(second);
+ var firstProvider = QSessionStoreQBitContext.getProvider(first);
+ var secondProvider = QSessionStoreQBitContext.getProvider(second);
+ assertNotNull(firstProvider);
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> firstService.start(first));
+ var duplicate = new SharedRedisSessionStoreRuntimeService();
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> duplicate.start(first));
+ duplicate.afterApplicationStop();
+ assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
+ QContext.setQInstance(second);
+ firstService.stop();
+ assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
+ firstService.afterApplicationStop();
+ firstService.afterApplicationStop();
+ assertNull(QSessionStoreQBitContext.getProvider(first));
+ assertNull(QSessionStoreQBitContext.getConfig(first));
+ assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
+ assertSame(secondProvider, QSessionStoreQBitContext.getProvider(second));
+ assertEquals("PONG", RedisRuntimeService.connection(first, "sessions").client().ping());
+ firstService.start(first);
+ assertNotSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
+ var replacement = new InMemorySessionStoreProvider();
+ QSessionStoreRegistry.getInstance(first).register(replacement);
+ QSessionStoreQBitContext.setProvider(first, replacement);
+ firstService.afterApplicationStop();
+ assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
+ assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> firstService.start(first));
+ assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
+ assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
+ }
+ finally
+ {
+ firstService.afterApplicationStop();
+ secondService.afterApplicationStop();
+ firstPool.afterApplicationStop();
+ secondPool.afterApplicationStop();
+ for(var instance : List.of(first, second))
+ {
+ QSessionStoreQBitContext.clear(instance);
+ QSessionStoreRegistry.getInstance(instance).clear();
+ }
+ QContext.setQInstance(original);
+ }
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Exercise real launcher callbacks and rollback after a later service fails.
+ ***************************************************************************/
+ @Test
+ void applicationLauncherOwnsNormalAndFailedShutdown() throws Exception
+ {
+ var original = QContext.capture();
+ try(GenericContainer> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
+ {
+ redis.start();
+ QInstance instance = host(redis, "launcher", true);
+ var http = new java.util.concurrent.atomic.AtomicReference();
+ var launcher = com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(
+ application(instance), launcherConfig(http));
+ var pool = RedisRuntimeService.connection(instance, "sessions");
+ try
+ {
+ assertTrue(http.get().isStarted());
+ assertTrue(launcher.getStartedServiceNames().containsAll(List.of("redis", "shared-redis-session-store")));
+ var provider = QSessionStoreQBitContext.getProvider(instance);
+ provider.store("launcher-session", new QSession().withValue("owner", "launcher"), null);
+ assertEquals("launcher", provider.load("launcher-session").orElseThrow().getValue("owner"));
+ QContext.clear();
+ }
+ finally
+ {
+ launcher.stop();
+ }
+ launcher.stop();
+ assertTrue(http.get().isStopped());
+ assertNull(QSessionStoreQBitContext.getProvider(instance));
+ assertNull(QSessionStoreQBitContext.getConfig(instance));
+ assertTrue(QSessionStoreRegistry.getInstance(instance).getProvider().isEmpty());
+ assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(instance, "sessions"));
+ assertThrows(RuntimeException.class, () -> pool.client().ping());
+
+ QInstance failing = host(redis, "failed-launcher", true);
+ failing.withRuntimeService(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(FailAfterSessions.class));
+ var failedHttp = new java.util.concurrent.atomic.AtomicReference();
+ var failure = assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(failing), launcherConfig(failedHttp)));
+ assertTrue(failure.getMessage().contains("owned late failure"));
+ assertNotNull(failedHttp.get());
+ assertTrue(failedHttp.get().isStopped());
+ assertNull(QSessionStoreQBitContext.getProvider(failing));
+ assertNull(QSessionStoreQBitContext.getConfig(failing));
+ assertTrue(QSessionStoreRegistry.getInstance(failing).getProvider().isEmpty());
+ assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(failing, "sessions"));
+
+ redis.execInContainer("redis-cli", "CONFIG", "SET", "requirepass", "owned-startup-password");
+ QInstance early = host(redis, "early-failure", true);
+ var configuredProvider = QSessionStoreQBitContext.getProvider(early);
+ var earlyHttp = new java.util.concurrent.atomic.AtomicReference();
+ var earlyFailure = assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(early), launcherConfig(earlyHttp)));
+ assertTrue(earlyFailure.getMessage().contains("Redis startup failed"));
+ assertTrue(earlyHttp.get().isStopped());
+ assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(early, "sessions"));
+ assertSame(configuredProvider, QSessionStoreQBitContext.getProvider(early));
+ assertThrows(IllegalStateException.class, () -> configuredProvider.load("unstarted"));
+ assertEquals(0, redis.execInContainer("redis-cli", "-a", "owned-startup-password",
+ "CONFIG", "SET", "requirepass", "").getExitCode());
+ var retriedHttp = new java.util.concurrent.atomic.AtomicReference();
+ var retried = com.kingsrook.qqq.middleware.javalin.QApplicationLauncher.run(application(early), launcherConfig(retriedHttp));
+ try
+ {
+ assertTrue(retriedHttp.get().isStarted());
+ configuredProvider.store("retry", new QSession(), null);
+ assertTrue(configuredProvider.load("retry").isPresent());
+ }
+ finally
+ {
+ retried.stop();
+ }
+ assertTrue(retriedHttp.get().isStopped());
+ assertNull(QSessionStoreQBitContext.getProvider(early));
+ assertTrue(QSessionStoreRegistry.getInstance(early).getProvider().isEmpty());
+ assertThrows(IllegalStateException.class, () -> RedisRuntimeService.connection(early, "sessions"));
+ }
+ finally
+ {
+ QContext.clear();
+ QContext.init(original);
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** The launcher receives the same already-produced metadata as the runtime tests.
+ ***************************************************************************/
+ private com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication application(QInstance instance)
+ {
+ return new com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication()
+ {
+ /**********************************************************************
+ ** Return this test's owned application, never a global fixture.
+ **********************************************************************/
+ @Override
+ public QInstance defineQInstance()
+ {
+ return instance;
+ }
+ };
+ }
+
+
+
+ /***************************************************************************
+ ** Bind an ephemeral loopback HTTP port and observe the real Jetty lifecycle.
+ ***************************************************************************/
+ private com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig launcherConfig(
+ java.util.concurrent.atomic.AtomicReference http)
+ {
+ return new com.kingsrook.qqq.middleware.javalin.QApplicationLauncherConfig()
+ .withRegisterShutdownHook(false).withServerCustomizer(server -> server.withPort(0)
+ .withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
+ .withJavalinConfigCustomizer(config ->
+ {
+ config.jetty.host = "127.0.0.1";
+ config.jetty.modifyServer(http::set);
+ }));
+ }
+
+
+
+ /***************************************************************************
+ ** Fail only after proving Redis and session registration have started.
+ ***************************************************************************/
+ public static class FailAfterSessions implements com.kingsrook.qqq.backend.core.instances.QRuntimeServiceInterface
+ {
+ /***********************************************************************
+ ** Stable failure-stage name for launcher diagnostics.
+ ***********************************************************************/
+ @Override
+ public String getName()
+ {
+ return "owned-late-failure";
+ }
+
+
+
+ /***********************************************************************
+ ** The thrown message proves the preceding runtime dependencies were ready.
+ ***********************************************************************/
+ @Override
+ public void start(QInstance instance) throws com.kingsrook.qqq.backend.core.exceptions.QException
+ {
+ if(QSessionStoreQBitContext.getProvider(instance) == null
+ || !"PONG".equals(RedisRuntimeService.connection(instance, "sessions").client().ping()))
+ {
+ throw new com.kingsrook.qqq.backend.core.exceptions.QException("Preceding services were not ready");
+ }
+ throw new com.kingsrook.qqq.backend.core.exceptions.QException("owned late failure");
+ }
+
+
+
+ /***********************************************************************
+ ** No resources are acquired by this deliberately failing service.
+ ***********************************************************************/
+ @Override
+ public void stop()
+ {
+ }
+ }
+
+
+
+ /***************************************************************************
+ ** Reject bad settings or missing infrastructure before publishing qbit metadata.
+ ***************************************************************************/
+ @Test
+ void invalidConfigurationDoesNotRegister() throws Exception
+ {
+ assertThrows(IllegalArgumentException.class,
+ () -> new SharedRedisSessionStoreSettings("", "test", "app", "auth", 100));
+ assertThrows(IllegalArgumentException.class,
+ () -> new SharedRedisSessionStoreSettings("pool", "test", "app", "auth", 0));
+ assertThrows(IllegalArgumentException.class,
+ () -> new SharedRedisSessionStoreSettings("pool", "test", "app", "auth", 1048577));
+ QInstance instance = new QInstance();
+ var config = new QSessionStoreQBitConfig().withProviderType(QSessionStoreProviderType.SHARED_REDIS);
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
+ config.withSharedRedis(new SharedRedisSessionStoreSettings("missing", "test", "app", "auth", 65536));
+ assertThrows(IllegalArgumentException.class,
+ () -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
+ assertTrue(instance.getQBits().isEmpty());
+ assertTrue(QSessionStoreRegistry.getInstance(instance).getProvider().isEmpty());
+ assertNull(QSessionStoreQBitContext.getProvider(instance));
+ config.withDefaultTtl(Duration.ZERO);
+ assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
+ () -> new QSessionStoreQBitProducer().withConfig(config).produce(instance, "session-store"));
+ }
+
+
+
+ /***************************************************************************
+ ** Compose framework metadata and bind the session provider before pool startup.
+ ***************************************************************************/
+ private QInstance host(GenericContainer> redis, String application, boolean sliding) throws Exception
+ {
+ var instance = new QInstance();
+ instance.addBackend(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData()
+ .withName("memory").withBackendType(com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule.class));
+ instance.withInstanceDefaultAuthentication(new com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData()
+ .withName("test").withType(com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType.FULLY_ANONYMOUS));
+ instance.addTable(new com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData().withName("record").withBackendName("memory").withPrimaryKeyField("id")
+ .withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER)));
+ new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("sessions",
+ new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379))
+ .withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
+ .produce(instance);
+ new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
+ .withProviderType(QSessionStoreProviderType.SHARED_REDIS)
+ .withSharedRedis(new SharedRedisSessionStoreSettings("sessions", "test", application, "authentication", 65536))
+ .withDefaultTtl(Duration.ofSeconds(30)).withEnableSlidingExpiration(sliding))
+ .produce(instance, "session-store");
+ assertNull(instance.getProcess("cleanExpiredSessions"));
+ return instance;
+ }
+}