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:
co-authored by
Claude Opus 5
parent
3b5aee50e3
commit
d57d2f62a0
@@ -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`)이 소유한다.
|
||||
|
||||
## 노출 계층 (D1–D4)
|
||||
|
||||
| 계층 | 내용 | 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=
|
||||
|
||||
+81
@@ -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";
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
}
|
||||
+68
@@ -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();
|
||||
}
|
||||
}
|
||||
+24
@@ -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);
|
||||
}
|
||||
+37
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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('.', '-');
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+28
@@ -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();
|
||||
}
|
||||
}
|
||||
+62
@@ -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});
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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()));
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+28
@@ -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) {}
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
}
|
||||
+77
@@ -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());
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
+57
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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();
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
}
|
||||
+92
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
}
|
||||
}
|
||||
+61
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
+74
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+68
@@ -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());
|
||||
}
|
||||
}
|
||||
+65
@@ -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"));
|
||||
}
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
+92
@@ -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.");
|
||||
}
|
||||
}
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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);
|
||||
}
|
||||
+63
@@ -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 + "]";
|
||||
}
|
||||
}
|
||||
+53
@@ -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
|
||||
}
|
||||
}
|
||||
+62
@@ -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));
|
||||
}
|
||||
}
|
||||
+60
@@ -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"));
|
||||
}
|
||||
}
|
||||
+29
@@ -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) {}
|
||||
}
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+137
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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();
|
||||
}
|
||||
}
|
||||
+109
@@ -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();
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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());
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+56
@@ -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
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -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
|
||||
}
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+55
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
}
|
||||
+72
@@ -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
|
||||
}
|
||||
+187
@@ -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();
|
||||
}
|
||||
}
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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();
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+23
@@ -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
Reference in New Issue
Block a user