Compare commits

..
Author SHA1 Message Date
james.maesandCodex c2501cfc17 ci: centralize rc release completion in qqq orb
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 02:21:15 -05:00
31 changed files with 250 additions and 2998 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.8
qqq-orb: kingsrook/qqq-orb@0.7.11
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
orb_version: 0.7.11
filters:
branches:
ignore: automation/qbit-rc-pending
-7
View File
@@ -1,7 +0,0 @@
name: build
on:
push:
jobs:
maven:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
secrets: inherit
+1 -1
View File
@@ -53,7 +53,7 @@ jobs:
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
'release:rc'))
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@7d73ac90b6b5a6acb61b4d3fa56625c5b6dcc652
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
}}
+1 -1
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: '0.8'
orb_version: 0.7.11
default_branch: develop
java_version: '21'
browsers: true
+1 -25
View File
@@ -5,12 +5,7 @@ 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
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.
## [Unreleased] - 0.1.0-SNAPSHOT
### Added
- In-memory session store provider with LRU eviction
@@ -20,22 +15,3 @@ merges back.
- 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
+4 -98
View File
@@ -8,21 +8,7 @@ Every QQQ request authenticates and builds a `QSession` containing security keys
## Project Status
**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.
**0.1.0-SNAPSHOT** -- under active development. API may change before 1.0.
## Features
@@ -49,7 +35,7 @@ Add the dependency to your project:
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-session-store</artifactId>
<version>0.1.0</version>
<version>0.1.0-SNAPSHOT</version>
</dependency>
```
@@ -152,53 +138,6 @@ 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
@@ -211,18 +150,10 @@ new QSessionStoreQBitProducer()
## Accessing the Provider
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).
After producing the QBit, retrieve the provider via context:
```java
QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider(qInstance);
QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider();
// Store a session
provider.store(session.getUuid(), session, Duration.ofHours(1));
@@ -241,31 +172,6 @@ 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-<name>` | `0.1.0-<feature>-<sha>-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.
-34
View File
@@ -1,34 +0,0 @@
# 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).
+8 -37
View File
@@ -18,24 +18,16 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.3.0-SNAPSHOT</revision>
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<argLine></argLine>
<jedis.version>7.2.0</jedis.version>
<!-- Published QQQ 4.1 core. 4.1.0-RC.22 is the first release-line core that carries
QSessionStoreRegistry.getInstance(QInstance) and clearIfSame, which the
application-scoped SHARED_REDIS provider requires (RC.21 and earlier do not compile). -->
<qqq.version>4.1.0-RC.26</qqq.version>
<!-- Published qbit-redis release candidate whose public api (RedisAccess, RedisCache,
RedisNamespace) the SHARED_REDIS provider is built against. -->
<qbit-redis.version>0.2.0-RC.1</qbit-redis.version>
<jedis.version>5.1.0</jedis.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.2.1</version>
<version>1.4.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
@@ -86,7 +78,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.version}</version>
<version>4.1.0-RC.18</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -99,13 +91,6 @@
<artifactId>qqq-backend-core</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-redis</artifactId>
<version>${qbit-redis.version}</version>
<optional>true</optional>
</dependency>
<!-- Redis client (optional - for RedisSessionStoreProvider) -->
<dependency>
<groupId>redis.clients</groupId>
@@ -115,22 +100,11 @@
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<version>${qqq.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-postgres</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
@@ -149,7 +123,7 @@
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.7</version>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
@@ -164,13 +138,13 @@
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>2.0.3</version>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<version>2.0.3</version>
<artifactId>junit-jupiter</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
</dependencies>
@@ -198,9 +172,6 @@
<include>**/*IntegrationTest.java</include>
<include>**/*IT.java</include>
</includes>
<!-- PostgreSQL instants (TableBasedSessionStorePostgresIT) are only correct under a UTC JVM.
@{argLine} keeps the JaCoCo agent that prepare-agent puts in the argLine property. -->
<argLine>@{argLine} -Duser.timezone=UTC</argLine>
</configuration>
<executions>
<execution>
@@ -18,12 +18,9 @@ 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;
/*******************************************************************************
@@ -36,23 +33,12 @@ 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());
};
@@ -24,6 +24,5 @@ public enum QSessionStoreProviderType
IN_MEMORY,
TABLE_BASED,
REDIS,
SHARED_REDIS,
CUSTOM
}
@@ -30,7 +30,6 @@ 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;
@@ -78,18 +77,6 @@ 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)
@@ -547,23 +534,4 @@ 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;
}
}
@@ -16,144 +16,63 @@
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;
/*******************************************************************************
** Application-scoped configuration and provider access for session-store actions.
** Static context holder for the Session Store QBit configuration and provider.
*******************************************************************************/
public class QSessionStoreQBitContext
{
private static final Map<QInstance, QSessionStoreQBitConfig> CONFIGS = new WeakHashMap<>();
private static final Map<QInstance, QSessionStoreProviderInterface> PROVIDERS = new WeakHashMap<>();
private static QSessionStoreQBitConfig config;
private static QSessionStoreProviderInterface provider;
/***************************************************************************
** Get configuration for the current application.
** Getter for config
***************************************************************************/
public static QSessionStoreQBitConfig getConfig()
{
return getConfig(QContext.getQInstance());
return 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.
** Setter for config
***************************************************************************/
public static void setConfig(QSessionStoreQBitConfig config)
{
setConfig(QContext.getQInstance(), config);
QSessionStoreQBitContext.config = config;
}
/***************************************************************************
** 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.
** Getter for provider
***************************************************************************/
public static QSessionStoreProviderInterface getProvider()
{
return getProvider(QContext.getQInstance());
return 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.
** Setter for provider
***************************************************************************/
public static void setProvider(QSessionStoreProviderInterface provider)
{
setProvider(QContext.getQInstance(), provider);
QSessionStoreQBitContext.provider = provider;
}
/***************************************************************************
** 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.
** Clear all context (useful for testing).
***************************************************************************/
public static void clear()
{
clear(QContext.getQInstance());
config = null;
provider = null;
}
/***************************************************************************
** 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;
}
}
@@ -60,13 +60,6 @@ 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 //
///////////////////////////////
@@ -82,6 +75,7 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Create provider instance //
///////////////////////////////
QSessionStoreProviderInterface provider = QSessionStoreProviderFactory.createProvider(config);
LOG.info("Created session store provider", logPair("type", config.getProviderType()));
///////////////////////////////
@@ -96,8 +90,7 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Produce cleanup process //
///////////////////////////////
if(Boolean.TRUE.equals(config.getEnableCleanupProcess())
&& config.getProviderType() != QSessionStoreProviderType.SHARED_REDIS)
if(Boolean.TRUE.equals(config.getEnableCleanupProcess()))
{
CleanExpiredSessionsProcessMetaDataProducer.produce(qInstance, config);
LOG.info("Produced cleanup process");
@@ -106,17 +99,13 @@ public class QSessionStoreQBitProducer implements QBitProducer
///////////////////////////////
// Store in context //
///////////////////////////////
QSessionStoreQBitContext.setConfig(qInstance, config);
QSessionStoreQBitContext.setProvider(qInstance, provider);
QSessionStoreQBitContext.setConfig(config);
QSessionStoreQBitContext.setProvider(provider);
///////////////////////////////
// Register with core registry //
///////////////////////////////
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));
}
QSessionStoreRegistry.getInstance().register(provider);
LOG.info("Registered session store provider with core registry");
}
@@ -1,151 +0,0 @@
/*
* 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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.json.JSONObject;
/*******************************************************************************
** Shared at-rest rules for every persistent provider.
**
** A session id is the credential that resumes the session (the cookie value of a
** native login), so it is never written to a table, Redis key, log line or the
** stored JSON. Providers address a session by {@link #key(String)} (a one-way
** digest) and store {@link #encode(String, QSession)}, an envelope whose session
** has its own uuid and id reference removed; {@link #decode(String, String)}
** restores them from the id the caller presented. A reader that does not hold
** the id therefore cannot resume anything from a copy of the store, and an
** envelope read under another id is refused.
*******************************************************************************/
public final class SessionCodec
{
private static final String KEY_DOMAIN = "qbit-session-store:v1:";
private static final int VERSION = 1;
private static final int LOG_LENGTH = 12;
/***************************************************************************
** Utility class.
***************************************************************************/
private SessionCodec()
{
}
/***************************************************************************
** The storage key for a session id: lower-case hex SHA-256 of a domain
** separated id.
***************************************************************************/
public static String key(String sessionId)
{
Objects.requireNonNull(sessionId, "Session id is required");
if(sessionId.isBlank())
{
throw new IllegalArgumentException("Session id is required");
}
try
{
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((KEY_DOMAIN + sessionId).getBytes(StandardCharsets.UTF_8)));
}
catch(NoSuchAlgorithmException e)
{
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
/***************************************************************************
** A short non-reversible tag for log lines.
***************************************************************************/
public static String logTag(String sessionId)
{
return sessionId == null || sessionId.isBlank() ? "none" : key(sessionId).substring(0, LOG_LENGTH);
}
/***************************************************************************
** Serialize a session for storage under the given id, without the id.
***************************************************************************/
public static String encode(String sessionId, QSession session)
{
Objects.requireNonNull(session, "Session is required");
JSONObject stored = new JSONObject(JsonUtils.toJson(session));
boolean uuidStripped = sessionId.equals(stored.optString("uuid", null));
boolean refStripped = sessionId.equals(stored.optString("idReference", null));
if(uuidStripped)
{
stored.remove("uuid");
}
if(refStripped)
{
stored.remove("idReference");
}
return new JSONObject()
.put("v", VERSION)
.put("keyDigest", key(sessionId))
.put("uuidIsId", uuidStripped)
.put("idReferenceIsId", refStripped)
.put("session", stored)
.toString();
}
/***************************************************************************
** Restore a stored envelope for the id the caller presented; anything that
** is not an envelope for exactly this id is refused.
***************************************************************************/
public static QSession decode(String sessionId, String json) throws IOException
{
JSONObject envelope;
try
{
envelope = new JSONObject(json);
}
catch(RuntimeException e)
{
throw new IOException("Stored session is not readable", e);
}
if(envelope.optInt("v", -1) != VERSION || !envelope.has("session") || !key(sessionId).equals(envelope.optString("keyDigest", null)))
{
throw new IOException("Stored session does not belong to the presented id");
}
JSONObject stored = envelope.getJSONObject("session");
if(envelope.optBoolean("uuidIsId"))
{
stored.put("uuid", sessionId);
}
if(envelope.optBoolean("idReferenceIsId"))
{
stored.put("idReference", sessionId);
}
return JsonUtils.toObject(stored.toString(), QSession.class);
}
}
@@ -1,147 +0,0 @@
/*
* 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<QInstance, Object> 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;
}
}
@@ -1,44 +0,0 @@
/*
* 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");
}
}
}
@@ -1,175 +0,0 @@
/*
* 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.ArrayList;
import java.util.List;
import java.util.function.UnaryOperator;
import com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** The PostgreSQL schema of the TABLE_BASED provider, derived from the very table
** metadata the producer installs, so the two cannot drift apart. A migration
** identity (the schema owner) applies it once; the provider itself only ever does
** DML and needs no DDL, history or ownership rights.
**
** QQQ quotes every identifier, so by default the physical names are exactly the
** metadata names (quoted). An application that maps the table onto another naming
** rule (for example snake_case through RDBMSTableBackendDetails and field backend
** names) passes that rule as the naming function, and gets the matching DDL.
*******************************************************************************/
public final class StoredSessionSchema
{
private static final UnaryOperator<String> EXACT = UnaryOperator.identity();
/***************************************************************************
** Utility class.
***************************************************************************/
private StoredSessionSchema()
{
}
/***************************************************************************
** The physical table name (exact metadata name).
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config)
{
return physicalTableName(config, EXACT);
}
/***************************************************************************
** The physical table name under a naming rule.
***************************************************************************/
public static String physicalTableName(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
return naming.apply(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** The physical column names (exact metadata names).
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config)
{
return physicalColumnNames(config, EXACT);
}
/***************************************************************************
** The physical column names under a naming rule.
***************************************************************************/
public static List<String> physicalColumnNames(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
List<String> names = new ArrayList<>();
for(QFieldMetaData field : table(config).getFields().values())
{
names.add(naming.apply(field.getName()));
}
return names;
}
/***************************************************************************
** Idempotent DDL with exact names.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config)
{
return postgresDdl(config, EXACT);
}
/***************************************************************************
** Idempotent DDL under a naming rule: the table, its unique key on the
** session-key digest and an index serving expiry cleanup. Identifiers are
** always quoted, as QQQ quotes them in its own statements.
***************************************************************************/
public static String postgresDdl(QSessionStoreQBitConfig config, UnaryOperator<String> naming)
{
QTableMetaData table = table(config);
String name = physicalTableName(config, naming);
String key = naming.apply(StoredSession.FIELD_SESSION_UUID);
String exp = naming.apply(StoredSession.FIELD_EXPIRES_AT);
List<String> lines = new ArrayList<>();
for(QFieldMetaData field : table.getFields().values())
{
lines.add(" " + quote(naming.apply(field.getName())) + " " + type(table, field));
}
lines.add(" CONSTRAINT " + quote("uq_" + name + "_" + key) + " UNIQUE (" + quote(key) + ")");
return "CREATE TABLE IF NOT EXISTS " + quote(name) + " (\n" + String.join(",\n", lines) + "\n);\n"
+ "CREATE INDEX IF NOT EXISTS " + quote("ix_" + name + "_" + exp) + " ON " + quote(name) + " (" + quote(exp) + ");\n";
}
/***************************************************************************
** Quote an identifier.
***************************************************************************/
public static String quote(String identifier)
{
return "\"" + identifier.replace("\"", "\"\"") + "\"";
}
/***************************************************************************
** The producer's table, built in a scratch instance.
***************************************************************************/
private static QTableMetaData table(QSessionStoreQBitConfig config)
{
QInstance scratch = new QInstance();
StoredSessionTableMetaDataProducer.produce(scratch, config);
return scratch.getTable(config.applyPrefix(config.getTableName()));
}
/***************************************************************************
** Column type and constraints of one field.
***************************************************************************/
private static String type(QTableMetaData table, QFieldMetaData field)
{
if(field.getName().equals(table.getPrimaryKeyField()))
{
return "INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY";
}
String base = switch(field.getType())
{
case STRING, PASSWORD -> field.getMaxLength() == null ? "TEXT" : "VARCHAR(" + field.getMaxLength() + ")";
case TEXT, HTML -> "TEXT";
case DATE_TIME -> "TIMESTAMP WITH TIME ZONE";
default -> throw new IllegalStateException("Unsupported stored-session field type " + field.getType());
};
return base + (Boolean.TRUE.equals(field.getIsRequired()) ? " NOT NULL" : "");
}
}
@@ -22,19 +22,12 @@ import com.kingsrook.qbits.sessionstore.model.StoredSession;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.DenyBehavior;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Metadata producer for the StoredSession table.
**
** The table is internal: hidden, and protected by its own permission set that no
** role is given (the provider reads and writes it as the system user). Its rows
** are session-key digests and id-free envelopes, never session ids.
*******************************************************************************/
public class StoredSessionTableMetaDataProducer
{
@@ -49,11 +42,6 @@ public class StoredSessionTableMetaDataProducer
QTableMetaData table = new QTableMetaData()
.withName(tableName)
.withLabel("Stored Session")
.withIsHidden(true)
.withPermissionRules(new QPermissionRules()
.withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS)
.withPermissionBaseName(tableName)
.withDenyBehavior(DenyBehavior.HIDDEN))
.withBackendName(config.getBackendName())
.withPrimaryKeyField(StoredSession.FIELD_ID)
.withRecordLabelFormat("%s")
@@ -61,10 +49,10 @@ public class StoredSessionTableMetaDataProducer
.withUniqueKey(new UniqueKey(StoredSession.FIELD_SESSION_UUID))
.withField(new QFieldMetaData(StoredSession.FIELD_ID, QFieldType.INTEGER).withIsEditable(false))
.withField(new QFieldMetaData(StoredSession.FIELD_SESSION_UUID, QFieldType.STRING)
.withLabel("Session Key (digest)")
.withLabel("Session UUID")
.withIsRequired(true)
.withIsEditable(false)
.withMaxLength(64))
.withMaxLength(36))
.withField(new QFieldMetaData(StoredSession.FIELD_USER_ID, QFieldType.STRING)
.withLabel("User ID")
.withIsEditable(false))
@@ -25,7 +25,6 @@ import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
@@ -58,24 +57,6 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
/***************************************************************************
** A request that mutates its session must not change what another request
** will load, so the store holds and returns copies.
***************************************************************************/
private static QSession copy(QSession session)
{
try
{
return session.clone();
}
catch(CloneNotSupportedException e)
{
throw new IllegalStateException("Session cannot be copied", e);
}
}
/***************************************************************************
** Configure the provider.
***************************************************************************/
@@ -118,8 +99,8 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
cache.put(sessionUuid, new CachedSession(copy(session), expiresAt));
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
cache.put(sessionUuid, new CachedSession(session, expiresAt));
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
}
@@ -139,11 +120,11 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
return Optional.of(copy(cached.session()));
return Optional.of(cached.session());
}
@@ -155,7 +136,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
public void remove(String sessionUuid)
{
cache.remove(sessionUuid);
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
}
@@ -171,7 +152,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
{
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
}
}
@@ -203,7 +184,7 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
if(cached.isExpired())
{
cache.remove(sessionUuid);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
@@ -212,9 +193,9 @@ public class InMemorySessionStoreProvider implements QSessionStoreProviderInterf
//////////////////////////////////////////////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
cache.put(sessionUuid, new CachedSession(cached.session(), newExpiresAt));
LOG.debug("Loaded and touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
return Optional.of(copy(cached.session()));
return Optional.of(cached.session());
}
@@ -20,9 +20,9 @@ import java.time.Duration;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
@@ -82,7 +82,7 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
***************************************************************************/
private String buildKey(String sessionUuid)
{
return keyPrefix + SessionCodec.key(sessionUuid);
return keyPrefix + sessionUuid;
}
@@ -97,14 +97,14 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
String key = buildKey(sessionUuid);
String sessionJson = SessionCodec.encode(sessionUuid, session);
String sessionJson = JsonUtils.toJson(session);
jedis.setex(key, effectiveTtl.toSeconds(), sessionJson);
LOG.debug("Stored session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("ttlSeconds", effectiveTtl.toSeconds()));
LOG.debug("Stored session in Redis", logPair("sessionUuid", sessionUuid), logPair("ttlSeconds", effectiveTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to store session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to store session in Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -126,12 +126,12 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load session from Redis", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -148,11 +148,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.del(key);
LOG.debug("Removed session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session from Redis", logPair("sessionUuid", sessionUuid));
}
catch(Exception e)
{
LOG.warn("Failed to remove session from Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to remove session from Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -168,11 +168,11 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
{
String key = buildKey(sessionUuid);
jedis.expire(key, defaultTtl.toSeconds());
LOG.debug("Touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
LOG.debug("Touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
}
catch(Exception e)
{
LOG.warn("Failed to touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to touch session in Redis", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -229,13 +229,13 @@ public class RedisSessionStoreProvider implements QSessionStoreProviderInterface
return Optional.empty();
}
QSession session = SessionCodec.decode(sessionUuid, sessionJson);
LOG.debug("Loaded and touched session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newTtlSeconds", defaultTtl.toSeconds()));
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session in Redis", logPair("sessionUuid", sessionUuid), logPair("newTtlSeconds", defaultTtl.toSeconds()));
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session in Redis", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load and touch session in Redis", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -1,235 +0,0 @@
/*
* 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.SessionCodec;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Session rules over application-owned Redis infrastructure. Never owns a pool.
*******************************************************************************/
public final class SharedRedisSessionStoreProvider implements QSessionStoreProviderInterface
{
private final WeakReference<QInstance> 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(SessionCodec.key(id), SessionCodec.encode(id, 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<QSession> load(String id)
{
return decode(id, cache().get(SessionCodec.key(id)));
}
/***************************************************************************
** Failed eviction must propagate so logout can retry rather than claim success.
***************************************************************************/
@Override
public void remove(String id)
{
cache().remove(SessionCodec.key(id));
}
/***************************************************************************
** Explicit renewal never recreates an expired or deleted session.
***************************************************************************/
@Override
public void touch(String id)
{
cache().touch(SessionCodec.key(id), defaultTtl);
}
/***************************************************************************
** Respect sliding-expiration configuration with one atomic read/renew command.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String id)
{
String key = SessionCodec.key(id);
return decode(id, sliding ? cache().getAndTouch(key, defaultTtl) : cache().get(key));
}
/***************************************************************************
** Bound decoded payloads and never include session contents in error messages.
***************************************************************************/
private Optional<QSession> decode(String id, Optional<byte[]> value)
{
return value.map(bytes ->
{
if(bytes.length > settings.maximumValueBytes())
{
throw new IllegalStateException("Stored session exceeds its configured size limit");
}
try
{
return Objects.requireNonNull(SessionCodec.decode(id, new String(bytes, StandardCharsets.UTF_8)));
}
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";
}
}
@@ -16,26 +16,23 @@
package com.kingsrook.qbits.sessionstore.providers;
import java.io.IOException;
import java.io.Serializable;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.model.StoredSession;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction;
import com.kingsrook.qqq.backend.core.actions.tables.GetAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput;
@@ -43,22 +40,19 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** Table-based session store provider using QQQ table actions.
** Best for: multi-instance deployments, persistence across restarts.
**
** Rows are addressed by {@link SessionCodec#key(String)} and hold an envelope that
** does not contain the session id, so the table never holds a credential that
** resumes a session. Storing a session that is already present replaces its data:
** a stale copy (old permissions, old tenant keys) is never left in place, and when
** a store cannot complete the previous copy is removed.
*******************************************************************************/
public class TableBasedSessionStoreProvider implements QSessionStoreProviderInterface
{
@@ -67,7 +61,6 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
private QSessionStoreQBitConfig config;
private String tableName;
private Duration defaultTtl;
private boolean sliding;
@@ -80,196 +73,85 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
this.config = config;
this.tableName = config.applyPrefix(config.getTableName());
this.defaultTtl = config.getDefaultTtl();
this.sliding = !Boolean.FALSE.equals(config.getEnableSlidingExpiration());
LOG.info("TableBased session store configured", logPair("tableName", tableName));
}
/***************************************************************************
** Store a session with the given TTL, replacing any existing copy.
** Store a session with the given TTL.
***************************************************************************/
@Override
public void store(String sessionUuid, QSession session, Duration ttl)
{
String key = SessionCodec.key(sessionUuid);
try
{
Duration effectiveTtl = ttl != null ? ttl : defaultTtl;
Instant expiresAt = Instant.now().plus(effectiveTtl);
String stored = SessionCodec.encode(sessionUuid, session);
String userId = session.getUser() != null ? session.getUser().getIdReference() : null;
Instant expiresAt = Instant.now().plus(effectiveTtl);
String sessionJson = JsonUtils.toJson(session);
QRecord record = new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, sessionUuid)
.withValue(StoredSession.FIELD_USER_ID, session.getUser() != null ? session.getUser().getIdReference() : null)
.withValue(StoredSession.FIELD_SESSION_DATA, sessionJson)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt);
runWithSystemSession(() ->
{
try
{
upsert(key, userId, stored, expiresAt);
}
catch(QException e)
{
///////////////////////////////////////////////////////////////////////
// two instances storing the same new session race on the unique key: //
// the loser retries once, as an update //
///////////////////////////////////////////////////////////////////////
upsert(key, userId, stored, expiresAt);
}
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(record));
new InsertAction().execute(insertInput);
return null;
});
LOG.debug("Stored session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("expiresAt", expiresAt));
LOG.debug("Stored session", logPair("sessionUuid", sessionUuid), logPair("expiresAt", expiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to store session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
remove(sessionUuid);
LOG.warn("Failed to store session", logPair("sessionUuid", sessionUuid), e);
}
}
/***************************************************************************
** Insert or update the one row for a key.
***************************************************************************/
private void upsert(String key, String userId, String stored, Instant expiresAt) throws QException
{
QRecord existing = get(key);
if(existing == null)
{
InsertInput insertInput = new InsertInput();
insertInput.setTableName(tableName);
insertInput.setRecords(List.of(new QRecord()
.withValue(StoredSession.FIELD_SESSION_UUID, key)
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new InsertAction().execute(insertInput).getRecords());
}
else
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", existing.getValue("id"))
.withValue(StoredSession.FIELD_USER_ID, userId)
.withValue(StoredSession.FIELD_SESSION_DATA, stored)
.withValue(StoredSession.FIELD_EXPIRES_AT, expiresAt)));
requireNoErrors(new UpdateAction().execute(updateInput).getRecords());
}
}
/***************************************************************************
** A record-level error (unique key, constraint) is a failed store.
***************************************************************************/
private static void requireNoErrors(List<QRecord> records) throws QException
{
for(QRecord record : records)
{
if(record.getErrors() != null && !record.getErrors().isEmpty())
{
throw new QException("Session store write was rejected");
}
}
}
/***************************************************************************
** The stored row for a storage key, or null.
***************************************************************************/
private QRecord get(String key) throws QException
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, key));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
}
/***************************************************************************
** Load a session by id.
** Load a session by UUID.
***************************************************************************/
@Override
public Optional<QSession> load(String sessionUuid)
{
return read(sessionUuid, false);
}
/***************************************************************************
** Load a session and, when sliding expiration is on, renew it.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
return read(sessionUuid, sliding);
}
/***************************************************************************
** Shared read: expired, unreadable and mismatched rows are removed and
** reported as a miss.
***************************************************************************/
private Optional<QSession> read(String sessionUuid, boolean touch)
{
String key = SessionCodec.key(sessionUuid);
try
{
return runWithSystemSession(() ->
QRecord record = runWithSystemSession(() ->
{
QRecord record = get(key);
if(record == null)
{
return Optional.<QSession>empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
deleteByKey(key);
LOG.debug("Session expired", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
QSession session;
try
{
session = SessionCodec.decode(sessionUuid, record.getValueString(StoredSession.FIELD_SESSION_DATA));
String storedUser = record.getValueString(StoredSession.FIELD_USER_ID);
String sessionUser = session.getUser() != null ? session.getUser().getIdReference() : null;
if(!Objects.equals(storedUser, sessionUser))
{
throw new IOException("Stored session identity does not match its row");
}
}
catch(IOException | RuntimeException e)
{
deleteByKey(key);
LOG.warn("Discarded an unreadable stored session", logPair("session", SessionCodec.logTag(sessionUuid)));
return Optional.<QSession>empty();
}
if(touch)
{
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, Instant.now().plus(defaultTtl))));
new UpdateAction().execute(updateInput);
}
return Optional.of(session);
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
return getOutput.getRecord();
});
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
remove(sessionUuid);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.empty();
}
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
return Optional.of(session);
}
catch(Exception e)
{
LOG.warn("Failed to load session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to load session", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
@@ -277,7 +159,7 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Remove a session by id.
** Remove a session by UUID.
***************************************************************************/
@Override
public void remove(String sessionUuid)
@@ -286,35 +168,26 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
runWithSystemSession(() ->
{
deleteByKey(SessionCodec.key(sessionUuid));
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
return null;
});
LOG.debug("Removed session", logPair("session", SessionCodec.logTag(sessionUuid)));
LOG.debug("Removed session", logPair("sessionUuid", sessionUuid));
}
catch(Exception e)
{
LOG.warn("Failed to remove session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to remove session", logPair("sessionUuid", sessionUuid), e);
}
}
/***************************************************************************
** Delete the row for a storage key.
***************************************************************************/
private void deleteByKey(String key) throws QException
{
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, key)));
new DeleteAction().execute(deleteInput);
}
/***************************************************************************
** Touch a session to reset its TTL; never recreates a missing session.
** Touch a session to reset its TTL.
***************************************************************************/
@Override
public void touch(String sessionUuid)
@@ -325,24 +198,33 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
runWithSystemSession(() ->
{
QRecord record = get(SessionCodec.key(sessionUuid));
if(record != null)
/////////////////////////////////////////////////////////////////
// First get the record to find its primary key //
/////////////////////////////////////////////////////////////////
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
if(getOutput.getRecord() != null)
{
QRecord updateRecord = new QRecord()
.withValue("id", getOutput.getRecord().getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt)));
updateInput.setRecords(List.of(updateRecord));
new UpdateAction().execute(updateInput);
}
return null;
});
LOG.debug("Touched session", logPair("session", SessionCodec.logTag(sessionUuid)), logPair("newExpiresAt", newExpiresAt));
LOG.debug("Touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
}
catch(Exception e)
{
LOG.warn("Failed to touch session", logPair("session", SessionCodec.logTag(sessionUuid)), e);
LOG.warn("Failed to touch session", logPair("sessionUuid", sessionUuid), e);
}
}
@@ -359,6 +241,83 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
/***************************************************************************
** Load a session and touch it in a single operation.
**
** Optimized to perform both load and TTL update in one database round-trip
** by updating the expiresAt during the same get+update operation.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String sessionUuid)
{
try
{
return runWithSystemSession(() ->
{
GetInput getInput = new GetInput();
getInput.setTableName(tableName);
getInput.setUniqueKey(Map.of(StoredSession.FIELD_SESSION_UUID, sessionUuid));
GetOutput getOutput = new GetAction().execute(getInput);
QRecord record = getOutput.getRecord();
if(record == null)
{
return Optional.empty();
}
Instant expiresAt = record.getValueInstant(StoredSession.FIELD_EXPIRES_AT);
if(expiresAt != null && Instant.now().isAfter(expiresAt))
{
///////////////////////////////
// Session is expired, clean up //
///////////////////////////////
DeleteInput deleteInput = new DeleteInput();
deleteInput.setTableName(tableName);
deleteInput.setQueryFilter(new QQueryFilter()
.withCriteria(new QFilterCriteria(StoredSession.FIELD_SESSION_UUID, QCriteriaOperator.EQUALS, sessionUuid)));
new DeleteAction().execute(deleteInput);
LOG.debug("Session expired", logPair("sessionUuid", sessionUuid));
return Optional.<QSession>empty();
}
///////////////////////////////
// Update expiresAt (touch) //
///////////////////////////////
Instant newExpiresAt = Instant.now().plus(defaultTtl);
QRecord updateRecord = new QRecord()
.withValue("id", record.getValue("id"))
.withValue(StoredSession.FIELD_EXPIRES_AT, newExpiresAt);
UpdateInput updateInput = new UpdateInput();
updateInput.setTableName(tableName);
updateInput.setRecords(List.of(updateRecord));
new UpdateAction().execute(updateInput);
///////////////////////////////
// Return the session //
///////////////////////////////
String sessionJson = record.getValueString(StoredSession.FIELD_SESSION_DATA);
try
{
QSession session = JsonUtils.toObject(sessionJson, QSession.class);
LOG.debug("Loaded and touched session", logPair("sessionUuid", sessionUuid), logPair("newExpiresAt", newExpiresAt));
return Optional.of(session);
}
catch(java.io.IOException e)
{
throw new QException("Failed to deserialize session", e);
}
});
}
catch(Exception e)
{
LOG.warn("Failed to load and touch session", logPair("sessionUuid", sessionUuid), e);
return Optional.empty();
}
}
/***************************************************************************
** Clean up expired sessions.
***************************************************************************/
@@ -397,9 +356,11 @@ public class TableBasedSessionStoreProvider implements QSessionStoreProviderInte
{
long count = runWithSystemSession(() ->
{
CountInput countInput = new CountInput();
countInput.setTableName(tableName);
return new CountAction().execute(countInput).getCount().longValue();
QueryInput queryInput = new QueryInput();
queryInput.setTableName(tableName);
queryInput.setFilter(new QQueryFilter());
QueryOutput queryOutput = new QueryAction().execute(queryInput);
return (long) queryOutput.getRecords().size();
});
return "TableBased: " + count + " sessions in " + tableName;
}
@@ -1,101 +0,0 @@
/*
* 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();
}
}
}
}
@@ -18,7 +18,6 @@ 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;
@@ -64,19 +63,14 @@ class QSessionStoreQBitProducerTest
assertThat(qInstance.getQBits()).hasSize(1);
// Verify provider was created and stored in context
assertThat(QSessionStoreQBitContext.getProvider(qInstance)).isInstanceOf(InMemorySessionStoreProvider.class);
assertThat(QSessionStoreQBitContext.getConfig(qInstance)).isSameAs(config);
assertThat(QSessionStoreQBitContext.getProvider()).isInstanceOf(InMemorySessionStoreProvider.class);
assertThat(QSessionStoreQBitContext.getConfig()).isSameAs(config);
// Verify cleanup process was created
assertThat(qInstance.getProcess("cleanExpiredSessions")).isNotNull();
// Verify registered with core registry
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);
assertThat(QSessionStoreRegistry.getInstance().getProvider()).isPresent();
}
@@ -149,41 +143,4 @@ 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);
}
}
}
@@ -1,110 +0,0 @@
/*
* 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.io.IOException;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** Unit tests for the at-rest rules shared by the persistent providers.
*******************************************************************************/
class SessionCodecTest
{
private static final String ID = "3f1b6d3c-6b0e-4f2d-9d52-0a7d9ad1c001";
/***************************************************************************
** The key is a stable one-way digest that does not contain the id.
***************************************************************************/
@Test
void testKeyIsStableDigestWithoutId()
{
String key = SessionCodec.key(ID);
assertThat(key).matches("[0-9a-f]{64}").doesNotContain(ID).isEqualTo(SessionCodec.key(ID)).isNotEqualTo(SessionCodec.key(ID + "x"));
assertThat(SessionCodec.logTag(ID)).hasSize(12).doesNotContain(ID);
assertThat(SessionCodec.logTag(null)).isEqualTo("none");
assertThatThrownBy(() -> SessionCodec.key(" ")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> SessionCodec.key(null)).isInstanceOf(NullPointerException.class);
}
/***************************************************************************
** The stored text never contains the id, and decoding restores it.
***************************************************************************/
@Test
void testEncodeRemovesIdAndDecodeRestoresIt() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference(ID);
session.setUser(new QUser().withIdReference("alice"));
session.setPermissions(Set.of("p.read"));
session.setValue("tenant", "t-1");
String stored = SessionCodec.encode(ID, session);
assertThat(stored).doesNotContain(ID).contains("alice").contains("p.read");
QSession decoded = SessionCodec.decode(ID, stored);
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo(ID);
assertThat(decoded.getUser().getIdReference()).isEqualTo("alice");
assertThat(decoded.getPermissions()).containsExactly("p.read");
assertThat(decoded.getValue("tenant")).isEqualTo("t-1");
}
/***************************************************************************
** A session whose id reference differs from its uuid keeps its own value.
***************************************************************************/
@Test
void testDifferingIdReferenceIsKept() throws IOException
{
QSession session = new QSession();
session.setUuid(ID);
session.setIdReference("alice@example.com");
QSession decoded = SessionCodec.decode(ID, SessionCodec.encode(ID, session));
assertThat(decoded.getUuid()).isEqualTo(ID);
assertThat(decoded.getIdReference()).isEqualTo("alice@example.com");
}
/***************************************************************************
** An envelope read under another id, a legacy plain session, and junk are all refused.
***************************************************************************/
@Test
void testForeignLegacyAndJunkAreRefused()
{
String stored = SessionCodec.encode(ID, new QSession().withValue("k", "v"));
assertThatThrownBy(() -> SessionCodec.decode(ID + "other", stored)).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, JsonUtils.toJson(new QSession().withValue("k", "v")))).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, "not json")).isInstanceOf(IOException.class);
assertThatThrownBy(() -> SessionCodec.decode(ID, stored.replace("\"v\":1", "\"v\":2"))).isInstanceOf(IOException.class);
}
}
@@ -1,238 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.github.dockerjava.api.command.InspectContainerResponse;
import org.testcontainers.DockerClientFactory;
import org.testcontainers.containers.GenericContainer;
/*******************************************************************************
** Where a disposable test container is reachable FROM THIS JVM.
**
** The original behavior is Testcontainers' own: the host it reports and the
** published port. That is kept everywhere except one measured topology: a JVM
** inside a build container that shares the daemon's default bridge with the
** fixture (the full Linux verify, which talks to the host daemon through its
** socket). On the Docker Desktop runner the published port is reached through
** the host-gateway hop; measured on an idle machine that path showed round trips
** of about 1 ms with stalls of 0.3 s and about 2 s, against about 0.13 ms and
** no stalls when dialing the sibling directly on the bridge. These tests use
** 250 ms to 1 s socket timeouts, so such stalls can expire them; that is a
** plausible contributor, not shown to be the only cause of the observed
** "Read timed out" failures. In that one topology the fixture is dialed on its
** bridge address and its own port.
**
** The choice is made from the daemon's own description of the build container's
** networks (no connection probing). If the build container cannot be identified
** or is not on the default bridge (a job network, host networking, a remote
** daemon), the original published endpoint is used unchanged. Timeouts,
** scenarios, assertions and server configuration are unchanged.
*******************************************************************************/
final class FixtureEndpoint
{
private static final String DEFAULT_BRIDGE = "bridge";
private static final Pattern MOUNTINFO_CONTAINER_ID = Pattern.compile("/docker/containers/([0-9a-f]{64})/");
private static volatile Boolean buildContainerOnDefaultBridge;
/***************************************************************************
** Host and port to dial.
***************************************************************************/
record Address(String host, int port)
{
}
/***************************************************************************
** Utility class.
***************************************************************************/
private FixtureEndpoint()
{
}
/***************************************************************************
** Host this JVM must dial for the container.
***************************************************************************/
static String host(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).host());
}
/***************************************************************************
** Port matching {@link #host(GenericContainer)}.
***************************************************************************/
static int port(GenericContainer<?> container)
{
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).port());
}
/***************************************************************************
** A container that lives only on a custom test network is also attached to the
** default bridge when (and only when) the build container is on that bridge, so
** that the build container can reach it directly. The test network, and therefore
** every partition scenario between the fixture servers, is untouched: attach only
** the container the test client dials, never one whose reachability the test cuts
** or whose peers must stay isolated from it. No-op in every other topology.
***************************************************************************/
static void attachToDefaultBridgeWhenInsideContainer(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return;
}
var docker = container.getDockerClient();
var networks = docker.inspectContainerCmd(container.getContainerId()).exec().getNetworkSettings().getNetworks();
if(networks == null || !networks.containsKey(DEFAULT_BRIDGE))
{
docker.connectToNetworkCmd().withContainerId(container.getContainerId()).withNetworkId(DEFAULT_BRIDGE).exec();
}
}
/***************************************************************************
** Whether this JVM runs in a container that the daemon reports on the default
** bridge. Decided once per JVM from the daemon's description of this container.
***************************************************************************/
private static boolean onBridgeWithBuildContainer()
{
Boolean known = buildContainerOnDefaultBridge;
if(known == null)
{
known = decideBuildContainerOnDefaultBridge();
buildContainerOnDefaultBridge = known;
}
return (known);
}
/***************************************************************************
** Inspect the build container; anything that cannot be established means the
** original published endpoint (false).
***************************************************************************/
private static boolean decideBuildContainerOnDefaultBridge()
{
if(!Files.exists(Path.of("/.dockerenv")))
{
return (false);
}
try
{
String id = ownContainerId(Files.readString(Path.of("/proc/self/mountinfo")), System.getenv("HOSTNAME"));
if(id == null)
{
return (false);
}
var networks = DockerClientFactory.instance().client().inspectContainerCmd(id).exec().getNetworkSettings().getNetworks();
return (networks != null && networks.containsKey(DEFAULT_BRIDGE));
}
catch(Exception e)
{
return (false);
}
}
/***************************************************************************
** The id of the container this JVM runs in: from the mount table when it names
** the container directory, otherwise the host name (the container id unless
** overridden); null when neither exists.
***************************************************************************/
static String ownContainerId(String mountInfo, String hostName)
{
Matcher matcher = MOUNTINFO_CONTAINER_ID.matcher(mountInfo == null ? "" : mountInfo);
if(matcher.find())
{
return (matcher.group(1));
}
return (hostName == null || hostName.isBlank() ? null : hostName.trim());
}
/***************************************************************************
** The container's address on the default bridge, or null when it has none.
***************************************************************************/
private static String bridgeIp(GenericContainer<?> container)
{
if(!onBridgeWithBuildContainer())
{
return (null);
}
InspectContainerResponse inspected = container.getDockerClient().inspectContainerCmd(container.getContainerId()).exec();
var networks = inspected.getNetworkSettings().getNetworks();
return (networks == null || networks.get(DEFAULT_BRIDGE) == null ? null : networks.get(DEFAULT_BRIDGE).getIpAddress());
}
/***************************************************************************
** Testcontainers' published port for the Redis port, or null when the build
** container dials the bridge (so the published port is not consulted).
***************************************************************************/
private static Integer publishedPort(GenericContainer<?> container)
{
return (onBridgeWithBuildContainer() ? null : container.getMappedPort(6379));
}
/***************************************************************************
** Pure decision, separately tested. With the build container on the default
** bridge: the sibling's bridge address and its own port. Otherwise the published
** endpoint exactly as Testcontainers reports it.
***************************************************************************/
static Address choose(boolean buildContainerOnDefaultBridge, String bridgeIp, int internalPort, String publishedHost, Integer publishedPort)
{
if(buildContainerOnDefaultBridge)
{
if(bridgeIp == null || bridgeIp.isBlank())
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NO_BRIDGE_ADDRESS: the build container cannot reach a sibling without its bridge address");
}
return (new Address(bridgeIp, internalPort));
}
if(publishedHost == null || publishedHost.isBlank() || publishedPort == null)
{
throw new IllegalStateException("FIXTURE_ENDPOINT_NOT_PUBLISHED: no published endpoint to dial");
}
return (new Address(publishedHost, publishedPort));
}
}
@@ -1,92 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* 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
*
* https://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 org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/*******************************************************************************
** The topology decision of the sibling-container fixtures.
*******************************************************************************/
class FixtureEndpointTest
{
private static final String CONTAINER_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
/***************************************************************************
** Build container on the default bridge: dial the sibling bridge IP and its own port, never the published port.
***************************************************************************/
@Test
void buildContainerOnTheBridgeUsesTheBridgeAddress()
{
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, null, null));
}
/***************************************************************************
** Every other topology (JVM on the Docker host, remote daemon, job network, host networking, unidentified
** build container) keeps the original Testcontainers host and published port exactly, whatever the host is.
***************************************************************************/
@Test
void everyOtherTopologyKeepsTheOriginalPublishedEndpoint()
{
assertEquals(new FixtureEndpoint.Address("localhost", 41111), FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", 41111));
assertEquals(new FixtureEndpoint.Address("docker.example.test", 32768), FixtureEndpoint.choose(false, null, 6379, "docker.example.test", 32768));
assertEquals(new FixtureEndpoint.Address("host.docker.internal", 40000), FixtureEndpoint.choose(false, null, 6379, "host.docker.internal", 40000));
}
/***************************************************************************
** An unknown endpoint is an error, never a guessed address.
***************************************************************************/
@Test
void missingEndpointFailsClosed()
{
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, null, 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, " ", 6379, "localhost", 41111));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", null));
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, " ", 41111));
}
/***************************************************************************
** The build container id comes from the mount table when present, else the host name.
***************************************************************************/
@Test
void ownContainerIdPrefersTheMountTableThenTheHostName()
{
String mountInfo = "700 699 0:50 /docker/containers/" + CONTAINER_ID + "/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw";
assertEquals(CONTAINER_ID, FixtureEndpoint.ownContainerId(mountInfo, "unrelated"));
assertEquals("abc123", FixtureEndpoint.ownContainerId("no container mounts here", " abc123 "));
assertNull(FixtureEndpoint.ownContainerId("", null));
assertNull(FixtureEndpoint.ownContainerId(null, " "));
}
}
@@ -27,33 +27,57 @@ 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; unavailable infrastructure fails verification.
** These tests require Docker to be running. If Docker is not available,
** the tests will be skipped.
*******************************************************************************/
class RedisSessionStoreProviderIT
{
private static final int REDIS_PORT = 6379;
private static boolean dockerAvailable = false;
private static GenericContainer<?> redis;
private RedisSessionStoreProvider provider;
/***************************************************************************
** Start required Redis infrastructure; failures must fail verification.
** Check Docker availability and start container if available.
***************************************************************************/
@BeforeAll
static void startContainer()
{
redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))
.withExposedPorts(REDIS_PORT);
redis.start();
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;
}
}
}
@@ -75,6 +99,9 @@ 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()
@@ -1,551 +0,0 @@
/*
* 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.SessionCodec;
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
{
/***************************************************************************
** 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();
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary);
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(replica);
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(SessionCodec.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.
***************************************************************************/
@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(SessionCodec.key("sliding"))) > 20000);
assertEquals(-2L, client.pttl(scope.key("sliding")), "the raw session id must never be a Redis key");
a.store("fixed", new QSession(), Duration.ofMillis(200));
assertTrue(b.loadAndTouch("fixed").isPresent());
assertTrue(client.pttl(scope.key(SessionCodec.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(SessionCodec.key("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<org.eclipse.jetty.server.Server>();
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<org.eclipse.jetty.server.Server>();
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<org.eclipse.jetty.server.Server>();
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<org.eclipse.jetty.server.Server>();
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<org.eclipse.jetty.server.Server> 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(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
.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;
}
}
@@ -1,491 +0,0 @@
/*
* 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.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderFactory;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderInterface;
import com.kingsrook.qbits.sessionstore.QSessionStoreProviderType;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitConfig;
import com.kingsrook.qbits.sessionstore.QSessionStoreQBitProducer;
import com.kingsrook.qbits.sessionstore.SessionCodec;
import com.kingsrook.qbits.sessionstore.StoredSessionSchema;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.module.postgres.model.metadata.PostgreSQLBackendMetaData;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/*******************************************************************************
** The TABLE_BASED provider on a real PostgreSQL 17, with the production
** separation of identities: a schema OWNER applies the DDL once, and the
** provider runs as a DML-only role that owns nothing and cannot create or alter
** anything. Two application instances (separate QInstances and providers)
** share the one database, as replicas of the host would.
*******************************************************************************/
class TableBasedSessionStorePostgresIT
{
private static final String OWNER = "qbit_owner";
private static final String RUNTIME = "qbit_runtime";
private static final String BACKEND = "sessionsPg";
private static final String TABLE = "qbitSessionStoreEntry";
private static GenericContainer<?> postgres;
private static String host;
private static int port;
private QSessionStoreQBitConfig config;
private QInstance instanceA;
private QInstance instanceB;
private QSessionStoreProviderInterface providerA;
private QSessionStoreProviderInterface providerB;
/***************************************************************************
** One PG17, owner-created schema, DML-only runtime role.
***************************************************************************/
@BeforeAll
static void startDatabase() throws Exception
{
postgres = new GenericContainer<>("postgres:17-alpine")
.withEnv("POSTGRES_PASSWORD", "super-synthetic")
.withExposedPorts(5432)
.waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2).withStartupTimeout(Duration.ofMinutes(2)));
postgres.start();
host = postgres.getHost();
port = postgres.getMappedPort(5432);
try(Connection admin = connect("postgres", "super-synthetic", "postgres"); Statement statement = admin.createStatement())
{
statement.execute("CREATE ROLE " + OWNER + " LOGIN PASSWORD 'owner-synthetic'");
statement.execute("CREATE ROLE " + RUNTIME + " LOGIN PASSWORD 'runtime-synthetic'");
statement.execute("CREATE DATABASE sessions OWNER " + OWNER);
statement.execute("REVOKE ALL ON DATABASE sessions FROM PUBLIC");
statement.execute("GRANT CONNECT ON DATABASE sessions TO " + RUNTIME);
}
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("REVOKE ALL ON SCHEMA public FROM PUBLIC");
statement.execute("GRANT USAGE ON SCHEMA public TO " + RUNTIME);
String ddl = StoredSessionSchema.postgresDdl(configFor());
statement.execute(ddl);
statement.execute(ddl); // idempotent
String table = StoredSessionSchema.physicalTableName(configFor());
statement.execute("GRANT SELECT, INSERT, UPDATE, DELETE ON " + StoredSessionSchema.quote(table) + " TO " + RUNTIME);
}
}
/***************************************************************************
** Stop the database.
***************************************************************************/
@AfterAll
static void stopDatabase()
{
if(postgres != null)
{
postgres.stop();
}
}
/***************************************************************************
** Fresh table content and two runtime instances per test.
***************************************************************************/
@BeforeEach
void setUp() throws Exception
{
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("DELETE FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(configFor())));
}
config = configFor();
instanceA = instance();
instanceB = instance();
QContext.init(instanceA, new QSession());
providerA = QSessionStoreProviderFactory.createProvider(instanceA, config);
QContext.init(instanceB, new QSession());
providerB = QSessionStoreProviderFactory.createProvider(instanceB, config);
}
/***************************************************************************
** Clear the context.
***************************************************************************/
@AfterEach
void tearDown()
{
QContext.clear();
}
/***************************************************************************
** Store and load as the DML-only role; a second instance sees it, and a
** removal on either instance revokes it on both.
***************************************************************************/
@Test
void testSharedAcrossInstancesAndRevocation()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceB, () -> providerB.load(id)).orElseThrow();
assertThat(seen.getUuid()).isEqualTo(id);
assertThat(seen.getUser().getIdReference()).isEqualTo("alice");
assertThat(seen.getValue("tenant")).isEqualTo("tenant-a");
assertThat(seen.getPermissions()).containsExactly("orders.read");
run(instanceB, () -> providerB.remove(id));
assertThat(on(instanceA, () -> providerA.load(id))).isEmpty();
assertThat(on(instanceA, () -> providerA.loadAndTouch(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** Re-storing a session replaces what was cached: narrower permissions or a
** different tenant never survive as a stale copy; one row remains.
***************************************************************************/
@Test
void testRestoreReplacesStaleCopy()
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.write", "orders.read"), Duration.ofMinutes(10)));
run(instanceB, () -> providerB.store(id, session(id, "alice", "tenant-b", "orders.read"), Duration.ofMinutes(10)));
QSession seen = on(instanceA, () -> providerA.load(id)).orElseThrow();
assertThat(seen.getValue("tenant")).isEqualTo("tenant-b");
assertThat(seen.getPermissions()).containsExactly("orders.read");
assertThat(rows()).hasSize(1);
}
/***************************************************************************
** Sessions of different actors and tenants stay separate, and a session id
** that was never stored loads nothing.
***************************************************************************/
@Test
void testActorAndTenantSeparation()
{
String idAlice = UUID.randomUUID().toString();
String idBob = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(idAlice, session(idAlice, "alice", "tenant-a", "a.read"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(idBob, session(idBob, "bob", "tenant-b", "b.read"), Duration.ofMinutes(10)));
QSession alice = on(instanceB, () -> providerB.load(idAlice)).orElseThrow();
QSession bob = on(instanceB, () -> providerB.load(idBob)).orElseThrow();
assertThat(alice.getUser().getIdReference()).isEqualTo("alice");
assertThat(alice.getValue("tenant")).isEqualTo("tenant-a");
assertThat(bob.getUser().getIdReference()).isEqualTo("bob");
assertThat(bob.getValue("tenant")).isEqualTo("tenant-b");
assertThat(on(instanceB, () -> providerB.load(UUID.randomUUID().toString()))).isEmpty();
assertThat(on(instanceB, () -> providerB.load(idAlice.toUpperCase()))).isEmpty();
}
/***************************************************************************
** The database holds no session id and no id-bearing JSON: only digests.
***************************************************************************/
@Test
void testDatabaseHoldsNoSessionId() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
List<String> columns = StoredSessionSchema.physicalColumnNames(config);
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT * FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
assertThat(rs.next()).isTrue();
for(String column : columns)
{
String value = rs.getString(column);
assertThat(value == null ? "" : value).as(column).doesNotContain(id);
}
assertThat(rs.getString("sessionUuid")).isEqualTo(SessionCodec.key(id));
}
}
/***************************************************************************
** An expired row is a miss and is removed; cleanup deletes only expired rows.
***************************************************************************/
@Test
void testExpiryAndCleanup() throws Exception
{
String live = UUID.randomUUID().toString();
String expired = UUID.randomUUID().toString();
String lazy = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(live, session(live, "alice", "t", "p"), Duration.ofMinutes(10)));
run(instanceA, () -> providerA.store(expired, session(expired, "bob", "t", "p"), Duration.ofMillis(1)));
run(instanceA, () -> providerA.store(lazy, session(lazy, "carol", "t", "p"), Duration.ofMillis(1)));
Thread.sleep(50);
assertThat(on(instanceB, () -> providerB.load(lazy))).isEmpty();
assertThat(rows()).hasSize(2);
run(instanceB, providerB::cleanExpired);
assertThat(rows()).hasSize(1);
assertThat(on(instanceA, () -> providerA.load(live))).isPresent();
assertThat(on(instanceA, () -> providerA.load(expired))).isEmpty();
}
/***************************************************************************
** A row whose identity column no longer matches its envelope is discarded
** rather than served.
***************************************************************************/
@Test
void testTamperedRowIsDiscarded() throws Exception
{
String id = UUID.randomUUID().toString();
run(instanceA, () -> providerA.store(id, session(id, "alice", "tenant-a", "orders.read"), Duration.ofMinutes(10)));
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement())
{
statement.execute("UPDATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " SET \"userId\" = 'mallory'");
}
assertThat(on(instanceB, () -> providerB.load(id))).isEmpty();
assertThat(rows()).isEmpty();
}
/***************************************************************************
** The runtime identity can do DML only: no DDL, no other relation, and the
** schema it was given is not its own.
***************************************************************************/
@Test
void testRuntimeRoleHasNoDdlOrOwnership() throws Exception
{
try(Connection runtime = connect(RUNTIME, "runtime-synthetic", "sessions"); Statement statement = runtime.createStatement())
{
assertThatThrownBy(() -> statement.execute("CREATE TABLE intruder (id int)")).isInstanceOf(SQLException.class).hasMessageContaining("permission denied");
assertThatThrownBy(() -> statement.execute("ALTER TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)) + " ADD COLUMN x int")).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("DROP TABLE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
assertThatThrownBy(() -> statement.execute("TRUNCATE " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config)))).isInstanceOf(SQLException.class);
try(ResultSet rs = statement.executeQuery("SELECT count(*) FROM pg_class c JOIN pg_roles r ON r.oid = c.relowner WHERE r.rolname = '" + RUNTIME + "'"))
{
rs.next();
assertThat(rs.getInt(1)).isZero();
}
}
}
/***************************************************************************
** The table is internal: hidden, and a signed-in user session, however
** privileged elsewhere, cannot query it through the core actions.
***************************************************************************/
@Test
void testTableIsInternal() throws Exception
{
QSessionStoreQBitConfig produced = configFor();
QInstance instance = baseInstance();
new QSessionStoreQBitProducer().withConfig(produced).produce(instance, "");
new QInstanceValidator().validate(instance);
assertThat(instance.getTable(TABLE).getIsHidden()).isTrue();
QSession user = new QSession();
user.setUser(new QUser().withIdReference("alice"));
user.setPermissions(Set.of("everything.read"));
QContext.init(instance, user);
assertThatThrownBy(() ->
{
QueryInput input = new QueryInput();
input.setTableName(TABLE);
new QueryAction().execute(input);
}).isInstanceOf(QException.class);
}
/***************************************************************************
** DDL is derived from the producer's metadata, idempotent, and names every
** field.
***************************************************************************/
@Test
void testDdlMatchesMetadata()
{
String ddl = StoredSessionSchema.postgresDdl(configFor());
assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS \"qbitSessionStoreEntry\"").contains("\"sessionUuid\" VARCHAR(64) NOT NULL")
.contains("GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY").contains("CREATE INDEX IF NOT EXISTS");
assertThat(StoredSessionSchema.physicalColumnNames(configFor())).containsExactlyInAnyOrder("id", "sessionUuid", "userId", "sessionData", "expiresAt", "createDate", "modifyDate");
String snake = StoredSessionSchema.postgresDdl(configFor(), com.kingsrook.qqq.backend.core.instances.QInstanceEnricher::inferBackendName);
assertThat(snake).contains("CREATE TABLE IF NOT EXISTS \"qbit_session_store_entry\"").contains("\"session_uuid\" VARCHAR(64) NOT NULL").contains("\"expires_at\" TIMESTAMP WITH TIME ZONE NOT NULL");
}
/***************************************************************************
** Run something with the given instance as the QQQ context, as one
** application replica would.
***************************************************************************/
private static <T> T on(QInstance instance, java.util.function.Supplier<T> action)
{
QContext.init(instance, new QSession());
return action.get();
}
/***************************************************************************
** Void variant.
***************************************************************************/
private static void run(QInstance instance, Runnable action)
{
QContext.init(instance, new QSession());
action.run();
}
/***************************************************************************
** The row count, read as the owner.
***************************************************************************/
private List<String> rows()
{
List<String> keys = new ArrayList<>();
try(Connection owner = connect(OWNER, "owner-synthetic", "sessions"); Statement statement = owner.createStatement();
ResultSet rs = statement.executeQuery("SELECT \"sessionUuid\" FROM " + StoredSessionSchema.quote(StoredSessionSchema.physicalTableName(config))))
{
while(rs.next())
{
keys.add(rs.getString(1));
}
}
catch(SQLException e)
{
throw new IllegalStateException(e);
}
return keys;
}
/***************************************************************************
** A synthetic session.
***************************************************************************/
private static QSession session(String id, String user, String tenant, String... permissions)
{
QSession session = new QSession();
session.setUuid(id);
session.setIdReference(id);
session.setUser(new QUser().withIdReference(user));
session.setPermissions(Set.of(permissions));
session.setValue("tenant", tenant);
return session;
}
/***************************************************************************
** The provider configuration.
***************************************************************************/
private static QSessionStoreQBitConfig configFor()
{
return new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.TABLE_BASED)
.withBackendName(BACKEND)
.withTableName(TABLE)
.withDefaultTtl(Duration.ofMinutes(30))
.withEnableCleanupProcess(false);
}
/***************************************************************************
** A runtime application instance on the DML-only role, enriched and
** validated the way a started server is.
***************************************************************************/
private static QInstance instance() throws QException
{
QInstance instance = baseInstance();
com.kingsrook.qbits.sessionstore.metadata.StoredSessionTableMetaDataProducer.produce(instance, configFor());
new QInstanceValidator().validate(instance);
return instance;
}
/***************************************************************************
** The application without the qbit: enforcement on, native PG backend as the
** DML-only role.
***************************************************************************/
private static QInstance baseInstance()
{
QInstance instance = new QInstance();
instance.setEnforcePermissionsInCoreActions(true);
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
PostgreSQLBackendMetaData backend = new PostgreSQLBackendMetaData();
backend.setName(BACKEND);
backend.setHostName(host);
backend.setPort(port);
backend.setDatabaseName("sessions");
backend.setUsername(RUNTIME);
backend.setPassword("runtime-synthetic");
instance.addBackend(backend);
return instance;
}
/***************************************************************************
** A JDBC connection as a named role.
***************************************************************************/
private static Connection connect(String user, String password, String database) throws SQLException
{
return DriverManager.getConnection("jdbc:postgresql://" + host + ":" + port + "/" + database, user, password);
}
}
@@ -110,7 +110,7 @@ class TableBasedSessionStoreProviderIT
conn.createStatement().execute("""
CREATE TABLE `storedSession` (
`id` INTEGER AUTO_INCREMENT PRIMARY KEY,
`sessionUuid` VARCHAR(64) NOT NULL UNIQUE,
`sessionUuid` VARCHAR(36) NOT NULL UNIQUE,
`userId` VARCHAR(255),
`sessionData` TEXT,
`expiresAt` TIMESTAMP NOT NULL,