feat(mongodb): implement the MongoDB document persistence platform

Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

The design assumes 19 Stable + 12 Advanced Gradle projects under
modules/mongodb*. This repository's fail-closed registry declares exactly 19
leaf identities, so those modules become package boundaries inside the
registered leaf :adapter:outbound:persistence-mongo, with the design's module
dependency table enforced by ten ArchUnit rules. The mapping and every
deviation are recorded in docs/mongodb/repository-adaptation.md.

Contract highlights, all enforced by tests rather than convention:

- Transaction body retry and commit retry are separate loops. A new session per
  body attempt; commit-only retry on an unknown commit. The body is never
  replayed after a commit ambiguity, so a failover cannot become a duplicate.
- MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and
  failure, and MongoFailureContext records only the design-permitted fields.
- Failure classification reads server error labels before numeric codes.
- BSON representations come from a pinned manifest, never a library default,
  and a golden type-signature gate fails on any drift.
- Index and validator changes go through the manifest and the admin plane;
  metadata ownership gates every drop.
- Every Advanced capability refuses construction unless its flag is enabled.

Verified against real servers, not only unit tests. Running the lanes for the
first time exposed four defects that a green `check` had hidden:

- Four release lanes passed while executing zero tests; the gate now counts
  executed tests per lane and fails on zero.
- The "single replica set" fixture was a standalone, because Testcontainers 2.x
  needs withReplicaSet(); its test only asserted a connection string.
- The three-node fixture was three independent clusters, so no election could
  occur, and awaitNewPrimary() compared against the post-stop primary.
- The migration lease checked modifiedCount, so a same-millisecond refresh read
  as a lost lease.

scripts/verify-mongodb-platform.sh now reports:
  9 lanes, 0 skipped, 0 failed, every evidence category produced.

scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology
evidence (real sharded cluster, real KMS, real target deployment) is
unobtainable here, so it is named rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 13:41:00 +09:00
co-authored by Claude Opus 5
parent 3b5aee50e3
commit d57d2f62a0
430 changed files with 29846 additions and 154 deletions
@@ -8,27 +8,34 @@
- Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0.
- Registry SSOT: `src/config/architecture/modules.json`.
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in Spring
Data MongoDB infrastructure. Design rationale lives in [README.md](README.md).
Package root: `dev.caskeleton.adapter.outbound.mongo`. Driven (outbound) adapter — opt-in MongoDB
Document Persistence Platform. Design rationale lives in [README.md](README.md); the mapping from the
design package's assumed module layout onto this leaf lives in
`docs/mongodb/repository-adaptation.md` and is the file to update when that mapping changes.
## Responsibility
- Provide opt-in Mongo client and template infrastructure without shipping a fake business domain.
- Real forks add their own document, repository, mapper, and application/domain port implementation.
- Provide the opt-in Mongo client, template and **platform policy** surface without shipping a fake
business domain. Real forks add their own document, repository, mapper, and application/domain port
implementation.
- It does **not** reimplement idempotency / outbox / lock on Mongo (those stay JPA-only).
- Opt-in: `MongoPersistenceConfig` re-imports the Mongo auto-configuration (`@ImportAutoConfiguration`)
only when `ca-skeleton.persistence-mongo.enabled=true` (default off). The connection URI and
database come from Spring's standard `spring.data.mongodb.*` settings.
only when `ca-skeleton.persistence-mongo.enabled=true` (default off). `MongoPlatformAutoConfiguration`
is gated on the same flag. The connection URI and database come from Spring's standard
`spring.data.mongodb.*` settings; platform profiles come from
`ca-skeleton.persistence-mongo.platform.*`.
- `MongoOptInAutoConfigurationImportFilter`, registered through `META-INF/spring.factories`, blocks
Boot 4's classpath-driven sync/reactive/data/repository/health/metrics Mongo auto-configuration
when the module enable flag is absent or false.
## Allowed
- No project dependency is required by the generic infrastructure. The allowed-edge SSOT remains
the `adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`.
- External: `org.springframework.boot:spring-boot-starter-data-mongodb` (version via the shared
Spring Boot BOM), `spring-boot-configuration-processor` (annotation processor).
- No project dependency is required. The allowed-edge SSOT remains the
`adapter-outbound-persistence-mongo` entry in `src/config/architecture/modules.json`.
- External: `spring-boot-starter-data-mongodb` and `-reactive`, `spring-boot-autoconfigure`,
`micrometer-core`, `slf4j-api`, `spring-boot-configuration-processor` (annotation processor).
Versions come from the shared Spring Boot BOM; never pin the driver directly.
- Test-only: ArchUnit, reactor-test, Testcontainers (`mongodb`, `toxiproxy`).
## Forbidden
@@ -38,13 +45,47 @@ Data MongoDB infrastructure. Design rationale lives in [README.md](README.md).
- Adding idempotency/outbox/lock on Mongo without a separately approved contract.
- Fully-qualified inline type references; more than one public top-level type per file.
### Package-boundary rules (`MongoModuleBoundaryTest`)
These reproduce the design's module dependency table. Breaking one fails the build:
- `…mongo.api..` must not import Spring, the MongoDB driver, BSON or Reactor. It is the
framework-free core contract; `api/package-info.java` records why.
- No Stable package may depend on `…mongo.advanced..`.
- No production package may depend on `…mongo.testkit..`.
- `imperative``reactive`, `query``aggregation`, `schema` ↛ execution packages,
`observation` ↛ execution packages, `migration``migration.flamingock`.
### Platform invariants that are not stylistic
- Transaction body retry and commit retry are **separate loops**: a new session per body attempt, and
commit-only retry on an unknown commit. The body is never replayed after a commit ambiguity
(`MongoTransactionRetryCoordinator`, ADR-MONGO-003).
- `MongoExecutionOutcome`'s two ambiguous values must not be collapsed into success or failure.
- BSON representations come from `MongoTypeRepresentationManifest`, never from a library default
(ADR-MONGO-002).
- Index and validator changes go through the manifest and the admin plane; ownership gates every drop
(ADR-MONGO-004).
- Every Advanced entry point refuses construction unless its `MongoAdvancedCapabilityFlags` capability
is enabled.
- Observation tags are limited to `MongoObservationConvention`'s allowlist.
## Tests
`MongoPersistenceConfigTest` proves default/false behavior through an actual
`@EnableAutoConfiguration` context, typed enablement binding, and enabled infrastructure with a
mock `MongoClient` plus a real `MongoTemplate` without a network connection.
mock `MongoClient` plus a real `MongoTemplate` without a network connection. It must keep passing —
the platform additions are opt-in and must not turn the module on by existing.
Hermetic contract tests carry `@Tag("mongodb-contract")` and run in `mongoStableContractTest`, which
`check` depends on. Container lanes carry `mongodb-replicaset` / `mongodb-failover` and run only in
their own tasks; the default `test` task excludes them, because a lane that needs Docker inside
`check` teaches people to skip `check`.
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:check
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
```
Release gates run from the repository root: `scripts/verify-mongodb-platform.sh` (Stable) and
`scripts/verify-mongodb-advanced.sh` (Advanced).
@@ -1,9 +1,13 @@
# adapter:outbound:persistence-mongo
`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in Spring Data MongoDB 인프라 모듈이다.
`dev.caskeleton.adapter.outbound.mongo` 패키지의 opt-in MongoDB Document Persistence Platform이다.
템플릿 production 코드에 가짜 비즈니스 `Example*` 타입을 두지 않고, 실제 프로젝트가 자신의
document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계
제공한다.
document/repository/mapper와 application 또는 domain port 구현을 추가할 수 있는 구성 경계
플랫폼 정책을 제공한다.
설계 원본은 `mongodb-superpowers-package/docs/superpowers/specs/`이고, 이 저장소로 어떻게
매핑했는지는 [docs/mongodb/repository-adaptation.md](../../../../docs/mongodb/repository-adaptation.md)가
단일 기록이다.
## 활성화
@@ -15,9 +19,10 @@ spring.data.mongodb.uri=mongodb://localhost:27017/portfolio
```
활성화 시 `MongoPersistenceConfig`가 Spring Boot의 Mongo client 및 data auto-configuration을
명시적으로 가져와 `MongoClient``MongoTemplate`을 구성한다. repository scanning은 템플릿
임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와 composition을 명시해야
한다.
명시적으로 가져와 `MongoClient``MongoTemplate`을 구성하고, `MongoPlatformAutoConfiguration`
플랫폼 정책 bean(startup validator, client generation registry, health indicator)을 등록한다.
repository scanning은 템플릿이 임의로 소유하지 않는다. 실제 consumer가 자신의 repository package와
composition을 명시해야 한다.
Mongo starter는 classpath만으로도 Boot auto-configuration 후보를 등록하므로 config의 조건만으로는
기본 비활성을 보장할 수 없다. `MongoOptInAutoConfigurationImportFilter`가 Boot 4의 sync/reactive
@@ -26,25 +31,98 @@ client, data, repository, health, metrics Mongo auto-configuration을 default/fa
등록되어 있으며, `enabled=true`일 때는 후보를 그대로 허용한다.
`MongoPersistenceProperties`는 모듈 opt-in만 소유한다. URI, database, credential은 Spring의
표준 `spring.data.mongodb.*` 설정을 사용한다.
표준 `spring.data.mongodb.*` 설정을 사용한다. 플랫폼 profile은
`ca-skeleton.persistence-mongo.platform.*` (`MongoPlatformProperties`)이 소유한다.
## 노출 계층 (D1D4)
| 계층 | 내용 | Client |
|---|---|---|
| D1 표준 document 영속성 | Spring Data repository, typed query, mapping manifest, atomic update, optimistic revision | Stable API V1 strict |
| D2 고급 document 연산 | `MongoTemplate`, transaction/session, bulk, aggregation, keyset cursor, change stream | Stable API V1 strict |
| D3 명시적 capability | native BSON, time series, search/vector, CSFLE/QE, shard-aware | capability client |
| D4 admin plane | collection, validator, index, migration, shard, repair | admin client + 별도 credential |
D3는 raw client escape가 아니다. `PolicyAwareMongoNativeGateway`가 capability → database profile →
collection allowlist → operation name → timeout → consistency → result limit → trace → redaction →
command category → D4 차단 순서를 고정한다.
## 패키지 지도
| 패키지 | 책임 |
|---|---|
| `api` (+ `capability`, `consistency`, `error`, `mapping`, `observation`, `profile`, `schema`) | framework 없는 core 계약. Spring/driver/BSON/Reactor import 금지 (ArchUnit) |
| `mapping` (+ `type`), `failure` | Spring Data 통합, BSON 표현 manifest, 실패 분류·변환 |
| `imperative` (+ `atomic`, `bulk`, `revision`) | 명령형 실행, update operator, bulk 부분 결과, optimistic revision |
| `reactive` (+ `cursor`) | 반응형 실행, cursor lease/guard |
| `query` (+ `budget`, `pagination`) | query guardrail, operation budget, keyset pagination |
| `aggregation` | 등록된 pipeline plan과 risk 등급 |
| `transaction` (+ `retry`, `session`) | transaction 실행, body/commit 분리 retry, causal session |
| `schema` (+ `index`, `manifest`, `model`, `ttl`, `validation`) | document model·index·validator manifest와 diff/apply, TTL 정책 |
| `changestream` (+ `projector`, `recovery`) | at-least-once projector, resume checkpoint, history-lost 처리 |
| `geo` | GeoJSON / 2dsphere |
| `migration` (+ `flamingock`) | checksum·lock·precondition 기반 migration runner |
| `observation` | driver-native command/pool/SDAM 관측, tag allowlist, redaction |
| `security` (+ `admin`), `nativecap` | 역할·credential·TLS profile, admin plane, native capability gateway |
| `autoconfigure` | Boot auto-configuration, startup validation, client generation, release gate |
| `advanced/**` | opt-in Advanced/Experimental capability (sharding, time series, CSFLE, QE, search, vector, tenancy, bridge, GridFS) |
| `architecture` | fork가 자기 코드에 적용하는 `@MongoOperation` marker와 ArchUnit rule set |
## 의존성 경계
- production project dependency 없음
- Spring Boot MongoDB starter configuration processor만 사용
- Spring Boot MongoDB starter(sync/reactive), configuration processor, Micrometer, SLF4J만 사용
- JPA persistence adapter 및 다른 adapter와 의존 관계 없음
- idempotency, outbox, distributed lock은 기존 JPA adapter 책임을 유지
- 패키지 간 방향은 `MongoModuleBoundaryTest`(ArchUnit) 10개 규칙이 강제한다: core-api는 framework
무의존, Stable은 Advanced에 의존 금지, production은 testkit에 의존 금지, imperative↛reactive,
query↛aggregation, schema↛execution, observability↛execution, migration↛flamingock
## 테스트 lane
| Task | 내용 | Docker |
|---|---|---|
| `test` | 단위 + hermetic contract (Docker tag 제외) | 불필요 |
| `mongoStableContractTest` | `mongodb-contract` 태그. `check`에 포함 | 불필요 |
| `mongoReplicaSetTest` | single-node replica set | 필요 |
| `mongoFailoverTest` | 3-node set + Toxiproxy | 필요 |
| `mongoMigrationTest` | migration/backfill 재시작 | 필요 |
| `mongoCompatibilityTest` | MongoDB 7.0 lane | 필요 |
| `mongoSecurityIntegrationTest` | credential/TLS/회전 | 필요 |
| `mongoPerformanceTest` | 자원 budget과 chaos gate | 필요 |
이미지는 고정되어 있다: `mongo:8.0.16`(primary), `mongo:7.0.28`(compatibility),
`ghcr.io/shopify/toxiproxy:2.12.0`. `-PmongoPrimaryImage=` 등으로 재정의할 수 있다.
## 검증
`MongoPersistenceConfigTest`는 다음을 검증한다.
- 실제 `@EnableAutoConfiguration` context의 기본/false 모드에서 Mongo 인프라가 생성되지 않는다.
- enable flag가 typed properties에 바인딩된다.
- enabled 모드는 mock `MongoClient`로 네트워크 없이 실제 `MongoTemplate`을 생성한다.
- `Example` production bean이 존재하지 않는다.
```bash
cd src
./gradlew :adapter:outbound:persistence-mongo:check --console=plain
```
릴리스 게이트는 저장소 루트에서 실행한다.
```bash
bash scripts/verify-mongodb-platform.sh
bash scripts/verify-mongodb-advanced.sh
```
## 문서
- [docs/mongodb/support-matrix.md](../../../../docs/mongodb/support-matrix.md)
- [docs/mongodb/document-modeling-guide.md](../../../../docs/mongodb/document-modeling-guide.md)
- [docs/mongodb/bson-mapping-guide.md](../../../../docs/mongodb/bson-mapping-guide.md)
- [docs/mongodb/consistency-transaction-guide.md](../../../../docs/mongodb/consistency-transaction-guide.md)
- [docs/mongodb/query-aggregation-guide.md](../../../../docs/mongodb/query-aggregation-guide.md)
- [docs/mongodb/schema-index-migration-guide.md](../../../../docs/mongodb/schema-index-migration-guide.md)
- [docs/mongodb/change-stream-guide.md](../../../../docs/mongodb/change-stream-guide.md)
- [docs/mongodb/security-observability.md](../../../../docs/mongodb/security-observability.md)
- Runbook: [failover](../../../../docs/mongodb/runbooks/failover.md) ·
[unknown-commit](../../../../docs/mongodb/runbooks/unknown-commit.md) ·
[history-lost](../../../../docs/mongodb/runbooks/history-lost.md)
- ADR: [001 platform boundary](../../../../docs/adr/ADR-MONGO-001-platform-boundary.md) ·
[002 BSON representation](../../../../docs/adr/ADR-MONGO-002-bson-representation.md) ·
[003 transaction retry](../../../../docs/adr/ADR-MONGO-003-transaction-retry.md) ·
[004 index/schema admin plane](../../../../docs/adr/ADR-MONGO-004-index-schema-admin-plane.md) ·
[ADV-001 capability promotion](../../../../docs/adr/ADR-MONGO-ADV-001-capability-promotion.md)
@@ -1,13 +1,195 @@
// Driven adapter: opt-in Spring Data MongoDB infrastructure. This leaf owns only enablement and
// Mongo client/template auto-configuration; consuming projects add real documents, repositories,
// mappings, and ports without shipping a fake business domain in the template.
// MongoDB Document Persistence Platform leaf — see
// docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md (design package)
// and docs/mongodb/repository-adaptation.md (how the design's 19 stable + 12 advanced library
// modules map here).
//
// spring-boot-starter-data-mongodb's version is managed by the Spring Boot BOM (applied to every
// module in src/build.gradle), so no module-scoped platform is needed.
description = 'Outbound adapter: opt-in Spring Data MongoDB infrastructure'
// The design models the platform as 19 Stable and 12 Advanced Gradle modules under
// `modules/mongodb` and `modules/mongodb-advanced`. This repository's fail-closed 19-leaf registry
// (src/config/architecture/modules.json) outranks that layout, so the module boundaries are
// packages under dev.caskeleton.adapter.outbound.mongo and MongoModuleBoundaryTest enforces the
// design's module dependency table.
//
// Driver and Spring Data MongoDB versions come from the Spring Boot BOM applied to every module in
// src/build.gradle (design §4: "개별 Driver 버전 override 금지"), so nothing here pins them.
description = 'Outbound adapter: MongoDB document persistence platform (manifests, atomic writes, ' +
'consistency profiles, guardrails, change streams)'
dependencies {
// D1/D2 imperative execution path and the mapping subsystem.
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
// D1/D2 reactive execution path: reactive template, cursors and change streams (design §20).
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
// The starter package registers auto-configuration and binds typed properties.
implementation 'org.springframework.boot:spring-boot-autoconfigure'
// Driver-native observability conventions (design §27) publish through Micrometer.
implementation 'io.micrometer:micrometer-core'
implementation 'org.slf4j:slf4j-api'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// The design's module dependency table is enforced as package rules, so ArchUnit is what keeps
// "packages instead of modules" from meaning "no boundary at all".
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'io.projectreactor:reactor-test'
// Real replica set / failover / migration lanes (design §29). Test-scoped so no production
// package can reach a container fixture.
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testImplementation 'org.testcontainers:testcontainers-mongodb'
testImplementation 'org.testcontainers:testcontainers-toxiproxy'
}
// The testkit is its own source set rather than part of `test` because several lanes consume it and
// because the design forbids a production module from depending on the testkit. Declaring its
// dependencies only on the test configurations gives that guarantee without a new Gradle project.
sourceSets {
testkit {
java.srcDir 'src/testkit/java'
resources.srcDir 'src/testkit/resources'
compileClasspath += sourceSets.main.output
runtimeClasspath += output + compileClasspath
}
mongoPerformanceTest {
java.srcDir 'src/mongoPerformanceTest/java'
compileClasspath += sourceSets.main.output + sourceSets.testkit.output
runtimeClasspath += output + compileClasspath
}
}
configurations {
// The testkit compiles against exactly what a test does: testImplementation already extends
// implementation, so this is the module's own dependencies plus the test libraries.
testkitImplementation.extendsFrom testImplementation
testkitRuntimeOnly.extendsFrom testRuntimeOnly
mongoPerformanceTestImplementation.extendsFrom testImplementation
mongoPerformanceTestRuntimeOnly.extendsFrom testRuntimeOnly
}
// Every test lane compiles and runs against the testkit.
sourceSets.test {
compileClasspath += sourceSets.testkit.output
runtimeClasspath += sourceSets.testkit.output
}
dependencies {
testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testkitImplementation 'io.projectreactor:reactor-test'
testkitImplementation 'org.testcontainers:testcontainers'
testkitImplementation 'org.testcontainers:testcontainers-junit-jupiter'
testkitImplementation 'org.testcontainers:testcontainers-mongodb'
testkitImplementation 'org.testcontainers:testcontainers-toxiproxy'
}
// Pinned server images. The design forbids `latest` for a certification lane (Task 44): a mutable
// tag makes a red run unattributable. `-PmongoPrimaryImage=` / `-PmongoCompatibilityImage=`
// override them for a one-off run.
Closure<Void> applyMongoImageSelection = { task ->
task.systemProperty 'mongodb.primary.image',
(project.findProperty('mongoPrimaryImage') ?: 'mongo:8.0.16').toString()
task.systemProperty 'mongodb.compatibility.image',
(project.findProperty('mongoCompatibilityImage') ?: 'mongo:7.0.28').toString()
task.systemProperty 'mongodb.toxiproxy.image',
(project.findProperty('mongoToxiproxyImage') ?: 'ghcr.io/shopify/toxiproxy:2.12.0').toString()
}
// Docker-backed lanes are excluded from the default unit run: they fail closed without Docker, and
// a `check` that fails on a laptop without Docker teaches people to skip `check`.
tasks.named('test', Test) {
useJUnitPlatform {
excludeTags 'quarantine',
'mongodb-replicaset',
'mongodb-failover',
'mongodb-migration',
'mongodb-compatibility',
'mongodb-security-integration'
}
}
tasks.register('mongoReplicaSetTest', Test) {
group = 'verification'
description = 'Single-node replica set contract lane: mapping, atomic write, transaction, ' +
'change stream (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-replicaset' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoFailoverTest', Test) {
group = 'verification'
description = 'Three-node replica set failover lane: primary kill, partition, unknown commit, ' +
'resume (design §29).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-failover' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoMigrationTest', Test) {
group = 'verification'
description = 'Migration lane: empty / N-1 / oldest-supported snapshots, lock, checkpoint ' +
'restart (design §12).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-migration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoCompatibilityTest', Test) {
group = 'verification'
description = 'MongoDB 7.0 compatibility and 8.0 primary certification matrix (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-compatibility' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoSecurityIntegrationTest', Test) {
group = 'verification'
description = 'RBAC, TLS, injection and redaction release gate against a real server ' +
'(design §26).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-security-integration' }
applyMongoImageSelection(it)
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
tasks.register('mongoPerformanceTest', Test) {
group = 'verification'
description = 'Certifies contention, aggregation spill, pagination and pool resource bounds ' +
'(design §29).'
testClassesDirs = sourceSets.mongoPerformanceTest.output.classesDirs
classpath = sourceSets.mongoPerformanceTest.runtimeClasspath
useJUnitPlatform()
applyMongoImageSelection(it)
systemProperty 'performance.assertions.enabled',
(project.findProperty('performance.assertions.enabled') ?: 'false').toString()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
// `check` gains only the hermetic lanes. The Docker-backed ones stay opt-in for the reason above.
tasks.named('check') {
dependsOn 'mongoStableContractTest'
}
tasks.register('mongoStableContractTest', Test) {
group = 'verification'
description = 'Hermetic stable contract suite: manifests, guardrails, retry scopes, ' +
'redaction (design §30).'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform { includeTags 'mongodb-contract' }
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
}
@@ -1,166 +1,194 @@
# 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=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.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
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,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=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,mongoPerformanceTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath
com.google.code.gson:gson:2.13.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.9.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
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.bytebuddy:byte-buddy-agent:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.java.dev.jna:jna:5.18.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.minidev:accessors-smart:2.6.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.minidev:json-smart:2.6.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
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
org.apache.commons:commons-compress:1.28.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
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=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
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
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
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.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.assertj:assertj-core:3.27.6=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.awaitility:awaitility:4.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
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.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hamcrest:hamcrest:3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
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
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jetbrains:annotations:17.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,mongoPerformanceTestAnnotationProcessor,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.junit:junit-bom:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.mongodb:bson-record-codec:5.6.1=runtimeClasspath,testRuntimeClasspath
org.mongodb:bson:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,runtimeClasspath,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=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.latencyutils:LatencyUtils:2.0.3=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:bson-record-codec:5.6.1=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.mongodb:bson:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-reactivestreams:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.objenesis:objenesis:3.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath
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
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.ow2.asm:asm:9.7.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=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.rnorth.duct-tape:duct-tape:1.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
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-autoconfigure:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
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-mongodb:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-persistence: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-data-mongodb:4.0.0=compileClasspath,runtimeClasspath,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=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-mongodb: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=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
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.data:spring-data-mongodb:5.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: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-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-reactor:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-mongodb-reactive:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.data:spring-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.data:spring-data-mongodb:5.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-test:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-tx:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-web:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-mongodb:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-toxiproxy:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,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.xmlunit:xmlunit-core:2.10.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
empty=
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/**
* The per-capability opt-in switch for everything Advanced (advanced plan Task 1).
*
* <p>Every Advanced capability is off unless explicitly enabled. That is not caution for its own
* sake: each of these needs something the Stable lane does not have — a sharded cluster, an Atlas
* deployment, a KMS, a separate credential — and a capability that wires itself because its code is
* on the classpath will fail at the first call rather than at startup.
*
* <p>The Stable starter references nothing in this package, so no Advanced capability can arrive as
* a transitive dependency of ordinary document persistence.
*/
public final class MongoAdvancedCapabilityFlags {
/** The configuration prefix each capability's flag lives under. */
public static final String PROPERTY_PREFIX = "ca-skeleton.persistence-mongo.advanced";
private final Map<MongoCapability, Boolean> enabled;
private MongoAdvancedCapabilityFlags(Map<MongoCapability, Boolean> enabled) {
this.enabled = enabled;
}
/** Every Advanced capability disabled. */
public static MongoAdvancedCapabilityFlags allDisabled() {
return new MongoAdvancedCapabilityFlags(new EnumMap<>(MongoCapability.class));
}
/** A flag set built from configuration. */
public static MongoAdvancedCapabilityFlags of(Map<MongoCapability, Boolean> flags) {
Objects.requireNonNull(flags, "flags");
return new MongoAdvancedCapabilityFlags(new EnumMap<>(flags));
}
/** Returns a copy with one capability enabled. */
public MongoAdvancedCapabilityFlags withEnabled(MongoCapability capability) {
Map<MongoCapability, Boolean> updated = new EnumMap<>(enabled);
updated.put(Objects.requireNonNull(capability, "capability"), true);
return new MongoAdvancedCapabilityFlags(updated);
}
/** True when a capability has been explicitly enabled. */
public boolean isEnabled(MongoCapability capability) {
return Boolean.TRUE.equals(enabled.get(Objects.requireNonNull(capability, "capability")));
}
/**
* Fails when a capability is used without being enabled.
*
* @throws MongoOperationRejectedException naming the property that would enable it
*/
public void require(MongoCapability capability) {
if (!isEnabled(capability)) {
throw MongoOperationRejectedException.of(
"advanced.capability",
"capability "
+ capability
+ " is an opt-in Advanced module; set "
+ propertyFor(capability)
+ "=true and provide the topology, credential and provider it requires");
}
}
/** The configuration property that enables a capability. */
public static String propertyFor(MongoCapability capability) {
return PROPERTY_PREFIX
+ '.'
+ Objects.requireNonNull(capability, "capability")
.name()
.toLowerCase(java.util.Locale.ROOT)
.replace('_', '-')
+ ".enabled";
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What promoting an Advanced capability requires (advanced plan Task 15).
*
* <p>{@code actual-topology} is the category that cannot be substituted. Every other kind of
* evidence can be produced in CI; sharding, search, vector and encryption behave differently on a
* real cluster or a real provider, and that difference is the whole reason they are Advanced rather
* than Stable.
*/
public record MongoAdvancedPromotionEvidence(Set<String> requiredCategories, Set<String> supplied) {
/** The categories every promotion must supply. */
public static final Set<String> REQUIRED =
Set.of("stable-platform", "actual-topology", "security", "migration", "failure", "runbook");
public MongoAdvancedPromotionEvidence {
Objects.requireNonNull(requiredCategories, "requiredCategories");
Objects.requireNonNull(supplied, "supplied");
requiredCategories = Set.copyOf(requiredCategories);
supplied = Set.copyOf(supplied);
}
/** The standard requirement set with nothing supplied yet. */
public static MongoAdvancedPromotionEvidence fixture() {
return new MongoAdvancedPromotionEvidence(REQUIRED, Set.of());
}
/** Returns a copy with one more category supplied. */
public MongoAdvancedPromotionEvidence with(String category) {
Set<String> updated = new LinkedHashSet<>(supplied);
updated.add(Objects.requireNonNull(category, "category"));
return new MongoAdvancedPromotionEvidence(requiredCategories, updated);
}
/**
* Asserts one category is supplied.
*
* @throws IllegalStateException naming the missing category
*/
public void require(String category) {
if (!supplied.contains(Objects.requireNonNull(category, "category"))) {
throw new IllegalStateException(
"the MongoDB Advanced promotion gate is missing '"
+ category
+ "' evidence; the required categories are "
+ requiredCategories);
}
}
/** The categories still missing. */
public Set<String> missing() {
Set<String> missing = new LinkedHashSet<>(requiredCategories);
missing.removeAll(supplied);
return Set.copyOf(missing);
}
}
@@ -0,0 +1,42 @@
package dev.caskeleton.adapter.outbound.mongo.advanced;
import java.util.Objects;
/**
* The check that decides whether an Advanced capability may be promoted (advanced plan Task 15).
*
* <p>Promotion to Stable changes the support level, not the dependency boundary: a promoted
* capability is still an opt-in module until a separate starter ADR says otherwise. Conflating the
* two would mean a promotion silently adds a dependency — and a topology requirement — to every
* deployment that only wanted ordinary document persistence.
*/
public final class MongoAdvancedPromotionGate {
/**
* Verifies one capability's promotion evidence.
*
* @throws IllegalStateException naming the first missing category
*/
public void verify(MongoAdvancedPromotionEvidence evidence) {
Objects.requireNonNull(evidence, "evidence");
evidence.require("stable-platform");
evidence.require("actual-topology");
evidence.require("security");
evidence.require("failure");
evidence.require("runbook");
}
/** True when every required category is supplied. */
public boolean passes(MongoAdvancedPromotionEvidence evidence) {
return Objects.requireNonNull(evidence, "evidence").missing().isEmpty();
}
/**
* Whether promotion adds the capability to the Stable starter's dependencies.
*
* <p>Always false; that needs its own ADR.
*/
public boolean addsStarterDependency() {
return false;
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
/**
* When the bridge may advance its MongoDB checkpoint (advanced plan Task 12).
*
* <p>Only after the broker has accepted the message. Advancing on a failed publish loses the event
* with no trace — the change stream moves past it and nothing will ever redeliver it — so the
* bridge chooses duplicates over loss here, exactly as the projector does.
*/
public enum MongoBridgeCheckpointPolicy {
/** Advance only after the broker accepted the message. */
AFTER_PUBLISH_CONFIRMED,
/**
* Advance after an ambiguous publish result.
*
* <p>Valid only when the message id is deterministic and the consumer deduplicates, because the
* message may or may not have been accepted.
*/
AFTER_PUBLISH_AMBIGUOUS_WITH_DEDUPLICATION;
/**
* True when the checkpoint may advance given this publish outcome.
*
* @param published whether the broker confirmed acceptance
* @param ambiguous whether the publish result was unknown
*/
public boolean mayAdvance(boolean published, boolean ambiguous) {
if (published) {
return true;
}
return ambiguous && this == AFTER_PUBLISH_AMBIGUOUS_WITH_DEDUPLICATION;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
/**
* When a change stream bridge is not enough (advanced plan Task 12).
*
* <p>The bridge publishes after the write is committed, so there is a window in which the write
* exists and the event does not. For most integrations that is fine — the event arrives late. It is
* not fine when the event must exist if and only if the write does, and the only way to get that is
* to write the event in the same transaction as the data, which is a transactional outbox.
*
* <p>Stated as a policy rather than a comment because the distinction is easy to get wrong in the
* direction that looks like it works.
*/
public enum MongoBridgeOutboxPolicy {
/**
* At-least-once publication after commit is acceptable.
*
* <p>The consumer tolerates duplicates and a delay, and no business rule depends on the event
* existing exactly when the write does.
*/
CHANGE_STREAM_SUFFICIENT,
/**
* The event and the write must be atomic.
*
* <p>Use a transactional outbox: write the event document in the same transaction as the data and
* publish from the outbox.
*/
OUTBOX_REQUIRED;
/** True when a change stream bridge can serve this integration. */
public boolean bridgeSufficient() {
return this == CHANGE_STREAM_SUFFICIENT;
}
/**
* Chooses a policy from the integration's requirements.
*
* @param eventMustBeAtomicWithWrite whether a consumer may ever observe the write without the
* event
*/
public static MongoBridgeOutboxPolicy forRequirement(boolean eventMustBeAtomicWithWrite) {
return eventMustBeAtomicWithWrite ? OUTBOX_REQUIRED : CHANGE_STREAM_SUFFICIENT;
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpointStore;
import java.util.Objects;
import org.bson.BsonDocument;
import reactor.core.publisher.Mono;
/**
* Publishes mapped integration events and checkpoints only after the broker accepts (advanced plan
* Task 12).
*
* <p>A failed publish leaves the MongoDB checkpoint untouched, so the change is redelivered. That
* is the same trade the change stream projector makes — duplicates rather than loss — and it works
* for the same reason: the message id is derived from the change identity, so a redelivered change
* produces a message the consumer can recognise as one it has already seen.
*/
public final class MongoChangeMessagingBridge {
private final MongoChangeToIntegrationEventMapper mapper;
private final MongoIntegrationEventPublisher publisher;
private final MongoResumeCheckpointStore checkpoints;
private final MongoBridgeCheckpointPolicy checkpointPolicy;
public MongoChangeMessagingBridge(
MongoChangeToIntegrationEventMapper mapper,
MongoIntegrationEventPublisher publisher,
MongoResumeCheckpointStore checkpoints,
MongoBridgeCheckpointPolicy checkpointPolicy) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.publisher = Objects.requireNonNull(publisher, "publisher");
this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints");
this.checkpointPolicy = Objects.requireNonNull(checkpointPolicy, "checkpointPolicy");
}
/**
* Handles one change event.
*
* <p>A change the mapper does not map still advances the checkpoint: it was considered and found
* uninteresting, which is different from having failed to publish it.
*/
public Mono<Void> handle(
MongoChangeEventIdentity identity, BsonDocument change, MongoResumeCheckpoint checkpoint) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(change, "change");
Objects.requireNonNull(checkpoint, "checkpoint");
MongoIntegrationEventEnvelope envelope = mapper.map(identity, change);
if (envelope == null) {
return checkpoints.save(checkpoint);
}
return publisher
.publish(envelope)
.then(Mono.defer(() -> advanceIfAllowed(checkpoint, true, false)))
.onErrorResume(failure -> Mono.error(failure));
}
private Mono<Void> advanceIfAllowed(
MongoResumeCheckpoint checkpoint, boolean published, boolean ambiguous) {
return checkpointPolicy.mayAdvance(published, ambiguous)
? checkpoints.save(checkpoint)
: Mono.empty();
}
}
@@ -0,0 +1,24 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity;
import org.bson.BsonDocument;
/**
* Turns a physical change into a business event (advanced plan Task 12).
*
* <p>This mapper is the boundary the design refuses to remove. A MongoDB change event exposes the
* collection's field names, its update descriptions and its storage layout; publishing it as an
* integration contract makes every consumer depend on all three, so renaming a field becomes a
* breaking change to an external API.
*
* <p>The event type, schema version and message id are the mapper's own decisions, not derived from
* the change document, which is what lets the storage layout change without the contract changing.
*/
@FunctionalInterface
public interface MongoChangeToIntegrationEventMapper {
/**
* Maps one change event, or returns {@code null} when the change is not externally interesting.
*/
MongoIntegrationEventEnvelope map(MongoChangeEventIdentity identity, BsonDocument change);
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import java.util.Map;
import java.util.Objects;
/**
* A stable integration event, ready to publish (advanced plan Task 12).
*
* <p>The platform's own envelope rather than the messaging module's type: this leaf may not depend
* on a sibling adapter, so the composition root adapts this to whatever the messaging platform
* publishes. The adaptation is one mapper; the alternative would be a dependency edge the
* architecture registry forbids.
*
* <p>{@code messageId} is derived from the change event identity, so a redelivered change produces
* the same message id and the consumer's deduplication works.
*/
public record MongoIntegrationEventEnvelope(
String eventType, int schemaVersion, String messageId, Map<String, Object> payload) {
public MongoIntegrationEventEnvelope {
Objects.requireNonNull(eventType, "eventType");
Objects.requireNonNull(messageId, "messageId");
Objects.requireNonNull(payload, "payload");
payload = Map.copyOf(payload);
if (eventType.isBlank()) {
throw new IllegalArgumentException("an integration event needs a type");
}
if (schemaVersion < 1) {
throw new IllegalArgumentException("an integration event schema version starts at 1");
}
if (messageId.isBlank()) {
throw new IllegalArgumentException(
"an integration event needs a deterministic message id so a redelivered change produces "
+ "the same message");
}
}
}
@@ -0,0 +1,17 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.bridge;
import reactor.core.publisher.Mono;
/**
* The outbound port the bridge publishes through (advanced plan Task 12).
*
* <p>Declared here rather than imported from the messaging adapter, because the architecture
* registry does not permit an edge between two outbound adapters. The composition root implements
* this against whichever messaging platform is wired.
*/
@FunctionalInterface
public interface MongoIntegrationEventPublisher {
/** Publishes one event. Completes only when the broker has accepted it. */
Mono<Void> publish(MongoIntegrationEventEnvelope envelope);
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import com.mongodb.AutoEncryptionSettings;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Builds the automatic encryption settings for a CSFLE-enabled client (advanced plan Task 6).
*
* <p>KMS providers are passed through as an opaque map and never copied into a field, a log or a
* failure message. The platform's job here is to assemble settings, not to hold key material for
* longer than the call.
*/
public final class MongoCsfleClientFactory {
public MongoCsfleClientFactory(MongoAdvancedCapabilityFlags flags) {
// Checked once, at construction: an instance cannot exist unless CSFLE was enabled.
Objects.requireNonNull(flags, "flags").require(MongoCapability.CSFLE);
}
/**
* Builds automatic encryption settings for one profile.
*
* @param kmsProviders the KMS configuration, passed straight to the driver
* @param encryptedFieldsMapJson the per-collection encrypted field map, as extended JSON
*/
public AutoEncryptionSettings settingsFor(
MongoCsfleProfile profile,
Map<String, Map<String, Object>> kmsProviders,
String encryptedFieldsMapJson) {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(kmsProviders, "kmsProviders");
Objects.requireNonNull(encryptedFieldsMapJson, "encryptedFieldsMapJson");
Map<String, org.bson.BsonDocument> schemaMap = new LinkedHashMap<>();
schemaMap.put(profile.collection(), org.bson.BsonDocument.parse(encryptedFieldsMapJson));
return AutoEncryptionSettings.builder()
.keyVaultNamespace(profile.keyVaultNamespace())
.kmsProviders(kmsProviders)
.schemaMap(schemaMap)
.build();
}
/** The capability this factory requires. */
public MongoCapability capability() {
return MongoCapability.CSFLE;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import java.util.Objects;
/**
* How one field is encrypted, and why (advanced plan Task 6).
*
* <p>{@link #forPii} defaults to randomized. Deterministic encryption is only reachable by asking
* for it with a stated equality-query requirement, because it is the choice that leaks: the default
* has to be the safe one, since the unsafe one is also the more convenient one.
*/
public record MongoCsfleFieldPolicy(
String fieldPath, MongoCsfleMode mode, String keyAlias, String equalityQueryJustification) {
public MongoCsfleFieldPolicy {
Objects.requireNonNull(fieldPath, "fieldPath");
Objects.requireNonNull(mode, "mode");
Objects.requireNonNull(keyAlias, "keyAlias");
Objects.requireNonNull(equalityQueryJustification, "equalityQueryJustification");
if (fieldPath.isBlank()) {
throw new IllegalArgumentException("an encrypted field needs a path");
}
if (mode.requiresLeakageReview() && equalityQueryJustification.isBlank()) {
throw new IllegalArgumentException(
"deterministic encryption of '"
+ fieldPath
+ "' needs a documented equality-query requirement: stable ciphertext exposes the "
+ "value distribution, which recovers low-cardinality plaintext by frequency analysis");
}
}
/**
* The policy for a PII field.
*
* @param queryable whether the field must support equality queries
*/
public static MongoCsfleFieldPolicy forPii(String fieldPath, boolean queryable) {
return queryable
? new MongoCsfleFieldPolicy(
fieldPath,
MongoCsfleMode.DETERMINISTIC,
defaultKeyAlias(fieldPath),
"equality lookup required by the use case")
: new MongoCsfleFieldPolicy(
fieldPath, MongoCsfleMode.RANDOMIZED, defaultKeyAlias(fieldPath), "");
}
/** The policy for a field that is never queried and never indexed. */
public static MongoCsfleFieldPolicy unindexed(String fieldPath) {
return new MongoCsfleFieldPolicy(
fieldPath, MongoCsfleMode.UNINDEXED, defaultKeyAlias(fieldPath), "");
}
private static String defaultKeyAlias(String fieldPath) {
return "key-" + fieldPath.replace('.', '-');
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
/**
* How a CSFLE field is encrypted (advanced plan Task 6).
*
* <p>The two differ in what they leak. Randomized produces a different ciphertext every time, so
* nothing can be inferred and nothing can be queried. Deterministic produces the same ciphertext
* for the same plaintext, which makes equality queries work and makes the distribution of values
* visible — on a low-cardinality field such as a status or a country, frequency analysis recovers
* the plaintext without any key.
*/
public enum MongoCsfleMode {
/** Different ciphertext each time. Not queryable, leaks nothing. The default for PII. */
RANDOMIZED,
/** Stable ciphertext. Supports equality queries, leaks value distribution. */
DETERMINISTIC,
/** Encrypted without an index; not queryable at all. */
UNINDEXED;
/** True when equality queries work against this mode. */
public boolean supportsEqualityQuery() {
return this == DETERMINISTIC;
}
/** True when choosing this mode requires a documented leakage review. */
public boolean requiresLeakageReview() {
return this == DETERMINISTIC;
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference;
import java.util.List;
import java.util.Objects;
/**
* One collection's CSFLE configuration (advanced plan Task 6).
*
* <p>The key vault has its own credential. Sharing the application's would mean a compromise of the
* application is also a compromise of the data keys, which makes the encryption ornamental.
*
* <p>CSFLE and Queryable Encryption are refused on the same collection: they are separate
* mechanisms with separate metadata, and combining them produces a collection neither can fully
* read.
*/
public record MongoCsfleProfile(
String collection,
List<MongoCsfleFieldPolicy> fields,
MongoCredentialReference keyVaultCredential,
String keyVaultNamespace,
boolean queryableEncryptionPresent) {
public MongoCsfleProfile {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(fields, "fields");
Objects.requireNonNull(keyVaultCredential, "keyVaultCredential");
Objects.requireNonNull(keyVaultNamespace, "keyVaultNamespace");
fields = List.copyOf(fields);
if (queryableEncryptionPresent) {
throw new IllegalArgumentException(
"collection '"
+ collection
+ "' already uses Queryable Encryption; CSFLE and QE are separate mechanisms and must "
+ "not be applied to the same collection");
}
if (fields.isEmpty()) {
throw new IllegalArgumentException("a CSFLE profile needs at least one encrypted field");
}
}
/**
* Rejects CSFLE on a time series collection.
*
* @throws MongoOperationRejectedException when the collection is a time series collection
*/
public void requireNotTimeSeries(boolean timeSeries) {
if (timeSeries) {
throw MongoOperationRejectedException.of(
"encryption.csfle",
"a time series collection does not support CSFLE; encrypt the measurements upstream");
}
}
/** The field policies that support equality queries. */
public List<MongoCsfleFieldPolicy> queryableFields() {
return fields.stream().filter(field -> field.mode().supportsEqualityQuery()).toList();
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle;
import java.util.Optional;
/**
* Resolves the data key a field is encrypted with (advanced plan Task 6).
*
* <p>An interface rather than a lookup table, because the key for a field can depend on the tenant.
* Per-tenant keys are what make "delete this tenant's data" achievable by destroying one key
* instead of finding every document.
*
* <p>Implementations return an alias, never key material. The driver resolves the alias against the
* key vault; the platform never holds a plaintext key.
*/
public interface MongoDataKeyResolver {
/** The key alias for a field, optionally scoped to a tenant. */
Optional<String> resolveKeyAlias(String collection, String fieldPath, String tenantKey);
/** A resolver that always returns the field policy's declared alias. */
static MongoDataKeyResolver fixed(MongoCsfleProfile profile) {
return (collection, fieldPath, tenantKey) ->
profile.fields().stream()
.filter(field -> field.fieldPath().equals(fieldPath))
.map(MongoCsfleFieldPolicy::keyAlias)
.findFirst();
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import java.util.Objects;
import java.util.Optional;
/**
* One Queryable Encryption field (advanced plan Task 7).
*
* <p>A range field must declare its bounds. QE range indexes are built over a declared domain, and
* a value outside it cannot be inserted — so the bounds are part of the schema, not a tuning
* parameter, and widening them later is a re-encryption rather than a configuration change.
*/
public record MongoEncryptedFieldDescriptor(
String path,
MongoQueryableEncryptionQueryType queryType,
String keyAlias,
String bsonType,
Long rangeMinimum,
Long rangeMaximum) {
public MongoEncryptedFieldDescriptor {
Objects.requireNonNull(path, "path");
Objects.requireNonNull(queryType, "queryType");
Objects.requireNonNull(keyAlias, "keyAlias");
Objects.requireNonNull(bsonType, "bsonType");
if (path.isBlank()) {
throw new IllegalArgumentException("an encrypted field needs a path");
}
if (queryType == MongoQueryableEncryptionQueryType.RANGE
&& (rangeMinimum == null || rangeMaximum == null)) {
throw new IllegalArgumentException(
"range field '"
+ path
+ "' must declare its minimum and maximum; a QE range index is built over a declared "
+ "domain and widening it later means re-encrypting the collection");
}
if (rangeMinimum != null && rangeMaximum != null && rangeMinimum >= rangeMaximum) {
throw new IllegalArgumentException("range field '" + path + "' has an empty domain");
}
}
/** An equality-queryable encrypted field. */
public static MongoEncryptedFieldDescriptor equality(
String path, String keyAlias, String bsonType) {
return new MongoEncryptedFieldDescriptor(
path, MongoQueryableEncryptionQueryType.EQUALITY, keyAlias, bsonType, null, null);
}
/** A range-queryable encrypted field over a declared domain. */
public static MongoEncryptedFieldDescriptor range(
String path, String keyAlias, String bsonType, long minimum, long maximum) {
return new MongoEncryptedFieldDescriptor(
path, MongoQueryableEncryptionQueryType.RANGE, keyAlias, bsonType, minimum, maximum);
}
/** The declared domain, when this is a range field. */
public Optional<long[]> domain() {
return rangeMinimum == null || rangeMaximum == null
? Optional.empty()
: Optional.of(new long[] {rangeMinimum, rangeMaximum});
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership;
import java.util.Objects;
import java.util.Set;
/**
* Marks Queryable Encryption's internal state as untouchable (advanced plan Task 7).
*
* <p>QE maintains a {@code __safeContent__} array and companion metadata collections. To
* application drift cleanup they look exactly like orphans nobody declared — and dropping one makes
* the encrypted collection unqueryable until it is rebuilt from scratch. This is the list that
* stops that from happening.
*/
public final class MongoEncryptionMetadataOwnership {
/** The field QE maintains inside every encrypted document. */
public static final String SAFE_CONTENT_FIELD = "__safeContent__";
/** The prefix of the collections QE maintains alongside an encrypted collection. */
public static final String METADATA_COLLECTION_PREFIX = "enxcol_.";
private MongoEncryptionMetadataOwnership() {}
/** The internal collections QE maintains for one encrypted collection. */
public static Set<String> metadataCollectionsFor(String collection) {
Objects.requireNonNull(collection, "collection");
return Set.of(
METADATA_COLLECTION_PREFIX + collection + ".esc",
METADATA_COLLECTION_PREFIX + collection + ".ecoc");
}
/** The ownership a drift engine must assign to a QE-managed artefact. */
public static MongoMetadataOwnership ownershipOf(String name) {
Objects.requireNonNull(name, "name");
return name.startsWith(METADATA_COLLECTION_PREFIX) || name.contains(SAFE_CONTENT_FIELD)
? MongoMetadataOwnership.ENCRYPTION_MANAGED
: MongoMetadataOwnership.APPLICATION;
}
/** True when drift cleanup must leave this artefact alone. */
public static boolean isEncryptionManaged(String name) {
return ownershipOf(name) == MongoMetadataOwnership.ENCRYPTION_MANAGED;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Creates and maintains encrypted collections, on the admin plane (advanced plan Task 7).
*
* <p>An encrypted collection must exist with its encrypted-fields map before the first application
* write. Writing to a collection that was created without it produces plaintext documents that look
* correct and are not encrypted — and the only fix is to re-encrypt and re-import everything
* already written.
*/
public final class MongoQueryableEncryptionCollectionManager {
private final MongoAdminGateway adminGateway;
public MongoQueryableEncryptionCollectionManager(
MongoAdminGateway adminGateway, MongoAdvancedCapabilityFlags flags) {
this.adminGateway = Objects.requireNonNull(adminGateway, "adminGateway");
// The flag is checked once, here: an instance of this manager cannot exist unless the
// capability
// was enabled, so no later method has to re-check it.
Objects.requireNonNull(flags, "flags").require(MongoCapability.QUERYABLE_ENCRYPTION);
}
/** Creates the encrypted collection and its metadata collections. */
public void createEncryptedCollection(
MongoQueryableEncryptionProfile profile,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(profile, "profile");
adminGateway.execute(
MongoAdminOperation.CREATE_COLLECTION, profile.collection(), operator, reason, apply);
}
/**
* Refuses an application write to a collection that was not set up as encrypted.
*
* @throws MongoOperationRejectedException when setup has not completed
*/
public void requireSetupComplete(String collection, boolean encryptedCollectionExists) {
if (!encryptedCollectionExists) {
throw MongoOperationRejectedException.of(
"encryption.qe",
"collection '"
+ collection
+ "' has not been created with its encrypted-fields map; writing now would store "
+ "plaintext that looks correct and is not encrypted");
}
}
/** Rotates a data key. Requires its own runbook and evidence. */
public void rotateDataKey(String keyAlias, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(
MongoAdminOperation.MANAGE_ENCRYPTION_KEY, keyAlias, operator, reason, apply);
}
/** Compacts the QE metadata collections, which grow with every encrypted write. */
public void compactMetadata(
String collection, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(MongoAdminOperation.COLL_MOD, collection, operator, reason, apply);
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
import java.util.List;
import java.util.Objects;
/**
* One collection's Queryable Encryption configuration (advanced plan Task 7).
*
* <p>The unsupported query shapes are constructible and immediately rejected, so a team that
* planned a "search encrypted names" feature learns it is not available from a named exception
* rather than from a query that returns nothing.
*/
public record MongoQueryableEncryptionProfile(
String collection, List<MongoEncryptedFieldDescriptor> fields, boolean csfleAlreadyApplied) {
public MongoQueryableEncryptionProfile {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(fields, "fields");
fields = List.copyOf(fields);
if (csfleAlreadyApplied) {
throw new IllegalArgumentException(
"collection '"
+ collection
+ "' already uses CSFLE; CSFLE and Queryable Encryption are separate mechanisms and "
+ "must not be applied to the same collection");
}
if (fields.isEmpty()) {
throw new IllegalArgumentException("a QE profile needs at least one encrypted field");
}
}
/** A profile whose fields are all equality-queryable. */
public static MongoQueryableEncryptionProfile equality(
String collection, List<MongoEncryptedFieldDescriptor> fields) {
return new MongoQueryableEncryptionProfile(collection, fields, false);
}
/**
* Prefix queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile prefix(String path) {
return refuse("prefix", path);
}
/**
* Suffix queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile suffix(String path) {
return refuse("suffix", path);
}
/**
* Substring queries are not supported on the MongoDB 8.0 Stable lane.
*
* @throws UnsupportedOperationException always
*/
public static MongoQueryableEncryptionProfile substring(String path) {
return refuse("substring", path);
}
private static MongoQueryableEncryptionProfile refuse(String queryShape, String path) {
Objects.requireNonNull(path, "path");
throw new UnsupportedOperationException(
queryShape
+ " Queryable Encryption on '"
+ path
+ "' is not part of the MongoDB 8.0 Stable surface; the supported query types are "
+ List.of(MongoQueryableEncryptionQueryType.values()));
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe;
/**
* The query types Queryable Encryption supports on the MongoDB 8.0 Stable lane (advanced plan Task
* 7).
*
* <p>Equality and range, and nothing else. Prefix, suffix and substring queries are not part of the
* 8.0 Stable surface, and modelling them as constants that are rejected — rather than leaving them
* out — is what turns "we planned a search feature on an encrypted field" into a design-time
* answer.
*/
public enum MongoQueryableEncryptionQueryType {
/** Encrypted equality lookup. */
EQUALITY,
/** Encrypted range lookup. */
RANGE
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.io.InputStream;
/**
* Read-only access to legacy GridFS content (advanced plan Task 13, design D-14).
*
* <p>There is no upload method, and that is deliberate. Offering one would make GridFS a live file
* platform again, and the migration this module exists to perform would never finish — new files
* would keep arriving in the place everything is being moved out of.
*/
public interface MongoGridFsCompatibilityReader {
/** Opens a legacy file for reading. */
GridFsLegacyContent open(String legacyId);
/**
* One legacy GridFS file.
*
* @param legacyId the GridFS file id
* @param filename the stored filename
* @param sizeBytes the file length
* @param checksum the stored checksum, or an empty string when GridFS recorded none
* @param content the byte stream, which the caller closes
*/
record GridFsLegacyContent(
String legacyId, String filename, long sizeBytes, String checksum, InputStream content) {}
}
@@ -0,0 +1,44 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.time.Instant;
import java.util.Objects;
/**
* How far a GridFS migration has progressed (advanced plan Task 13).
*
* <p>Migrating a file corpus is measured in hours or days, so the checkpoint is what makes the job
* survivable across deployments. The failed count is tracked separately from the migrated count
* because a run that migrated everything except forty files is a different situation from one that
* migrated everything.
*/
public record MongoGridFsMigrationCheckpoint(
String lastMigratedLegacyId, long migratedCount, long failedCount, Instant updatedAt) {
public MongoGridFsMigrationCheckpoint {
Objects.requireNonNull(lastMigratedLegacyId, "lastMigratedLegacyId");
Objects.requireNonNull(updatedAt, "updatedAt");
if (migratedCount < 0 || failedCount < 0) {
throw new IllegalArgumentException("migration counts must not be negative");
}
}
/** The checkpoint before anything has been migrated. */
public static MongoGridFsMigrationCheckpoint start(Instant now) {
return new MongoGridFsMigrationCheckpoint("", 0, 0, now);
}
/** The checkpoint after one successful file. */
public MongoGridFsMigrationCheckpoint migrated(String legacyId, Instant now) {
return new MongoGridFsMigrationCheckpoint(legacyId, migratedCount + 1, failedCount, now);
}
/** The checkpoint after one failed file; the position still advances so the run continues. */
public MongoGridFsMigrationCheckpoint failed(String legacyId, Instant now) {
return new MongoGridFsMigrationCheckpoint(legacyId, migratedCount, failedCount + 1, now);
}
/** True when every file processed so far succeeded. */
public boolean clean() {
return failedCount == 0;
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import java.time.Clock;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiFunction;
import java.util.function.Consumer;
/**
* Copies legacy GridFS content into the file source of truth (advanced plan Task 13).
*
* <p>Verify before switching, and never delete here. The order is copy, verify size and checksum,
* then write the new reference — so a mismatch leaves the document pointing at GridFS, where the
* bytes still are. Deleting the source is a separate, audited cleanup phase that runs after the
* references have been switched and observed.
*
* <p>The legacy id is the deterministic identity, so re-running the job over an already-migrated
* file produces the same content key rather than a second copy.
*/
public final class MongoGridFsMigrationJob {
private final MongoGridFsCompatibilityReader reader;
private final BiFunction<
String, MongoGridFsCompatibilityReader.GridFsLegacyContent, MongoGridFsObjectReference>
contentStoreWriter;
private final Consumer<MongoGridFsObjectReference> referenceWriter;
private final Clock clock;
public MongoGridFsMigrationJob(
MongoGridFsCompatibilityReader reader,
BiFunction<
String,
MongoGridFsCompatibilityReader.GridFsLegacyContent,
MongoGridFsObjectReference>
contentStoreWriter,
Consumer<MongoGridFsObjectReference> referenceWriter,
MongoAdvancedCapabilityFlags flags,
Clock clock) {
this.reader = Objects.requireNonNull(reader, "reader");
this.contentStoreWriter = Objects.requireNonNull(contentStoreWriter, "contentStoreWriter");
this.referenceWriter = Objects.requireNonNull(referenceWriter, "referenceWriter");
this.clock = Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(flags, "flags").require(MongoCapability.GRIDFS_COMPATIBILITY);
}
/**
* Migrates one file.
*
* @return the new reference when the copy verified, empty when it did not
*/
public Optional<MongoGridFsObjectReference> migrate(String legacyId) {
Objects.requireNonNull(legacyId, "legacyId");
MongoGridFsCompatibilityReader.GridFsLegacyContent source = reader.open(legacyId);
MongoGridFsObjectReference written = contentStoreWriter.apply(legacyId, source);
if (!written.matches(source.sizeBytes(), source.checksum())) {
// The source stays exactly where it is: the document still references GridFS, so nothing is
// lost and the file can be retried.
return Optional.empty();
}
referenceWriter.accept(written);
return Optional.of(written);
}
/** Migrates one file and folds the outcome into a checkpoint. */
public MongoGridFsMigrationCheckpoint migrate(
String legacyId, MongoGridFsMigrationCheckpoint checkpoint) {
Objects.requireNonNull(checkpoint, "checkpoint");
return migrate(legacyId).isPresent()
? checkpoint.migrated(legacyId, clock.instant())
: checkpoint.failed(legacyId, clock.instant());
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs;
import java.util.Objects;
/**
* The reference that replaces a GridFS file after migration (advanced plan Task 13, design D-14).
*
* <p>The document keeps a reference; the bytes live in the file source of truth. That is the whole
* point of the migration: GridFS makes MongoDB a file server, which means file storage competes
* with the working set for cache and with the oplog for replication bandwidth.
*/
public record MongoGridFsObjectReference(
String legacyGridFsId, String contentKey, long sizeBytes, String checksum) {
public MongoGridFsObjectReference {
Objects.requireNonNull(legacyGridFsId, "legacyGridFsId");
Objects.requireNonNull(contentKey, "contentKey");
Objects.requireNonNull(checksum, "checksum");
if (sizeBytes < 0) {
throw new IllegalArgumentException("a content size must not be negative");
}
if (checksum.isBlank()) {
throw new IllegalArgumentException(
"a migrated reference needs a checksum; without one the copy cannot be verified and the "
+ "source cannot safely be deleted");
}
}
/** True when a target object matches this reference's size and checksum. */
public boolean matches(long targetSizeBytes, String targetChecksum) {
return sizeBytes == targetSizeBytes && checksum.equals(targetChecksum);
}
}
@@ -0,0 +1,38 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership;
import java.util.List;
import java.util.Objects;
/**
* A search index declaration (advanced plan Task 8).
*
* <p>Owned by {@link MongoMetadataOwnership#SEARCH_MANAGED}, so ordinary index drift cleanup leaves
* it alone: a search index is not a b-tree index, it does not appear in {@code listIndexes}, and
* the subsystem that maintains it has its own admin plane.
*/
public record MongoSearchIndexDescriptor(
String name, String collection, List<String> searchablePaths, String analyzer) {
public MongoSearchIndexDescriptor {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(searchablePaths, "searchablePaths");
Objects.requireNonNull(analyzer, "analyzer");
searchablePaths = List.copyOf(searchablePaths);
if (searchablePaths.isEmpty()) {
throw new IllegalArgumentException("a search index needs at least one searchable path");
}
}
/** A search index over the given paths using the standard analyzer. */
public static MongoSearchIndexDescriptor standard(
String name, String collection, List<String> searchablePaths) {
return new MongoSearchIndexDescriptor(name, collection, searchablePaths, "lucene.standard");
}
/** Who owns this index for drift purposes. Always the search subsystem. */
public MongoMetadataOwnership metadataOwnership() {
return MongoMetadataOwnership.SEARCH_MANAGED;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
/**
* The lifecycle of a search or vector index (advanced plan Task 8).
*
* <p>Creation returns as soon as the request is accepted, and the index then builds asynchronously.
* A query against a {@code BUILDING} index does not fail — it returns partial results — so a
* deployment that queries immediately after creating gets a search feature that silently misses
* documents and then quietly starts working.
*/
public enum MongoSearchIndexState {
/** The creation request was accepted. */
CREATED,
/** The index is being built. Queries would return partial results. */
BUILDING,
/** The index is complete and safe to query. */
READY,
/** The build failed. */
FAILED,
/** The index is being removed. */
DELETING;
/** True when queries against this index return complete results. */
public boolean queryable() {
return this == READY;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.util.List;
/**
* Full-text search against a {@code READY} index (advanced plan Task 8).
*
* <p>Legacy {@code $text} is deliberately not routed here. The two have different relevance models
* and different index requirements, so silently redirecting a {@code $text} query to Search would
* change result ordering for every caller that was relying on the old behaviour.
*/
public interface MongoSearchOperations {
/** Runs a search query, refusing if the index is not ready. */
<T> List<T> search(MongoOperationContext context, MongoSearchQuery query, Class<T> documentType);
/** The current state of a search index. */
MongoSearchIndexState indexState(String indexName);
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* A bounded, allowlisted search query (advanced plan Task 8).
*
* <p>Search paths are allowlisted for the same reason ordinary query fields are: {@code $search}
* runs against whatever the index covers, and an index built over a whole document covers fields
* the caller was never meant to search — or to learn the existence of from a hit count.
*/
public record MongoSearchQuery(
String indexName, List<String> paths, String queryText, int resultLimit) {
/** The largest result set a search query may request. */
public static final int MAX_RESULT_LIMIT = 200;
/** The longest query text accepted. */
public static final int MAX_QUERY_LENGTH = 512;
public MongoSearchQuery {
Objects.requireNonNull(indexName, "indexName");
Objects.requireNonNull(paths, "paths");
Objects.requireNonNull(queryText, "queryText");
paths = List.copyOf(paths);
if (paths.isEmpty()) {
throw new IllegalArgumentException("a search query needs at least one path");
}
if (queryText.length() > MAX_QUERY_LENGTH) {
throw MongoOperationRejectedException.of(
"search.query",
"the search text is " + queryText.length() + " characters, above " + MAX_QUERY_LENGTH);
}
if (resultLimit <= 0 || resultLimit > MAX_RESULT_LIMIT) {
throw MongoOperationRejectedException.of(
"search.query", "a search query needs a result limit between 1 and " + MAX_RESULT_LIMIT);
}
}
/**
* Checks the query's paths against the index's allowlist.
*
* @throws MongoOperationRejectedException when a path is not searchable
*/
public void requireAllowedPaths(Set<String> allowedPaths) {
Objects.requireNonNull(allowedPaths, "allowedPaths");
for (String path : paths) {
if (!allowedPaths.contains(path)) {
throw MongoOperationRejectedException.of(
"search.query", "path '" + path + "' is not on this index's searchable allowlist");
}
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.search;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
/**
* Refuses to query an index that is not {@code READY} (advanced plan Task 8).
*
* <p>The distinction this gate enforces is the one the API makes easy to miss: "the index was
* created" and "the index can answer queries" are different states, separated by a build that can
* take minutes on a large collection.
*/
public final class MongoSearchReadinessGate {
/**
* Checks an index state before a query runs.
*
* @throws MongoOperationRejectedException when the index cannot return complete results
*/
public void requireReady(MongoSearchIndexState state) {
Objects.requireNonNull(state, "state");
if (!state.queryable()) {
throw MongoOperationRejectedException.of(
"search.readiness",
"the search index is "
+ state
+ ", not READY; querying it now returns partial results rather than an error");
}
}
/** True when a query may run against this index. */
public boolean queryable(MongoSearchIndexState state) {
return Objects.requireNonNull(state, "state").queryable();
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
/**
* How many shards an operation will reach (advanced plan Task 2).
*
* <p>The classification is what makes sharded performance predictable at review time. A
* scatter-gather query works perfectly on a two-shard cluster and degrades linearly as shards are
* added — so the query that was fine in staging is the one that stops the migration to twelve
* shards.
*/
public enum MongoRoutingClassification {
/** The full shard key is present; exactly one shard is contacted. */
TARGETED,
/** A prefix of the shard key is present; a subset of shards is contacted. */
PREFIX_TARGETED,
/** No usable shard key predicate; every shard is contacted. */
SCATTER_GATHER,
/** The operation is not permitted at all without routing evidence. */
REJECTED;
/** True when the operation contacts fewer than all shards. */
public boolean isRouted() {
return this == TARGETED || this == PREFIX_TARGETED;
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
import java.util.Set;
/**
* Classifies an operation's routing before it runs (advanced plan Task 2).
*
* <p>Read classification is advisory: a scatter-gather read is legal, expensive, and only permitted
* on a profile that declared it. Write classification is not advisory — a single-document update
* without the shard key is refused, because MongoDB cannot route it and the alternatives are worse
* than an error.
*
* <p>This module never executes {@code shardCollection}, {@code refineCollectionShardKey} or {@code
* reshardCollection}. Those are D4 operations with their own credential and approval.
*/
public final class ShardAwareQueryValidator {
/** Classifies a query by which shard key fields its predicate constrains. */
public MongoRoutingClassification classify(
ShardKeyDescriptor shardKey, Set<String> predicateFields) {
Objects.requireNonNull(shardKey, "shardKey");
Objects.requireNonNull(predicateFields, "predicateFields");
if (shardKey.isFullyCovered(predicateFields)) {
return MongoRoutingClassification.TARGETED;
}
return shardKey.coveredPrefixLength(predicateFields) > 0
? MongoRoutingClassification.PREFIX_TARGETED
: MongoRoutingClassification.SCATTER_GATHER;
}
/**
* Checks a single-document write.
*
* @throws MongoOperationRejectedException when the write carries no shard key
*/
public void requireRoutedWrite(ShardKeyDescriptor shardKey, Set<String> predicateFields) {
MongoRoutingClassification classification = classify(shardKey, predicateFields);
if (classification != MongoRoutingClassification.TARGETED) {
throw MongoOperationRejectedException.of(
"sharding.write",
"a single-document write on a sharded collection needs the full shard key "
+ shardKey.fields()
+ "; the predicate constrains "
+ predicateFields
+ ", which classifies as "
+ classification);
}
}
/**
* Checks a read against the profile's declared routing tolerance.
*
* @throws MongoOperationRejectedException when a scatter-gather read was not declared
*/
public MongoRoutingClassification requireAllowedRead(
ShardKeyDescriptor shardKey, Set<String> predicateFields, boolean scatterGatherReviewed) {
MongoRoutingClassification classification = classify(shardKey, predicateFields);
if (classification == MongoRoutingClassification.SCATTER_GATHER && !scatterGatherReviewed) {
throw MongoOperationRejectedException.of(
"sharding.read",
"this read contacts every shard and its profile has not declared scatter-gather; the cost "
+ "grows with every shard added, so it needs an explicit review");
}
return classification;
}
/**
* Checks a unique index against the shard key.
*
* @throws MongoOperationRejectedException when uniqueness could only be enforced per shard
*/
public void requireCompatibleUniqueIndex(
ShardKeyDescriptor shardKey, java.util.List<String> indexFields) {
if (!shardKey.supportsUniqueIndexOn(indexFields)) {
throw MongoOperationRejectedException.of(
"sharding.index",
"a unique index on "
+ indexFields
+ " is not prefixed by the shard key "
+ shardKey.fields()
+ "; MongoDB would enforce uniqueness per shard only, so duplicates appear as soon as "
+ "two matching documents land on different shards");
}
}
}
@@ -0,0 +1,75 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
/**
* A collection's shard key, in order (advanced plan Task 2).
*
* <p>Order is the whole content of a compound shard key. {@code (tenantId, orderId)} lets a query
* on {@code tenantId} alone target a subset of shards; {@code (orderId, tenantId)} does not, and no
* amount of indexing recovers it.
*/
public record ShardKeyDescriptor(List<ShardKeyPart> parts) {
public ShardKeyDescriptor {
Objects.requireNonNull(parts, "parts");
parts = List.copyOf(parts);
if (parts.isEmpty()) {
throw new IllegalArgumentException("a shard key needs at least one field");
}
}
/** A ranged shard key over the given fields, in order. */
public static ShardKeyDescriptor range(String... fields) {
return new ShardKeyDescriptor(
Arrays.stream(fields).map(field -> new ShardKeyPart(field, ShardStrategy.RANGE)).toList());
}
/** A hashed shard key on a single field. */
public static ShardKeyDescriptor hashed(String field) {
return new ShardKeyDescriptor(List.of(new ShardKeyPart(field, ShardStrategy.HASHED)));
}
/** The shard key fields, in order. */
public List<String> fields() {
return parts.stream().map(ShardKeyPart::field).toList();
}
/** True when the given fields include the complete shard key. */
public boolean isFullyCovered(java.util.Set<String> predicateFields) {
Objects.requireNonNull(predicateFields, "predicateFields");
return predicateFields.containsAll(fields());
}
/** How many leading shard key fields the given predicate fields cover. */
public int coveredPrefixLength(java.util.Set<String> predicateFields) {
Objects.requireNonNull(predicateFields, "predicateFields");
int covered = 0;
for (String field : fields()) {
if (!predicateFields.contains(field)) {
break;
}
covered++;
}
return covered;
}
/**
* True when a unique index on the given fields is compatible with this shard key.
*
* <p>MongoDB can only enforce uniqueness within a shard, so a unique index must be prefixed by
* the shard key. A unique index that is not — an email address on a tenant-sharded collection —
* is silently only unique per shard, and the duplicate appears the first time two tenants land
* differently.
*/
public boolean supportsUniqueIndexOn(List<String> indexFields) {
Objects.requireNonNull(indexFields, "indexFields");
List<String> shardFields = fields();
if (indexFields.size() < shardFields.size()) {
return false;
}
return indexFields.subList(0, shardFields.size()).equals(shardFields);
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
import java.util.Objects;
/** One field of a compound shard key, in declaration order (advanced plan Task 2). */
public record ShardKeyPart(String field, ShardStrategy strategy) {
public ShardKeyPart {
Objects.requireNonNull(field, "field");
Objects.requireNonNull(strategy, "strategy");
if (field.isBlank()) {
throw new IllegalArgumentException("a shard key part needs a field");
}
}
@Override
public String toString() {
return field + ':' + strategy;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding;
/**
* How a shard key distributes documents (advanced plan Task 2).
*
* <p>The choice is effectively permanent — changing it means resharding, which rewrites the whole
* collection — and the two options fail in opposite ways. Ranged keeps documents with adjacent keys
* together, so range queries stay targeted and a monotonically increasing key sends every insert to
* one shard. Hashed spreads writes evenly and makes every range query a scatter-gather.
*/
public enum ShardStrategy {
/** Documents are distributed by key ranges. Range queries target; monotonic keys hotspot. */
RANGE,
/** Documents are distributed by the hash of the key. Writes spread; range queries scatter. */
HASHED;
/** True when a range predicate on the shard key can target a subset of shards. */
public boolean supportsTargetedRangeQueries() {
return this == RANGE;
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway;
import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation;
import java.util.List;
import java.util.Objects;
import java.util.function.Supplier;
/**
* Sharding topology operations, on the admin plane only (advanced plan Task 3).
*
* <p>Every method here changes the shape of the cluster. They run through the D4 gateway so each
* one carries an operator, a reason and an audit record, and behind the shard-admin credential so
* an application runtime cannot reach them even by constructing this class.
*/
public final class MongoShardingAdminGateway {
private final MongoAdminGateway adminGateway;
public MongoShardingAdminGateway(
MongoAdminGateway adminGateway, MongoAdvancedCapabilityFlags flags) {
this.adminGateway = Objects.requireNonNull(adminGateway, "adminGateway");
// Checked once, at construction: an instance cannot exist unless sharding was enabled.
Objects.requireNonNull(flags, "flags").require(MongoCapability.SHARDING);
}
/**
* Enables sharding on a collection.
*
* @throws MongoOperationRejectedException when the key was not approved or its index is missing
*/
public void shardCollection(
String collection,
ShardKeyDescriptor shardKey,
ShardKeyReadinessReport readiness,
List<String> supportingIndexFields,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(shardKey, "shardKey");
Objects.requireNonNull(readiness, "readiness");
if (!readiness.approved()) {
throw MongoOperationRejectedException.of(
"sharding.shard-collection",
"the shard key for '" + collection + "' was not approved: " + readiness.reasons());
}
if (!supportingIndexFields
.subList(0, Math.min(shardKey.fields().size(), supportingIndexFields.size()))
.equals(shardKey.fields())) {
throw MongoOperationRejectedException.of(
"sharding.shard-collection",
"sharding '"
+ collection
+ "' needs a supporting index prefixed by the shard key "
+ shardKey.fields());
}
adminGateway.execute(MongoAdminOperation.SHARD_COLLECTION, collection, operator, reason, apply);
}
/** Adds a field to an existing shard key. Requires the same evidence as a reshard. */
public void refineShardKey(
String collection,
ReshardApproval approval,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(approval, "approval").require();
adminGateway.execute(MongoAdminOperation.REFINE_SHARD_KEY, collection, operator, reason, apply);
}
/** Changes a collection's shard key, rewriting the whole collection. */
public void reshardCollection(
String collection,
ReshardApproval approval,
String operator,
String reason,
Supplier<Void> apply) {
Objects.requireNonNull(approval, "approval").require();
adminGateway.execute(
MongoAdminOperation.RESHARD_COLLECTION, collection, operator, reason, apply);
}
/** Starts or stops the balancer. */
public void controlBalancer(String scope, String operator, String reason, Supplier<Void> apply) {
adminGateway.execute(MongoAdminOperation.BALANCER_CONTROL, scope, operator, reason, apply);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Objects;
/**
* The evidence a reshard needs before it may start (advanced plan Task 3).
*
* <p>Resharding rewrites every document in a collection while it keeps serving traffic. It cannot
* be paused for convenience and there is no reverse operation — the way back is another reshard. So
* the approval carries a readiness report, a completed dry run, a named approver and a written
* forward strategy, and the gateway refuses without all four.
*/
public record ReshardApproval(
ShardKeyDescriptor newShardKey,
ShardKeyReadinessReport readiness,
boolean dryRunCompleted,
String approver,
String forwardStrategy) {
public ReshardApproval {
Objects.requireNonNull(newShardKey, "newShardKey");
Objects.requireNonNull(readiness, "readiness");
Objects.requireNonNull(approver, "approver");
Objects.requireNonNull(forwardStrategy, "forwardStrategy");
}
/**
* Checks that the approval is complete.
*
* @throws MongoOperationRejectedException naming the first missing piece of evidence
*/
public void require() {
if (!readiness.approved()) {
throw MongoOperationRejectedException.of(
"sharding.reshard",
"the new shard key was not approved by analysis: " + readiness.reasons());
}
if (!dryRunCompleted) {
throw MongoOperationRejectedException.of(
"sharding.reshard", "a reshard requires a completed dry run before it starts");
}
if (approver.isBlank()) {
throw MongoOperationRejectedException.of(
"sharding.reshard", "a reshard requires a named approver");
}
if (forwardStrategy.isBlank()) {
throw MongoOperationRejectedException.of(
"sharding.reshard",
"a reshard requires a written forward strategy; there is no reverse operation, so the "
+ "recovery from a bad outcome is another reshard");
}
}
}
@@ -0,0 +1,76 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* Turns sampled statistics into a shard key verdict (advanced plan Task 3).
*
* <p>The thresholds are conservative on purpose. A shard key that is marginal at today's volume is
* a shard key that fails at ten times the volume, and by then the only remedy is a reshard — which
* rewrites the whole collection while it is serving traffic.
*
* <p>The sampling itself runs through the shard-admin credential; this class only interprets the
* result, which is what makes the verdict testable without a cluster.
*/
public final class ShardKeyAnalyzer {
/** Below this many distinct values per shard, chunks cannot be split evenly. */
public static final long MINIMUM_CARDINALITY_PER_SHARD = 1000;
/** Above this share for a single value, one chunk becomes a hotspot. */
public static final double MAXIMUM_VALUE_FREQUENCY = 0.05;
/** Above this monotonicity score, inserts concentrate on the highest chunk. */
public static final double MAXIMUM_MONOTONICITY = 0.8;
/** Below this targeting ratio, most operations contact every shard. */
public static final double MINIMUM_TARGETING_RATIO = 0.9;
/**
* Analyses one candidate.
*
* @param shardKey the candidate key
* @param distinctValues how many distinct key values were sampled
* @param shardCount how many shards the collection would spread across
* @param topValueFrequency the share of documents holding the most common key value
* @param monotonicity 0 for random, 1 for strictly increasing
* @param readTargetingRatio the share of sampled reads that would be targeted
* @param writeTargetingRatio the share of sampled writes that would be targeted
*/
public ShardKeyReadinessReport analyze(
ShardKeyDescriptor shardKey,
long distinctValues,
int shardCount,
double topValueFrequency,
double monotonicity,
double readTargetingRatio,
double writeTargetingRatio) {
Objects.requireNonNull(shardKey, "shardKey");
if (shardCount <= 0) {
throw new IllegalArgumentException("shardCount must be positive");
}
Set<String> reasons = new LinkedHashSet<>();
if (distinctValues < MINIMUM_CARDINALITY_PER_SHARD * shardCount) {
reasons.add(ShardKeyReadinessReport.LOW_CARDINALITY);
}
if (topValueFrequency > MAXIMUM_VALUE_FREQUENCY) {
reasons.add(ShardKeyReadinessReport.HIGH_FREQUENCY);
}
if (monotonicity > MAXIMUM_MONOTONICITY) {
reasons.add(ShardKeyReadinessReport.MONOTONIC);
}
if (readTargetingRatio < MINIMUM_TARGETING_RATIO
|| writeTargetingRatio < MINIMUM_TARGETING_RATIO) {
reasons.add(ShardKeyReadinessReport.POOR_TARGETING);
}
return reasons.isEmpty()
? ShardKeyReadinessReport.approved(monotonicity, readTargetingRatio, writeTargetingRatio)
: ShardKeyReadinessReport.rejected(
reasons, monotonicity, readTargetingRatio, writeTargetingRatio);
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin;
import java.util.Objects;
import java.util.Set;
/**
* Whether a shard key candidate is fit to be committed to (advanced plan Task 3).
*
* <p>The shard key is close to irreversible: changing it means resharding, which rewrites every
* document. The three ways a candidate goes wrong are all measurable in advance — too few distinct
* values to spread across shards, one value dominating the distribution, or a monotonically
* increasing value that sends every insert to the same chunk — so the decision is made from a
* report rather than from intuition.
*/
public record ShardKeyReadinessReport(
boolean approved,
Set<String> reasons,
double monotonicity,
double readTargetingRatio,
double writeTargetingRatio) {
/** Reason code: too few distinct shard key values. */
public static final String LOW_CARDINALITY = "LOW_CARDINALITY";
/** Reason code: one shard key value dominates the distribution. */
public static final String HIGH_FREQUENCY = "HIGH_FREQUENCY";
/** Reason code: the shard key increases monotonically, so all inserts hit one chunk. */
public static final String MONOTONIC = "MONOTONIC";
/** Reason code: too many operations would be scatter-gather. */
public static final String POOR_TARGETING = "POOR_TARGETING";
public ShardKeyReadinessReport {
Objects.requireNonNull(reasons, "reasons");
reasons = Set.copyOf(reasons);
if (approved && !reasons.isEmpty()) {
throw new IllegalArgumentException(
"an approved shard key report must have no reasons against it");
}
}
/** A candidate with too few distinct values to spread across shards. */
public static ShardKeyReadinessReport lowCardinality(String field) {
Objects.requireNonNull(field, "field");
return new ShardKeyReadinessReport(false, Set.of(LOW_CARDINALITY), 0, 0, 0);
}
/** A candidate that passed every check. */
public static ShardKeyReadinessReport approved(
double monotonicity, double readTargetingRatio, double writeTargetingRatio) {
return new ShardKeyReadinessReport(
true, Set.of(), monotonicity, readTargetingRatio, writeTargetingRatio);
}
/** A candidate rejected for the given reasons. */
public static ShardKeyReadinessReport rejected(
Set<String> reasons, double monotonicity, double readTargeting, double writeTargeting) {
return new ShardKeyReadinessReport(false, reasons, monotonicity, readTargeting, writeTargeting);
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* Bounds how many tenant clients exist at once (advanced plan Task 11).
*
* <p>Database-per-tenant is Experimental precisely because of this: each client carries its own
* connection pool and its own monitoring threads, so a thousand tenants is a thousand pools. The
* registry caps the number and evicts idle ones, and refuses rather than exceeding the cap — an
* unbounded registry fails later, as connection exhaustion on an unrelated request.
*/
public final class MongoTenantClientRegistry {
private final int maximumActiveClients;
private final Duration idleEviction;
private final Map<String, Instant> lastUsed = new LinkedHashMap<>();
public MongoTenantClientRegistry(int maximumActiveClients) {
this(maximumActiveClients, Duration.ofMinutes(10));
}
public MongoTenantClientRegistry(int maximumActiveClients, Duration idleEviction) {
this.idleEviction = Objects.requireNonNull(idleEviction, "idleEviction");
if (maximumActiveClients <= 0) {
throw new IllegalArgumentException("the client cap must be positive");
}
this.maximumActiveClients = maximumActiveClients;
}
/**
* Acquires a client for a tenant.
*
* @throws MongoOperationRejectedException when the cap is reached and nothing can be evicted
*/
public void acquire(String tenantKey) {
acquire(tenantKey, Instant.now());
}
/** Acquires a client for a tenant at an explicit instant, so eviction is testable. */
public void acquire(String tenantKey, Instant now) {
Objects.requireNonNull(tenantKey, "tenantKey");
Objects.requireNonNull(now, "now");
if (lastUsed.containsKey(tenantKey)) {
lastUsed.put(tenantKey, now);
return;
}
evictIdle(now);
if (lastUsed.size() >= maximumActiveClients) {
throw MongoOperationRejectedException.of(
"tenancy.client",
"the tenant client cap of "
+ maximumActiveClients
+ " is reached and no client is idle; each tenant client carries its own connection "
+ "pool and monitoring threads, so the cap is what stops one process from exhausting "
+ "the cluster's connections");
}
lastUsed.put(tenantKey, now);
}
/** Releases a tenant's client. */
public void release(String tenantKey) {
lastUsed.remove(Objects.requireNonNull(tenantKey, "tenantKey"));
}
/** The tenants with an active client. */
public Set<String> activeTenants() {
return Set.copyOf(lastUsed.keySet());
}
/** How many clients are currently open. */
public int activeCount() {
return lastUsed.size();
}
private void evictIdle(Instant now) {
lastUsed.entrySet().removeIf(entry -> entry.getValue().plus(idleEviction).isBefore(now));
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext;
import dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName;
/**
* Maps a tenant to its database profile (advanced plan Task 11).
*
* <p>The mapping comes from a trusted registry, never from request input. Deriving a database name
* from a header or a token claim makes the database name attacker-controlled, and a database name
* is the one string that decides which tenant's data a query reads.
*/
@FunctionalInterface
public interface MongoTenantDatabaseResolver {
/** The database profile for a tenant. */
DatabaseProfileName resolve(MongoTenantContext tenant);
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.util.Objects;
/**
* What must happen before a tenant database is used, and before it is destroyed (advanced plan Task
* 11).
*
* <p>Onboarding first: a tenant database that starts serving before its schema and indexes are in
* place accepts documents that fail the validator and queries that scan, and both are then already
* in the data by the time anyone notices.
*
* <p>Offboarding is the same rule in reverse. A tenant's data cannot be dropped on request alone —
* retention obligations may still apply, and once dropped there is no export.
*/
public record MongoTenantLifecyclePolicy(
Duration retentionAfterOffboarding, boolean exportRequiredBeforeDelete) {
public MongoTenantLifecyclePolicy {
Objects.requireNonNull(retentionAfterOffboarding, "retentionAfterOffboarding");
if (retentionAfterOffboarding.isNegative()) {
throw new IllegalArgumentException("a retention window must not be negative");
}
}
/** The platform default: 30 days of retention and a mandatory export. */
public static MongoTenantLifecyclePolicy standard() {
return new MongoTenantLifecyclePolicy(Duration.ofDays(30), true);
}
/**
* Refuses to activate a tenant whose schema and indexes are not in place.
*
* @throws MongoOperationRejectedException when validation has not completed
*/
public void requireActivationReady(String tenantKey, boolean schemaAndIndexesValidated) {
Objects.requireNonNull(tenantKey, "tenantKey");
if (!schemaAndIndexesValidated) {
throw MongoOperationRejectedException.of(
"tenancy.activation",
"the tenant database is not validated; activating it now accepts documents the validator "
+ "would have rejected and queries no index supports");
}
}
/**
* Refuses a delete that lacks its evidence.
*
* @throws MongoOperationRejectedException when the retention window has not elapsed or no export
* exists
*/
public void requireDeleteAllowed(
String tenantKey, Duration elapsedSinceOffboarding, boolean exportCompleted) {
Objects.requireNonNull(tenantKey, "tenantKey");
Objects.requireNonNull(elapsedSinceOffboarding, "elapsedSinceOffboarding");
if (elapsedSinceOffboarding.compareTo(retentionAfterOffboarding) < 0) {
throw MongoOperationRejectedException.of(
"tenancy.offboarding",
"the retention window of "
+ retentionAfterOffboarding
+ " has not elapsed; "
+ elapsedSinceOffboarding
+ " has passed since offboarding");
}
if (exportRequiredBeforeDelete && !exportCompleted) {
throw MongoOperationRejectedException.of(
"tenancy.offboarding",
"no completed export exists for this tenant; once the database is dropped there is "
+ "nothing left to export from");
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database;
import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Fans a migration out across tenant databases, slowly (advanced plan Task 11).
*
* <p>The concurrency limit is the point. Running the same migration against a thousand tenant
* databases at once turns a routine schema change into a cluster-wide load event, and the databases
* that fail are the ones whose tenants happened to be busy.
*
* <p>Checkpoints are per tenant, so a fan-out interrupted after six hundred tenants resumes at six
* hundred and one rather than at one.
*/
public final class MongoTenantMigrationCoordinator {
private final int maxConcurrentTenants;
private final Duration pauseBetweenTenants;
private final Map<String, MongoMigrationCheckpoint> checkpointsByTenant = new LinkedHashMap<>();
public MongoTenantMigrationCoordinator(int maxConcurrentTenants, Duration pauseBetweenTenants) {
this.pauseBetweenTenants = Objects.requireNonNull(pauseBetweenTenants, "pauseBetweenTenants");
if (maxConcurrentTenants <= 0) {
throw new IllegalArgumentException("the tenant concurrency limit must be positive");
}
this.maxConcurrentTenants = maxConcurrentTenants;
}
/** The platform default: four tenants at a time, a second apart. */
public static MongoTenantMigrationCoordinator standard() {
return new MongoTenantMigrationCoordinator(4, Duration.ofSeconds(1));
}
/** The next batch of tenants to migrate, skipping those already completed. */
public List<String> nextBatch(List<String> allTenants, List<String> completedTenants) {
Objects.requireNonNull(allTenants, "allTenants");
Objects.requireNonNull(completedTenants, "completedTenants");
return allTenants.stream()
.filter(tenant -> !completedTenants.contains(tenant))
.limit(maxConcurrentTenants)
.toList();
}
/** Records how far one tenant's migration got. */
public void recordCheckpoint(String tenantKey, MongoMigrationCheckpoint checkpoint) {
checkpointsByTenant.put(
Objects.requireNonNull(tenantKey, "tenantKey"),
Objects.requireNonNull(checkpoint, "checkpoint"));
}
/** The stored checkpoint for a tenant, if the fan-out was interrupted mid-tenant. */
public Optional<MongoMigrationCheckpoint> checkpointFor(String tenantKey) {
return Optional.ofNullable(
checkpointsByTenant.get(Objects.requireNonNull(tenantKey, "tenantKey")));
}
/** How long to wait between tenants. */
public Duration pauseBetweenTenants() {
return pauseBetweenTenants;
}
/** How many tenants may migrate concurrently. */
public int maxConcurrentTenants() {
return maxConcurrentTenants;
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
/**
* The tenant a request belongs to (advanced plan Task 10).
*
* <p>The key is opaque and the raw tenant id never reaches telemetry: a tenant id in a metric tag
* is both unbounded cardinality and, on a B2B system, a customer list published to whoever can read
* the dashboard. {@link #observableKey()} is the hashed form for the rare case where per-tenant
* observability is genuinely needed.
*/
public record MongoTenantContext(String opaqueTenantKey) {
public MongoTenantContext {
if (opaqueTenantKey == null || opaqueTenantKey.isBlank()) {
throw new IllegalArgumentException("tenant context required");
}
}
/** A short, stable hash suitable for correlation without naming the tenant. */
public String observableKey() {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of()
.formatHex(digest.digest(opaqueTenantKey.getBytes(StandardCharsets.UTF_8)))
.substring(0, 12);
} catch (NoSuchAlgorithmException unavailable) {
throw new IllegalStateException("SHA-256 is required to derive an observable tenant key");
}
}
/** Describes the tenant without naming it. */
@Override
public String toString() {
return "MongoTenantContext[" + observableKey() + "]";
}
/** The document field a shared collection stores the tenant in. */
public static String tenantField() {
return "tenantId";
}
/** The value written into the tenant field. */
public String storedValue() {
return Objects.requireNonNull(opaqueTenantKey);
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey;
import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest;
import java.util.List;
import java.util.Objects;
/**
* Checks that a shared collection's indexes agree with its tenancy (advanced plan Task 10).
*
* <p>A unique index without the tenant field enforces uniqueness across the whole collection, which
* on a shared-collection system means one tenant's value blocks another's. The failure is invisible
* during development, where there is one tenant, and appears the day the second one signs up.
*/
public final class MongoTenantManifestValidator {
/**
* Validates one shared collection's manifest.
*
* @param tenantScopedUniqueIndexes index names whose uniqueness is meant to be per tenant
* @throws MongoOperationRejectedException naming the index that would be globally unique
*/
public void validate(MongoCollectionManifest manifest, List<String> tenantScopedUniqueIndexes) {
Objects.requireNonNull(manifest, "manifest");
Objects.requireNonNull(tenantScopedUniqueIndexes, "tenantScopedUniqueIndexes");
for (MongoIndexManifest index : manifest.indexes()) {
if (!index.unique() || !tenantScopedUniqueIndexes.contains(index.name())) {
continue;
}
if (!startsWithTenantField(index)) {
throw MongoOperationRejectedException.of(
"tenancy.index",
"unique index '"
+ index.name()
+ "' on shared collection '"
+ manifest.collection()
+ "' is meant to be per tenant but does not start with '"
+ MongoTenantContext.tenantField()
+ "'; it would make one tenant's value block every other tenant's");
}
}
}
/**
* Rejects a shard key that assumes the tenant field without analysis.
*
* @throws MongoOperationRejectedException when the tenant field was chosen without a readiness
* report
*/
public void requireShardKeyAnalysed(String collection, boolean readinessReportPresent) {
if (!readinessReportPresent) {
throw MongoOperationRejectedException.of(
"tenancy.shard-key",
"the shard key for shared collection '"
+ collection
+ "' was chosen without a readiness report; the tenant field is the obvious candidate "
+ "and often the wrong one, because one large tenant becomes one hot shard");
}
}
private static boolean startsWithTenantField(MongoIndexManifest index) {
List<MongoIndexKey> keys = index.keys();
return !keys.isEmpty() && keys.get(0).field().equals(MongoTenantContext.tenantField());
}
}
@@ -0,0 +1,65 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter;
import java.util.Objects;
import java.util.Optional;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
/**
* Adds the tenant predicate to every tenant-scoped operation (advanced plan Task 10).
*
* <p>Fail-closed on a missing tenant context, which is the only safe default: the failure mode of
* "no tenant predicate" on a shared collection is a query that returns every tenant's data, and it
* returns it successfully. An error is recoverable; a cross-tenant read is a disclosure.
*
* <p>Aggregations get the predicate as a first-stage match rather than anywhere later, so no stage
* ever observes another tenant's documents — a {@code $group} placed before the filter would leak
* through its own output even if the final result were filtered.
*/
public final class MongoTenantPredicateInjector {
/**
* Adds the tenant predicate to an atomic filter.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public AtomicFilter apply(Optional<MongoTenantContext> tenant, AtomicFilter filter) {
Objects.requireNonNull(filter, "filter");
MongoTenantContext context = require(tenant);
return filter.andEquals(MongoTenantContext.tenantField(), context.storedValue());
}
/**
* Adds the tenant predicate to a query.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public Query apply(Optional<MongoTenantContext> tenant, Query query) {
Objects.requireNonNull(query, "query");
MongoTenantContext context = require(tenant);
query.addCriteria(Criteria.where(MongoTenantContext.tenantField()).is(context.storedValue()));
return query;
}
/**
* The first-stage match an aggregation must begin with.
*
* @throws MongoOperationRejectedException when no tenant context is present
*/
public Criteria firstStageMatch(Optional<MongoTenantContext> tenant) {
MongoTenantContext context = require(tenant);
return Criteria.where(MongoTenantContext.tenantField()).is(context.storedValue());
}
private static MongoTenantContext require(Optional<MongoTenantContext> tenant) {
Objects.requireNonNull(tenant, "tenant");
return tenant.orElseThrow(
() ->
MongoOperationRejectedException.of(
"tenancy.context",
"no tenant context is present; a tenant-scoped operation without its predicate "
+ "reads and writes across every tenant in the shared collection"));
}
}
@@ -0,0 +1,39 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate;
import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult;
import java.util.List;
import java.util.Optional;
import org.springframework.data.mongodb.core.query.Query;
/**
* Operations that cannot run without a tenant predicate (advanced plan Task 10).
*
* <p>Every method takes the tenant context explicitly rather than reading it from a thread or a
* request scope. An implicit lookup is invisible at the call site, which means a background job, a
* scheduled task or a message consumer can run tenant-scoped code with no tenant and nothing in the
* code says so.
*/
public interface TenantScopedMongoOperations {
/** Finds documents belonging to the tenant. */
<T> List<T> find(
MongoOperationContext context,
Optional<MongoTenantContext> tenant,
Query query,
Class<T> documentType);
/** Updates one of the tenant's documents. */
<T> AtomicUpdateResult<T> updateOne(
MongoOperationContext context,
Optional<MongoTenantContext> tenant,
AtomicFilter filter,
AtomicUpdate update,
Class<T> documentType);
/** Deletes one of the tenant's documents. */
long deleteOne(
MongoOperationContext context, Optional<MongoTenantContext> tenant, AtomicFilter filter);
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.util.Objects;
/**
* Refuses the ordinary collection capabilities a time series collection does not have (advanced
* plan Task 5).
*
* <p>Each refusal maps to something MongoDB genuinely does not support on a time series collection.
* The reason for making them explicit is that the failure otherwise arrives late and looks like a
* bug: a change stream on a time series collection simply never delivers an event, and a schema
* validator configured on one is accepted and never applied.
*/
public final class MongoTimeSeriesCapabilityValidator {
/**
* Time series collections do not support change streams.
*
* @throws UnsupportedOperationException always
*/
public void requireChangeStream(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support change streams; watch the source of the "
+ "measurements instead of the bucketed collection");
}
/**
* Time series collections do not support JSON Schema validators.
*
* @throws UnsupportedOperationException always
*/
public void requireSchemaValidator(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support a JSON Schema validator; validate measurements "
+ "before they are written");
}
/**
* Time series collections do not support client-side field level encryption.
*
* @throws UnsupportedOperationException always
*/
public void requireFieldLevelEncryption(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection does not support CSFLE or Queryable Encryption; encrypt the "
+ "measurements upstream or keep sensitive fields in a separate collection");
}
/**
* Time series collections do not support transactional writes.
*
* @throws UnsupportedOperationException always
*/
public void requireTransactionalWrite(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
throw new UnsupportedOperationException(
"a time series collection cannot be written inside a multi-document transaction");
}
/**
* Validates a descriptor's own settings.
*
* @throws IllegalArgumentException when a required field is missing or a bound is unusable
*/
public void validate(MongoTimeSeriesDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
descriptor
.retentionWindow()
.ifPresent(
retention -> {
if (retention.compareTo(descriptor.granularity().bucketSpan()) < 0) {
throw new IllegalArgumentException(
"a retention window of "
+ retention
+ " is shorter than one "
+ descriptor.granularity()
+ " bucket ("
+ descriptor.granularity().bucketSpan()
+ "), so measurements would expire before their bucket closes");
}
});
}
/** True when the given server version supports sharding a time series collection. */
public boolean shardingSupported(String serverVersion) {
Objects.requireNonNull(serverVersion, "serverVersion");
return !serverVersion.startsWith("5.") && !serverVersion.startsWith("6.");
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* A time series collection's shape (advanced plan Task 5).
*
* <p>A separate descriptor rather than a variant of the ordinary collection manifest, because a
* time series collection does not inherit the ordinary contract: it has no schema validator, no
* change stream, no CSFLE, and its update and delete support is restricted. Modelling it as "a
* collection with a flag" would let all of those be configured and silently ignored.
*/
public record MongoTimeSeriesDescriptor(
String timeField,
String metaField,
MongoTimeSeriesGranularity granularity,
Duration retention) {
public MongoTimeSeriesDescriptor {
Objects.requireNonNull(timeField, "timeField");
Objects.requireNonNull(granularity, "granularity");
if (timeField.isBlank()) {
throw new IllegalArgumentException("a time series collection needs an explicit timeField");
}
if (retention != null && retention.isNegative()) {
throw new IllegalArgumentException("a time series retention must not be negative");
}
}
/** The common shape: a time field, a metadata field and minute granularity. */
public static MongoTimeSeriesDescriptor standard(String timeField, String metaField) {
return new MongoTimeSeriesDescriptor(
timeField, metaField, MongoTimeSeriesGranularity.MINUTES, null);
}
/** The metadata field, when the collection declares one. */
public Optional<String> meta() {
return Optional.ofNullable(metaField).filter(field -> !field.isBlank());
}
/**
* The retention window, when one is declared.
*
* <p>Time series retention is TTL-based, so it carries the same caveat as any TTL: it reclaims
* space eventually and is not a scheduler.
*/
public Optional<Duration> retentionWindow() {
return Optional.ofNullable(retention);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import java.time.Duration;
/**
* How far apart a time series collection expects consecutive measurements (advanced plan Task 5).
*
* <p>Granularity decides the bucket span, and a wrong choice is expensive in both directions: too
* coarse and each bucket holds too many measurements to read efficiently, too fine and the
* collection carries a bucket per measurement plus its overhead.
*/
public enum MongoTimeSeriesGranularity {
/** Measurements arrive seconds apart. */
SECONDS(Duration.ofHours(1)),
/** Measurements arrive minutes apart. */
MINUTES(Duration.ofHours(24)),
/** Measurements arrive hours apart. */
HOURS(Duration.ofDays(30));
private final Duration bucketSpan;
MongoTimeSeriesGranularity(Duration bucketSpan) {
this.bucketSpan = bucketSpan;
}
/** The time span one bucket covers at this granularity. */
public Duration bucketSpan() {
return bucketSpan;
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.time.Instant;
import java.util.List;
/**
* The operations a time series collection actually supports (advanced plan Task 5).
*
* <p>Insert and range read, and nothing else. There is no update or delete method because MongoDB's
* support for both is restricted on time series collections, and an API that offered them would be
* offering something that fails at runtime depending on the server version and the fields touched.
*/
public interface MongoTimeSeriesOperations {
/** Appends measurements. */
<T> void insertAll(MongoOperationContext context, List<T> measurements, Class<T> measurementType);
/** Reads measurements in a bounded time range, which is the query shape buckets are built for. */
<T> List<T> findInRange(
MongoOperationContext context,
Instant from,
Instant to,
Class<T> measurementType,
int resultLimit);
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.Objects;
/**
* An embedding bound to the index it was produced for (advanced plan Task 9).
*
* <p>{@link #forIndex} is the only way to build one, so a dimension mismatch cannot reach a query.
* MongoDB rejects a wrong-length vector, but the more common mistake — a vector of the right length
* from a different model — is caught here only by the index binding, which is why the factory takes
* the descriptor rather than a bare length.
*/
public final class MongoEmbedding {
private final float[] values;
private final MongoVectorIndexDescriptor index;
private MongoEmbedding(float[] values, MongoVectorIndexDescriptor index) {
this.values = values;
this.index = index;
}
/**
* Binds a vector to an index.
*
* @throws IllegalArgumentException when the vector's length is not the index's dimension
*/
public static MongoEmbedding forIndex(MongoVectorIndexDescriptor index, float[] values) {
Objects.requireNonNull(index, "index");
Objects.requireNonNull(values, "values");
if (values.length != index.dimensions()) {
throw new IllegalArgumentException(
"embedding has "
+ values.length
+ " dimensions but index '"
+ index.name()
+ "' expects "
+ index.dimensions());
}
return new MongoEmbedding(values.clone(), index);
}
/** A defensive copy of the vector. */
public float[] values() {
return values.clone();
}
/** The index this embedding was produced for. */
public MongoVectorIndexDescriptor index() {
return index;
}
/** The number of dimensions. */
public int dimensions() {
return values.length;
}
@Override
public String toString() {
return "MongoEmbedding[index=" + index.name() + ", dimensions=" + values.length + "]";
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.Objects;
/**
* A vector index declaration (advanced plan Task 9).
*
* <p>Dimension and similarity metric are part of the index contract, not query parameters. Querying
* a cosine index with vectors produced for a dot-product model returns results — ranked by the
* wrong notion of similarity — so the mismatch has to be caught at the type level rather than
* observed in the output.
*/
public record MongoVectorIndexDescriptor(
String name, String path, int dimensions, MongoVectorSimilarity similarity) {
/** The largest embedding dimension the platform accepts. */
public static final int MAX_DIMENSIONS = 4096;
public MongoVectorIndexDescriptor {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(path, "path");
Objects.requireNonNull(similarity, "similarity");
if (dimensions <= 0 || dimensions > MAX_DIMENSIONS) {
throw new IllegalArgumentException(
"an embedding dimension must be between 1 and " + MAX_DIMENSIONS);
}
}
/** A cosine-similarity index. */
public static MongoVectorIndexDescriptor cosine(String path, int dimensions) {
return new MongoVectorIndexDescriptor(
"ix_vector_" + path.replace('.', '_'), path, dimensions, MongoVectorSimilarity.COSINE);
}
/** A dot-product index. */
public static MongoVectorIndexDescriptor dotProduct(String path, int dimensions) {
return new MongoVectorIndexDescriptor(
"ix_vector_" + path.replace('.', '_'), path, dimensions, MongoVectorSimilarity.DOT_PRODUCT);
}
/** How similarity is measured by this index. */
public enum MongoVectorSimilarity {
/** Angle between vectors; magnitude-independent. */
COSINE,
/** Dot product; magnitude matters. */
DOT_PRODUCT,
/** Euclidean distance. */
EUCLIDEAN
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* A bounded approximate-nearest-neighbour query (advanced plan Task 9).
*
* <p>{@code numCandidates} is the accuracy-versus-cost dial: the search examines that many
* candidates and returns the best {@code limit} of them. It has to exceed the limit — asking for 10
* results from 10 candidates is not an approximate search, it is an arbitrary one — and it has to
* be bounded, because the cost grows with it.
*/
public record MongoVectorQuery(
MongoEmbedding queryVector,
int limit,
int numCandidates,
Set<String> filterFields,
Duration timeout) {
/** The largest candidate pool the platform allows. */
public static final int MAX_CANDIDATES = 10_000;
/** The largest result set the platform allows. */
public static final int MAX_LIMIT = 100;
public MongoVectorQuery {
Objects.requireNonNull(queryVector, "queryVector");
Objects.requireNonNull(filterFields, "filterFields");
Objects.requireNonNull(timeout, "timeout");
filterFields = Set.copyOf(filterFields);
if (limit <= 0 || limit > MAX_LIMIT) {
throw MongoOperationRejectedException.of(
"vector.query", "a vector query needs a limit between 1 and " + MAX_LIMIT);
}
if (numCandidates > MAX_CANDIDATES) {
throw MongoOperationRejectedException.of(
"vector.query", "numCandidates is above the ceiling of " + MAX_CANDIDATES);
}
if (numCandidates <= limit) {
throw MongoOperationRejectedException.of(
"vector.query",
"numCandidates ("
+ numCandidates
+ ") must exceed the limit ("
+ limit
+ "); otherwise the search returns whatever it examined rather than the nearest");
}
if (timeout.isZero() || timeout.isNegative()) {
throw MongoOperationRejectedException.of(
"vector.query", "a vector query needs a positive timeout");
}
}
/** A query with the platform's default candidate ratio. */
public static MongoVectorQuery nearest(MongoEmbedding queryVector, int limit) {
return new MongoVectorQuery(
queryVector, limit, Math.min(limit * 20, MAX_CANDIDATES), Set.of(), Duration.ofSeconds(2));
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What a vector search deployment must prove before promotion (advanced plan Tasks 9, 15).
*
* <p>Functional success is not evidence for vector search. An approximate index returns results for
* any query; whether they are the right results depends on recall, which cannot be observed from
* the application side. So the gate requires a measured recall against a known-answer set,
* alongside the usual latency and memory bounds.
*/
public record MongoVectorSearchBenchmarkGate(
double minimumRecallAtK, long maximumP99Millis, long maximumIndexMemoryBytes) {
/** The platform's default bar: 90% recall@10, 200 ms p99. */
public static MongoVectorSearchBenchmarkGate standard() {
return new MongoVectorSearchBenchmarkGate(0.9, 200, 4L * 1024 * 1024 * 1024);
}
public MongoVectorSearchBenchmarkGate {
if (minimumRecallAtK <= 0 || minimumRecallAtK > 1) {
throw new IllegalArgumentException("recall must be a fraction between 0 and 1");
}
if (maximumP99Millis <= 0 || maximumIndexMemoryBytes <= 0) {
throw new IllegalArgumentException("benchmark bounds must be positive");
}
}
/** The bounds a measured run failed, empty when it passed. */
public Set<String> failures(
double measuredRecall, long measuredP99Millis, long measuredIndexMemoryBytes) {
Set<String> failures = new LinkedHashSet<>();
if (measuredRecall < minimumRecallAtK) {
failures.add("recall " + measuredRecall + " below " + minimumRecallAtK);
}
if (measuredP99Millis > maximumP99Millis) {
failures.add("p99 " + measuredP99Millis + "ms above " + maximumP99Millis + "ms");
}
if (measuredIndexMemoryBytes > maximumIndexMemoryBytes) {
failures.add(
"index memory " + measuredIndexMemoryBytes + " above " + maximumIndexMemoryBytes);
}
return Set.copyOf(failures);
}
/** True when a measured run clears every bound. */
public boolean passes(
double measuredRecall, long measuredP99Millis, long measuredIndexMemoryBytes) {
return failures(measuredRecall, measuredP99Millis, measuredIndexMemoryBytes).isEmpty();
}
/** The evidence categories a promotion must supply. */
public static Set<String> requiredEvidence() {
return Objects.requireNonNull(
Set.of("index-readiness", "recall", "latency", "memory", "actual-topology"));
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.advanced.vector;
import dev.caskeleton.adapter.outbound.mongo.advanced.search.MongoSearchIndexState;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import java.util.List;
/**
* Vector similarity search against a {@code READY} index (advanced plan Task 9).
*
* <p>Results carry a named score contract rather than the provider's raw number. A raw score is
* only interpretable against the index's similarity metric, and a caller that thresholds on it is
* coupled to a metric that can change when the index is rebuilt.
*/
public interface MongoVectorSearchOperations {
/** Runs a vector query, refusing if the index is not ready. */
<T> List<MongoVectorHit<T>> search(
MongoOperationContext context, MongoVectorQuery query, Class<T> documentType);
/** The current state of a vector index. */
MongoSearchIndexState indexState(String indexName);
/**
* One result and its interpreted score.
*
* @param <T> the document type
*/
record MongoVectorHit<T>(T document, double normalizedScore, String scoreContract) {}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import org.springframework.data.mongodb.core.aggregation.AggregationOperation;
/**
* A pipeline together with the grading of every stage in it (design §17).
*
* <p>Stages and operations are added in one call so the two lists cannot drift. If a caller could
* append an {@code AggregationOperation} without declaring which stage it is, the policy check
* would be reviewing a description of the pipeline rather than the pipeline itself.
*/
public record MongoAggregationPlan(
List<AggregationOperation> operations,
List<MongoAggregationStageDescriptor> stages,
Set<String> lookupCollections) {
public MongoAggregationPlan {
Objects.requireNonNull(operations, "operations");
Objects.requireNonNull(stages, "stages");
Objects.requireNonNull(lookupCollections, "lookupCollections");
operations = List.copyOf(operations);
stages = List.copyOf(stages);
lookupCollections = Set.copyOf(lookupCollections);
if (operations.size() != stages.size()) {
throw new IllegalArgumentException(
"every aggregation operation must declare exactly one stage descriptor");
}
if (operations.isEmpty()) {
throw new IllegalArgumentException("an aggregation plan needs at least one stage");
}
}
/** Starts a plan. */
public static Builder builder() {
return new Builder();
}
/** Collects operations together with their declared stage names. */
public static final class Builder {
private final List<AggregationOperation> operations = new ArrayList<>();
private final List<MongoAggregationStageDescriptor> stages = new ArrayList<>();
private final Set<String> lookupCollections = new LinkedHashSet<>();
private Builder() {}
/** Appends one operation and the stage name it produces. */
public Builder stage(String stageName, AggregationOperation operation) {
Objects.requireNonNull(stageName, "stageName");
Objects.requireNonNull(operation, "operation");
stages.add(MongoAggregationStageDescriptor.of(stageName));
operations.add(operation);
return this;
}
/** Appends a {@code $lookup} and records the collection it reads. */
public Builder lookup(String targetCollection, AggregationOperation operation) {
lookupCollections.add(Objects.requireNonNull(targetCollection, "targetCollection"));
return stage("$lookup", operation);
}
/** Builds the immutable plan. */
public MongoAggregationPlan build() {
return new MongoAggregationPlan(operations, stages, lookupCollections);
}
}
}
@@ -0,0 +1,137 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.Objects;
import java.util.Set;
/**
* What one aggregation caller is allowed to run (design §17).
*
* <p>{@code allowDiskUse} is a declared property rather than a retry-on-failure fallback. Enabling
* it after a memory-limit failure turns an error into a silent slowdown: the pipeline then succeeds
* by writing to disk, and nobody learns that the data outgrew the plan.
*/
public record MongoAggregationProfile(
Set<MongoAggregationRisk> allowedRisks,
Set<String> reviewedStages,
Set<String> lookupCollectionAllowlist,
int maxStages,
boolean allowDiskUse,
boolean strictMapping) {
/** A pipeline longer than this is a report, not a query, and belongs in an offline job. */
public static final int DEFAULT_MAX_STAGES = 12;
public MongoAggregationProfile {
Objects.requireNonNull(allowedRisks, "allowedRisks");
Objects.requireNonNull(reviewedStages, "reviewedStages");
Objects.requireNonNull(lookupCollectionAllowlist, "lookupCollectionAllowlist");
allowedRisks = Set.copyOf(allowedRisks);
reviewedStages = Set.copyOf(reviewedStages);
lookupCollectionAllowlist = Set.copyOf(lookupCollectionAllowlist);
if (maxStages <= 0) {
throw new IllegalArgumentException("an aggregation profile needs a positive stage limit");
}
if (allowedRisks.contains(MongoAggregationRisk.A4_ADMIN)) {
throw new IllegalArgumentException(
"an aggregation profile must not allow admin stages; $out and $merge are D4 operations");
}
}
/** The default read profile: streaming stages only, strict mapping, no disk spill. */
public static MongoAggregationProfile stableRead() {
return new MongoAggregationProfile(
Set.of(MongoAggregationRisk.A1_BOUNDED),
Set.of(),
Set.of(),
DEFAULT_MAX_STAGES,
false,
true);
}
/** A profile that also permits accumulating stages, having declared the resources for them. */
public static MongoAggregationProfile budgetedRead(boolean allowDiskUse) {
return new MongoAggregationProfile(
Set.of(MongoAggregationRisk.A1_BOUNDED, MongoAggregationRisk.A2_BUDGETED),
Set.of(),
Set.of(),
DEFAULT_MAX_STAGES,
allowDiskUse,
true);
}
/** Returns a copy that permits the named reviewed stages. */
public MongoAggregationProfile withReviewedStages(String... stages) {
Set<String> reviewed = new LinkedHashSet<>(reviewedStages);
reviewed.addAll(Arrays.asList(stages));
return new MongoAggregationProfile(
allowedRisks, reviewed, lookupCollectionAllowlist, maxStages, allowDiskUse, strictMapping);
}
/** Returns a copy that permits {@code $lookup} against the named collections. */
public MongoAggregationProfile withLookupCollections(String... collections) {
Set<String> allowlist = new LinkedHashSet<>(lookupCollectionAllowlist);
allowlist.addAll(Arrays.asList(collections));
return new MongoAggregationProfile(
allowedRisks, reviewedStages, allowlist, maxStages, allowDiskUse, strictMapping);
}
/**
* Checks one stage against this profile.
*
* @throws MongoOperationRejectedException naming why the stage is not permitted
*/
public void requireAllowed(MongoAggregationStageDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
if (descriptor.writes()) {
throw MongoOperationRejectedException.of(
"aggregation.stage",
"stage "
+ descriptor.stage()
+ " writes to a collection and is a D4 admin operation; it never executes through the "
+ "read aggregation API");
}
if (allowedRisks.contains(descriptor.risk())) {
return;
}
if (descriptor.risk() == MongoAggregationRisk.A3_REVIEWED
&& reviewedStages.contains(descriptor.stage())) {
return;
}
throw MongoOperationRejectedException.of(
"aggregation.stage",
"stage "
+ descriptor.stage()
+ " is graded "
+ descriptor.risk()
+ ", which this profile does not permit");
}
/**
* Checks a {@code $lookup} target.
*
* @throws MongoOperationRejectedException when the collection is not on the allowlist
*/
public void requireLookupCollection(String collection) {
if (!lookupCollectionAllowlist.contains(Objects.requireNonNull(collection, "collection"))) {
throw MongoOperationRejectedException.of(
"aggregation.lookup",
"collection '" + collection + "' is not on this profile's $lookup allowlist");
}
}
/**
* Checks the pipeline length.
*
* @throws MongoOperationRejectedException when the pipeline is longer than the profile allows
*/
public void requireStageCount(int stageCount) {
if (stageCount > maxStages) {
throw MongoOperationRejectedException.of(
"aggregation.stage",
"the pipeline has " + stageCount + " stages, above this profile's limit of " + maxStages);
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
/**
* The resource class of one aggregation stage (design §17).
*
* <p>Aggregation stages are not equally dangerous, and the difference is not visible in the
* pipeline text. {@code $match} streams; {@code $group} and {@code $sort} accumulate, hit a 100 MiB
* per-stage memory limit and then either fail or spill to disk; {@code $facet} and {@code
* $graphLookup} multiply that cost; {@code $out} and {@code $merge} write. Grading the stages is
* what lets one policy answer "may this pipeline run here" without reading it line by line.
*/
public enum MongoAggregationRisk {
/** Streaming, bounded stages. Allowed by default. */
A1_BOUNDED,
/** Accumulating stages. Require a declared resource profile. */
A2_BUDGETED,
/** Multiplying stages. Require explicit review registration. */
A3_REVIEWED,
/** Write and administrative stages. D4 only; never reachable from a read API. */
A4_ADMIN;
/** True when a stage of this class may run without extra registration. */
public boolean allowedByDefault() {
return this == A1_BOUNDED;
}
/** True when a stage of this class writes and therefore belongs to the admin plane. */
public boolean isWriteStage() {
return this == A4_ADMIN;
}
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import java.util.Map;
import java.util.Objects;
/**
* One aggregation stage, graded (design §17).
*
* <p>The grade table is the platform's own, not the server's. MongoDB will happily run a {@code
* $facet} over an unbounded input; whether this application should is a capacity decision, and this
* is where it is recorded.
*/
public record MongoAggregationStageDescriptor(String stage, MongoAggregationRisk risk) {
private static final Map<String, MongoAggregationRisk> KNOWN_STAGES =
Map.ofEntries(
Map.entry("$match", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$project", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$set", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$addFields", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$unset", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$limit", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$skip", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$count", MongoAggregationRisk.A1_BOUNDED),
Map.entry("$sort", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$group", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$unwind", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$lookup", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$bucket", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$bucketAuto", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$sortByCount", MongoAggregationRisk.A2_BUDGETED),
Map.entry("$facet", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$graphLookup", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$setWindowFields", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$unionWith", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$densify", MongoAggregationRisk.A3_REVIEWED),
Map.entry("$out", MongoAggregationRisk.A4_ADMIN),
Map.entry("$merge", MongoAggregationRisk.A4_ADMIN),
Map.entry("$planCacheStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$collStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$indexStats", MongoAggregationRisk.A4_ADMIN),
Map.entry("$currentOp", MongoAggregationRisk.A4_ADMIN),
Map.entry("$listSessions", MongoAggregationRisk.A4_ADMIN));
public MongoAggregationStageDescriptor {
Objects.requireNonNull(stage, "stage");
Objects.requireNonNull(risk, "risk");
if (!stage.startsWith("$")) {
throw new IllegalArgumentException("an aggregation stage name starts with '$': " + stage);
}
}
/**
* Grades a stage by name.
*
* <p>An unknown stage is graded {@code A3_REVIEWED}, not {@code A1_BOUNDED}. A stage this
* platform has never seen is one whose cost nobody here has reasoned about, and defaulting it to
* "cheap" would let a future server release introduce an expensive stage that runs unreviewed.
*/
public static MongoAggregationStageDescriptor of(String stage) {
Objects.requireNonNull(stage, "stage");
return new MongoAggregationStageDescriptor(
stage, KNOWN_STAGES.getOrDefault(stage, MongoAggregationRisk.A3_REVIEWED));
}
/** True when this stage writes to a collection. */
public boolean writes() {
return risk.isWriteStage();
}
}
@@ -0,0 +1,109 @@
package dev.caskeleton.adapter.outbound.mongo.aggregation;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext;
import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetPolicyRegistry;
import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import org.springframework.data.mongodb.core.MongoOperations;
import org.springframework.data.mongodb.core.aggregation.Aggregation;
import org.springframework.data.mongodb.core.aggregation.AggregationOptions;
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
/**
* Runs an aggregation only after its stages, budget and lookups have been checked (design §17).
*
* <p>Every guard runs before the first stage reaches the server, because an aggregation that is
* going to be refused should cost nothing. The one guard that runs afterwards is the result-count
* check: {@code $limit} bounds what the pipeline emits, but a pipeline whose own shape produced
* more than the budget allows has still told the caller something worth failing over.
*/
public final class PolicyAwareMongoAggregationExecutor {
private final MongoOperations operations;
private final MongoBudgetPolicyRegistry budgets;
private final MongoBudgetEnforcer enforcer;
public PolicyAwareMongoAggregationExecutor(
MongoOperations operations, MongoBudgetPolicyRegistry budgets, MongoBudgetEnforcer enforcer) {
this.operations = Objects.requireNonNull(operations, "operations");
this.budgets = Objects.requireNonNull(budgets, "budgets");
this.enforcer = Objects.requireNonNull(enforcer, "enforcer");
}
/**
* Validates a plan against a profile without executing it.
*
* <p>Separate from execution so a startup check or a test can prove a pipeline is admissible
* without a server.
*/
public void validate(MongoAggregationPlan plan, MongoAggregationProfile profile) {
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(profile, "profile");
profile.requireStageCount(plan.stages().size());
plan.stages().forEach(profile::requireAllowed);
plan.lookupCollections().forEach(profile::requireLookupCollection);
}
/**
* Executes a typed aggregation.
*
* @throws MongoOperationRejectedException when a stage, lookup, budget or result size is not
* permitted
*/
public <T> List<T> execute(
MongoOperationContext context,
MongoAggregationProfile profile,
MongoAggregationPlan plan,
String collection,
Class<T> outputType) {
Objects.requireNonNull(context, "context");
Objects.requireNonNull(collection, "collection");
Objects.requireNonNull(outputType, "outputType");
validate(plan, profile);
MongoOperationBudget budget = budgets.require(context.operationName());
MongoOperationBudget effective =
enforcer.narrow(budget, budget.narrowedTo(budgetFor(context, budget)));
Aggregation aggregation =
Aggregation.newAggregation(plan.operations()).withOptions(optionsFor(profile, effective));
AggregationResults<T> results = operations.aggregate(aggregation, collection, outputType);
List<T> mapped = results.getMappedResults();
if (mapped.size() > effective.maxResults()) {
throw MongoOperationRejectedException.of(
"aggregation.result",
"the aggregation returned "
+ mapped.size()
+ " documents, above the budget of "
+ effective.maxResults());
}
return mapped;
}
private static MongoOperationBudget budgetFor(
MongoOperationContext context, MongoOperationBudget registered) {
// The context's timeout is the caller's deadline; it may tighten maxTimeMS but never extend it.
long contextMillis = Math.max(1L, context.timeout().toMillis());
return new MongoOperationBudget(
registered.maxResults(),
registered.maxResultBytes(),
Math.min(registered.maxTimeMillis(), contextMillis),
registered.cursorBatchSize());
}
private static AggregationOptions optionsFor(
MongoAggregationProfile profile, MongoOperationBudget budget) {
AggregationOptions.Builder options =
AggregationOptions.builder()
.allowDiskUse(profile.allowDiskUse())
.cursorBatchSize(budget.cursorBatchSize())
.maxTime(Duration.ofMillis(budget.maxTimeMillis()));
return profile.strictMapping() ? options.strictMapping().build() : options.build();
}
}
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Registered logical name of a collection profile (design §7.1, §16.2).
*
* <p>Every operation resolves its guardrails — field allowlist, operator allowlist, budget, index
* manifest, TTL policy — through this name. Accepting a caller-supplied collection string instead
* would defeat all of them at once, so the same dynamic-value rejection as {@link
* DatabaseProfileName} applies here.
*/
public record CollectionProfileName(String value) {
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9-]{2,63}");
private static final Pattern UUID_LIKE =
Pattern.compile("(?i).*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*");
public CollectionProfileName {
if (value == null || !FORMAT.matcher(value).matches() || UUID_LIKE.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB collection profile name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Registered logical name of a database profile (design §7.1, §28).
*
* <p>A profile name selects a configured connection, credential, consistency default and timeout
* set. It is never the physical database name supplied by a caller: allowing that would turn a
* request value into a routing decision and into a metric tag. The pattern therefore rejects
* slashes, whitespace and generated-identifier shapes, which is what {@code Dynamic collection
* profile} in the design's startup failure list means in practice.
*/
public record DatabaseProfileName(String value) {
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9-]{2,63}");
/** A value that looks like a generated identifier is dynamic input, not a registered profile. */
private static final Pattern UUID_LIKE =
Pattern.compile("(?i).*[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}.*");
public DatabaseProfileName {
if (value == null || !FORMAT.matcher(value).matches() || UUID_LIKE.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB database profile name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile;
import java.time.Duration;
import java.util.Objects;
/**
* Immutable execution context every platform operation requires (design §7.1).
*
* <p>The context is deliberately the only way to reach an execution path: it forces the caller to
* name the operation, the database and collection profiles, the consistency guarantee it is asking
* for, and a positive timeout. Nothing here identifies a document, a tenant or a user, so the whole
* context can be attached to telemetry without a redaction step.
*/
public record MongoOperationContext(
MongoOperationName operationName,
DatabaseProfileName databaseProfile,
CollectionProfileName collectionProfile,
MongoConsistencyProfile consistency,
Duration timeout) {
public MongoOperationContext {
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(databaseProfile, "databaseProfile");
Objects.requireNonNull(collectionProfile, "collectionProfile");
Objects.requireNonNull(consistency, "consistency");
Objects.requireNonNull(timeout, "timeout");
if (timeout.isZero() || timeout.isNegative()) {
throw new IllegalArgumentException("MongoDB operation timeout must be positive");
}
}
/** Convenience factory for the common case where profile names are plain registered strings. */
public static MongoOperationContext of(
String operationName,
String databaseProfile,
String collectionProfile,
MongoConsistencyProfile consistency,
Duration timeout) {
return new MongoOperationContext(
new MongoOperationName(operationName),
new DatabaseProfileName(databaseProfile),
new CollectionProfileName(collectionProfile),
consistency,
timeout);
}
/**
* Narrows the consistency profile of an existing context.
*
* <p>Returns a new value; a context is never mutated, because the same instance is handed to
* observation, budget resolution and failure translation.
*/
public MongoOperationContext withConsistency(MongoConsistencyProfile newConsistency) {
return new MongoOperationContext(
operationName, databaseProfile, collectionProfile, newConsistency, timeout);
}
}
@@ -0,0 +1,29 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.regex.Pattern;
/**
* Bounded, low-cardinality identity for one logical MongoDB operation (design §7.1).
*
* <p>The value is the key used by metrics, traces, budget lookup and policy resolution, so it must
* never carry a dynamic value: no document id, no tenant id, no collection name assembled at
* runtime, no request-scoped value. The format is fixed by the design and validated in the
* canonical constructor, which is what keeps metric cardinality bounded at the type level rather
* than by convention.
*/
public record MongoOperationName(String value) {
/** Design §7.1 — the exact accepted shape of an operation name. */
private static final Pattern FORMAT = Pattern.compile("[a-z][a-z0-9.-]{2,95}");
public MongoOperationName {
if (value == null || !FORMAT.matcher(value).matches()) {
throw new IllegalArgumentException("invalid MongoDB operation name");
}
}
@Override
public String toString() {
return value;
}
}
@@ -0,0 +1,46 @@
package dev.caskeleton.adapter.outbound.mongo.api;
import java.util.Objects;
/**
* The three identifiers every failure, metric and trace is keyed by (design §15, §27).
*
* <p>Grouping them is what makes "never null" affordable: a failure raised before profile
* resolution still needs an operation name, so the unresolved profiles become the explicit {@code
* unspecified} identity rather than a null the telemetry layer has to defend against.
*/
public record MongoOperationScope(
MongoOperationName operationName,
DatabaseProfileName databaseProfile,
CollectionProfileName collectionProfile) {
/** Registered name used when a failure happens before profile resolution. */
public static final String UNSPECIFIED = "unspecified";
public MongoOperationScope {
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(databaseProfile, "databaseProfile");
Objects.requireNonNull(collectionProfile, "collectionProfile");
}
/** The scope of a fully resolved operation context. */
public static MongoOperationScope of(MongoOperationContext context) {
Objects.requireNonNull(context, "context");
return new MongoOperationScope(
context.operationName(), context.databaseProfile(), context.collectionProfile());
}
/** A scope for a failure raised before the database and collection profiles were resolved. */
public static MongoOperationScope ofOperation(MongoOperationName operationName) {
return new MongoOperationScope(
operationName,
new DatabaseProfileName(UNSPECIFIED),
new CollectionProfileName(UNSPECIFIED));
}
/** True when the profiles are still the placeholder identity. */
public boolean isProfileResolved() {
return !UNSPECIFIED.equals(databaseProfile.value())
&& !UNSPECIFIED.equals(collectionProfile.value());
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.mongo.api;
/**
* The kind of MongoDB operation, as a bounded telemetry dimension (design §15, §27).
*
* <p>Distinct from {@link MongoOperationName}: the name says which business operation ran, the type
* says which shape of database work it was. Both are low cardinality, and neither carries data.
*/
public enum MongoOperationType {
FIND,
COUNT,
DISTINCT,
INSERT,
UPDATE,
REPLACE,
DELETE,
FIND_AND_MODIFY,
BULK_WRITE,
AGGREGATE,
CURSOR,
CHANGE_STREAM,
TRANSACTION_COMMIT,
TRANSACTION_ABORT,
CAPABILITY_COMMAND,
ADMIN_COMMAND,
UNKNOWN
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
/**
* The closed set of capabilities the platform can report on (design §7.4, §22-§25).
*
* <p>A closed enum rather than free strings, because a capability name is looked up at startup to
* decide whether a whole subsystem may wire itself. A typo in a free-form key would silently answer
* "unsupported" and disable a feature the deployment paid for.
*/
public enum MongoCapability {
/** Multi-document transactions; implies a replica set or sharded topology. */
TRANSACTION,
/** Causally consistent sessions for read-your-writes. */
CAUSAL_SESSION,
/** Change streams; implies a replica set or sharded topology and a watch privilege. */
CHANGE_STREAM,
/** GeoJSON and 2dsphere queries. */
GEOSPATIAL,
/** TTL indexes as physical cleanup. */
TTL_CLEANUP,
/** Sharded routing awareness in the application plane. */
SHARDING,
/** Time series collections, which do not inherit general collection capabilities. */
TIME_SERIES,
/** Client-side field level encryption. */
CSFLE,
/** Queryable encryption, equality and range only on the MongoDB 8.0 Stable lane. */
QUERYABLE_ENCRYPTION,
/** Full-text search indexes and queries. */
SEARCH,
/** Vector search indexes and queries. */
VECTOR_SEARCH,
/** Shared-collection multi-tenancy guardrails. */
SHARED_COLLECTION_TENANCY,
/** Database-per-tenant routing and lifecycle. */
DATABASE_PER_TENANT,
/** GridFS legacy read and migration compatibility. */
GRIDFS_COMPATIBILITY,
/** D4 administrative plane: collection, validator, index, migration, shard, repair. */
ADMIN_PLANE
}
@@ -0,0 +1,70 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/**
* The capability report for one configured runtime (design §7.4).
*
* <p>Unknown capabilities do not throw: they answer {@code UNSUPPORTED} with the reason {@code
* not-reported}, so a startup probe can distinguish "the server said no" from "nobody ever asked
* the server". Both are refusals, but only the second is a configuration bug.
*/
public final class MongoCapabilitySet {
private static final String NOT_REPORTED = "not-reported";
private final Map<MongoCapability, MongoCapabilitySupport> supports;
private MongoCapabilitySet(Map<MongoCapability, MongoCapabilitySupport> supports) {
this.supports = supports;
}
/** Builds a set from explicit support declarations; a later duplicate replaces an earlier one. */
public static MongoCapabilitySet of(MongoCapabilitySupport... declarations) {
return of(Arrays.asList(declarations));
}
/** Builds a set from explicit support declarations; a later duplicate replaces an earlier one. */
public static MongoCapabilitySet of(Collection<MongoCapabilitySupport> declarations) {
Objects.requireNonNull(declarations, "declarations");
Map<MongoCapability, MongoCapabilitySupport> byCapability =
new EnumMap<>(MongoCapability.class);
for (MongoCapabilitySupport declaration : declarations) {
Objects.requireNonNull(declaration, "declaration");
byCapability.put(declaration.capability(), declaration);
}
return new MongoCapabilitySet(byCapability);
}
/** An empty report: every capability answers unsupported with an explicit reason. */
public static MongoCapabilitySet empty() {
return new MongoCapabilitySet(new EnumMap<>(MongoCapability.class));
}
/**
* Returns the support record for a capability, never {@code null}.
*
* <p>The design's rule is that a refusal always carries a reason, so an unreported capability is
* materialised as an {@code UNSUPPORTED} record rather than an empty optional the caller might
* quietly ignore.
*/
public MongoCapabilitySupport require(MongoCapability capability) {
Objects.requireNonNull(capability, "capability");
MongoCapabilitySupport support = supports.get(capability);
return support != null ? support : MongoCapabilitySupport.unsupported(capability, NOT_REPORTED);
}
/** True only when the capability is certified on the Stable lane. */
public boolean isStable(MongoCapability capability) {
return require(capability).usableOnStableLane();
}
/** All declared support records, keyed by capability. */
public Map<MongoCapability, MongoCapabilitySupport> declared() {
return Map.copyOf(supports);
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* What one capability actually supports here, and under which constraints (design §7.4).
*
* <p>The design is explicit that a capability must not answer with a bare {@code boolean}. Time
* series, sharding, encryption and search each fail for a different reason — wrong topology, server
* version, missing privilege, unsupported combination — and a caller that only learns "no" cannot
* tell an operator what to change. The constraint map carries that reason, as immutable strings
* only: no driver handle, no provider object, nothing that would make this value unsafe to log.
*/
public record MongoCapabilitySupport(
MongoCapability capability, MongoSupportLevel level, Map<String, String> constraints) {
/** Constraint key for the topology a capability requires. */
public static final String TOPOLOGY = "topology";
/** Constraint key for the minimum server version a capability requires. */
public static final String SERVER_VERSION = "serverVersion";
/** Constraint key for the privilege or principal a capability requires. */
public static final String PRIVILEGE = "privilege";
/** Constraint key explaining why a capability is unsupported. */
public static final String REASON = "reason";
public MongoCapabilitySupport {
Objects.requireNonNull(capability, "capability");
Objects.requireNonNull(level, "level");
Objects.requireNonNull(constraints, "constraints");
constraints = Map.copyOf(constraints);
if (level == MongoSupportLevel.UNSUPPORTED && !constraints.containsKey(REASON)) {
throw new IllegalArgumentException(
"unsupported MongoDB capability must carry an explicit reason: " + capability);
}
}
/** Declares a capability supported at the given level with no further constraint. */
public static MongoCapabilitySupport of(MongoCapability capability, MongoSupportLevel level) {
return new MongoCapabilitySupport(capability, level, Map.of());
}
/** Declares a capability unsupported, forcing the caller to state why. */
public static MongoCapabilitySupport unsupported(MongoCapability capability, String reason) {
return new MongoCapabilitySupport(
capability, MongoSupportLevel.UNSUPPORTED, Map.of(REASON, reason));
}
/** Returns a copy with one additional constraint entry. */
public MongoCapabilitySupport withConstraint(String key, String value) {
Map<String, String> merged = new LinkedHashMap<>(constraints);
merged.put(Objects.requireNonNull(key, "key"), Objects.requireNonNull(value, "value"));
return new MongoCapabilitySupport(capability, level, merged);
}
/** The topology this capability requires, or an empty string when it imposes none. */
public String requiredTopology() {
return constraints.getOrDefault(TOPOLOGY, "");
}
/**
* The minimum server version this capability requires, or an empty string when it imposes none.
*/
public String requiredServerVersion() {
return constraints.getOrDefault(SERVER_VERSION, "");
}
/** The privilege this capability requires, or an empty string when it imposes none. */
public String requiredPrivilege() {
return constraints.getOrDefault(PRIVILEGE, "");
}
/** True when this capability may be used on the Stable lane without an opt-in module. */
public boolean usableOnStableLane() {
return level == MongoSupportLevel.STABLE;
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.mongo.api.capability;
/**
* Support tier of one MongoDB capability (design §7.4).
*
* <p>The tier is part of the public contract, not documentation: an {@code ADVANCED} or {@code
* EXPERIMENTAL} capability is never reachable from the Stable starter's default wiring, and {@code
* UNSUPPORTED} always carries a reason instead of degrading into a silent {@code false}.
*/
public enum MongoSupportLevel {
/** Certified on both release lanes and covered by the Stable release gate. */
STABLE,
/** Isolated opt-in module with its own topology, credential or provider gate. */
ADVANCED,
/** Not promotable yet: operational scale or provider evidence is missing. */
EXPERIMENTAL,
/** Not available in this profile, topology, server version or privilege set. */
UNSUPPORTED
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
import java.util.Objects;
/**
* The concrete read preference, read concern and write concern behind a named profile (design
* §7.2).
*
* <p>The values are plain strings rather than driver types because this type lives in the
* framework-free core: the Spring Data layer translates them once, at the edge, and every other
* layer reasons about the profile instead of about driver enums.
*/
public record MongoConsistencyDescriptor(
MongoConsistencyProfile profile,
String readPreference,
String readConcern,
String writeConcern,
boolean requiresCausalSession,
MongoConsistencyGuarantee guarantee) {
/** Read preference value meaning "always the primary". */
public static final String PRIMARY = "primary";
/** Read preference value meaning "a secondary when one is available". */
public static final String SECONDARY_PREFERRED = "secondaryPreferred";
public MongoConsistencyDescriptor {
Objects.requireNonNull(profile, "profile");
Objects.requireNonNull(readPreference, "readPreference");
Objects.requireNonNull(readConcern, "readConcern");
Objects.requireNonNull(writeConcern, "writeConcern");
Objects.requireNonNull(guarantee, "guarantee");
if (requiresCausalSession && !"majority".equals(readConcern)) {
throw new IllegalArgumentException(
"a causal session profile requires majority read concern: " + profile);
}
if (requiresCausalSession && !"majority".equals(writeConcern)) {
throw new IllegalArgumentException(
"a causal session profile requires majority write concern: " + profile);
}
}
/** True when this profile may serve reads from a secondary. */
public boolean readsFromSecondary() {
return !PRIMARY.equals(readPreference);
}
/** Convenience view used by the transaction layer, which forbids secondary reads. */
public boolean staleReadsPossible() {
return guarantee.staleReadsPossible();
}
}
@@ -0,0 +1,25 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
import java.util.Objects;
/**
* The human-readable guarantee a consistency profile actually provides (design §7.2).
*
* <p>The design's completion criterion asks whether "the caller knows the real guarantee of the
* read/write concern". A prose sentence attached to the profile is how that question gets a
* checkable answer: it is asserted in tests and rendered into the generated support matrix, so a
* profile whose concerns change without its promise changing fails the build.
*/
public record MongoConsistencyGuarantee(
String summary,
boolean durableAgainstPrimaryFailover,
boolean readsOwnWrites,
boolean staleReadsPossible) {
public MongoConsistencyGuarantee {
Objects.requireNonNull(summary, "summary");
if (summary.isBlank()) {
throw new IllegalArgumentException("consistency guarantee needs a summary");
}
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
/**
* Named read/write guarantee a caller asks for (design §7.2).
*
* <p>These are profiles rather than three independent knobs because read preference, read concern
* and write concern only mean something together. A caller that picks {@code majority} write
* concern and {@code secondaryPreferred} reads has not chosen durability, it has chosen a bug.
*
* <p>{@link #STALE_READ_ALLOWED} is named for its risk on purpose: the design forbids deriving
* secondary routing from a {@code readOnly=true} annotation, because that turns an optimisation
* hint into a silent correctness change.
*/
public enum MongoConsistencyProfile {
/** primary / local / acknowledged — ordinary low-latency work. */
PRIMARY_LOCAL,
/** primary / majority / majority — rollback resistance and durability. */
PRIMARY_MAJORITY,
/** primary / majority / majority inside a causal session — read-your-writes. */
CAUSAL_MAJORITY,
/** secondaryPreferred — explicitly accepts stale data. Never a default. */
STALE_READ_ALLOWED,
/** primary / snapshot / majority — multi-document snapshot transactions. */
SNAPSHOT_TRANSACTION,
/** primary, profile-defined concerns — short write transactions. */
MONGO_SHORT_WRITE
}
@@ -0,0 +1,164 @@
package dev.caskeleton.adapter.outbound.mongo.api.consistency;
import java.util.Collection;
import java.util.EnumMap;
import java.util.Map;
import java.util.Objects;
/**
* The closed registry of consistency profiles (design §7.2).
*
* <p>A deployment cannot invent a profile name. Every profile in the enum must be described here or
* {@link #standard()} fails to build, which turns "we added an enum constant and forgot its
* semantics" into a startup failure rather than a runtime surprise.
*/
public final class MongoConsistencyRegistry {
private final Map<MongoConsistencyProfile, MongoConsistencyDescriptor> descriptors;
private final MongoConsistencyProfile defaultProfile;
private MongoConsistencyRegistry(
Map<MongoConsistencyProfile, MongoConsistencyDescriptor> descriptors,
MongoConsistencyProfile defaultProfile) {
this.descriptors = descriptors;
this.defaultProfile = defaultProfile;
}
/** The platform's standard registry, with {@code PRIMARY_MAJORITY} as the default. */
public static MongoConsistencyRegistry standard() {
Map<MongoConsistencyProfile, MongoConsistencyDescriptor> byProfile =
new EnumMap<>(MongoConsistencyProfile.class);
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.PRIMARY_LOCAL,
MongoConsistencyDescriptor.PRIMARY,
"local",
"acknowledged",
false,
new MongoConsistencyGuarantee(
"Reads the primary's latest data and acknowledges writes without waiting for "
+ "replication; a primary failover can roll an acknowledged write back.",
false,
true,
false)));
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.PRIMARY_MAJORITY,
MongoConsistencyDescriptor.PRIMARY,
"majority",
"majority",
false,
new MongoConsistencyGuarantee(
"Reads and writes majority-committed data, so an acknowledged write survives a "
+ "primary failover.",
true,
true,
false)));
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.CAUSAL_MAJORITY,
MongoConsistencyDescriptor.PRIMARY,
"majority",
"majority",
true,
new MongoConsistencyGuarantee(
"Adds a causally consistent session on top of majority concerns, so a later read "
+ "in the same session observes this session's earlier writes.",
true,
true,
false)));
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.STALE_READ_ALLOWED,
MongoConsistencyDescriptor.SECONDARY_PREFERRED,
"local",
"acknowledged",
false,
new MongoConsistencyGuarantee(
"Explicitly accepts arbitrarily stale data from a secondary; never derived from a "
+ "read-only annotation.",
false,
false,
true)));
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.SNAPSHOT_TRANSACTION,
MongoConsistencyDescriptor.PRIMARY,
"snapshot",
"majority",
false,
new MongoConsistencyGuarantee(
"Reads a single majority-committed snapshot for the whole transaction, so a "
+ "multi-document invariant sees one consistent point in time.",
true,
true,
false)));
put(
byProfile,
new MongoConsistencyDescriptor(
MongoConsistencyProfile.MONGO_SHORT_WRITE,
MongoConsistencyDescriptor.PRIMARY,
"majority",
"majority",
false,
new MongoConsistencyGuarantee(
"A deliberately short write transaction on majority concerns; the profile exists to "
+ "bound transaction duration, not to weaken durability.",
true,
true,
false)));
requireComplete(byProfile);
return new MongoConsistencyRegistry(byProfile, MongoConsistencyProfile.PRIMARY_MAJORITY);
}
/** Builds a registry from explicit descriptors; used by tests and by profile overrides. */
public static MongoConsistencyRegistry of(
Collection<MongoConsistencyDescriptor> declarations, MongoConsistencyProfile defaultProfile) {
Objects.requireNonNull(declarations, "declarations");
Objects.requireNonNull(defaultProfile, "defaultProfile");
Map<MongoConsistencyProfile, MongoConsistencyDescriptor> byProfile =
new EnumMap<>(MongoConsistencyProfile.class);
declarations.forEach(declaration -> put(byProfile, declaration));
requireComplete(byProfile);
if (defaultProfile == MongoConsistencyProfile.STALE_READ_ALLOWED) {
throw new IllegalArgumentException("stale reads must never be the default consistency");
}
return new MongoConsistencyRegistry(byProfile, defaultProfile);
}
private static void put(
Map<MongoConsistencyProfile, MongoConsistencyDescriptor> target,
MongoConsistencyDescriptor descriptor) {
Objects.requireNonNull(descriptor, "descriptor");
target.put(descriptor.profile(), descriptor);
}
private static void requireComplete(
Map<MongoConsistencyProfile, MongoConsistencyDescriptor> byProfile) {
for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) {
if (!byProfile.containsKey(profile)) {
throw new IllegalStateException("consistency profile is not described: " + profile);
}
}
}
/** The descriptor for a profile; every enum constant is always present. */
public MongoConsistencyDescriptor require(MongoConsistencyProfile profile) {
Objects.requireNonNull(profile, "profile");
MongoConsistencyDescriptor descriptor = descriptors.get(profile);
if (descriptor == null) {
throw new IllegalStateException("unknown MongoDB consistency profile: " + profile);
}
return descriptor;
}
/** The profile applied when an operation does not name one. Never the stale-read profile. */
public MongoConsistencyProfile defaultProfile() {
return defaultProfile;
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A bulk write partially succeeded (design §19.2).
*
* <p>A first-class type rather than a generic write failure, because the recovery is the opposite
* of the usual one: the successful items must <em>not</em> be re-run. The counts here are what a
* caller needs to decide that; the per-item detail stays in the bulk result, which the caller
* already holds.
*/
public final class MongoBulkPartialFailureException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
private final int requested;
private final int succeeded;
private final int failed;
public MongoBulkPartialFailureException(
MongoFailureContext failureContext, int requested, int succeeded, int failed) {
super("the bulk write partially succeeded; do not re-run the successful items", failureContext);
this.requested = requested;
this.succeeded = succeeded;
this.failed = failed;
}
/** How many operations the caller submitted. */
public int requested() {
return requested;
}
/** How many the server confirmed. These must not be replayed. */
public int succeeded() {
return succeeded;
}
/** How many failed and are eligible for a targeted retry. */
public int failed() {
return failed;
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A socket or connection-pool level failure (design §15).
*
* <p>Whether it is safe to replay depends on when the connection broke, which is why the outcome is
* carried explicitly: a failure while connecting is {@code NOT_SENT}, a failure while awaiting the
* response to a write is {@code WRITE_RESULT_UNKNOWN}.
*/
public final class MongoConnectionException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoConnectionException(MongoFailureContext failureContext) {
super("the MongoDB connection failed", failureContext);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A cursor was killed, expired or is otherwise unusable (design §15, §19.1).
*
* <p>Streaming reads never transparently retry once the first document has been emitted, because
* the consumer has already acted on a prefix and a silent restart would deliver it twice. This
* exception is how that decision is handed back to the caller.
*/
public final class MongoCursorException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoCursorException(MongoFailureContext failureContext) {
super("the MongoDB cursor is no longer usable", failureContext);
}
}
@@ -0,0 +1,55 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A stored document's schema version is outside the supported range (design §12.2).
*
* <p>Raised before domain deserialization, which is the whole value: a document written by a newer
* release, or one left behind by a retired version, must not be silently coerced into the current
* shape. Versions are integers and carry no data, so both are safe to name in the message.
*/
public final class MongoDataSchemaUnsupportedException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
private final int documentVersion;
private final int minimumSupported;
private final int currentVersion;
public MongoDataSchemaUnsupportedException(
MongoFailureContext failureContext,
int documentVersion,
int minimumSupported,
int currentVersion) {
super(
"stored schema version "
+ documentVersion
+ " is outside the supported range ["
+ minimumSupported
+ ", "
+ currentVersion
+ "]",
failureContext);
this.documentVersion = documentVersion;
this.minimumSupported = minimumSupported;
this.currentVersion = currentVersion;
}
/** The version found on the stored document. */
public int documentVersion() {
return documentVersion;
}
/** The oldest version this release can still read. */
public int minimumSupported() {
return minimumSupported;
}
/** The version this release writes. */
public int currentVersion() {
return currentVersion;
}
}
@@ -0,0 +1,37 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A document exceeded the size budget (design §9.2, §15).
*
* <p>The platform's ceiling is below MongoDB's 16 MiB limit on purpose, so this exception normally
* fires against the project budget rather than the server limit. That difference is the point: an
* unbounded embedded array is a modelling defect, and catching it at the budget leaves room to fix
* the model before the server starts rejecting writes.
*/
public final class MongoDocumentTooLargeException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
private final long estimatedBytes;
private final long budgetBytes;
public MongoDocumentTooLargeException(
MongoFailureContext failureContext, long estimatedBytes, long budgetBytes) {
super("the document exceeds the configured BSON size budget", failureContext);
this.estimatedBytes = estimatedBytes;
this.budgetBytes = budgetBytes;
}
/** Estimated serialized size. A size, not content: safe to log. */
public long estimatedBytes() {
return estimatedBytes;
}
/** The configured ceiling that was exceeded. */
public long budgetBytes() {
return budgetBytes;
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A unique index rejected the write (design §15).
*
* <p>The duplicated value is deliberately absent: it is business data, and for a competing-create
* flow it is usually the natural key the caller already holds. The index name is not carried
* either, because it appears verbatim in the driver message and would reintroduce collection-shaped
* detail into telemetry.
*/
public final class MongoDuplicateKeyException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoDuplicateKeyException(MongoFailureContext failureContext) {
super(
"MongoDB rejected the write because a unique index already holds that key", failureContext);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* An encryption, key vault or KMS operation failed (design §23).
*
* <p>The strictest exception in the hierarchy for what it may carry: no plaintext, no ciphertext,
* no key material, no key alias. An operator diagnoses these from the KMS audit trail and the
* operation name, never from the application's exception message.
*/
public final class MongoEncryptionException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoEncryptionException(MongoFailureContext failureContext) {
super("a MongoDB encryption operation failed", failureContext);
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
/**
* What actually happened to a write, including the cases where nobody knows (design §7.3).
*
* <p>The two ambiguous outcomes are the reason this enum exists instead of a boolean. {@code
* WRITE_RESULT_UNKNOWN} and {@code TRANSACTION_COMMIT_UNKNOWN} are not failures: the write may well
* have been applied. Re-running the business body on either of them is how duplicate orders get
* created, so recovery reads the version, unique key, idempotency record or transaction record
* instead.
*/
public enum MongoExecutionOutcome {
/**
* The command never left the client: budget rejection, validation failure, no server selected.
*/
NOT_SENT,
/** The server processed the command and matched nothing, so no document changed. */
NO_WRITE_PERFORMED,
/** The server acknowledged the write at the requested write concern. */
WRITE_CONFIRMED,
/** Some bulk items succeeded and some failed; the successful ones must not be re-run. */
PARTIAL_BULK_WRITE,
/** The write may or may not have been applied; the response was lost. */
WRITE_RESULT_UNKNOWN,
/** The commit may or may not have succeeded; only the commit may be retried. */
TRANSACTION_COMMIT_UNKNOWN;
/** True when the caller cannot conclude whether data changed. */
public boolean isAmbiguous() {
return this == WRITE_RESULT_UNKNOWN || this == TRANSACTION_COMMIT_UNKNOWN;
}
/** True when re-running the same business body would risk a duplicate effect. */
public boolean forbidsBlindReplay() {
return isAmbiguous() || this == PARTIAL_BULK_WRITE;
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
/**
* Stable parent category of a failure (design §15).
*
* <p>Server codes and driver error labels change between releases; this category does not. It is
* the value that appears in metrics and dashboards, which is why an unrecognised server code maps
* to {@link #UNCLASSIFIED} rather than inventing a new category at runtime and blowing up metric
* cardinality.
*/
public enum MongoFailureCategory {
/** A unique index rejected the write. */
DUPLICATE_KEY,
/** The collection's JSON Schema validator rejected the document. */
SCHEMA_VALIDATION,
/** An expected-revision predicate matched nothing while the document still exists. */
OPTIMISTIC_CONFLICT,
/** Two concurrent writers touched the same document inside transactions. */
WRITE_CONFLICT,
/** The transaction can be retried in full from a new session. */
TRANSACTION_TRANSIENT,
/** The commit outcome is unknown; only the commit may be retried. */
TRANSACTION_COMMIT_UNKNOWN,
/** The write concern could not be satisfied. */
WRITE_CONCERN,
/** The read concern could not be satisfied. */
READ_CONCERN,
/** No suitable server was found within the server selection timeout. */
SERVER_SELECTION,
/** A socket or pool level connection failure. */
CONNECTION,
/** The operation exceeded its deadline. */
TIMEOUT,
/** A cursor was killed, expired or is otherwise unusable. */
CURSOR,
/** The document exceeded the BSON size limit. */
DOCUMENT_TOO_LARGE,
/** A bulk write partially succeeded. */
BULK_PARTIAL_FAILURE,
/** A sharded operation could not be routed, or was routed unacceptably. */
SHARD_ROUTING,
/** A change stream could not resume from its checkpoint. */
RESUME,
/** An encryption, key vault or KMS operation failed. */
ENCRYPTION,
/** The platform refused the operation locally before contacting a server. */
OPERATION_REJECTED,
/** The stored document's schema version is outside the supported range. */
SCHEMA_VERSION_UNSUPPORTED,
/** A recognised failure with no more specific stable category. */
UNCLASSIFIED
}
@@ -0,0 +1,187 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType;
import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* Everything the platform is allowed to remember about a failure (design §15).
*
* <p>The design lists the permitted fields exhaustively, and the list is short on purpose: the
* fields it excludes — documents, query parameters, credentials, plaintext encrypted values, resume
* tokens, shard key values — are exactly the ones that end up in an exception message, then in a
* log, then in a log aggregator that is not in the data-protection scope. Keeping them out of the
* type is stronger than keeping them out of the log statement, because there is nothing to leak.
*/
public record MongoFailureContext(
MongoOperationScope scope,
MongoOperationType operationType,
MongoConsistencyProfile consistencyProfile,
MongoFailureCategory category,
MongoExecutionOutcome outcome,
boolean retryable,
boolean ambiguous,
Set<String> errorLabels,
String serverCode,
int attempt,
Duration elapsed,
String traceId) {
public MongoFailureContext {
Objects.requireNonNull(scope, "scope");
Objects.requireNonNull(operationType, "operationType");
Objects.requireNonNull(consistencyProfile, "consistencyProfile");
Objects.requireNonNull(category, "category");
Objects.requireNonNull(outcome, "outcome");
Objects.requireNonNull(errorLabels, "errorLabels");
Objects.requireNonNull(serverCode, "serverCode");
Objects.requireNonNull(elapsed, "elapsed");
Objects.requireNonNull(traceId, "traceId");
errorLabels = Set.copyOf(errorLabels);
if (attempt < 1) {
throw new IllegalArgumentException("attempt must be at least 1");
}
if (elapsed.isNegative()) {
throw new IllegalArgumentException("elapsed must not be negative");
}
}
/**
* The commit-unknown context of design §14.3.
*
* <p>{@code retryable} is false because the field means "may the business body be replayed", and
* the answer for an unknown commit is never. Commit-only retry is a separate decision made by the
* transaction retry coordinator, which is why it is not expressed as a retryable failure here.
*/
public static MongoFailureContext commitUnknown(
MongoOperationName operationName, String serverCode, Duration elapsed) {
return new MongoFailureContext(
MongoOperationScope.ofOperation(operationName),
MongoOperationType.TRANSACTION_COMMIT,
MongoConsistencyProfile.PRIMARY_MAJORITY,
MongoFailureCategory.TRANSACTION_COMMIT_UNKNOWN,
MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN,
false,
true,
Set.of("UnknownTransactionCommitResult"),
serverCode,
1,
elapsed,
"");
}
/** A locally rejected operation: nothing was sent, nothing is ambiguous, nothing is retryable. */
public static MongoFailureContext rejected(MongoOperationName operationName) {
return new MongoFailureContext(
MongoOperationScope.ofOperation(operationName),
MongoOperationType.UNKNOWN,
MongoConsistencyProfile.PRIMARY_LOCAL,
MongoFailureCategory.OPERATION_REJECTED,
MongoExecutionOutcome.NOT_SENT,
false,
false,
Set.of(),
"",
1,
Duration.ZERO,
"");
}
/**
* A stored document that does not match the schema this release expects.
*
* <p>Nothing was written, so the outcome is {@code NO_WRITE_PERFORMED} rather than {@code
* NOT_SENT}: the read did reach the server, and what came back is unusable.
*/
public static MongoFailureContext schemaMismatch(MongoOperationName operationName) {
return new MongoFailureContext(
MongoOperationScope.ofOperation(operationName),
MongoOperationType.FIND,
MongoConsistencyProfile.PRIMARY_LOCAL,
MongoFailureCategory.SCHEMA_VALIDATION,
MongoExecutionOutcome.NO_WRITE_PERFORMED,
false,
false,
Set.of(),
"",
1,
Duration.ZERO,
"");
}
/** Returns a copy carrying the trace identifier of the surrounding observation. */
public MongoFailureContext withTraceId(String newTraceId) {
return new MongoFailureContext(
scope,
operationType,
consistencyProfile,
category,
outcome,
retryable,
ambiguous,
errorLabels,
serverCode,
attempt,
elapsed,
Objects.requireNonNull(newTraceId, "traceId"));
}
/** Returns a copy recorded as the given attempt number. */
public MongoFailureContext withAttempt(int newAttempt) {
return new MongoFailureContext(
scope,
operationType,
consistencyProfile,
category,
outcome,
retryable,
ambiguous,
errorLabels,
serverCode,
newAttempt,
elapsed,
traceId);
}
/** True when the driver attached the given error label to the failure. */
public boolean hasLabel(String label) {
return errorLabels.contains(label);
}
/**
* A one-line, redaction-safe summary.
*
* <p>Every value here is already bounded and data-free, so this string is safe to put in an
* exception message without a second redaction pass.
*/
public String describe() {
return "operation="
+ scope.operationName()
+ " database="
+ scope.databaseProfile()
+ " collection="
+ scope.collectionProfile()
+ " type="
+ operationType
+ " consistency="
+ consistencyProfile
+ " category="
+ category
+ " outcome="
+ outcome
+ " retryable="
+ retryable
+ " ambiguous="
+ ambiguous
+ " serverCode="
+ serverCode
+ " attempt="
+ attempt
+ " elapsedMs="
+ elapsed.toMillis();
}
}
@@ -0,0 +1,32 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName;
import java.io.Serial;
/**
* The platform refused the operation locally, before any server was contacted (design §15, §16.2).
*
* <p>This is the guardrail exception: unregistered field, unregistered operator, raised budget,
* write stage in a read API, deep skip, missing tenant context, admin command on a runtime client.
* It is always {@code NOT_SENT} and never retryable, because retrying a rejected request only
* repeats the rejection.
*/
public final class MongoOperationRejectedException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoOperationRejectedException(String summary, MongoFailureContext failureContext) {
super(summary, failureContext);
}
/** Rejects an operation identified only by name, before profiles were resolved. */
public static MongoOperationRejectedException of(String operationName, String summary) {
return new MongoOperationRejectedException(
summary, MongoFailureContext.rejected(new MongoOperationName(operationName)));
}
/** Rejects a platform-internal policy violation that has no caller-supplied operation name. */
public static MongoOperationRejectedException policy(String summary) {
return of("platform.policy", summary);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* An expected-revision predicate matched nothing while the document still exists (design §13.2).
*
* <p>Distinct from "document not found" on purpose: a conflict means someone else advanced the
* revision, and the recovery is to reload and recompute the whole use case. Recovering by re-saving
* the stale object is precisely the lost update the revision predicate exists to prevent.
*/
public final class MongoOptimisticConflictException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoOptimisticConflictException(MongoFailureContext failureContext) {
super(
"the document was modified concurrently; reload and recompute rather than resaving",
failureContext);
}
}
@@ -0,0 +1,52 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
import java.util.Objects;
/**
* Root of the provider-stable MongoDB error hierarchy (design §15).
*
* <p>Two rules shape this class. First, the message is derived from {@link MongoFailureContext},
* which can only hold bounded, data-free values — so no caller can accidentally put a document or a
* query parameter into an exception message. Second, no constructor accepts a {@link Throwable}
* cause: attaching the driver exception would re-expose everything the failure context deliberately
* dropped, through {@code getCause()} and through every stack trace printer. The driver's
* information survives as the error labels and server code already carried by the context.
*/
public abstract class MongoPersistenceException extends RuntimeException {
@Serial private static final long serialVersionUID = 1L;
private final transient MongoFailureContext failureContext;
protected MongoPersistenceException(String summary, MongoFailureContext failureContext) {
super(
summary + " [" + Objects.requireNonNull(failureContext, "failureContext").describe() + "]");
this.failureContext = failureContext;
}
/** The bounded metadata for this failure. Never contains document or query data. */
public MongoFailureContext failureContext() {
return failureContext;
}
/** Stable parent category for metrics and dashboards. */
public MongoFailureCategory category() {
return failureContext.category();
}
/** What the write actually did, including the two ambiguous outcomes. */
public MongoExecutionOutcome outcome() {
return failureContext.outcome();
}
/** True when replaying the same business body is safe. */
public boolean retryable() {
return failureContext.retryable();
}
/** True when the caller cannot conclude whether data changed. */
public boolean ambiguous() {
return failureContext.ambiguous();
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* The requested read concern could not be satisfied (design §15).
*
* <p>Most often a snapshot read whose snapshot is no longer available, or a majority read on a
* topology that cannot form a majority. Both are consistency-profile problems, not query problems,
* so the profile stays in the failure context to point at the real cause.
*/
public final class MongoReadConcernException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoReadConcernException(MongoFailureContext failureContext) {
super("MongoDB could not satisfy the requested read concern", failureContext);
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A change stream could not resume from its stored checkpoint (design §20.3).
*
* <p>The resume token is not attached. It is opaque, it is on the forbidden telemetry list, and it
* is stored encrypted; putting it in an exception message would undo all three at once.
*/
public final class MongoResumeException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoResumeException(MongoFailureContext failureContext) {
super("the change stream could not resume from its checkpoint", failureContext);
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
/**
* How much of a failed unit of work may be replayed (design §14.3, D-10).
*
* <p>The design's central retry rule is that {@code TransientTransactionError} and {@code
* UnknownTransactionCommitResult} demand opposite responses: the first replays the whole body from
* a new session, the second must never replay the body at all. Encoding that as a scope rather than
* a {@code retryable} boolean is what stops the two from collapsing into one flag at the call site.
*/
public enum MongoRetryScope {
/** Nothing may be replayed. */
NONE,
/** The operation may be re-sent; nothing was applied. */
WHOLE_OPERATION,
/** The transaction body may be replayed, but only from a fresh session. */
WHOLE_TRANSACTION,
/** Only the commit may be retried. The business body must not run again. */
COMMIT_ONLY,
/** Nothing may be replayed; durable evidence must be read to settle what happened. */
RECONCILIATION
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* The collection's JSON Schema validator rejected the document (design §12.1, §15).
*
* <p>Reaching this exception means the last line of defence caught something Bean Validation and
* the domain invariants let through, so it is a modelling bug rather than a user error. The
* rejected document is not attached for the reason the validator exists: it is the untrusted value.
*/
public final class MongoSchemaValidationException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoSchemaValidationException(MongoFailureContext failureContext) {
super("MongoDB schema validation rejected the document", failureContext);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* No server satisfying the read preference was found before the selection timeout (design §15).
*
* <p>Nothing was sent, so this failure is unambiguous and safely retryable. It is kept separate
* from a connection failure because the operational fix is different: server selection points at
* topology, elections and read preference, not at sockets.
*/
public final class MongoServerSelectionException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoServerSelectionException(MongoFailureContext failureContext) {
super("no suitable MongoDB server was selected within the selection timeout", failureContext);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A sharded operation lacked the routing evidence its collection profile requires (design §22).
*
* <p>The shard key value is never carried: it is business data, and it is on the design's forbidden
* telemetry list. What the operator needs is which operation was unrouted, and that is the
* operation name already in the failure context.
*/
public final class MongoShardRoutingException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoShardRoutingException(MongoFailureContext failureContext) {
super(
"the operation lacks the shard key or routing evidence its profile requires",
failureContext);
}
}
@@ -0,0 +1,19 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* The operation exceeded its deadline (design §15).
*
* <p>A timeout says when the client stopped waiting, not what the server did. A write that timed
* out after being sent is {@code WRITE_RESULT_UNKNOWN} and must be reconciled; only a timeout that
* fired before the command left is safe to replay.
*/
public final class MongoTimeoutException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoTimeoutException(MongoFailureContext failureContext) {
super("the MongoDB operation exceeded its deadline", failureContext);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
import java.util.Objects;
/**
* The commit outcome is unknown after the commit retry budget was exhausted (design §14.3).
*
* <p>This is the exception the business must never respond to by re-running its body: the
* transaction may have committed. The reconciliation hint names the evidence to read instead — the
* version, unique key, idempotency record or transaction record that can settle the question.
*/
public final class MongoTransactionCommitUnknownException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
private final String reconciliationHint;
public MongoTransactionCommitUnknownException(
MongoFailureContext failureContext, String reconciliationHint) {
super(
"the transaction commit result is unknown; reconcile instead of replaying the body",
failureContext);
this.reconciliationHint = Objects.requireNonNull(reconciliationHint, "reconciliationHint");
}
/** Names the durable evidence that can decide whether the commit happened. */
public String reconciliationHint() {
return reconciliationHint;
}
}
@@ -0,0 +1,20 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* The transaction failed with {@code TransientTransactionError} (design §14.3).
*
* <p>The whole body may be replayed, but only from a new {@code ClientSession}: the aborted session
* cannot be reused, and reusing it is the failure mode this dedicated type exists to make visible.
*/
public final class MongoTransactionTransientException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoTransactionTransientException(MongoFailureContext failureContext) {
super(
"the transaction failed transiently; replay the whole body from a new session",
failureContext);
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.outbound.mongo.api.error;
import java.io.Serial;
/**
* A recognised failure with no more specific stable category (design §15).
*
* <p>The design requires an unknown server code to "map to a stable parent category while
* preserving bounded metadata". {@link MongoPersistenceException} is that category but is abstract,
* so this is the concrete carrier: a caller can still catch the parent, and the server code, error
* labels and outcome all survive in the failure context.
*
* <p>Deliberately not retryable. A failure the platform cannot classify is one whose write outcome
* it cannot vouch for, and guessing in the safe-looking direction is how duplicates are created.
*/
public final class MongoUnclassifiedFailureException extends MongoPersistenceException {
@Serial private static final long serialVersionUID = 1L;
public MongoUnclassifiedFailureException(MongoFailureContext failureContext) {
super("MongoDB reported a failure this platform release does not classify", failureContext);
}
}

Some files were not shown because too many files have changed in this diff Show More