Merge branch 'main' into worktree-messaging-platform
# Conflicts: # src/config/spotbugs/exclude.xml
This commit is contained in:
@@ -24,8 +24,15 @@ readonly EXPECTED_WORKFLOW_LOCK=(
|
||||
'58e28f3358d794ca08f4aa8df4516e03f50a9ee58488b3f0d2619998e069ef14 .github/workflows/httpclient-contract.yml'
|
||||
'823bc346e58a58b2c0814cd1e3e55ec90d360c138419ec3d8f05deb59c62c7eb .github/workflows/httpclient-nightly.yml'
|
||||
'ad84000efc438ee7439517b8f85819e62b13dab0aa4f94066c2905060f3bb581 .github/workflows/httpclient-release.yml'
|
||||
'e0cb998969b8f4b8531f50d38413ee4640931839be7ad50509e1d0e1a84f919e .github/workflows/jpa-next-hibernate8.yml'
|
||||
'6f577e71cd10d74facdf76353f211132d2e2ba04363b8025affb45873f349d9f .github/workflows/jpa-next-jpa4.yml'
|
||||
'f69e174cd0e5a2451078ea23d52efefc13fb27d2c120f5fe30dc93ffc4d532aa .github/workflows/jpa-next-postgresql19.yml'
|
||||
'053593c3f1b5acdc98f01f1986ffbe74163d61c58384d16e27949b879757bef1 .github/workflows/jpa-nightly.yml'
|
||||
'04851f44ba94533bfbc8fabe2b3a2b408726a9996e86ed3864986d1499d16b50 .github/workflows/jpa-pr.yml'
|
||||
'59cb3a0ffc687a15eefe96bc5e3a70d42be78e1cc85d2e7f7880dac6124ca4c7 .github/workflows/jpa-r2-evidence.yml'
|
||||
'ea7f8214a3cc9ec3e7ba3183a2201fd26a05a61f0b0fdcb1f041b71efca3e81c .github/workflows/jpa-release.yml'
|
||||
'5be7e931db749029d89787da042d6d7cf8e683d60698bd8a2993c29db26355fb .github/workflows/link-check.yml'
|
||||
'3d5afcef6bf1c65dcd8cad3d1687f07c2cfbb15d360f41251e46f9eb8950baac .github/workflows/notification-platform.yml'
|
||||
'64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml'
|
||||
'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: jpa-next-hibernate8
|
||||
|
||||
# Hibernate ORM 8 compatibility lane (experimental plan Task 8).
|
||||
#
|
||||
# Re-runs the contracts most likely to move between provider majors: collection fetch pagination,
|
||||
# StatementInspector, Statistics, JSONB, batch, and StatelessSession. Differences are recorded, not
|
||||
# accommodated — weakening the 7.x gate to make this lane green would delete the evidence that 7.x
|
||||
# behaves as documented.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 5 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
hibernate8-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Hibernate ORM 8 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*HibernateCompatibilityPolicyTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,43 @@
|
||||
name: jpa-next-jpa4
|
||||
|
||||
# Jakarta Persistence 4.0 compatibility lane (experimental plan Task 7).
|
||||
#
|
||||
# Non-blocking by design: it reports whether the Stable public API still compiles and whether the
|
||||
# selected mapping contracts still hold on JPA 4. It publishes nothing, and a red result here never
|
||||
# changes a Stable contract — the 3.2 gate keeps asserting what 3.2 must do, because that is what
|
||||
# deployments run.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 4 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
jpa4-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report Jakarta Persistence 4.0 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*CompatibilityLaneDefinitionTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,42 @@
|
||||
name: jpa-next-postgresql19
|
||||
|
||||
# PostgreSQL 19 compatibility lane (experimental plan Task 9).
|
||||
#
|
||||
# Promotion needs evidence, not availability. Two supported patch runs with no unresolved semantic
|
||||
# regression, plus a reviewed ADR, before the Stable support matrix changes — which is what
|
||||
# ExperimentalPromotionGate encodes.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
postgresql19-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Report PostgreSQL 19 compatibility
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test --tests '*ExperimentalPromotionGateTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,131 @@
|
||||
name: jpa-nightly
|
||||
|
||||
# The suites that are too slow, too Docker-heavy, or too machine-dependent for a PR, and the middle
|
||||
# of the PostgreSQL matrix.
|
||||
#
|
||||
# The failure-injection lane is the one that matters most and is easiest to lose: it is the only
|
||||
# place the commit-ambiguity scenarios run, and they are the only evidence that a lost commit
|
||||
# acknowledgement produces completion-unknown rather than a retry.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# 02:30 UTC daily.
|
||||
- cron: '30 2 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
jpa-full-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
postgresql: ["16", "17", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-failure-injection:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Reproduce deadlock, serialization, and commit-ambiguity scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformFailureTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-query-plan-and-security:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the query plan and database security suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformQueryPlanTest
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformSecurityTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-pool-pressure:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Measure pool saturation and REQUIRES_NEW pressure
|
||||
working-directory: src
|
||||
# Machine-dependent bounds are reported rather than asserted unless explicitly enabled, so a
|
||||
# noisy shared runner does not produce a red build that means nothing.
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformPerformanceTest
|
||||
-Pperformance.assertions.enabled=false
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,114 @@
|
||||
name: jpa-pr
|
||||
|
||||
# Every "Stable" row in docs/jpa/support-matrix.md is backed by a job here or in jpa-nightly /
|
||||
# jpa-release. A support level with no job behind it is a marketing claim.
|
||||
#
|
||||
# The PR lane runs the oldest and the newest Stable PostgreSQL rather than all three: a behaviour
|
||||
# that differs across the matrix almost always differs at its ends, and the middle version is
|
||||
# covered nightly. What it does not do is skip the container lane on a runner without Docker —
|
||||
# PostgreSqlContainerFactory throws, because a skipped contract reports success for a database
|
||||
# nobody tested.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/adapter/outbound/persistence-jpa/**'
|
||||
- 'src/app-bootstrap/src/**/jpa/**'
|
||||
- 'src/config/architecture/modules.json'
|
||||
- 'docs/jpa/**'
|
||||
- 'docs/adr/ADR-JPA-*'
|
||||
- 'infra/jpa/**'
|
||||
- '.github/workflows/jpa-pr.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
jpa-unit-and-architecture:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the JPA unit and architecture suites
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:test
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-postgresql-contract:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 16 and 18 — the ends of the Stable matrix. 17 runs nightly.
|
||||
postgresql: ["16", "18"]
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Certify the platform against PostgreSQL ${{ matrix.postgresql }}
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformContractTest
|
||||
-Pjpa.matrix.versions=${{ matrix.postgresql }}
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-migration-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the migration upgrade smoke scenarios
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
:adapter:outbound:persistence-jpa:jpaPlatformMigrationTest
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,73 @@
|
||||
name: jpa-release
|
||||
|
||||
# The release gate. Every item in docs/jpa/support-matrix.md's gate table has a job or an assertion
|
||||
# here, and JpaReleaseManifest parses that document so a gate removed from the docs fails the build
|
||||
# rather than quietly ceasing to be checked.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
jpa-release-gate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Run the full JPA release gate
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
jpaReleaseGate
|
||||
-Pjpa.matrix.versions=16,17,18
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
|
||||
jpa-architecture-and-docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Verify architecture boundaries and the support matrix
|
||||
working-directory: src
|
||||
run: >-
|
||||
./gradlew
|
||||
verifyCleanArchitectureDependencies
|
||||
verifyOneTypePerFile
|
||||
:app-bootstrap:test --tests '*CleanArchitectureTest'
|
||||
:adapter:outbound:persistence-jpa:test --tests '*JpaReleaseManifestTest'
|
||||
--no-daemon
|
||||
--stacktrace
|
||||
@@ -0,0 +1,121 @@
|
||||
name: notification-platform
|
||||
|
||||
# Verification tiers for the Notification Delivery Platform.
|
||||
#
|
||||
# The PR tier is deliberately free of any external provider. A gate that depends on a third-party
|
||||
# sandbox fails for reasons that have nothing to do with the change under review, and a gate people
|
||||
# learn to re-run is not a gate. Real provider smoke tests live in the secret-protected tier, where
|
||||
# a failure is an environment signal rather than a merge blocker.
|
||||
#
|
||||
# Every job that invokes Gradle validates the wrapper first with the repository's pinned action;
|
||||
# the wrapper JAR is executable code fetched at build time, so validating it is what keeps a
|
||||
# compromised wrapper from turning any workflow run into arbitrary code execution.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/application-core/src/**/notification/platform/**'
|
||||
- 'src/adapter/outbound/notification/**'
|
||||
- 'src/adapter/outbound/persistence-jpa/src/**/notification/**'
|
||||
- 'src/adapter/inbound/web/src/**/notification/**'
|
||||
- 'docs/notification/**'
|
||||
- 'infra/notification/**'
|
||||
- '.github/workflows/notification-platform.yml'
|
||||
push:
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
# Nightly: the chaos tier, which is slower and inherently less deterministic than the PR tier.
|
||||
- cron: '0 17 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: notification-platform-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pr:
|
||||
name: contract (Java 21, no external provider)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Compile and format check
|
||||
working-directory: src
|
||||
run: ./gradlew :application-core:compileJava :adapter:outbound:notification:compileJava --console=plain
|
||||
- name: Application contracts
|
||||
working-directory: src
|
||||
run: ./gradlew :application-core:test --console=plain
|
||||
- name: Provider contract suite
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:notification:test --console=plain
|
||||
- name: Persistence and web
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:persistence-jpa:test :adapter:inbound:web:test --console=plain
|
||||
- name: Architecture gates
|
||||
working-directory: src
|
||||
run: |
|
||||
./gradlew verifyCleanArchitectureDependencies --console=plain
|
||||
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --tests '*NotificationArchitectureTest' --console=plain
|
||||
- name: Configuration surface
|
||||
working-directory: src
|
||||
run: ./gradlew verifyEnvKeys verifyPublicPathSnapshot --console=plain
|
||||
- name: Static analysis
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:notification:check -x test --console=plain
|
||||
|
||||
nightly-chaos:
|
||||
name: chaos (ambiguity, restart recovery, callback burst)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Validate Gradle wrapper
|
||||
id: gradle-wrapper-validation
|
||||
uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6
|
||||
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21.0.11+10"
|
||||
cache: gradle
|
||||
cache-dependency-path: |
|
||||
src/**/*.gradle
|
||||
src/**/gradle-wrapper.properties
|
||||
src/**/gradle.lockfile
|
||||
- name: Ambiguity and fault harness
|
||||
working-directory: src
|
||||
run: ./gradlew :adapter:outbound:notification:test --tests '*ChaosSecurity*' --tests '*CrossProviderContractSuite*' --console=plain
|
||||
- name: Full suite
|
||||
working-directory: src
|
||||
run: ./gradlew test --console=plain
|
||||
|
||||
provider-sandbox:
|
||||
name: provider sandbox smoke (secret-protected, non-blocking)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment: notification-provider-sandbox
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2
|
||||
- name: Smoke test against real provider sandboxes
|
||||
env:
|
||||
NOTIFICATION_SANDBOX_ENABLED: 'true'
|
||||
run: |
|
||||
echo "Runs only where provider sandbox credentials are configured."
|
||||
echo "Never a required check: an external outage must not block a merge."
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR-JPA-001 — The domain owns the persistence model
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §10.1, §23.3
|
||||
|
||||
## Context
|
||||
|
||||
A persistence platform can either own the repository abstraction — a `GenericRepository<T, ID>`
|
||||
every aggregate inherits — or provide only the pieces domains assemble themselves.
|
||||
|
||||
## Decision
|
||||
|
||||
The domain owns entities, embeddables, repositories, queries, index requirements, and lock,
|
||||
soft-delete, and audit policy. The platform provides no generic CRUD repository and no base
|
||||
repository. `JpaRepositoryFragmentSupport` exists, has no `save`, `findById`, `findAll`, or
|
||||
`delete`, and is enforced not to acquire them.
|
||||
|
||||
## Consequences
|
||||
|
||||
A generic base repository has one property that looks like a benefit and is not: every aggregate
|
||||
gets the same operations. That means each aggregate is offered operations that may be wrong for it —
|
||||
a `delete` on an append-only ledger, a `findAll` on a table that will never be small — and, worse,
|
||||
one aggregate's later requirement changes the shared base and therefore changes behaviour for
|
||||
aggregates nobody reviewed.
|
||||
|
||||
Spring Data already implements CRUD. Re-implementing it adds a layer whose only function is to be
|
||||
harder to opt out of.
|
||||
|
||||
The cost is a small amount of repetition: each domain declares the repository interface it needs.
|
||||
That repetition is the thing that makes each aggregate's persistence surface reviewable.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaArchitectureRules.noGenericRepository()`; `JpaRepositoryFragmentSupportTest`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-002 — Retry re-runs the whole use case
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §19.2
|
||||
|
||||
## Context
|
||||
|
||||
Optimistic conflicts, deadlocks, and serialization failures are recoverable. The question is what
|
||||
unit gets retried: the failed statement, the transaction, or the use case.
|
||||
|
||||
## Decision
|
||||
|
||||
The whole use case, in a new transaction with a new Persistence Context.
|
||||
`FullTransactionRetryCoordinator` re-enters `JpaTransactionExecutor` for every attempt, and the
|
||||
retry advice is ordered outside Spring's transaction advice so each attempt begins a new
|
||||
transaction.
|
||||
|
||||
## Consequences
|
||||
|
||||
Statement-level retry is wrong for exactly the failures being retried. An optimistic conflict means
|
||||
the state the attempt computed against is no longer the committed state; re-issuing the same
|
||||
statement computes the same wrong answer against a version that has moved on. The domain rules have
|
||||
to run again over reloaded data, which means the whole use case.
|
||||
|
||||
Reusing the Persistence Context would be equally wrong: the second attempt would read the first
|
||||
attempt's stale entities out of the first-level cache. And with the advice ordering inverted, the
|
||||
retry loop would run inside one transaction that has already been marked rollback-only, so the
|
||||
second attempt fails immediately without executing anything.
|
||||
|
||||
The cost is that a retryable use case must be safe to run from scratch — no irreversible external
|
||||
effect before the commit. `IrreversibleSideEffectContext` lets a use case declare when that does not
|
||||
hold, and the policy then refuses to retry it whatever budget remains.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`FullTransactionRetryCoordinatorTest`; `RetryableJpaTransactionInterceptor.DEFAULT_ORDER`.
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-JPA-003 — Completion unknown is never retried
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §17
|
||||
|
||||
## Context
|
||||
|
||||
A connection can break while a commit is in flight. The server may have committed; the
|
||||
acknowledgement may simply have been lost. The driver cannot tell the two apart.
|
||||
|
||||
## Decision
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, automatically or otherwise. It is
|
||||
produced only by a failure observed while the transaction phase is `COMMITTING`, and only for
|
||||
SQLSTATE `40003`, a connection-class (`08*`) state, or a transport break. Recovery is
|
||||
domain-specific reconciliation through `TransactionCompletionResolver`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Retrying a possibly-committed write is the most damaging thing this platform could do: a duplicate
|
||||
payment, a duplicate order, a double decrement. There is no budget or backoff that makes it safe,
|
||||
because the failure is epistemic rather than transient.
|
||||
|
||||
The invariant is enforced at the type level rather than by policy alone. `JpaFailureContext` refuses
|
||||
to construct a retryable completion-unknown context, and the exception rebuilds its context through
|
||||
the safe factory whatever it is handed. A future policy bug therefore cannot produce an unsafe
|
||||
retry — the value it would need does not exist.
|
||||
|
||||
The rule is deliberately narrow in the other direction too. Classifying every connection failure as
|
||||
completion-unknown would push ordinary pool exhaustion and server restarts into the reconciliation
|
||||
queue, which trains operators to clear that queue without reading it — and then the one entry that
|
||||
mattered gets cleared with the rest.
|
||||
|
||||
The cost is that the domain must supply the resolver. The platform cannot: only the domain knows
|
||||
which idempotency record, business row, or outbox entry proves the write happened.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaFailureContextTest`; `DefaultJpaRetryPolicyTest`; `CommitFailureClassifierTest`; release gate
|
||||
`completion-unknown-no-retry`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# ADR-JPA-004 — Flyway is the schema source of truth
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §31
|
||||
|
||||
## Context
|
||||
|
||||
Hibernate can create and alter schema from the entity mapping. Flyway can apply versioned scripts.
|
||||
Both cannot own the schema.
|
||||
|
||||
## Decision
|
||||
|
||||
Flyway owns every schema change. Hibernate validates and never mutates: `ddl-auto` is `validate` or
|
||||
`none`, enforced at startup. The runtime database credential holds no DDL privilege, so the rule is
|
||||
enforced by the server as well as by configuration.
|
||||
|
||||
## Consequences
|
||||
|
||||
`ddl-auto=update` fails in a specific and expensive way: it adds but never drops or narrows, so the
|
||||
result is a schema that is neither the previous one nor the one the mappings describe — produced
|
||||
silently, by whichever instance started first, with no record of what it did.
|
||||
|
||||
Two credentials rather than one is what makes this more than a convention. A configuration rule can
|
||||
be overridden by a property; a role without `CREATE` cannot be overridden by anything the
|
||||
application does.
|
||||
|
||||
Validation fails closed and never repairs. `repair` rewrites the schema history to match the scripts
|
||||
on disk, which resolves a checksum mismatch by deleting the evidence of which change is missing.
|
||||
|
||||
The cost is that a schema change requires a migration script and a deployment step. That is the
|
||||
intended cost: it makes schema change reviewable and reversible.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`JpaDangerousConfigurationGuard`; `FlywaySchemaPolicy`; `FlywayValidationGate`;
|
||||
`PostgreSqlRuntimeRoleVerifier`; release gates `flyway-validate` and `runtime-role-no-ddl`.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-JPA-005 — Contracts run against real PostgreSQL
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-11
|
||||
- Design: §40
|
||||
|
||||
## Context
|
||||
|
||||
An in-memory database makes tests fast and hermetic. A container makes them slow and requires
|
||||
Docker.
|
||||
|
||||
## Decision
|
||||
|
||||
Every persistence contract runs against real PostgreSQL 16, 17, and 18 in containers. H2 remains a
|
||||
local-development convenience and never satisfies a contract. The lanes fail closed when Docker is
|
||||
absent rather than skipping.
|
||||
|
||||
## Consequences
|
||||
|
||||
The behaviours these contracts verify either do not exist in H2 or differ there: SQLSTATE values for
|
||||
the same violation, `FOR UPDATE SKIP LOCKED` semantics, JSONB operators, range types, concurrent
|
||||
index builds, `search_path` privileges, and the generated SQL for a paged collection fetch. A green
|
||||
H2 run is evidence that the code compiles and runs — not that any of the above holds.
|
||||
|
||||
Three versions rather than one because the platform claims three. A contract suite that ran only on
|
||||
16 would make "Stable on 17 and 18" an assumption.
|
||||
|
||||
Skipping on missing Docker is the failure mode this decision most wants to avoid: a skipped contract
|
||||
reports success, and CI eventually inherits that silence. `PostgreSqlContainerFactory.assertDockerAvailable()`
|
||||
throws instead.
|
||||
|
||||
The cost is that the contract lanes need Docker and take minutes. The unit lane stays hermetic and
|
||||
fast, and is where most tests live; the container lanes verify the things only a real server can
|
||||
answer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`PostgreSqlVersion.stable()`; `PostgreSqlContainerFactory`; release gate `postgresql-contract`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# ADR-MONGO-001 — MongoDB platform boundary
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
|
||||
|
||||
## Context
|
||||
|
||||
Two failure modes are common when a team wraps MongoDB.
|
||||
|
||||
The first is flattening: a shared `CommonMongoRepository<T, ID>` and a generic CRUD facade, which
|
||||
forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's
|
||||
single-document atomicity, aggregation model and change streams stop being reachable, and the first
|
||||
collection that needs something different gets a cast or a leaky generic.
|
||||
|
||||
The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any
|
||||
service can drop a collection, run an unbounded pipeline, or issue an admin command from a request
|
||||
thread, and no review catches it because there is nothing structural to catch.
|
||||
|
||||
## Decision
|
||||
|
||||
The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes:
|
||||
|
||||
| Plane | Contents | Client |
|
||||
|---|---|---|
|
||||
| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` |
|
||||
| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` |
|
||||
| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client |
|
||||
| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential |
|
||||
|
||||
Specifically:
|
||||
|
||||
1. **No `CommonMongoRepository<T, ID>`.** Each aggregate declares its own repository.
|
||||
2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails
|
||||
at development time instead of on the next server upgrade.
|
||||
3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered
|
||||
→ database profile → collection allowlist → operation name → timeout → consistency profile →
|
||||
result limit → trace → redaction → command category → admin-command refusal → execute.
|
||||
4. **D4 is a separate client with a separate credential.** No application-plane path reaches it;
|
||||
`PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability.
|
||||
5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the
|
||||
Stable surface.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed
|
||||
for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit
|
||||
trail and credential.
|
||||
|
||||
**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of
|
||||
configuration rather than zero. A genuinely new capability requires a registration before it can be
|
||||
used. Both are deliberate: the cost is paid once per operation, at review time.
|
||||
|
||||
**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every
|
||||
query in every service, and the operations that matter (unbounded pipeline, `dropCollection`,
|
||||
unanchored regex on user input) look unremarkable in a diff.
|
||||
|
||||
## Repository adaptation
|
||||
|
||||
The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry
|
||||
declares exactly 19 leaf identities, so the modules became package boundaries inside
|
||||
`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See
|
||||
[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md).
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-MONGO-002 — BSON representation is a pinned manifest
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §10, decision D-06
|
||||
|
||||
## Context
|
||||
|
||||
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
|
||||
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
|
||||
changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID`
|
||||
can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`.
|
||||
|
||||
The consequences are asymmetric. A representation change is invisible in a value-equality test —
|
||||
`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds
|
||||
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
|
||||
Java representation byte-swaps two halves of the UUID, so a document written under one representation
|
||||
and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong
|
||||
record.
|
||||
|
||||
## Decision
|
||||
|
||||
`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and
|
||||
`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library
|
||||
default.
|
||||
|
||||
| Java | BSON | Rationale |
|
||||
|---|---|---|
|
||||
| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
|
||||
| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. |
|
||||
| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. |
|
||||
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. |
|
||||
| `LocalDate` | declared per field | A calendar day is not an instant. |
|
||||
| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. |
|
||||
| `enum` | `String` name | Ordinals renumber when someone inserts a constant. |
|
||||
|
||||
Type metadata follows `MongoTypeMetadataPolicy` — `NONE`, `ALIAS` or `CLASS_NAME`. A
|
||||
`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes
|
||||
a package rename a data migration.
|
||||
|
||||
The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document,
|
||||
preserving BSON types and keeping missing distinct from null, and
|
||||
`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry
|
||||
pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default
|
||||
is the exact drift the gate exists to catch.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact.
|
||||
UUIDs read back as themselves. Class moves stay refactors.
|
||||
|
||||
**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a
|
||||
migration. A new mapped type needs a manifest entry before it can be used. This is the intended
|
||||
friction: the alternative is discovering the change in production.
|
||||
|
||||
**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate
|
||||
protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as
|
||||
hex, and missing and null both disappear.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §14–§16, decisions D-07 through D-10
|
||||
|
||||
## Context
|
||||
|
||||
MongoDB reports two transaction failures that look similar and must be handled in opposite ways.
|
||||
|
||||
`TransientTransactionError` means the transaction definitively did not commit. The correct response is
|
||||
to run the whole thing again.
|
||||
|
||||
`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the
|
||||
primary changed while the commit was in flight. The correct response is to retry *the commit*, which
|
||||
is a no-op if it already succeeded.
|
||||
|
||||
The common implementation wraps everything in one retry loop. That loop replays the body after an
|
||||
unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path
|
||||
that is a duplicate charge or a duplicate message, produced by the error handler.
|
||||
|
||||
The related trap is session reuse: retrying on the same session after an abort carries the aborted
|
||||
transaction's state into the retry.
|
||||
|
||||
## Decision
|
||||
|
||||
`MongoTransactionRetryCoordinator` implements two loops with different scopes.
|
||||
|
||||
```
|
||||
for each body attempt within the budget:
|
||||
open a NEW session
|
||||
run the body
|
||||
TransientTransactionError -> abort, continue to next body attempt
|
||||
commitWithRetry(session):
|
||||
UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session
|
||||
```
|
||||
|
||||
Rules that follow, all of them load-bearing:
|
||||
|
||||
1. **A new session per body attempt.** No aborted state leaks into a retry.
|
||||
2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a
|
||||
distinct value from `BODY` precisely so this cannot be collapsed by accident.
|
||||
3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with
|
||||
jittered backoff, so a struggling primary is not retried into the ground by every instance at once.
|
||||
4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An
|
||||
ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not
|
||||
happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md).
|
||||
5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what
|
||||
actually happened. A transaction that cannot be reconciled has no recovery path.
|
||||
6. **Classification reads labels before codes.** Server error labels are the authoritative statement
|
||||
about retryability; error codes vary by version.
|
||||
|
||||
Surrounding decisions that reduce how often this path is reached at all: single-document atomic
|
||||
operations are preferred over transactions (D-09), partial changes use update operators rather than
|
||||
`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as
|
||||
an ambiguity. The retry budget is bounded in both attempts and time.
|
||||
|
||||
**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must
|
||||
write a marker they would not otherwise need. Both costs are small compared with reconciling
|
||||
duplicated financial effects after the fact.
|
||||
|
||||
**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction
|
||||
body to be fully idempotent, which is a much stronger and much less checkable property than writing
|
||||
one marker, and it is silently violated the first time someone adds a non-idempotent step.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-MONGO-004 — Index and schema changes belong to the admin plane
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §21–§25, decisions D-11, D-13
|
||||
|
||||
## Context
|
||||
|
||||
Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a
|
||||
collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO
|
||||
and memory on the primary for minutes to hours, and it starts because a pod restarted.
|
||||
|
||||
Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to
|
||||
the code that needs the index, and no record afterwards of what was created.
|
||||
|
||||
Schema validators have the same shape with a sharper edge: tightening a validator on a collection with
|
||||
existing data rejects writes to documents that were legal when they were written.
|
||||
|
||||
TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a
|
||||
minute and deletes in batches, so an expired document routinely remains readable for minutes or hours.
|
||||
|
||||
## Decision
|
||||
|
||||
**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic
|
||||
index creation in production is disabled.
|
||||
|
||||
1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator
|
||||
(`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull
|
||||
request.
|
||||
2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and
|
||||
*changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently
|
||||
rebuild an index whose definition moved.
|
||||
3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF`
|
||||
(staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit).
|
||||
4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from
|
||||
`SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are
|
||||
droppable on drift. A diff engine without ownership eventually proposes dropping
|
||||
`enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary.
|
||||
5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden →
|
||||
observed-unused → droppable, one deployment per transition. Hiding is instantly reversible;
|
||||
dropping is a rebuild.
|
||||
6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
|
||||
contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings →
|
||||
`error`+`STRICT`, in two deployments.
|
||||
7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a
|
||||
document's presence is not authorization and its absence is not a deadline. Access control checks
|
||||
the expiry field; scheduling uses a scheduler.
|
||||
8. **Migrations are checksummed, locked, precondition-checked and resumable.**
|
||||
`MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running
|
||||
different code under one id is worse than a failed deploy.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at
|
||||
every step. Drift is visible without being dangerous. Nothing drops what it does not own.
|
||||
|
||||
**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development
|
||||
uses `APPLY` so the friction is confined to environments where it is warranted.
|
||||
|
||||
**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production,
|
||||
which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a
|
||||
second, divergent source of truth.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-MONGO-ADV-001 — Advanced capability promotion
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15
|
||||
|
||||
## Context
|
||||
|
||||
Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work
|
||||
in a demo within an afternoon. What they do not do is behave the same way in production, and the
|
||||
differences are not discovered by functional tests:
|
||||
|
||||
- Sharding changes which queries are efficient. A query that misses the shard key becomes
|
||||
scatter-gather, which passes every test on a one-shard cluster.
|
||||
- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation —
|
||||
none of which occur against a local key provider.
|
||||
- Search and vector search can be functionally correct and useless: the index returns results, and
|
||||
the results are not relevant. Recall is not visible in a pass/fail assertion.
|
||||
- Database-per-tenant works until the tenant count crosses what the connection and file-handle
|
||||
budget supports, which is an operational property, not a code property.
|
||||
|
||||
The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that
|
||||
never touched the environment where it will run.
|
||||
|
||||
## Decision
|
||||
|
||||
Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion
|
||||
requires evidence, not confidence.
|
||||
|
||||
### Enablement
|
||||
|
||||
`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability
|
||||
does not produce a runtime warning — the type refuses to be constructed, naming the property that
|
||||
enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not
|
||||
being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them.
|
||||
|
||||
### Promotion evidence
|
||||
|
||||
`MongoAdvancedPromotionGate.verify(evidence)` requires every category:
|
||||
|
||||
| Category | Means |
|
||||
|---|---|
|
||||
| `stable-platform` | The Stable release gate passed on the same revision. |
|
||||
| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). |
|
||||
| `security` | Privileges reviewed; the capability's admin role is separate from the application role. |
|
||||
| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. |
|
||||
| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. |
|
||||
| `runbook` | A runbook exists for the capability's characteristic incident. |
|
||||
|
||||
### Additional per-capability requirements
|
||||
|
||||
- **Search / vector search:** relevance and performance evidence, not functional success alone.
|
||||
`MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that
|
||||
measures only latency certifies a fast wrong answer.
|
||||
- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale
|
||||
evidence exists. Both are correct in the small and unbounded in the large.
|
||||
- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It
|
||||
rewrites the collection.
|
||||
|
||||
### Promotion does not change the dependency boundary
|
||||
|
||||
A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the
|
||||
dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive
|
||||
dependency to every service.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** No capability reaches production on the strength of a container-only test. The evidence
|
||||
list is the same for every capability, so promotion is reviewable rather than negotiated.
|
||||
|
||||
**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the
|
||||
target deployment. That is the cost of the guarantee: the alternative is finding out in production,
|
||||
where encryption and sharding are both expensive to reverse.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-advanced.sh
|
||||
```
|
||||
@@ -0,0 +1,64 @@
|
||||
# Entity Mapping Guide
|
||||
|
||||
Design §10-§13. The rules here exist because each one has a failure mode that is invisible in review
|
||||
and expensive in production.
|
||||
|
||||
## The domain owns the model
|
||||
|
||||
The platform defines no business entity. Table names, column semantics, keys, unique and check
|
||||
requirements, associations, cascade rules, lock policy, and soft-delete policy all belong to the
|
||||
domain module. There is no `GenericRepository<T, ID>` and no platform base repository, because a
|
||||
single generic API forces every aggregate through the same operations — and one aggregate's later
|
||||
requirement then changes behaviour for all of them.
|
||||
|
||||
## Entities must be proxyable
|
||||
|
||||
- Not `final`. Hibernate creates a lazy proxy by generating a subclass; a final entity cannot be
|
||||
subclassed, so *every* association to it loads eagerly whatever the mapping says. Nothing errors.
|
||||
- A non-private no-arg constructor. The provider instantiates entities reflectively before
|
||||
populating fields.
|
||||
|
||||
`EntityMappingCondition` in the testkit enforces both.
|
||||
|
||||
## Identifiers
|
||||
|
||||
Default to a sequence with an `allocationSize` that matches the migration's `INCREMENT BY`. When
|
||||
they disagree, the provider hands out identifiers the sequence has not reserved and the collision
|
||||
surfaces later as a primary-key violation under load.
|
||||
|
||||
`GenerationType.IDENTITY` is supported and limited: the key is assigned on insert, so the provider
|
||||
must execute each insert immediately to learn it, which disables JDBC insert batching entirely.
|
||||
`HibernateBatchConfigurationGuard` fails a batch profile that targets an IDENTITY entity rather than
|
||||
letting the import silently run an order of magnitude slower.
|
||||
|
||||
UUIDv7 (`UuidV7Generator`) is the application-side option. It is preferred over UUIDv4 for a primary
|
||||
key because v4 is uniformly random: every insert lands on a random leaf of the B-tree, so the index
|
||||
never stays in cache and write amplification grows with the table.
|
||||
|
||||
## Values
|
||||
|
||||
- Enums are `EnumType.STRING` or an explicit converter. **Never** `ORDINAL` — it stores the
|
||||
constant's position, so inserting a new constant anywhere but the end silently reinterprets every
|
||||
existing row.
|
||||
- Money is `BigDecimal` with explicit precision and scale. `double` cannot represent `0.1`, so sums
|
||||
drift and reconciliation disagrees with the ledger.
|
||||
- `Duration` goes through a converter that stores milliseconds. The ISO-8601 text form sorts and
|
||||
compares wrongly in SQL.
|
||||
- `Instant` and `OffsetDateTime` map differently; a column typed for one cannot faithfully store the
|
||||
other.
|
||||
|
||||
## Associations
|
||||
|
||||
- To-one associations are `LAZY`. JPA's default is `EAGER`, which means every query that loads a
|
||||
child also queries for its parent — the most common accidental N+1 in a JPA application.
|
||||
- The owning side holds the foreign key. Adding to the inverse collection alone leaves the row
|
||||
unlinked, so aggregates expose an association helper that sets both sides.
|
||||
- `CascadeType.ALL` with `orphanRemoval` is correct only for a child the aggregate genuinely owns.
|
||||
Between independent aggregates it deletes rows another part of the system still owns.
|
||||
|
||||
## Entities never leave the transaction
|
||||
|
||||
A controller must not return an entity, or a collection or `Optional` of one. Response serialisation
|
||||
happens after the transaction closes, so a lazy association touched by the serialiser either throws
|
||||
or — with OSIV on, which this platform forbids — issues a query from the view layer, one per element.
|
||||
`EntityExposureCondition` checks generic type arguments, not just the erased return type.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Promotion Checklist
|
||||
|
||||
`ExperimentalPromotionGate` evaluates this checklist. Every technical item, then the ADR.
|
||||
|
||||
## Technical evidence
|
||||
|
||||
- [ ] **Compatibility** — the Stable contract suite passes on the experimental target, twice, on two
|
||||
supported patch releases. One passing run is a coincidence.
|
||||
- [ ] **Security** — for tenancy features, cross-tenant read *and* write are both proven impossible,
|
||||
including through native SQL, bulk DML, `getReference`, and the second-level cache. A filter
|
||||
that covers only entity queries covers none of those.
|
||||
- [ ] **Failure** — connection reuse does not leak tenant context; a failover does not silently route
|
||||
a read-after-write to a stale replica; the commit-ambiguity scenarios still behave.
|
||||
- [ ] **Migration** — per-tenant migration is resumable after a partial failure, and rate-limited.
|
||||
With one schema per tenant, a run is N independent migrations and "it failed" is not an answer.
|
||||
- [ ] **Performance** — pool capacity, replica lag under load, and per-tenant memory are measured,
|
||||
not estimated. Database-per-tenant fails as a sum, not as an individual pool.
|
||||
|
||||
## Decision
|
||||
|
||||
- [ ] **Reviewed ADR** — recording what is being promised, the operational burden it carries, and
|
||||
what would cause it to be withdrawn.
|
||||
|
||||
The ADR is not a formality. The technical suites establish that something works; the ADR records
|
||||
that the platform should promise it, which is a different question with a different cost.
|
||||
|
||||
## What does not count as evidence
|
||||
|
||||
- The version being generally available.
|
||||
- The feature working in one environment.
|
||||
- A passing suite that skipped because Docker was unavailable.
|
||||
- A green lane whose assertions were relaxed to make it pass.
|
||||
|
||||
## Outcomes
|
||||
|
||||
| Decision | Meaning |
|
||||
|---|---|
|
||||
| `BLOCKED_TECHNICAL` | at least one suite has not passed |
|
||||
| `BLOCKED_MISSING_ADR` | evidence is complete; no reviewed decision exists |
|
||||
| `ELIGIBLE_FOR_STABLE_REVIEW` | both; Stable review may begin |
|
||||
|
||||
The two blocked states are distinct because they need different work: one needs evidence, the other
|
||||
needs a decision.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Experimental Support Matrix
|
||||
|
||||
Everything here is off unless its `backend.jpa.experimental.*` flag is explicitly true, and none of
|
||||
it is part of the Stable composition.
|
||||
|
||||
| Feature | Flag | State |
|
||||
|---|---|---|
|
||||
| Shared-schema multi-tenancy (column) | `backend.jpa.experimental.multitenancy-column` | Experimental |
|
||||
| PostgreSQL RLS multi-tenancy | `backend.jpa.experimental.multitenancy-rls` | Experimental |
|
||||
| Schema-per-tenant | `backend.jpa.experimental.multitenancy-schema` | Experimental |
|
||||
| Database-per-tenant | `backend.jpa.experimental.multitenancy-database` | Experimental |
|
||||
| Consistency-aware read replica | `backend.jpa.experimental.read-replica` | Experimental |
|
||||
| Jakarta Persistence 4.0 lane | `backend.jpa.experimental.jakarta-persistence-4` | Experimental |
|
||||
| Hibernate ORM 8 lane | `backend.jpa.experimental.hibernate-8` | Experimental |
|
||||
| PostgreSQL 19 lane | `backend.jpa.experimental.postgresql-19` | Experimental |
|
||||
|
||||
Presence on the classpath is not consent. `ExperimentalFeatureGate` fails startup when a module is
|
||||
present and its flag is not set, because an experimental module can arrive transitively and a
|
||||
tenant-isolation feature that switched itself on would be the worst possible default.
|
||||
|
||||
## Known constraints
|
||||
|
||||
- Tenant context is fail-closed. An unbound tenant in a shared-schema deployment means a query with
|
||||
no tenant predicate, which returns every tenant's rows.
|
||||
- A Hibernate filter is not the security boundary. It does not apply to native SQL, bulk DML,
|
||||
`getReference`, or the second-level cache.
|
||||
- RLS requires all three of: `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` (the owner is
|
||||
otherwise exempt from its own policies), and a runtime role without `BYPASSRLS`.
|
||||
- Tenant bindings are transaction-local. A session-local setting survives the connection's return to
|
||||
the pool.
|
||||
- `readOnly=true` never routes to a replica on its own. Read-after-write uses a consistency token or
|
||||
the primary.
|
||||
- Unavailable replica lag evidence means the primary. Absence of evidence is not evidence of
|
||||
freshness.
|
||||
- Per-tenant pools are bounded globally. Fifty tenants with a modest pool each is five hundred
|
||||
connections against a server that permits a hundred.
|
||||
- Tenant ids never become metric tags. Tenant cardinality is unbounded by definition.
|
||||
|
||||
## Lanes never change Stable
|
||||
|
||||
A compatibility lane publishes nothing and changes no Stable contract. If Hibernate 8 generates
|
||||
different SQL for the fetch-pagination gate, that is a finding about Hibernate 8 — the 7.x gate keeps
|
||||
asserting what 7.x must do, because that is what deployments run.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Migration Guide
|
||||
|
||||
Design §31-§32. Flyway owns the schema; Hibernate only validates.
|
||||
|
||||
## Who may change the schema
|
||||
|
||||
| Environment | Mode |
|
||||
|---|---|
|
||||
| local, test, dev | migrate at startup with the migration credential |
|
||||
| staging, prod | deployment-owned migration; the application validates only |
|
||||
|
||||
Migrating from inside the application in production means every instance of a rolling deploy races
|
||||
to apply the same script, and the loser's failure is indistinguishable from a real one.
|
||||
|
||||
`ddl-auto` is `validate` or `none`. Never `update`: it never drops or narrows anything, so it
|
||||
produces a schema that is neither the old one nor the one the migrations describe — silently, on
|
||||
whichever instance started first.
|
||||
|
||||
## Validation fails closed and never repairs
|
||||
|
||||
`FlywayValidationGate` throws `SchemaMismatchException` on a checksum mismatch, a missing migration,
|
||||
or a schema Hibernate disagrees with. It never calls `repair`.
|
||||
|
||||
Repair rewrites the schema history table to match whatever scripts are on disk. That resolves the
|
||||
symptom by deleting the evidence: a checksum mismatch means the deployed script differs from the
|
||||
applied one, and the interesting question is which change is missing from this database. Repair
|
||||
makes that question unaskable. It exists only as an explicit admin operation with an operator, a
|
||||
reason, and an approval (design §8.4).
|
||||
|
||||
Only Flyway's structured error codes reach the exception. Its messages embed the script path and
|
||||
part of the failing statement.
|
||||
|
||||
## Concurrent index builds
|
||||
|
||||
`CREATE INDEX CONCURRENTLY` cannot run inside a transaction block, and Flyway wraps migrations in
|
||||
one by default. The migration therefore needs a companion configuration:
|
||||
|
||||
```conf
|
||||
# V42__order_index.sql.conf
|
||||
executeInTransaction=false
|
||||
```
|
||||
|
||||
`ConcurrentIndexMigrationInspector` fails validation without it, and additionally requires the
|
||||
migration to contain nothing else. A failed concurrent build leaves an invalid index behind;
|
||||
recovering is a single `DROP INDEX` when the migration did nothing else, and a manual reconstruction
|
||||
of partial state when it did.
|
||||
|
||||
An invalid index is not merely useless — the planner ignores it while every write still maintains
|
||||
it. `FailedConcurrentIndexRecovery` reports them with the statement to run, and deliberately does
|
||||
not drop them: an invalid index can also mean a build is still running, and the two are
|
||||
indistinguishable from the catalog alone.
|
||||
|
||||
## Upgrade scenarios
|
||||
|
||||
Three, each catching something the others do not:
|
||||
|
||||
| Scenario | Catches |
|
||||
|---|---|
|
||||
| `empty` | an early migration edited to match a later one, no longer applying to a fresh database |
|
||||
| `previous-release` | the actual deployment path; the only one exercising this release's migrations |
|
||||
| `oldest-supported` | a migration that silently assumes state only recent databases have |
|
||||
|
||||
Each asserts a data invariant, not just the schema version. A migration that renames a column and
|
||||
loses its contents leaves the version correct and the data gone.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Observability
|
||||
|
||||
Design §37. What is measured, and what must never appear in a measurement.
|
||||
|
||||
## Bounded tags, always
|
||||
|
||||
Every JPA metric carries exactly five tags: persistence unit, operation, query, outcome, failure
|
||||
category. All five are registered identifiers, validated by `LowCardinality` at construction rather
|
||||
than at the registry — so an unbounded value fails where it was introduced instead of surviving
|
||||
until a dashboard stops loading.
|
||||
|
||||
Never a tag: entity id, tenant id, SQL parameter, exception message, JDBC URL. Each is unbounded, so
|
||||
each creates a time series per row or per failure; several are also the data the platform keeps out
|
||||
of logs, which a metrics backend would store just as durably and export just as widely.
|
||||
|
||||
## Transaction metrics
|
||||
|
||||
| Meter | Why it exists |
|
||||
|---|---|
|
||||
| `jpa.transaction.duration` | the baseline |
|
||||
| `jpa.transaction.rollback` | rollback rate by failure category |
|
||||
| `jpa.transaction.timeout` | timeouts, distinct from other rollbacks |
|
||||
| `jpa.transaction.completion.unknown` | its own counter, deliberately |
|
||||
|
||||
Completion-unknown gets a separate counter rather than being folded into failures. It is the one
|
||||
outcome that means a human has to look: every other failure is a transaction that definitely did not
|
||||
happen, while this one is a transaction that may have.
|
||||
|
||||
## Query metrics
|
||||
|
||||
`jpa.query.duration` and `jpa.query.rows`. Rows are measured as well as duration because a query
|
||||
that issues one statement and hydrates twenty thousand rows is fast per statement and catastrophic
|
||||
per request — a duration metric alone reports it as merely slow.
|
||||
|
||||
## Retry metrics
|
||||
|
||||
Attempts are metrics, not warnings. Optimistic conflicts and serialization failures are the expected
|
||||
cost of concurrency; logging each at WARN pages someone for a system working as designed, after
|
||||
which the retry log gets filtered out and takes the genuinely interesting entries with it.
|
||||
|
||||
`jpa.retry.attempt`, `jpa.retry.attempts` (distribution per operation), `jpa.retry.exhausted`.
|
||||
|
||||
## Query names in SQL
|
||||
|
||||
`NamedStatementInspector` prefixes each statement with its registered query name as a SQL comment,
|
||||
which travels into `pg_stat_activity`, `auto_explain`, and the slow-query log. Without it, "which
|
||||
endpoint issues this query" is answered by grepping the codebase for fragments of SQL.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
`SqlDiagnosticRedactor` removes string literals, numbers, and anything email-shaped before SQL
|
||||
reaches a log. Redaction is blunt on purpose: preserving "harmless" values would require knowing
|
||||
which columns hold personal data.
|
||||
|
||||
## The actuator endpoint
|
||||
|
||||
`jpaplatform` reports database major version, provider version, schema version, OSIV state, runtime
|
||||
role verification, and capability levels. It reports no JDBC URL, no username, no SQL, and no entity
|
||||
catalog — an actuator endpoint is reachable by anyone who reaches the management port, and each of
|
||||
those would be a free reconnaissance answer. It is read-only: an endpoint that could trigger a
|
||||
migration or a repair would be an admin capability exposed over HTTP.
|
||||
@@ -0,0 +1,72 @@
|
||||
# PostgreSQL Extensions
|
||||
|
||||
Design §8.3, §21, §30. What the platform uses beyond portable JPA, and what each is guarded by.
|
||||
|
||||
Everything here is core PostgreSQL. No server extension is required.
|
||||
|
||||
## Locking
|
||||
|
||||
`SELECT ... FOR UPDATE` with a finite bound, always. `PostgreSqlLockOptions` refuses an unbounded
|
||||
lock request because it waits as long as the holder holds it, turning one slow transaction into a
|
||||
pile-up of blocked connections.
|
||||
|
||||
`NOWAIT` and a wait timeout are separate requests, not two spellings of one — modelling them as a
|
||||
single field with a magic zero is how "no wait" becomes "wait forever".
|
||||
|
||||
`55P03` (lock not available) and `40P01` (deadlock) drive opposite recovery and are never collapsed:
|
||||
the first leaves the transaction alive and the caller in control; the second has already been rolled
|
||||
back by the server.
|
||||
|
||||
## Work claims
|
||||
|
||||
`FOR UPDATE SKIP LOCKED` is reachable only through a registered `WorkQueueName`, never as a
|
||||
repository flag. It deliberately returns an incomplete view of the table: correct for handing
|
||||
disjoint work to competing workers, silently wrong for anything that needs to see every matching
|
||||
row. A registered claim statement must skip locked rows and impose a deterministic `ORDER BY`.
|
||||
|
||||
## Upserts
|
||||
|
||||
`INSERT ... ON CONFLICT ... RETURNING` under a registered `NativeWriteName` with a fixed conflict
|
||||
target and update column set. The conflict target cannot be a bound parameter, so accepting one from
|
||||
a caller would mean building SQL from input.
|
||||
|
||||
An upsert is the correct answer to a create race precisely because the database decides.
|
||||
Read-then-write cannot be made correct: another transaction can commit between the read and the
|
||||
write. `(xmax = 0) AS inserted` in the `RETURNING` list is what lets the platform report
|
||||
insert-versus-update without a second query.
|
||||
|
||||
The executor flushes before and clears after: a native write is invisible to the Persistence
|
||||
Context, so a pending managed change would otherwise overwrite it, and a managed entity loaded
|
||||
beforehand would keep serving pre-upsert values.
|
||||
|
||||
## JSONB
|
||||
|
||||
`JsonDocument` carries a schema name and version alongside the payload. A JSONB column is schemaless
|
||||
at the database level, so without an envelope the only record of what a stored document means is the
|
||||
code that wrote it — and a document written two releases ago is indistinguishable from a current one.
|
||||
|
||||
The payload never carries a Java class name. Type metadata in a JSONB column is a deserialization
|
||||
gadget: whoever can write a row chooses the class the reader instantiates.
|
||||
|
||||
Query paths are registered. A JSON path is part of the SQL text and cannot be bound, so forwarding a
|
||||
request field into one is concatenating untrusted input into a statement. Values are always bound.
|
||||
|
||||
## Arrays and ranges
|
||||
|
||||
Arrays are built with `Connection.createArrayOf`, never by formatting a literal — hand-formatting is
|
||||
where quoting bugs live, and a tag containing a comma changes the array's shape rather than its
|
||||
content.
|
||||
|
||||
`PgRange` models both endpoints as independently optional and independently inclusive, because that
|
||||
is what a PostgreSQL range is. Whether `[09:00, 10:00)` and `[10:00, 11:00)` overlap depends on the
|
||||
bracket, not the values, and a pair of `timestamptz` columns cannot express it.
|
||||
|
||||
## COPY (J4 admin)
|
||||
|
||||
`COPY` bypasses the Persistence Context, entity callbacks, version checks, and Envers entirely. That
|
||||
is why it is fast and why it is an admin capability with a registered statement, a bounded stream, a
|
||||
row and byte cap, a finite server-side `statement_timeout`, and a named operator.
|
||||
|
||||
The registry accepts only `COPY ... FROM STDIN`. `COPY ... FROM '/path'` reads a file on the
|
||||
*database server* as the server's OS user; it is superuser-only for exactly that reason and does not
|
||||
belong behind an application API.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Query and Fetch Guide
|
||||
|
||||
Design §23-§28. How queries are chosen, bounded, and proven.
|
||||
|
||||
## Named queries
|
||||
|
||||
Every registered query carries a `QueryName`. It becomes the metric tag, the trace attribute, and
|
||||
the SQL comment that appears in `pg_stat_activity` and the slow-query log — which is the only thing
|
||||
that connects a statement on the server back to the use case that issued it. The format rejects raw
|
||||
SQL for a reason: a metric tag built from a query string is unbounded by construction, and one built
|
||||
from a parameterised value leaks row data into telemetry.
|
||||
|
||||
## Fetch plans, not eager mappings
|
||||
|
||||
N+1 is solved per use case with a registered entity graph, not by making an association `EAGER` in
|
||||
the mapping. The eager fix repairs the one query that needed it and imposes the extra join on every
|
||||
other query against that entity, including the ones that only wanted the id.
|
||||
|
||||
`fetchgraph` and `loadgraph` are different: a fetch graph is exhaustive (attributes outside it are
|
||||
lazy whatever the mapping says), a load graph is additive. Choosing the wrong one produces either
|
||||
missing data or the amplification the graph was meant to avoid.
|
||||
|
||||
## Measuring, not guessing
|
||||
|
||||
`QueryMeasurement` records statements, hydrated entities, rows, fetches, and elapsed time. Statement
|
||||
count alone cannot distinguish the two failures that matter:
|
||||
|
||||
- **N+1** — many statements, few rows.
|
||||
- **Cartesian fetch** — one statement, an enormous number of rows.
|
||||
|
||||
A suite asserting only on statement count passes the second one every time.
|
||||
|
||||
## Pagination
|
||||
|
||||
Offset pagination makes the database walk and discard `n` rows before returning any. Keyset
|
||||
pagination replaces it:
|
||||
|
||||
- The predicate is lexicographic. For an ordering of `(createdAt, id)`, "after `(t, x)`" is
|
||||
`createdAt < t OR (createdAt = t AND id < x)` — **not** `createdAt <= t AND id < x`, which reads
|
||||
plausibly and silently drops rows from the middle of the result set.
|
||||
- The ordering must end in a unique column. Without one, a page boundary inside a run of equal
|
||||
values duplicates and skips rows.
|
||||
- `size + 1` rows are fetched and `size` returned. That extra row answers `hasNext` without a count
|
||||
query, which would be a second full scan whose answer is stale on arrival.
|
||||
|
||||
Cursors are signed. An unsigned cursor is client-controlled ordering state: rewriting it lets a
|
||||
caller seek to arbitrary keys.
|
||||
|
||||
## Sorting
|
||||
|
||||
Client sort parameters are mapped through `SafeSortRegistry`, never passed through. A sort field
|
||||
reaches the query as part of the ORDER BY clause rather than as a bound value, so forwarding the
|
||||
client's string means the client writes part of the statement. `JpaSort.unsafe` has no call site in
|
||||
this platform.
|
||||
|
||||
The registry's tie-breaker is always appended, because a sort that does not end in a unique column
|
||||
has no total order and paging over a non-total order duplicates and skips rows.
|
||||
|
||||
## Streaming
|
||||
|
||||
A JPA `Stream` is a live cursor holding a `ResultSet`, a statement, and a connection. `JpaStreamExecutor`
|
||||
consumes it inside a try-with-resources and never returns it, because a stream returned past the
|
||||
transaction boundary is a connection leak that presents as unrelated timeouts elsewhere. A read-only
|
||||
transaction is required: streaming inside a write transaction pins a write connection for the whole
|
||||
traversal.
|
||||
|
||||
## Batching
|
||||
|
||||
Configuring `hibernate.jdbc.batch_size` proves nothing. `BatchExecutionResult.jdbcBatches` comes from
|
||||
counting real `executeBatch()` calls at the JDBC layer, because an IDENTITY generator, an interleaved
|
||||
select, or a mid-loop flush disables batching while the configuration still says it is on.
|
||||
|
||||
Flush and clear are separate boundaries. Flushing alone sends the statements and keeps every entity
|
||||
in the Persistence Context — the classic bulk-import out-of-memory.
|
||||
@@ -0,0 +1,156 @@
|
||||
# JPA Relational Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `jpa-superpowers-package/docs/superpowers/specs/2026-08-11-jpa-persistence-platform-design.md`
|
||||
(copied to `docs/superpowers/specs/`)
|
||||
**Stable plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-platform-implementation-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
**Experimental plan source:** `jpa-superpowers-package/docs/superpowers/plans/2026-08-11-jpa-persistence-experimental-expansion-plan.md`
|
||||
(copied to `docs/superpowers/plans/`)
|
||||
|
||||
The design package states its own adaptation rule (§3.2): the assumed package paths and Gradle
|
||||
structure are explicit implementation *assumptions* made because the real Backend Skeleton
|
||||
repository was not supplied. Before implementing, paths are adjusted to the repository's existing
|
||||
conventions and root package while the public contracts and policy semantics are preserved.
|
||||
|
||||
This file is the single record of *how* that mapping was performed. Only paths, build DSL, and
|
||||
composition-root ownership changed. Public contracts, policy order, retry semantics, and error
|
||||
semantics are implemented as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The plan assumes a greenfield library with 18 Stable Gradle projects under `modules/jpa/` plus 7
|
||||
Experimental projects under `modules/jpa-experimental/`. This repository is a Clean Architecture
|
||||
template whose **fail-closed registry** (`src/config/architecture/modules.json`, enforced by
|
||||
`src/settings.gradle` and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf
|
||||
identities**, and `src/settings.gradle` throws when the registry does not contain exactly 19
|
||||
modules. Creating 25 more Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the plan's library modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-jpa`, with two exceptions driven by this repository's own rules.
|
||||
This is the same adaptation already applied to the HTTP client platform
|
||||
(`docs/httpclient/repository-adaptation.md`).
|
||||
|
||||
| Plan module | Repository home | Reason |
|
||||
|---|---|---|
|
||||
| `jpa-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.jpa`) | This repository's composition root owns wiring, startup validation, and actuator surface; an adapter leaf must not auto-configure itself. `AGENTS.md` assigns composition to `app-bootstrap`. |
|
||||
| `jpa-testkit`, `jpa-testkit-postgresql`, `jpa-testkit-migration`, `jpa-testkit-queryplan` | `:adapter:outbound:persistence-jpa` `src/testkit/java/**/testkit` | The plan forbids production modules depending on the testkit. A source set whose dependencies are declared only on test configurations gives the same guarantee without a new Gradle project, and more than one lane consumes it. |
|
||||
|
||||
The package boundary is enforced by `JpaModuleBoundaryTest`, which reproduces the plan's
|
||||
§3 module dependency map as package rules.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.jpa` → `dev.caskeleton.adapter.outbound.persistence`.
|
||||
|
||||
| Plan module | Plan package | Repository package |
|
||||
|---|---|---|
|
||||
| `jpa-core-api` | `…jpa.api` (+ `.capability`, `.error`, `.query`, `.transaction`) | `dev.caskeleton.adapter.outbound.persistence.api` (+ same subpackages) |
|
||||
| `jpa-transaction` | `…jpa.transaction` | `…persistence.transaction` |
|
||||
| `jpa-spring-data` | `…jpa.springdata` | `…persistence.springdata` |
|
||||
| `jpa-querydsl` | `…jpa.querydsl` | `…persistence.querydsl` |
|
||||
| `jpa-hibernate` | `…jpa.hibernate` (+ `.batch`, `.bulk`, `.stateless`) | `…persistence.hibernate` (+ same subpackages) |
|
||||
| `jpa-postgresql` | `…jpa.postgresql` (+ `.error`, `.lock`, `.constraint`, `.json`, `.array`, `.range`, `.write`) | `…persistence.postgresql` (+ same subpackages) |
|
||||
| `jpa-postgresql-copy` | `…jpa.postgresql.copy` | `…persistence.postgresql.copy` |
|
||||
| `jpa-migration-flyway` | `…jpa.migration` | `…persistence.migration` |
|
||||
| `jpa-auditing` | `…jpa.auditing` | `…persistence.auditing` |
|
||||
| `jpa-envers` | `…jpa.envers` | `…persistence.envers` |
|
||||
| `jpa-cache-hibernate` | `…jpa.cache` | `…persistence.cache` |
|
||||
| `jpa-observability` | `…jpa.observation` | `…persistence.observation` |
|
||||
| `jpa-security` | `…jpa.security` | `…persistence.security` |
|
||||
| `jpa-spring-boot-starter` | `…jpa.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.jpa` |
|
||||
| `jpa-testkit*` | `…jpa.testkit` (+ `.id`, `.mapping`, `.lifecycle`, `.query`, `.fetch`, `.postgresql`, `.migration`, `.queryplan`, `.failure`, `.pool`, `.release`) | `…persistence.testkit` (+ same subpackages), `testkit` source set |
|
||||
| `jpa-experimental/*` | `…jpa.experimental` (+ `.tenant`, `.rls`, `.schema`, `.database`, `.replica`, `.next`) | `…persistence.experimental` (+ same subpackages) |
|
||||
|
||||
The existing `…persistence.transaction` and `…persistence.postgresql` packages already hold this
|
||||
leaf's `TransactionPort` implementation and PostgreSQL vendor composition. The platform types are
|
||||
**additive**: no existing type was renamed, moved, or replaced, and no plan type collides with an
|
||||
existing name.
|
||||
|
||||
## 3. Test-suite mapping
|
||||
|
||||
The plan declares seven JVM test suites (`test`, `integrationTest`, `contractTest`,
|
||||
`migrationTest`, `failureTest`, `performanceTest`, `compatibilityTest`). This leaf already owns a
|
||||
Docker-backed `postgresqlIntegrationTest` source set and its readiness Gradle tasks are registered
|
||||
in a fail-closed contract (`verifyJpaReadinessRegistry` in `src/build.gradle`).
|
||||
|
||||
| Plan suite | Repository lane |
|
||||
|---|---|
|
||||
| `test` | `src/test` — hermetic unit lane, `./gradlew :adapter:outbound:persistence-jpa:test` |
|
||||
| `contractTest`, `integrationTest`, `migrationTest`, `failureTest`, `compatibilityTest` | `src/postgresqlIntegrationTest` — real PostgreSQL containers; selected by the `jpaPlatform*` Gradle tasks |
|
||||
| `performanceTest` | `src/jpaPlatformPerformanceTest` — machine-dependent bounds, never part of `check` |
|
||||
|
||||
Docker-dependent lanes fail closed rather than skipping, matching the existing
|
||||
`PostgreSqlReadinessSupport.assertDockerAvailable()` convention in this leaf.
|
||||
|
||||
## 4. Other deliberate substitutions
|
||||
|
||||
| Plan assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. |
|
||||
| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). |
|
||||
| Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. |
|
||||
| PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. |
|
||||
| `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. |
|
||||
| `infra/jpa/{postgres,roles,toxiproxy}` | Repository already owns `infra/` | Created at the same repository-relative paths. |
|
||||
| `docs/jpa/**`, `docs/adr/ADR-JPA-*`, `.github/workflows/jpa-*.yml` | Repository already owns `docs/` and `.github/workflows/` | Created at the same repository-relative paths. |
|
||||
| `build.gradle.kts` release aggregate `jpaReleaseGate` | Root is `src/build.gradle` | Registered there against the repository lane names in §3. |
|
||||
| Per-task `git add` + `git commit` | `AGENTS.md`: commit policy is `human-only`; agents do not stage, commit, amend, or push | Implementation is delivered unstaged. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| Querydsl as an optional module dependency | Querydsl is not part of this repository's dependency set | `querydsl` is implemented against the plan's contracts with the Querydsl types kept behind `compileOnly`, so the Stable runtime classpath never carries Querydsl and a deployment opting in adds the artifact itself. |
|
||||
| Hibernate Envers as a module dependency | Envers is not part of this repository's dependency set | Same treatment as Querydsl: `compileOnly` + explicit opt-in, matching the plan's "Envers is opt-in and never enabled by a global base class". |
|
||||
| `build-logic/src/test/kotlin/JpaModuleBoundaryTest.kt` | There is no `build-logic` project and no Kotlin source set; module boundaries are enforced by the registry itself | `verifyCleanArchitectureDependencies` plus `:app-bootstrap:test --tests '*CleanArchitectureTest'` assert the same property against `src/config/architecture/modules.json`, which is the authority the plan's test would have had to duplicate. |
|
||||
| `PostgreSqlRuntimeRoleVerifierIntegrationTest` (Task 45) | The security lane is one suite in this leaf rather than a per-module `integrationTest` | `PostgreSqlSecurityContractTest` (tag `jpa-security`) exercises `PostgreSqlRuntimeRoleVerifier.verify` and `.requireSafe` against a real restricted role on a real server. |
|
||||
| `JpaSafetyProperties`, `JpaDataSourceProperties` | `NamingConventionTest` requires every `@ConfigurationProperties` type to end in `Settings` or `Policy` | Renamed to `JpaSafetySettings` and `JpaDataSourceSettings`. The bound property prefixes and every field are unchanged; only the class names move to this repository's convention. |
|
||||
|
||||
### Types relocated to keep the dependency direction legal
|
||||
|
||||
The plan's module map forbids `jpa-core-api` from depending on any other platform module. Three
|
||||
value-only types the design places in a downstream module are consumed by a core contract, so they
|
||||
live in the core here instead. Each is a pure value with no framework dependency, so the relocation
|
||||
costs nothing and the alternative — a core contract importing an adapter package — would break the
|
||||
boundary the module map exists to hold.
|
||||
|
||||
| Type | Plan module | Repository package | Consumed by |
|
||||
|---|---|---|---|
|
||||
| `TransactionCompletionEvidence` | `jpa-transaction` | `…persistence.api.transaction` | `TransactionCompletionUnknownException` (design §17.3 types the field) |
|
||||
| `ConstraintCode` | `jpa-postgresql` | `…persistence.api.error` | `ConstraintViolationDetails` (design §22.4) |
|
||||
| `SqlStateResolver`, `SqlExceptionSqlStateResolver` | `jpa-transaction` | `…persistence.api.error` | both the transaction module's commit classifier and the PostgreSQL translator |
|
||||
|
||||
The ArchUnit rule pack (`JpaArchitectureRules`, `EntityMappingCondition`, `EntityExposureCondition`)
|
||||
is placed in the `testkit` source set rather than in `…persistence.security` production code. ArchUnit
|
||||
is a test library; putting the rule pack in `main` would drag it onto every deployment's runtime
|
||||
classpath to serve code that only ever runs in a test.
|
||||
|
||||
|
||||
### Findings the contracts produced against a real server
|
||||
|
||||
Two of the design's rules turned out to be stated slightly wrong, and the container lanes are what
|
||||
showed it. Both are recorded here because the design text still reads the old way.
|
||||
|
||||
- **§17.2 commit ambiguity is not only SQLSTATE class `08`.** `pg_terminate_backend` on a backend
|
||||
with a commit in flight reports `57P01` (`admin_shutdown`), not a connection-class state — and the
|
||||
commit record may already be in the WAL when it arrives. `CommitFailureClassifier` now treats
|
||||
`57P01`/`57P02`/`57P03` as completion-unknown alongside `40003`, class `08`, and transport breaks.
|
||||
`CommitAmbiguityContractTest` asserts the SQLSTATE directly so the rule cannot silently narrow
|
||||
again.
|
||||
- **Schema-per-tenant status must be read back, not inferred from the run.** `MigrateResult`'s
|
||||
target version is empty for a tenant that was already current, so recording it reported migrated
|
||||
tenants as unmigrated during a partial rollout. `SchemaTenantMigrationOrchestrator` now reads the
|
||||
applied version from the tenant's schema history.
|
||||
|
||||
## 5. What is unchanged from the design
|
||||
|
||||
- Domain owns Entity, Embeddable, Repository, Query, index requirements, lock/soft-delete/audit
|
||||
policy. No `GenericRepository<T, ID>` and no Spring Data CRUD re-implementation exists.
|
||||
- Application Service owns the transaction boundary; OSIV is false in every runtime profile.
|
||||
- `TransactionCompletionUnknownException` always reports `completionUnknown=true`,
|
||||
`retryable=false`, and is never automatically retried — reconciliation handles it.
|
||||
- Retry re-executes the whole use case in a new transaction and a new Persistence Context.
|
||||
- SQLSTATE classification is structural (`40001`, `40003`, `40P01`, `23505`, `23503`, `23514`,
|
||||
`55P03`) and never parses localized message text.
|
||||
- Flyway is the source of truth for production schema change; Hibernate only validates;
|
||||
`ddl-auto` never mutates a deployed schema.
|
||||
- `CREATE INDEX CONCURRENTLY` requires an explicit non-transactional migration marker.
|
||||
- Metric labels and ordinary logs never carry SQL parameters, entity IDs, tenant IDs, or PII.
|
||||
- Experimental features (multi-tenancy, RLS, schema/database tenancy, read replica, JPA 4,
|
||||
Hibernate 8, PostgreSQL 19) stay behind `backend.jpa.experimental.*` flags and never enter the
|
||||
Stable composition.
|
||||
@@ -0,0 +1,85 @@
|
||||
# JPA Platform Runbooks
|
||||
|
||||
Operator procedures for the failures this platform is designed to surface rather than hide.
|
||||
|
||||
## A transaction reported completion unknown
|
||||
|
||||
**Signal:** `jpa.transaction.completion.unknown` incremented; a `CompletionUnknownRecord` in the
|
||||
reconciliation channel.
|
||||
|
||||
**What it means:** the commit may or may not have happened. It is not a rollback.
|
||||
|
||||
**Do not** re-run the use case. That is what the platform refused to do automatically, for the same
|
||||
reason.
|
||||
|
||||
**Procedure:**
|
||||
|
||||
1. Take the `transactionKey` from the record.
|
||||
2. Check the idempotency record for that key.
|
||||
3. Check the business row the use case would have written.
|
||||
4. Check the outbox for a corresponding event.
|
||||
5. If all three agree the write happened, mark the record `COMMITTED` and stop.
|
||||
6. If all three agree it did not, the use case may be re-run.
|
||||
7. If they disagree or are inconclusive, leave it `STILL_UNKNOWN` and escalate. An inconclusive
|
||||
answer is a legitimate outcome; guessing is not.
|
||||
|
||||
A record with no `transactionKey` cannot be resolved automatically — use the operation name and
|
||||
timestamp.
|
||||
|
||||
## Deadlock or serialization rate rising
|
||||
|
||||
**Signal:** `jpa.retry.attempt` rising; `jpa.retry.exhausted` non-zero.
|
||||
|
||||
Retries are expected. Exhaustion is not.
|
||||
|
||||
1. Group `jpa.retry.attempt` by operation. A single operation dominating means a hot row or an
|
||||
inconsistent lock order.
|
||||
2. For deadlocks, check whether two operations take the same rows in opposite orders — that is a
|
||||
code fix, not a tuning one.
|
||||
3. For serialization failures under `SERIALIZABLE`, confirm the isolation is actually required.
|
||||
4. Only then consider raising `maxAttempts`. A larger budget on a hot row converts a fast failure
|
||||
into a slow one.
|
||||
|
||||
## Pool exhaustion
|
||||
|
||||
**Signal:** connection acquisition timeouts; `PoolMeasurement.pending` non-zero.
|
||||
|
||||
1. Check `REQUIRES_NEW` usage. It takes a second connection while pinning the first, so the pool
|
||||
must satisfy `(threads x (1 + depth)) + 1`.
|
||||
2. Check for streaming outside a bounded scope — a `Stream` returned past the transaction holds its
|
||||
connection until the pool notices.
|
||||
3. Check for external calls inside a DB transaction. The design forbids them precisely because an
|
||||
HTTP timeout then holds a connection for its whole duration.
|
||||
|
||||
## Flyway validation failed at startup
|
||||
|
||||
The deployment is running against a schema it was not built for. It failed closed, which is correct.
|
||||
|
||||
1. Read the reported error codes (the messages are deliberately not propagated).
|
||||
2. `CHECKSUM_MISMATCH` — an applied migration was edited afterwards. Find which change is missing
|
||||
from this database. **Do not run `repair`**: it rewrites history to match the scripts, which
|
||||
resolves the symptom by deleting the evidence.
|
||||
3. `MISSING_SCRIPT` — a migration applied here is not in this build. Usually a rollback to an older
|
||||
artifact.
|
||||
|
||||
## An invalid index exists
|
||||
|
||||
**Signal:** `FailedConcurrentIndexRecovery.invalidIndexes()` is non-empty.
|
||||
|
||||
A concurrent build failed. The index is ignored by the planner and maintained by every write.
|
||||
|
||||
1. Confirm no build is currently running. An in-progress build looks identical in the catalog.
|
||||
2. Run the reported `DROP INDEX CONCURRENTLY` outside a migration.
|
||||
3. Re-apply the index migration.
|
||||
|
||||
The platform does not drop these automatically: on a rolling deploy every instance would race to
|
||||
drop an index another instance was about to finish building.
|
||||
|
||||
## The runtime role failed verification
|
||||
|
||||
Startup refused because the runtime credential holds `CREATE`, or `search_path` contains an
|
||||
unapproved schema.
|
||||
|
||||
This is not a false positive to be worked around. Re-provision from
|
||||
`infra/jpa/roles/runtime-roles.sql`; the application's credential having DDL is the condition that
|
||||
makes every other schema guarantee unenforceable.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Security
|
||||
|
||||
Design §36. Credential separation, privilege verification, and what never leaves the process.
|
||||
|
||||
## Three credentials
|
||||
|
||||
| Role | May |
|
||||
|---|---|
|
||||
| `app_migration` | own the schema, apply migrations (DDL) |
|
||||
| `app_runtime` | select, insert, update, delete (DML only) |
|
||||
| `app_admin` | J4 operations — COPY, backfill, maintenance |
|
||||
|
||||
The separation is what makes "Flyway owns schema change" enforceable rather than aspirational. If
|
||||
the application's own credential cannot execute DDL, then no code path, no library, and no injected
|
||||
statement can alter the schema at runtime, regardless of what the application intended.
|
||||
|
||||
`infra/jpa/roles/runtime-roles.sql` provisions them.
|
||||
|
||||
## Startup verification
|
||||
|
||||
`PostgreSqlRuntimeRoleVerifier` asks the *server* what the connection can do:
|
||||
|
||||
```sql
|
||||
select current_user,
|
||||
current_setting('search_path'),
|
||||
has_schema_privilege(current_user, current_schema(), 'CREATE'),
|
||||
has_database_privilege(current_user, current_database(), 'CREATE')
|
||||
```
|
||||
|
||||
Configuration cannot answer this. Effective privileges come from direct grants, inherited role
|
||||
memberships, `PUBLIC` grants, and schema ownership, and no reading of a deployment manifest
|
||||
reconstructs that combination reliably.
|
||||
|
||||
Startup fails when the runtime role is not on the allowlist, or holds `CREATE` on the schema or the
|
||||
database.
|
||||
|
||||
## search_path
|
||||
|
||||
`SearchPathPolicy` is an allowlist. `search_path` decides which schema an unqualified name resolves
|
||||
to, so a writable untrusted schema on it — classically `public`, where `CREATE` was granted broadly
|
||||
before PostgreSQL 15 — lets a planted table, function, or operator shadow the real one, and the
|
||||
application executes it without noticing. `$user` is exempt: only the connected role owns it.
|
||||
|
||||
Refusing the runtime role `CREATE` closes the same route from the other side.
|
||||
|
||||
## What never leaves the process
|
||||
|
||||
- SQL parameter values, entity ids, tenant ids, and PII: not in exception messages, not in metric
|
||||
tags, not in logs. `JpaFailureContext` composes messages from bounded values only.
|
||||
- Constraint names reach the application as registered `ConstraintCode`s; an unregistered physical
|
||||
name maps to a bounded unknown code rather than being passed through.
|
||||
- Cursors are HMAC-signed. An unsigned cursor is client-controlled ordering state.
|
||||
- The actuator report carries no JDBC URL, username, or SQL.
|
||||
|
||||
## Injection surfaces, and how each is closed
|
||||
|
||||
| Surface | Why it cannot be a parameter | Closed by |
|
||||
|---|---|---|
|
||||
| sort field | part of ORDER BY | `SafeSortRegistry` allowlist |
|
||||
| JSON path | part of the statement | registered `JsonPathName` |
|
||||
| schema name | an identifier | registered `SchemaTenantRegistry` |
|
||||
| upsert conflict target | an identifier list | registered `UpsertConflictTarget` |
|
||||
| COPY table | an identifier | registered `RegisteredCopyStatement` |
|
||||
| queue claim SQL | a whole statement | registered `WorkQueueDefinition` |
|
||||
|
||||
Values are always bound. Identifiers are always registered.
|
||||
@@ -0,0 +1,79 @@
|
||||
# JPA Persistence Platform — Support Matrix
|
||||
|
||||
The machine-readable source for `JpaReleaseManifest`. A release gate parses this file, so a version
|
||||
or gate that stops being named here stops being claimed — and the build fails rather than the
|
||||
document quietly drifting from the code.
|
||||
|
||||
## Database
|
||||
|
||||
| Database | Support | Evidence |
|
||||
|---|---|---|
|
||||
| PostgreSQL 16 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 17 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 18 | Stable | full contract suite, release lane |
|
||||
| PostgreSQL 19 | Experimental | compatibility lane only; promotion requires an ADR |
|
||||
| H2 | Local convenience | **never** evidence of PostgreSQL behaviour |
|
||||
|
||||
H2 is not a second production target. It reports different SQLSTATEs for the same violation, has no
|
||||
`SKIP LOCKED` guarantee the platform relies on, no JSONB operators, no range types, and no
|
||||
concurrent index builds. A green H2 run is evidence that the code compiles and runs, and nothing
|
||||
more.
|
||||
|
||||
## Specification and provider
|
||||
|
||||
| Component | Stable | Experimental |
|
||||
|---|---|---|
|
||||
| Jakarta Persistence | 3.2 | 4.0 (lane) |
|
||||
| Hibernate ORM | 7.4 declared baseline | 8 (lane) |
|
||||
| Spring Boot | repository BOM | — |
|
||||
|
||||
The Hibernate row needs a note. The design declares 7.4 as the Stable provider; this repository's
|
||||
Spring Boot BOM resolves 7.1.x. `HibernateProviderPolicy` holds both — the declared baseline as a
|
||||
constant, the resolved version read from Hibernate itself — and `driftsFromDeclaredBaseline()` makes
|
||||
the difference visible instead of asserting a constant against itself. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §4.
|
||||
|
||||
## Capability support levels
|
||||
|
||||
| Capability | Level |
|
||||
|---|---|
|
||||
| Full-transaction retry | Stable |
|
||||
| Commit completion evidence | Stable |
|
||||
| Keyset pagination | Stable |
|
||||
| JDBC batch | Stable |
|
||||
| Flyway schema gate | Stable |
|
||||
| Runtime role verification | Stable |
|
||||
| Observability | Stable |
|
||||
| PostgreSQL native write (`ON CONFLICT`/`RETURNING`) | Advanced |
|
||||
| PostgreSQL work claim (`SKIP LOCKED`) | Advanced |
|
||||
| PostgreSQL JSONB | Advanced |
|
||||
| PostgreSQL array and range | Advanced |
|
||||
| Bulk DML | Advanced |
|
||||
| Hibernate `StatelessSession` | Advanced |
|
||||
| PostgreSQL `COPY` | Admin (J4) |
|
||||
| Hibernate second-level cache | Advanced |
|
||||
| Hibernate Envers | Advanced |
|
||||
| Multi-tenancy (column, RLS, schema, database) | Experimental |
|
||||
| Consistency-aware read replica | Experimental |
|
||||
|
||||
## Release gates
|
||||
|
||||
Each row is a way the platform could pass its tests and still be wrong in production.
|
||||
|
||||
| Gate | Kind | What it prevents |
|
||||
|---|---|---|
|
||||
| `postgresql-contract` | gate | a release whose only database evidence came from H2 |
|
||||
| `completion-unknown-no-retry` | gate | automatically re-running a write that may already have committed |
|
||||
| `osiv-disabled` | gate | lazy loading from the view layer, one query per rendered row |
|
||||
| `flyway-validate` | gate | Hibernate mutating a deployed schema, or running against one it was not built for |
|
||||
| `runtime-role-no-ddl` | gate | the application's own credential being able to alter or drop schema objects |
|
||||
| `hibernate-7.4-fetch-pagination` | gate | a paged collection fetch silently reading the whole table and paginating in memory |
|
||||
|
||||
## Explicitly unsupported
|
||||
|
||||
- Reactive JPA. JPA is a blocking specification; a reactive facade over it moves the blocking call
|
||||
onto an event loop rather than removing it.
|
||||
- Hibernate as the production schema writer. `ddl-auto` never mutates a deployed schema.
|
||||
- A platform-owned generic CRUD repository. Domains own their repositories (design §10.1).
|
||||
- Automatic reconciliation of a completion-unknown transaction. The platform records; the domain
|
||||
resolves.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Transaction Guide
|
||||
|
||||
Design §15-§20. What owns a transaction, what may be retried, and what must never be.
|
||||
|
||||
## The application service owns the boundary
|
||||
|
||||
Repository adapters do not open transactions. The use case does, through `TransactionPort` or
|
||||
`JpaTransactionExecutor`, because the unit of work is a business decision and only the use case
|
||||
knows where it starts and ends.
|
||||
|
||||
Open Session In View is off in every runtime profile. It is on by default in Spring Boot, which is
|
||||
why `JpaDangerousConfigurationGuard` fails startup rather than trusting configuration review.
|
||||
|
||||
## Profiles
|
||||
|
||||
A `TransactionProfile` fixes propagation, isolation, timeout, read-only, and the retry budget. A
|
||||
write profile must carry a positive finite timeout — the type refuses to represent one without —
|
||||
because an unbounded write transaction holds a connection, its locks, and its row versions for as
|
||||
long as one stuck statement takes.
|
||||
|
||||
`REQUIRES_NEW` is opt-in. It acquires a second physical connection while pinning the first, so a
|
||||
profile using it must be paired with the pool-pressure evidence in design §38:
|
||||
|
||||
```text
|
||||
maximumPoolSize >= (concurrent_threads x (1 + max_requires_new_depth)) + 1
|
||||
```
|
||||
|
||||
## Retry is per use case, never per statement
|
||||
|
||||
`FullTransactionRetryCoordinator` re-enters the executor, which produces a new transaction and a new
|
||||
Persistence Context for every attempt. That granularity is the whole point: an optimistic conflict
|
||||
means the state the attempt computed against is no longer the committed state, so re-issuing the
|
||||
same statement would compute the same wrong answer. The domain rules have to run again against
|
||||
reloaded data.
|
||||
|
||||
Retryable: serialization failure (`40001`), deadlock (`40P01`), optimistic conflict.
|
||||
Not retryable: constraint violations, schema mismatch, query timeout, and anything unclassified.
|
||||
|
||||
Two additional refusals, independent of budget:
|
||||
|
||||
- An attempt that declared an irreversible external effect through `IrreversibleSideEffectContext`.
|
||||
Rollback reverses database work only; an email or a card charge has already changed the world.
|
||||
- Anything completion-unknown.
|
||||
|
||||
## Completion unknown
|
||||
|
||||
`TransactionCompletionUnknownException` is never retried, and the type system enforces it twice:
|
||||
`JpaFailureContext` refuses to represent a retryable completion-unknown failure, and the exception
|
||||
rebuilds its context through the safe factory whatever it is handed.
|
||||
|
||||
`EvidenceAwareJpaTransactionManager` marks the phase `COMMITTING` immediately before delegating to
|
||||
the provider commit and never after. If the network, the JVM, or the server dies inside that call,
|
||||
the last thing written is "we asked, we do not know" — which is exactly the state that must not be
|
||||
mistaken for a rollback.
|
||||
|
||||
Recovery is reconciliation, not retry:
|
||||
|
||||
```text
|
||||
record the transaction key -> check the idempotency record
|
||||
-> check the business row
|
||||
-> check the outbox
|
||||
-> still undetermined? reconciliation queue
|
||||
```
|
||||
|
||||
`CompletionUnknownRecorder` writes that record through a channel outside the unknown transaction.
|
||||
Writing it through the same connection would make the audit trail share the failure it documents.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Advanced — CSFLE and Queryable Encryption
|
||||
|
||||
**Capabilities:** `MongoCapability.CSFLE`, `MongoCapability.QUERYABLE_ENCRYPTION`
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.csfle.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.queryable-encryption.enabled`
|
||||
**Status:** Advanced.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Replica set or sharded cluster. |
|
||||
| Server | MongoDB 7.0 or 8.0 (see §4 for the 8.0 query-type limits). |
|
||||
| Privilege | `MongoPrincipalRole.ENCRYPTION_ADMIN` for the key vault; the application role never holds it. |
|
||||
| Environment | A real KMS and key vault. A local key provider does not exercise any of the failure modes that matter. |
|
||||
|
||||
## 1. CSFLE
|
||||
|
||||
`MongoCsfleProfile` binds a collection to its `MongoCsfleFieldPolicy` list, a key vault
|
||||
`MongoCredentialReference` and the key vault namespace. `MongoCsfleClientFactory` builds the encrypted
|
||||
client; `MongoDataKeyResolver` resolves data keys.
|
||||
|
||||
`MongoCsfleMode`:
|
||||
|
||||
| Mode | Queryable | Trade-off |
|
||||
|---|---|---|
|
||||
| `RANDOMIZED` | no | Same plaintext encrypts differently each time. The safe default. |
|
||||
| `DETERMINISTIC` | equality only | Same plaintext always yields the same ciphertext, so equality works — and so does frequency analysis. |
|
||||
| `UNINDEXED` | no | Stored encrypted, excluded from any index. |
|
||||
|
||||
`MongoCsfleFieldPolicy.forPii(field, queryable)` defaults to `RANDOMIZED` when the field is not
|
||||
queried. Deterministic encryption requires a written `equalityQueryJustification`; the constructor
|
||||
refuses a blank one, naming frequency analysis. A low-cardinality deterministic field (a status, a
|
||||
country, a boolean) leaks its distribution to anyone who can read the collection, which is the party
|
||||
encryption was protecting against.
|
||||
|
||||
## 2. Queryable Encryption
|
||||
|
||||
`MongoQueryableEncryptionProfile` binds a collection to `MongoEncryptedFieldDescriptor` entries.
|
||||
`MongoQueryableEncryptionQueryType` has exactly two values:
|
||||
|
||||
- `EQUALITY`
|
||||
- `RANGE` — must declare its domain (`min`, `max`). The constructor refuses a range field without one,
|
||||
because changing the domain later means re-encrypting the field.
|
||||
|
||||
`MongoQueryableEncryptionCollectionManager` owns the collection's lifecycle, because a QE collection
|
||||
is not just a collection: it carries metadata collections.
|
||||
|
||||
## 3. Metadata ownership
|
||||
|
||||
`MongoEncryptionMetadataOwnership` maps `customers` to `enxcol_.customers.esc` and
|
||||
`enxcol_.customers.ecoc`, and reports `__safeContent__`-prefixed indexes as
|
||||
`MongoMetadataOwnership.ENCRYPTION_MANAGED`.
|
||||
|
||||
These are never application-owned and never droppable by drift reconciliation. A drift tool that
|
||||
drops `enxcol_.customers.ecoc` corrupts the collection's queryability. This is the single most
|
||||
important integration point between encryption and
|
||||
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md).
|
||||
|
||||
## 4. Unsupported combinations
|
||||
|
||||
Refused at declaration, not discovered at runtime:
|
||||
|
||||
| Combination | Why |
|
||||
|---|---|
|
||||
| CSFLE **and** QE on the same collection | Two incompatible encryption schemes over one namespace. Both profile constructors refuse it. |
|
||||
| CSFLE on a time series collection | `requireNotTimeSeries(true)` raises `MongoOperationRejectedException`. |
|
||||
| QE `prefix` / `suffix` / `substring` | Not available on the platform's 8.0 baseline. The factory methods throw `UnsupportedOperationException` rather than returning a profile that fails later. |
|
||||
| Deterministic CSFLE without a justification | `IllegalArgumentException` naming frequency analysis. |
|
||||
| Range QE without a declared domain | `IllegalArgumentException` naming re-encryption. |
|
||||
|
||||
## 5. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `MongoEncryptionException` on read | Wrong data key, or the key vault is unreachable | Check KMS reachability and the key vault credential. Data is intact; the client cannot decrypt it. |
|
||||
| `MongoEncryptionException` on write | KMS permission revoked mid-operation | Restore the grant. Writes fail closed — nothing was written in plaintext. |
|
||||
| Queries return nothing on a deterministic field | The field was re-keyed | Equality matching is over ciphertext; a new key produces different ciphertext. Re-encrypt the field. |
|
||||
| QE queries fail after a drift reconciliation | A metadata collection was dropped | Restore from backup. This is why ownership gates drops. |
|
||||
|
||||
**Key rotation.** Rotating the customer master key re-wraps the data keys and does not require
|
||||
re-encrypting documents. Rotating a *data* key does require re-encrypting every document that used
|
||||
it. These are different operations with different costs, and confusing them is how a rotation becomes
|
||||
an outage.
|
||||
|
||||
## 6. Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md), promotion requires the
|
||||
real KMS and key vault, plus negative cases that fail closed: wrong key, missing permission, rotation
|
||||
mid-operation (`MongoAtlasCapabilityContractSuite.kmsFailureModes`). A local key provider certifies
|
||||
none of these — it never rejects anything.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Advanced — GridFS compatibility and migration
|
||||
|
||||
**Capability:** `MongoCapability.GRIDFS_COMPATIBILITY`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.gridfs-compatibility.enabled`
|
||||
**Status:** Advanced, compatibility only. Decision D-14.
|
||||
|
||||
## Position
|
||||
|
||||
GridFS is a **compatibility adapter for files that already exist there**. New files use the existing
|
||||
Fileserver / Object Storage adapter, which is the source of truth for binary content.
|
||||
|
||||
The reason is not preference. GridFS stores file chunks in the same collections, on the same replica
|
||||
set, competing for the same working set as your documents. A large file read evicts document pages
|
||||
from cache, and file storage growth becomes replica-set growth — which means it becomes oplog
|
||||
pressure, backup duration and failover time. Object storage was built for this and MongoDB was not.
|
||||
|
||||
## Reading legacy files
|
||||
|
||||
`MongoGridFsCompatibilityReader` reads existing GridFS content as
|
||||
`GridFsLegacyContent(legacyId, filename, sizeBytes, checksum, stream)`. It reads; it does not write.
|
||||
|
||||
## Migration
|
||||
|
||||
`MongoGridFsMigrationJob` moves a file to object storage in a fixed order:
|
||||
|
||||
```
|
||||
read legacy content
|
||||
→ write to object storage
|
||||
→ verify the target checksum matches the source
|
||||
→ switch the reference
|
||||
→ (later, separately) delete the source
|
||||
```
|
||||
|
||||
Three properties, each of which exists because of a specific way this goes wrong:
|
||||
|
||||
1. **Verify before switching.** `MongoGridFsObjectReference` requires a non-blank checksum, and the
|
||||
job returns empty and writes no reference when the target checksum does not match the source. A
|
||||
migration that switches the reference on a successful *write* rather than a verified *copy*
|
||||
silently points at a truncated object.
|
||||
2. **The source is never deleted here.** Deletion is a separate, later decision after the new
|
||||
location has been serving reads long enough to be trusted. A migration that deletes as it goes has
|
||||
no rollback.
|
||||
3. **The checkpoint separates migrated from failed.** `MongoGridFsMigrationCheckpoint` tracks
|
||||
`migratedCount()`, `failedCount()`, `clean()` and `lastMigratedLegacyId()`, so a restart continues
|
||||
from the last completed file rather than starting over, and a partially failed run is visible as
|
||||
partial rather than as "done".
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `migrate` returns empty | Checksum mismatch | The copy is bad. Investigate before retrying; do not force the reference. |
|
||||
| `IllegalArgumentException` on the reference | Missing checksum | A reference without a checksum cannot be verified and is refused. |
|
||||
| Checkpoint not `clean()` | Some files failed | Re-run for the failed ids only; the checkpoint names the last successful one. |
|
||||
| Reference switched but content missing | Source deleted too early | Restore from backup. This is what rule 2 prevents. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Actual-topology evidence against the real object storage backend, a security review of the storage
|
||||
credential, the migration path above, failure cases (checksum mismatch refused, missing checksum
|
||||
refused, restart resumes), and this document as the runbook.
|
||||
|
||||
New file storage does not go through here at all — see the fileserver adapter.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Advanced — Multi-tenancy
|
||||
|
||||
**Capabilities:** `MongoCapability.SHARED_COLLECTION_TENANCY` (Advanced),
|
||||
`MongoCapability.DATABASE_PER_TENANT` (Experimental)
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled`
|
||||
|
||||
## 1. Shared collection
|
||||
|
||||
Every tenant's documents live in one collection, discriminated by a tenant field.
|
||||
|
||||
`MongoTenantContext` carries the tenant. `MongoTenantPredicateInjector` adds the tenant predicate to
|
||||
every query, every atomic filter and the **first** aggregation stage. `TenantScopedMongoOperations`
|
||||
is the entry point, so a caller cannot construct an unscoped operation by forgetting.
|
||||
|
||||
Three details are load-bearing:
|
||||
|
||||
- **Injection, not convention.** A tenant predicate that each query is expected to add itself is a
|
||||
cross-tenant leak waiting for one missed `where(...)`. The injector adds it structurally.
|
||||
- **First aggregation stage.** `firstStageMatch(...)` places the tenant `$match` before anything else.
|
||||
A `$lookup` or `$group` that runs before the tenant filter has already crossed the boundary, even if
|
||||
a later stage filters the output.
|
||||
- **An absent tenant is not "all tenants".** The injector takes an `Optional<MongoTenantContext>` so
|
||||
the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears.
|
||||
|
||||
`MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes)` checks that every unique
|
||||
index that should be per-tenant actually includes the tenant field. A unique index on `email` alone in
|
||||
a shared collection makes an email globally unique across tenants — tenant B cannot register an
|
||||
address tenant A already used, which is both a bug and an information leak.
|
||||
|
||||
`requireShardKeyAnalysed(...)` requires a shard-key readiness report before a shared-collection tenant
|
||||
model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard.
|
||||
|
||||
### Observability
|
||||
|
||||
`tenantId` and `rawTenantId` are on `MongoObservationConvention`'s forbidden tag list. Cardinality
|
||||
grows with the customer list, and the tag ships tenant identity into the metrics backend.
|
||||
|
||||
## 2. Database per tenant (Experimental)
|
||||
|
||||
`MongoTenantDatabaseResolver` maps a tenant to its database; `MongoTenantClientRegistry` holds the
|
||||
clients.
|
||||
|
||||
Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant
|
||||
database costs connections, file handles and monitoring cardinality. It works beautifully at 20
|
||||
tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion
|
||||
requires operational scale evidence.
|
||||
|
||||
`MongoTenantLifecyclePolicy`:
|
||||
|
||||
- `requireActivationReady(tenantKey, schemaAndIndexesValidated)` — a tenant is not activated until its
|
||||
schema and indexes are validated. Activating first means the first customer request is the migration
|
||||
test.
|
||||
- `requireDeleteAllowed(...)` — deletion requires an explicit retention decision. Dropping a tenant
|
||||
database is irreversible and takes the backup surface with it.
|
||||
|
||||
`MongoTenantMigrationCoordinator` runs a migration across tenant databases with per-tenant results.
|
||||
Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is
|
||||
not a report anyone can act on.
|
||||
|
||||
## 3. Choosing
|
||||
|
||||
| | Shared collection | Database per tenant |
|
||||
|---|---|---|
|
||||
| Isolation | Logical, enforced by injection | Physical |
|
||||
| Tenant count | Unbounded | Bounded by connections and file handles |
|
||||
| Per-tenant restore | Hard | Natural |
|
||||
| Noisy neighbour | Shared resources | Isolated |
|
||||
| Migration | One collection | N databases, partial failures |
|
||||
| Cross-tenant query | Possible (and must be forbidden) | Structurally impossible |
|
||||
|
||||
Shared collection is the default. Database-per-tenant is for a small number of tenants with a
|
||||
contractual isolation or per-tenant-restore requirement.
|
||||
|
||||
## 4. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Cross-tenant data visible | An operation bypassed `TenantScopedMongoOperations` | Treat as a security incident. Find the path, close it, audit access. |
|
||||
| Unique constraint fires across tenants | Unique index missing the tenant field | Rebuild the index with the tenant field as prefix; the validator catches this before it ships. |
|
||||
| One shard holds most data | Tenant id as shard-key prefix with a dominant tenant | Refine the shard key with a high-cardinality suffix. |
|
||||
| Connection exhaustion | Database-per-tenant beyond the connection budget | The scale limit. Consolidate or move to shared collections. |
|
||||
| Migration partially applied across tenants | Normal | `MongoTenantMigrationCoordinator` reports per tenant; re-run for the failures only. |
|
||||
|
||||
## 5. Promotion evidence
|
||||
|
||||
Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access,
|
||||
a migration path, failure cases (injection proven on query, atomic filter and first aggregation
|
||||
stage), this runbook. Database-per-tenant additionally requires **operational scale evidence** and
|
||||
stays Experimental until it exists.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Advanced — Search and Vector Search
|
||||
|
||||
**Capabilities:** `MongoCapability.SEARCH`, `MongoCapability.VECTOR_SEARCH`
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.search.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.vector-search.enabled`
|
||||
**Status:** Experimental (design §3.3). Hybrid search likewise.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | A deployment with the search service. Atlas Local in a container is a pull-request convenience and is **not** release evidence. |
|
||||
| Privilege | `MongoPrincipalRole.SEARCH_ADMIN` for index management; the application role queries only. |
|
||||
| Gate | `MongoAtlasCapabilityContractSuite` on the actual target deployment. |
|
||||
|
||||
## 1. Created is not ready
|
||||
|
||||
`MongoSearchIndexState`: `CREATED` → `BUILDING` → `READY`, plus `FAILED` and `DELETING`.
|
||||
|
||||
`MongoSearchReadinessGate.requireReady(state)` refuses anything but `READY`. A search index is built
|
||||
asynchronously: the create call returns immediately and the index answers queries with *partial*
|
||||
results while building. Not an error, not empty — partial. A deployment that creates an index and
|
||||
starts querying serves incomplete results for as long as the build takes, and nothing reports it.
|
||||
|
||||
## 2. Search indexes are not application-owned
|
||||
|
||||
`MongoSearchIndexDescriptor.metadataOwnership()` is `MongoMetadataOwnership.SEARCH_MANAGED`, and
|
||||
`droppableByApplicationDrift()` is false. The index reconciliation described in
|
||||
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md) must not drop it.
|
||||
|
||||
## 3. Query guardrails
|
||||
|
||||
`MongoSearchQuery` binds an index, an allowlist of paths, the search text and a result limit.
|
||||
|
||||
- `requireAllowedPaths(allowed)` raises `MongoOperationRejectedException` on a path outside the
|
||||
allowlist. Without it, a caller can search any indexed field, including ones indexed for a
|
||||
different purpose.
|
||||
- Search text length and result count are bounded at construction. An unbounded search text is a
|
||||
cost multiplier on someone else's service.
|
||||
|
||||
## 4. Vector search
|
||||
|
||||
`MongoVectorIndexDescriptor.cosine(path, dimensions)` declares the index.
|
||||
`MongoEmbedding.forIndex(index, values)` binds an embedding to it and **rejects a dimension
|
||||
mismatch** — a 1536-dimension embedding against a 768-dimension index is not a runtime degradation,
|
||||
it is a category error, and catching it at construction beats catching it as a confusing server
|
||||
message.
|
||||
|
||||
`MongoEmbedding` copies its backing array in and out. A vector that shares an array with its caller
|
||||
can be mutated after the query is built, which produces a query nobody wrote.
|
||||
|
||||
`MongoVectorQuery` requires `numCandidates > limit` — searching 10 candidates to return 10 results is
|
||||
an exhaustive scan wearing an ANN index's name. `MongoVectorQuery.nearest(embedding, 10)` uses the
|
||||
standard 20× ratio (200 candidates for 10 results).
|
||||
|
||||
## 5. Relevance is the gate, not functionality
|
||||
|
||||
`MongoVectorSearchBenchmarkGate.standard()` requires **recall** alongside latency and index size.
|
||||
`requiredEvidence()` names recall explicitly.
|
||||
|
||||
This is the difference between search and everything else in the platform. A vector index can be
|
||||
functionally perfect — it accepts the index, accepts the query, returns k results, within the latency
|
||||
budget — and return the wrong k. A gate that measures only latency certifies a fast wrong answer.
|
||||
`failures(recall, latencyMs, indexMb)` reports which dimension failed so the finding is actionable.
|
||||
|
||||
## 6. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Incomplete results after a deploy | Queried a `BUILDING` index | Wait for `READY`. The gate prevents this; if it fired, something bypassed it. |
|
||||
| `MongoOperationRejectedException` on a path | Path not in the allowlist | Add it deliberately, or fix the caller. |
|
||||
| Dimension mismatch | Model changed | A new model means a new index. Build alongside, cut over, then retire. |
|
||||
| Recall dropped without a code change | The index was rebuilt with different parameters, or the data distribution shifted | Re-run the benchmark gate; treat a recall regression like a failing test. |
|
||||
| Index `FAILED` | Build error on the search service | Search-side diagnosis; the application must not fall back to a scan silently. |
|
||||
|
||||
## 7. Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): the actual target
|
||||
deployment (not Atlas Local), security review of `SEARCH_ADMIN`, a rebuild path, failure cases
|
||||
(non-ready index refused, disallowed path refused, dimension mismatch refused), this document as the
|
||||
runbook, **and** relevance evidence. Search and vector search do not promote on functional success.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Advanced — Sharding
|
||||
|
||||
**Capability:** `MongoCapability.SHARDING`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.sharding.enabled`
|
||||
**Status:** Advanced. Reshard orchestration remains Experimental.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | A real sharded cluster. A replica set cannot exercise routing. |
|
||||
| Server | MongoDB 7.0 or 8.0. |
|
||||
| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. |
|
||||
| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. |
|
||||
|
||||
## Shard key
|
||||
|
||||
`ShardKeyDescriptor` declares the key as an ordered list of `ShardKeyPart` plus a `ShardStrategy`:
|
||||
|
||||
| Strategy | Distributes | Cost |
|
||||
|---|---|---|
|
||||
| `RANGE` | by value ranges | Range queries stay targeted; a monotonic key (a timestamp, an `ObjectId`) sends every insert to one shard. |
|
||||
| `HASHED` | by hash of the key | Inserts spread evenly; every range query becomes scatter-gather. |
|
||||
|
||||
There is no strategy that is good at both, which is why the choice is a declaration rather than a
|
||||
default.
|
||||
|
||||
## Routing classification
|
||||
|
||||
`ShardAwareQueryValidator` classifies each query before execution:
|
||||
|
||||
| `MongoRoutingClassification` | Meaning |
|
||||
|---|---|
|
||||
| `TARGETED` | The full shard key is present. One shard answers. |
|
||||
| `PREFIX_TARGETED` | A prefix of a compound key is present. A subset of shards answers. |
|
||||
| `SCATTER_GATHER` | No shard-key predicate. Every shard answers. |
|
||||
| `REJECTED` | Scatter-gather where the profile forbids it. |
|
||||
|
||||
A scatter-gather query is not an error — some queries legitimately need every shard — but it must be
|
||||
declared. Undeclared scatter-gather raises `MongoShardRoutingException`. The reason is that
|
||||
scatter-gather passes every test on a single-shard development cluster and only degrades once the
|
||||
cluster grows, at which point the query is already in production and the fix is a schema change.
|
||||
|
||||
## Unsupported combinations
|
||||
|
||||
- Unique index on a field that is not a prefix of the shard key. MongoDB cannot enforce it across
|
||||
shards, and it fails at index creation, not at query time.
|
||||
- Transactions that touch documents on multiple shards remain supported but cost a cross-shard
|
||||
two-phase commit. Prefer a shard key that keeps a transaction's documents co-located.
|
||||
- CSFLE on a sharded collection: see [encryption.md](encryption.md) for the combinations that are
|
||||
refused.
|
||||
|
||||
## Admin plane
|
||||
|
||||
`MongoShardingAdminGateway` (D4, `SHARD_ADMIN` credential) covers shard-collection, refine-shard-key
|
||||
and reshard.
|
||||
|
||||
`ShardKeyAnalyzer` produces a `ShardKeyReadinessReport` before sharding a collection: cardinality,
|
||||
frequency skew and monotonicity. A key with low cardinality creates jumbo chunks that cannot be split;
|
||||
a monotonic key creates a hot shard. Both are visible in the report and invisible in a functional
|
||||
test.
|
||||
|
||||
`ReshardApproval` is required for a reshard — a named approver and a stated window. Resharding
|
||||
rewrites the collection: it duplicates the data during the operation and saturates IO. It is not a
|
||||
runtime operation and the type refuses to pretend otherwise.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `MongoShardRoutingException` | Undeclared scatter-gather | Add the shard key to the predicate, or declare the query as scatter-gather in its profile after review. |
|
||||
| Jumbo chunks | Low-cardinality shard key | Refine the shard key (adds a suffix, non-destructive) before considering a reshard. |
|
||||
| One hot shard | Monotonic range key | Refine with a high-cardinality prefix, or reshard to hashed if range queries are not needed. |
|
||||
| Balancer never converges | Chunk migration blocked by long-running operations | Check for long transactions and cursors; the balancer waits on them. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): actual sharded-cluster
|
||||
evidence, security review of the `SHARD_ADMIN` role, a migration path for an existing unsharded
|
||||
collection, failure cases (undeclared scatter-gather refused, jumbo chunk detected), and this
|
||||
document as the runbook. Reshard orchestration stays Experimental until operational scale evidence
|
||||
exists.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Advanced — Time Series
|
||||
|
||||
**Capability:** `MongoCapability.TIME_SERIES`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.time-series.enabled`
|
||||
**Status:** Advanced.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Replica set or sharded cluster. |
|
||||
| Server | MongoDB 7.0 or 8.0. |
|
||||
| Privilege | Standard application role; collection creation goes through the admin plane. |
|
||||
|
||||
## Descriptor
|
||||
|
||||
`MongoTimeSeriesDescriptor` declares:
|
||||
|
||||
- **timeField** — required, a BSON date. This is the bucketing axis.
|
||||
- **metaField** — optional but nearly always wanted: the series identity (device id, tenant, sensor).
|
||||
Documents sharing a `metaField` value bucket together, which is where the compression comes from.
|
||||
- **granularity** — `MongoTimeSeriesGranularity`:
|
||||
|
||||
| Granularity | Bucket span | Use for |
|
||||
|---|---|---|
|
||||
| `SECONDS` | 1 hour | Sub-second to per-second ingest. |
|
||||
| `MINUTES` | 24 hours | Per-minute metrics. |
|
||||
| `HOURS` | 30 days | Hourly rollups. |
|
||||
|
||||
Granularity that is too fine produces many small buckets and loses the compression; too coarse
|
||||
produces oversized buckets that must be read whole to answer a narrow query.
|
||||
|
||||
## What a time series collection is not
|
||||
|
||||
`MongoTimeSeriesCapabilityValidator` refuses the operations the collection type does not support, at
|
||||
declaration time rather than at first use:
|
||||
|
||||
- **No arbitrary updates.** Time series data is append-mostly. Delete and limited update support
|
||||
exists on recent servers but is not part of this platform's contract.
|
||||
- **No unique index on the measurement.** There is no `_id` to be unique on in the usual sense.
|
||||
- **No CSFLE.** Refused — see [encryption.md](encryption.md).
|
||||
- **No change stream on the raw buckets** as a business event source. The bucket documents are a
|
||||
storage representation, not your measurements.
|
||||
|
||||
Converting an existing regular collection to a time series collection is a copy, not an alter. Plan
|
||||
it as a migration with a dual-write window.
|
||||
|
||||
## TTL
|
||||
|
||||
Time series collections use `expireAfterSeconds` on the collection rather than a TTL index on a
|
||||
field. The [TTL rules](../schema-index-migration-guide.md#4-ttl) still apply: expiry is physical
|
||||
cleanup on a bucket boundary, so a measurement can outlive its expiry by up to a bucket span plus the
|
||||
monitor interval. Do not treat absence as a deadline.
|
||||
|
||||
## Operations
|
||||
|
||||
`MongoTimeSeriesOperations` is the port for insert and windowed read. Reads are bounded by the same
|
||||
`MongoOperationBudget` as everything else: an unbounded time-range query on a time series collection
|
||||
is the fastest way to read a year of data into heap.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Writes rejected with an unsupported-operation error | An update or unique-index expectation | The collection type does not support it; change the access pattern. |
|
||||
| Poor compression / large storage | Missing `metaField`, or granularity too fine | Both require a rebuild; measure on a copy before committing. |
|
||||
| Slow range queries | Granularity too coarse for the query window | Same: rebuild with the granularity matched to the dominant query. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Actual-topology evidence on the target deployment, a migration path from the existing collection,
|
||||
failure cases (unsupported update refused, CSFLE combination refused), and this document as the
|
||||
runbook.
|
||||
@@ -0,0 +1,92 @@
|
||||
# BSON Mapping Guide
|
||||
|
||||
Design §10 and decision D-06. The representation of a value in BSON is a data contract, not an
|
||||
implementation detail: once a collection holds a million documents, changing how a `BigDecimal` is
|
||||
stored is a migration with downtime, not a code change. `MongoTypeRepresentationManifest` pins the
|
||||
representation so a library upgrade or a different default cannot move it.
|
||||
|
||||
## 1. The manifest
|
||||
|
||||
`MongoTypeRepresentationManifest.standard()` fixes:
|
||||
|
||||
| Java type | BSON | Representation type |
|
||||
|---|---|---|
|
||||
| `UUID` | `Binary` subtype 4 | `MongoUuidRepresentation.STANDARD` |
|
||||
| `BigDecimal` | `Decimal128` | `MongoDecimalRepresentation.DECIMAL_128` |
|
||||
| `BigInteger` | `Decimal128` (or `String` when out of range, declared) | `MongoBigIntegerRepresentation` |
|
||||
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | `MongoTemporalRepresentation.UTC_DATE` |
|
||||
| `LocalDate` | `String` (ISO-8601) or UTC `Date`, declared per field | `MongoTemporalRepresentation` |
|
||||
| `enum` | `String` name | `MongoEnumRepresentation.NAME` |
|
||||
|
||||
`MongoMappingConfiguration` and `MongoCustomConversionsFactory` build the Spring Data converters from
|
||||
the manifest, so there is one place to read and one place to change.
|
||||
|
||||
## 2. UUID
|
||||
|
||||
`UuidRepresentation.STANDARD` (subtype 4), always. The driver's legacy Java representation
|
||||
(subtype 3) byte-swaps two halves of the UUID, so a document written by one representation and read
|
||||
by the other yields a different — and valid-looking — UUID. Nothing errors; you just get the wrong
|
||||
row. The golden snapshot kit pins the codec explicitly for this reason
|
||||
(`MongoBsonSnapshot.defaultRegistry()`).
|
||||
|
||||
## 3. Decimal
|
||||
|
||||
`BigDecimal` → `Decimal128`, never `Double`. `12.30` stored as a double is `12.299999999999999`, and
|
||||
a monetary comparison written against it will one day be wrong by a cent for a customer who notices.
|
||||
`BigDecimalToDecimal128Converter` / `Decimal128ToBigDecimalConverter` are registered from the
|
||||
manifest.
|
||||
|
||||
`Decimal128` has 34 significant digits; a `BigDecimal` beyond that range fails on write rather than
|
||||
rounding silently.
|
||||
|
||||
## 4. Time
|
||||
|
||||
Store instants, not local times. `LocalDateTimeMappingGuard` refuses `LocalDateTime` fields on a
|
||||
mapped document: a `LocalDateTime` has no offset, so the value that goes in depends on the JVM
|
||||
default zone of whichever instance wrote it, and the two instances in a rolling deploy can disagree.
|
||||
Use `Instant` when the moment matters and `LocalDate` when the calendar day matters.
|
||||
|
||||
## 5. Type metadata
|
||||
|
||||
`MongoTypeMetadataPolicy` decides what goes in `_class`:
|
||||
|
||||
| Policy | Stored | Use when |
|
||||
|---|---|---|
|
||||
| `NONE` | nothing | The collection holds exactly one type and never will hold a subtype. |
|
||||
| `ALIAS` | a registered short alias | A polymorphic hierarchy in a long-lived collection. |
|
||||
| `CLASS_NAME` | the FQCN | Short-lived or internal collections only. |
|
||||
|
||||
`PolicyAwareMongoTypeMapper` enforces it, and `MongoTypeMetadataRegistry` holds alias → class.
|
||||
A `@LongLivedMongoDocument` type with `CLASS_NAME` is refused: writing `com.example.OrderV2` into a
|
||||
million documents means that renaming the package is a data migration.
|
||||
|
||||
## 6. Missing versus null
|
||||
|
||||
The golden kit keeps these apart deliberately. `MongoBsonSnapshotAssert.hasNoField(...)` and
|
||||
`hasExplicitNull(...)` are different assertions, because in MongoDB they are different documents:
|
||||
`{"a": null}` matches `{a: null}` and `{a: {$exists: true}}`, while `{}` matches only the first.
|
||||
A mapper change that starts writing explicit nulls silently changes what your queries return.
|
||||
|
||||
## 7. Golden representation tests
|
||||
|
||||
Every collection with a fixed representation should have a snapshot test:
|
||||
|
||||
```java
|
||||
MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(storedDocument);
|
||||
MongoBsonSnapshotAssert.assertThat(snapshot)
|
||||
.hasBsonType("amount", "DECIMAL128")
|
||||
.hasBsonType("externalId", "BINARY")
|
||||
.hasNoJavaClassName("dev.caskeleton")
|
||||
.hasTypeSignature("_id:OBJECT_ID,amount:DECIMAL128,createdAt:DATE_TIME,externalId:BINARY");
|
||||
```
|
||||
|
||||
`hasTypeSignature` is the regression gate: it fails on *any* representation change, including ones a
|
||||
value-equality assertion would pass. When it fails, the question is whether the change was intended
|
||||
and has a migration — not whether to update the string.
|
||||
|
||||
## 8. Round trips
|
||||
|
||||
`MongoRoundTripContract` asserts that `write → read` returns an equal domain object *and* that
|
||||
`write → read → write` produces an identical BSON document. The second half is what catches an
|
||||
asymmetric converter: a value that reads back equal but re-serialises differently makes every
|
||||
subsequent `save()` a spurious update, and turns change streams into a noise generator.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Change Stream Guide
|
||||
|
||||
Design §20, decision D-12. A change stream is an **at-least-once projector**, not an event bus.
|
||||
|
||||
## 1. What a change stream is not
|
||||
|
||||
D-12 is explicit: a physical change event is not a business integration event. The two differ in
|
||||
ways that matter to every consumer:
|
||||
|
||||
| Change event | Integration event |
|
||||
|---|---|
|
||||
| Emitted per document write | Emitted per business fact |
|
||||
| Shape follows the storage schema | Shape is a published contract |
|
||||
| A refactor of the document changes it | A refactor of the document does not change it |
|
||||
| Replayed on resume, duplicated on retry | Versioned and deliberately evolved |
|
||||
|
||||
Publishing raw change events externally makes your storage schema a public API, and the first time
|
||||
someone renames a field the downstream consumers break. If you need to bridge to messaging, use the
|
||||
Advanced bridge, which maps to an owned envelope
|
||||
([advanced/multi-tenancy.md](advanced/multi-tenancy.md) is separate;
|
||||
the bridge is described in §7 below).
|
||||
|
||||
## 2. Subscription and resume
|
||||
|
||||
`MongoChangeStreamSubscription` declares the collection, pipeline and consistency. `MongoResumePosition`
|
||||
is either a resume token or a cluster time; `MongoResumeCheckpoint` is what gets persisted and
|
||||
`MongoResumeCheckpointStore` persists it.
|
||||
|
||||
The checkpoint stores the token as a Base64 `encodedToken` string rather than a byte array — a record
|
||||
with an array component has broken equality, and a checkpoint that does not compare correctly is a
|
||||
checkpoint that silently fails its own dedup test.
|
||||
|
||||
## 3. Checkpoint after processing, not after receiving
|
||||
|
||||
The ordering rule that makes at-least-once actually hold:
|
||||
|
||||
```
|
||||
receive event
|
||||
→ process it (idempotently)
|
||||
→ persist the checkpoint
|
||||
```
|
||||
|
||||
Checkpointing on receipt turns the delivery guarantee into at-most-once, and the events lost are
|
||||
exactly the ones the process died while handling.
|
||||
|
||||
## 4. Idempotency
|
||||
|
||||
`MongoChangeEventIdentity` is the dedup key: `(resumeToken, documentKey, clusterTime, operationType)`.
|
||||
`MongoChangeDeduplicationStore` records what has been applied. Duplicates are not an edge case — every
|
||||
resume after any interruption replays at least one event, so a projector that is not idempotent is
|
||||
wrong on its first restart, not on some rare day.
|
||||
|
||||
`MongoChangeProjector` returns a `MongoChangeProjectionResult` so the runner can distinguish applied
|
||||
from skipped-as-duplicate, and the skip count is worth a metric: a sudden rise means something is
|
||||
looping.
|
||||
|
||||
## 5. States and recovery
|
||||
|
||||
`MongoChangeStreamState`: `STARTING`, `RUNNING`, `RESUMING`, `STOPPED`, `HISTORY_LOST`.
|
||||
|
||||
`MongoChangeStreamRecoveryPolicy` returns a `MongoChangeStreamRecoveryDecision`, which is either
|
||||
`resume()` (auto-resume from the checkpoint) or `halt(state, runbook)`. A halting decision **must**
|
||||
name a runbook — a decision that only says "stopped" leaves the on-call engineer to work out from
|
||||
scratch whether the projection can be rebuilt and from what.
|
||||
|
||||
| Situation | Decision |
|
||||
|---|---|
|
||||
| Transient network error, token still valid | `resume()` |
|
||||
| Primary failover | `resume()` — the token survives an election |
|
||||
| `invalidate` (collection dropped/renamed) | `halt(STOPPED, …)` → `MongoInvalidateRecovery` |
|
||||
| Token no longer in the oplog | `halt(HISTORY_LOST, "history-lost")` → `MongoChangeHistoryLostException` |
|
||||
|
||||
## 6. History lost
|
||||
|
||||
`MongoChangeHistoryLostException` is raised when the resume token predates the oldest oplog entry.
|
||||
The stream **cannot** be resumed: the events between the checkpoint and now are gone from the server,
|
||||
and no amount of retrying brings them back.
|
||||
|
||||
What the platform will not do is silently restart from "now". That looks like a recovery and is
|
||||
actually a silent gap in the projection — the worst possible outcome, because nothing reports it. The
|
||||
runner halts and requires an operator decision. See
|
||||
[runbooks/history-lost.md](runbooks/history-lost.md).
|
||||
|
||||
## 7. Bridging to messaging (Advanced)
|
||||
|
||||
`MongoChangeMessagingBridge` is opt-in behind `MongoCapability.CHANGE_STREAM` plus the bridge's own
|
||||
flag. It maps a change event to a platform-owned `MongoIntegrationEventEnvelope` through
|
||||
`MongoChangeToIntegrationEventMapper` and hands it to a `MongoIntegrationEventPublisher` port.
|
||||
|
||||
The port is defined in the bridge package rather than imported from the messaging adapter because the
|
||||
architecture registry forbids adapter-to-adapter dependencies; the composition root supplies the
|
||||
implementation.
|
||||
|
||||
`MongoBridgeOutboxPolicy` and `MongoBridgeCheckpointPolicy` state the delivery contract: publish then
|
||||
checkpoint, at-least-once, consumers must dedup on the envelope's event id.
|
||||
|
||||
## 8. Operating notes
|
||||
|
||||
- Change streams require a replica set. `MongoStartupValidator` refuses a change-stream profile on
|
||||
`STANDALONE`.
|
||||
- The change-stream principal is its own role (`MongoPrincipalRole.CHANGE_STREAM`) with
|
||||
`changeStream` and `find` — not the application write credential.
|
||||
- Oplog window is the recovery budget. If the oplog holds four hours, a consumer that is down for five
|
||||
hours needs a rebuild, not a resume. Alert on consumer lag against the oplog window, not against
|
||||
wall-clock.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Consistency and Transaction Guide
|
||||
|
||||
Design §12–§16, decisions D-07 through D-10. This is the part of the platform where the wrong
|
||||
default is most expensive and the least visible in testing, because every failure mode here needs a
|
||||
primary change to reproduce.
|
||||
|
||||
## 1. Prefer a single-document atomic operation
|
||||
|
||||
D-09: a transaction is for a **multi-document invariant**, nothing else. A single document is already
|
||||
atomic in MongoDB, so wrapping a one-document update in a transaction buys nothing and costs a
|
||||
session, a two-phase commit and a new ambiguous outcome.
|
||||
|
||||
D-07: partial change uses update operators, not `save()`. `MongoAtomicOperations` /
|
||||
`MongoAtomicOperationsTemplate` expose the operator set through `MongoUpdateOperator`
|
||||
(`$set`, `$inc`, `$push`, `$pull`, `$addToSet`, `$min`, `$max`, `$currentDate`, …) with an
|
||||
`AtomicFilter` precondition and a `ReturnDocumentMode`. Read-modify-write through `save()` replaces
|
||||
the whole document and silently discards any field another writer changed in between — a lost update
|
||||
with no error.
|
||||
|
||||
## 2. Whole-document replacement needs a revision
|
||||
|
||||
D-08. `VersionedMongoUpdater` requires a `MongoRevision`: either a Spring Data `@Version` field or an
|
||||
explicit expected-revision predicate in `VersionedUpdateCommand`. A replacement whose filter matched
|
||||
zero documents is not "nothing to do" — `MongoOptimisticConflictTranslator` distinguishes:
|
||||
|
||||
- filter matched nothing and the id does not exist → `MongoDocumentNotFoundException`
|
||||
- filter matched nothing and the id exists → `MongoOptimisticConflictException`
|
||||
|
||||
Collapsing these two into one is how a concurrent overwrite becomes a 404.
|
||||
|
||||
## 3. Consistency profiles
|
||||
|
||||
`MongoConsistencyProfile` names the read/write concern pair; `MongoConsistencyRegistry` binds a
|
||||
profile to an operation or collection, and `MongoConsistencyBinder` /
|
||||
`ReactiveMongoConsistencyBinder` apply it at execution.
|
||||
|
||||
| Profile | Meaning | Use for |
|
||||
|---|---|---|
|
||||
| `PRIMARY_LOCAL` | primary read, local concern | Throughput-sensitive reads that tolerate a rollback window. |
|
||||
| `PRIMARY_MAJORITY` | primary read, majority write | The default for anything a user will see again immediately. |
|
||||
| `CAUSAL_MAJORITY` | majority inside a causal session | Read-your-writes across separate operations. |
|
||||
| `STALE_READ_ALLOWED` | secondary reads permitted | Reporting and analytics that state their staleness. |
|
||||
| `SNAPSHOT_TRANSACTION` | snapshot isolation | Multi-document reads inside a transaction. |
|
||||
|
||||
A profile is a declaration, not a hint: the registry is consulted per operation and an operation
|
||||
without a registered profile is rejected rather than defaulting.
|
||||
|
||||
## 4. Causal sessions
|
||||
|
||||
`MongoCausalSessionContext` plus `SpringMongoCausalSessionExecutor` /
|
||||
`ReactiveMongoCausalSessionExecutor` carry the cluster time and operation time between operations, so
|
||||
"write then read" returns the write even when the read lands on a different node. Without a causal
|
||||
session, `PRIMARY_MAJORITY` gives you durability but not read-your-writes across two calls.
|
||||
|
||||
In the reactive path the session travels in the Reactor context (`ReactiveMongoContextKeys`), not in
|
||||
a thread local — a thread local is empty on the next operator in the chain.
|
||||
|
||||
## 5. Transactions
|
||||
|
||||
`MongoTransactionExecutor` / `ReactiveMongoTransactionExecutor` open a session through the session
|
||||
factory, run the body, and commit. `MongoTransactionProfile` carries the consistency profile, the
|
||||
`maxCommitTime` and the retry budget. Topology matters: a transaction requires a replica set, and
|
||||
`MongoStartupValidator` refuses a transaction-declaring profile on `STANDALONE` at startup rather
|
||||
than at the first call.
|
||||
|
||||
## 6. Retry: body and commit are different loops
|
||||
|
||||
D-10, and the single most consequential rule in the design.
|
||||
|
||||
```
|
||||
for each body attempt:
|
||||
open a NEW session
|
||||
run the body
|
||||
TransientTransactionError -> abort, next body attempt
|
||||
commit
|
||||
UnknownTransactionCommitResult -> retry COMMIT ONLY, same session
|
||||
```
|
||||
|
||||
`MongoTransactionRetryCoordinator` implements exactly this:
|
||||
|
||||
- **A new session per body attempt.** Reusing the session after an abort carries the aborted
|
||||
transaction's state into the retry.
|
||||
- **The body is never replayed after a commit ambiguity.** An unknown commit means the commit may
|
||||
already have applied. Re-running the body would apply it a second time. Only the commit is retried,
|
||||
and a commit retry on an already-committed transaction is a no-op by design.
|
||||
- **A budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with jittered
|
||||
backoff (`delayBefore(attempt, random)`), so a struggling primary is not retried into the ground.
|
||||
|
||||
`MongoRetryDecision` and `MongoRetryScope` (in `…api.error`) say what may be retried:
|
||||
`MongoRetryScope.BODY`, `COMMIT_ONLY`, or `NONE`.
|
||||
|
||||
## 7. Ambiguous outcomes
|
||||
|
||||
`MongoExecutionOutcome` has six values, two of which are ambiguous and must not be collapsed:
|
||||
|
||||
| Outcome | Did the write happen? |
|
||||
|---|---|
|
||||
| `NOT_SENT` | No. Safe to retry. |
|
||||
| `NO_WRITE_PERFORMED` | No — the server answered and did nothing. |
|
||||
| `WRITE_CONFIRMED` | Yes. |
|
||||
| `PARTIAL_BULK_WRITE` | Some of it. See `MongoBulkResult`. |
|
||||
| `WRITE_RESULT_UNKNOWN` | **Unknown.** |
|
||||
| `TRANSACTION_COMMIT_UNKNOWN` | **Unknown.** |
|
||||
|
||||
An unknown outcome is not a failure and must not be reported to a caller as one. The caller either
|
||||
reconciles (`MongoCommitReconciler` re-reads a deterministic marker the body wrote) or surfaces the
|
||||
ambiguity. See [runbooks/unknown-commit.md](runbooks/unknown-commit.md).
|
||||
|
||||
`MongoFailureContext` records only the design-permitted fields — outcome, category, operation name,
|
||||
collection profile, retry scope, attempt — never the query, the document, or the values.
|
||||
|
||||
## 8. Failure translation
|
||||
|
||||
`DefaultMongoFailureClassifier` classifies **labels before codes**. The server's error labels
|
||||
(`TransientTransactionError`, `UnknownTransactionCommitResult`, `RetryableWriteError`) are the
|
||||
authoritative statement about retryability; an error code is a secondary signal whose meaning varies
|
||||
by server version. `DefaultMongoFailureTranslator` maps a classification onto the stable exception
|
||||
hierarchy, and anything unmatched becomes `MongoUnclassifiedFailureException` rather than leaking a
|
||||
driver type.
|
||||
|
||||
## 9. Bulk writes
|
||||
|
||||
`MongoBulkExecutor` returns a `MongoBulkResult` with per-item `MongoBulkItemFailure` entries. An
|
||||
unordered bulk write that partially fails is `PARTIAL_BULK_WRITE`, not a failure: some documents were
|
||||
written. `MongoBulkPartialFailureException` carries the succeeded and failed indexes so a caller can
|
||||
resume rather than replay.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Document Modeling Guide
|
||||
|
||||
Design §7–§9. The platform does not own your documents — D-01 is explicit that the domain owns
|
||||
`@Document`, repositories, queries, index requirements and schema version. What the platform owns is
|
||||
the set of modeling decisions that are expensive to reverse once a collection holds production data.
|
||||
|
||||
## 1. There is no `CommonMongoRepository`
|
||||
|
||||
A generic `CommonMongoRepository<T, ID>` is listed under explicitly unsupported (§3.4), and the
|
||||
reason is not purity. A shared supertype forces every collection to share an id strategy, a
|
||||
consistency profile and a query surface, and the first collection that needs a different one either
|
||||
gets a cast or a leaky generic parameter. Declare a Spring Data repository per aggregate.
|
||||
|
||||
## 2. Embed or reference
|
||||
|
||||
`MongoDocumentModelManifest` records the decision per collection so it is reviewable, and
|
||||
`MongoDocumentModelValidator` refuses the combinations that do not survive growth.
|
||||
|
||||
| Descriptor | Use when |
|
||||
|---|---|
|
||||
| `EmbeddedCollectionDescriptor` | The child is read with the parent, is bounded, and has no independent lifecycle. Declare `maxElements`; an unbounded array is the single most common way a document reaches the size limit. |
|
||||
| `MongoReferenceDescriptor` | The child is queried independently, is unbounded, or outlives the parent. Declare `MongoReferenceLifecycle` so the deletion story is written down rather than discovered. |
|
||||
|
||||
The validator rejects an embedded collection without a bound, and a reference whose lifecycle says
|
||||
the child is owned by the parent but which is also referenced from elsewhere.
|
||||
|
||||
## 3. Size budget
|
||||
|
||||
`MongoDocumentSizeBudget`:
|
||||
|
||||
| Constant | Bytes | Meaning |
|
||||
|---|---|---|
|
||||
| `MONGODB_HARD_LIMIT_BYTES` | 16 MiB | MongoDB's own limit. |
|
||||
| `PLATFORM_CEILING_BYTES` | 4 MiB | The largest budget the platform will accept. |
|
||||
| `DEFAULT_BYTES` | 2 MiB | `MongoDocumentSizeBudget.standard()`. |
|
||||
|
||||
A budget above the ceiling is refused at construction. Budgeting to 16 MiB means the failing write
|
||||
is the first symptom, and by then the collection is already full of near-limit documents.
|
||||
|
||||
## 4. Identity
|
||||
|
||||
`DomainDocumentId` and `MongoIdRepresentation` fix how a domain identifier becomes `_id`. Pick the
|
||||
representation once per collection and record it in the manifest:
|
||||
|
||||
- `OBJECT_ID` — server-generated, monotonic, 12 bytes. Good default when the domain has no natural id.
|
||||
- `UUID_BINARY` — a domain UUID stored as `Binary` subtype 4 (`STANDARD`). Never store a UUID as a
|
||||
string "because it is easier to read"; it doubles the index size and loses the type.
|
||||
- `STRING` — a natural key that is genuinely a string (a slug, an external system's id).
|
||||
|
||||
An `_id` choice is effectively permanent: it is the shard key candidate, the resume-token join key
|
||||
and the pagination tie-breaker.
|
||||
|
||||
## 5. Schema version
|
||||
|
||||
Every long-lived collection carries `DocumentSchemaVersion`. `MongoSchemaVersionPolicy` and
|
||||
`MongoSchemaVersionRange` say which versions the running code can read; a document outside the range
|
||||
raises `MongoDataSchemaUnsupportedException` rather than being silently mapped with missing fields.
|
||||
|
||||
Write the range down before the migration, not after: the range is what lets old and new instances
|
||||
run at once during a rolling deploy.
|
||||
|
||||
## 6. Type metadata
|
||||
|
||||
`@LongLivedMongoDocument` marks a document whose stored type alias must not be a Java class name.
|
||||
`MongoTypeMetadataRegistry` maps alias → class. Storing the FQCN means moving or renaming the class
|
||||
becomes a data migration; storing an alias keeps it a refactor. See
|
||||
[bson-mapping-guide.md](bson-mapping-guide.md) §4.
|
||||
|
||||
## 7. Collection profiles
|
||||
|
||||
`MongoCollectionProfileRegistry` binds a `CollectionProfileName` to its consistency profile, budget
|
||||
and allowlist. A collection that is not registered cannot be reached through
|
||||
`MongoImperativeExecutor` or `ReactiveMongoExecutor` — the allowlist is the mechanism that keeps an
|
||||
unreviewed collection from appearing in production by accident.
|
||||
|
||||
## 8. What to write down before the first insert
|
||||
|
||||
1. Embed/reference decision per child collection, with bounds.
|
||||
2. Size budget.
|
||||
3. `_id` representation.
|
||||
4. Schema version range.
|
||||
5. Index manifest (see [schema-index-migration-guide.md](schema-index-migration-guide.md)).
|
||||
6. Consistency profile (see [consistency-transaction-guide.md](consistency-transaction-guide.md)).
|
||||
|
||||
Each of these is cheap now and a migration later.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Query and Aggregation Guide
|
||||
|
||||
Design §17–§19, decision D-11. Every query and every pipeline is a registered, bounded thing. Free-form
|
||||
JSON queries and unbounded pipelines are explicitly unsupported (§3.4).
|
||||
|
||||
## 1. Registered operations
|
||||
|
||||
Every execution carries a `MongoOperationContext`: a `MongoOperationName`, a `DatabaseProfileName`, a
|
||||
`CollectionProfileName`, a `MongoOperationType` and a `MongoOperationScope`.
|
||||
|
||||
`MongoOperationName` matches `[a-z][a-z0-9.-]{2,95}`. It is the join key for the budget registry, the
|
||||
consistency registry, the metric tag and the log line — a free-form or interpolated name breaks all
|
||||
four at once, which is why the pattern is enforced at construction.
|
||||
|
||||
`MongoOperationScope` uses an `UNSPECIFIED` sentinel rather than `null`, so "the caller did not say"
|
||||
is a value the policy layer can reject rather than an NPE further down.
|
||||
|
||||
## 2. Query guardrails
|
||||
|
||||
`PolicyAwareMongoQueryBuilder` builds a query from `MongoFieldDescriptor` + `MongoOperator` pairs
|
||||
against a `MongoQueryPolicy`. The policy refuses:
|
||||
|
||||
- a field not in the collection's allowlist
|
||||
- an operator not allowed for that field
|
||||
- a sort on an unindexed field
|
||||
- `$where`, `$expr` with arbitrary JavaScript, and server-side evaluation generally
|
||||
- an unbounded `$regex`
|
||||
|
||||
`MongoRegexPolicy` requires an anchored prefix pattern and bounds the pattern length. An unanchored
|
||||
regex is a collection scan wearing an index's clothes, and a user-supplied one is a denial-of-service
|
||||
primitive.
|
||||
|
||||
`MongoSortDescriptor` pairs a field with a direction and is validated against the index manifest, so
|
||||
a sort that would spill to disk fails review rather than production.
|
||||
|
||||
## 3. Operation budgets
|
||||
|
||||
`MongoOperationBudget` bounds four things at once:
|
||||
|
||||
| Bound | Why |
|
||||
|---|---|
|
||||
| `maxTimeMS` | The server stops working on a query nobody is waiting for. |
|
||||
| result limit | An unbounded result set is an OOM with extra steps. |
|
||||
| batch size | Bounds the per-round-trip memory. |
|
||||
| examined-document ceiling | Catches an index regression that a time limit alone would hide on a fast day. |
|
||||
|
||||
`MongoBudgetPolicyRegistry` binds a budget to an operation name; `MongoBudgetEnforcer` applies it and
|
||||
raises `MongoOperationRejectedException` before execution when a request exceeds it, and
|
||||
`MongoTimeoutException` when the server enforces it.
|
||||
|
||||
## 4. Keyset pagination
|
||||
|
||||
Unbounded `skip` is unsupported: `skip(1_000_000)` makes the server walk a million documents to throw
|
||||
them away, so page 1000 costs a thousand times page 1.
|
||||
|
||||
`MongoKeysetQueryBuilder` builds the resume predicate lexicographically. For a sort on `(a DESC, _id
|
||||
DESC)` resuming after `(A, I)`:
|
||||
|
||||
```
|
||||
(a < A) OR (a = A AND _id < I)
|
||||
```
|
||||
|
||||
`validate()` rejects a `MongoKeysetSort` without a unique tie-breaker. Without one, two documents with
|
||||
the same sort value straddle the page boundary and one of them is skipped or repeated — invisibly,
|
||||
and only under concurrency.
|
||||
|
||||
`MongoNullSortOrdering` makes null placement explicit, because MongoDB's own ordering of missing
|
||||
versus null versus present is not what most people assume.
|
||||
|
||||
### Cursors are authenticated
|
||||
|
||||
`MongoKeysetCursorCodec` signs the cursor with HMAC-SHA256 and compares with
|
||||
`MessageDigest.isEqual` (constant time). An unsigned cursor is a client-controlled query predicate: a
|
||||
caller can edit it to read a range they were never offered. A tampered or truncated cursor yields
|
||||
`MongoCursorException`, never a partially-decoded resume position.
|
||||
|
||||
## 5. Aggregation guardrails
|
||||
|
||||
`MongoAggregationPlan` is a registered pipeline: an ordered list of `MongoAggregationStageDescriptor`
|
||||
validated against a `MongoAggregationProfile`. `PolicyAwareMongoAggregationExecutor` runs only a
|
||||
registered plan.
|
||||
|
||||
`MongoAggregationRisk` grades each stage, and the profile sets the ceiling:
|
||||
|
||||
| Risk | Stages | Policy |
|
||||
|---|---|---|
|
||||
| low | `$match` on an indexed prefix, `$limit`, `$project` | Always allowed. |
|
||||
| moderate | `$group`, `$sort` with an index, `$unwind` with a bound | Allowed within budget. |
|
||||
| high | `$lookup`, `$graphLookup`, `$facet`, unindexed `$sort` | Requires explicit approval in the profile. |
|
||||
| forbidden | `$out`, `$merge` outside the admin plane, `$function`, `$accumulator` | Refused. |
|
||||
|
||||
`allowDiskUse` is a declared property of the plan, not a runtime flag. A pipeline that needs disk is a
|
||||
pipeline whose shape should be reviewed.
|
||||
|
||||
## 6. Reactive execution and cursors
|
||||
|
||||
`ReactiveMongoExecutor` / `DefaultReactiveMongoExecutor` carry the operation context in the Reactor
|
||||
context. `MongoCursorGuard` and `MongoCursorLease` bound cursor lifetime:
|
||||
|
||||
- a cursor has a lease with a deadline
|
||||
- cancellation closes the server-side cursor (`MongoCursorTermination`)
|
||||
- an abandoned cursor is a server-side resource, so the lease is released on cancel, error *and*
|
||||
completion — `MongoReactiveCursorPublisher` uses `Flux.using` so all three paths run the same
|
||||
release
|
||||
|
||||
A leaked cursor does not fail anything locally; it consumes a connection and a snapshot on the server
|
||||
until the server's own timeout, which is why the guard is not optional.
|
||||
|
||||
## 7. Geospatial
|
||||
|
||||
`MongoGeoQuery` + `MongoGeoPoint` + `MongoGeoDistance` over a `2dsphere` index. Distances are metres
|
||||
on a sphere (`nearSphere` with `maxDistance`), never degrees — a degree of longitude is a different
|
||||
distance in Oslo than in Nairobi, and a radius expressed in degrees is a bug that only shows up away
|
||||
from the equator. `SpringMongoGeospatialOperations` is the Spring Data binding;
|
||||
`MongoGeospatialOperations` is the port.
|
||||
|
||||
## 8. Native capability gateway
|
||||
|
||||
When a registered operation genuinely needs something outside the Stable API, it goes through
|
||||
`MongoNativeCapabilityGateway` (`PolicyAwareMongoNativeGateway`), never through the driver directly.
|
||||
The admission order is fixed:
|
||||
|
||||
```
|
||||
capability registered
|
||||
→ database profile
|
||||
→ collection allowlist
|
||||
→ operation name present
|
||||
→ timeout / maxTimeMS
|
||||
→ consistency profile
|
||||
→ result / batch limit
|
||||
→ trace
|
||||
→ log redaction
|
||||
→ command category (MongoNativeCommandCategory)
|
||||
→ D4 admin command refused
|
||||
→ execute
|
||||
```
|
||||
|
||||
`ApprovedMongoNativeOperation` is the registration record; `MongoNativeOperationPolicy` is the policy.
|
||||
An admin-plane command reaching this gateway is refused regardless of capability — the admin plane has
|
||||
its own credential and its own client (see [security-observability.md](security-observability.md)).
|
||||
@@ -0,0 +1,124 @@
|
||||
# MongoDB Document Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
|
||||
**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
|
||||
**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
|
||||
|
||||
The design package declares its own module root (`modules/mongodb`) and root package
|
||||
(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the
|
||||
single record of how that assumed layout was mapped onto this repository. Only paths, build DSL,
|
||||
and composition-root ownership changed. Public contracts, policy order, and error semantics are
|
||||
implemented exactly as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The design assumes 19 Stable Gradle projects under `modules/mongodb/` and 12 Advanced projects
|
||||
under `modules/mongodb-advanced/`. This repository is a Clean Architecture template whose
|
||||
**fail-closed registry** (`src/config/architecture/modules.json`, enforced by `src/settings.gradle`
|
||||
and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 31 more
|
||||
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the design's 31 modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-mongo`, following the precedent already set by
|
||||
[docs/httpclient/repository-adaptation.md](../httpclient/repository-adaptation.md). The design's
|
||||
module dependency table (§6.3) is reproduced as ten ArchUnit rules in `MongoModuleBoundaryTest`, so
|
||||
a forbidden edge fails the build the same way a missing Gradle dependency would.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.mongodb` → `dev.caskeleton.adapter.outbound.mongo`.
|
||||
|
||||
### 2.1 Stable modules
|
||||
|
||||
| Design module | Repository package |
|
||||
|---|---|
|
||||
| `mongodb-core-api` | `…outbound.mongo.api` (+ `.capability`, `.consistency`, `.error`, `.mapping`, `.observation`, `.profile`, `.schema`) |
|
||||
| `mongodb-spring-data` | `…outbound.mongo.mapping` (+ `.type`), `…outbound.mongo.failure` |
|
||||
| `mongodb-imperative` | `…outbound.mongo.imperative` (+ `.atomic`, `.bulk`, `.revision`) |
|
||||
| `mongodb-reactive` | `…outbound.mongo.reactive` (+ `.cursor`) |
|
||||
| `mongodb-query` | `…outbound.mongo.query` (+ `.budget`, `.pagination`) |
|
||||
| `mongodb-aggregation` | `…outbound.mongo.aggregation` |
|
||||
| `mongodb-transaction` | `…outbound.mongo.transaction` (+ `.retry`, `.session`) |
|
||||
| `mongodb-index-schema` | `…outbound.mongo.schema` (+ `.index`, `.manifest`, `.model`, `.ttl`, `.validation`) |
|
||||
| `mongodb-change-stream` | `…outbound.mongo.changestream` (+ `.projector`, `.recovery`) |
|
||||
| `mongodb-geospatial` | `…outbound.mongo.geo` |
|
||||
| `mongodb-migration-core` | `…outbound.mongo.migration` |
|
||||
| `mongodb-migration-flamingock` | `…outbound.mongo.migration.flamingock` |
|
||||
| `mongodb-observability` | `…outbound.mongo.observation` |
|
||||
| `mongodb-security` | `…outbound.mongo.security` (+ `.admin`), `…outbound.mongo.nativecap` |
|
||||
| `mongodb-spring-boot-starter` | `…outbound.mongo.autoconfigure` |
|
||||
| `mongodb-testkit-core` | `…outbound.mongo.testkit.mapping`, `.compat`, `.performance` (`testkit` source set) |
|
||||
| `mongodb-testkit-replicaset` | `…outbound.mongo.testkit.rs` (`testkit` source set) |
|
||||
| `mongodb-testkit-failover` | `…outbound.mongo.testkit.failover` (`testkit` source set) |
|
||||
| `mongodb-testkit-migration` | `…outbound.mongo.testkit.migration` (`testkit` source set) |
|
||||
|
||||
`…outbound.mongo.architecture` has no design counterpart: it holds the `@MongoOperation` marker and
|
||||
the reusable ArchUnit rule set a fork applies to its own document/repository code.
|
||||
|
||||
### 2.2 Advanced modules
|
||||
|
||||
| Design module | Repository package |
|
||||
|---|---|
|
||||
| `mongodb-sharding` | `…outbound.mongo.advanced.sharding` (+ `.admin` for the D4 shard plane) |
|
||||
| `mongodb-timeseries` | `…outbound.mongo.advanced.timeseries` |
|
||||
| `mongodb-csfle` | `…outbound.mongo.advanced.encryption.csfle` |
|
||||
| `mongodb-queryable-encryption` | `…outbound.mongo.advanced.encryption.qe` |
|
||||
| `mongodb-search` | `…outbound.mongo.advanced.search` |
|
||||
| `mongodb-vector-search` | `…outbound.mongo.advanced.vector` |
|
||||
| `mongodb-tenancy-shared` | `…outbound.mongo.advanced.tenancy.shared` |
|
||||
| `mongodb-tenancy-database` | `…outbound.mongo.advanced.tenancy.database` |
|
||||
| `mongodb-change-stream-messaging-bridge` | `…outbound.mongo.advanced.bridge` |
|
||||
| `mongodb-gridfs-compat` | `…outbound.mongo.advanced.gridfs` |
|
||||
| `mongodb-testkit-sharded` | `…outbound.mongo.testkit.sharded` (`testkit` source set) |
|
||||
| `mongodb-testkit-atlas` | `…outbound.mongo.testkit.atlas` (`testkit` source set) |
|
||||
|
||||
The design's rule that a Stable module never depends on an Advanced one survives as an ArchUnit rule
|
||||
(`stableNeverDependsOnAdvanced`) plus the opt-in flag: every Advanced entry point requires
|
||||
`MongoAdvancedCapabilityFlags` to have the matching capability enabled and refuses construction
|
||||
otherwise. Being on the classpath is not being enabled.
|
||||
|
||||
## 3. Other deliberate substitutions
|
||||
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
|
||||
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
|
||||
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
|
||||
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. |
|
||||
|
||||
## 4. What is unchanged from the design
|
||||
|
||||
- D1 / D2 / D3 / D4 exposure planes and the ordered D3 admission sequence (§5).
|
||||
- Stable API V1 with `apiStrict=true` on the D1/D2 client generation; D3/D4 on separate generations.
|
||||
- `MongoExecutionOutcome`, including both ambiguous outcomes (`WRITE_RESULT_UNKNOWN`,
|
||||
`TRANSACTION_COMMIT_UNKNOWN`), and `MongoFailureContext`'s permitted-field list.
|
||||
- The complete stable exception hierarchy and the label-before-code classification order.
|
||||
- The BSON representation manifest (UUID `STANDARD`, `Decimal128`, UTC instants, alias type metadata)
|
||||
and the document-size budget.
|
||||
- Update-operator-first writes, and optimistic revision as the precondition for whole-document
|
||||
replacement.
|
||||
- Transaction body retry and commit retry as separate loops: a new session per body attempt, and
|
||||
commit-only retry on unknown commit. The body is never replayed after a commit ambiguity.
|
||||
- Registered operation names and manifests for query, aggregation and index; no free-form JSON query
|
||||
and no unbounded pipeline.
|
||||
- Keyset pagination with an authenticated cursor and a unique tie-breaker requirement.
|
||||
- Change stream as an at-least-once projector with resume-token checkpointing and explicit
|
||||
history-lost handling.
|
||||
- TTL as physical cleanup only, never the sole basis for access denial or business scheduling.
|
||||
- Manifest-owned index/validator state with an apply policy that never drops what it does not own.
|
||||
- Low-cardinality observation tags, command redaction, and the credential reference indirection.
|
||||
- The Stable release gate's evidence categories, and the Advanced promotion gate's requirement for
|
||||
actual-topology evidence.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-platform.sh # Stable gate
|
||||
bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes)
|
||||
```
|
||||
|
||||
Both scripts run from the repository root and delegate to `src/gradlew`.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Runbook — MongoDB primary failover
|
||||
category: mongodb
|
||||
severity: P2
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: MongoDB primary failover
|
||||
|
||||
Design §29, scenarios `PRIMARY_KILL`, `NETWORK_PARTITION`, `SERVER_SELECTION_TIMEOUT`,
|
||||
`WRITE_RESPONSE_LOSS`.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
|
||||
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
|
||||
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
|
||||
stays flat — the wait is topology, not query cost.
|
||||
- Latency spike on writes with no corresponding rise in read latency.
|
||||
|
||||
A failover that resolves in under ~15 s and produces no `WRITE_RESULT_UNKNOWN` is normal replica-set
|
||||
behaviour and needs no action beyond confirming it self-healed.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. Confirm an election actually happened. SDAM events distinguish an election from "the database got
|
||||
slow"; without them the two are indistinguishable in application metrics.
|
||||
2. Split the failure categories. Metric tag `failureCategory`:
|
||||
- `SERVER_SELECTION` / `CONNECTION` → the driver could not reach a primary. `NOT_SENT`; safe.
|
||||
- `TIMEOUT` with outcome `WRITE_RESULT_UNKNOWN` → a write may have applied. Not safe; see below.
|
||||
- `TRANSACTION_COMMIT_UNKNOWN` → go to [unknown-commit.md](unknown-commit.md) instead.
|
||||
3. Check the election duration against `MongoRetryBudget`. If the election outlasted the budget, the
|
||||
retries were exhausted before a primary existed and callers saw errors that a longer budget would
|
||||
have absorbed.
|
||||
4. Check whether the new primary is in the expected region/AZ. A failover to a distant node changes
|
||||
write latency permanently, not transiently.
|
||||
|
||||
## Action
|
||||
|
||||
**Self-healed (the common case).**
|
||||
Confirm outcome distribution contains no `WRITE_RESULT_UNKNOWN`, record the election in the incident
|
||||
log, and close. Nothing to replay.
|
||||
|
||||
**Writes with `WRITE_RESULT_UNKNOWN`.**
|
||||
These writes may or may not have applied. Do not blind-retry.
|
||||
- Idempotent operation (registered `MongoUpdateOperator` with an `AtomicFilter` precondition): retry.
|
||||
The precondition makes the second application a no-op.
|
||||
- Non-idempotent operation: reconcile by reading the target document and comparing against the
|
||||
intended post-state. Retry only if it does not reflect the write.
|
||||
|
||||
**Server selection never recovers.**
|
||||
The set has lost quorum — two of three nodes are down or partitioned. No client-side action fixes
|
||||
this; escalate to the database owner to restore a majority. The application should be failing closed,
|
||||
not queueing.
|
||||
|
||||
**Elections are frequent (more than one a day, unprompted).**
|
||||
This is an infrastructure symptom, not an application one: check node resource saturation, disk
|
||||
latency on the primary, and network stability between members. Repeated elections cause repeated
|
||||
unknown-outcome windows.
|
||||
|
||||
## Escalation
|
||||
|
||||
- P2 → P1 if server selection has failed for more than 2 minutes, or if any non-idempotent write
|
||||
returned `WRITE_RESULT_UNKNOWN` and cannot be reconciled.
|
||||
- Page the database owner for quorum loss, and the service owner for reconciliation of ambiguous
|
||||
writes.
|
||||
|
||||
## Verification
|
||||
|
||||
The failover lane reproduces this deliberately:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
It starts a real three-node set (`MongoThreeNodeReplicaSet`), stops the primary
|
||||
(`MongoPrimaryController`), and injects network faults through Toxiproxy
|
||||
(`ToxiproxyMongoNetworkFaultController`). A single-node set is not sufficient for the election: it
|
||||
never holds one, so every guarantee that depends on a primary change goes untested.
|
||||
|
||||
The network faults need their own fixture (`MongoProxiedReplicaSetNode`) because a stopped container
|
||||
cannot produce them. Stopping a node tells the client the write did not happen; cutting the *path*
|
||||
while the server keeps running produces a client that cannot tell. `MongoNetworkFaultLaneTest`
|
||||
asserts the difference by reaching the same server twice — once through the proxy, once directly:
|
||||
|
||||
- **Partition**: the proxied client fails, the direct client finds the server healthy and the earlier
|
||||
write intact. The path was cut, not the server.
|
||||
- **Response loss**: the proxied client fails, and the direct client then finds the document
|
||||
*present*. The write applied and only the acknowledgement was lost —
|
||||
`DefaultMongoFailureClassifier` returns `WRITE_RESULT_UNKNOWN`, and a retry would have inserted a
|
||||
second document.
|
||||
|
||||
One detail the lane depends on: the connection is warmed before the toxic is applied. On a cold
|
||||
connection it is the driver's handshake whose response is dropped, so the write is never transmitted
|
||||
— `NOT_SENT`, the opposite of the ambiguity being tested.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Runbook — MongoDB change stream history lost
|
||||
category: mongodb
|
||||
severity: P1
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: change stream history lost
|
||||
|
||||
Design §20.3, scenarios `OPLOG_HISTORY_LOSS`, `RESUME_TOKEN_LOSS`.
|
||||
|
||||
The stored resume token predates the oldest entry in the oplog. The events between the checkpoint and
|
||||
now are gone from the server; no retry recovers them. `MongoChangeStreamRecoveryPolicy` returns
|
||||
`halt(HISTORY_LOST, "history-lost")` and the runner stops.
|
||||
|
||||
**The platform will not silently restart from "now".** That looks like a recovery and is actually a
|
||||
permanent, unreported gap in the projection.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoChangeHistoryLostException`.
|
||||
- `MongoChangeStreamState.HISTORY_LOST`; the consumer is stopped, not looping.
|
||||
- Precedes it: consumer lag approaching the oplog window, or a consumer that was down for a long
|
||||
period (a deploy that failed, a scaled-to-zero worker, a long outage).
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. **Determine the gap.** The checkpoint's cluster time is the start; the oldest oplog entry is the
|
||||
end of what is unrecoverable. Everything in between was never processed.
|
||||
2. **Determine the oplog window.** `rs.printReplicationInfo()` on the primary gives the first and last
|
||||
oplog timestamps. If the window is materially smaller than it was, the write rate rose or the
|
||||
oplog was resized — the consumer may be fine and the server changed.
|
||||
3. **Determine what the projection is missing.** Which collections and which operations does this
|
||||
projector consume? The gap is bounded by that, not by everything that happened.
|
||||
4. **Check for a second consumer.** If another projector on the same collection is healthy, its
|
||||
checkpoint tells you whether the problem is this consumer or the oplog.
|
||||
|
||||
## Action
|
||||
|
||||
Resuming is not an option. The choices are:
|
||||
|
||||
**Rebuild from source.** If the projection is derivable from the current state of the source
|
||||
collections, rebuild it: stop the consumer, rebuild the projection, then start the stream from the
|
||||
cluster time at which the rebuild snapshot was taken. This is the correct answer whenever the
|
||||
projection is a materialised view rather than an event log, and it is the reason a projection should
|
||||
be derivable.
|
||||
|
||||
**Backfill the gap.** If the source documents carry a timestamp covering the gap, run a bounded
|
||||
backfill for that window through the migration runner (checkpointed, resumable — see
|
||||
[schema-index-migration-guide.md](../schema-index-migration-guide.md) §5), then resume from the
|
||||
current cluster time.
|
||||
|
||||
**Accept the gap explicitly.** Only when the projection is advisory and the business owner says so.
|
||||
Record the window in the incident log and reset the checkpoint. This is a decision someone signs, not
|
||||
a default.
|
||||
|
||||
Never: reset the checkpoint to "now" and restart quietly. That converts a visible P1 into an
|
||||
invisible data-quality defect that surfaces months later as "the report has been wrong since March".
|
||||
|
||||
## Prevention
|
||||
|
||||
- **Alert on lag against the oplog window, not wall-clock.** "Consumer is 30 minutes behind" is fine
|
||||
with a 24-hour oplog and an emergency with a 45-minute one. The threshold that matters is
|
||||
`lag / oplogWindow`.
|
||||
- **Size the oplog for the longest tolerable consumer outage**, including a failed deploy discovered
|
||||
the next morning.
|
||||
- **Checkpoint after processing, never on receipt** — see
|
||||
[change-stream-guide.md](../change-stream-guide.md) §3.
|
||||
- **Back up the checkpoint store.** `RESUME_TOKEN_LOSS` is the same incident reached from the other
|
||||
direction: the oplog is fine, the checkpoint is gone.
|
||||
- **Make the projection rebuildable.** A projection that can only be built by replaying every event
|
||||
has no recovery path once the oplog rolls.
|
||||
|
||||
## Escalation
|
||||
|
||||
- P1 on detection. The consumer is stopped, so lag grows for as long as this is unresolved.
|
||||
- Page the service owner for the rebuild decision, and the database owner if the oplog window shrank
|
||||
unexpectedly.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
`MongoFailoverScenario.OPLOG_HISTORY_LOSS` and `RESUME_TOKEN_LOSS` assert the runner halts and names
|
||||
this runbook rather than restarting from the current position.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Runbook — MongoDB unknown transaction commit result
|
||||
category: mongodb
|
||||
severity: P1
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: unknown transaction commit result
|
||||
|
||||
Design §16, decision D-10, scenario `UNKNOWN_TRANSACTION_COMMIT_RESULT`.
|
||||
|
||||
`MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN` means the commit **may have applied**. It is not a
|
||||
failure and must never be reported to a caller as one. The single worst response is to re-run the
|
||||
transaction body: if the commit did apply, the body applies a second time.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoTransactionCommitUnknownException` in logs.
|
||||
- Metric `failureCategory=TRANSACTION_COMMIT_UNKNOWN`.
|
||||
- Usually accompanies a primary election — see [failover.md](failover.md).
|
||||
- Downstream reports of duplicated effects (double charge, double increment) are the symptom of this
|
||||
being handled wrongly, not of the condition itself.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. **Confirm the platform did the right thing automatically.**
|
||||
`MongoTransactionRetryCoordinator` retries the *commit only*, on the same session, within
|
||||
`MongoRetryBudget`. A commit retry against an already-committed transaction is a no-op by design.
|
||||
Most occurrences resolve here and never reach a human.
|
||||
|
||||
2. **If the budget was exhausted, determine the actual state.** The commit either applied or it did
|
||||
not; you must find out which, not guess.
|
||||
- If the transaction body wrote a deterministic marker (an idempotency key, a business id, a
|
||||
revision), read it back. That is exactly what `MongoCommitReconciler` does, and it is the
|
||||
reason the design requires transactions to write one.
|
||||
- If there is no marker: reconstruct from a downstream artefact — an outbox row, an audit record,
|
||||
an external side effect. If nothing exists to compare against, the transaction was not
|
||||
designed to be reconcilable and that is the finding to record.
|
||||
|
||||
3. **Check whether the body was replayed.** Grep for a second execution with the same operation name
|
||||
and correlation id. If the body ran twice, the effects need reversing, and the code path that
|
||||
replayed it is a defect: an ambiguous commit is `COMMIT_ONLY` scope
|
||||
(`MongoRetryScope.COMMIT_ONLY`), never `BODY`.
|
||||
|
||||
## Action
|
||||
|
||||
**Commit applied.** Nothing to do. Record the reconciliation.
|
||||
|
||||
**Commit did not apply.** Re-run the whole operation from the top — a new session, a new body
|
||||
attempt. This is safe precisely because you established the previous attempt left no trace.
|
||||
|
||||
**Cannot determine.** Do not retry. Escalate. A blind retry here is a coin flip between "no effect"
|
||||
and "duplicate effect", and duplicates in a financial or notification path are worse than a delay.
|
||||
Freeze the affected entity if the domain supports it, and hand off with: operation name, correlation
|
||||
id, document id, the time window, and what you checked.
|
||||
|
||||
**Recurring.** More than one an hour means the commit path is racing something structural — a
|
||||
`maxCommitTime` shorter than the observed election duration, an oversized transaction, or an
|
||||
undersized retry budget. Fix the budget or the transaction shape; do not raise the retry count and
|
||||
call it resolved.
|
||||
|
||||
## Prevention
|
||||
|
||||
- Every transaction body writes a deterministic marker that identifies its own commit.
|
||||
- `maxCommitTime` exceeds the observed p99 election duration.
|
||||
- Callers surface the ambiguity to their own callers rather than mapping it to a generic 500 — an
|
||||
ambiguous outcome reported as a failure invites the caller to retry, which is the one thing that
|
||||
must not happen.
|
||||
- Prefer a single-document atomic operation (D-09). A transaction that exists only to wrap one
|
||||
document write has invented this failure mode for nothing.
|
||||
|
||||
## Escalation
|
||||
|
||||
- Always P1 when the state cannot be determined and the operation has an external effect.
|
||||
- Page the service owner immediately; the database owner only if elections are the trigger.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
`MongoFailoverScenario.UNKNOWN_TRANSACTION_COMMIT_RESULT` runs this path against a real three-node
|
||||
set, and the coordinator test asserts the body is never replayed after a commit ambiguity.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Schema, Index and Migration Guide
|
||||
|
||||
Design §21–§25, decision D-13. Indexes and validators are **declared** in a manifest and **applied**
|
||||
by an explicit plane. Automatic index creation in production is explicitly unsupported (§3.4): an
|
||||
index build on a large collection is a capacity event, and discovering it because a deployment
|
||||
started one is not an operating model.
|
||||
|
||||
## 1. The manifest is the source of truth
|
||||
|
||||
`MongoManifestRegistry` holds one `MongoCollectionManifest` per collection, containing:
|
||||
|
||||
- `MongoIndexManifest` — the declared indexes (`MongoIndexKey`, `MongoIndexDirection`, uniqueness,
|
||||
partial filter, collation)
|
||||
- `MongoSchemaManifest` — the declared `$jsonSchema` validator
|
||||
- `MongoMetadataOwnership` — who owns each observed object
|
||||
|
||||
Ownership is the field that makes drift handling safe:
|
||||
|
||||
| Ownership | Owner | Droppable on drift |
|
||||
|---|---|---|
|
||||
| `APPLICATION_MANAGED` | this manifest | yes |
|
||||
| `SEARCH_MANAGED` | the search service | no |
|
||||
| `ENCRYPTION_MANAGED` | Queryable Encryption | no |
|
||||
| `EXTERNAL` | someone else (a DBA, another service) | no |
|
||||
|
||||
A diff engine that does not know about ownership eventually proposes dropping
|
||||
`enxcol_.customers.esc` or a search index, and "the drift tool cleaned it up" is a very bad incident
|
||||
summary.
|
||||
|
||||
## 2. Index diff and apply
|
||||
|
||||
`MongoIndexDiffEngine` compares the manifest against `MongoIndexDescriptorView` observations and
|
||||
produces a `MongoIndexDiff`: missing, extra, and *changed* (same name, different definition —
|
||||
MongoDB will not silently rebuild these, so they must be reported rather than re-issued).
|
||||
|
||||
`MongoIndexApplyPolicy` decides what happens with a diff:
|
||||
|
||||
| Policy | Behaviour | Environment |
|
||||
|---|---|---|
|
||||
| `APPLY` | create what is missing | local / test |
|
||||
| `APPLY_WITH_DIFF` | create what is missing and report the rest | staging |
|
||||
| `DIFF_WITH_APPROVED_APPLY` | apply only what a human approved | production |
|
||||
| `REPORT_ONLY` | never write | audit |
|
||||
|
||||
Dropping is never implicit. `MongoIndexRetirementPlan` moves an index through
|
||||
`MongoIndexRetirementState` — declared → hidden → observed-unused → droppable — and each transition
|
||||
is a separate deployment. Hiding an index makes the planner ignore it while keeping it maintained, so
|
||||
an unexpected regression is one command to undo. Dropping it is not.
|
||||
|
||||
## 3. Validators
|
||||
|
||||
`MongoValidatorDescriptor` carries the `$jsonSchema`, a `MongoValidationLevel`
|
||||
(`OFF` / `MODERATE` / `STRICT`) and a `MongoValidationAction`.
|
||||
|
||||
**Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
|
||||
contract on MongoDB 7.0 or 8.0 and the descriptor refuses it.
|
||||
|
||||
`MongoValidatorDiffEngine` produces a `MongoValidatorDiff`; `MongoValidatorApplyPolicy` gates the
|
||||
apply. Tightening a validator on a collection with existing data is the dangerous direction: introduce
|
||||
it as `warn` + `MODERATE`, confirm the warning count is zero, then promote to `error` + `STRICT` in a
|
||||
second deployment.
|
||||
|
||||
## 4. TTL
|
||||
|
||||
D-13: TTL is **physical cleanup**, nothing else.
|
||||
|
||||
`MongoTtlIndexDescriptor` declares the field and `expireAfterSeconds`. `MongoTtlPolicyValidator`
|
||||
enforces what `MongoTtlPolicy` allows, and `MongoExpirationAccessPolicy` states the rule that matters:
|
||||
|
||||
> A document's presence is not authorization, and its absence is not a deadline.
|
||||
|
||||
The TTL monitor runs about once a minute and deletes in batches, so a document can outlive its
|
||||
expiry by minutes to hours under load. Consequences:
|
||||
|
||||
- Access control must check the expiry field, not the document's existence. A still-present expired
|
||||
session is a valid document and an invalid session.
|
||||
- Business scheduling must not be built on TTL. If something must happen at a time, schedule it.
|
||||
- A TTL field must be a BSON date. A TTL index on a string silently never deletes anything.
|
||||
|
||||
## 5. Migrations
|
||||
|
||||
`MongoMigrationRunner` executes `MongoMigration` units with:
|
||||
|
||||
- `MongoMigrationId` — ordered, unique
|
||||
- `MongoMigrationChecksum` — content hash; a changed checksum for an applied id is a hard failure, not
|
||||
a re-run. Editing an applied migration means two environments ran different code under the same id.
|
||||
- `MongoMigrationLedger` — what has been applied
|
||||
- `MongoMigrationLock` — one runner at a time; a rolling deploy starts several instances at once
|
||||
- `MongoMigrationPrecondition` / `MongoMigrationPostcondition` — checked before and after; a migration
|
||||
that cannot verify its own result is a migration whose failure is discovered by a customer
|
||||
- `MongoMigrationCheckpoint` — a resumable position for a backfill
|
||||
|
||||
`MongoMigrationResult` reports applied / incomplete / dry-run with the reason. `INCOMPLETE` is not a
|
||||
failure: a rate-limited backfill that ran out of its time budget has done real work and stored a
|
||||
checkpoint, and reporting it as failed would send the next run back to the beginning.
|
||||
|
||||
`MongoCollectionMigrationLedger` and `MongoCollectionMigrationLock` are the MongoDB-backed
|
||||
implementations. Two details are load-bearing and only exist on a server:
|
||||
|
||||
- The ledger's **unique index** on the migration id, created by `ensureIndexes()`. Without it, two
|
||||
runners that both pass the "not applied yet" read both insert, and the ledger then reports one
|
||||
migration applied twice with two checksums — indistinguishable from tampering.
|
||||
- The lease is taken with **one conditional update**, not read-then-write. A filter matching only a
|
||||
free or expired lease lets the server pick the winner; two runners that each read "free" and then
|
||||
write would both believe they hold it.
|
||||
|
||||
The lease expires so a runner killed mid-migration does not block every future deployment, and
|
||||
`refresh` between batches is what proves the holder is still alive.
|
||||
|
||||
### Backfills restart, they do not restart-from-zero
|
||||
|
||||
A long backfill will be interrupted — a deploy, an OOM, a node replacement. The checkpoint records
|
||||
the last completed key so the restart continues rather than re-processing from the beginning.
|
||||
`MongoBackfillRestartFixture` in the testkit asserts exactly this: kill mid-run, restart, and the
|
||||
result is identical to the uninterrupted run and does not re-apply completed work.
|
||||
|
||||
### Flamingock
|
||||
|
||||
`FlamingockMongoMigrationAdapter` bridges to Flamingock through the platform-owned
|
||||
`FlamingockChangeUnitView`, with `FlamingockLedgerAdapter` and `FlamingockLockAdapter` mapping the
|
||||
ledger and lock. The public contract does not reference Flamingock types, so the provider can be
|
||||
replaced without touching a migration.
|
||||
|
||||
## 6. Ordering with deployments
|
||||
|
||||
```
|
||||
1. Add the index (hidden if it is large) -> deployment N
|
||||
2. Unhide / verify usage -> deployment N+1
|
||||
3. Ship code that depends on the index -> deployment N+1
|
||||
4. Backfill data -> migration, resumable
|
||||
5. Tighten the validator from warn to error -> deployment N+2
|
||||
6. Retire the old index through the retirement states -> deployments N+3…
|
||||
```
|
||||
|
||||
Each step is independently reversible. A deployment that adds an index and the code that requires it
|
||||
at the same time has no safe rollback: rolling back the code leaves the index build running, and
|
||||
rolling back the index breaks the code that is still live on half the fleet.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Security and Observability
|
||||
|
||||
Design §26–§28, decision D-05. The application plane and the admin plane are different credentials on
|
||||
different clients, and telemetry never becomes an exfiltration path.
|
||||
|
||||
## 1. Roles
|
||||
|
||||
`MongoPrincipalRole` — one credential per role, least privilege:
|
||||
|
||||
| Role | Grants |
|
||||
|---|---|
|
||||
| `APP_READ` | `find` on allowlisted collections |
|
||||
| `APP_WRITE` | `insert`, `update`, `delete` on allowlisted collections |
|
||||
| `CHANGE_STREAM` | `changeStream`, `find` |
|
||||
| `MIGRATION` | index and validator management on the target collections |
|
||||
| `SEARCH_ADMIN` | search index management |
|
||||
| `SHARD_ADMIN` | shard key operations |
|
||||
| `ENCRYPTION_ADMIN` | key vault access |
|
||||
| `DBA` | the human plane; never used by an application |
|
||||
|
||||
`MongoSecurityProfileValidator` checks the profile at startup. `forbiddenPrivilegesHeld()` names the
|
||||
privileges the profile holds and must not — the validator reports *which* one, because "your
|
||||
credential is over-privileged" without a name is an unactionable finding.
|
||||
|
||||
The privileges that must never appear on an application credential: `dropDatabase`,
|
||||
`dropCollection`, `shutdown`, `killop`, `root`, `__system`, `dbOwner`, `userAdminAnyDatabase`.
|
||||
|
||||
## 2. Credentials are references, not values
|
||||
|
||||
`MongoCredentialReference` holds a `secret://…` reference plus the role. The reference is resolved at
|
||||
connection time by the secret provider; the password is never a property value, a log field, or a
|
||||
constructor argument that could end up in a stack trace.
|
||||
|
||||
`MongoCredentialRotationPolicy` states the rotation contract: overlapping validity, a drain window,
|
||||
and a rotation that never requires a restart. `MongoClientGenerationRegistry` implements the swap —
|
||||
a new `MongoClientGeneration` starts serving new operations while the previous generation is
|
||||
`markDraining()` until its in-flight operations finish. Killing the old client immediately fails every
|
||||
in-flight request, which is why rotation without generations is an outage.
|
||||
|
||||
Rotation is a failover scenario in the release gate (`MongoFailoverScenario.CREDENTIAL_ROTATION`),
|
||||
not a runbook step people hope works.
|
||||
|
||||
## 3. TLS and connection policy
|
||||
|
||||
`MongoSecurityProfile.production(...)` requires TLS and refuses `tlsAllowInvalidCertificates` /
|
||||
`tlsAllowInvalidHostnames`. `MongoSecurityProfile.local(...)` exists so a developer does not have to
|
||||
weaken the production factory to get a container to connect; the startup validator refuses a local
|
||||
profile on a production runtime profile.
|
||||
|
||||
## 4. Admin plane (D4)
|
||||
|
||||
`MongoAdminGateway` is the only path to `MongoAdminOperation`, and it runs on the D4 client with the
|
||||
DBA-scoped credential — not the application's.
|
||||
|
||||
- `MongoAdminAuthorization` checks the caller's role against the operation.
|
||||
- `MongoAdminRuntimeGuard` refuses high-risk operations (`highRisk()`) unless the runtime profile
|
||||
explicitly permits them; a `dropCollection` reachable from a running application is a data-loss
|
||||
vector regardless of how well-reviewed the calling code is.
|
||||
- `MongoAdminAuditRecord` records who ran what, when and against which collection profile — before
|
||||
execution, so a failed attempt is recorded too.
|
||||
|
||||
The native capability gateway (D3) refuses any admin-category command, so there is no path from the
|
||||
application plane into the admin plane.
|
||||
|
||||
## 5. Observability tags
|
||||
|
||||
`MongoObservationConvention` allowlists exactly eight tag names:
|
||||
|
||||
```
|
||||
mongoProfile, databaseProfile, collectionProfile, operationName,
|
||||
operationType, result, failureCategory, consistencyProfile
|
||||
```
|
||||
|
||||
and explicitly forbids:
|
||||
|
||||
```
|
||||
documentId, rawTenantId, tenantId, dynamicCollectionName, queryParameter,
|
||||
query, fullBson, resumeToken, shardKeyValue, plaintextPII, credential
|
||||
```
|
||||
|
||||
Two reasons, and both matter. Cardinality: a tag whose values are document ids produces one time
|
||||
series per document, which is how a metrics backend falls over. Confidentiality: a metric label is
|
||||
stored, shipped and retained by systems with a different access model than the database.
|
||||
`requireAllowed(tagName)` throws on anything outside the list, so a new tag is a deliberate change to
|
||||
the convention rather than a line in a service.
|
||||
|
||||
`MicrometerMongoOperationObserver` implements the `MongoOperationObserver` port;
|
||||
`NoOpMongoOperationObserver` is the default so observation is opt-in and never a hard dependency.
|
||||
|
||||
## 6. Driver-native listeners
|
||||
|
||||
`MongoDriverObservabilityConfiguration` registers three driver listeners, because they answer
|
||||
questions the application-level timer cannot:
|
||||
|
||||
| Listener | Answers |
|
||||
|---|---|
|
||||
| `MongoCommandObservationListener` | How long did the *server* take, versus how long the caller waited? |
|
||||
| `MongoPoolObservationListener` | Was the wait time connection checkout rather than query execution? |
|
||||
| `MongoSdamObservationListener` | Did the topology change — an election, a node removed — during the window? |
|
||||
|
||||
Without pool and SDAM events, every failover looks like "the database got slow", and the difference
|
||||
between "we need a bigger pool" and "we lost a primary" is invisible.
|
||||
|
||||
## 7. Command redaction
|
||||
|
||||
`MongoObservationRedactor.describe(commandName)`:
|
||||
|
||||
- Authentication and user-management commands (`authenticate`, `saslStart`, `saslContinue`,
|
||||
`getnonce`, `createUser`, `updateUser`, `copydb*`) render as `<redacted>` — their arguments carry
|
||||
credentials and key material.
|
||||
- Structural commands (`ping`, `hello`, `buildInfo`, `listCollections`, `listIndexes`, `collStats`)
|
||||
render by name; their arguments are not data-bearing.
|
||||
- Everything else renders as `name(...)`: you get the command, never the filter or the document.
|
||||
|
||||
`isAlwaysRedacted(...)` is the assertion hook so a test can prove no logging path can render an auth
|
||||
command's arguments.
|
||||
|
||||
## 8. Startup validation
|
||||
|
||||
`MongoStartupValidator` runs at context refresh, before the first request:
|
||||
|
||||
1. `MongoTopologyProbe` reports the actual `MongoTopology`.
|
||||
2. Each declared `MongoTopologyRequirement` is checked against it — a transaction, causal-session or
|
||||
change-stream requirement fails closed on `STANDALONE`.
|
||||
3. `MongoSecurityProfileValidator` checks credentials and TLS.
|
||||
4. `MongoCapabilitySupport` checks declared capabilities against the server version, with
|
||||
`MongoSupportLevel` distinguishing `STABLE` / `ADVANCED` / `EXPERIMENTAL` / `UNSUPPORTED`.
|
||||
5. `MongoPlatformHealthIndicator` reports the outcome for the readiness probe.
|
||||
|
||||
A misconfiguration found at startup costs a failed deploy. The same misconfiguration found at runtime
|
||||
costs an incident, and the failing operation is rarely the one that reveals the cause.
|
||||
|
||||
## 9. How the security lane proves any of this
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain
|
||||
```
|
||||
|
||||
The lane runs against `MongoAuthenticatedReplicaSetContainer`, which starts mongod with `--auth` and a
|
||||
generated keyfile. That detail is the whole lane: Testcontainers' `MongoDBContainer` starts mongod
|
||||
*without* `--auth`, so users created on it all have every privilege and a least-privilege assertion
|
||||
passes no matter how wrong the roles are. A security test that cannot fail is not a security test.
|
||||
|
||||
What the lane asserts is the refusal: the `read` role's insert is rejected, and the application role's
|
||||
`dropDatabase` is rejected. Then it checks that `MongoSecurityProfileValidator` names the same
|
||||
privilege the server just refused.
|
||||
@@ -0,0 +1,91 @@
|
||||
# MongoDB Platform — Support Matrix
|
||||
|
||||
Design §4. This file records what the platform is *certified* on, not what it happens to run on.
|
||||
A configuration absent from this table is unsupported until someone runs the gate against it and
|
||||
adds a row.
|
||||
|
||||
## 1. Runtime baseline
|
||||
|
||||
| Component | Version | Policy |
|
||||
|---|---|---|
|
||||
| Java | 21 | Repository runtime baseline. |
|
||||
| Spring Boot | 4.0.0 | BOM-managed. Individual driver overrides are forbidden. |
|
||||
| Spring Data MongoDB | 5.0.0 | Repository and `MongoTemplate` integration. Version comes from the Boot BOM. |
|
||||
| MongoDB Java Driver | 5.6.1 | BOM-managed. Never pinned directly in the module. |
|
||||
| Reactor | 3.8.0 | Reactive execution path. |
|
||||
| Micrometer | 1.16.0 | Driver-native observability. |
|
||||
| Testcontainers | 2.0.2 | Replica-set, failover, migration and compatibility lanes. |
|
||||
|
||||
The design's baseline is Spring Boot 4.1.x / Spring Data MongoDB 5.1.x. This repository is on
|
||||
4.0.0 / 5.0.0, so the platform targets only the API surface common to both. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §3.
|
||||
|
||||
## 2. Server versions
|
||||
|
||||
| Lane | Version | Pinned image | Gradle task |
|
||||
|---|---|---|---|
|
||||
| Primary certification | MongoDB 8.0 | `mongo:8.0.16` | `mongoReplicaSetTest`, `mongoFailoverTest` |
|
||||
| Compatibility | MongoDB 7.0 | `mongo:7.0.28` | `mongoCompatibilityTest` |
|
||||
| Network fault injection | — | `ghcr.io/shopify/toxiproxy:2.12.0` | `mongoFailoverTest` |
|
||||
|
||||
Images are pinned, never `latest`: a mutable tag means the certification result describes whatever
|
||||
was pulled that morning, not the version in the row. Override with
|
||||
`-PmongoPrimaryImage=…` / `-PmongoCompatibilityImage=…` when testing a new patch level, and update
|
||||
the row once the gate passes.
|
||||
|
||||
`MongoVersionMatrix.standard()` is the machine-readable form of this table; a version outside it
|
||||
fails `certifies()`.
|
||||
|
||||
## 3. Topologies
|
||||
|
||||
| Topology | Status | What is certified | What is not |
|
||||
|---|---|---|---|
|
||||
| Standalone | **Smoke only** | Basic CRUD and mapping. | Not a production profile and never counts as Stable release evidence (D-03). Transactions, retryable writes and change streams are refused at startup by `MongoStartupValidator`. |
|
||||
| Single-node replica set | **Local default** (D-02) | Transactions, retryable writes, change streams — the same semantics as production. | Elections. A single-node set never holds one, so failover behaviour is untested here. |
|
||||
| 3-node replica set | **Stable production gate** | Everything above plus primary failover, unknown-commit handling and change-stream resume across an election. | Shard routing. |
|
||||
| Sharded cluster | **Advanced gate** | Shard-key routing classification, scatter-gather refusal, `admin` plane operations. | Not included in the Stable gate. |
|
||||
| Atlas / provider-managed | **Per-capability gate** | Search, vector search and encryption against the actual target deployment. | Atlas Local in a container is a pull-request convenience, explicitly **not** release evidence (`MongoAtlasCapabilityContractSuite.Environment`). |
|
||||
|
||||
## 4. Stable API and client generations
|
||||
|
||||
| Plane | Stable API | Purpose |
|
||||
|---|---|---|
|
||||
| D1 Standard document persistence | V1, `apiStrict=true` | Repositories, typed queries, atomic updates, optimistic revision. |
|
||||
| D2 Advanced document operations | V1, `apiStrict=true` | `MongoTemplate`, transactions, bulk, aggregation, keyset cursors, change streams. |
|
||||
| D3 Explicit Mongo capability | Not strict | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations — each behind a registered capability. |
|
||||
| D4 Admin plane | Not strict | Collection, validator, index, migration, shard and repair commands. Separate credential, separate client. |
|
||||
|
||||
D3 is not a raw-client escape. Every call passes capability registration → database profile →
|
||||
collection allowlist → operation name → timeout → consistency profile → result limit → trace →
|
||||
redaction → command category → D4 refusal, in that order.
|
||||
|
||||
## 5. Validation actions
|
||||
|
||||
Stable validation actions are `error` and `warn`. `errorAndLog` is **not** part of the Stable
|
||||
contract on 7.0 or 8.0 and `MongoValidatorDescriptor` refuses it.
|
||||
|
||||
## 6. Explicitly unsupported
|
||||
|
||||
Per design §3.4, none of the following is provided, and adding one is a design change rather than a
|
||||
feature request:
|
||||
|
||||
- A generic `CommonMongoRepository<T, ID>`.
|
||||
- Arbitrary runtime `runCommand`.
|
||||
- Automatic index creation in production.
|
||||
- A Standalone production contract.
|
||||
- TTL as an exact business scheduler or as the only access control.
|
||||
- Publishing raw change events as external business integration events.
|
||||
- GridFS as the source of truth for new files.
|
||||
- Java fully-qualified class names as a long-lived BSON schema.
|
||||
- Unbounded skip pagination, unbounded aggregation pipelines, unbounded regex, unbounded results.
|
||||
|
||||
## 7. Capability tiers
|
||||
|
||||
| Tier | Capabilities | Enablement |
|
||||
|---|---|---|
|
||||
| Stable | Mapping, imperative/reactive execution, atomic update, optimistic lock, transactions, consistency profiles, retry/translation, query and aggregation guardrails, schema/index manifests, keyset pagination, bulk partial results, change streams, TTL contract, GeoJSON, security, observability | On when `ca-skeleton.persistence-mongo.enabled=true`. |
|
||||
| Advanced | Sharding-aware query, time series, CSFLE, Queryable Encryption (equality/range), change-stream→messaging bridge, shared-collection multi-tenancy | Each behind `ca-skeleton.persistence-mongo.advanced.<capability>.enabled`. |
|
||||
| Experimental | Search, vector search, hybrid search, database-per-tenant, collection-per-tenant, reshard orchestration, provider-specific features | Same flag mechanism; promotion additionally requires the evidence in [ADR-MONGO-ADV-001](../adr/ADR-MONGO-ADV-001-capability-promotion.md). |
|
||||
|
||||
`MongoAdvancedCapabilityFlags.propertyFor(capability)` is the authoritative property name for any
|
||||
capability; the table above is its prose form.
|
||||
@@ -0,0 +1,27 @@
|
||||
# NOTIF-ADR-001 — `submit()` means durable acceptance
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The obvious API for a notification platform is `send()` returning success or failure. Every channel
|
||||
this platform supports makes that return value a lie:
|
||||
|
||||
- SES accepts a request, returns a `MessageId`, and can still decline to send.
|
||||
- Twilio separates `accepted`, `sent` and `delivered` into distinct, later events.
|
||||
- APNs accepts a notification and may then deliver, store or discard it.
|
||||
- Web Push separates push-service acceptance from user-agent acknowledgement at the protocol level.
|
||||
|
||||
## Decision
|
||||
|
||||
`submit()` and `schedule()` return once the logical request and its recipient jobs are committed to
|
||||
the database. The receipt carries `notificationId`, `RequestStatus` and `acceptedAt`, and has no
|
||||
`delivered`, `sent` or `read` component. No provider is contacted while the transaction is open.
|
||||
|
||||
## Consequences
|
||||
|
||||
Callers cannot mistake acceptance for delivery, because the type does not offer that reading.
|
||||
Delivery state is a separate query against the projection built from the provider event ledger. The
|
||||
cost is that "did it arrive?" is a second question — which is the honest number of questions.
|
||||
@@ -0,0 +1,25 @@
|
||||
# NOTIF-ADR-002 — append-only event ledger with channel projectors
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
A single linear delivery status has to be updated in place, which forces a rule for deciding whether
|
||||
a new event outranks the stored one. The natural rule — compare ordinals — is wrong for real provider
|
||||
traffic. Twilio does not guarantee callback ordering, so `sent` arrives after `delivered`. Email
|
||||
generates complaints after deliveries. Both cases lose information under an ordinal rule.
|
||||
|
||||
## Decision
|
||||
|
||||
Provider events are appended to an immutable ledger before any projection runs. Channel-specific
|
||||
projectors merge events into `SubmissionOutcome`, `DeliveryOutcome`, `EvidenceLevel`,
|
||||
`EngagementFacts` and `SuppressionFacts` using explicit transition tables. Projection is idempotent
|
||||
and can be replayed from the ledger.
|
||||
|
||||
## Consequences
|
||||
|
||||
Duplicate, out-of-order and late events are normal inputs rather than defects. A projector bug is
|
||||
recoverable, because the events it mis-projected are still stored. Projector versions can be migrated
|
||||
by replay. The cost is a second write per event and a projection that can lag its ledger.
|
||||
@@ -0,0 +1,37 @@
|
||||
# NOTIF-ADR-003 — ambiguous submission is a first-class state
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
The most common serious failure is not a rejection. It is a request whose body reached the provider
|
||||
and whose response never came back. The platform has no provider request id, and the user may or may
|
||||
not have received the notification.
|
||||
|
||||
Treating that as a failure produces duplicates: a retry sends a second message, and a cross-channel
|
||||
fallback sends the SMS next to the push that already arrived. Treating it as a success loses real
|
||||
failures.
|
||||
|
||||
## Decision
|
||||
|
||||
`AMBIGUOUS` is a stored `SubmissionOutcome` and `AttemptConfirmation`. Attempts record
|
||||
`requestStarted`, `requestBodyCommitted` and `providerResponseReceived`, each with an
|
||||
`EvidenceCertainty` of `PROVEN`, `INFERRED` or `UNKNOWN`, so an adapter that does not know is not
|
||||
forced to answer `false`.
|
||||
|
||||
While an ambiguous attempt exists on a recipient delivery:
|
||||
|
||||
- automatic retry is blocked unless the provider proves per-request idempotency
|
||||
- automatic cross-channel fallback is blocked unconditionally
|
||||
- reconciliation runs where the provider supports a status query
|
||||
- otherwise the delivery stops and waits for an operator
|
||||
|
||||
Operator redrive of an ambiguous attempt requires explicit duplicate-risk approval.
|
||||
|
||||
## Consequences
|
||||
|
||||
Some notifications stop in a state that needs a human or a reconciliation pass. That is the intended
|
||||
trade: an unresolved unknown is cheaper than a guaranteed duplicate, and the state is visible rather
|
||||
than silently resolved in either direction.
|
||||
@@ -0,0 +1,27 @@
|
||||
# NOTIF-ADR-004 — FCM installation id is the primary target
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Context
|
||||
|
||||
Firebase now recommends the installation id (FID) and treats registration-token multicast paths as
|
||||
legacy. A contact point model built on a single `token` string would encode the older model as the
|
||||
only one, and a later migration would be a runtime interpretation problem: the same string field
|
||||
would mean different things for different rows.
|
||||
|
||||
## Decision
|
||||
|
||||
`MobilePushTarget` is a sealed hierarchy of `FcmInstallationId`, `LegacyFcmRegistrationToken` and
|
||||
`ApnsDeviceToken`. The kinds are separate types, never a discriminator on one string field, and each
|
||||
carries its own `ContactPointType` so the uniqueness scope and the encryption associated data differ.
|
||||
|
||||
APNs tokens additionally carry their environment, because sandbox and production are separate
|
||||
namespaces rather than a flag.
|
||||
|
||||
## Consequences
|
||||
|
||||
Migrating a target kind is a compile-time change with an exhaustive `switch`, not a runtime guess.
|
||||
The adapter maps each kind to its own wire representation, so a provider changing one path cannot
|
||||
silently change the other. The cost is one more type than a string field would need.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Callbacks and reconciliation
|
||||
|
||||
## Ingestion order
|
||||
|
||||
```text
|
||||
body size limit
|
||||
→ content type
|
||||
→ profile lookup
|
||||
→ signature verification
|
||||
→ append to the ledger
|
||||
→ duplicate detection
|
||||
→ normalization
|
||||
→ attempt resolution
|
||||
→ projection
|
||||
→ side effects
|
||||
→ 2xx
|
||||
```
|
||||
|
||||
Appending before projecting is what makes a fast 2xx honest. The provider is told the event is
|
||||
recorded, and a projector defect becomes a replay problem rather than a lost event.
|
||||
|
||||
A rejected signature is recorded in the security audit, never in the provider event ledger. Writing
|
||||
it to the ledger would let anyone who can reach the endpoint fill a delivery history with noise.
|
||||
|
||||
## Duplicates and ordering
|
||||
|
||||
Duplicate suppression uses `(providerProfileId, providerEventId)` where the provider supplies an
|
||||
event id, and a deterministic fingerprint over profile, request id, event type, occurrence time and
|
||||
payload digest where it does not. A duplicate is acknowledged and projected exactly once.
|
||||
|
||||
Out-of-order callbacks are normal. Ordering is resolved by event semantics, not by arrival time.
|
||||
|
||||
## Unknown fields
|
||||
|
||||
Callback parsers tolerate unknown JSON fields. Normalization only rejects a payload when a field
|
||||
required to identify the attempt is missing. Providers add fields; that must not stop ingestion.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
Reconciliation targets:
|
||||
|
||||
- attempts stuck in `DISPATCHING` past their lease
|
||||
- ambiguous submissions
|
||||
- accepted attempts whose callback SLA has expired
|
||||
- unmatched provider events
|
||||
|
||||
A confirmed query result is appended to the same ledger with `source = RECONCILIATION` and projected
|
||||
by the same projector, so projection replay stays possible: there is no privileged second path that
|
||||
writes projections directly.
|
||||
|
||||
Where a provider has no status-query capability, the platform records `Unsupported` and leaves the
|
||||
attempt ambiguous. It does not infer a final status.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Configuration reference
|
||||
|
||||
## Dispatch
|
||||
|
||||
| Property | Meaning | Bound |
|
||||
|---|---|---|
|
||||
| `claim-batch-size` | Rows claimed per scheduler tick | 1..1000 |
|
||||
| `lease-duration` | How long a claimed job stays owned | positive, finite |
|
||||
| `max-global-concurrency` | Ceiling across all providers | positive |
|
||||
| `max-queue-age` | Age at which a job is escalated | positive |
|
||||
| `max-retry-concurrency` | Ceiling for retry work | positive |
|
||||
| `scheduler-poll-interval` | Queue poll cadence | positive |
|
||||
| `callback-worker-concurrency` | Callback projection workers | positive |
|
||||
|
||||
Every value is bounded. "Unlimited" is not an accepted configuration.
|
||||
|
||||
## Provider profiles
|
||||
|
||||
A profile pins provider type, environment, credential profile, timeouts, concurrency, rate limit,
|
||||
retry policy and callback profile. Sender identity and credential profile are separate concerns.
|
||||
|
||||
## Startup failures
|
||||
|
||||
Startup fails rather than degrading when:
|
||||
|
||||
- a payload or queue setting is unbounded
|
||||
- a timeout is negative
|
||||
- a TTL-required profile has no expiry source
|
||||
- a callback signing secret is missing
|
||||
- a production profile enables trust-all
|
||||
- an APNs profile is missing its environment or topic
|
||||
- a Web Push profile is missing its VAPID key
|
||||
- two provider profiles share an id
|
||||
- a route points only at disabled providers
|
||||
- ambiguous fallback is enabled by default
|
||||
|
||||
## Secrets
|
||||
|
||||
All key material arrives through `SecretMaterialProvider`. Nothing is read from source, from a
|
||||
committed file, or from a plaintext log. Contact point encryption and lookup HMAC keys must be
|
||||
distinct, and the encryption key must be exactly 256 bits.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Delivery evidence model
|
||||
|
||||
## The shape
|
||||
|
||||
```text
|
||||
NotificationRequest
|
||||
└─ RecipientDelivery
|
||||
└─ DeliveryAttempt
|
||||
└─ ProviderEvent (append-only)
|
||||
└─ channel projector
|
||||
└─ SubmissionOutcome / DeliveryOutcome / EvidenceLevel
|
||||
+ EngagementFacts + SuppressionFacts
|
||||
```
|
||||
|
||||
Four identities, four lifecycles. A logical request is not a recipient job, a recipient job is not a
|
||||
provider attempt, and a provider attempt is not the event stream that describes it.
|
||||
|
||||
## Why not one status enum
|
||||
|
||||
A single linear status would have to answer "what happened?" with one value, and the real answers do
|
||||
not fit on one line:
|
||||
|
||||
- An email can be `DELIVERED` and then generate a complaint. Both facts are true and both matter:
|
||||
one for reporting, the other for suppression.
|
||||
- Twilio does not guarantee callback ordering, so `sent` routinely arrives after `delivered`. Under
|
||||
an ordinal rule the later, weaker event silently overwrites the stronger one.
|
||||
- APNs may accept a notification and then store, replace or discard it.
|
||||
|
||||
So the ledger stores events and a channel projector merges them through an explicit transition table.
|
||||
`StandardDeliveryProjector` holds the shared rules; provider projectors add only their own event
|
||||
vocabulary.
|
||||
|
||||
## Merge rules
|
||||
|
||||
| Transition | Result |
|
||||
|---|---|
|
||||
| `sent` → `delivered` | applied |
|
||||
| `delivered` → `sent` | ignored, event still stored |
|
||||
| `delivered` → `complaint` | complaint fact added, delivery preserved |
|
||||
| `complaint` → `delivered` | delivery applied, complaint preserved |
|
||||
| `accepted` → `bounced` | applied |
|
||||
| `read` → `displayed` | ignored |
|
||||
| hard bounce → `delivered` | ignored, hard bounce is terminal |
|
||||
|
||||
Engagement (`opened`, `clicked`) is stored beside the delivery outcome and never changes it.
|
||||
|
||||
## Ambiguity
|
||||
|
||||
```text
|
||||
platform ──── send ────▶ provider
|
||||
│
|
||||
└── accepted
|
||||
✗ connection reset
|
||||
```
|
||||
|
||||
The platform may hold no provider request id while the notification really was sent. The attempt
|
||||
records `requestStarted`, `requestBodyCommitted`, `providerResponseReceived` and an
|
||||
`EvidenceCertainty` for each, so a later decision can tell "we know nothing was sent" apart from "we
|
||||
could not read the answer".
|
||||
|
||||
`ProviderSubmissionResult` enforces this: an ambiguous result may not claim `PROVIDER_ACCEPTED`, and
|
||||
no submission result of any kind may carry a delivery outcome.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Migration guide
|
||||
|
||||
## From the R0 routing seam
|
||||
|
||||
The pre-existing `dev.caskeleton.adapter.outbound.notification` router (`RoutingNotifier`,
|
||||
`FailOpenNotificationProvider`, the Google email and Slack webhook seams) stays untouched. The
|
||||
delivery platform lives beside it under `…notification.platform` and does not modify or delete any
|
||||
R0 class.
|
||||
|
||||
Migration order per capability:
|
||||
|
||||
1. Register the contact points behind `ContactPointStorePort` so the platform owns protected values.
|
||||
2. Publish the template version, and pin the template id, version and locale at every call site.
|
||||
3. Move the call site from the router to the N1 typed facade for the channel.
|
||||
4. Verify evidence in the snapshot rather than in the caller's return value: `submit()` is durable
|
||||
acceptance and nothing more.
|
||||
5. Remove the R0 route only after the platform route has produced provider evidence in the target
|
||||
environment.
|
||||
|
||||
## Return-value semantics change
|
||||
|
||||
The R0 seam returned a send-shaped result. `NotificationReceipt` returns `notificationId`, a request
|
||||
status and an acceptance time. Callers that treated the old return value as proof of delivery must be
|
||||
changed; there is no compatibility shim, because a shim would have to invent the delivery claim this
|
||||
platform exists to avoid.
|
||||
|
||||
## FCM target migration
|
||||
|
||||
Registration tokens keep working through `LegacyFcmRegistrationToken`. New registrations should use
|
||||
`FcmInstallationId`. The two are distinct types, so a migration is a compile-time task rather than a
|
||||
runtime guess.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Notification Delivery Platform — module mapping
|
||||
|
||||
> Source design: `notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md`
|
||||
>
|
||||
> Source plan: `notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md`
|
||||
|
||||
## Why a mapping exists
|
||||
|
||||
The plan was written against a hypothetical repository (`modules/notification/**`, root package
|
||||
`io.backend.skeleton.notification`, 31 Gradle projects). This repository is a fail-closed
|
||||
19-leaf Clean Architecture template: `src/settings.gradle` rejects any registry that does not
|
||||
contain exactly the 19 modules in `src/config/architecture/modules.json`, and
|
||||
`verifyCleanArchitectureDependencies` rejects any project edge outside `allowed_dependencies`.
|
||||
|
||||
Creating 31 new Gradle projects would violate HARD-STOP #5 of `AGENTS.md`. The package README
|
||||
anticipates this and instructs the implementer to map dependency catalog and package/file paths onto
|
||||
the host repository's rules while preserving the public contracts and reliability semantics.
|
||||
|
||||
Every logical module of the plan is therefore implemented as a **package** inside the registered leaf
|
||||
that owns its responsibility. No public contract, evidence rule, or reliability semantic is dropped.
|
||||
|
||||
## Logical module → registered leaf
|
||||
|
||||
| Plan module | Registered leaf | Package |
|
||||
|---|---|---|
|
||||
| `notification-core-api` | `application-core` | `dev.caskeleton.application.notification.platform.api` |
|
||||
| `notification-content-api` | `application-core` | `…platform.api.content` |
|
||||
| `notification-contact-api` | `application-core` | `…platform.contact` |
|
||||
| `notification-template-api` | `application-core` | `…platform.template` |
|
||||
| `notification-policy` | `application-core` | `…platform.policy` |
|
||||
| `notification-provider-spi` | `application-core` | `…platform.provider` |
|
||||
| `notification-callback-api` | `application-core` | `…platform.callback` |
|
||||
| `notification-email-api` | `application-core` | `…platform.email` |
|
||||
| `notification-sms-api` | `application-core` | `…platform.sms` |
|
||||
| `notification-push-api` | `application-core` | `…platform.push` |
|
||||
| `notification-webpush` (API half) | `application-core` | `…platform.webpush` |
|
||||
| `notification-inbox-api` | `application-core` | `…platform.inbox` |
|
||||
| `notification-admin-api` | `application-core` | `…platform.admin` |
|
||||
| `notification-security` (ports + redaction) | `application-core` | `…platform.security` |
|
||||
| `notification-observability` (ports) | `application-core` | `…platform.observation` |
|
||||
| `notification-dispatch-runtime` | `adapter:outbound:notification` | `dev.caskeleton.adapter.outbound.notification.platform.dispatch` |
|
||||
| `notification-security` (AES-GCM/HMAC impl) | `adapter:outbound:notification` | `…platform.security` |
|
||||
| `notification-template-thymeleaf` (reference renderer) | `adapter:outbound:notification` | `…platform.template` |
|
||||
| `notification-email-smtp` | `adapter:outbound:notification` | `…platform.provider.smtp` |
|
||||
| `notification-email-ses` | `adapter:outbound:notification` | `…platform.provider.ses` |
|
||||
| `notification-sms-twilio` | `adapter:outbound:notification` | `…platform.provider.twilio` |
|
||||
| `notification-push-fcm` | `adapter:outbound:notification` | `…platform.provider.fcm` |
|
||||
| `notification-push-apns` | `adapter:outbound:notification` | `…platform.provider.apns` |
|
||||
| `notification-webpush` (transport + crypto) | `adapter:outbound:notification` | `…platform.provider.webpush` |
|
||||
| `notification-webhook-extension` | `adapter:outbound:notification` | `…platform.provider.webhook` |
|
||||
| `notification-observability` (Micrometer impl) | `adapter:outbound:notification` | `…platform.observation` |
|
||||
| `notification-admin-runtime` | `adapter:outbound:notification` | `…platform.admin` |
|
||||
| `notification-reactor` | `adapter:outbound:notification` | `…platform.reactor` |
|
||||
| `notification-spring-boot-starter` | `adapter:outbound:notification` (+ `app-bootstrap` wiring) | `…platform.autoconfigure` |
|
||||
| `notification-persistence-jpa` | `adapter:outbound:persistence-jpa` | `dev.caskeleton.adapter.outbound.persistence.notification.platform` |
|
||||
| `notification-inbox-jpa` | `adapter:outbound:persistence-jpa` | `…persistence.notification.platform.inbox` |
|
||||
| `notification-callback-mvc` | `adapter:inbound:web` | `dev.caskeleton.adapter.inbound.web.notification.platform.callback` |
|
||||
| `notification-callback-webflux` | `adapter:inbound:web` | `…callback.reactive` |
|
||||
| `notification-testkit` | test source sets of the owning leaves | `…platform.testkit` |
|
||||
|
||||
## Dependency-direction consequences
|
||||
|
||||
The plan's module DAG (`*-api` → `provider-spi`/`policy` → runtime/adapters → starter) is preserved
|
||||
by the leaf DAG that the registry already enforces:
|
||||
|
||||
```text
|
||||
application-core (all *-api, provider SPI, policy, callback contracts)
|
||||
↑ ↑ ↑
|
||||
adapter:outbound:notification adapter:outbound:persistence-jpa adapter:inbound:web
|
||||
↑ ↑ ↑
|
||||
app-bootstrap
|
||||
```
|
||||
|
||||
Two plan edges cannot be expressed as project edges in this repository, and are replaced by ports:
|
||||
|
||||
1. `notification-email-ses`, `notification-sms-twilio`, `notification-push-fcm`,
|
||||
`notification-push-apns`, `notification-webpush`, `notification-webhook-extension`
|
||||
→ `httpclient platform`.
|
||||
`adapter-outbound-notification` is not allowed to depend on `adapter-outbound-httpclient`.
|
||||
The provider adapters therefore call
|
||||
`dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway`,
|
||||
an adapter-local port with a JDK `java.net.http.HttpClient` default implementation.
|
||||
`app-bootstrap` sees both leaves and is the supported place to substitute an implementation backed
|
||||
by the HTTP Client Platform (TLS/timeout/circuit-breaker/SSRF/dynamic-target policy reuse).
|
||||
2. `notification-inbox-jpa` → `optional messaging outbox integration`.
|
||||
`adapter-outbound-persistence-jpa` may not depend on `adapter-outbound-messaging`; the inbox
|
||||
publishes through the existing persistence outbox tables plus the
|
||||
`NotificationInboxSignalPort` application port, and `app-bootstrap` binds the relay.
|
||||
|
||||
## Commit policy
|
||||
|
||||
`AGENTS.md` pins commit policy to `human-only`. Step 5 (`git add` / `git commit`) of every plan task
|
||||
is therefore intentionally **not** executed by the agent; the working tree carries the change and the
|
||||
human owner commits.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Operations
|
||||
|
||||
## Runtime shape
|
||||
|
||||
```text
|
||||
durable queue (PostgreSQL, FOR UPDATE SKIP LOCKED)
|
||||
→ expiry check
|
||||
→ suppression and eligibility re-check
|
||||
→ provider health gate
|
||||
→ rate limiter
|
||||
→ concurrency limiter
|
||||
→ provider adapter
|
||||
```
|
||||
|
||||
Provider calls run outside every database transaction. The attempt row is committed first, so after a
|
||||
crash the row is either absent (nothing was sent) or present in `DISPATCHING` (reconciliation has
|
||||
something to ask about).
|
||||
|
||||
## Guards that exist for specific incidents
|
||||
|
||||
| Guard | The incident it prevents |
|
||||
|---|---|
|
||||
| Credential failure opens the provider route | One expired key multiplied by a queue becomes a self-inflicted outage |
|
||||
| Retry budget per provider profile | A provider outage turning every queued notification into its own retry loop |
|
||||
| Ambiguous attempts block automatic fallback | A push whose response was lost arriving alongside the "just in case" SMS |
|
||||
| Permits released during backoff | A slow provider pinning the whole concurrency budget on work that is only waiting |
|
||||
| Bounded drain on rotation | A provider that never answers holding a credential rotation open forever |
|
||||
| Fail-fast intake on capacity | An unbounded in-memory queue absorbing a burst it cannot survive |
|
||||
|
||||
## Scheduling
|
||||
|
||||
`scheduleAt` activates the job, `notBefore` is the earliest permitted provider submission, and
|
||||
`expiresAt` blocks new attempts, retries and fallbacks. Suppression and expiry are re-checked
|
||||
immediately before dispatch, because a scheduled notification can sit in the queue for hours and the
|
||||
user may have opted out in the meantime.
|
||||
|
||||
## Redrive
|
||||
|
||||
A redrive preserves `NotificationId` and `RecipientDeliveryId`, creates a new `DeliveryAttemptId`, and
|
||||
reuses the pinned template version and rendered digest. Sending different content is a new
|
||||
notification, not a redrive. Redriving an ambiguous attempt requires explicit duplicate-risk approval,
|
||||
because the platform genuinely cannot tell whether the first submission reached the user.
|
||||
|
||||
## Actuator surface
|
||||
|
||||
Provider runtime states and generations, queue depth and age, callback and reconciliation health.
|
||||
Never addresses, never credentials.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Provider runbooks
|
||||
|
||||
## SMTP
|
||||
|
||||
| Symptom | Classification | Action |
|
||||
|---|---|---|
|
||||
| Final `2xx` after `DATA` | `CONFIRMED_ACCEPTED` / `PROVIDER_ACCEPTED` | None; this is acceptance, not inbox delivery |
|
||||
| `4yz` | `TRANSIENT_PROVIDER` | Retry under budget and deadline |
|
||||
| `5yz` | `PERMANENT_PROVIDER` or `INVALID_RECIPIENT` | Stop, or invalidate the contact point |
|
||||
| Connection lost after `DATA` | `AMBIGUOUS_SUBMISSION` | Reconcile or escalate; do not resend automatically |
|
||||
|
||||
Connection, read, write and pool-acquire timeouts are all finite. There is no unbounded timeout.
|
||||
|
||||
## Amazon SES
|
||||
|
||||
`MessageId` is acceptance evidence. SES itself documents that it can accept a request and then not
|
||||
send, so `MessageId` is never mapped to `DELIVERED`.
|
||||
|
||||
| Event | Normalized |
|
||||
|---|---|
|
||||
| `Send` | reinforces `PROVIDER_ACCEPTED` |
|
||||
| `Delivery` | `DELIVERY_CONFIRMED` / `NETWORK_OR_CARRIER_ACCEPTED` |
|
||||
| `DeliveryDelay` | delay fact |
|
||||
| `Bounce` (permanent) | `BOUNCED_HARD` plus hard-bounce suppression |
|
||||
| `Bounce` (transient) | `BOUNCED_SOFT`; retry policy input, not a suppression reason |
|
||||
| `Complaint` | complaint fact plus suppression |
|
||||
| `Reject` | `PROVIDER_REJECTED` |
|
||||
| `RenderingFailure` | `TEMPLATE_FAILURE` |
|
||||
|
||||
## Twilio
|
||||
|
||||
`accepted`/`queued` is acceptance only. `sent` is carrier acceptance. `delivered` is device delivery.
|
||||
|
||||
Callbacks are not ordered. A `sent` arriving after `delivered` is stored and ignored by the
|
||||
projection. Missing callbacks are corrected by status polling under the provider rate limit.
|
||||
|
||||
Signature verification uses the canonical external URL from the profile, not the URL the servlet
|
||||
container reconstructed behind a proxy.
|
||||
|
||||
## FCM
|
||||
|
||||
| Error | Classification |
|
||||
|---|---|
|
||||
| `UNREGISTERED` | `INVALID_RECIPIENT`; invalidate the contact point, never retry |
|
||||
| `INVALID_ARGUMENT` | `INVALID_PAYLOAD` |
|
||||
| `QUOTA_EXCEEDED` | `THROTTLED`, exponential backoff |
|
||||
| `UNAVAILABLE` | `TRANSIENT_PROVIDER`, honour `Retry-After`, add jitter |
|
||||
| Credential failure | `AUTHENTICATION`; opens the provider route |
|
||||
|
||||
A batch is one transport call and many attempts. Partial results map back by input index; one
|
||||
transport failure does not become one shared outcome unless the adapter can prove it.
|
||||
|
||||
## APNs
|
||||
|
||||
2xx is acceptance. Environment and topic mismatches are configuration failures, not delivery
|
||||
failures. Sandbox and production tokens are separate namespaces.
|
||||
|
||||
## Web Push
|
||||
|
||||
`TTL` is mandatory by protocol. `201` is acceptance. `404` is an expired subscription per RFC 8030;
|
||||
provider-documented `410` maps the same way. Payloads use `aes128gcm` per RFC 8291 and VAPID JWTs are
|
||||
signed per RFC 8292 with the audience taken from the endpoint origin.
|
||||
|
||||
VAPID key rotation is not ordinary credential rotation: a restricted subscription may need to be
|
||||
re-created, so it is a migration operation.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Security and privacy
|
||||
|
||||
## Protected values
|
||||
|
||||
Email addresses, phone numbers, FCM installation ids and legacy tokens, APNs device tokens, Web Push
|
||||
endpoints and keys, VAPID private keys, provider credentials, callback signing secrets, template
|
||||
variables, rendered bodies, attachment references and unsubscribe tokens.
|
||||
|
||||
## At rest
|
||||
|
||||
Contact points are encrypted with AES-256-GCM. Equality lookup uses a separate HMAC-SHA-256
|
||||
fingerprint.
|
||||
|
||||
Two keys, not one, because the requirements are opposite: the ciphertext must be non-deterministic so
|
||||
two records of the same address are not visibly identical, while equality lookup must be
|
||||
deterministic. The fingerprint is keyed rather than a plain digest because phone numbers and email
|
||||
addresses come from a small, enumerable space — an unkeyed hash of a phone number is recoverable in
|
||||
seconds.
|
||||
|
||||
The contact point kind is bound into the GCM associated data, so a ciphertext cannot be moved between
|
||||
contact kinds without failing the authentication tag.
|
||||
|
||||
An unknown key id is refused rather than silently falling back to the current key: a silent fallback
|
||||
would turn every historical row into a tag failure at read time.
|
||||
|
||||
## Never logged, never a metric tag
|
||||
|
||||
Addresses, tokens, Web Push endpoints and keys, message bodies, template variables, provider
|
||||
credentials, unsubscribe tokens, attachment URLs, raw callback payloads and raw provider request ids.
|
||||
|
||||
Two mechanisms enforce this rather than convention:
|
||||
|
||||
- `CardinalityGuard` validates every metric tag against a closed allowlist.
|
||||
- `SafeDiagnosticContext` rejects any structured-diagnostic field outside its allowlist.
|
||||
|
||||
An allowlist rather than a denylist, because the failure mode of a denylist is that the one field
|
||||
nobody thought of is the one that leaks.
|
||||
|
||||
Every contact point value type overrides `toString()` to print `[redacted]`. That covers the case a
|
||||
central redactor cannot: a value interpolated into a log line by accident.
|
||||
|
||||
## Web Push endpoints
|
||||
|
||||
RFC 8030 defines the push URI as a capability URL — knowing it is sufficient to push to the
|
||||
subscriber. It is handled as a secret, not as a URL.
|
||||
|
||||
## Callbacks
|
||||
|
||||
TLS, provider signature verification over the exact received bytes and external URL, replay defence
|
||||
where a timestamp or nonce is available, body-size and content-type limits, profile binding, rate
|
||||
limiting, idempotent ingestion and a security audit trail for rejections.
|
||||
|
||||
## Tenant isolation
|
||||
|
||||
Every store port carries the tenant boundary in its signature. Administrative operations require an
|
||||
explicit tenant or a global authority.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Notification support matrix
|
||||
|
||||
What each channel can actually prove, and what the platform refuses to claim.
|
||||
|
||||
## Channels
|
||||
|
||||
| Channel | Reference implementation | Grade | Strongest evidence the platform records by default |
|
||||
|---|---|---|---|
|
||||
| Email | SMTP, Amazon SES API | Stable | Provider acceptance; recipient mail-server delivery, bounce and complaint when the provider publishes events |
|
||||
| SMS | Twilio Programmable Messaging | Stable | `accepted`/`queued`, `sent`, and carrier-DLR `delivered`/`undelivered` |
|
||||
| Mobile push (Android and cross-platform) | FCM, FID-first with legacy registration token compatibility | Stable | FCM acceptance and explicit failures |
|
||||
| Mobile push (Apple) | APNs HTTP/2 provider API | Stable | APNs acceptance |
|
||||
| Web Push | RFC 8030, RFC 8291, RFC 8292 | Stable | Push-service acceptance; user-agent acknowledgement only where the service offers receipts |
|
||||
| In-app inbox | Own database | Optional stable | `PERSISTED`, `SEEN`, `READ` |
|
||||
| Webhook | HTTP client platform | Extension | Whatever the receiving HTTP contract states |
|
||||
|
||||
## Evidence levels
|
||||
|
||||
`NONE` → `PLATFORM_QUEUED` → `PROVIDER_ACCEPTED` → `NETWORK_OR_CARRIER_ACCEPTED` →
|
||||
`DEVICE_DELIVERED` → `USER_AGENT_DISPLAYED` → `USER_READ`
|
||||
|
||||
| Provider signal | Highest evidence it may produce |
|
||||
|---|---|
|
||||
| Internal queue commit | `PLATFORM_QUEUED` |
|
||||
| SES `MessageId` | `PROVIDER_ACCEPTED` |
|
||||
| SES `Delivery` | `NETWORK_OR_CARRIER_ACCEPTED` |
|
||||
| Twilio `accepted` / `queued` | `PROVIDER_ACCEPTED` |
|
||||
| Twilio `sent` | `NETWORK_OR_CARRIER_ACCEPTED` |
|
||||
| Twilio `delivered` | `DEVICE_DELIVERED` |
|
||||
| FCM send success | `PROVIDER_ACCEPTED` |
|
||||
| APNs 2xx | `PROVIDER_ACCEPTED` |
|
||||
| Web Push `201` | `PROVIDER_ACCEPTED` |
|
||||
| Web Push receipt capability | `DEVICE_DELIVERED` |
|
||||
| In-app row commit | `PROVIDER_ACCEPTED` |
|
||||
| In-app `seen` endpoint | `USER_AGENT_DISPLAYED` |
|
||||
| In-app `read` endpoint, authenticated app receipt | `USER_READ` |
|
||||
|
||||
Promotions the platform will not make, in code or in configuration:
|
||||
|
||||
- FCM send success is not `DEVICE_DELIVERED`.
|
||||
- An APNs 2xx is not `DELIVERED`.
|
||||
- An SES `MessageId` is not `DELIVERED`.
|
||||
- An SMTP `250` is not inbox delivery.
|
||||
|
||||
## Submission outcomes
|
||||
|
||||
`NOT_SUBMITTED`, `CONFIRMED_ACCEPTED`, `CONFIRMED_REJECTED`, `AMBIGUOUS`.
|
||||
|
||||
`AMBIGUOUS` is a first-class stored state, not an error path. It means the request body was committed
|
||||
to the provider and the outcome could not be read. While an ambiguous attempt exists on a recipient
|
||||
delivery, automatic retry and automatic cross-channel fallback are both blocked.
|
||||
|
||||
## Not supported
|
||||
|
||||
The platform will not claim any of the following, because no channel above can support them:
|
||||
|
||||
- guaranteed delivery
|
||||
- guaranteed read
|
||||
- exactly-once human notification
|
||||
- unconditional multi-provider failover after an unread response
|
||||
- provider SDK types in the public API
|
||||
- audience selection, campaign segmentation or jurisdiction rulings
|
||||
|
||||
## Target model
|
||||
|
||||
`FCM_FID` is the primary mobile push target. `FCM_REGISTRATION_TOKEN_LEGACY` and
|
||||
`APNS_DEVICE_TOKEN` are separate types with separate lifecycles; they are never flattened into one
|
||||
string field.
|
||||
@@ -0,0 +1,771 @@
|
||||
# JPA Experimental Expansion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Stable JPA 플랫폼을 변경하지 않고 Multi-tenancy, PostgreSQL RLS, schema/database tenant 분리, consistency-aware Read Replica, Jakarta Persistence 4.0, Hibernate ORM 8, PostgreSQL 19 호환성을 독립 Experimental 모듈과 승격 Gate로 검증한다.
|
||||
|
||||
**Architecture:** Experimental module은 Stable `jpa-core-api` 계약만 소비하며 Stable starter에 자동 포함되지 않는다. 각 기능은 명시적 feature flag와 별도 compatibility/failure suite를 요구한다. 실험 결과가 Stable 의미론과 충돌하면 Core를 왜곡하지 않고 capability 또는 별도 profile로 유지한다.
|
||||
|
||||
**Tech Stack:** Stable 계획의 Java 21·Spring Boot 4.1·PostgreSQL Testcontainers 기반, PostgreSQL RLS, AbstractRoutingDataSource, tenant-specific DataSource registry, Jakarta Persistence 4.0 preview/final compatibility lane, Hibernate ORM 8 compatibility lane, PostgreSQL 19 compatibility lane.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Stable 계획 Task 1~53이 완료되고 Release Gate가 통과한 뒤 시작한다.
|
||||
- 모듈 루트는 `modules/jpa-experimental`이다.
|
||||
- Experimental module은 `jpa-spring-boot-starter`의 기본 dependency가 아니다.
|
||||
- 모든 기능은 `backend.jpa.experimental.*` feature flag를 요구한다.
|
||||
- Tenant ID와 consistency token은 metric label에 기록하지 않는다.
|
||||
- Tenant context 누락은 fail-closed다.
|
||||
- `readOnly=true`만으로 replica routing하지 않는다.
|
||||
- Lock query, write transaction, read-after-write pin은 primary를 사용한다.
|
||||
- JPA4/Hibernate8/PG19 결과로 Stable 3.2/7.4/PG16~18 contract를 수정하지 않는다.
|
||||
- 승격 전 별도 security, failure, migration and compatibility evidence가 필요하다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Experimental 파일 구조
|
||||
|
||||
```text
|
||||
modules/jpa-experimental/
|
||||
├── jpa-experimental-core/
|
||||
├── jpa-multitenancy-column/
|
||||
├── jpa-multitenancy-rls/
|
||||
├── jpa-multitenancy-schema/
|
||||
├── jpa-multitenancy-database/
|
||||
├── jpa-read-replica/
|
||||
└── jpa-next-compatibility/
|
||||
```
|
||||
|
||||
---
|
||||
### Task 1: Experimental Module·Feature Gate·Dependency Isolation 구성
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java`
|
||||
- Create: `modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java`
|
||||
- Modify: `settings.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Stable `jpa-core-api` and explicit environment feature flags.
|
||||
- Produces: Isolated experimental projects that cannot enter the Stable starter transitively.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Every module depends only on Stable public contracts, never on Stable internal packages.
|
||||
- Feature gate fails startup when module is present but flag is absent.
|
||||
- Add a dependency graph test proving the Stable starter has no experimental dependency.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
class ExperimentalFeatureGateTest {
|
||||
@Test
|
||||
void featureIsDisabledUnlessExplicitlyEnabled() {
|
||||
assertThatThrownBy(() -> gate.requireEnabled(MULTITENANCY_COLUMN, Map.of()))
|
||||
.hasMessageContaining("backend.jpa.experimental.multitenancy-column=true");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental;
|
||||
|
||||
public final class ExperimentalFeatureGate {
|
||||
public void requireEnabled(
|
||||
ExperimentalFeature feature,
|
||||
Map<String, Boolean> flags) {
|
||||
if (!Boolean.TRUE.equals(flags.get(feature.property()))) {
|
||||
throw new IllegalStateException(feature.property() + "=true is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test --tests 'io.backend.skeleton.jpa.experimental.ExperimentalFeatureGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-experimental-core:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-experimental-core/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-column/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-rls/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-schema/build.gradle.kts' 'modules/jpa-experimental/jpa-multitenancy-database/build.gradle.kts' 'modules/jpa-experimental/jpa-read-replica/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeature.java' 'modules/jpa-experimental/jpa-experimental-core/src/main/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGate.java' 'settings.gradle.kts' 'modules/jpa-experimental/jpa-experimental-core/src/test/java/io/backend/skeleton/jpa/experimental/ExperimentalFeatureGateTest.java'
|
||||
git commit -m "build: isolate jpa experimental modules"
|
||||
```
|
||||
|
||||
### Task 2: Shared-schema Tenant Context와 Column Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Explicit request/job tenant context and domain Entity tenant-column contracts.
|
||||
- Produces: Fail-closed tenant context propagation and query/write isolation evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Reject Repository access when tenant context is absent outside an audited admin scope.
|
||||
- Require tenant column in unique/index requirements where isolation depends on it.
|
||||
- Test async job context propagation and cleanup.
|
||||
- Do not rely on Hibernate filter alone as the final security boundary.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
class TenantColumnIsolationTest {
|
||||
@Test
|
||||
void tenantARepositoryCannotReadTenantBRows() {
|
||||
insertFor(TENANT_A, "a");
|
||||
insertFor(TENANT_B, "b");
|
||||
|
||||
assertThat(withTenant(TENANT_A, repository::findAll))
|
||||
.extracting(Item::value)
|
||||
.containsExactly("a");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.tenant;
|
||||
|
||||
public final class TenantContext {
|
||||
private static final ThreadLocal<TenantId> CURRENT = new ThreadLocal<>();
|
||||
|
||||
public static TenantId require() {
|
||||
TenantId tenant = CURRENT.get();
|
||||
if (tenant == null) throw new IllegalStateException("tenant context is required");
|
||||
return tenant;
|
||||
}
|
||||
|
||||
public static void clear() { CURRENT.remove(); }
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:integrationTest --tests 'io.backend.skeleton.jpa.experimental.tenant.TenantColumnIsolationTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-column:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantId.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantContext.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantAwareRepositoryGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/main/java/io/backend/skeleton/jpa/experimental/tenant/TenantEntityListenerGuard.java' 'modules/jpa-experimental/jpa-multitenancy-column/src/integrationTest/java/io/backend/skeleton/jpa/experimental/tenant/TenantColumnIsolationTest.java'
|
||||
git commit -m "feat: add experimental tenant column isolation"
|
||||
```
|
||||
|
||||
### Task 3: PostgreSQL RLS Tenant Policy와 Connection Reuse Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: TenantContext, PostgreSQL transaction-local settings and restricted runtime role.
|
||||
- Produces: Database-enforced tenant isolation that resets safely across pooled connections.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Set tenant context with transaction-local `set_config` before tenant queries.
|
||||
- Prove a pooled connection cannot leak the prior tenant into the next transaction.
|
||||
- Runtime role must not own tables or bypass RLS.
|
||||
- Admin bypass requires a separate DataSource and audit token.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
class RlsIsolationFailureTest {
|
||||
@Test
|
||||
void pooledConnectionDoesNotLeakPriorTenantSetting() {
|
||||
withTenant(TENANT_A, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withTenant(TENANT_B, () -> assertThat(repository.count()).isEqualTo(1));
|
||||
withoutTenant(() -> assertThatThrownBy(repository::count).isInstanceOf(DataAccessException.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.rls;
|
||||
|
||||
public final class RlsTenantSessionBinder {
|
||||
public void bind(EntityManager entityManager, TenantId tenant) {
|
||||
entityManager.createNativeQuery(
|
||||
"select set_config('app.tenant_id', :tenant, true)")
|
||||
.setParameter("tenant", tenant.value())
|
||||
.getSingleResult();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:failureTest --tests 'io.backend.skeleton.jpa.experimental.rls.RlsIsolationFailureTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-rls:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsTenantSessionBinder.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsPolicyVerifier.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/java/io/backend/skeleton/jpa/experimental/rls/RlsAdminBypassToken.java' 'modules/jpa-experimental/jpa-multitenancy-rls/src/main/resources/db/experimental-rls/V1__tenant_rls.sql' 'modules/jpa-experimental/jpa-multitenancy-rls/src/failureTest/java/io/backend/skeleton/jpa/experimental/rls/RlsIsolationFailureTest.java'
|
||||
git commit -m "feat: add experimental postgresql rls isolation"
|
||||
```
|
||||
|
||||
### Task 4: Schema-per-tenant Connection Provider와 Migration Orchestrator 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Validated tenant→schema catalog and Flyway migration gate.
|
||||
- Produces: Bounded schema selection and per-tenant migration status without accepting raw schema names.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Map TenantId to a pre-registered schema identifier; no user-provided SQL identifier.
|
||||
- Reset schema/search_path when returning pooled connections.
|
||||
- Track migration version and failure per tenant.
|
||||
- Rate-limit tenant migrations and support resume without auto-repair.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
class SchemaTenantMigrationContractTest {
|
||||
@Test
|
||||
void migratesOnlyRegisteredSchemasAndResumesAfterFailure() {
|
||||
orchestrator.migrateAll(List.of(TENANT_A, TENANT_B));
|
||||
assertThat(status(TENANT_A).version()).isEqualTo(LATEST);
|
||||
assertThatThrownBy(() -> orchestrator.migrate(new TenantId("../public")))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.schema;
|
||||
|
||||
public final class SchemaTenantRegistry {
|
||||
public String requireSchema(TenantId tenant) {
|
||||
return Optional.ofNullable(schemaByTenant.get(tenant))
|
||||
.orElseThrow(() -> new IllegalArgumentException("unregistered tenant schema"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:migrationTest --tests 'io.backend.skeleton.jpa.experimental.schema.SchemaTenantMigrationContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-schema:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaMultiTenantConnectionProvider.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/main/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationOrchestrator.java' 'modules/jpa-experimental/jpa-multitenancy-schema/src/migrationTest/java/io/backend/skeleton/jpa/experimental/schema/SchemaTenantMigrationContractTest.java'
|
||||
git commit -m "feat: add experimental schema per tenant persistence"
|
||||
```
|
||||
|
||||
### Task 5: Database-per-tenant DataSource Registry와 Capacity Guard 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java`
|
||||
- Create: `modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java`
|
||||
- Test: `modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Secret-backed tenant connection profiles and global DB connection budget.
|
||||
- Produces: Lazy bounded per-tenant pools with eviction, credential rotation and migration status.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Never create an unbounded Hikari pool per tenant.
|
||||
- Enforce global maximum pools and connections before creating a DataSource.
|
||||
- Drain and close pools on tenant removal or credential rotation.
|
||||
- Do not expose tenant JDBC URLs or credentials in diagnostics.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
class TenantPoolCapacityContractTest {
|
||||
@Test
|
||||
void refusesNewTenantPoolWhenGlobalConnectionBudgetIsExhausted() {
|
||||
registry.openTenants(globalBudget().maxTenants());
|
||||
assertThatThrownBy(() -> registry.require(ANOTHER_TENANT))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("tenant pool budget");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.database;
|
||||
|
||||
public record TenantPoolBudget(
|
||||
int maxOpenPools,
|
||||
int maxConnectionsAcrossPools) {
|
||||
public void requireCapacity(int openPools, int allocatedConnections) {
|
||||
if (openPools >= maxOpenPools || allocatedConnections >= maxConnectionsAcrossPools) {
|
||||
throw new IllegalStateException("tenant pool budget exhausted");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:performanceTest --tests 'io.backend.skeleton.jpa.experimental.database.TenantPoolCapacityContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-multitenancy-database:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantEntityManagerFactoryRegistry.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantPoolBudget.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/main/java/io/backend/skeleton/jpa/experimental/database/TenantDataSourceLifecycle.java' 'modules/jpa-experimental/jpa-multitenancy-database/src/performanceTest/java/io/backend/skeleton/jpa/experimental/database/TenantPoolCapacityContractTest.java'
|
||||
git commit -m "feat: add experimental database per tenant registry"
|
||||
```
|
||||
|
||||
### Task 6: Consistency-aware Read Replica Routing 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java`
|
||||
- Create: `modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java`
|
||||
- Test: `modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Primary/replica DataSources, transaction state, lock intent and replica lag evidence.
|
||||
- Produces: Routing decisions for PRIMARY_REQUIRED, BOUNDED_STALENESS and EVENTUAL reads.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Writes, lock queries, REQUIRES_NEW writes and active write transactions always use primary.
|
||||
- Read-after-write uses a consistency token or primary pin, not `readOnly=true` alone.
|
||||
- Fallback to primary when replica lag exceeds policy or evidence is unavailable.
|
||||
- Keep routing fixed for the life of one transaction.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
class ReadAfterWriteRoutingContractTest {
|
||||
@Test
|
||||
void immediateReadAfterWriteUsesPrimaryUntilConsistencyTokenIsSatisfied() {
|
||||
var token = service.writeAndReturnConsistencyToken();
|
||||
var decision = router.route(readOnlyTransaction(), ReadConsistency.after(token));
|
||||
assertThat(decision.target()).isEqualTo(PRIMARY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.replica;
|
||||
|
||||
public final class ConsistencyAwareDataSourceRouter {
|
||||
public ReplicaRoutingDecision route(
|
||||
TransactionContext transaction,
|
||||
ReadConsistency consistency) {
|
||||
if (transaction.write() || transaction.locking() ||
|
||||
!lagMonitor.satisfies(consistency)) {
|
||||
return ReplicaRoutingDecision.primary();
|
||||
}
|
||||
return ReplicaRoutingDecision.replica();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:failureTest --tests 'io.backend.skeleton.jpa.experimental.replica.ReadAfterWriteRoutingContractTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-read-replica:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReadConsistency.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyToken.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaRoutingDecision.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ConsistencyAwareDataSourceRouter.java' 'modules/jpa-experimental/jpa-read-replica/src/main/java/io/backend/skeleton/jpa/experimental/replica/ReplicaLagMonitor.java' 'modules/jpa-experimental/jpa-read-replica/src/failureTest/java/io/backend/skeleton/jpa/experimental/replica/ReadAfterWriteRoutingContractTest.java'
|
||||
git commit -m "feat: add experimental consistency aware replica routing"
|
||||
```
|
||||
|
||||
### Task 7: Jakarta Persistence 4.0 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-jpa4.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Published Jakarta Persistence 4.0 milestone/final artifact when available and the Stable contract suite.
|
||||
- Produces: A non-blocking compatibility report that does not alter Stable JPA 3.2 APIs.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run the Stable public API compilation and selected mapping contracts against JPA 4.
|
||||
- Record removed/changed APIs and provider support separately.
|
||||
- Do not publish JPA4 compiled artifacts under Stable coordinates.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class CompatibilityLaneDefinitionTest {
|
||||
@Test
|
||||
void jpaFourLaneIsExperimentalAndSeparateFromStablePublication() {
|
||||
assertThat(lane("jpa4").publicationEnabled()).isFalse();
|
||||
assertThat(lane("jpa4").supportLevel()).isEqualTo(EXPERIMENTAL);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityJpa4") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-core-api"))
|
||||
implementation(libs.jakarta.persistence.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.CompatibilityLaneDefinitionTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityJpa4/java/io/backend/skeleton/jpa/experimental/next/Jpa4CompatibilityTest.java' '.github/workflows/jpa-next-jpa4.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/CompatibilityLaneDefinitionTest.java'
|
||||
git commit -m "test: add jakarta persistence four compatibility lane"
|
||||
```
|
||||
|
||||
### Task 8: Hibernate ORM 8 Compatibility Lane 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java`
|
||||
- Create: `.github/workflows/jpa-next-hibernate8.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Hibernate ORM 8 milestone/final artifact and Stable Hibernate 7.4 regression suites.
|
||||
- Produces: Generated SQL, fetch pagination, statistics, batch and extension compatibility evidence.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Re-run collection fetch pagination, StatementInspector, Statistics, JSONB, Batch and StatelessSession contracts.
|
||||
- Record SQL and performance differences without weakening the 7.4 Stable gate.
|
||||
- Do not allow Hibernate 8 dependencies in Stable published modules.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```kotlin
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class HibernateCompatibilityPolicyTest {
|
||||
@Test
|
||||
void hibernateEightCannotReplaceStableProviderWithoutPromotion() {
|
||||
assertThat(policy.stableProvider()).isEqualTo("7.4");
|
||||
assertThat(policy.experimentalProviders()).contains("8");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```kotlin
|
||||
testing {
|
||||
suites {
|
||||
register<JvmTestSuite>("compatibilityHibernate8") {
|
||||
useJUnitJupiter()
|
||||
dependencies {
|
||||
implementation(project(":modules:jpa:jpa-testkit-postgresql"))
|
||||
implementation(libs.hibernate.orm.next)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.HibernateCompatibilityPolicyTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityHibernate8/java/io/backend/skeleton/jpa/experimental/next/Hibernate8CompatibilityTest.java' '.github/workflows/jpa-next-hibernate8.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/HibernateCompatibilityPolicyTest.java'
|
||||
git commit -m "test: add hibernate eight compatibility lane"
|
||||
```
|
||||
|
||||
### Task 9: PostgreSQL 19 Compatibility와 Stable 승격 Gate 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java`
|
||||
- Create: `docs/jpa/experimental-support-matrix.md`
|
||||
- Create: `docs/jpa/experimental-promotion-checklist.md`
|
||||
- Create: `.github/workflows/jpa-next-postgresql19.yml`
|
||||
- Modify: `modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts`
|
||||
- Test: `modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: PG19 image when GA, all Stable contracts, experimental security/failure/migration/performance reports.
|
||||
- Produces: A promotion decision that requires evidence rather than version availability alone.
|
||||
|
||||
**Implementation requirements:**
|
||||
- Run mapping, SQLSTATE, lock, batch, Flyway, plan and native extension contracts on PG19.
|
||||
- Promotion requires two supported patch runs and no unresolved semantic regression.
|
||||
- Multi-tenancy/replica promotion requires tenant leakage, failover, lag and pool-capacity evidence.
|
||||
- Update Stable support matrix only through a reviewed ADR.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
class ExperimentalPromotionGateTest {
|
||||
@Test
|
||||
void promotionRequiresAllEvidenceAndReviewedAdr() {
|
||||
var evidence = evidence().withCompatibility(true).withSecurity(true).withFailure(true)
|
||||
.withMigration(true).withPerformance(true).withReviewedAdr(false);
|
||||
assertThat(gate.evaluate(evidence)).isEqualTo(BLOCKED_MISSING_ADR);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify the failure**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
```
|
||||
|
||||
Expected: FAIL because the production type or behavior does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest complete production contract**
|
||||
|
||||
```java
|
||||
package io.backend.skeleton.jpa.experimental.next;
|
||||
|
||||
public final class ExperimentalPromotionGate {
|
||||
public PromotionDecision evaluate(PromotionEvidence evidence) {
|
||||
if (!evidence.allTechnicalGatesPassed()) return BLOCKED_TECHNICAL;
|
||||
if (!evidence.reviewedAdr()) return BLOCKED_MISSING_ADR;
|
||||
return ELIGIBLE_FOR_STABLE_REVIEW;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Implement every file and invariant listed under **Implementation requirements**; the snippet fixes the public names and central behavior rather than replacing those requirements.
|
||||
|
||||
- [ ] **Step 4: Run the focused test and the module test suite**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test --tests 'io.backend.skeleton.jpa.experimental.next.ExperimentalPromotionGateTest'
|
||||
./gradlew :modules:jpa-experimental:jpa-next-compatibility:test
|
||||
```
|
||||
|
||||
Expected: PASS with all assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit the independently reviewable change**
|
||||
|
||||
```bash
|
||||
git add 'modules/jpa-experimental/jpa-next-compatibility/src/compatibilityPostgresql19/java/io/backend/skeleton/jpa/experimental/next/PostgreSql19CompatibilityTest.java' 'docs/jpa/experimental-support-matrix.md' 'docs/jpa/experimental-promotion-checklist.md' '.github/workflows/jpa-next-postgresql19.yml' 'modules/jpa-experimental/jpa-next-compatibility/build.gradle.kts' 'modules/jpa-experimental/jpa-next-compatibility/src/test/java/io/backend/skeleton/jpa/experimental/next/ExperimentalPromotionGateTest.java'
|
||||
git commit -m "docs: add jpa experimental promotion gates"
|
||||
```
|
||||
## 2. Experimental 완료 조건
|
||||
|
||||
```text
|
||||
Stable starter가 Experimental module에 의존하지 않는다.
|
||||
Tenant context 누락과 connection reuse에서 fail-closed다.
|
||||
RLS runtime role이 policy를 bypass하지 못한다.
|
||||
Schema/database tenant migration과 pool capacity가 bounded다.
|
||||
Replica routing이 read-after-write와 lock query를 primary에 고정한다.
|
||||
JPA4/Hibernate8/PG19 lane이 Stable artifacts를 변경하지 않는다.
|
||||
승격은 ADR와 compatibility/security/failure/migration/performance 증거를 요구한다.
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
# infra/jpa/postgres
|
||||
|
||||
Server-side settings the JPA platform's contracts assume, and why each one matters.
|
||||
|
||||
The contract suites start their own containers through
|
||||
`dev.caskeleton.adapter.outbound.persistence.testkit.postgresql.PostgreSqlContainerFactory`, so
|
||||
nothing here is needed to run them. This directory records what a *deployed* PostgreSQL has to look
|
||||
like for the platform's guarantees to hold, because several of them are server settings rather than
|
||||
application code.
|
||||
|
||||
## Settings the platform depends on
|
||||
|
||||
| Setting | Why the platform cares |
|
||||
|---|---|
|
||||
| `statement_timeout` | The last bound on a runaway statement. The platform sets transaction timeouts, but a single statement inside a transaction can still outlive the request that asked for it. |
|
||||
| `idle_in_transaction_session_timeout` | An idle open transaction holds its locks and its snapshot indefinitely, which blocks writers and prevents vacuum. This is what turns "someone left a transaction open" into a bounded incident. |
|
||||
| `lock_timeout` | A cluster-wide floor under the per-request lock bounds in `PostgreSqlLockOptions`. |
|
||||
| `max_connections` | The number `app.jpa-platform.datasource.maximum-pool-size` must be sized against — across every instance, and allowing for `REQUIRES_NEW` taking a second connection while pinning the first. |
|
||||
| `default_transaction_isolation` | Left at `read committed`. The platform selects `repeatable read` or `serializable` per transaction profile; changing the default would silently change every transaction that did not ask. |
|
||||
|
||||
## Suggested baseline
|
||||
|
||||
```conf
|
||||
statement_timeout = '30s'
|
||||
idle_in_transaction_session_timeout = '60s'
|
||||
lock_timeout = '10s'
|
||||
default_transaction_isolation = 'read committed'
|
||||
```
|
||||
|
||||
These are starting points, not recommendations: the right `statement_timeout` depends on the
|
||||
slowest legitimate query in the application, and setting it below that turns a working report into
|
||||
an error. Measure before pinning.
|
||||
|
||||
## What is deliberately not configured here
|
||||
|
||||
- **Roles.** Credential separation lives in [`../roles/runtime-roles.sql`](../roles/runtime-roles.sql).
|
||||
- **Schema.** Flyway owns it (design §31). Nothing in this directory creates a table.
|
||||
- **Extensions.** The platform's PostgreSQL support — JSONB, arrays, ranges, `SKIP LOCKED`,
|
||||
`ON CONFLICT` — is all core PostgreSQL. No extension is required, and none should be assumed.
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Runtime / migration / admin credential separation for the JPA persistence platform.
|
||||
-- Design §36; enforced at startup by PostgreSqlRuntimeRoleVerifier + DatabaseRolePolicy.
|
||||
--
|
||||
-- The separation is what makes "Flyway owns schema change" enforceable rather than aspirational.
|
||||
-- If the application's own credential cannot execute DDL, then no code path, no library, and no
|
||||
-- injected statement can alter the schema at runtime — regardless of what the application intended.
|
||||
--
|
||||
-- Run as a superuser once per database. Replace the placeholder passwords with values from the
|
||||
-- deployment's secret store; they are intentionally not committed.
|
||||
|
||||
-- 1. The schema the application owns. Owned by the migration role, not the runtime role.
|
||||
create schema if not exists app authorization app_migration;
|
||||
|
||||
-- 2. Roles.
|
||||
-- app_migration : owns the schema, applies Flyway migrations. DDL.
|
||||
-- app_runtime : the application's credential. DML only, no DDL, no CREATE.
|
||||
-- app_admin : J4 operations — COPY, backfill, maintenance. Never used by request paths.
|
||||
create role app_migration login password 'REPLACE_FROM_SECRET_STORE';
|
||||
create role app_runtime login password 'REPLACE_FROM_SECRET_STORE';
|
||||
create role app_admin login password 'REPLACE_FROM_SECRET_STORE';
|
||||
|
||||
-- 3. Revoke the PUBLIC grants that make the checks in DatabaseRolePolicy necessary.
|
||||
-- Before PostgreSQL 15, PUBLIC held CREATE on the public schema — which is how an unprivileged
|
||||
-- role ends up able to plant an object that shadows a real one through search_path.
|
||||
revoke all on database current_database() from public;
|
||||
revoke create on schema public from public;
|
||||
|
||||
-- 4. Runtime: read and write rows in the application schema. Nothing else.
|
||||
grant connect on database current_database() to app_runtime;
|
||||
grant usage on schema app to app_runtime;
|
||||
grant select, insert, update, delete on all tables in schema app to app_runtime;
|
||||
grant usage, select on all sequences in schema app to app_runtime;
|
||||
|
||||
-- Tables created by future migrations must inherit the same grants, or the first deployment after
|
||||
-- a new table silently fails at runtime with a permission error.
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant select, insert, update, delete on tables to app_runtime;
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant usage, select on sequences to app_runtime;
|
||||
|
||||
-- 5. Explicitly deny the two privileges the startup verifier checks for.
|
||||
revoke create on schema app from app_runtime;
|
||||
revoke create on database current_database() from app_runtime;
|
||||
|
||||
-- 6. Admin: bulk operations under an audited identity, still without schema ownership.
|
||||
grant connect on database current_database() to app_admin;
|
||||
grant usage on schema app to app_admin;
|
||||
grant select, insert, update, delete on all tables in schema app to app_admin;
|
||||
alter default privileges for role app_migration in schema app
|
||||
grant select, insert, update, delete on tables to app_admin;
|
||||
|
||||
-- 7. Pin the runtime search_path so an unqualified name cannot resolve anywhere unexpected.
|
||||
alter role app_runtime set search_path = app, pg_catalog;
|
||||
alter role app_admin set search_path = app, pg_catalog;
|
||||
@@ -0,0 +1,43 @@
|
||||
# Commit-ambiguity failure injection for the JPA platform (design §39).
|
||||
#
|
||||
# The suite needs a proxy rather than a kill switch because the scenario that matters cannot be
|
||||
# produced any other way. Stopping the container, killing the process, or closing the client socket
|
||||
# all break *before* the server commits — the easy case, where the transaction rolled back and the
|
||||
# use case may simply be re-run. The hard case is a commit the server completed whose
|
||||
# acknowledgement never came back, and it only exists if you can cut the return path while leaving
|
||||
# the forward path intact.
|
||||
#
|
||||
# That is what CommitAmbiguityProxy does with a downstream-only toxic, and it is the one scenario
|
||||
# that distinguishes a platform that reports completion-unknown from one that retries a write which
|
||||
# already succeeded.
|
||||
#
|
||||
# Ordinary contract runs use Testcontainers and do not need this file; it exists for reproducing a
|
||||
# failure scenario by hand.
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_DB: jpa_failure
|
||||
POSTGRES_USER: jpa_failure
|
||||
POSTGRES_PASSWORD: jpa_failure
|
||||
# No published port: the suite must reach PostgreSQL only through the proxy, or the injected
|
||||
# fault can be bypassed by connecting directly and the test passes without testing anything.
|
||||
expose:
|
||||
- "5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U jpa_failure -d jpa_failure"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.11.0
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# 8474 is the control API the suite drives; 8666 is the proxied PostgreSQL port.
|
||||
- "8474:8474"
|
||||
- "8666:8666"
|
||||
command: ["-host", "0.0.0.0"]
|
||||
@@ -0,0 +1,94 @@
|
||||
# Toxiproxy fault injection for the notification delivery platform.
|
||||
#
|
||||
# Scope, stated up front: this is the *nightly and release* fault suite, not the PR gate. The PR
|
||||
# suite runs against a loopback socket harness in-process — deterministic, no Docker, no provider
|
||||
# sandbox — because a gate that needs infrastructure is a gate people learn to skip. What lives
|
||||
# here are the faults that harness cannot produce: real TCP behaviour under latency, bandwidth
|
||||
# starvation, and connection resets at a point the JVM's own socket layer decides.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f infra/notification/toxiproxy/docker-compose.yml up -d
|
||||
# ./gradlew :adapter:outbound:notification:test -Dnotification.faultProxy=http://127.0.0.1:8474
|
||||
#
|
||||
# The proxies below front *stub* upstreams, never a provider's real API. Pointing a toxic proxy at
|
||||
# a live provider sends real notifications to real people from a test run, and adds a rate-limit
|
||||
# incident on an account the team shares.
|
||||
services:
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.11.0
|
||||
container_name: notification-toxiproxy
|
||||
ports:
|
||||
- "8474:8474" # control API
|
||||
- "18081:18081" # -> ses-stub
|
||||
- "18082:18082" # -> twilio-stub
|
||||
- "18083:18083" # -> push-stub (APNs / FCM / Web Push)
|
||||
networks: [notification-fault]
|
||||
healthcheck:
|
||||
test: ["CMD", "/toxiproxy-cli", "list"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
# Deterministic upstreams. Each returns the provider's success shape and nothing else; the
|
||||
# interesting behaviour is injected by the proxy in front of it, not by the stub.
|
||||
ses-stub:
|
||||
image: mendhak/http-https-echo:35
|
||||
environment:
|
||||
HTTP_PORT: "8080"
|
||||
networks: [notification-fault]
|
||||
|
||||
twilio-stub:
|
||||
image: mendhak/http-https-echo:35
|
||||
environment:
|
||||
HTTP_PORT: "8080"
|
||||
networks: [notification-fault]
|
||||
|
||||
push-stub:
|
||||
image: mendhak/http-https-echo:35
|
||||
environment:
|
||||
HTTP_PORT: "8080"
|
||||
networks: [notification-fault]
|
||||
|
||||
# Creates the proxies and the toxics once the control API is up. Kept as a job rather than a
|
||||
# README step so the topology is reproducible and reviewable rather than typed from memory.
|
||||
provision:
|
||||
image: ghcr.io/shopify/toxiproxy:2.11.0
|
||||
depends_on:
|
||||
toxiproxy:
|
||||
condition: service_healthy
|
||||
networks: [notification-fault]
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
CLI="/toxiproxy-cli -h toxiproxy:8474"
|
||||
$$CLI create -l 0.0.0.0:18081 -u ses-stub:8080 ses
|
||||
$$CLI create -l 0.0.0.0:18082 -u twilio-stub:8080 twilio
|
||||
$$CLI create -l 0.0.0.0:18083 -u push-stub:8080 push
|
||||
|
||||
# Response loss after the request was committed: the provider received and acted on the
|
||||
# message, and the answer never came back. This is the AMBIGUOUS case, and it is the one
|
||||
# fault no provider's documentation describes.
|
||||
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 ses
|
||||
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 twilio
|
||||
$$CLI toxic add -t timeout -a timeout=0 -n response_loss --downstream --toxicity 0 push
|
||||
|
||||
# Latency past the adapter's own timeout, to prove the timeout is the adapter's decision
|
||||
# rather than the socket's.
|
||||
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 ses
|
||||
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 twilio
|
||||
$$CLI toxic add -t latency -a latency=8000 -n slow --toxicity 0 push
|
||||
|
||||
# Partial write: the connection dies mid-body. Distinct from response loss, because the
|
||||
# provider never got a complete request and the attempt is genuinely retryable.
|
||||
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 ses
|
||||
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 twilio
|
||||
$$CLI toxic add -t limit_data -a bytes=64 -n partial_write --upstream --toxicity 0 push
|
||||
|
||||
echo "proxies ready; toxics are registered at toxicity=0 and enabled per test"
|
||||
$$CLI list
|
||||
|
||||
networks:
|
||||
notification-fault:
|
||||
driver: bridge
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The MongoDB Advanced capability gate (advanced plan Task 15).
|
||||
#
|
||||
# Advanced capabilities are opt-in modules. This script verifies the contracts that can be verified
|
||||
# without provider infrastructure, and then reports -- explicitly -- which promotion evidence it
|
||||
# could NOT produce.
|
||||
#
|
||||
# Required promotion categories (MongoAdvancedPromotionEvidence.REQUIRED):
|
||||
#
|
||||
# stable-platform, actual-topology, security, migration, failure, runbook
|
||||
#
|
||||
# `actual-topology` is the one that cannot be substituted. A container gives a functional pass for
|
||||
# sharding, search, vector and encryption while exercising none of the behaviour that makes them
|
||||
# Advanced rather than Stable: real shard distribution, a real analyzer, a real KMS. Atlas Local is
|
||||
# a pull-request convenience and is not release evidence -- see
|
||||
# MongoAtlasCapabilityContractSuite.Environment.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/verify-mongodb-advanced.sh
|
||||
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-advanced.sh
|
||||
# MONGODB_SHARDED_URI=... MONGODB_ATLAS_URI=... MONGODB_KMS=... bash scripts/verify-mongodb-advanced.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GRADLE_DIR="${REPO_ROOT}/src"
|
||||
MODULE=':adapter:outbound:persistence-mongo'
|
||||
GRADLE=(./gradlew --console=plain)
|
||||
|
||||
FAILED=()
|
||||
MISSING_EVIDENCE=()
|
||||
|
||||
echo "MongoDB Advanced capability gate"
|
||||
echo "repository: ${REPO_ROOT}"
|
||||
|
||||
# --- stable-platform -------------------------------------------------------------------------
|
||||
# An Advanced capability cannot be promoted over a Stable platform that does not itself pass.
|
||||
echo ""
|
||||
echo "=== [stable-platform] Stable gate"
|
||||
if bash "${REPO_ROOT}/scripts/verify-mongodb-platform.sh"; then
|
||||
echo "stable-platform: supplied"
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "stable-platform: INCOMPLETE (the Stable gate skipped lanes)"
|
||||
MISSING_EVIDENCE+=("stable-platform (Stable gate incomplete)")
|
||||
else
|
||||
FAILED+=("stable-platform")
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- failure + runbook (hermetic) -------------------------------------------------------------
|
||||
# Every Advanced refusal contract: disabled capability refuses construction, CSFLE/QE cannot share a
|
||||
# collection, QE substring/prefix/suffix unsupported on 8.0, a non-READY search index cannot serve,
|
||||
# undeclared scatter-gather is rejected, a dimension mismatch is refused.
|
||||
echo ""
|
||||
echo "=== [failure] Advanced contract tests"
|
||||
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*advanced*'); then
|
||||
echo "failure: supplied"
|
||||
else
|
||||
FAILED+=("failure")
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== [runbook] capability documentation"
|
||||
for doc in sharding time-series encryption search-vector multi-tenancy gridfs-migration; do
|
||||
path="${REPO_ROOT}/docs/mongodb/advanced/${doc}.md"
|
||||
if [[ -f "${path}" ]]; then
|
||||
echo " + ${doc}.md"
|
||||
else
|
||||
echo " - ${doc}.md MISSING"
|
||||
FAILED+=("runbook:${doc}")
|
||||
fi
|
||||
done
|
||||
if [[ ! -f "${REPO_ROOT}/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md" ]]; then
|
||||
echo " - ADR-MONGO-ADV-001 MISSING"
|
||||
FAILED+=("runbook:ADR-MONGO-ADV-001")
|
||||
fi
|
||||
|
||||
# --- actual-topology -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== [actual-topology] provider environments"
|
||||
if [[ -n "${MONGODB_SHARDED_URI:-}" ]]; then
|
||||
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*Shard*' \
|
||||
-Dmongodb.sharded.uri="${MONGODB_SHARDED_URI}"); then
|
||||
echo "actual-topology(sharded): supplied"
|
||||
else
|
||||
FAILED+=("actual-topology:sharded")
|
||||
fi
|
||||
else
|
||||
echo "actual-topology(sharded): no MONGODB_SHARDED_URI"
|
||||
MISSING_EVIDENCE+=("actual-topology: sharded cluster")
|
||||
fi
|
||||
|
||||
if [[ -n "${MONGODB_ATLAS_URI:-}" ]]; then
|
||||
echo "actual-topology(search/vector): MONGODB_ATLAS_URI present"
|
||||
else
|
||||
echo "actual-topology(search/vector): no MONGODB_ATLAS_URI"
|
||||
MISSING_EVIDENCE+=("actual-topology: search/vector on the actual target deployment")
|
||||
fi
|
||||
|
||||
if [[ -n "${MONGODB_KMS:-}" ]]; then
|
||||
echo "actual-topology(encryption): MONGODB_KMS present"
|
||||
else
|
||||
echo "actual-topology(encryption): no MONGODB_KMS"
|
||||
MISSING_EVIDENCE+=("actual-topology: real KMS and key vault")
|
||||
fi
|
||||
|
||||
# --- security + migration ---------------------------------------------------------------------
|
||||
# These are review artefacts, not test runs: a role review and a documented migration path per
|
||||
# capability. The gate records that they are outstanding rather than pretending a green test covers
|
||||
# them.
|
||||
MISSING_EVIDENCE+=("security: per-capability privilege review sign-off")
|
||||
MISSING_EVIDENCE+=("migration: per-capability migration path sign-off")
|
||||
|
||||
# --- Report ------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "---------------------------------------------------------------"
|
||||
if (( ${#FAILED[@]} > 0 )); then
|
||||
echo "ADVANCED GATE: FAILED"
|
||||
for entry in "${FAILED[@]}"; do echo " - ${entry}"; done
|
||||
echo "---------------------------------------------------------------"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "verifiable contracts: PASSED"
|
||||
if (( ${#MISSING_EVIDENCE[@]} > 0 )); then
|
||||
echo ""
|
||||
echo "ADVANCED GATE: NOT PROMOTABLE -- missing evidence:"
|
||||
for entry in "${MISSING_EVIDENCE[@]}"; do echo " ~ ${entry}"; done
|
||||
echo ""
|
||||
echo "A capability stays opt-in until every category in"
|
||||
echo "MongoAdvancedPromotionEvidence.REQUIRED is supplied. See"
|
||||
echo "docs/adr/ADR-MONGO-ADV-001-capability-promotion.md."
|
||||
echo "---------------------------------------------------------------"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "ADVANCED GATE: PASSED"
|
||||
echo "---------------------------------------------------------------"
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The MongoDB Stable release gate (design §30, plan Task 50).
|
||||
#
|
||||
# Runs every lane that produces one of the Stable evidence categories:
|
||||
#
|
||||
# mapping, transaction, migration, change-stream, security,
|
||||
# failover, performance, compatibility
|
||||
#
|
||||
# The gate exists because "the test suite is green" and "every category has evidence" are different
|
||||
# statements. A suite passes happily with a whole lane skipped -- no Docker, a disabled tag, a
|
||||
# renamed task -- and a release built on that suite has no failover or compatibility evidence at
|
||||
# all, silently. Each lane below is therefore run by name, and a skipped lane is reported as skipped
|
||||
# rather than counted as passed.
|
||||
#
|
||||
# Advanced capabilities are NOT promoted or transitively included here. See
|
||||
# scripts/verify-mongodb-advanced.sh.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/verify-mongodb-platform.sh # hermetic lanes only
|
||||
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-platform.sh # + container lanes
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GRADLE_DIR="${REPO_ROOT}/src"
|
||||
MODULE=':adapter:outbound:persistence-mongo'
|
||||
GRADLE=(./gradlew --console=plain)
|
||||
|
||||
RESULTS_DIR="${GRADLE_DIR}/adapter/outbound/persistence-mongo/build/test-results"
|
||||
|
||||
RAN=()
|
||||
SKIPPED=()
|
||||
FAILED=()
|
||||
|
||||
# Counts the tests a lane actually executed, from its JUnit XML.
|
||||
#
|
||||
# A lane whose filter matches nothing passes: Gradle runs the task, discovers no tests, and reports
|
||||
# success. That is the failure mode this whole gate exists to prevent -- an empty lane is not
|
||||
# evidence, it is the absence of evidence wearing a green tick. Any lane that reports zero executed
|
||||
# tests is treated as a failure.
|
||||
executed_tests() {
|
||||
local task="$1"
|
||||
local dir="${RESULTS_DIR}/${task}"
|
||||
[[ -d "${dir}" ]] || { echo 0; return; }
|
||||
local total=0
|
||||
shopt -s nullglob
|
||||
for xml in "${dir}"/*.xml; do
|
||||
local count
|
||||
count=$(sed -n 's/.*<testsuite[^>]* tests="\([0-9]*\)".*/\1/p' "${xml}" | head -1)
|
||||
total=$(( total + ${count:-0} ))
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "${total}"
|
||||
}
|
||||
|
||||
run_lane() {
|
||||
local category="$1"
|
||||
local task="$2"
|
||||
shift 2
|
||||
echo ""
|
||||
echo "=== [${category}] ${task}"
|
||||
if ! (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:${task}" "$@"); then
|
||||
FAILED+=("${category}:${task}")
|
||||
return
|
||||
fi
|
||||
# `check` aggregates several tasks and has no results directory of its own.
|
||||
if [[ "${task}" == "check" ]]; then
|
||||
RAN+=("${category}:${task}")
|
||||
return
|
||||
fi
|
||||
local executed
|
||||
executed=$(executed_tests "${task}")
|
||||
if (( executed == 0 )); then
|
||||
echo "!!! ${task} passed without executing a single test — the lane's filter matches nothing,"
|
||||
echo "!!! so the '${category}' evidence category is empty."
|
||||
FAILED+=("${category}:${task} (0 tests executed)")
|
||||
else
|
||||
RAN+=("${category}:${task} (${executed} tests)")
|
||||
fi
|
||||
}
|
||||
|
||||
skip_lane() {
|
||||
local category="$1"
|
||||
local task="$2"
|
||||
local reason="$3"
|
||||
echo ""
|
||||
echo "=== [${category}] ${task} -- SKIPPED (${reason})"
|
||||
SKIPPED+=("${category}:${task} (${reason})")
|
||||
}
|
||||
|
||||
docker_available() {
|
||||
[[ "${MONGODB_DOCKER:-0}" == "1" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "MongoDB Stable release gate"
|
||||
echo "repository: ${REPO_ROOT}"
|
||||
|
||||
# --- Always-on lanes -------------------------------------------------------------------------
|
||||
# Static analysis, architecture boundaries, unit and hermetic contract tests. These produce the
|
||||
# mapping, transaction, migration, change-stream and security evidence that does not need a server.
|
||||
run_lane "static-analysis" "check" -x "mongoStableContractTest"
|
||||
run_lane "mapping+transaction+migration+change-stream+security" "mongoStableContractTest"
|
||||
|
||||
# --- Container lanes -------------------------------------------------------------------------
|
||||
# A lane that needs Docker inside `check` teaches people to skip `check`, so these are opt-in --
|
||||
# but opting out is recorded, not silent.
|
||||
if docker_available; then
|
||||
run_lane "compatibility" "mongoCompatibilityTest"
|
||||
run_lane "migration" "mongoMigrationTest"
|
||||
run_lane "security" "mongoSecurityIntegrationTest"
|
||||
run_lane "failover" "mongoReplicaSetTest"
|
||||
run_lane "failover" "mongoFailoverTest"
|
||||
run_lane "performance" "mongoPerformanceTest"
|
||||
else
|
||||
reason="MONGODB_DOCKER!=1 or Docker unavailable"
|
||||
skip_lane "compatibility" "mongoCompatibilityTest" "${reason}"
|
||||
skip_lane "migration" "mongoMigrationTest" "${reason}"
|
||||
skip_lane "security" "mongoSecurityIntegrationTest" "${reason}"
|
||||
skip_lane "failover" "mongoReplicaSetTest" "${reason}"
|
||||
skip_lane "failover" "mongoFailoverTest" "${reason}"
|
||||
skip_lane "performance" "mongoPerformanceTest" "${reason}"
|
||||
fi
|
||||
|
||||
# --- Architecture-wide gates -----------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== [architecture] repository-wide verification"
|
||||
if (cd "${GRADLE_DIR}" \
|
||||
&& "${GRADLE[@]}" verifyCleanArchitectureDependencies \
|
||||
&& "${GRADLE[@]}" :app-bootstrap:test --tests '*CleanArchitectureTest'); then
|
||||
RAN+=("architecture:repository-wide")
|
||||
else
|
||||
FAILED+=("architecture:repository-wide")
|
||||
fi
|
||||
|
||||
# --- Report ------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "---------------------------------------------------------------"
|
||||
echo "ran: ${#RAN[@]}"
|
||||
for entry in "${RAN[@]:-}"; do [[ -n "${entry}" ]] && echo " + ${entry}"; done
|
||||
echo "skipped: ${#SKIPPED[@]}"
|
||||
for entry in "${SKIPPED[@]:-}"; do [[ -n "${entry}" ]] && echo " ~ ${entry}"; done
|
||||
echo "failed: ${#FAILED[@]}"
|
||||
for entry in "${FAILED[@]:-}"; do [[ -n "${entry}" ]] && echo " - ${entry}"; done
|
||||
echo "---------------------------------------------------------------"
|
||||
|
||||
if (( ${#FAILED[@]} > 0 )); then
|
||||
echo "STABLE GATE: FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( ${#SKIPPED[@]} > 0 )); then
|
||||
echo "STABLE GATE: INCOMPLETE -- lanes above were not run, so their evidence categories are absent."
|
||||
echo "A release requires every category. Re-run with MONGODB_DOCKER=1 on a host with Docker."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "STABLE GATE: PASSED -- every evidence category produced."
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Security chain for the provider callback endpoints.
|
||||
*
|
||||
* <p>Callbacks authenticate with a provider signature, not with a user session, so they get their
|
||||
* own chain: CSRF and session creation are off, and the ordinary user chain never sees them.
|
||||
* Putting them on the user chain would either break every provider or force the user chain to be
|
||||
* permissive.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform.callbacks",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public class CallbackMvcSecurityConfiguration {
|
||||
|
||||
/** Dedicated, ordered-first chain for the callback path. */
|
||||
@Bean
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public SecurityFilterChain notificationCallbackFilterChain(HttpSecurity http) throws Exception {
|
||||
return http.securityMatcher("/internal/notification/callbacks/**")
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(
|
||||
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Builds the transport-neutral callback request.
|
||||
*
|
||||
* <p>Both the servlet and reactive endpoints use this, so signature verification sees exactly the
|
||||
* same canonical bytes and URL regardless of which stack received the call.
|
||||
*/
|
||||
public final class CallbackRequestFactory {
|
||||
|
||||
private final ExternalRequestUrlResolver urlResolver;
|
||||
private final Clock clock;
|
||||
|
||||
public CallbackRequestFactory(ExternalRequestUrlResolver urlResolver, Clock clock) {
|
||||
this.urlResolver = Objects.requireNonNull(urlResolver, "urlResolver");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
/** Build from a servlet request plus the already-read raw body. */
|
||||
public CallbackRequest create(
|
||||
String provider, String profile, HttpServletRequest request, byte[] body) {
|
||||
Objects.requireNonNull(provider, "provider");
|
||||
Objects.requireNonNull(profile, "profile");
|
||||
Objects.requireNonNull(request, "request");
|
||||
Objects.requireNonNull(body, "body");
|
||||
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
for (String name : Collections.list(request.getHeaderNames())) {
|
||||
headers.put(name, new ArrayList<>(Collections.list(request.getHeaders(name))));
|
||||
}
|
||||
|
||||
return new CallbackRequest(
|
||||
new ProviderId(provider),
|
||||
new ProviderProfileId(profile),
|
||||
urlResolver.resolve(request),
|
||||
request.getMethod(),
|
||||
Optional.ofNullable(request.getContentType()),
|
||||
headers,
|
||||
body,
|
||||
clock.instant());
|
||||
}
|
||||
|
||||
/** Build from an already-resolved external URL, used by the reactive endpoint. */
|
||||
public CallbackRequest create(
|
||||
String provider,
|
||||
String profile,
|
||||
String externalUrl,
|
||||
String method,
|
||||
Optional<String> contentType,
|
||||
Map<String, List<String>> headers,
|
||||
byte[] body) {
|
||||
return new CallbackRequest(
|
||||
new ProviderId(provider),
|
||||
new ProviderProfileId(profile),
|
||||
externalUrl,
|
||||
method,
|
||||
contentType,
|
||||
headers,
|
||||
body,
|
||||
clock.instant());
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Reconstructs the URL the provider actually called.
|
||||
*
|
||||
* <p>Several providers sign the request URL, so getting this wrong turns every valid webhook into a
|
||||
* signature failure. Forwarded headers are only honoured when the immediate peer is a configured
|
||||
* trusted proxy: trusting them unconditionally would let any caller choose the URL that gets
|
||||
* verified, which defeats the signature entirely.
|
||||
*/
|
||||
public final class ExternalRequestUrlResolver {
|
||||
|
||||
private final Set<String> trustedProxies;
|
||||
|
||||
public ExternalRequestUrlResolver(Set<String> trustedProxies) {
|
||||
this.trustedProxies = Set.copyOf(Objects.requireNonNull(trustedProxies, "trustedProxies"));
|
||||
}
|
||||
|
||||
/** External URL of a request. */
|
||||
public String resolve(HttpServletRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
String scheme = request.getScheme();
|
||||
String host = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
|
||||
if (trustedProxies.contains(request.getRemoteAddr())) {
|
||||
String forwarded = request.getHeader("Forwarded");
|
||||
if (forwarded != null) {
|
||||
for (String element : forwarded.split(";", -1)) {
|
||||
String trimmed = element.trim().toLowerCase(Locale.ROOT);
|
||||
if (trimmed.startsWith("proto=")) {
|
||||
scheme = trimmed.substring("proto=".length());
|
||||
} else if (trimmed.startsWith("host=")) {
|
||||
host = element.trim().substring("host=".length());
|
||||
port = -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String protoHeader = request.getHeader("X-Forwarded-Proto");
|
||||
String hostHeader = request.getHeader("X-Forwarded-Host");
|
||||
if (protoHeader != null) {
|
||||
scheme = protoHeader;
|
||||
}
|
||||
if (hostHeader != null) {
|
||||
host = hostHeader;
|
||||
port = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder url = new StringBuilder(scheme).append("://").append(host);
|
||||
boolean defaultPort =
|
||||
port < 0
|
||||
|| ("https".equalsIgnoreCase(scheme) && port == 443)
|
||||
|| ("http".equalsIgnoreCase(scheme) && port == 80);
|
||||
if (!defaultPort) {
|
||||
url.append(':').append(port);
|
||||
}
|
||||
url.append(request.getRequestURI());
|
||||
String query = request.getQueryString();
|
||||
if (query != null && !query.isBlank()) {
|
||||
url.append('?').append(query);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Objects;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Servlet callback endpoint.
|
||||
*
|
||||
* <p>The body arrives as raw bytes, never as a parsed form. Providers sign the exact octets, and
|
||||
* letting the container parse and re-encode them is the most common cause of a valid webhook
|
||||
* failing verification.
|
||||
*
|
||||
* <p>The response is a bare {@code 204}: no body, no diagnostics. A provider only needs to know the
|
||||
* event is recorded, and an error body would be a channel for leaking what the platform knows.
|
||||
*
|
||||
* <p>Registered only in a servlet application and only when callbacks are enabled. An annotated
|
||||
* controller is also honoured by WebFlux, so without the servlet condition a reactive deployment
|
||||
* would map both this and the functional router onto the same path — and a provider signature would
|
||||
* then be verified twice against two different canonical URLs.
|
||||
*/
|
||||
@RestController
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform.callbacks",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
@RequestMapping("/internal/notification/callbacks")
|
||||
public final class NotificationCallbackMvcController {
|
||||
|
||||
/** Hard body ceiling applied before any provider adapter is consulted. */
|
||||
public static final int MAX_BODY_BYTES = 65_536;
|
||||
|
||||
private final ProviderCallbackIngestionService ingestion;
|
||||
private final CallbackRequestFactory requestFactory;
|
||||
|
||||
public NotificationCallbackMvcController(
|
||||
ProviderCallbackIngestionService ingestion, CallbackRequestFactory requestFactory) {
|
||||
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
|
||||
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
|
||||
}
|
||||
|
||||
/** Receive one provider callback. */
|
||||
@PostMapping(path = "/{provider}/{profile}")
|
||||
public ResponseEntity<Void> callback(
|
||||
@PathVariable String provider,
|
||||
@PathVariable String profile,
|
||||
HttpServletRequest request,
|
||||
@RequestBody byte[] body) {
|
||||
if (body.length > MAX_BODY_BYTES) {
|
||||
return ResponseEntity.status(HttpStatus.CONTENT_TOO_LARGE).build();
|
||||
}
|
||||
// A duplicate answers 204 exactly like a first delivery. The provider did its job either way,
|
||||
// and any other status would make it retry an event that is already recorded.
|
||||
ingestion.ingest(requestFactory.create(provider, profile, request, body));
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** A rejected callback never reveals why beyond the status code. */
|
||||
@ExceptionHandler(CallbackValidationException.class)
|
||||
public ResponseEntity<Void> onValidationFailure(CallbackValidationException failure) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.core.io.buffer.DataBufferUtils;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* Reads the raw body with a hard ceiling and no buffer leaks.
|
||||
*
|
||||
* <p>Every {@link DataBuffer} is released on success, on error and on cancellation. A reactive
|
||||
* endpoint that forgets the cancellation path leaks native memory exactly when it is under the load
|
||||
* that caused the cancellation.
|
||||
*/
|
||||
public final class BoundedCallbackBodyReader {
|
||||
|
||||
private final int maxBytes;
|
||||
|
||||
public BoundedCallbackBodyReader(int maxBytes) {
|
||||
if (maxBytes < 1) {
|
||||
throw new IllegalArgumentException("maxBytes");
|
||||
}
|
||||
this.maxBytes = maxBytes;
|
||||
}
|
||||
|
||||
/** Read at most the configured number of bytes. */
|
||||
public Mono<byte[]> read(ServerRequest request) {
|
||||
Objects.requireNonNull(request, "request");
|
||||
return DataBufferUtils.join(request.bodyToFlux(DataBuffer.class), maxBytes)
|
||||
.map(
|
||||
buffer -> {
|
||||
try {
|
||||
byte[] bytes = new byte[buffer.readableByteCount()];
|
||||
buffer.read(bytes);
|
||||
return bytes;
|
||||
} finally {
|
||||
DataBufferUtils.release(buffer);
|
||||
}
|
||||
})
|
||||
.defaultIfEmpty(new byte[0]);
|
||||
}
|
||||
|
||||
/** Configured ceiling. */
|
||||
public int maxBytes() {
|
||||
return maxBytes;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
/**
|
||||
* Registers the reactive callback transport, and only it.
|
||||
*
|
||||
* <p>This configuration is {@code REACTIVE}-only and the servlet controller carries the matching
|
||||
* {@code SERVLET} condition, so exactly one of the two is ever registered — by construction rather
|
||||
* than by convention. Both on the same path would mean a provider signature is verified twice
|
||||
* against two different canonical URLs, a failure that shows up only in production and only for
|
||||
* signed providers, and reads like a credential problem.
|
||||
*
|
||||
* <p>The body ceiling is read as a property rather than through the platform settings type: that
|
||||
* type belongs to the outbound notification adapter, which this inbound adapter must not depend on.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform.callbacks",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public class CallbackWebFluxConfiguration {
|
||||
|
||||
/** Bounded body reader; the ceiling applies before any provider adapter is consulted. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public BoundedCallbackBodyReader notificationCallbackBodyReader(
|
||||
@Value("${ca-skeleton.notification.platform.callbacks.max-body-bytes:65536}") int maxBytes) {
|
||||
return new BoundedCallbackBodyReader(maxBytes);
|
||||
}
|
||||
|
||||
/** Reactive handler. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public NotificationCallbackWebFluxHandler notificationCallbackWebFluxHandler(
|
||||
ProviderCallbackIngestionService ingestion,
|
||||
CallbackRequestFactory requestFactory,
|
||||
BoundedCallbackBodyReader bodyReader) {
|
||||
return new NotificationCallbackWebFluxHandler(ingestion, requestFactory, bodyReader);
|
||||
}
|
||||
|
||||
/** Functional route for the callback path. */
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> notificationCallbackRoutes(
|
||||
NotificationCallbackWebFluxHandler handler) {
|
||||
return new CallbackWebFluxRouter(handler).routes();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
|
||||
|
||||
import java.util.Objects;
|
||||
import org.springframework.web.reactive.function.server.RequestPredicates;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
||||
/**
|
||||
* Routes the reactive callback path.
|
||||
*
|
||||
* <p>Kept separate from the servlet controller so that only one of the two is ever registered; two
|
||||
* endpoints on the same path would mean a provider's signature is verified twice against two
|
||||
* different canonical URLs.
|
||||
*/
|
||||
public final class CallbackWebFluxRouter {
|
||||
|
||||
private final NotificationCallbackWebFluxHandler handler;
|
||||
|
||||
public CallbackWebFluxRouter(NotificationCallbackWebFluxHandler handler) {
|
||||
this.handler = Objects.requireNonNull(handler, "handler");
|
||||
}
|
||||
|
||||
/** Router function for the callback path. */
|
||||
public RouterFunction<ServerResponse> routes() {
|
||||
return RouterFunctions.route(
|
||||
RequestPredicates.POST("/internal/notification/callbacks/{provider}/{profile}"),
|
||||
handler::handle);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback.reactive;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.notification.platform.callback.CallbackRequestFactory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import org.springframework.core.io.buffer.DataBufferLimitException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
|
||||
/**
|
||||
* Reactive callback endpoint.
|
||||
*
|
||||
* <p>Ingestion is blocking — it writes to the database — so it runs on {@code boundedElastic} and
|
||||
* never on the event loop. Running it inline would stall every other connection the loop is
|
||||
* serving.
|
||||
*
|
||||
* <p>It shares the canonicalisation and the ingestion service with the servlet endpoint, so a
|
||||
* deployment can switch web stacks without changing what a provider signature is checked against.
|
||||
*/
|
||||
public final class NotificationCallbackWebFluxHandler {
|
||||
|
||||
private final ProviderCallbackIngestionService ingestion;
|
||||
private final CallbackRequestFactory requestFactory;
|
||||
private final BoundedCallbackBodyReader bodyReader;
|
||||
|
||||
public NotificationCallbackWebFluxHandler(
|
||||
ProviderCallbackIngestionService ingestion,
|
||||
CallbackRequestFactory requestFactory,
|
||||
BoundedCallbackBodyReader bodyReader) {
|
||||
this.ingestion = Objects.requireNonNull(ingestion, "ingestion");
|
||||
this.requestFactory = Objects.requireNonNull(requestFactory, "requestFactory");
|
||||
this.bodyReader = Objects.requireNonNull(bodyReader, "bodyReader");
|
||||
}
|
||||
|
||||
/** Handle one callback. */
|
||||
public Mono<ServerResponse> handle(ServerRequest request) {
|
||||
String provider = request.pathVariable("provider");
|
||||
String profile = request.pathVariable("profile");
|
||||
|
||||
return bodyReader
|
||||
.read(request)
|
||||
.flatMap(
|
||||
body ->
|
||||
Mono.fromCallable(
|
||||
() ->
|
||||
ingestion.ingest(
|
||||
requestFactory.create(
|
||||
provider,
|
||||
profile,
|
||||
request.uri().toString(),
|
||||
request.method().name(),
|
||||
request.headers().contentType().map(Object::toString),
|
||||
headers(request),
|
||||
body)))
|
||||
.subscribeOn(Schedulers.boundedElastic()))
|
||||
.then(ServerResponse.noContent().build())
|
||||
.onErrorResume(
|
||||
DataBufferLimitException.class,
|
||||
failure -> ServerResponse.status(HttpStatus.CONTENT_TOO_LARGE).build())
|
||||
.onErrorResume(
|
||||
CallbackValidationException.class,
|
||||
failure -> ServerResponse.status(HttpStatus.BAD_REQUEST).build());
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> headers(ServerRequest request) {
|
||||
Map<String, List<String>> headers = new java.util.LinkedHashMap<>();
|
||||
request
|
||||
.headers()
|
||||
.asHttpHeaders()
|
||||
.forEach((name, values) -> headers.put(name, List.copyOf(values)));
|
||||
return Map.copyOf(headers);
|
||||
}
|
||||
|
||||
/** Content type of a request, if declared. */
|
||||
public static Optional<String> contentType(ServerRequest request) {
|
||||
return request.headers().contentType().map(Object::toString);
|
||||
}
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
package dev.caskeleton.adapter.inbound.web.notification.platform.callback;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.error.CallbackValidationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.callback.AppendEventResult;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackLimits;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackRequest;
|
||||
import dev.caskeleton.application.notification.platform.callback.CallbackVerificationResult;
|
||||
import dev.caskeleton.application.notification.platform.callback.NormalizedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProjectionResult;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapter;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackAdapterRegistry;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderCallbackIngestionService;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventLedger;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventProjectionService;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecord;
|
||||
import dev.caskeleton.application.notification.platform.callback.ProviderEventRecordId;
|
||||
import dev.caskeleton.application.notification.platform.callback.VerifiedCallback;
|
||||
import dev.caskeleton.application.notification.platform.callback.VerifiedProviderEvent;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationSecurityAuditPort;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
/**
|
||||
* What the servlet transport is responsible for handing the callback pipeline.
|
||||
*
|
||||
* <p>The pipeline itself belongs to application-core and is tested there. What is only testable
|
||||
* here is the translation: the exact received octets, the externally-visible URL, and headers that
|
||||
* survive the servlet container's own casing. Each is a common cause of a valid webhook failing
|
||||
* verification, and none is visible from a unit test of the provider adapter.
|
||||
*
|
||||
* <p>The capture point is the provider adapter's {@code verify}, which is the first thing in the
|
||||
* pipeline to see the whole request. It rejects, so the test never needs a ledger.
|
||||
*/
|
||||
class NotificationCallbackMvcControllerTest {
|
||||
|
||||
private static final Clock CLOCK =
|
||||
Clock.fixed(Instant.parse("2026-08-14T00:00:00Z"), ZoneOffset.UTC);
|
||||
private static final String TRUSTED_PROXY = "10.0.0.1";
|
||||
|
||||
private final List<CallbackRequest> verified = new ArrayList<>();
|
||||
private final List<String> rejections = new ArrayList<>();
|
||||
|
||||
private final NotificationCallbackMvcController controller =
|
||||
new NotificationCallbackMvcController(
|
||||
new ProviderCallbackIngestionService(
|
||||
new CapturingRegistry(),
|
||||
new UnusedLedger(),
|
||||
// Never reached: verification always fails in this fixture, and the pipeline appends
|
||||
// only after a valid signature.
|
||||
new ProviderEventProjectionService(
|
||||
new UnusedLedger(),
|
||||
providerId -> java.util.Optional.empty(),
|
||||
new UnusedAttemptResolver(),
|
||||
new UnusedProjectionStore(),
|
||||
(attempt, facts) -> {
|
||||
throw new UnsupportedOperationException();
|
||||
},
|
||||
new UnusedTransactions(),
|
||||
new DiscardingMetrics()),
|
||||
new UnusedPayloadProtection(),
|
||||
new RecordingSecurityAudit(),
|
||||
new DiscardingMetrics(),
|
||||
CLOCK),
|
||||
new CallbackRequestFactory(new ExternalRequestUrlResolver(Set.of(TRUSTED_PROXY)), CLOCK));
|
||||
|
||||
@Test
|
||||
void theExactReceivedOctetsReachTheAdapterUnparsed() {
|
||||
byte[] body =
|
||||
"MessageSid=SM1&MessageStatus=delivered&Signed=a+b%2Fc".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.callback(
|
||||
"twilio", "twilio-primary", request("application/x-www-form-urlencoded"), body))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
|
||||
// Byte for byte, including the percent-encoding a form parse would have consumed and re-encoded
|
||||
// differently — which is the single most common cause of a valid webhook failing its signature.
|
||||
assertThat(verified).hasSize(1);
|
||||
assertThat(verified.get(0).body()).isEqualTo(body);
|
||||
assertThat(verified.get(0).contentType()).contains("application/x-www-form-urlencoded");
|
||||
assertThat(verified.get(0).httpMethod()).isEqualTo("POST");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForwardedHostFromAnUntrustedPeerIsIgnored() {
|
||||
var request = request("application/json");
|
||||
request.setRemoteAddr("203.0.113.9");
|
||||
request.addHeader("X-Forwarded-Proto", "https");
|
||||
request.addHeader("X-Forwarded-Host", "attacker.example.com");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.callback(
|
||||
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
|
||||
// Honouring the header unconditionally would let any caller choose the URL that gets verified,
|
||||
// which defeats the signature entirely.
|
||||
assertThat(verified.get(0).externalUrl()).doesNotContain("attacker.example.com");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForwardedHostFromATrustedProxyBecomesTheCanonicalUrl() {
|
||||
var request = request("application/json");
|
||||
request.setRemoteAddr(TRUSTED_PROXY);
|
||||
request.addHeader("X-Forwarded-Proto", "https");
|
||||
request.addHeader("X-Forwarded-Host", "callback.example.com");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.callback(
|
||||
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
|
||||
assertThat(verified.get(0).externalUrl())
|
||||
.isEqualTo(
|
||||
"https://callback.example.com/internal/notification/callbacks/twilio/twilio-primary");
|
||||
}
|
||||
|
||||
@Test
|
||||
void headersSurviveTheContainersCasingAndStayAddressableEitherWay() {
|
||||
var request = request("application/json");
|
||||
request.addHeader("X-Twilio-Signature", "abc123");
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.callback(
|
||||
"twilio", "twilio-primary", request, "{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
|
||||
assertThat(verified.get(0).header("x-twilio-signature")).contains("abc123");
|
||||
assertThat(verified.get(0).header("X-TWILIO-SIGNATURE")).contains("abc123");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyOverTheTransportCeilingIsRefusedBeforeAnyAdapterIsConsulted() {
|
||||
byte[] oversized = new byte[NotificationCallbackMvcController.MAX_BODY_BYTES + 1];
|
||||
|
||||
var response =
|
||||
controller.callback("twilio", "twilio-primary", request("application/json"), oversized);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONTENT_TOO_LARGE);
|
||||
// Nothing downstream sees it, so no signature check ever runs over an attacker-sized payload.
|
||||
assertThat(verified).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRejectedCallbackRevealsNothingBeyondTheStatusCode() {
|
||||
var response =
|
||||
controller.onValidationFailure(
|
||||
new CallbackValidationException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.CALLBACK_SIGNATURE_INVALID,
|
||||
FailureCategory.CALLBACK_VALIDATION_FAILURE)));
|
||||
|
||||
// The endpoint is unauthenticated by design — the signature is the authentication — so an error
|
||||
// body is a free oracle for whoever is probing it.
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
assertThat(response.getBody()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRejectedSignatureIsRecordedAsASecurityEventRatherThanADeliveryEvent() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
controller.callback(
|
||||
"twilio",
|
||||
"twilio-primary",
|
||||
request("application/json"),
|
||||
"{}".getBytes(StandardCharsets.UTF_8)))
|
||||
.isInstanceOf(CallbackValidationException.class);
|
||||
|
||||
// Writing it to the ledger would let anyone who can reach the endpoint fill a recipient's
|
||||
// delivery history with noise.
|
||||
assertThat(rejections).containsExactly("SIGNATURE_MISMATCH");
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request(String contentType) {
|
||||
var request =
|
||||
new MockHttpServletRequest(
|
||||
"POST", "/internal/notification/callbacks/twilio/twilio-primary");
|
||||
request.setContentType(contentType);
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Registry whose adapter records the request and then refuses it. */
|
||||
private final class CapturingRegistry implements ProviderCallbackAdapterRegistry {
|
||||
|
||||
@Override
|
||||
public ProviderCallbackAdapter require(ProviderProfileId profileId) {
|
||||
return new ProviderCallbackAdapter() {
|
||||
|
||||
@Override
|
||||
public ProviderId providerId() {
|
||||
return new ProviderId("twilio");
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackVerificationResult verify(CallbackRequest request) {
|
||||
verified.add(request);
|
||||
return CallbackVerificationResult.invalid("SIGNATURE_MISMATCH");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NormalizedProviderEvent> normalize(VerifiedCallback callback) {
|
||||
throw new UnsupportedOperationException("verification always fails in this fixture");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public CallbackLimits limitsFor(ProviderProfileId profileId) {
|
||||
return new CallbackLimits(
|
||||
65_536L, Set.of("application/json", "application/x-www-form-urlencoded"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Security audit that keeps the rejection reason. */
|
||||
private final class RecordingSecurityAudit implements NotificationSecurityAuditPort {
|
||||
|
||||
@Override
|
||||
public void callbackSignatureRejected(ProviderProfileId profileId, String reasonCode) {
|
||||
rejections.add(reasonCode);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void callbackRejectedByLimit(ProviderProfileId profileId, String reasonCode) {
|
||||
rejections.add(reasonCode);
|
||||
}
|
||||
}
|
||||
|
||||
/** Metrics are exercised elsewhere; discarding them keeps this test about the transport. */
|
||||
private static final class DiscardingMetrics implements NotificationMetricsPort {
|
||||
|
||||
@Override
|
||||
public void increment(String metricName, Map<String, String> tags) {
|
||||
// Intentionally empty.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String metricName, Map<String, String> tags, Duration value) {
|
||||
// Intentionally empty.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void gauge(String metricName, Map<String, String> tags, double value) {
|
||||
// Intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: attempt correlation happens only for an accepted callback. */
|
||||
private static final class UnusedAttemptResolver
|
||||
implements dev.caskeleton.application.notification.platform.callback
|
||||
.DeliveryAttemptResolverPort {
|
||||
|
||||
@Override
|
||||
public java.util.Optional<
|
||||
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
|
||||
byAttemptId(DeliveryAttemptId attemptId) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Optional<
|
||||
dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot>
|
||||
byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) {
|
||||
return java.util.Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: projection runs only after a signature has been accepted. */
|
||||
private static final class UnusedProjectionStore
|
||||
implements dev.caskeleton.application.notification.platform.callback
|
||||
.DeliveryProjectionStorePort {
|
||||
|
||||
@Override
|
||||
public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load(
|
||||
DeliveryAttemptId attemptId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(
|
||||
DeliveryAttemptId attemptId,
|
||||
dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: nothing in this fixture gets as far as a transaction. */
|
||||
private static final class UnusedTransactions
|
||||
implements dev.caskeleton.application.transaction.TransactionPort {
|
||||
|
||||
@Override
|
||||
public <T> T inWrite(java.util.function.Supplier<T> action) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRootWrite(java.util.function.Supplier<T> action) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inRead(java.util.function.Supplier<T> action) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T inNew(java.util.function.Supplier<T> action) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: every request in this fixture is rejected before the payload is retained. */
|
||||
private static final class UnusedPayloadProtection
|
||||
implements dev.caskeleton.application.notification.platform.callback
|
||||
.CallbackPayloadProtectionPort {
|
||||
|
||||
@Override
|
||||
public byte[] protectRawPayload(byte[] rawBody) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String digest(byte[] rawBody) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fingerprint(
|
||||
ProviderProfileId profileId, NormalizedProviderEvent event, String rawPayloadDigest) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
|
||||
/** Never reached: every request in this fixture is rejected before the append. */
|
||||
private static final class UnusedLedger implements ProviderEventLedger {
|
||||
|
||||
@Override
|
||||
public AppendEventResult append(VerifiedProviderEvent event) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public AppendEventResult appendAll(List<VerifiedProviderEvent> events) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> pendingProjection(int limit) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markApplied(ProviderEventRecordId eventId, ProjectionResult result) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void markFailed(ProviderEventRecordId eventId, String errorCode) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> unmatched(int limit) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderEventRecord> eventsForAttempt(DeliveryAttemptId attemptId) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,34 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-autoconfigure'
|
||||
implementation 'org.springframework:spring-web' // Slack webhook client (RestClient)
|
||||
implementation 'org.slf4j:slf4j-api'
|
||||
|
||||
// Notification Delivery Platform.
|
||||
// - mail: the SMTP provider adapter is built on JavaMailSender/MimeMessageHelper, which is where
|
||||
// multipart/alternative, inline resources and header validation already live. Rebuilding MIME
|
||||
// by hand to avoid one dependency would be the more dangerous choice.
|
||||
// - jackson-databind: provider payloads, callback bodies and the canonical variables payload are
|
||||
// JSON. It stays inside this adapter; application-core never sees a JSON type.
|
||||
// - reactor-core: only the optional Reactor facade uses it. The core async type stays
|
||||
// CompletionStage, so nothing else on this classpath depends on Reactor.
|
||||
implementation 'org.springframework.boot:spring-boot-starter-mail'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-json'
|
||||
implementation 'io.projectreactor:reactor-core'
|
||||
// JSON Schema 2020-12 validation of template variables, using the same validator and version the
|
||||
// messaging adapter already depends on rather than a second implementation of the same spec.
|
||||
// The YAML dataformat is excluded: schemas are supplied as JSON strings, so pulling a YAML
|
||||
// parser onto the runtime classpath would add attack surface for a format nothing reads.
|
||||
// Thymeleaf is the reference HTML renderer, added as the engine only — not the Spring
|
||||
// starter, which would drag a view resolver and a servlet integration onto an outbound
|
||||
// adapter that renders strings and never serves a request.
|
||||
implementation 'org.thymeleaf:thymeleaf'
|
||||
|
||||
implementation('com.networknt:json-schema-validator:3.0.2') {
|
||||
exclude group: 'tools.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
exclude group: 'com.fasterxml.jackson.dataformat', module: 'jackson-dataformat-yaml'
|
||||
}
|
||||
|
||||
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
|
||||
|
||||
testImplementation 'io.projectreactor:reactor-test'
|
||||
}
|
||||
tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' }
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
@@ -32,6 +33,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
@@ -43,8 +45,11 @@ io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnota
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
@@ -53,6 +58,7 @@ net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
ognl:ognl:3.3.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs
|
||||
@@ -60,9 +66,9 @@ org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
@@ -73,14 +79,18 @@ org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,test
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.eclipse.angus:angus-activation:2.0.3=runtimeClasspath,testRuntimeClasspath
|
||||
org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
@@ -95,10 +105,10 @@ org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeCla
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
@@ -106,28 +116,32 @@ org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -137,16 +151,19 @@ org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRunti
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.admin.AdminAccessDeniedException;
|
||||
import dev.caskeleton.application.notification.platform.admin.AdminActor;
|
||||
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Operator authority check.
|
||||
*
|
||||
* <p>Application authority never grants an operator authority. The two planes are separated so that
|
||||
* a compromised application credential cannot redrive a message or lift a suppression — the actions
|
||||
* whose whole purpose is to override the platform's own safety decisions.
|
||||
*/
|
||||
public final class AdminAuthorizationGuard {
|
||||
|
||||
/** Require an authority, or refuse. */
|
||||
public void require(AdminActor actor, NotificationAdminAuthority authority) {
|
||||
Objects.requireNonNull(actor, "actor");
|
||||
Objects.requireNonNull(authority, "authority");
|
||||
if (!actor.holds(authority)) {
|
||||
throw new AdminAccessDeniedException(authority);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require that the actor may act on a tenant.
|
||||
*
|
||||
* <p>An actor with no tenant is a global operator; one bound to a tenant may only act inside it.
|
||||
*/
|
||||
public void requireTenant(AdminActor actor, TenantId tenantId) {
|
||||
Objects.requireNonNull(actor, "actor");
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Optional<TenantId> scope = actor.tenantId();
|
||||
if (scope.isPresent() && !scope.get().equals(tenantId)) {
|
||||
throw new AdminAccessDeniedException(NotificationAdminAuthority.SUPPRESS);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.admin.DuplicateRiskApprovalRequiredException;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.AttemptConfirmation;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Blocks an unapproved redrive of an ambiguous attempt.
|
||||
*
|
||||
* <p>The platform cannot tell whether the first submission reached the user, so re-sending is a
|
||||
* decision with a real cost that only a human can accept. Requiring the approval flag makes that
|
||||
* acceptance an explicit, audited act rather than a default.
|
||||
*/
|
||||
public final class DuplicateRiskGuard {
|
||||
|
||||
/** Verify the operator accepted the duplicate risk when one exists. */
|
||||
public void verify(DeliveryAttemptSnapshot attempt, boolean approved) {
|
||||
Objects.requireNonNull(attempt, "attempt");
|
||||
boolean risky =
|
||||
attempt.confirmation() == AttemptConfirmation.AMBIGUOUS
|
||||
|| attempt.submissionOutcome()
|
||||
== dev.caskeleton.application.notification.platform.api.delivery.SubmissionOutcome
|
||||
.CONFIRMED_ACCEPTED;
|
||||
if (risky && !approved) {
|
||||
throw new DuplicateRiskApprovalRequiredException();
|
||||
}
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.admin;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.dispatch.ProviderRuntimeRegistry;
|
||||
import dev.caskeleton.application.notification.platform.admin.AdminActor;
|
||||
import dev.caskeleton.application.notification.platform.admin.AdminOperationResult;
|
||||
import dev.caskeleton.application.notification.platform.admin.AdminOperationStorePort;
|
||||
import dev.caskeleton.application.notification.platform.admin.NotificationAdminAuthority;
|
||||
import dev.caskeleton.application.notification.platform.admin.NotificationAdminService;
|
||||
import dev.caskeleton.application.notification.platform.admin.ReconcileCommand;
|
||||
import dev.caskeleton.application.notification.platform.admin.RedriveCommand;
|
||||
import dev.caskeleton.application.notification.platform.admin.SetProviderStateCommand;
|
||||
import dev.caskeleton.application.notification.platform.admin.SuppressCommand;
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.api.delivery.RecipientDeliveryState;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientDeliveryStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationAuditEvent;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationAuditPort;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionEntry;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionId;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionSource;
|
||||
import dev.caskeleton.application.notification.platform.policy.SuppressionStorePort;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
import dev.caskeleton.application.transaction.TransactionPort;
|
||||
import java.time.Clock;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* N4 operator plane.
|
||||
*
|
||||
* <p>Four properties hold for every operation: a separate authority, an idempotent operation id, a
|
||||
* recorded reason, and an audit row. The idempotency matters more than it looks — an operator
|
||||
* retrying a redrive after a timeout must not send the message twice, which is exactly the failure
|
||||
* the operation is trying to repair.
|
||||
*
|
||||
* <p>A dry run reads and reports but writes nothing, so an operator can see the blast radius of a
|
||||
* bulk action before committing to it.
|
||||
*/
|
||||
public final class NotificationAdminServiceImpl implements NotificationAdminService {
|
||||
|
||||
private final AdminAuthorizationGuard authorization;
|
||||
private final DuplicateRiskGuard duplicateRiskGuard;
|
||||
private final DeliveryAttemptStorePort attempts;
|
||||
private final RecipientDeliveryStorePort recipients;
|
||||
private final ReconciliationService reconciliation;
|
||||
private final SuppressionStorePort suppressions;
|
||||
private final ProviderRuntimeRegistry runtimes;
|
||||
private final AdminOperationStorePort operations;
|
||||
private final NotificationAuditPort audit;
|
||||
private final TransactionPort transactions;
|
||||
private final Clock clock;
|
||||
|
||||
public NotificationAdminServiceImpl(
|
||||
AdminAuthorizationGuard authorization,
|
||||
DuplicateRiskGuard duplicateRiskGuard,
|
||||
DeliveryAttemptStorePort attempts,
|
||||
RecipientDeliveryStorePort recipients,
|
||||
ReconciliationService reconciliation,
|
||||
SuppressionStorePort suppressions,
|
||||
ProviderRuntimeRegistry runtimes,
|
||||
AdminOperationStorePort operations,
|
||||
NotificationAuditPort audit,
|
||||
TransactionPort transactions,
|
||||
Clock clock) {
|
||||
this.authorization = Objects.requireNonNull(authorization, "authorization");
|
||||
this.duplicateRiskGuard = Objects.requireNonNull(duplicateRiskGuard, "duplicateRiskGuard");
|
||||
this.attempts = Objects.requireNonNull(attempts, "attempts");
|
||||
this.recipients = Objects.requireNonNull(recipients, "recipients");
|
||||
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
|
||||
this.suppressions = Objects.requireNonNull(suppressions, "suppressions");
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
|
||||
this.operations = Objects.requireNonNull(operations, "operations");
|
||||
this.audit = Objects.requireNonNull(audit, "audit");
|
||||
this.transactions = Objects.requireNonNull(transactions, "transactions");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult redrive(RedriveCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.REDRIVE);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
|
||||
DeliveryAttemptSnapshot original =
|
||||
attempts
|
||||
.snapshot(command.attemptId())
|
||||
.orElseThrow(() -> new IllegalStateException("delivery attempt is not available"));
|
||||
authorization.requireTenant(actor, original.tenantId());
|
||||
duplicateRiskGuard.verify(original, command.approveDuplicateRisk());
|
||||
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.of(original.notificationId()),
|
||||
Optional.of(original.recipientDeliveryId()),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
// The logical identities are preserved and only the attempt is new, so the history stays
|
||||
// one story rather than becoming two unrelated notifications.
|
||||
recipients.transition(
|
||||
original.recipientDeliveryId(),
|
||||
RecipientDeliveryState.READY_TO_DISPATCH,
|
||||
Optional.of(clock.instant()));
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
1,
|
||||
Optional.of(original.notificationId()),
|
||||
Optional.of(original.recipientDeliveryId()),
|
||||
Optional.empty(),
|
||||
List.of(command.reason()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_REDRIVE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of(
|
||||
"provider", original.providerId().value(),
|
||||
"channel", original.channel().name())));
|
||||
return operations.save(result, actor, "ADMIN_REDRIVE");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult reconcile(ReconcileCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.RECONCILE);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
command.attemptIds().size(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
int reconciled = 0;
|
||||
for (DeliveryAttemptId attemptId : command.attemptIds()) {
|
||||
reconciliation.reconcile(attemptId);
|
||||
reconciled++;
|
||||
}
|
||||
reasons.add(command.reason());
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
reconciled,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.copyOf(reasons));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_RECONCILE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of()));
|
||||
return operations.save(result, actor, "ADMIN_RECONCILE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult suppress(SuppressCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.SUPPRESS);
|
||||
authorization.requireTenant(actor, command.tenantId());
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
return transactions.inWrite(
|
||||
() -> {
|
||||
int affected;
|
||||
if (command.remove()) {
|
||||
// Removal is by fingerprint match rather than by id, because an operator lifting a
|
||||
// suppression knows the target, not the row identifier the platform assigned.
|
||||
affected =
|
||||
suppressions
|
||||
.activeFor(
|
||||
command.tenantId(), command.targetFingerprint(), clock.instant())
|
||||
.stream()
|
||||
.map(entry -> suppressions.remove(command.tenantId(), entry.id()))
|
||||
.filter(Optional::isPresent)
|
||||
.count()
|
||||
> 0
|
||||
? 1
|
||||
: 0;
|
||||
} else {
|
||||
suppressions.upsert(
|
||||
new SuppressionEntry(
|
||||
new SuppressionId(UUID.randomUUID()),
|
||||
command.tenantId(),
|
||||
command.scope(),
|
||||
command.reason(),
|
||||
command.targetFingerprint(),
|
||||
Optional.empty(),
|
||||
clock.instant(),
|
||||
command.expiresAt(),
|
||||
SuppressionSource.ADMIN));
|
||||
affected = 1;
|
||||
}
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
affected,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(command.reasonText()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
command.remove() ? "ADMIN_SUPPRESSION_REMOVED" : "ADMIN_SUPPRESSION_ADDED",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason().name()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of()));
|
||||
return operations.save(
|
||||
result, actor, command.remove() ? "ADMIN_SUPPRESS_REMOVE" : "ADMIN_SUPPRESS_ADD");
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminOperationResult setProviderState(SetProviderStateCommand command, AdminActor actor) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
authorization.require(actor, NotificationAdminAuthority.PROVIDER_CONTROL);
|
||||
|
||||
Optional<AdminOperationResult> replayed = operations.findByOperationId(command.operationId());
|
||||
if (replayed.isPresent()) {
|
||||
return replayed.get();
|
||||
}
|
||||
if (command.dryRun()) {
|
||||
return new AdminOperationResult(
|
||||
command.operationId(),
|
||||
true,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of("DRY_RUN"));
|
||||
}
|
||||
|
||||
var runtime = runtimes.current(command.profileId());
|
||||
switch (command.desiredState()) {
|
||||
case DISABLED -> runtime.markDisabled();
|
||||
case DRAINING -> runtime.markDraining();
|
||||
case HEALTHY -> runtime.markHealthy();
|
||||
case DEGRADED -> runtime.markDegraded(command.reason());
|
||||
case THROTTLED -> runtime.markThrottled();
|
||||
case AUTHENTICATION_FAILED -> runtime.markAuthenticationFailed(command.reason());
|
||||
// Unreachable while the enum is exhaustive; present so a state added later fails loudly here
|
||||
// rather than silently leaving the runtime in whatever state it was already in.
|
||||
default -> throw new IllegalStateException("unhandled provider runtime state");
|
||||
}
|
||||
|
||||
AdminOperationResult result =
|
||||
new AdminOperationResult(
|
||||
command.operationId(),
|
||||
false,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
List.of(command.reason()));
|
||||
audit.record(
|
||||
new NotificationAuditEvent(
|
||||
"ADMIN_PROVIDER_STATE",
|
||||
actor.actorRef(),
|
||||
Optional.of(command.reason()),
|
||||
Optional.of(command.operationId()),
|
||||
clock.instant(),
|
||||
Map.of(
|
||||
"providerProfile", command.profileId().value(),
|
||||
"status", command.desiredState().name())));
|
||||
return operations.save(result, actor, "ADMIN_PROVIDER_STATE");
|
||||
}
|
||||
|
||||
/** Current state of a provider runtime, for the health endpoint. */
|
||||
public ProviderRuntimeState providerState(
|
||||
dev.caskeleton.application.notification.platform.api.ProviderProfileId profileId) {
|
||||
return runtimes.state(profileId);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpGateway;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.security.AesGcmContactPointProtector;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.JacksonNotificationVariablesCodec;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.JsonSchemaVariableValidator;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationTemplateEngine;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.PlaceholderTemplateEngine;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.Sha256MessageDigestAdapter;
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.MessageDigestPort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationVariablesCodecPort;
|
||||
import dev.caskeleton.application.notification.platform.security.ContactPointProtector;
|
||||
import dev.caskeleton.application.notification.platform.security.SecretMaterialProvider;
|
||||
import dev.caskeleton.application.notification.platform.template.TemplateVariableValidator;
|
||||
import java.time.Duration;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* Notification platform wiring.
|
||||
*
|
||||
* <p>Everything is opt-in and conditional. The platform contributes no beans unless it is enabled,
|
||||
* and the contact point protector only appears once a secret provider exists — because a protector
|
||||
* without keys would fail on the first delivery instead of at startup.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableConfigurationProperties(NotificationPlatformSettings.class)
|
||||
@ConditionalOnProperty(
|
||||
prefix = "ca-skeleton.notification.platform",
|
||||
name = "enabled",
|
||||
havingValue = "true")
|
||||
public class NotificationPlatformAutoConfiguration {
|
||||
|
||||
/** Canonical variables codec. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public NotificationVariablesCodecPort notificationVariablesCodec() {
|
||||
return new JacksonNotificationVariablesCodec();
|
||||
}
|
||||
|
||||
/** Request fingerprint hashing. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public MessageDigestPort notificationMessageDigest() {
|
||||
return new Sha256MessageDigestAdapter();
|
||||
}
|
||||
|
||||
/** JSON Schema 2020-12 variable validation. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public TemplateVariableValidator notificationTemplateVariableValidator() {
|
||||
return new JsonSchemaVariableValidator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Template engine, defaulting to the deterministic placeholder substitution.
|
||||
*
|
||||
* <p>Thymeleaf is the opt-in alternative: it escapes by default, which matters for HTML email
|
||||
* bodies built from application input. The default stays the placeholder engine because it has no
|
||||
* expression evaluator at all, and an unknown engine name fails the boot rather than quietly
|
||||
* falling back — a deployment that thought it had escaping and did not is the worse outcome.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public NotificationTemplateEngine notificationTemplateEngine(
|
||||
@Value("${ca-skeleton.notification.platform.template.engine:placeholder}") String engine) {
|
||||
return switch (engine.toLowerCase(java.util.Locale.ROOT)) {
|
||||
case "placeholder" -> new PlaceholderTemplateEngine();
|
||||
case "thymeleaf" -> new ThymeleafStringTemplateEngine();
|
||||
default ->
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.notification.platform.template.engine must be"
|
||||
+ " 'placeholder' or 'thymeleaf', not '"
|
||||
+ engine
|
||||
+ "'");
|
||||
};
|
||||
}
|
||||
|
||||
/** Contact point protection, only once key material is available. */
|
||||
@Bean
|
||||
@ConditionalOnBean(SecretMaterialProvider.class)
|
||||
@ConditionalOnMissingBean
|
||||
public ContactPointProtector notificationContactPointProtector(SecretMaterialProvider secrets) {
|
||||
return new AesGcmContactPointProtector(secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default provider transport.
|
||||
*
|
||||
* <p>Replaced in the composition root when the HTTP Client Platform is bound, which is the
|
||||
* supported way to reuse its TLS, circuit-breaker and SSRF policy.
|
||||
*/
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public NotificationHttpGateway notificationHttpGateway() {
|
||||
return new JdkNotificationHttpGateway(Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.autoconfigure;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Bound notification platform configuration.
|
||||
*
|
||||
* <p>Validation happens in the constructor, so a misconfiguration fails the boot rather than
|
||||
* surfacing as a delivery incident hours later. Everything is bounded: there is no property whose
|
||||
* value may be "unlimited", because an unbounded queue or payload is a resource failure waiting for
|
||||
* the first burst.
|
||||
*/
|
||||
@ConfigurationProperties("ca-skeleton.notification.platform")
|
||||
public record NotificationPlatformSettings(
|
||||
boolean enabled, Dispatch dispatch, Callbacks callbacks, Map<String, Provider> providers) {
|
||||
|
||||
public NotificationPlatformSettings {
|
||||
dispatch = dispatch == null ? Dispatch.defaults() : dispatch;
|
||||
callbacks = callbacks == null ? Callbacks.defaults() : callbacks;
|
||||
providers = providers == null ? Map.of() : Map.copyOf(providers);
|
||||
providers.forEach((id, provider) -> provider.validate(id));
|
||||
}
|
||||
|
||||
/** Dispatch runtime bounds. */
|
||||
public record Dispatch(
|
||||
int claimBatchSize,
|
||||
Duration leaseDuration,
|
||||
Duration pollInterval,
|
||||
int maxGlobalConcurrency,
|
||||
int maxAdditionalAttempts,
|
||||
Duration maxQueueAge,
|
||||
boolean allowAmbiguousFallback) {
|
||||
|
||||
private static final int MAX_CLAIM_BATCH = 1000;
|
||||
|
||||
public Dispatch {
|
||||
Objects.requireNonNull(leaseDuration, "leaseDuration");
|
||||
Objects.requireNonNull(pollInterval, "pollInterval");
|
||||
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
|
||||
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
|
||||
throw new IllegalArgumentException(
|
||||
"ca-skeleton.notification.platform.dispatch.claim-batch-size must be 1.."
|
||||
+ MAX_CLAIM_BATCH);
|
||||
}
|
||||
if (maxGlobalConcurrency < 1) {
|
||||
throw new IllegalArgumentException("max-global-concurrency must be positive");
|
||||
}
|
||||
if (maxAdditionalAttempts < 0) {
|
||||
throw new IllegalArgumentException("max-additional-attempts must not be negative");
|
||||
}
|
||||
if (leaseDuration.isNegative() || leaseDuration.isZero()) {
|
||||
throw new IllegalArgumentException("lease-duration must be positive and finite");
|
||||
}
|
||||
if (leaseDuration.compareTo(pollInterval) <= 0) {
|
||||
throw new IllegalArgumentException("lease-duration must exceed poll-interval");
|
||||
}
|
||||
if (allowAmbiguousFallback) {
|
||||
// Refused outright rather than warned about: automatic fallback after an ambiguous
|
||||
// submission is the configuration that turns an unknown into a guaranteed duplicate.
|
||||
throw new IllegalArgumentException(
|
||||
"allow-ambiguous-fallback is not a supported configuration");
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative defaults. */
|
||||
public static Dispatch defaults() {
|
||||
return new Dispatch(
|
||||
100, Duration.ofSeconds(30), Duration.ofMillis(250), 128, 3, Duration.ofHours(24), false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Callback endpoint bounds. */
|
||||
public record Callbacks(boolean enabled, long maxBodyBytes, Duration replaySkew) {
|
||||
|
||||
private static final long MAX_BODY_CEILING = 1_048_576L;
|
||||
|
||||
public Callbacks {
|
||||
Objects.requireNonNull(replaySkew, "replaySkew");
|
||||
if (maxBodyBytes < 1 || maxBodyBytes > MAX_BODY_CEILING) {
|
||||
throw new IllegalArgumentException("max-body-bytes must be 1.." + MAX_BODY_CEILING);
|
||||
}
|
||||
if (replaySkew.isNegative()) {
|
||||
throw new IllegalArgumentException("replay-skew must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
/** Conservative defaults. */
|
||||
public static Callbacks defaults() {
|
||||
return new Callbacks(false, 65_536L, Duration.ofMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
/** One provider profile. */
|
||||
public record Provider(
|
||||
String type,
|
||||
boolean enabled,
|
||||
String environment,
|
||||
String credentialProfile,
|
||||
String topic,
|
||||
String vapidPublicKey,
|
||||
String callbackSigningSecretRef,
|
||||
Duration timeout,
|
||||
int maxConcurrency,
|
||||
int ratePerSecond) {
|
||||
|
||||
/** Fail the boot when a profile cannot possibly work. */
|
||||
public void validate(String profileId) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
require(type != null && !type.isBlank(), profileId, "type is required");
|
||||
require(environment != null && !environment.isBlank(), profileId, "environment is required");
|
||||
require(
|
||||
credentialProfile != null && !credentialProfile.isBlank(),
|
||||
profileId,
|
||||
"credential-profile is required");
|
||||
require(
|
||||
timeout != null && !timeout.isNegative() && !timeout.isZero(),
|
||||
profileId,
|
||||
"timeout must be positive and finite");
|
||||
require(maxConcurrency >= 1, profileId, "max-concurrency must be positive");
|
||||
require(ratePerSecond >= 1, profileId, "rate-limit-per-second must be positive");
|
||||
|
||||
switch (type == null ? "" : type.toUpperCase(java.util.Locale.ROOT)) {
|
||||
case "APNS" ->
|
||||
require(topic != null && !topic.isBlank(), profileId, "APNs profiles require a topic");
|
||||
case "WEB_PUSH" ->
|
||||
require(
|
||||
vapidPublicKey != null && !vapidPublicKey.isBlank(),
|
||||
profileId,
|
||||
"Web Push profiles require a VAPID key");
|
||||
case "TWILIO", "SES" ->
|
||||
require(
|
||||
callbackSigningSecretRef != null && !callbackSigningSecretRef.isBlank(),
|
||||
profileId,
|
||||
"callback-capable profiles require a callback signing secret reference");
|
||||
default -> {
|
||||
// Providers without extra requirements are already covered by the common checks.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String profileId, String message) {
|
||||
if (!condition) {
|
||||
throw new IllegalArgumentException(
|
||||
"notification provider profile '" + profileId + "': " + message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
/**
|
||||
* A held concurrency slot for one provider attempt.
|
||||
*
|
||||
* <p>Closing it is what releases the slot, so every call site uses try-with-resources. The permit
|
||||
* also carries the credential generation the attempt ran under, which is what makes a rotation
|
||||
* auditable after the fact.
|
||||
*/
|
||||
public interface AttemptPermit extends AutoCloseable {
|
||||
|
||||
/** Credential generation this attempt is bound to. */
|
||||
long generation();
|
||||
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationGatewayPort;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationCapability;
|
||||
import dev.caskeleton.application.notification.platform.provider.ReconciliationResult;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Routes a reconciliation to the capability that owns the provider.
|
||||
*
|
||||
* <p>A profile with no registered capability reports {@code Unsupported} rather than falling back
|
||||
* to a guess. Inventing a final status for a provider that cannot be queried is precisely the
|
||||
* behaviour the ambiguity model exists to prevent.
|
||||
*/
|
||||
public final class CapabilityReconciliationGateway implements ReconciliationGatewayPort {
|
||||
|
||||
private final Map<ProviderProfileId, ReconciliationCapability> capabilities;
|
||||
private final ProviderRuntimeRegistry runtimes;
|
||||
|
||||
public CapabilityReconciliationGateway(
|
||||
Map<ProviderProfileId, ReconciliationCapability> capabilities,
|
||||
ProviderRuntimeRegistry runtimes) {
|
||||
this.capabilities = Map.copyOf(Objects.requireNonNull(capabilities, "capabilities"));
|
||||
this.runtimes = Objects.requireNonNull(runtimes, "runtimes");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(ProviderProfileId profileId) {
|
||||
Objects.requireNonNull(profileId, "profileId");
|
||||
return Optional.ofNullable(capabilities.get(profileId))
|
||||
.map(capability -> capability.supports(runtimes.current(profileId).profile()))
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReconciliationResult reconcile(DeliveryAttemptSnapshot attempt) {
|
||||
Objects.requireNonNull(attempt, "attempt");
|
||||
ReconciliationCapability capability = capabilities.get(attempt.providerProfileId());
|
||||
if (capability == null) {
|
||||
return new ReconciliationResult.Unsupported();
|
||||
}
|
||||
// The permit is taken so a reconciliation backlog cannot become a second load source during the
|
||||
// incident that produced it.
|
||||
try (AttemptPermit permit = runtimes.current(attempt.providerProfileId()).acquireAttempt()) {
|
||||
return capability.reconcile(attempt).toCompletableFuture().join();
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.RecipientSpec;
|
||||
import dev.caskeleton.application.notification.platform.api.TenantId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.DeliveryStrategy;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.ExplicitChannel;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.OrderedFallback;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutePlannerPort;
|
||||
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Turns a strategy into an ordered route plan using the configured channel-to-profile map.
|
||||
*
|
||||
* <p>A channel with no configured provider, or a recipient with no contact point for it, simply
|
||||
* produces no candidate. The routing engine then reports {@code NO_ELIGIBLE_ROUTE} rather than the
|
||||
* dispatcher failing on a null, which is the difference between a diagnosable state and a stack
|
||||
* trace.
|
||||
*/
|
||||
public final class ConfiguredRoutePlanner implements NotificationRoutePlannerPort {
|
||||
|
||||
private final Map<Channel, ProviderProfileId> profilesByChannel;
|
||||
|
||||
public ConfiguredRoutePlanner(Map<Channel, ProviderProfileId> profilesByChannel) {
|
||||
this.profilesByChannel =
|
||||
Map.copyOf(Objects.requireNonNull(profilesByChannel, "profilesByChannel"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RouteCandidate> plan(
|
||||
TenantId tenantId, RecipientSpec recipient, DeliveryStrategy strategy) {
|
||||
Objects.requireNonNull(tenantId, "tenantId");
|
||||
Objects.requireNonNull(recipient, "recipient");
|
||||
Objects.requireNonNull(strategy, "strategy");
|
||||
|
||||
List<Channel> ordered =
|
||||
switch (strategy) {
|
||||
case ExplicitChannel explicit -> List.of(explicit.channel());
|
||||
case OrderedFallback fallback -> fallback.channels();
|
||||
};
|
||||
|
||||
List<RouteCandidate> routes = new ArrayList<>(ordered.size());
|
||||
int index = 0;
|
||||
for (Channel channel : ordered) {
|
||||
ProviderProfileId profileId = profilesByChannel.get(channel);
|
||||
if (profileId == null) {
|
||||
continue;
|
||||
}
|
||||
var selector =
|
||||
recipient.contactPoints().stream()
|
||||
.filter(candidate -> candidate.channel() == channel)
|
||||
.findFirst();
|
||||
if (selector.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
boolean blocked =
|
||||
recipient
|
||||
.channelOverride()
|
||||
.map(override -> override.blockedChannels().contains(channel))
|
||||
.orElse(false);
|
||||
routes.add(
|
||||
new RouteCandidate(
|
||||
index++, channel, selector.get().contactPointId(), profileId, !blocked, true));
|
||||
}
|
||||
return List.copyOf(routes);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
/** Verifies a candidate generation before it becomes the current one. */
|
||||
@FunctionalInterface
|
||||
public interface CredentialProbe {
|
||||
|
||||
/** Return false when the candidate credential is not usable. */
|
||||
boolean isUsable(ProviderRuntime candidate);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationException;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
|
||||
/** Raised when a candidate credential generation fails its probe before any cutover. */
|
||||
public class CredentialValidationException extends NotificationException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public CredentialValidationException() {
|
||||
super(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_CONFIGURATION_INVALID,
|
||||
FailureCategory.AUTHENTICATION));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.adapter.outbound.notification.platform.template.NotificationJsonMapper;
|
||||
import dev.caskeleton.application.notification.platform.api.ContactPointId;
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationRoutingPlanCodecPort;
|
||||
import dev.caskeleton.application.notification.platform.policy.RouteCandidate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
|
||||
/**
|
||||
* Route plan encoding.
|
||||
*
|
||||
* <p>The plan is frozen at submit time, so this is a snapshot format rather than a view: it stores
|
||||
* exactly what was decided, including which routes were usable then, and never recomputes.
|
||||
*/
|
||||
public final class JacksonRoutingPlanCodec implements NotificationRoutingPlanCodecPort {
|
||||
|
||||
@Override
|
||||
public String encode(List<RouteCandidate> routes) {
|
||||
Objects.requireNonNull(routes, "routes");
|
||||
List<Map<String, Object>> encoded = new ArrayList<>(routes.size());
|
||||
for (RouteCandidate route : routes) {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("routeIndex", route.routeIndex());
|
||||
entry.put("channel", route.channel().name());
|
||||
entry.put("contactPointId", route.contactPointId().value().toString());
|
||||
entry.put("providerProfileId", route.providerProfileId().value());
|
||||
entry.put("contactPointActive", route.contactPointActive());
|
||||
entry.put("providerEnabled", route.providerEnabled());
|
||||
encoded.add(entry);
|
||||
}
|
||||
return NotificationJsonMapper.mapper().writeValueAsString(encoded);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RouteCandidate> decode(String payload) {
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
List<LinkedHashMap<String, Object>> raw =
|
||||
NotificationJsonMapper.mapper()
|
||||
.readValue(payload, new TypeReference<ArrayList<LinkedHashMap<String, Object>>>() {});
|
||||
List<RouteCandidate> routes = new ArrayList<>(raw.size());
|
||||
for (Map<String, Object> entry : raw) {
|
||||
routes.add(
|
||||
new RouteCandidate(
|
||||
((Number) entry.get("routeIndex")).intValue(),
|
||||
Channel.valueOf(String.valueOf(entry.get("channel"))),
|
||||
new ContactPointId(UUID.fromString(String.valueOf(entry.get("contactPointId")))),
|
||||
new ProviderProfileId(String.valueOf(entry.get("providerProfileId"))),
|
||||
Boolean.TRUE.equals(entry.get("contactPointActive")),
|
||||
Boolean.TRUE.equals(entry.get("providerEnabled"))));
|
||||
}
|
||||
return List.copyOf(routes);
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.DeliveryAttemptId;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.DeliveryAttemptStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.ReconciliationService;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Recovers deliveries a dead worker left in flight.
|
||||
*
|
||||
* <p>An expired lease on a {@code DISPATCHING} delivery is the crash case: the attempt row exists,
|
||||
* so a provider call may have happened. Recovery therefore reconciles rather than re-dispatching —
|
||||
* re-dispatching would be the platform choosing to duplicate rather than to ask.
|
||||
*/
|
||||
public final class LeaseRecoveryService {
|
||||
|
||||
private final RecipientLeaseStorePort leases;
|
||||
private final DeliveryAttemptStorePort attempts;
|
||||
private final ReconciliationService reconciliation;
|
||||
private final Duration staleAfter;
|
||||
private final int batchSize;
|
||||
|
||||
public LeaseRecoveryService(
|
||||
RecipientLeaseStorePort leases,
|
||||
DeliveryAttemptStorePort attempts,
|
||||
ReconciliationService reconciliation,
|
||||
Duration staleAfter,
|
||||
int batchSize) {
|
||||
this.leases = Objects.requireNonNull(leases, "leases");
|
||||
this.attempts = Objects.requireNonNull(attempts, "attempts");
|
||||
this.reconciliation = Objects.requireNonNull(reconciliation, "reconciliation");
|
||||
this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter");
|
||||
this.batchSize = batchSize;
|
||||
if (batchSize < 1) {
|
||||
throw new IllegalArgumentException("batchSize");
|
||||
}
|
||||
if (staleAfter.isNegative() || staleAfter.isZero()) {
|
||||
throw new IllegalArgumentException("staleAfter must be positive and finite");
|
||||
}
|
||||
}
|
||||
|
||||
/** Recover one batch of abandoned deliveries; returns how many were handled. */
|
||||
public int recoverOnce() {
|
||||
List<dev.caskeleton.application.notification.platform.api.RecipientDeliveryId> abandoned =
|
||||
leases.expiredDispatching(batchSize, staleAfter);
|
||||
int handled = 0;
|
||||
for (var recipientDeliveryId : abandoned) {
|
||||
for (var attempt : attempts.attemptsOf(recipientDeliveryId)) {
|
||||
if (attempt.completedAt().isEmpty()) {
|
||||
DeliveryAttemptId attemptId = attempt.id();
|
||||
reconciliation.reconcile(attemptId);
|
||||
handled++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.inbox.InboxItemCreated;
|
||||
import dev.caskeleton.application.notification.platform.inbox.NotificationInboxSignalPort;
|
||||
import java.util.Objects;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Default inbox signal sink.
|
||||
*
|
||||
* <p>Emits identifiers only, never content. A deployment with a WebSocket or messaging relay
|
||||
* replaces it; until then the inbox is still complete, because the row — not the signal — is the
|
||||
* source of truth.
|
||||
*/
|
||||
public final class LoggingInboxSignalPublisher implements NotificationInboxSignalPort {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger("notification.inbox.signal");
|
||||
|
||||
@Override
|
||||
public void publish(InboxItemCreated event) {
|
||||
Objects.requireNonNull(event, "event");
|
||||
log.info(
|
||||
"event=inbox_item_created itemId={} tenant={} category={}",
|
||||
event.itemId().value(),
|
||||
event.principal().tenantId().value(),
|
||||
event.category());
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.routing.Channel;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.TemplateRendererRegistry;
|
||||
import dev.caskeleton.application.notification.platform.template.NotificationTemplateRenderer;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Channel-to-renderer lookup built at wiring time. */
|
||||
public final class MapTemplateRendererRegistry implements TemplateRendererRegistry {
|
||||
|
||||
private final Map<Channel, NotificationTemplateRenderer> renderers;
|
||||
|
||||
public MapTemplateRendererRegistry(List<NotificationTemplateRenderer> renderers) {
|
||||
Objects.requireNonNull(renderers, "renderers");
|
||||
Map<Channel, NotificationTemplateRenderer> byChannel = new EnumMap<>(Channel.class);
|
||||
renderers.forEach(renderer -> byChannel.put(renderer.channel(), renderer));
|
||||
this.renderers = Map.copyOf(byChannel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationTemplateRenderer rendererFor(Channel channel) {
|
||||
NotificationTemplateRenderer renderer = renderers.get(channel);
|
||||
if (renderer == null) {
|
||||
throw new IllegalStateException("no renderer registered for the channel");
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Dispatch runtime bounds.
|
||||
*
|
||||
* <p>Every field is bounded and validated at construction. "Unlimited" is never an accepted value:
|
||||
* an unbounded claim batch or queue is how a burst becomes an out-of-memory failure instead of
|
||||
* backpressure.
|
||||
*/
|
||||
public record NotificationDispatchProperties(
|
||||
int claimBatchSize,
|
||||
Duration leaseDuration,
|
||||
Duration pollInterval,
|
||||
int maxGlobalConcurrency,
|
||||
int maxAdditionalAttempts,
|
||||
Duration estimatedDispatchDuration,
|
||||
Duration maxQueueAge,
|
||||
Duration shutdownGrace) {
|
||||
|
||||
private static final int MAX_CLAIM_BATCH = 1000;
|
||||
|
||||
public NotificationDispatchProperties {
|
||||
Objects.requireNonNull(leaseDuration, "leaseDuration");
|
||||
Objects.requireNonNull(pollInterval, "pollInterval");
|
||||
Objects.requireNonNull(estimatedDispatchDuration, "estimatedDispatchDuration");
|
||||
Objects.requireNonNull(maxQueueAge, "maxQueueAge");
|
||||
Objects.requireNonNull(shutdownGrace, "shutdownGrace");
|
||||
if (claimBatchSize < 1 || claimBatchSize > MAX_CLAIM_BATCH) {
|
||||
throw new IllegalArgumentException("claimBatchSize must be 1.." + MAX_CLAIM_BATCH);
|
||||
}
|
||||
if (maxGlobalConcurrency < 1) {
|
||||
throw new IllegalArgumentException("maxGlobalConcurrency");
|
||||
}
|
||||
if (maxAdditionalAttempts < 0) {
|
||||
throw new IllegalArgumentException("maxAdditionalAttempts");
|
||||
}
|
||||
requirePositive(leaseDuration, "leaseDuration");
|
||||
requirePositive(pollInterval, "pollInterval");
|
||||
requirePositive(maxQueueAge, "maxQueueAge");
|
||||
if (leaseDuration.compareTo(pollInterval) <= 0) {
|
||||
// A lease shorter than the poll interval expires before the worker can renew it, so two
|
||||
// workers would routinely claim the same job.
|
||||
throw new IllegalArgumentException("leaseDuration must exceed pollInterval");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requirePositive(Duration value, String name) {
|
||||
if (value.isNegative() || value.isZero()) {
|
||||
throw new IllegalArgumentException(name + " must be positive and finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.dispatch.NotificationDispatchService;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientLease;
|
||||
import dev.caskeleton.application.notification.platform.dispatch.RecipientLeaseStorePort;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationMetricName;
|
||||
import dev.caskeleton.application.notification.platform.observation.NotificationMetricsPort;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Claims due deliveries and hands them to the dispatcher.
|
||||
*
|
||||
* <p>The worker never calls a provider itself. It claims, submits to a bounded executor, and stops
|
||||
* claiming the moment shutdown begins — so a rolling restart drains rather than abandoning leases
|
||||
* that then have to time out.
|
||||
*
|
||||
* <p>Claiming is bounded twice over: by the claim batch size and by a global concurrency permit.
|
||||
* The second bound matters because a slow provider would otherwise let the queue depth become the
|
||||
* thread count.
|
||||
*/
|
||||
public final class NotificationSchedulerWorker implements AutoCloseable {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(NotificationSchedulerWorker.class);
|
||||
|
||||
private final RecipientLeaseStorePort leases;
|
||||
private final NotificationDispatchService dispatcher;
|
||||
private final NotificationMetricsPort metrics;
|
||||
private final NotificationDispatchProperties properties;
|
||||
private final String workerId;
|
||||
private final ExecutorService dispatchExecutor;
|
||||
private final Semaphore globalConcurrency;
|
||||
private final AtomicBoolean running = new AtomicBoolean();
|
||||
private final AtomicBoolean shuttingDown = new AtomicBoolean();
|
||||
|
||||
public NotificationSchedulerWorker(
|
||||
RecipientLeaseStorePort leases,
|
||||
NotificationDispatchService dispatcher,
|
||||
NotificationMetricsPort metrics,
|
||||
NotificationDispatchProperties properties,
|
||||
String workerId) {
|
||||
this.leases = Objects.requireNonNull(leases, "leases");
|
||||
this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.workerId = Objects.requireNonNull(workerId, "workerId");
|
||||
this.dispatchExecutor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
this.globalConcurrency = new Semaphore(properties.maxGlobalConcurrency());
|
||||
}
|
||||
|
||||
/** Claim and dispatch one batch. Returns how many deliveries were claimed. */
|
||||
public int runOnce() {
|
||||
if (shuttingDown.get()) {
|
||||
return 0;
|
||||
}
|
||||
List<RecipientLease> claimed =
|
||||
leases.claim(workerId, properties.claimBatchSize(), properties.leaseDuration());
|
||||
metrics.gauge(NotificationMetricName.QUEUE_DEPTH, Map.of(), claimed.size());
|
||||
|
||||
for (RecipientLease lease : claimed) {
|
||||
globalConcurrency.acquireUninterruptibly();
|
||||
dispatchExecutor.execute(
|
||||
() -> {
|
||||
try {
|
||||
dispatcher.dispatch(lease);
|
||||
} catch (RuntimeException failure) {
|
||||
// The lease is left to expire rather than being released optimistically: a worker
|
||||
// that
|
||||
// failed mid-dispatch cannot prove what the provider did.
|
||||
log.warn(
|
||||
"notification dispatch failed worker={} reason={}",
|
||||
workerId,
|
||||
failure.getClass().getSimpleName());
|
||||
} finally {
|
||||
globalConcurrency.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
return claimed.size();
|
||||
}
|
||||
|
||||
/** Start the polling loop on a dedicated thread. */
|
||||
public void start() {
|
||||
if (!running.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
Thread.ofVirtual()
|
||||
.name("notification-scheduler-" + workerId)
|
||||
.start(
|
||||
() -> {
|
||||
while (running.get() && !shuttingDown.get()) {
|
||||
try {
|
||||
if (runOnce() == 0) {
|
||||
Thread.sleep(properties.pollInterval().toMillis());
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
} catch (RuntimeException failure) {
|
||||
log.warn(
|
||||
"notification scheduler tick failed worker={} reason={}",
|
||||
workerId,
|
||||
failure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
shuttingDown.set(true);
|
||||
running.set(false);
|
||||
dispatchExecutor.shutdown();
|
||||
try {
|
||||
if (!dispatchExecutor.awaitTermination(
|
||||
properties.shutdownGrace().toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
dispatchExecutor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
dispatchExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the worker has stopped claiming new work. */
|
||||
public boolean shuttingDown() {
|
||||
return shuttingDown.get();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* Per-provider rate and concurrency guard.
|
||||
*
|
||||
* <p>Tokens are spent on real attempts only. A delivery waiting out its backoff holds no permit,
|
||||
* because a provider outage would otherwise pin the whole concurrency budget on deliveries that are
|
||||
* not doing anything.
|
||||
*/
|
||||
public final class ProviderAttemptLimiter {
|
||||
|
||||
private final Semaphore concurrency;
|
||||
private final int maxConcurrency;
|
||||
private final int ratePerSecond;
|
||||
private final Clock clock;
|
||||
private final AtomicLong windowStartSecond = new AtomicLong();
|
||||
private final AtomicLong issuedInWindow = new AtomicLong();
|
||||
|
||||
public ProviderAttemptLimiter(int maxConcurrency, int ratePerSecond, Clock clock) {
|
||||
if (maxConcurrency < 1) {
|
||||
throw new IllegalArgumentException("maxConcurrency");
|
||||
}
|
||||
if (ratePerSecond < 1) {
|
||||
throw new IllegalArgumentException("ratePerSecond");
|
||||
}
|
||||
this.concurrency = new Semaphore(maxConcurrency);
|
||||
this.maxConcurrency = maxConcurrency;
|
||||
this.ratePerSecond = ratePerSecond;
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
this.windowStartSecond.set(clock.instant().getEpochSecond());
|
||||
}
|
||||
|
||||
/** Acquire one attempt slot, or fail fast when the provider budget is spent. */
|
||||
public void acquire() {
|
||||
long second = clock.instant().getEpochSecond();
|
||||
long windowStart = windowStartSecond.get();
|
||||
if (second != windowStart && windowStartSecond.compareAndSet(windowStart, second)) {
|
||||
issuedInWindow.set(0L);
|
||||
}
|
||||
if (issuedInWindow.incrementAndGet() > ratePerSecond) {
|
||||
throw unavailable();
|
||||
}
|
||||
if (!concurrency.tryAcquire()) {
|
||||
issuedInWindow.decrementAndGet();
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
/** Release a previously acquired slot. */
|
||||
public void release() {
|
||||
concurrency.release();
|
||||
}
|
||||
|
||||
/** Slots currently held. */
|
||||
public int activeAttempts() {
|
||||
return maxConcurrency - concurrency.availablePermits();
|
||||
}
|
||||
|
||||
private static ProviderUnavailableException unavailable() {
|
||||
return new ProviderUnavailableException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_UNAVAILABLE, FailureCategory.CAPACITY_REJECTED));
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.error.FailureCategory;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureCode;
|
||||
import dev.caskeleton.application.notification.platform.api.error.NotificationFailureDescriptor;
|
||||
import dev.caskeleton.application.notification.platform.api.error.ProviderUnavailableException;
|
||||
import dev.caskeleton.application.notification.platform.provider.NotificationProviderAdapter;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderProfileSnapshot;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* One immutable credential generation of a provider.
|
||||
*
|
||||
* <p>Generations are replaced, never mutated. Rotating a key by editing a live client would leave
|
||||
* in-flight calls half-way between two credentials; replacing the whole runtime and letting the old
|
||||
* one drain keeps every attempt attributable to exactly one generation.
|
||||
*
|
||||
* <p>An authentication failure moves the whole runtime, not the message. One expired credential
|
||||
* multiplied by a queue of notifications is a self-inflicted outage, so the route opens once and
|
||||
* raises an operational alert instead.
|
||||
*/
|
||||
public final class ProviderRuntime {
|
||||
|
||||
private final ProviderProfileSnapshot profile;
|
||||
private final NotificationProviderAdapter adapter;
|
||||
private final ProviderAttemptLimiter limiter;
|
||||
private final AtomicReference<ProviderRuntimeState> state;
|
||||
private final AtomicReference<String> unhealthyReason = new AtomicReference<>();
|
||||
|
||||
public ProviderRuntime(
|
||||
ProviderProfileSnapshot profile,
|
||||
NotificationProviderAdapter adapter,
|
||||
ProviderAttemptLimiter limiter) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.adapter = Objects.requireNonNull(adapter, "adapter");
|
||||
this.limiter = Objects.requireNonNull(limiter, "limiter");
|
||||
this.state = new AtomicReference<>(ProviderRuntimeState.HEALTHY);
|
||||
}
|
||||
|
||||
/** Profile snapshot including the credential generation. */
|
||||
public ProviderProfileSnapshot profile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
/** Credential generation of this runtime. */
|
||||
public long generation() {
|
||||
return profile.credentialGeneration();
|
||||
}
|
||||
|
||||
/** Provider adapter bound to this generation. */
|
||||
public NotificationProviderAdapter adapter() {
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/** Current health. */
|
||||
public ProviderRuntimeState state() {
|
||||
return state.get();
|
||||
}
|
||||
|
||||
/** Why the runtime is unhealthy, if it is. */
|
||||
public Optional<String> unhealthyReason() {
|
||||
return Optional.ofNullable(unhealthyReason.get());
|
||||
}
|
||||
|
||||
/** Attempts currently in flight on this generation. */
|
||||
public int activeAttempts() {
|
||||
return limiter.activeAttempts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a permit for one attempt.
|
||||
*
|
||||
* <p>The health check happens before the limiter, so a disabled or failed provider never consumes
|
||||
* a token it cannot use.
|
||||
*/
|
||||
public AttemptPermit acquireAttempt() {
|
||||
ProviderRuntimeState current = state.get();
|
||||
if (!current.admitsNewAttempts()) {
|
||||
throw new ProviderUnavailableException(
|
||||
NotificationFailureDescriptor.preDispatch(
|
||||
NotificationFailureCode.PROVIDER_UNAVAILABLE,
|
||||
current == ProviderRuntimeState.AUTHENTICATION_FAILED
|
||||
? FailureCategory.AUTHENTICATION
|
||||
: FailureCategory.CAPACITY_REJECTED));
|
||||
}
|
||||
limiter.acquire();
|
||||
return new LimiterPermit(profile.credentialGeneration(), limiter);
|
||||
}
|
||||
|
||||
/** Mark the credential as rejected by the provider. */
|
||||
public void markAuthenticationFailed(String reasonCode) {
|
||||
unhealthyReason.set(Objects.requireNonNull(reasonCode, "reasonCode"));
|
||||
state.set(ProviderRuntimeState.AUTHENTICATION_FAILED);
|
||||
}
|
||||
|
||||
/** Mark the provider as rate limited. */
|
||||
public void markThrottled() {
|
||||
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.THROTTLED);
|
||||
}
|
||||
|
||||
/** Mark the provider as degraded but still usable. */
|
||||
public void markDegraded(String reasonCode) {
|
||||
unhealthyReason.set(reasonCode);
|
||||
state.compareAndSet(ProviderRuntimeState.HEALTHY, ProviderRuntimeState.DEGRADED);
|
||||
}
|
||||
|
||||
/** Return to healthy after a successful attempt. */
|
||||
public void markHealthy() {
|
||||
unhealthyReason.set(null);
|
||||
state.compareAndSet(ProviderRuntimeState.THROTTLED, ProviderRuntimeState.HEALTHY);
|
||||
state.compareAndSet(ProviderRuntimeState.DEGRADED, ProviderRuntimeState.HEALTHY);
|
||||
}
|
||||
|
||||
/** Stop admitting new attempts; in-flight attempts finish. */
|
||||
public void markDraining() {
|
||||
state.set(ProviderRuntimeState.DRAINING);
|
||||
}
|
||||
|
||||
/** Operator disable. */
|
||||
public void markDisabled() {
|
||||
state.set(ProviderRuntimeState.DISABLED);
|
||||
}
|
||||
|
||||
/** A permit that releases exactly one limiter slot. */
|
||||
private record LimiterPermit(long generation, ProviderAttemptLimiter limiter)
|
||||
implements AttemptPermit {
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
limiter.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.caskeleton.adapter.outbound.notification.platform.dispatch;
|
||||
|
||||
import dev.caskeleton.application.notification.platform.api.ProviderProfileId;
|
||||
import dev.caskeleton.application.notification.platform.provider.ProviderRuntimeState;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/** Holds the current generation of every provider profile plus the generations still draining. */
|
||||
public final class ProviderRuntimeRegistry {
|
||||
|
||||
private final Map<ProviderProfileId, ProviderRuntime> current = new ConcurrentHashMap<>();
|
||||
private final Map<ProviderProfileId, CopyOnWriteArrayList<ProviderRuntime>> draining =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
/** Register the first generation of a profile. */
|
||||
public void register(ProviderRuntime runtime) {
|
||||
Objects.requireNonNull(runtime, "runtime");
|
||||
current.put(runtime.profile().profileId(), runtime);
|
||||
}
|
||||
|
||||
/** Current generation, or a configuration failure when the profile is unknown. */
|
||||
public ProviderRuntime current(ProviderProfileId profileId) {
|
||||
ProviderRuntime runtime = current.get(profileId);
|
||||
if (runtime == null) {
|
||||
throw new IllegalStateException("no provider runtime registered for the profile");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/** Current generation if registered. */
|
||||
public Optional<ProviderRuntime> find(ProviderProfileId profileId) {
|
||||
return Optional.ofNullable(current.get(profileId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap in a new generation and start draining the old one.
|
||||
*
|
||||
* <p>New dispatches immediately use the new generation while the previous one finishes what it
|
||||
* already started, which is what makes a credential rotation invisible to callers.
|
||||
*/
|
||||
public Optional<ProviderRuntime> replace(ProviderRuntime replacement) {
|
||||
Objects.requireNonNull(replacement, "replacement");
|
||||
ProviderProfileId profileId = replacement.profile().profileId();
|
||||
ProviderRuntime previous = current.put(profileId, replacement);
|
||||
if (previous != null) {
|
||||
previous.markDraining();
|
||||
draining.computeIfAbsent(profileId, key -> new CopyOnWriteArrayList<>()).add(previous);
|
||||
forgetIfDrained(profileId);
|
||||
}
|
||||
return Optional.ofNullable(previous);
|
||||
}
|
||||
|
||||
/** Generations that are draining and still have work in flight. */
|
||||
public List<ProviderRuntime> drainingGenerations(ProviderProfileId profileId) {
|
||||
forgetIfDrained(profileId);
|
||||
return List.copyOf(draining.getOrDefault(profileId, new CopyOnWriteArrayList<>()));
|
||||
}
|
||||
|
||||
/** Health of the current generation. */
|
||||
public ProviderRuntimeState state(ProviderProfileId profileId) {
|
||||
return current(profileId).state();
|
||||
}
|
||||
|
||||
private void forgetIfDrained(ProviderProfileId profileId) {
|
||||
CopyOnWriteArrayList<ProviderRuntime> generations = draining.get(profileId);
|
||||
if (generations == null) {
|
||||
return;
|
||||
}
|
||||
generations.removeIf(runtime -> runtime.activeAttempts() == 0);
|
||||
if (generations.isEmpty()) {
|
||||
draining.remove(profileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user