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