Files
james.maesandClaude Fable 5.1 40226b3a2a
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (push) Successful in 9s
QRun-IO/ci-policy: native / native-result (push) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 7s
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 10s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
maven / build (push) Failing after 7s
build / maven (push) Failing after 7s
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 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-06 08:53:30 -06:00

272 lines
11 KiB
Markdown

# QBit Session Store
Pluggable session storage/caching for QQQ applications. Avoids re-deriving expensive session data (security keys, permissions) on every request.
## Why This Exists
Every QQQ request authenticates and builds a `QSession` containing security keys, permissions, and user metadata. For backends like Auth0, that means a network round-trip on every request. This QBit lets you store and reuse sessions across requests with configurable TTL, sliding expiration, and multiple storage backends.
## Project Status
**0.1.0** is the first published release line (release candidates publish as
`0.1.0-RC.N`). API may change before 1.0. See [CHANGELOG.md](CHANGELOG.md).
## Compatibility
| qbit-session-store | QQQ core (`qqq-bom-pom`) | qbit-redis (optional, `SHARED_REDIS` only) |
|--------------------|--------------------------|--------------------------------------------|
| 0.1.0 line | 4.1.0-RC.22 or later 4.1 | 0.1.0-RC.1 or later 0.1 |
The `SHARED_REDIS` provider and application-scoped registration call
`QSessionStoreRegistry.getInstance(QInstance)` and `clearIfSame`, which core
added after 4.1.0-RC.21; this QBit does not compile against RC.21 or earlier.
Artifacts publish to the Gitea Maven registry
(`https://git.kof22.com/api/packages/QRun-IO/maven`), which requires an
authenticated read.
## Features
- Four storage backends: in-memory (LRU), QQQ table, Redis, or custom
- Sliding expiration resets TTL on access
- Scheduled cleanup process for expired sessions
- Declarative QBit configuration via `QSessionStoreQBitProducer`
- Thread-safe across all providers
## Providers
| Provider | Storage | Best For |
|----------|---------|----------|
| IN_MEMORY | ConcurrentHashMap + LRU | Dev, testing, single-instance |
| TABLE_BASED | QQQ table (StoredSession) | Multi-instance, persistence |
| REDIS | Jedis with native TTL | Distributed HA deployments |
| CUSTOM | User-provided implementation | Specialized backends |
## Quick Start
Add the dependency to your project:
```xml
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-session-store</artifactId>
<version>0.1.0</version>
</dependency>
```
Produce the QBit into your QInstance:
```java
new QSessionStoreQBitProducer()
.withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.TABLE_BASED)
.withBackendName("primaryBackend")
.withDefaultTtl(Duration.ofHours(8))
.withEnableSlidingExpiration(true))
.produce(qInstance);
```
## Configuration Options
| Field | Default | Description |
|-------|---------|-------------|
| `providerType` | (required) | IN_MEMORY, TABLE_BASED, REDIS, or CUSTOM |
| `defaultTtl` | 1 hour | Session expiration duration |
| `enableSlidingExpiration` | true | Reset TTL on access |
| `backendName` | - | Required for TABLE_BASED |
| `tableName` | "storedSession" | Table name for TABLE_BASED |
| `tableNamePrefix` | - | Prefix for table name (e.g., "app_") |
| `maxCacheSize` | 10000 | LRU size for IN_MEMORY |
| `redisHost` | - | Required for REDIS |
| `redisPort` | 6379 | Redis port |
| `redisPassword` | - | Redis password (optional) |
| `redisKeyPrefix` | "qqq:session:" | Redis key namespace |
| `enableCleanupProcess` | true | Enable scheduled cleanup |
| `cleanupIntervalSeconds` | 300 | Cleanup interval (5 min) |
## Table Schema (TABLE_BASED Provider)
The QBit automatically creates the table metadata. You must create the physical table in your database:
```sql
-- PostgreSQL
CREATE TABLE stored_session (
id SERIAL PRIMARY KEY,
session_uuid VARCHAR(36) NOT NULL UNIQUE,
user_id VARCHAR(255),
session_data TEXT,
expires_at TIMESTAMP NOT NULL,
create_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
modify_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- With table prefix (e.g., tableNamePrefix="app_")
CREATE TABLE app_stored_session (
-- same columns as above
);
```
**Column mapping:**
- `id` - Auto-increment primary key
- `session_uuid` - UUID identifying the session (unique index)
- `user_id` - User ID reference for auditing
- `session_data` - Serialized QSession as JSON
- `expires_at` - Session expiration timestamp
- `create_date` / `modify_date` - Audit timestamps
## Usage Examples
### In-Memory (Development)
```java
new QSessionStoreQBitProducer()
.withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.IN_MEMORY)
.withMaxCacheSize(1000)
.withDefaultTtl(Duration.ofMinutes(30)))
.produce(qInstance);
```
### Table-Based with Prefix
```java
new QSessionStoreQBitProducer()
.withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.TABLE_BASED)
.withBackendName("primaryBackend")
.withTableNamePrefix("myapp_") // Results in "myapp_storedSession" table
.withDefaultTtl(Duration.ofHours(8)))
.produce(qInstance);
```
### Redis
```java
new QSessionStoreQBitProducer()
.withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.REDIS)
.withRedisHost("redis.example.com")
.withRedisPort(6379)
.withRedisPassword("secret")
.withRedisKeyPrefix("myapp:session:")
.withDefaultTtl(Duration.ofHours(4)))
.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
new QSessionStoreQBitProducer()
.withConfig(new QSessionStoreQBitConfig()
.withProviderType(QSessionStoreProviderType.CUSTOM)
.withCustomProviderCodeReference(new QCodeReference(MyCustomProvider.class)))
.produce(qInstance);
```
## 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).
```java
QSessionStoreProviderInterface provider = QSessionStoreQBitContext.getProvider(qInstance);
// Store a session
provider.store(session.getUuid(), session, Duration.ofHours(1));
// Load a session
Optional<QSession> cached = provider.load(sessionUuid);
// Touch (refresh TTL)
provider.touch(sessionUuid);
// Remove
provider.remove(sessionUuid);
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
## Releasing
Versions are computed by the shared `KofTwentyTwo/ci-workflows` Maven workflow
(`.gitea/workflows/build.yaml`) from what is pushed; the `revision` property on
`develop` stays `0.1.0-SNAPSHOT` and is never hand-edited for a release.
| Push | Published version | Side effects |
|----------------------------|--------------------------|---------------------------------------------------------------|
| `develop` | `0.1.0-SNAPSHOT` | none |
| branch `release/0.1` | `0.1.0-RC.1`, `RC.2`, .. | commits "Bump version to X [skip ci]" to the branch, tags `vX` |
| tag `v0.1.0` | `0.1.0` | pushes the version bump to `main` |
| tag `publish-<name>` | `0.1.0-<feature>-<sha>-SNAPSHOT` | none |
To cut the first release: merge this release preparation into `develop`, create
`release/0.1` from `develop` and push it (publishes `0.1.0-RC.1`), qualify the
candidate, then tag the bumped `release/0.1` head `v0.1.0` and push the tag
(publishes `0.1.0`). Merge `release/0.1` back into `develop`; the workflow then
advances `develop` to `0.2.0-SNAPSHOT`. Record the date in `CHANGELOG.md` when
tagging. GitHub is a read-only shadow; never push there.
## License
Apache-2.0 - See [LICENSE](LICENSE)
Launcher acceptance covers normal shutdown, failure of a later runtime, and Redis
authentication failure before session-runtime startup followed by retry. In that
early failure case no Redis pool is published; the weak pre-start registration
remains configured but cannot execute session operations until Redis starts.