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
james.maesandCodex 49f87072eb ci: publish GA artifacts only from stable tags (#13)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 01:09:12 -05:00
james.maesandCodex 703e16d849 ci: adopt shared orb 0.7.11 (#12)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:59:05 -05:00
james.maes c78ab8c6b7 Merge pull request #11 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:21:31 -05:00
james.maesandCodex a9adc28792 ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:08:24 -05:00
18 changed files with 66 additions and 1298 deletions

No files matched your search

+3 -5
View File
@@ -1,16 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.7.18
qqq-orb: kingsrook/qqq-orb@0.7.11
workflows:
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: 0.7.18
orb_version: 0.7.11
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only:
- /^v\d+\.\d+\.\d+$/
- /publish.*/
only: /^v\d+\.\d+\.\d+$/
+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 -2
View File
@@ -1,5 +1,5 @@
profile: qbit
orb_version: 0.7.18
orb_version: 0.7.11
default_branch: develop
java_version: '21'
browsers: true
@@ -10,7 +10,6 @@ release_tag_policy: stable
test_branch_policy: non-release
publish:
snapshot: true
feature: true
release_candidate: true
release: true
asciidoc: false
+2 -62
View File
@@ -138,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
@@ -197,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));
@@ -230,8 +175,3 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## 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).
+5 -19
View File
@@ -21,8 +21,7 @@
<revision>0.1.0-SNAPSHOT</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jedis.version>7.2.0</jedis.version>
<qqq.snapshot.version>4.1.0-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
<jedis.version>5.1.0</jedis.version>
</properties>
<parent>
@@ -79,7 +78,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<version>4.1.0-RC.18</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -92,13 +91,6 @@
<artifactId>qqq-backend-core</artifactId>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-redis</artifactId>
<version>0.1.0-redis-7fe5492-SNAPSHOT</version>
<optional>true</optional>
</dependency>
<!-- Redis client (optional - for RedisSessionStoreProvider) -->
<dependency>
<groupId>redis.clients</groupId>
@@ -108,12 +100,6 @@
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<version>${qqq.snapshot.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
@@ -152,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>
@@ -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,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,234 +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.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Session rules over application-owned Redis infrastructure. Never owns a pool.
*******************************************************************************/
public final class SharedRedisSessionStoreProvider implements QSessionStoreProviderInterface
{
private final WeakReference<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(id, JsonUtils.toJson(Objects.requireNonNull(session)).getBytes(StandardCharsets.UTF_8), validTtl(ttl == null ? defaultTtl : ttl));
}
/***************************************************************************
** Absence is a cache miss; infrastructure and decoding errors propagate.
***************************************************************************/
@Override
public Optional<QSession> load(String id)
{
return decode(cache().get(id));
}
/***************************************************************************
** Failed eviction must propagate so logout can retry rather than claim success.
***************************************************************************/
@Override
public void remove(String id)
{
cache().remove(id);
}
/***************************************************************************
** Explicit renewal never recreates an expired or deleted session.
***************************************************************************/
@Override
public void touch(String id)
{
cache().touch(id, defaultTtl);
}
/***************************************************************************
** Respect sliding-expiration configuration with one atomic read/renew command.
***************************************************************************/
@Override
public Optional<QSession> loadAndTouch(String id)
{
return decode(sliding ? cache().getAndTouch(id, defaultTtl) : cache().get(id));
}
/***************************************************************************
** Bound decoded payloads and never include session contents in error messages.
***************************************************************************/
private Optional<QSession> decode(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(JsonUtils.toObject(new String(bytes, StandardCharsets.UTF_8), QSession.class));
}
catch(IOException | RuntimeException e)
{
throw new IllegalStateException("Stored session could not be decoded");
}
});
}
/***************************************************************************
** Bound TTL arithmetic and prevent immortal or truncated sub-millisecond data.
***************************************************************************/
private static Duration validTtl(Duration ttl)
{
if(ttl == null || ttl.compareTo(Duration.ofMillis(1)) < 0 || ttl.compareTo(Duration.ofDays(365)) > 0)
{
throw new IllegalArgumentException("Session TTL must be between one millisecond and 365 days");
}
return ttl;
}
/***************************************************************************
** Report the configured session lifetime.
***************************************************************************/
@Override
public Duration getDefaultTtl()
{
return defaultTtl;
}
/***************************************************************************
** Redis owns expiration; no scans or wildcard deletes are needed.
***************************************************************************/
@Override
public void cleanExpired()
{
}
/***************************************************************************
** Health truth belongs to the shared runtime's framework health checks.
***************************************************************************/
@Override
public String status()
{
return "Shared Redis: health is reported by the Redis runtime";
}
}
@@ -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);
}
}
}
@@ -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,440 +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.SharedRedisSessionStoreRuntimeService;
import com.kingsrook.qbits.sessionstore.SharedRedisSessionStoreSettings;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.authentication.QSessionStoreRegistry;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;
import static org.junit.jupiter.api.Assertions.*;
/*******************************************************************************
** Real shared infrastructure proves namespace, lifecycle and expiry ownership.
*******************************************************************************/
class SharedRedisSessionStoreProviderIT
{
/***************************************************************************
** Separate nodes share one app's sessions while other apps remain isolated.
***************************************************************************/
@Test
void sharedPoolsIsolationExpiryAndFailure() throws Exception
{
QInstance original = QContext.getQInstance();
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
{
redis.start();
QInstance first = host(redis, "app-a", true);
QInstance second = host(redis, "app-a", false);
QInstance other = host(redis, "app-b", true);
var a = QSessionStoreQBitContext.getProvider(first);
var b = QSessionStoreQBitContext.getProvider(second);
var c = QSessionStoreQBitContext.getProvider(other);
var firstRuntime = new RedisRuntimeService();
var secondRuntime = new RedisRuntimeService();
var otherRuntime = new RedisRuntimeService();
try
{
assertThrows(IllegalStateException.class, () -> a.load("session"));
firstRuntime.start(first);
secondRuntime.start(second);
otherRuntime.start(other);
QContext.setQInstance(other);
a.store("session", new QSession().withValue("owner", "app-a"), null);
assertEquals("app-a", b.load("session").orElseThrow().getValue("owner"));
assertTrue(c.load("session").isEmpty());
c.store("session", new QSession().withValue("owner", "app-b"), null);
a.remove("session");
assertTrue(b.load("session").isEmpty());
assertEquals("app-b", c.load("session").orElseThrow().getValue("owner"));
a.touch("session");
assertTrue(a.loadAndTouch("session").isEmpty());
var scope = new RedisNamespace("test", "app-a", "authentication", "session-store");
var client = RedisRuntimeService.connection(first, "sessions").client();
a.store("sliding", new QSession(), Duration.ofSeconds(1));
assertTrue(a.loadAndTouch("sliding").isPresent());
assertTrue(client.pttl(scope.key("sliding")) > 20000);
a.store("fixed", new QSession(), Duration.ofMillis(200));
assertTrue(b.loadAndTouch("fixed").isPresent());
assertTrue(client.pttl(scope.key("fixed")) <= 200);
long deadline = System.nanoTime() + Duration.ofSeconds(3).toNanos();
while(b.load("fixed").isPresent() && System.nanoTime() < deadline)
{
Thread.sleep(10);
}
assertTrue(b.load("fixed").isEmpty());
assertThrows(IllegalArgumentException.class,
() -> a.store("bad", new QSession(), Duration.ZERO));
assertThrows(IllegalArgumentException.class,
() -> a.store("large", new QSession().withValue("large", "x".repeat(70000)), null));
RedisAccess.cache(first, "sessions", scope, 65536)
.put("broken", "test-secret-not-json".getBytes(java.nio.charset.StandardCharsets.UTF_8), Duration.ofSeconds(5));
var malformed = assertThrows(IllegalStateException.class, () -> a.load("broken"));
assertFalse(malformed.toString().contains("test-secret"));
assertNull(malformed.getCause());
a.cleanExpired();
assertTrue(a.status().contains("health"));
assertEquals(Duration.ofSeconds(30), a.getDefaultTtl());
firstRuntime.stop();
firstRuntime.afterApplicationStop();
assertThrows(IllegalStateException.class, () -> a.load("sliding"));
assertTrue(b.load("sliding").isPresent());
firstRuntime.start(first);
assertTrue(a.load("sliding").isPresent());
redis.stop();
assertThrows(RuntimeException.class, () -> a.remove("sliding"));
assertThrows(RuntimeException.class, () -> a.load("sliding"));
}
finally
{
for(var runtime : List.of(firstRuntime, secondRuntime, otherRuntime))
{
runtime.stop();
runtime.afterApplicationStop();
}
for(var instance : List.of(first, second, other))
{
QSessionStoreQBitContext.clear(instance);
QSessionStoreRegistry.getInstance(instance).clear();
}
QContext.setQInstance(original);
}
}
}
/***************************************************************************
** Startup failures and shutdown must release only the owning registrations.
***************************************************************************/
@Test
void registrationLifecycle() throws Exception
{
QInstance original = QContext.getQInstance();
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
{
redis.start();
QInstance first = host(redis, "app-a", true);
QInstance second = host(redis, "app-b", true);
var firstPool = new RedisRuntimeService();
var secondPool = new RedisRuntimeService();
var firstService = new SharedRedisSessionStoreRuntimeService();
var secondService = new SharedRedisSessionStoreRuntimeService();
try
{
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
() -> firstService.start(first));
assertNull(QSessionStoreQBitContext.getProvider(first));
assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
firstService.afterApplicationStop();
firstPool.start(first);
secondPool.start(second);
firstService.start(first);
secondService.start(second);
var firstProvider = QSessionStoreQBitContext.getProvider(first);
var secondProvider = QSessionStoreQBitContext.getProvider(second);
assertNotNull(firstProvider);
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
() -> firstService.start(first));
var duplicate = new SharedRedisSessionStoreRuntimeService();
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
() -> duplicate.start(first));
duplicate.afterApplicationStop();
assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
QContext.setQInstance(second);
firstService.stop();
assertSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
firstService.afterApplicationStop();
firstService.afterApplicationStop();
assertNull(QSessionStoreQBitContext.getProvider(first));
assertNull(QSessionStoreQBitContext.getConfig(first));
assertTrue(QSessionStoreRegistry.getInstance(first).getProvider().isEmpty());
assertSame(secondProvider, QSessionStoreQBitContext.getProvider(second));
assertEquals("PONG", RedisRuntimeService.connection(first, "sessions").client().ping());
firstService.start(first);
assertNotSame(firstProvider, QSessionStoreQBitContext.getProvider(first));
var replacement = new InMemorySessionStoreProvider();
QSessionStoreRegistry.getInstance(first).register(replacement);
QSessionStoreQBitContext.setProvider(first, replacement);
firstService.afterApplicationStop();
assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
assertThrows(com.kingsrook.qqq.backend.core.exceptions.QException.class,
() -> firstService.start(first));
assertSame(replacement, QSessionStoreQBitContext.getProvider(first));
assertSame(replacement, QSessionStoreRegistry.getInstance(first).getProvider().orElseThrow());
}
finally
{
firstService.afterApplicationStop();
secondService.afterApplicationStop();
firstPool.afterApplicationStop();
secondPool.afterApplicationStop();
for(var instance : List.of(first, second))
{
QSessionStoreQBitContext.clear(instance);
QSessionStoreRegistry.getInstance(instance).clear();
}
QContext.setQInstance(original);
}
}
}
/***************************************************************************
** Exercise real launcher callbacks and rollback after a later service fails.
***************************************************************************/
@Test
void applicationLauncherOwnsNormalAndFailedShutdown() throws Exception
{
var original = QContext.capture();
try(GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine")).withExposedPorts(6379))
{
redis.start();
QInstance instance = host(redis, "launcher", true);
var http = new java.util.concurrent.atomic.AtomicReference<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(redis.getHost()).withPort(redis.getMappedPort(6379))
.withTls(false).withSocketTimeoutMillis(300).withConnectionTimeoutMillis(300)))
.produce(instance);
new QSessionStoreQBitProducer().withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.SHARED_REDIS)
.withSharedRedis(new SharedRedisSessionStoreSettings("sessions", "test", application, "authentication", 65536))
.withDefaultTtl(Duration.ofSeconds(30)).withEnableSlidingExpiration(sliding))
.produce(instance, "session-store");
assertNull(instance.getProcess("cleanExpiredSessions"));
return instance;
}
}