Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
843fdb4fad | ||
|
|
595b9c5932
|
||
|
|
831b879678
|
||
|
|
c3ddd4bde4 | ||
|
|
71126ebce4
|
||
|
|
63e42c8153
|
||
|
|
e267f4f84a
|
||
|
|
94802b888b
|
||
|
|
0bafd255cb
|
||
|
|
07e3bffc54 | ||
|
|
0509d47893
|
||
|
|
4f169f2a7b
|
||
|
|
cc9c3d280c
|
||
|
|
7152f0a6a2 | ||
|
|
b1fd26385f
|
||
|
|
318bcad3ad
|
||
|
|
78f0ea1af6
|
||
|
|
9657131703
|
||
|
|
036af0698f
|
||
|
|
76d2d6f3f8
|
||
|
|
d4f5fd7aef | ||
|
|
33b21f9c0a |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Release candidate
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
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
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
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 || 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
|
||||
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0
|
||||
|
||||
First published release of `com.kingsrook.qbits:qbit-redis`, built on the
|
||||
published QQQ `4.1.0-RC.21` core and Jedis `7.2.0` (Java 21). The release
|
||||
candidate `0.1.0-RC.1` was published from `release/0.1` (source `6b0814d`, the
|
||||
`develop` head `0bafd25` plus the CI version bump); the stable `0.1.0` is cut by
|
||||
tagging `v0.1.0` on that train. See [docs/RELEASES.md](docs/RELEASES.md).
|
||||
|
||||
- Named, bounded Redis connections with required TLS (hostname verification on),
|
||||
host-provided credentials and TLS providers resolved at runtime, startup
|
||||
validation/ping of every pool, failed-startup cleanup and QInstance isolation.
|
||||
- Shared value cache (`RedisAccess.cache`): collision-safe namespaces, explicit
|
||||
size limits, positive TTL on every write, atomic SET with expiry and atomic
|
||||
GETEX read-and-renew; outages throw rather than reading as misses.
|
||||
- Trusted domain Lua through `RedisAccess.scripts` with EVALSHA/NOSCRIPT recovery.
|
||||
- Redis Pub/Sub realtime transport (candidate) with bounded hint queue, resync
|
||||
on overflow/reconnect, per-node heartbeat and transport metrics.
|
||||
- Framework health check `redis.connections` with aggregate pool metrics; an
|
||||
opt-in per-connection write probe (`withWriteHealthKeyPrefix`, default off,
|
||||
`SET NX PX 1000` under a reserved prefix) reports `writeChecks`.
|
||||
- Admin app and widgets with native help bundle; no host SQL schema.
|
||||
- Qualification fixtures (not runtime behavior): controlled replica promotion,
|
||||
stable endpoint switch, primary fencing and partition healing on real Redis
|
||||
containers ([docs/TOPOLOGY-QUALIFICATION.md](docs/TOPOLOGY-QUALIFICATION.md)).
|
||||
|
||||
Compared with the isolated development build `0.1.0-forge.54adcfed62b0` that
|
||||
GG Forge qualified, the published artifact compiles against the same QQQ
|
||||
`4.1.0-RC.21` (now the POM default rather than a command-line override), adds
|
||||
the optional write-health prefix and `writeChecks` detail, rewords the health
|
||||
check status messages, and adds test fixtures and CI. No API was removed and
|
||||
no default behavior changed.
|
||||
@@ -1,10 +1,13 @@
|
||||
# QBit Redis
|
||||
|
||||
Reusable Redis connections and bounded shared caches for QQQ applications.
|
||||
**Coordinated feature snapshot published; not a stable/GA release.** Track
|
||||
[QQQ #1295](https://github.com/QRun-IO/qqq/issues/1295) and [completed milestone and follow-ups](docs/TODO.md).
|
||||
See [published snapshot](docs/SNAPSHOT-RELEASE.md) for current normal-flow evidence,
|
||||
exact compatible versions and the explicitly deferred optional scenarios.
|
||||
Release train `0.1`: candidate `0.1.0-RC.1` is published to the Gitea Maven
|
||||
registry (`com.kingsrook.qbits:qbit-redis`, QQQ `4.1.0-RC.21`, Java 21); the
|
||||
stable `0.1.0` is cut from the same train. See [CHANGELOG.md](CHANGELOG.md) and
|
||||
[release operations](docs/RELEASES.md) for consumption, versioning and the
|
||||
maintainer path, and [follow-ups](docs/TODO.md) for the optional scenarios that
|
||||
no release declares supported. [SNAPSHOT-RELEASE.md](docs/SNAPSHOT-RELEASE.md)
|
||||
is the historical coordinated-snapshot record.
|
||||
|
||||
## Host integration
|
||||
|
||||
@@ -96,7 +99,7 @@ or proof that every application command/key is authorized. A writable stale
|
||||
primary can pass; topology fencing must remain an infrastructure guarantee.
|
||||
The host decides whether this check gates readiness. Do not enable the probe on
|
||||
an existing ACL until its dedicated prefix and write permission are configured.
|
||||
This option is currently an unpublished candidate.
|
||||
This option is off by default and first ships in 0.1.0.
|
||||
|
||||
Other qbits consume only `com.kingsrook.qbits.redis.api`: `RedisAccess`,
|
||||
`RedisNamespace`, `RedisCache`, `RedisScript`, `RedisScriptExecutor`,
|
||||
@@ -168,7 +171,7 @@ Java 21, Maven wrapper and Docker are required for the full current tests:
|
||||
.\mvnw.cmd test
|
||||
```
|
||||
|
||||
The current candidate uses local QQQ `4.1.0-SNAPSHOT` and Jedis `7.2.0`. Tests use
|
||||
The 0.1 release train uses the published QQQ `4.1.0-RC.21` and Jedis `7.2.0`. Tests use
|
||||
real disposable Redis containers. They cover sharing, expiry, isolation, outages,
|
||||
registration, failed startup cleanup, credential-error redaction, lifecycle,
|
||||
authentication, bounded pool exhaustion and TLS. TLS tests generate temporary
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Release operations
|
||||
|
||||
The current release train is `0.1`. Its candidate `0.1.0-RC.1` is published to
|
||||
the Gitea Maven registry from `release/0.1` (tag `v0.1.0-RC.1`, source
|
||||
`6b0814d83e18f70e3ea315a99e5aece1a562a213`). The stable `0.1.0` is cut by
|
||||
tagging `v0.1.0` on that train as described below. Changes are listed in
|
||||
[CHANGELOG.md](../CHANGELOG.md). Gitea (`git.kof22.com/QRun-IO/qbit-redis`) is
|
||||
primary for source, pull requests, CI, packages and releases; GitHub is a
|
||||
shadow/archive and its workflows are historical.
|
||||
|
||||
## Use the Maven registry
|
||||
|
||||
Configure the consuming application's Maven repository setup with repository ID
|
||||
`qrun-io` and URL `https://git.kof22.com/api/packages/QRun-IO/maven` (releases
|
||||
enabled). The registry requires authentication for reads as well as writes:
|
||||
match the ID to a `<server>` entry in your private `settings.xml` that references
|
||||
your managed username/token through environment variables or encrypted Maven
|
||||
credentials. Never commit a token or put it on a command line. Then depend on
|
||||
`com.kingsrook.qbits:qbit-redis:<version>`; QQQ `4.1.0-RC.21` and Jedis resolve
|
||||
from Maven Central. Verify the downloaded JAR/POM against the hashes recorded
|
||||
for the release before adopting it.
|
||||
|
||||
## Versioning
|
||||
|
||||
The POM version is the `revision` property. On `develop` it stays
|
||||
`0.1.0-SNAPSHOT` and is never hand-set: the shared
|
||||
`KofTwentyTwo/ci-workflows` Maven workflow (called by
|
||||
`.gitea/workflows/build.yaml` on every push) computes and commits versions.
|
||||
|
||||
| Ref | Build | Published version |
|
||||
| --- | --- | --- |
|
||||
| feature branches, pull requests, `main` | `mvn verify` only | none |
|
||||
| `develop` | `mvn deploy` | current `X.Y.0-SNAPSHOT` (bumped to the next minor after a release merges back) |
|
||||
| `release/X.Y` | `mvn deploy`, bump commit, tag `vX.Y.0-RC.N` | `X.Y.0-RC.N` |
|
||||
| tag `vX.Y.Z` | `mvn deploy`, bump commit pushed to `main` | `X.Y.Z` |
|
||||
| `hotfix/*` | `mvn deploy`, bump commit, tag | `X.Y.(Z+1)` |
|
||||
|
||||
The workflow runs the tests once inside `mvn deploy` on the `container-build`
|
||||
runner, which provides the Docker daemon the Redis Testcontainers suites need.
|
||||
|
||||
## Maintainer release path
|
||||
|
||||
1. Merge reviewed changes into `develop` through a normal pull request; the
|
||||
`develop` run publishes the SNAPSHOT.
|
||||
2. Merge `develop` into `release/0.1` (pull request or fast-forward). The push
|
||||
publishes the next `0.1.0-RC.N`, commits `Bump version to 0.1.0-RC.N [skip ci]`
|
||||
and tags `v0.1.0-RC.N` on that bump commit. Consumers qualify the RC.
|
||||
3. Bring `main` to the release-train head before tagging: the tag run pushes its
|
||||
`Bump version to 0.1.0` commit with `git push origin HEAD:main`, which must be
|
||||
a fast-forward. `main` currently carries CI-only commits with different SHAs
|
||||
from the equivalent `develop` commits, so it must be reset or merged to the
|
||||
`release/0.1` head first.
|
||||
4. Tag the qualified source: `git tag -s v0.1.0 <release/0.1 head>` and push the
|
||||
tag to origin only. The tag run sets `revision` to `0.1.0`, deploys
|
||||
`com.kingsrook.qbits:qbit-redis:0.1.0` to the registry and pushes the bump
|
||||
commit to `main`. A passing run alone does not prove publication: resolve the
|
||||
exact version's JAR/POM from the registry with an authenticated client and
|
||||
record their SHA-256 values here.
|
||||
5. Create a Gitea release for `v0.1.0` (the shared workflow does not), attach the
|
||||
same `qbit-redis-0.1.0.jar`, `qbit-redis-0.1.0.pom` and a flat `SHA256SUMS`,
|
||||
read them back with the approved forge identity, then publish. Never replace
|
||||
different bytes under a released version.
|
||||
6. Merge `release/0.1` back into `develop`; the next `develop` run moves it to
|
||||
`0.2.0-SNAPSHOT`.
|
||||
|
||||
Publication is separate from host adoption. The optional scenarios in
|
||||
[TODO.md](TODO.md) (QQQ #1300/#1301/#1302) are not declared supported by a
|
||||
release; [TOPOLOGY-QUALIFICATION.md](TOPOLOGY-QUALIFICATION.md) records what the
|
||||
fixtures demonstrate.
|
||||
+22
-23
@@ -1,35 +1,34 @@
|
||||
# Coordinated published snapshot
|
||||
# Coordinated candidate snapshot
|
||||
|
||||
The main-line milestone is complete. These feature snapshots are published and verified together; this is not a stable/GA release or a production deployment.
|
||||
Historical record. The 0.1 release train (`0.1.0-RC.1` published from `release/0.1`, stable `0.1.0` cut by tag) supersedes the CircleCI snapshot path below; see [RELEASES.md](RELEASES.md).
|
||||
|
||||
| Component | Version |
|
||||
| --- | --- |
|
||||
| QQQ BOM/modules | `4.1.0-collab-33464d37e-SNAPSHOT` |
|
||||
| qbit-redis | `0.1.0-redis-7fe5492-SNAPSHOT` |
|
||||
| qbit-collaboration | `0.1.0-collab-8f348da-SNAPSHOT` |
|
||||
| qbit-session-store | `0.1.0-session-b5276ab-SNAPSHOT` |
|
||||
| Next frontend source | `8d372142f8af0c41a75a353edc6d150c33ccf613` |
|
||||
Status, 2026-10-22: source is uploaded, canonical branch CI is verified and enforced, and the required Core snapshot is published. Publication of Redis `0.1.0-SNAPSHOT` through the canonical `develop` pipeline remains to be verified. This is not a production deployment checklist or an RC certification.
|
||||
|
||||
Use the public Maven snapshot repository https://central.sonatype.com/repository/maven-snapshots/ and import the candidate QQQ BOM. All four publishers succeeded. Independent JAR/POM downloads and a standalone consumer using an initially empty Maven cache passed; all five QQQ modules align and only Jedis7.2.0 is resolved.
|
||||
## Local evidence
|
||||
|
||||
## Verification
|
||||
- Collaboration default verify:179tests, no failures/errors/skips, coverage and guardrails passed.
|
||||
- UI focused34 plus Chromium normal-flow suites16 passed; TypeScript passed.
|
||||
- Live browser with mocks disabled: two independent QQQ JVMs share presence; editing focus, drafts, table projection and departure verified.
|
||||
- Redis admin shows live pool/realtime metrics and real framework-persisted health results, with navigation to the standard health check.
|
||||
- Standalone Maven consumer built and ran packaged collaboration/Redis candidates without source paths or a reactor; Redis-absent consumer uses the new issued-lease protocol successfully.
|
||||
|
||||
Redis23, collaboration177 and session-store96 tests passed against published prerequisites, with zero failures/errors/skips and configured coverage gates passing. Core verification4209 and frontend acceptance39683690943 passed. Frontend local3664 tests and TypeScript passed. The frontend acceptance run contains the tested export; no dashboard container image was published by that run.
|
||||
## Publication order and remaining checks
|
||||
|
||||
Final browser checks used the downloaded published JARs, exact CI frontend export, two independent JVMs and one Redis endpoint. Cross-node avatars, self exclusion, editor focus, preserved drafts, table presence and departure passed. Redis admin metrics, subscription state, persisted framework UP health history and health-record navigation passed. The fixtures use mock users and independent memory records; database concurrency and authentication have separate core/frontend tests. Test-owned processes were stopped afterward.
|
||||
1. Capture reviewed source revisions for the coordinated core, Redis, collaboration, session-store and frontend candidates. Preserve unrelated worktrees and edits.
|
||||
2. Publish the corresponding QQQ4.1.0-SNAPSHOT parent/BOM and required core modules first. The consumer graph currently includes backend-core, backend-module-rdbms, middleware-javalin and openapi.
|
||||
3. The public [Redis repository](https://github.com/QRun-IO/qbit-redis) has canonical `main` and `develop` branches; `develop` is the default. [PR #1](https://github.com/QRun-IO/qbit-redis/pull/1) installed the shared orb declarations at `33b21f9c0a1ea316f7f720bbde2465ac11d81af0`. CircleCI pipelines 10 and 11 passed on that revision. Both branches enforce the `setup` and `verify` checks from CircleCI app 18001, including for administrators.
|
||||
4. Publish `0.1.0-SNAPSHOT` through the shared QBit profile declared in `.qqq-ci.yml`. The canonical caller tracks `kingsrook/qqq-orb@0.8`; the orb owns verification, version calculation, signing and deployment. Normal `develop` changes select snapshot publication. CI-only declaration changes intentionally verify without publishing, so pipelines 10 and 11 do not prove artifact publication. Follow the actual publisher to success and resolve its timestamped POM/JAR from the remote repository before marking this step complete. Do not add a repository-specific publishing workflow.
|
||||
5. Run and publish collaboration0.1.0-SNAPSHOT and the coordinated session-store/frontend candidates as applicable. Frontend and collaboration lease endpoints require coordinated rollout; do not pair the new frontend with an older backend.
|
||||
6. Resolve exact published artifacts in a fresh consumer cache, repeat normal host startup/presence/admin checks, and record remote coordinates, source SHAs and artifact checksums. Local cache success is insufficient for this step.
|
||||
|
||||
Exact release receipts and evidence: [QQQ #1293](https://github.com/QRun-IO/qqq/issues/1293#issuecomment-6329618640). Host integration and migration guide: [coordinated candidate](https://github.com/QRun-IO/qbit-collaboration/blob/c3182e2/docs/CANDIDATE-RELEASE.md).
|
||||
## Verified dependency baseline
|
||||
|
||||
## Supported milestone
|
||||
Core commit `47eb326c4a36e481a8cf36b93c2e022760d08c76` completed publisher job 9606. Its published `4.1.0-SNAPSHOT` artifacts were checked remotely: 19 resolved POMs and the backend-core/Javalin JAR APIs needed by Redis (`getIfStarted`, message audience accessors, audience-provider configuration and hub audience filtering). Redis pipeline 8 then passed against the published dependency before PR #1 merged.
|
||||
|
||||
Multiple QQQ web nodes share one configured Redis endpoint. Collaboration owns presence semantics; Redis owns connection/cache infrastructure; session-store retains session semantics. Browser delivery uses SSE plus HTTP lease writes, and Redis Pub/Sub carries invalidation hints. Configure node affinity for SSE subscription updates. Shared Redis does not make connection IDs portable. Presence is advisory; versioned database compare-and-write protects saves.
|
||||
This baseline establishes the Core dependency and Redis CI path. It does not certify the coordinated collaboration/session-store/frontend deployment or a stable Redis release. Retain the separate consumer and host checks in steps 5 and 6.
|
||||
|
||||
Before host adoption, configure authoritative identity/tenant scope, unique deployment/application namespaces, TLS/authentication, bounded pools/timeouts, explicit QInstance session registration, owner-scoped shutdown, and schema migrations. Upgrade the frontend/backend lease protocol together. Existing hosts are not automatically migrated by library publication; rollout is tracked in [#1315](https://github.com/QRun-IO/qqq/issues/1315).
|
||||
## Operational scope
|
||||
|
||||
## Optional future work
|
||||
Initial path: multiple QQQ web nodes, one configured Redis endpoint, host-provided authentication/tenant scope, HTTP lease writes, SSE browser delivery and Redis Pub/Sub invalidation. Presence is advisory; database version checks protect saves.
|
||||
|
||||
The owner's main-line-only scope leaves attachment failure qualification #1289, capacity/My Work failure qualification #1291, selectable tag/EAV projection #1196, data loss/restore #1300, additional Redis topologies #1301, sustained scale #1302, additional proxy deployments #1316 and admin/client coupling enhancements #1317 open. Tooling/dependency maintenance remains #1304/#1305/#1307/#1309/#1310/#1312/#1314. No measured production capacity or disaster-recovery guarantee is claimed.
|
||||
|
||||
## Publication lessons
|
||||
|
||||
Use short feature branch names: the help bundle version limit is 50 characters. The first long core candidate failed before publication; unchanged source passed with the short coordinate above. Preserve original worktree branches and failed tags. Feature-enabled QBit setup must use the canonical `/publish.*/` tag filter; schema validation alone does not check that contract. Redis/session-store use orb0.7.18; collaboration uses the managed0.8 track. Pin actual published prerequisite versions before downstream publication and verify remote resolution afterward.
|
||||
Optional future work remains QQQ #1300 (loss/restore), #1301 (failover topology) and #1302 (sustained scale/failure injection). No transparent disaster-recovery guarantee is part of this snapshot. If the normal presence/admin flow fails in staging, stop rollout and retain the prior coordinated frontend/backend set; do not downgrade only one side of the lease protocol.
|
||||
@@ -4,7 +4,7 @@
|
||||
<parent>
|
||||
<groupId>com.kingsrook</groupId>
|
||||
<artifactId>qbit-build-parent</artifactId>
|
||||
<version>2.1.1</version>
|
||||
<version>2.2.1</version>
|
||||
</parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-redis</artifactId>
|
||||
@@ -35,9 +35,10 @@
|
||||
<tag>HEAD</tag>
|
||||
</scm>
|
||||
<properties>
|
||||
<revision>0.1.0-SNAPSHOT</revision>
|
||||
<revision>0.3.0-SNAPSHOT</revision>
|
||||
<maven.compiler.release>21</maven.compiler.release>
|
||||
<qqq.snapshot.version>4.1.0-collab-33464d37e-SNAPSHOT</qqq.snapshot.version>
|
||||
<!-- Published QQQ 4.1 core (Maven Central). Redis needs no API newer than this release: main and test sources compile against it. -->
|
||||
<qqq.version>4.1.0-RC.25</qqq.version>
|
||||
<jedis.version>7.2.0</jedis.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
@@ -45,7 +46,7 @@
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-bom-pom</artifactId>
|
||||
<version>${qqq.snapshot.version}</version>
|
||||
<version>${qqq.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.redis;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.github.dockerjava.api.command.InspectContainerResponse;
|
||||
import org.testcontainers.DockerClientFactory;
|
||||
import org.testcontainers.containers.GenericContainer;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a disposable test container is reachable FROM THIS JVM.
|
||||
**
|
||||
** The original behavior is Testcontainers' own: the host it reports and the
|
||||
** published port. That is kept everywhere except one measured topology: a JVM
|
||||
** inside a build container that shares the daemon's default bridge with the
|
||||
** fixture (the full Linux verify, which talks to the host daemon through its
|
||||
** socket). On the Docker Desktop runner the published port is reached through
|
||||
** the host-gateway hop; measured on an idle machine that path showed round trips
|
||||
** of about 1 ms with stalls of 0.3 s and about 2 s, against about 0.13 ms and
|
||||
** no stalls when dialing the sibling directly on the bridge. These tests use
|
||||
** 250 ms to 1 s socket timeouts, so such stalls can expire them; that is a
|
||||
** plausible contributor, not shown to be the only cause of the observed
|
||||
** "Read timed out" failures. In that one topology the fixture is dialed on its
|
||||
** bridge address and its own port.
|
||||
**
|
||||
** The choice is made from the daemon's own description of the build container's
|
||||
** networks (no connection probing). If the build container cannot be identified
|
||||
** or is not on the default bridge (a job network, host networking, a remote
|
||||
** daemon), the original published endpoint is used unchanged. Timeouts,
|
||||
** scenarios, assertions and server configuration are unchanged.
|
||||
*******************************************************************************/
|
||||
final class FixtureEndpoint
|
||||
{
|
||||
private static final String DEFAULT_BRIDGE = "bridge";
|
||||
|
||||
private static final Pattern MOUNTINFO_CONTAINER_ID = Pattern.compile("/docker/containers/([0-9a-f]{64})/");
|
||||
|
||||
private static volatile Boolean buildContainerOnDefaultBridge;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Host and port to dial.
|
||||
***************************************************************************/
|
||||
record Address(String host, int port)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private FixtureEndpoint()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Host this JVM must dial for the container.
|
||||
***************************************************************************/
|
||||
static String host(GenericContainer<?> container)
|
||||
{
|
||||
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).host());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Port matching {@link #host(GenericContainer)}.
|
||||
***************************************************************************/
|
||||
static int port(GenericContainer<?> container)
|
||||
{
|
||||
return (choose(onBridgeWithBuildContainer(), bridgeIp(container), 6379, container.getHost(), publishedPort(container)).port());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A container that lives only on a custom test network is also attached to the
|
||||
** default bridge when (and only when) the build container is on that bridge, so
|
||||
** that the build container can reach it directly. The test network, and therefore
|
||||
** every partition scenario between the fixture servers, is untouched: attach only
|
||||
** the container the test client dials, never one whose reachability the test cuts
|
||||
** or whose peers must stay isolated from it. No-op in every other topology.
|
||||
***************************************************************************/
|
||||
static void attachToDefaultBridgeWhenInsideContainer(GenericContainer<?> container)
|
||||
{
|
||||
if(!onBridgeWithBuildContainer())
|
||||
{
|
||||
return;
|
||||
}
|
||||
var docker = container.getDockerClient();
|
||||
var networks = docker.inspectContainerCmd(container.getContainerId()).exec().getNetworkSettings().getNetworks();
|
||||
if(networks == null || !networks.containsKey(DEFAULT_BRIDGE))
|
||||
{
|
||||
docker.connectToNetworkCmd().withContainerId(container.getContainerId()).withNetworkId(DEFAULT_BRIDGE).exec();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether this JVM runs in a container that the daemon reports on the default
|
||||
** bridge. Decided once per JVM from the daemon's description of this container.
|
||||
***************************************************************************/
|
||||
private static boolean onBridgeWithBuildContainer()
|
||||
{
|
||||
Boolean known = buildContainerOnDefaultBridge;
|
||||
if(known == null)
|
||||
{
|
||||
known = decideBuildContainerOnDefaultBridge();
|
||||
buildContainerOnDefaultBridge = known;
|
||||
}
|
||||
return (known);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Inspect the build container; anything that cannot be established means the
|
||||
** original published endpoint (false).
|
||||
***************************************************************************/
|
||||
private static boolean decideBuildContainerOnDefaultBridge()
|
||||
{
|
||||
if(!Files.exists(Path.of("/.dockerenv")))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
String id = ownContainerId(Files.readString(Path.of("/proc/self/mountinfo")), System.getenv("HOSTNAME"));
|
||||
if(id == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
var networks = DockerClientFactory.instance().client().inspectContainerCmd(id).exec().getNetworkSettings().getNetworks();
|
||||
return (networks != null && networks.containsKey(DEFAULT_BRIDGE));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id of the container this JVM runs in: from the mount table when it names
|
||||
** the container directory, otherwise the host name (the container id unless
|
||||
** overridden); null when neither exists.
|
||||
***************************************************************************/
|
||||
static String ownContainerId(String mountInfo, String hostName)
|
||||
{
|
||||
Matcher matcher = MOUNTINFO_CONTAINER_ID.matcher(mountInfo == null ? "" : mountInfo);
|
||||
if(matcher.find())
|
||||
{
|
||||
return (matcher.group(1));
|
||||
}
|
||||
return (hostName == null || hostName.isBlank() ? null : hostName.trim());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The container's address on the default bridge, or null when it has none.
|
||||
***************************************************************************/
|
||||
private static String bridgeIp(GenericContainer<?> container)
|
||||
{
|
||||
if(!onBridgeWithBuildContainer())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
InspectContainerResponse inspected = container.getDockerClient().inspectContainerCmd(container.getContainerId()).exec();
|
||||
var networks = inspected.getNetworkSettings().getNetworks();
|
||||
return (networks == null || networks.get(DEFAULT_BRIDGE) == null ? null : networks.get(DEFAULT_BRIDGE).getIpAddress());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Testcontainers' published port for the Redis port, or null when the build
|
||||
** container dials the bridge (so the published port is not consulted).
|
||||
***************************************************************************/
|
||||
private static Integer publishedPort(GenericContainer<?> container)
|
||||
{
|
||||
return (onBridgeWithBuildContainer() ? null : container.getMappedPort(6379));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Pure decision, separately tested. With the build container on the default
|
||||
** bridge: the sibling's bridge address and its own port. Otherwise the published
|
||||
** endpoint exactly as Testcontainers reports it.
|
||||
***************************************************************************/
|
||||
static Address choose(boolean buildContainerOnDefaultBridge, String bridgeIp, int internalPort, String publishedHost, Integer publishedPort)
|
||||
{
|
||||
if(buildContainerOnDefaultBridge)
|
||||
{
|
||||
if(bridgeIp == null || bridgeIp.isBlank())
|
||||
{
|
||||
throw new IllegalStateException("FIXTURE_ENDPOINT_NO_BRIDGE_ADDRESS: the build container cannot reach a sibling without its bridge address");
|
||||
}
|
||||
return (new Address(bridgeIp, internalPort));
|
||||
}
|
||||
if(publishedHost == null || publishedHost.isBlank() || publishedPort == null)
|
||||
{
|
||||
throw new IllegalStateException("FIXTURE_ENDPOINT_NOT_PUBLISHED: no published endpoint to dial");
|
||||
}
|
||||
return (new Address(publishedHost, publishedPort));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.redis;
|
||||
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The topology decision of the sibling-container fixtures.
|
||||
*******************************************************************************/
|
||||
class FixtureEndpointTest
|
||||
{
|
||||
private static final String CONTAINER_ID = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build container on the default bridge: dial the sibling bridge IP and its own port, never the published port.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void buildContainerOnTheBridgeUsesTheBridgeAddress()
|
||||
{
|
||||
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, "localhost", 41111));
|
||||
assertEquals(new FixtureEndpoint.Address("172.17.0.9", 6379), FixtureEndpoint.choose(true, "172.17.0.9", 6379, null, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every other topology (JVM on the Docker host, remote daemon, job network, host networking, unidentified
|
||||
** build container) keeps the original Testcontainers host and published port exactly, whatever the host is.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyOtherTopologyKeepsTheOriginalPublishedEndpoint()
|
||||
{
|
||||
assertEquals(new FixtureEndpoint.Address("localhost", 41111), FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", 41111));
|
||||
assertEquals(new FixtureEndpoint.Address("docker.example.test", 32768), FixtureEndpoint.choose(false, null, 6379, "docker.example.test", 32768));
|
||||
assertEquals(new FixtureEndpoint.Address("host.docker.internal", 40000), FixtureEndpoint.choose(false, null, 6379, "host.docker.internal", 40000));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An unknown endpoint is an error, never a guessed address.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void missingEndpointFailsClosed()
|
||||
{
|
||||
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, null, 6379, "localhost", 41111));
|
||||
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(true, " ", 6379, "localhost", 41111));
|
||||
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, "localhost", null));
|
||||
assertThrows(IllegalStateException.class, () -> FixtureEndpoint.choose(false, "172.17.0.9", 6379, " ", 41111));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The build container id comes from the mount table when present, else the host name.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void ownContainerIdPrefersTheMountTableThenTheHostName()
|
||||
{
|
||||
String mountInfo = "700 699 0:50 /docker/containers/" + CONTAINER_ID + "/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw";
|
||||
assertEquals(CONTAINER_ID, FixtureEndpoint.ownContainerId(mountInfo, "unrelated"));
|
||||
assertEquals("abc123", FixtureEndpoint.ownContainerId("no container mounts here", " abc123 "));
|
||||
assertNull(FixtureEndpoint.ownContainerId("", null));
|
||||
assertNull(FixtureEndpoint.ownContainerId(null, " "));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class RedisAuthenticationTest
|
||||
RotatingCredentials.owner = instance;
|
||||
RotatingCredentials.password = TEST_PASSWORD;
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("realtime", new RedisConnectionConfig()
|
||||
.withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false)
|
||||
.withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis)).withTls(false)
|
||||
.withCredentialsProvider(new QCodeReference(RotatingCredentials.class)))
|
||||
.withRealtime("realtime", "test", "rotation")).produce(instance);
|
||||
var runtime = new RedisRuntimeService();
|
||||
@@ -136,7 +136,7 @@ class RedisAuthenticationTest
|
||||
RotatingCredentials.owner = instance;
|
||||
RotatingCredentials.password = TEST_PASSWORD;
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("cache", new RedisConnectionConfig()
|
||||
.withHost(redis.getHost()).withPort(redis.getMappedPort(6379)).withTls(false)
|
||||
.withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis)).withTls(false)
|
||||
.withCredentialsProvider(new QCodeReference(RotatingCredentials.class)))).produce(instance);
|
||||
var runtime = new RedisRuntimeService();
|
||||
try
|
||||
@@ -217,7 +217,7 @@ class RedisAuthenticationTest
|
||||
.withCommand("redis-server", "--requirepass", TEST_PASSWORD).withExposedPorts(6379))
|
||||
{
|
||||
redis.start();
|
||||
var settings = new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379))
|
||||
var settings = new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
|
||||
.withTls(false).withMaximumConnections(1).withMaximumWaitMillis(100).withSocketTimeoutMillis(250)
|
||||
.withCredentialsProvider(new QCodeReference(ValidCredentials.class));
|
||||
var instance = RedisRuntimeTest.hostInstance();
|
||||
@@ -240,7 +240,7 @@ class RedisAuthenticationTest
|
||||
assertEquals(0, connection.metrics().active());
|
||||
long initialClients = clientCount(redis);
|
||||
|
||||
var bad = new RedisConnectionConfig().withHost(redis.getHost()).withPort(redis.getMappedPort(6379))
|
||||
var bad = new RedisConnectionConfig().withHost(FixtureEndpoint.host(redis)).withPort(FixtureEndpoint.port(redis))
|
||||
.withTls(false).withCredentialsProvider(new QCodeReference(WrongCredentials.class));
|
||||
assertRejected(bad);
|
||||
assertEquals(initialClients, clientCount(redis));
|
||||
|
||||
@@ -52,6 +52,7 @@ class RedisPartitionHealingTest
|
||||
{
|
||||
primary.start();
|
||||
replica.start();
|
||||
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary);
|
||||
try(var oldPool = connection(primary))
|
||||
{
|
||||
assertTimeoutPreemptively(Duration.ofSeconds(20), () ->
|
||||
@@ -137,7 +138,7 @@ class RedisPartitionHealingTest
|
||||
***************************************************************************/
|
||||
private static RedisConnection connection(GenericContainer<?> endpoint)
|
||||
{
|
||||
return (new RedisConnection(new HostAndPort(endpoint.getHost(), endpoint.getMappedPort(6379)),
|
||||
return (new RedisConnection(new HostAndPort(FixtureEndpoint.host(endpoint), FixtureEndpoint.port(endpoint)),
|
||||
DefaultJedisClientConfig.builder().connectionTimeoutMillis(500).socketTimeoutMillis(1000).build(),
|
||||
2, Duration.ofMillis(500)));
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ class RedisPromotionTest
|
||||
{
|
||||
primary.start();
|
||||
replica.start();
|
||||
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(primary);
|
||||
FixtureEndpoint.attachToDefaultBridgeWhenInsideContainer(replica);
|
||||
var firstInstance = host(replica);
|
||||
var secondInstance = host(replica);
|
||||
var firstRuntime = new RedisRuntimeService();
|
||||
var secondRuntime = new RedisRuntimeService();
|
||||
try(var oldPrimary = new RedisConnection(new HostAndPort(primary.getHost(), primary.getMappedPort(6379)),
|
||||
try(var oldPrimary = new RedisConnection(new HostAndPort(FixtureEndpoint.host(primary), FixtureEndpoint.port(primary)),
|
||||
DefaultJedisClientConfig.builder().connectionTimeoutMillis(500).socketTimeoutMillis(500).build(), 2, Duration.ofMillis(500)))
|
||||
{
|
||||
try
|
||||
@@ -156,7 +158,7 @@ class RedisPromotionTest
|
||||
private static QInstance host(GenericContainer<?> endpoint) throws Exception
|
||||
{
|
||||
var instance = RedisRuntimeTest.hostInstance();
|
||||
var settings = new RedisConnectionConfig().withHost(endpoint.getHost()).withPort(endpoint.getMappedPort(6379)).withTls(false)
|
||||
var settings = new RedisConnectionConfig().withHost(FixtureEndpoint.host(endpoint)).withPort(FixtureEndpoint.port(endpoint)).withTls(false)
|
||||
.withMaximumConnections(2).withConnectionTimeoutMillis(500).withSocketTimeoutMillis(1000);
|
||||
new RedisQBitProducer().withQBitConfig(new RedisQBitConfig().withConnection("realtime", settings)
|
||||
.withRealtime("realtime", "test", "promotion")).produce(instance);
|
||||
|
||||
Reference in new issue
Block a user