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
@@ -0,0 +1,63 @@
|
||||
# ADR-MONGO-001 — MongoDB platform boundary
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** `mongodb-superpowers-package/.../2026-08-11-mongodb-document-persistence-platform-design.md` §1, §2 (D-01, D-04, D-05), §5, §6
|
||||
|
||||
## Context
|
||||
|
||||
Two failure modes are common when a team wraps MongoDB.
|
||||
|
||||
The first is flattening: a shared `CommonMongoRepository<T, ID>` and a generic CRUD facade, which
|
||||
forces every collection to share an id strategy, a consistency profile and a query surface. MongoDB's
|
||||
single-document atomicity, aggregation model and change streams stop being reachable, and the first
|
||||
collection that needs something different gets a cast or a leaky generic.
|
||||
|
||||
The second is unrestricted exposure: the driver and `runCommand` available everywhere. Then any
|
||||
service can drop a collection, run an unbounded pipeline, or issue an admin command from a request
|
||||
thread, and no review catches it because there is nothing structural to catch.
|
||||
|
||||
## Decision
|
||||
|
||||
The domain owns its documents; the platform owns the cross-cutting decisions. Four exposure planes:
|
||||
|
||||
| Plane | Contents | Client |
|
||||
|---|---|---|
|
||||
| D1 Standard document persistence | Spring Data repositories, typed queries, mapping manifest, atomic update primitives, optimistic revision | Stable API V1, `apiStrict=true` |
|
||||
| D2 Advanced document operations | `MongoTemplate`, transactions/sessions, bulk, aggregation, keyset cursors, change streams | Stable API V1, `apiStrict=true` |
|
||||
| D3 Explicit Mongo capability | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations | Separate capability client |
|
||||
| D4 Admin plane | Collection, validator, index, migration, shard, repair | Separate admin client and credential |
|
||||
|
||||
Specifically:
|
||||
|
||||
1. **No `CommonMongoRepository<T, ID>`.** Each aggregate declares its own repository.
|
||||
2. **D1/D2 run on Stable API V1 with `apiStrict=true`,** so a command outside the versioned API fails
|
||||
at development time instead of on the next server upgrade.
|
||||
3. **D3 is not a raw-client escape.** Every call passes a fixed admission order: capability registered
|
||||
→ database profile → collection allowlist → operation name → timeout → consistency profile →
|
||||
result limit → trace → redaction → command category → admin-command refusal → execute.
|
||||
4. **D4 is a separate client with a separate credential.** No application-plane path reaches it;
|
||||
`PolicyAwareMongoNativeGateway` refuses admin-category commands regardless of capability.
|
||||
5. **Advanced and Experimental capabilities are opt-in modules**, never transitive dependencies of the
|
||||
Stable surface.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** MongoDB's semantics stay reachable. Misuse is refused structurally rather than reviewed
|
||||
for. A server upgrade cannot silently change D1/D2 behaviour. Admin operations have their own audit
|
||||
trail and credential.
|
||||
|
||||
**Negative.** Every operation needs a registered name and profile, so a new query is a small amount of
|
||||
configuration rather than zero. A genuinely new capability requires a registration before it can be
|
||||
used. Both are deliberate: the cost is paid once per operation, at review time.
|
||||
|
||||
**Rejected alternative — "expose the driver, rely on code review."** Review does not scale to every
|
||||
query in every service, and the operations that matter (unbounded pipeline, `dropCollection`,
|
||||
unanchored regex on user input) look unremarkable in a diff.
|
||||
|
||||
## Repository adaptation
|
||||
|
||||
The design assumes 19 Gradle modules under `modules/mongodb/`. This repository's fail-closed registry
|
||||
declares exactly 19 leaf identities, so the modules became package boundaries inside
|
||||
`:adapter:outbound:persistence-mongo`, enforced by ArchUnit. See
|
||||
[docs/mongodb/repository-adaptation.md](../mongodb/repository-adaptation.md).
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR-MONGO-002 — BSON representation is a pinned manifest
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §10, decision D-06
|
||||
|
||||
## Context
|
||||
|
||||
How a Java value is represented in BSON is a data contract, but nothing in the default toolchain
|
||||
treats it as one. Spring Data and the MongoDB driver both have defaults, and those defaults have
|
||||
changed across versions. A `BigDecimal` can land as a `Double`, a `String` or a `Decimal128`; a `UUID`
|
||||
can land as `Binary` subtype 3 or subtype 4; an `Instant` can land as a `Date` or a `String`.
|
||||
|
||||
The consequences are asymmetric. A representation change is invisible in a value-equality test —
|
||||
`12.30` looks like `12.30` whether it is a double or a `Decimal128` — but once a collection holds
|
||||
production data, changing it is a full migration. And the UUID case is worse than a migration: legacy
|
||||
Java representation byte-swaps two halves of the UUID, so a document written under one representation
|
||||
and read under the other yields a *different, valid-looking* UUID. Nothing errors. You get the wrong
|
||||
record.
|
||||
|
||||
## Decision
|
||||
|
||||
`MongoTypeRepresentationManifest` pins the representation for every type the platform maps, and
|
||||
`MongoMappingConfiguration` builds the Spring Data converters from it. Nothing relies on a library
|
||||
default.
|
||||
|
||||
| Java | BSON | Rationale |
|
||||
|---|---|---|
|
||||
| `UUID` | `Binary` subtype 4 (`STANDARD`) | Subtype 3 byte-swaps; cross-representation reads are silently wrong. |
|
||||
| `BigDecimal` | `Decimal128` | A double cannot represent `12.30`; money compared as a double is eventually wrong by a cent. |
|
||||
| `BigInteger` | `Decimal128`, or declared `String` when out of range | 34 significant digits; out of range fails on write instead of rounding. |
|
||||
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | One instant, one representation. |
|
||||
| `LocalDate` | declared per field | A calendar day is not an instant. |
|
||||
| `LocalDateTime` | **refused** | No offset: the stored value depends on the writing JVM's default zone. |
|
||||
| `enum` | `String` name | Ordinals renumber when someone inserts a constant. |
|
||||
|
||||
Type metadata follows `MongoTypeMetadataPolicy` — `NONE`, `ALIAS` or `CLASS_NAME`. A
|
||||
`@LongLivedMongoDocument` type may not use `CLASS_NAME`: writing a FQCN into a million documents makes
|
||||
a package rename a data migration.
|
||||
|
||||
The manifest is enforced by a golden gate. `MongoBsonSnapshot` canonicalises a stored document,
|
||||
preserving BSON types and keeping missing distinct from null, and
|
||||
`MongoBsonSnapshotAssert.hasTypeSignature(...)` fails on any representation change. The registry
|
||||
pins `UuidCodec(STANDARD)` explicitly rather than inheriting a default, since inheriting the default
|
||||
is the exact drift the gate exists to catch.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** A library upgrade cannot move a representation without failing a test. Money is exact.
|
||||
UUIDs read back as themselves. Class moves stay refactors.
|
||||
|
||||
**Negative.** Every representation-affecting change requires updating a snapshot *and* writing a
|
||||
migration. A new mapped type needs a manifest entry before it can be used. This is the intended
|
||||
friction: the alternative is discovering the change in production.
|
||||
|
||||
**Rejected alternative — "snapshot the JSON."** JSON destroys exactly the distinctions the gate
|
||||
protects: `Decimal128` and `String` both render as text, `Binary` UUID and `ObjectId` both render as
|
||||
hex, and missing and null both disappear.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-MONGO-003 — Transaction body retry and commit retry are separate loops
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §14–§16, decisions D-07 through D-10
|
||||
|
||||
## Context
|
||||
|
||||
MongoDB reports two transaction failures that look similar and must be handled in opposite ways.
|
||||
|
||||
`TransientTransactionError` means the transaction definitively did not commit. The correct response is
|
||||
to run the whole thing again.
|
||||
|
||||
`UnknownTransactionCommitResult` means the commit **may already have applied** — typically because the
|
||||
primary changed while the commit was in flight. The correct response is to retry *the commit*, which
|
||||
is a no-op if it already succeeded.
|
||||
|
||||
The common implementation wraps everything in one retry loop. That loop replays the body after an
|
||||
unknown commit, and if the commit did apply, the body applies twice. In a payment or notification path
|
||||
that is a duplicate charge or a duplicate message, produced by the error handler.
|
||||
|
||||
The related trap is session reuse: retrying on the same session after an abort carries the aborted
|
||||
transaction's state into the retry.
|
||||
|
||||
## Decision
|
||||
|
||||
`MongoTransactionRetryCoordinator` implements two loops with different scopes.
|
||||
|
||||
```
|
||||
for each body attempt within the budget:
|
||||
open a NEW session
|
||||
run the body
|
||||
TransientTransactionError -> abort, continue to next body attempt
|
||||
commitWithRetry(session):
|
||||
UnknownTransactionCommitResult -> retry the COMMIT ONLY, same session
|
||||
```
|
||||
|
||||
Rules that follow, all of them load-bearing:
|
||||
|
||||
1. **A new session per body attempt.** No aborted state leaks into a retry.
|
||||
2. **The body is never replayed after a commit ambiguity.** `MongoRetryScope.COMMIT_ONLY` is a
|
||||
distinct value from `BODY` precisely so this cannot be collapsed by accident.
|
||||
3. **One budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with
|
||||
jittered backoff, so a struggling primary is not retried into the ground by every instance at once.
|
||||
4. **An exhausted commit retry surfaces `TRANSACTION_COMMIT_UNKNOWN`,** never a generic failure. An
|
||||
ambiguous outcome reported as a failure invites the caller to retry — the one thing that must not
|
||||
happen. See [docs/mongodb/runbooks/unknown-commit.md](../mongodb/runbooks/unknown-commit.md).
|
||||
5. **Transaction bodies write a deterministic marker** so `MongoCommitReconciler` can establish what
|
||||
actually happened. A transaction that cannot be reconciled has no recovery path.
|
||||
6. **Classification reads labels before codes.** Server error labels are the authoritative statement
|
||||
about retryability; error codes vary by version.
|
||||
|
||||
Surrounding decisions that reduce how often this path is reached at all: single-document atomic
|
||||
operations are preferred over transactions (D-09), partial changes use update operators rather than
|
||||
`save()` (D-07), and whole-document replacement requires an optimistic revision (D-08).
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** A commit ambiguity cannot become a duplicate effect. The ambiguity reaches the caller as
|
||||
an ambiguity. The retry budget is bounded in both attempts and time.
|
||||
|
||||
**Negative.** Callers must handle a third outcome beyond success and failure. Transaction bodies must
|
||||
write a marker they would not otherwise need. Both costs are small compared with reconciling
|
||||
duplicated financial effects after the fact.
|
||||
|
||||
**Rejected alternative — "one retry loop, at-least-once everywhere."** It requires every transaction
|
||||
body to be fully idempotent, which is a much stronger and much less checkable property than writing
|
||||
one marker, and it is silently violated the first time someone adds a non-idempotent step.
|
||||
@@ -0,0 +1,62 @@
|
||||
# ADR-MONGO-004 — Index and schema changes belong to the admin plane
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §21–§25, decisions D-11, D-13
|
||||
|
||||
## Context
|
||||
|
||||
Spring Data can create indexes automatically from annotations. On a laptop this is convenient. On a
|
||||
collection with a hundred million documents, an index build is a capacity event: it consumes CPU, IO
|
||||
and memory on the primary for minutes to hours, and it starts because a pod restarted.
|
||||
|
||||
Worse, it starts N times when N pods restart, and there is no approval step, no ordering relative to
|
||||
the code that needs the index, and no record afterwards of what was created.
|
||||
|
||||
Schema validators have the same shape with a sharper edge: tightening a validator on a collection with
|
||||
existing data rejects writes to documents that were legal when they were written.
|
||||
|
||||
TTL has a third shape. It looks like a scheduler and is not one: the TTL monitor runs about once a
|
||||
minute and deletes in batches, so an expired document routinely remains readable for minutes or hours.
|
||||
|
||||
## Decision
|
||||
|
||||
**Indexes and validators are declared in a manifest and applied by the admin plane (D4).** Automatic
|
||||
index creation in production is disabled.
|
||||
|
||||
1. `MongoManifestRegistry` holds the declared indexes (`MongoIndexManifest`) and validator
|
||||
(`MongoSchemaManifest`) per collection. The manifest is the source of truth, reviewed in a pull
|
||||
request.
|
||||
2. `MongoIndexDiffEngine` compares manifest against observed state and reports missing, extra and
|
||||
*changed* indexes. Changed ones are reported rather than re-issued: MongoDB will not silently
|
||||
rebuild an index whose definition moved.
|
||||
3. `MongoIndexApplyPolicy` sets what an environment may do — `APPLY` (local), `APPLY_WITH_DIFF`
|
||||
(staging), `DIFF_WITH_APPROVED_APPLY` (production), `REPORT_ONLY` (audit).
|
||||
4. **Ownership gates every drop.** `MongoMetadataOwnership` distinguishes `APPLICATION_MANAGED` from
|
||||
`SEARCH_MANAGED`, `ENCRYPTION_MANAGED` and `EXTERNAL`. Only application-managed objects are
|
||||
droppable on drift. A diff engine without ownership eventually proposes dropping
|
||||
`enxcol_.customers.esc`, and "the drift tool cleaned it up" is a very bad incident summary.
|
||||
5. **Retirement is staged.** `MongoIndexRetirementState` moves an index declared → hidden →
|
||||
observed-unused → droppable, one deployment per transition. Hiding is instantly reversible;
|
||||
dropping is a rebuild.
|
||||
6. **Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
|
||||
contract on 7.0 or 8.0 and is refused. Tightening goes `warn`+`MODERATE` → confirm zero warnings →
|
||||
`error`+`STRICT`, in two deployments.
|
||||
7. **TTL is physical cleanup only** (D-13). `MongoExpirationAccessPolicy` states the rule: a
|
||||
document's presence is not authorization and its absence is not a deadline. Access control checks
|
||||
the expiry field; scheduling uses a scheduler.
|
||||
8. **Migrations are checksummed, locked, precondition-checked and resumable.**
|
||||
`MongoMigrationRunner` fails hard when an applied id's checksum changed — two environments running
|
||||
different code under one id is worse than a failed deploy.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** Index builds are scheduled by people who know the capacity. Rollback is possible at
|
||||
every step. Drift is visible without being dangerous. Nothing drops what it does not own.
|
||||
|
||||
**Negative.** Adding an index is a manifest change plus an apply, not an annotation. Local development
|
||||
uses `APPLY` so the friction is confined to environments where it is warranted.
|
||||
|
||||
**Rejected alternative — "auto-create with a feature flag."** The flag is either on in production,
|
||||
which is the problem, or off, in which case the manifest is the real mechanism and the annotation is a
|
||||
second, divergent source of truth.
|
||||
@@ -0,0 +1,79 @@
|
||||
# ADR-MONGO-ADV-001 — Advanced capability promotion
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-13
|
||||
- **Design source:** design §2 (D-15), §3.2–§3.3; Advanced expansion plan Task 15
|
||||
|
||||
## Context
|
||||
|
||||
Sharding, time series, CSFLE, Queryable Encryption, search, vector search and multi-tenancy each work
|
||||
in a demo within an afternoon. What they do not do is behave the same way in production, and the
|
||||
differences are not discovered by functional tests:
|
||||
|
||||
- Sharding changes which queries are efficient. A query that misses the shard key becomes
|
||||
scatter-gather, which passes every test on a one-shard cluster.
|
||||
- Encryption's failure modes are KMS failure modes — wrong key, revoked permission, mid-rotation —
|
||||
none of which occur against a local key provider.
|
||||
- Search and vector search can be functionally correct and useless: the index returns results, and
|
||||
the results are not relevant. Recall is not visible in a pass/fail assertion.
|
||||
- Database-per-tenant works until the tenant count crosses what the connection and file-handle
|
||||
budget supports, which is an operational property, not a code property.
|
||||
|
||||
The failure mode this ADR prevents is a capability marked "done" on the strength of a green test that
|
||||
never touched the environment where it will run.
|
||||
|
||||
## Decision
|
||||
|
||||
Every Advanced and Experimental capability is an **opt-in module behind its own flag**, and promotion
|
||||
requires evidence, not confidence.
|
||||
|
||||
### Enablement
|
||||
|
||||
`MongoAdvancedCapabilityFlags` gates construction of every Advanced entry point. A disabled capability
|
||||
does not produce a runtime warning — the type refuses to be constructed, naming the property that
|
||||
enables it (`MongoAdvancedCapabilityFlags.propertyFor(capability)`). Being on the classpath is not
|
||||
being enabled, and `stableNeverDependsOnAdvanced` (ArchUnit) keeps the Stable surface free of them.
|
||||
|
||||
### Promotion evidence
|
||||
|
||||
`MongoAdvancedPromotionGate.verify(evidence)` requires every category:
|
||||
|
||||
| Category | Means |
|
||||
|---|---|
|
||||
| `stable-platform` | The Stable release gate passed on the same revision. |
|
||||
| `actual-topology` | The capability ran on the real topology — a real sharded cluster, the real KMS, the actual target deployment. Atlas Local is a pull-request convenience and explicitly not release evidence (`MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL`). |
|
||||
| `security` | Privileges reviewed; the capability's admin role is separate from the application role. |
|
||||
| `migration` | A documented path in and, where the capability is irreversible, an explicit statement that there is no path back. |
|
||||
| `failure` | Negative cases fail closed: wrong key, missing permission, rotation, non-ready index, unrouted query. |
|
||||
| `runbook` | A runbook exists for the capability's characteristic incident. |
|
||||
|
||||
### Additional per-capability requirements
|
||||
|
||||
- **Search / vector search:** relevance and performance evidence, not functional success alone.
|
||||
`MongoVectorSearchBenchmarkGate` requires recall alongside latency and index size; a gate that
|
||||
measures only latency certifies a fast wrong answer.
|
||||
- **Database-per-tenant and reshard orchestration remain Experimental** until operational scale
|
||||
evidence exists. Both are correct in the small and unbounded in the large.
|
||||
- **Reshard requires an explicit `ReshardApproval`** — a named approver and a stated window. It
|
||||
rewrites the collection.
|
||||
|
||||
### Promotion does not change the dependency boundary
|
||||
|
||||
A capability promoted to Stable **remains an opt-in module** unless a later starter ADR changes the
|
||||
dependency boundary. Promotion is a statement about evidence, not an invitation to add a transitive
|
||||
dependency to every service.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive.** No capability reaches production on the strength of a container-only test. The evidence
|
||||
list is the same for every capability, so promotion is reviewable rather than negotiated.
|
||||
|
||||
**Negative.** Promotion requires access to real infrastructure — a sharded cluster, a real KMS, the
|
||||
target deployment. That is the cost of the guarantee: the alternative is finding out in production,
|
||||
where encryption and sharding are both expensive to reverse.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-advanced.sh
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# Advanced — CSFLE and Queryable Encryption
|
||||
|
||||
**Capabilities:** `MongoCapability.CSFLE`, `MongoCapability.QUERYABLE_ENCRYPTION`
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.csfle.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.queryable-encryption.enabled`
|
||||
**Status:** Advanced.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Replica set or sharded cluster. |
|
||||
| Server | MongoDB 7.0 or 8.0 (see §4 for the 8.0 query-type limits). |
|
||||
| Privilege | `MongoPrincipalRole.ENCRYPTION_ADMIN` for the key vault; the application role never holds it. |
|
||||
| Environment | A real KMS and key vault. A local key provider does not exercise any of the failure modes that matter. |
|
||||
|
||||
## 1. CSFLE
|
||||
|
||||
`MongoCsfleProfile` binds a collection to its `MongoCsfleFieldPolicy` list, a key vault
|
||||
`MongoCredentialReference` and the key vault namespace. `MongoCsfleClientFactory` builds the encrypted
|
||||
client; `MongoDataKeyResolver` resolves data keys.
|
||||
|
||||
`MongoCsfleMode`:
|
||||
|
||||
| Mode | Queryable | Trade-off |
|
||||
|---|---|---|
|
||||
| `RANDOMIZED` | no | Same plaintext encrypts differently each time. The safe default. |
|
||||
| `DETERMINISTIC` | equality only | Same plaintext always yields the same ciphertext, so equality works — and so does frequency analysis. |
|
||||
| `UNINDEXED` | no | Stored encrypted, excluded from any index. |
|
||||
|
||||
`MongoCsfleFieldPolicy.forPii(field, queryable)` defaults to `RANDOMIZED` when the field is not
|
||||
queried. Deterministic encryption requires a written `equalityQueryJustification`; the constructor
|
||||
refuses a blank one, naming frequency analysis. A low-cardinality deterministic field (a status, a
|
||||
country, a boolean) leaks its distribution to anyone who can read the collection, which is the party
|
||||
encryption was protecting against.
|
||||
|
||||
## 2. Queryable Encryption
|
||||
|
||||
`MongoQueryableEncryptionProfile` binds a collection to `MongoEncryptedFieldDescriptor` entries.
|
||||
`MongoQueryableEncryptionQueryType` has exactly two values:
|
||||
|
||||
- `EQUALITY`
|
||||
- `RANGE` — must declare its domain (`min`, `max`). The constructor refuses a range field without one,
|
||||
because changing the domain later means re-encrypting the field.
|
||||
|
||||
`MongoQueryableEncryptionCollectionManager` owns the collection's lifecycle, because a QE collection
|
||||
is not just a collection: it carries metadata collections.
|
||||
|
||||
## 3. Metadata ownership
|
||||
|
||||
`MongoEncryptionMetadataOwnership` maps `customers` to `enxcol_.customers.esc` and
|
||||
`enxcol_.customers.ecoc`, and reports `__safeContent__`-prefixed indexes as
|
||||
`MongoMetadataOwnership.ENCRYPTION_MANAGED`.
|
||||
|
||||
These are never application-owned and never droppable by drift reconciliation. A drift tool that
|
||||
drops `enxcol_.customers.ecoc` corrupts the collection's queryability. This is the single most
|
||||
important integration point between encryption and
|
||||
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md).
|
||||
|
||||
## 4. Unsupported combinations
|
||||
|
||||
Refused at declaration, not discovered at runtime:
|
||||
|
||||
| Combination | Why |
|
||||
|---|---|
|
||||
| CSFLE **and** QE on the same collection | Two incompatible encryption schemes over one namespace. Both profile constructors refuse it. |
|
||||
| CSFLE on a time series collection | `requireNotTimeSeries(true)` raises `MongoOperationRejectedException`. |
|
||||
| QE `prefix` / `suffix` / `substring` | Not available on the platform's 8.0 baseline. The factory methods throw `UnsupportedOperationException` rather than returning a profile that fails later. |
|
||||
| Deterministic CSFLE without a justification | `IllegalArgumentException` naming frequency analysis. |
|
||||
| Range QE without a declared domain | `IllegalArgumentException` naming re-encryption. |
|
||||
|
||||
## 5. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `MongoEncryptionException` on read | Wrong data key, or the key vault is unreachable | Check KMS reachability and the key vault credential. Data is intact; the client cannot decrypt it. |
|
||||
| `MongoEncryptionException` on write | KMS permission revoked mid-operation | Restore the grant. Writes fail closed — nothing was written in plaintext. |
|
||||
| Queries return nothing on a deterministic field | The field was re-keyed | Equality matching is over ciphertext; a new key produces different ciphertext. Re-encrypt the field. |
|
||||
| QE queries fail after a drift reconciliation | A metadata collection was dropped | Restore from backup. This is why ownership gates drops. |
|
||||
|
||||
**Key rotation.** Rotating the customer master key re-wraps the data keys and does not require
|
||||
re-encrypting documents. Rotating a *data* key does require re-encrypting every document that used
|
||||
it. These are different operations with different costs, and confusing them is how a rotation becomes
|
||||
an outage.
|
||||
|
||||
## 6. Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md), promotion requires the
|
||||
real KMS and key vault, plus negative cases that fail closed: wrong key, missing permission, rotation
|
||||
mid-operation (`MongoAtlasCapabilityContractSuite.kmsFailureModes`). A local key provider certifies
|
||||
none of these — it never rejects anything.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Advanced — GridFS compatibility and migration
|
||||
|
||||
**Capability:** `MongoCapability.GRIDFS_COMPATIBILITY`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.gridfs-compatibility.enabled`
|
||||
**Status:** Advanced, compatibility only. Decision D-14.
|
||||
|
||||
## Position
|
||||
|
||||
GridFS is a **compatibility adapter for files that already exist there**. New files use the existing
|
||||
Fileserver / Object Storage adapter, which is the source of truth for binary content.
|
||||
|
||||
The reason is not preference. GridFS stores file chunks in the same collections, on the same replica
|
||||
set, competing for the same working set as your documents. A large file read evicts document pages
|
||||
from cache, and file storage growth becomes replica-set growth — which means it becomes oplog
|
||||
pressure, backup duration and failover time. Object storage was built for this and MongoDB was not.
|
||||
|
||||
## Reading legacy files
|
||||
|
||||
`MongoGridFsCompatibilityReader` reads existing GridFS content as
|
||||
`GridFsLegacyContent(legacyId, filename, sizeBytes, checksum, stream)`. It reads; it does not write.
|
||||
|
||||
## Migration
|
||||
|
||||
`MongoGridFsMigrationJob` moves a file to object storage in a fixed order:
|
||||
|
||||
```
|
||||
read legacy content
|
||||
→ write to object storage
|
||||
→ verify the target checksum matches the source
|
||||
→ switch the reference
|
||||
→ (later, separately) delete the source
|
||||
```
|
||||
|
||||
Three properties, each of which exists because of a specific way this goes wrong:
|
||||
|
||||
1. **Verify before switching.** `MongoGridFsObjectReference` requires a non-blank checksum, and the
|
||||
job returns empty and writes no reference when the target checksum does not match the source. A
|
||||
migration that switches the reference on a successful *write* rather than a verified *copy*
|
||||
silently points at a truncated object.
|
||||
2. **The source is never deleted here.** Deletion is a separate, later decision after the new
|
||||
location has been serving reads long enough to be trusted. A migration that deletes as it goes has
|
||||
no rollback.
|
||||
3. **The checkpoint separates migrated from failed.** `MongoGridFsMigrationCheckpoint` tracks
|
||||
`migratedCount()`, `failedCount()`, `clean()` and `lastMigratedLegacyId()`, so a restart continues
|
||||
from the last completed file rather than starting over, and a partially failed run is visible as
|
||||
partial rather than as "done".
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `migrate` returns empty | Checksum mismatch | The copy is bad. Investigate before retrying; do not force the reference. |
|
||||
| `IllegalArgumentException` on the reference | Missing checksum | A reference without a checksum cannot be verified and is refused. |
|
||||
| Checkpoint not `clean()` | Some files failed | Re-run for the failed ids only; the checkpoint names the last successful one. |
|
||||
| Reference switched but content missing | Source deleted too early | Restore from backup. This is what rule 2 prevents. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Actual-topology evidence against the real object storage backend, a security review of the storage
|
||||
credential, the migration path above, failure cases (checksum mismatch refused, missing checksum
|
||||
refused, restart resumes), and this document as the runbook.
|
||||
|
||||
New file storage does not go through here at all — see the fileserver adapter.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Advanced — Multi-tenancy
|
||||
|
||||
**Capabilities:** `MongoCapability.SHARED_COLLECTION_TENANCY` (Advanced),
|
||||
`MongoCapability.DATABASE_PER_TENANT` (Experimental)
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.shared-collection-tenancy.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.database-per-tenant.enabled`
|
||||
|
||||
## 1. Shared collection
|
||||
|
||||
Every tenant's documents live in one collection, discriminated by a tenant field.
|
||||
|
||||
`MongoTenantContext` carries the tenant. `MongoTenantPredicateInjector` adds the tenant predicate to
|
||||
every query, every atomic filter and the **first** aggregation stage. `TenantScopedMongoOperations`
|
||||
is the entry point, so a caller cannot construct an unscoped operation by forgetting.
|
||||
|
||||
Three details are load-bearing:
|
||||
|
||||
- **Injection, not convention.** A tenant predicate that each query is expected to add itself is a
|
||||
cross-tenant leak waiting for one missed `where(...)`. The injector adds it structurally.
|
||||
- **First aggregation stage.** `firstStageMatch(...)` places the tenant `$match` before anything else.
|
||||
A `$lookup` or `$group` that runs before the tenant filter has already crossed the boundary, even if
|
||||
a later stage filters the output.
|
||||
- **An absent tenant is not "all tenants".** The injector takes an `Optional<MongoTenantContext>` so
|
||||
the missing case is a decision the policy makes explicitly, not a predicate that quietly disappears.
|
||||
|
||||
`MongoTenantManifestValidator.validate(manifest, tenantScopedUniqueIndexes)` checks that every unique
|
||||
index that should be per-tenant actually includes the tenant field. A unique index on `email` alone in
|
||||
a shared collection makes an email globally unique across tenants — tenant B cannot register an
|
||||
address tenant A already used, which is both a bug and an information leak.
|
||||
|
||||
`requireShardKeyAnalysed(...)` requires a shard-key readiness report before a shared-collection tenant
|
||||
model is sharded: tenant id as a shard key prefix concentrates the largest tenant on one shard.
|
||||
|
||||
### Observability
|
||||
|
||||
`tenantId` and `rawTenantId` are on `MongoObservationConvention`'s forbidden tag list. Cardinality
|
||||
grows with the customer list, and the tag ships tenant identity into the metrics backend.
|
||||
|
||||
## 2. Database per tenant (Experimental)
|
||||
|
||||
`MongoTenantDatabaseResolver` maps a tenant to its database; `MongoTenantClientRegistry` holds the
|
||||
clients.
|
||||
|
||||
Experimental for a specific reason: it is correct in the small and unbounded in the large. Each tenant
|
||||
database costs connections, file handles and monitoring cardinality. It works beautifully at 20
|
||||
tenants and falls over at 2,000, and nothing in a functional test distinguishes the two. Promotion
|
||||
requires operational scale evidence.
|
||||
|
||||
`MongoTenantLifecyclePolicy`:
|
||||
|
||||
- `requireActivationReady(tenantKey, schemaAndIndexesValidated)` — a tenant is not activated until its
|
||||
schema and indexes are validated. Activating first means the first customer request is the migration
|
||||
test.
|
||||
- `requireDeleteAllowed(...)` — deletion requires an explicit retention decision. Dropping a tenant
|
||||
database is irreversible and takes the backup surface with it.
|
||||
|
||||
`MongoTenantMigrationCoordinator` runs a migration across tenant databases with per-tenant results.
|
||||
Partial failure is normal and must be reported per tenant: "migration failed" across 500 databases is
|
||||
not a report anyone can act on.
|
||||
|
||||
## 3. Choosing
|
||||
|
||||
| | Shared collection | Database per tenant |
|
||||
|---|---|---|
|
||||
| Isolation | Logical, enforced by injection | Physical |
|
||||
| Tenant count | Unbounded | Bounded by connections and file handles |
|
||||
| Per-tenant restore | Hard | Natural |
|
||||
| Noisy neighbour | Shared resources | Isolated |
|
||||
| Migration | One collection | N databases, partial failures |
|
||||
| Cross-tenant query | Possible (and must be forbidden) | Structurally impossible |
|
||||
|
||||
Shared collection is the default. Database-per-tenant is for a small number of tenants with a
|
||||
contractual isolation or per-tenant-restore requirement.
|
||||
|
||||
## 4. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Cross-tenant data visible | An operation bypassed `TenantScopedMongoOperations` | Treat as a security incident. Find the path, close it, audit access. |
|
||||
| Unique constraint fires across tenants | Unique index missing the tenant field | Rebuild the index with the tenant field as prefix; the validator catches this before it ships. |
|
||||
| One shard holds most data | Tenant id as shard-key prefix with a dominant tenant | Refine the shard key with a high-cardinality suffix. |
|
||||
| Connection exhaustion | Database-per-tenant beyond the connection budget | The scale limit. Consolidate or move to shared collections. |
|
||||
| Migration partially applied across tenants | Normal | `MongoTenantMigrationCoordinator` reports per tenant; re-run for the failures only. |
|
||||
|
||||
## 5. Promotion evidence
|
||||
|
||||
Shared-collection tenancy: actual-topology evidence, a security review covering cross-tenant access,
|
||||
a migration path, failure cases (injection proven on query, atomic filter and first aggregation
|
||||
stage), this runbook. Database-per-tenant additionally requires **operational scale evidence** and
|
||||
stays Experimental until it exists.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Advanced — Search and Vector Search
|
||||
|
||||
**Capabilities:** `MongoCapability.SEARCH`, `MongoCapability.VECTOR_SEARCH`
|
||||
**Properties:** `ca-skeleton.persistence-mongo.advanced.search.enabled`,
|
||||
`ca-skeleton.persistence-mongo.advanced.vector-search.enabled`
|
||||
**Status:** Experimental (design §3.3). Hybrid search likewise.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | A deployment with the search service. Atlas Local in a container is a pull-request convenience and is **not** release evidence. |
|
||||
| Privilege | `MongoPrincipalRole.SEARCH_ADMIN` for index management; the application role queries only. |
|
||||
| Gate | `MongoAtlasCapabilityContractSuite` on the actual target deployment. |
|
||||
|
||||
## 1. Created is not ready
|
||||
|
||||
`MongoSearchIndexState`: `CREATED` → `BUILDING` → `READY`, plus `FAILED` and `DELETING`.
|
||||
|
||||
`MongoSearchReadinessGate.requireReady(state)` refuses anything but `READY`. A search index is built
|
||||
asynchronously: the create call returns immediately and the index answers queries with *partial*
|
||||
results while building. Not an error, not empty — partial. A deployment that creates an index and
|
||||
starts querying serves incomplete results for as long as the build takes, and nothing reports it.
|
||||
|
||||
## 2. Search indexes are not application-owned
|
||||
|
||||
`MongoSearchIndexDescriptor.metadataOwnership()` is `MongoMetadataOwnership.SEARCH_MANAGED`, and
|
||||
`droppableByApplicationDrift()` is false. The index reconciliation described in
|
||||
[ADR-MONGO-004](../../adr/ADR-MONGO-004-index-schema-admin-plane.md) must not drop it.
|
||||
|
||||
## 3. Query guardrails
|
||||
|
||||
`MongoSearchQuery` binds an index, an allowlist of paths, the search text and a result limit.
|
||||
|
||||
- `requireAllowedPaths(allowed)` raises `MongoOperationRejectedException` on a path outside the
|
||||
allowlist. Without it, a caller can search any indexed field, including ones indexed for a
|
||||
different purpose.
|
||||
- Search text length and result count are bounded at construction. An unbounded search text is a
|
||||
cost multiplier on someone else's service.
|
||||
|
||||
## 4. Vector search
|
||||
|
||||
`MongoVectorIndexDescriptor.cosine(path, dimensions)` declares the index.
|
||||
`MongoEmbedding.forIndex(index, values)` binds an embedding to it and **rejects a dimension
|
||||
mismatch** — a 1536-dimension embedding against a 768-dimension index is not a runtime degradation,
|
||||
it is a category error, and catching it at construction beats catching it as a confusing server
|
||||
message.
|
||||
|
||||
`MongoEmbedding` copies its backing array in and out. A vector that shares an array with its caller
|
||||
can be mutated after the query is built, which produces a query nobody wrote.
|
||||
|
||||
`MongoVectorQuery` requires `numCandidates > limit` — searching 10 candidates to return 10 results is
|
||||
an exhaustive scan wearing an ANN index's name. `MongoVectorQuery.nearest(embedding, 10)` uses the
|
||||
standard 20× ratio (200 candidates for 10 results).
|
||||
|
||||
## 5. Relevance is the gate, not functionality
|
||||
|
||||
`MongoVectorSearchBenchmarkGate.standard()` requires **recall** alongside latency and index size.
|
||||
`requiredEvidence()` names recall explicitly.
|
||||
|
||||
This is the difference between search and everything else in the platform. A vector index can be
|
||||
functionally perfect — it accepts the index, accepts the query, returns k results, within the latency
|
||||
budget — and return the wrong k. A gate that measures only latency certifies a fast wrong answer.
|
||||
`failures(recall, latencyMs, indexMb)` reports which dimension failed so the finding is actionable.
|
||||
|
||||
## 6. Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Incomplete results after a deploy | Queried a `BUILDING` index | Wait for `READY`. The gate prevents this; if it fired, something bypassed it. |
|
||||
| `MongoOperationRejectedException` on a path | Path not in the allowlist | Add it deliberately, or fix the caller. |
|
||||
| Dimension mismatch | Model changed | A new model means a new index. Build alongside, cut over, then retire. |
|
||||
| Recall dropped without a code change | The index was rebuilt with different parameters, or the data distribution shifted | Re-run the benchmark gate; treat a recall regression like a failing test. |
|
||||
| Index `FAILED` | Build error on the search service | Search-side diagnosis; the application must not fall back to a scan silently. |
|
||||
|
||||
## 7. Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): the actual target
|
||||
deployment (not Atlas Local), security review of `SEARCH_ADMIN`, a rebuild path, failure cases
|
||||
(non-ready index refused, disallowed path refused, dimension mismatch refused), this document as the
|
||||
runbook, **and** relevance evidence. Search and vector search do not promote on functional success.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Advanced — Sharding
|
||||
|
||||
**Capability:** `MongoCapability.SHARDING`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.sharding.enabled`
|
||||
**Status:** Advanced. Reshard orchestration remains Experimental.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | A real sharded cluster. A replica set cannot exercise routing. |
|
||||
| Server | MongoDB 7.0 or 8.0. |
|
||||
| Privilege | `MongoPrincipalRole.SHARD_ADMIN` for the admin plane; the application role is unchanged. |
|
||||
| Gate | `mongoShardedTest` lane with `MongoShardingContractSuite`. |
|
||||
|
||||
## Shard key
|
||||
|
||||
`ShardKeyDescriptor` declares the key as an ordered list of `ShardKeyPart` plus a `ShardStrategy`:
|
||||
|
||||
| Strategy | Distributes | Cost |
|
||||
|---|---|---|
|
||||
| `RANGE` | by value ranges | Range queries stay targeted; a monotonic key (a timestamp, an `ObjectId`) sends every insert to one shard. |
|
||||
| `HASHED` | by hash of the key | Inserts spread evenly; every range query becomes scatter-gather. |
|
||||
|
||||
There is no strategy that is good at both, which is why the choice is a declaration rather than a
|
||||
default.
|
||||
|
||||
## Routing classification
|
||||
|
||||
`ShardAwareQueryValidator` classifies each query before execution:
|
||||
|
||||
| `MongoRoutingClassification` | Meaning |
|
||||
|---|---|
|
||||
| `TARGETED` | The full shard key is present. One shard answers. |
|
||||
| `PREFIX_TARGETED` | A prefix of a compound key is present. A subset of shards answers. |
|
||||
| `SCATTER_GATHER` | No shard-key predicate. Every shard answers. |
|
||||
| `REJECTED` | Scatter-gather where the profile forbids it. |
|
||||
|
||||
A scatter-gather query is not an error — some queries legitimately need every shard — but it must be
|
||||
declared. Undeclared scatter-gather raises `MongoShardRoutingException`. The reason is that
|
||||
scatter-gather passes every test on a single-shard development cluster and only degrades once the
|
||||
cluster grows, at which point the query is already in production and the fix is a schema change.
|
||||
|
||||
## Unsupported combinations
|
||||
|
||||
- Unique index on a field that is not a prefix of the shard key. MongoDB cannot enforce it across
|
||||
shards, and it fails at index creation, not at query time.
|
||||
- Transactions that touch documents on multiple shards remain supported but cost a cross-shard
|
||||
two-phase commit. Prefer a shard key that keeps a transaction's documents co-located.
|
||||
- CSFLE on a sharded collection: see [encryption.md](encryption.md) for the combinations that are
|
||||
refused.
|
||||
|
||||
## Admin plane
|
||||
|
||||
`MongoShardingAdminGateway` (D4, `SHARD_ADMIN` credential) covers shard-collection, refine-shard-key
|
||||
and reshard.
|
||||
|
||||
`ShardKeyAnalyzer` produces a `ShardKeyReadinessReport` before sharding a collection: cardinality,
|
||||
frequency skew and monotonicity. A key with low cardinality creates jumbo chunks that cannot be split;
|
||||
a monotonic key creates a hot shard. Both are visible in the report and invisible in a functional
|
||||
test.
|
||||
|
||||
`ReshardApproval` is required for a reshard — a named approver and a stated window. Resharding
|
||||
rewrites the collection: it duplicates the data during the operation and saturates IO. It is not a
|
||||
runtime operation and the type refuses to pretend otherwise.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| `MongoShardRoutingException` | Undeclared scatter-gather | Add the shard key to the predicate, or declare the query as scatter-gather in its profile after review. |
|
||||
| Jumbo chunks | Low-cardinality shard key | Refine the shard key (adds a suffix, non-destructive) before considering a reshard. |
|
||||
| One hot shard | Monotonic range key | Refine with a high-cardinality prefix, or reshard to hashed if range queries are not needed. |
|
||||
| Balancer never converges | Chunk migration blocked by long-running operations | Check for long transactions and cursors; the balancer waits on them. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Per [ADR-MONGO-ADV-001](../../adr/ADR-MONGO-ADV-001-capability-promotion.md): actual sharded-cluster
|
||||
evidence, security review of the `SHARD_ADMIN` role, a migration path for an existing unsharded
|
||||
collection, failure cases (undeclared scatter-gather refused, jumbo chunk detected), and this
|
||||
document as the runbook. Reshard orchestration stays Experimental until operational scale evidence
|
||||
exists.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Advanced — Time Series
|
||||
|
||||
**Capability:** `MongoCapability.TIME_SERIES`
|
||||
**Property:** `ca-skeleton.persistence-mongo.advanced.time-series.enabled`
|
||||
**Status:** Advanced.
|
||||
|
||||
## Requirements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Topology | Replica set or sharded cluster. |
|
||||
| Server | MongoDB 7.0 or 8.0. |
|
||||
| Privilege | Standard application role; collection creation goes through the admin plane. |
|
||||
|
||||
## Descriptor
|
||||
|
||||
`MongoTimeSeriesDescriptor` declares:
|
||||
|
||||
- **timeField** — required, a BSON date. This is the bucketing axis.
|
||||
- **metaField** — optional but nearly always wanted: the series identity (device id, tenant, sensor).
|
||||
Documents sharing a `metaField` value bucket together, which is where the compression comes from.
|
||||
- **granularity** — `MongoTimeSeriesGranularity`:
|
||||
|
||||
| Granularity | Bucket span | Use for |
|
||||
|---|---|---|
|
||||
| `SECONDS` | 1 hour | Sub-second to per-second ingest. |
|
||||
| `MINUTES` | 24 hours | Per-minute metrics. |
|
||||
| `HOURS` | 30 days | Hourly rollups. |
|
||||
|
||||
Granularity that is too fine produces many small buckets and loses the compression; too coarse
|
||||
produces oversized buckets that must be read whole to answer a narrow query.
|
||||
|
||||
## What a time series collection is not
|
||||
|
||||
`MongoTimeSeriesCapabilityValidator` refuses the operations the collection type does not support, at
|
||||
declaration time rather than at first use:
|
||||
|
||||
- **No arbitrary updates.** Time series data is append-mostly. Delete and limited update support
|
||||
exists on recent servers but is not part of this platform's contract.
|
||||
- **No unique index on the measurement.** There is no `_id` to be unique on in the usual sense.
|
||||
- **No CSFLE.** Refused — see [encryption.md](encryption.md).
|
||||
- **No change stream on the raw buckets** as a business event source. The bucket documents are a
|
||||
storage representation, not your measurements.
|
||||
|
||||
Converting an existing regular collection to a time series collection is a copy, not an alter. Plan
|
||||
it as a migration with a dual-write window.
|
||||
|
||||
## TTL
|
||||
|
||||
Time series collections use `expireAfterSeconds` on the collection rather than a TTL index on a
|
||||
field. The [TTL rules](../schema-index-migration-guide.md#4-ttl) still apply: expiry is physical
|
||||
cleanup on a bucket boundary, so a measurement can outlive its expiry by up to a bucket span plus the
|
||||
monitor interval. Do not treat absence as a deadline.
|
||||
|
||||
## Operations
|
||||
|
||||
`MongoTimeSeriesOperations` is the port for insert and windowed read. Reads are bounded by the same
|
||||
`MongoOperationBudget` as everything else: an unbounded time-range query on a time series collection
|
||||
is the fastest way to read a year of data into heap.
|
||||
|
||||
## Failure recovery
|
||||
|
||||
| Symptom | Cause | Action |
|
||||
|---|---|---|
|
||||
| Writes rejected with an unsupported-operation error | An update or unique-index expectation | The collection type does not support it; change the access pattern. |
|
||||
| Poor compression / large storage | Missing `metaField`, or granularity too fine | Both require a rebuild; measure on a copy before committing. |
|
||||
| Slow range queries | Granularity too coarse for the query window | Same: rebuild with the granularity matched to the dominant query. |
|
||||
|
||||
## Promotion evidence
|
||||
|
||||
Actual-topology evidence on the target deployment, a migration path from the existing collection,
|
||||
failure cases (unsupported update refused, CSFLE combination refused), and this document as the
|
||||
runbook.
|
||||
@@ -0,0 +1,92 @@
|
||||
# BSON Mapping Guide
|
||||
|
||||
Design §10 and decision D-06. The representation of a value in BSON is a data contract, not an
|
||||
implementation detail: once a collection holds a million documents, changing how a `BigDecimal` is
|
||||
stored is a migration with downtime, not a code change. `MongoTypeRepresentationManifest` pins the
|
||||
representation so a library upgrade or a different default cannot move it.
|
||||
|
||||
## 1. The manifest
|
||||
|
||||
`MongoTypeRepresentationManifest.standard()` fixes:
|
||||
|
||||
| Java type | BSON | Representation type |
|
||||
|---|---|---|
|
||||
| `UUID` | `Binary` subtype 4 | `MongoUuidRepresentation.STANDARD` |
|
||||
| `BigDecimal` | `Decimal128` | `MongoDecimalRepresentation.DECIMAL_128` |
|
||||
| `BigInteger` | `Decimal128` (or `String` when out of range, declared) | `MongoBigIntegerRepresentation` |
|
||||
| `Instant` / `OffsetDateTime` / `ZonedDateTime` | UTC `Date` | `MongoTemporalRepresentation.UTC_DATE` |
|
||||
| `LocalDate` | `String` (ISO-8601) or UTC `Date`, declared per field | `MongoTemporalRepresentation` |
|
||||
| `enum` | `String` name | `MongoEnumRepresentation.NAME` |
|
||||
|
||||
`MongoMappingConfiguration` and `MongoCustomConversionsFactory` build the Spring Data converters from
|
||||
the manifest, so there is one place to read and one place to change.
|
||||
|
||||
## 2. UUID
|
||||
|
||||
`UuidRepresentation.STANDARD` (subtype 4), always. The driver's legacy Java representation
|
||||
(subtype 3) byte-swaps two halves of the UUID, so a document written by one representation and read
|
||||
by the other yields a different — and valid-looking — UUID. Nothing errors; you just get the wrong
|
||||
row. The golden snapshot kit pins the codec explicitly for this reason
|
||||
(`MongoBsonSnapshot.defaultRegistry()`).
|
||||
|
||||
## 3. Decimal
|
||||
|
||||
`BigDecimal` → `Decimal128`, never `Double`. `12.30` stored as a double is `12.299999999999999`, and
|
||||
a monetary comparison written against it will one day be wrong by a cent for a customer who notices.
|
||||
`BigDecimalToDecimal128Converter` / `Decimal128ToBigDecimalConverter` are registered from the
|
||||
manifest.
|
||||
|
||||
`Decimal128` has 34 significant digits; a `BigDecimal` beyond that range fails on write rather than
|
||||
rounding silently.
|
||||
|
||||
## 4. Time
|
||||
|
||||
Store instants, not local times. `LocalDateTimeMappingGuard` refuses `LocalDateTime` fields on a
|
||||
mapped document: a `LocalDateTime` has no offset, so the value that goes in depends on the JVM
|
||||
default zone of whichever instance wrote it, and the two instances in a rolling deploy can disagree.
|
||||
Use `Instant` when the moment matters and `LocalDate` when the calendar day matters.
|
||||
|
||||
## 5. Type metadata
|
||||
|
||||
`MongoTypeMetadataPolicy` decides what goes in `_class`:
|
||||
|
||||
| Policy | Stored | Use when |
|
||||
|---|---|---|
|
||||
| `NONE` | nothing | The collection holds exactly one type and never will hold a subtype. |
|
||||
| `ALIAS` | a registered short alias | A polymorphic hierarchy in a long-lived collection. |
|
||||
| `CLASS_NAME` | the FQCN | Short-lived or internal collections only. |
|
||||
|
||||
`PolicyAwareMongoTypeMapper` enforces it, and `MongoTypeMetadataRegistry` holds alias → class.
|
||||
A `@LongLivedMongoDocument` type with `CLASS_NAME` is refused: writing `com.example.OrderV2` into a
|
||||
million documents means that renaming the package is a data migration.
|
||||
|
||||
## 6. Missing versus null
|
||||
|
||||
The golden kit keeps these apart deliberately. `MongoBsonSnapshotAssert.hasNoField(...)` and
|
||||
`hasExplicitNull(...)` are different assertions, because in MongoDB they are different documents:
|
||||
`{"a": null}` matches `{a: null}` and `{a: {$exists: true}}`, while `{}` matches only the first.
|
||||
A mapper change that starts writing explicit nulls silently changes what your queries return.
|
||||
|
||||
## 7. Golden representation tests
|
||||
|
||||
Every collection with a fixed representation should have a snapshot test:
|
||||
|
||||
```java
|
||||
MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(storedDocument);
|
||||
MongoBsonSnapshotAssert.assertThat(snapshot)
|
||||
.hasBsonType("amount", "DECIMAL128")
|
||||
.hasBsonType("externalId", "BINARY")
|
||||
.hasNoJavaClassName("dev.caskeleton")
|
||||
.hasTypeSignature("_id:OBJECT_ID,amount:DECIMAL128,createdAt:DATE_TIME,externalId:BINARY");
|
||||
```
|
||||
|
||||
`hasTypeSignature` is the regression gate: it fails on *any* representation change, including ones a
|
||||
value-equality assertion would pass. When it fails, the question is whether the change was intended
|
||||
and has a migration — not whether to update the string.
|
||||
|
||||
## 8. Round trips
|
||||
|
||||
`MongoRoundTripContract` asserts that `write → read` returns an equal domain object *and* that
|
||||
`write → read → write` produces an identical BSON document. The second half is what catches an
|
||||
asymmetric converter: a value that reads back equal but re-serialises differently makes every
|
||||
subsequent `save()` a spurious update, and turns change streams into a noise generator.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Change Stream Guide
|
||||
|
||||
Design §20, decision D-12. A change stream is an **at-least-once projector**, not an event bus.
|
||||
|
||||
## 1. What a change stream is not
|
||||
|
||||
D-12 is explicit: a physical change event is not a business integration event. The two differ in
|
||||
ways that matter to every consumer:
|
||||
|
||||
| Change event | Integration event |
|
||||
|---|---|
|
||||
| Emitted per document write | Emitted per business fact |
|
||||
| Shape follows the storage schema | Shape is a published contract |
|
||||
| A refactor of the document changes it | A refactor of the document does not change it |
|
||||
| Replayed on resume, duplicated on retry | Versioned and deliberately evolved |
|
||||
|
||||
Publishing raw change events externally makes your storage schema a public API, and the first time
|
||||
someone renames a field the downstream consumers break. If you need to bridge to messaging, use the
|
||||
Advanced bridge, which maps to an owned envelope
|
||||
([advanced/multi-tenancy.md](advanced/multi-tenancy.md) is separate;
|
||||
the bridge is described in §7 below).
|
||||
|
||||
## 2. Subscription and resume
|
||||
|
||||
`MongoChangeStreamSubscription` declares the collection, pipeline and consistency. `MongoResumePosition`
|
||||
is either a resume token or a cluster time; `MongoResumeCheckpoint` is what gets persisted and
|
||||
`MongoResumeCheckpointStore` persists it.
|
||||
|
||||
The checkpoint stores the token as a Base64 `encodedToken` string rather than a byte array — a record
|
||||
with an array component has broken equality, and a checkpoint that does not compare correctly is a
|
||||
checkpoint that silently fails its own dedup test.
|
||||
|
||||
## 3. Checkpoint after processing, not after receiving
|
||||
|
||||
The ordering rule that makes at-least-once actually hold:
|
||||
|
||||
```
|
||||
receive event
|
||||
→ process it (idempotently)
|
||||
→ persist the checkpoint
|
||||
```
|
||||
|
||||
Checkpointing on receipt turns the delivery guarantee into at-most-once, and the events lost are
|
||||
exactly the ones the process died while handling.
|
||||
|
||||
## 4. Idempotency
|
||||
|
||||
`MongoChangeEventIdentity` is the dedup key: `(resumeToken, documentKey, clusterTime, operationType)`.
|
||||
`MongoChangeDeduplicationStore` records what has been applied. Duplicates are not an edge case — every
|
||||
resume after any interruption replays at least one event, so a projector that is not idempotent is
|
||||
wrong on its first restart, not on some rare day.
|
||||
|
||||
`MongoChangeProjector` returns a `MongoChangeProjectionResult` so the runner can distinguish applied
|
||||
from skipped-as-duplicate, and the skip count is worth a metric: a sudden rise means something is
|
||||
looping.
|
||||
|
||||
## 5. States and recovery
|
||||
|
||||
`MongoChangeStreamState`: `STARTING`, `RUNNING`, `RESUMING`, `STOPPED`, `HISTORY_LOST`.
|
||||
|
||||
`MongoChangeStreamRecoveryPolicy` returns a `MongoChangeStreamRecoveryDecision`, which is either
|
||||
`resume()` (auto-resume from the checkpoint) or `halt(state, runbook)`. A halting decision **must**
|
||||
name a runbook — a decision that only says "stopped" leaves the on-call engineer to work out from
|
||||
scratch whether the projection can be rebuilt and from what.
|
||||
|
||||
| Situation | Decision |
|
||||
|---|---|
|
||||
| Transient network error, token still valid | `resume()` |
|
||||
| Primary failover | `resume()` — the token survives an election |
|
||||
| `invalidate` (collection dropped/renamed) | `halt(STOPPED, …)` → `MongoInvalidateRecovery` |
|
||||
| Token no longer in the oplog | `halt(HISTORY_LOST, "history-lost")` → `MongoChangeHistoryLostException` |
|
||||
|
||||
## 6. History lost
|
||||
|
||||
`MongoChangeHistoryLostException` is raised when the resume token predates the oldest oplog entry.
|
||||
The stream **cannot** be resumed: the events between the checkpoint and now are gone from the server,
|
||||
and no amount of retrying brings them back.
|
||||
|
||||
What the platform will not do is silently restart from "now". That looks like a recovery and is
|
||||
actually a silent gap in the projection — the worst possible outcome, because nothing reports it. The
|
||||
runner halts and requires an operator decision. See
|
||||
[runbooks/history-lost.md](runbooks/history-lost.md).
|
||||
|
||||
## 7. Bridging to messaging (Advanced)
|
||||
|
||||
`MongoChangeMessagingBridge` is opt-in behind `MongoCapability.CHANGE_STREAM` plus the bridge's own
|
||||
flag. It maps a change event to a platform-owned `MongoIntegrationEventEnvelope` through
|
||||
`MongoChangeToIntegrationEventMapper` and hands it to a `MongoIntegrationEventPublisher` port.
|
||||
|
||||
The port is defined in the bridge package rather than imported from the messaging adapter because the
|
||||
architecture registry forbids adapter-to-adapter dependencies; the composition root supplies the
|
||||
implementation.
|
||||
|
||||
`MongoBridgeOutboxPolicy` and `MongoBridgeCheckpointPolicy` state the delivery contract: publish then
|
||||
checkpoint, at-least-once, consumers must dedup on the envelope's event id.
|
||||
|
||||
## 8. Operating notes
|
||||
|
||||
- Change streams require a replica set. `MongoStartupValidator` refuses a change-stream profile on
|
||||
`STANDALONE`.
|
||||
- The change-stream principal is its own role (`MongoPrincipalRole.CHANGE_STREAM`) with
|
||||
`changeStream` and `find` — not the application write credential.
|
||||
- Oplog window is the recovery budget. If the oplog holds four hours, a consumer that is down for five
|
||||
hours needs a rebuild, not a resume. Alert on consumer lag against the oplog window, not against
|
||||
wall-clock.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Consistency and Transaction Guide
|
||||
|
||||
Design §12–§16, decisions D-07 through D-10. This is the part of the platform where the wrong
|
||||
default is most expensive and the least visible in testing, because every failure mode here needs a
|
||||
primary change to reproduce.
|
||||
|
||||
## 1. Prefer a single-document atomic operation
|
||||
|
||||
D-09: a transaction is for a **multi-document invariant**, nothing else. A single document is already
|
||||
atomic in MongoDB, so wrapping a one-document update in a transaction buys nothing and costs a
|
||||
session, a two-phase commit and a new ambiguous outcome.
|
||||
|
||||
D-07: partial change uses update operators, not `save()`. `MongoAtomicOperations` /
|
||||
`MongoAtomicOperationsTemplate` expose the operator set through `MongoUpdateOperator`
|
||||
(`$set`, `$inc`, `$push`, `$pull`, `$addToSet`, `$min`, `$max`, `$currentDate`, …) with an
|
||||
`AtomicFilter` precondition and a `ReturnDocumentMode`. Read-modify-write through `save()` replaces
|
||||
the whole document and silently discards any field another writer changed in between — a lost update
|
||||
with no error.
|
||||
|
||||
## 2. Whole-document replacement needs a revision
|
||||
|
||||
D-08. `VersionedMongoUpdater` requires a `MongoRevision`: either a Spring Data `@Version` field or an
|
||||
explicit expected-revision predicate in `VersionedUpdateCommand`. A replacement whose filter matched
|
||||
zero documents is not "nothing to do" — `MongoOptimisticConflictTranslator` distinguishes:
|
||||
|
||||
- filter matched nothing and the id does not exist → `MongoDocumentNotFoundException`
|
||||
- filter matched nothing and the id exists → `MongoOptimisticConflictException`
|
||||
|
||||
Collapsing these two into one is how a concurrent overwrite becomes a 404.
|
||||
|
||||
## 3. Consistency profiles
|
||||
|
||||
`MongoConsistencyProfile` names the read/write concern pair; `MongoConsistencyRegistry` binds a
|
||||
profile to an operation or collection, and `MongoConsistencyBinder` /
|
||||
`ReactiveMongoConsistencyBinder` apply it at execution.
|
||||
|
||||
| Profile | Meaning | Use for |
|
||||
|---|---|---|
|
||||
| `PRIMARY_LOCAL` | primary read, local concern | Throughput-sensitive reads that tolerate a rollback window. |
|
||||
| `PRIMARY_MAJORITY` | primary read, majority write | The default for anything a user will see again immediately. |
|
||||
| `CAUSAL_MAJORITY` | majority inside a causal session | Read-your-writes across separate operations. |
|
||||
| `STALE_READ_ALLOWED` | secondary reads permitted | Reporting and analytics that state their staleness. |
|
||||
| `SNAPSHOT_TRANSACTION` | snapshot isolation | Multi-document reads inside a transaction. |
|
||||
|
||||
A profile is a declaration, not a hint: the registry is consulted per operation and an operation
|
||||
without a registered profile is rejected rather than defaulting.
|
||||
|
||||
## 4. Causal sessions
|
||||
|
||||
`MongoCausalSessionContext` plus `SpringMongoCausalSessionExecutor` /
|
||||
`ReactiveMongoCausalSessionExecutor` carry the cluster time and operation time between operations, so
|
||||
"write then read" returns the write even when the read lands on a different node. Without a causal
|
||||
session, `PRIMARY_MAJORITY` gives you durability but not read-your-writes across two calls.
|
||||
|
||||
In the reactive path the session travels in the Reactor context (`ReactiveMongoContextKeys`), not in
|
||||
a thread local — a thread local is empty on the next operator in the chain.
|
||||
|
||||
## 5. Transactions
|
||||
|
||||
`MongoTransactionExecutor` / `ReactiveMongoTransactionExecutor` open a session through the session
|
||||
factory, run the body, and commit. `MongoTransactionProfile` carries the consistency profile, the
|
||||
`maxCommitTime` and the retry budget. Topology matters: a transaction requires a replica set, and
|
||||
`MongoStartupValidator` refuses a transaction-declaring profile on `STANDALONE` at startup rather
|
||||
than at the first call.
|
||||
|
||||
## 6. Retry: body and commit are different loops
|
||||
|
||||
D-10, and the single most consequential rule in the design.
|
||||
|
||||
```
|
||||
for each body attempt:
|
||||
open a NEW session
|
||||
run the body
|
||||
TransientTransactionError -> abort, next body attempt
|
||||
commit
|
||||
UnknownTransactionCommitResult -> retry COMMIT ONLY, same session
|
||||
```
|
||||
|
||||
`MongoTransactionRetryCoordinator` implements exactly this:
|
||||
|
||||
- **A new session per body attempt.** Reusing the session after an abort carries the aborted
|
||||
transaction's state into the retry.
|
||||
- **The body is never replayed after a commit ambiguity.** An unknown commit means the commit may
|
||||
already have applied. Re-running the body would apply it a second time. Only the commit is retried,
|
||||
and a commit retry on an already-committed transaction is a no-op by design.
|
||||
- **A budget bounds both loops.** `MongoRetryBudget` limits attempts *and* elapsed time, with jittered
|
||||
backoff (`delayBefore(attempt, random)`), so a struggling primary is not retried into the ground.
|
||||
|
||||
`MongoRetryDecision` and `MongoRetryScope` (in `…api.error`) say what may be retried:
|
||||
`MongoRetryScope.BODY`, `COMMIT_ONLY`, or `NONE`.
|
||||
|
||||
## 7. Ambiguous outcomes
|
||||
|
||||
`MongoExecutionOutcome` has six values, two of which are ambiguous and must not be collapsed:
|
||||
|
||||
| Outcome | Did the write happen? |
|
||||
|---|---|
|
||||
| `NOT_SENT` | No. Safe to retry. |
|
||||
| `NO_WRITE_PERFORMED` | No — the server answered and did nothing. |
|
||||
| `WRITE_CONFIRMED` | Yes. |
|
||||
| `PARTIAL_BULK_WRITE` | Some of it. See `MongoBulkResult`. |
|
||||
| `WRITE_RESULT_UNKNOWN` | **Unknown.** |
|
||||
| `TRANSACTION_COMMIT_UNKNOWN` | **Unknown.** |
|
||||
|
||||
An unknown outcome is not a failure and must not be reported to a caller as one. The caller either
|
||||
reconciles (`MongoCommitReconciler` re-reads a deterministic marker the body wrote) or surfaces the
|
||||
ambiguity. See [runbooks/unknown-commit.md](runbooks/unknown-commit.md).
|
||||
|
||||
`MongoFailureContext` records only the design-permitted fields — outcome, category, operation name,
|
||||
collection profile, retry scope, attempt — never the query, the document, or the values.
|
||||
|
||||
## 8. Failure translation
|
||||
|
||||
`DefaultMongoFailureClassifier` classifies **labels before codes**. The server's error labels
|
||||
(`TransientTransactionError`, `UnknownTransactionCommitResult`, `RetryableWriteError`) are the
|
||||
authoritative statement about retryability; an error code is a secondary signal whose meaning varies
|
||||
by server version. `DefaultMongoFailureTranslator` maps a classification onto the stable exception
|
||||
hierarchy, and anything unmatched becomes `MongoUnclassifiedFailureException` rather than leaking a
|
||||
driver type.
|
||||
|
||||
## 9. Bulk writes
|
||||
|
||||
`MongoBulkExecutor` returns a `MongoBulkResult` with per-item `MongoBulkItemFailure` entries. An
|
||||
unordered bulk write that partially fails is `PARTIAL_BULK_WRITE`, not a failure: some documents were
|
||||
written. `MongoBulkPartialFailureException` carries the succeeded and failed indexes so a caller can
|
||||
resume rather than replay.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Document Modeling Guide
|
||||
|
||||
Design §7–§9. The platform does not own your documents — D-01 is explicit that the domain owns
|
||||
`@Document`, repositories, queries, index requirements and schema version. What the platform owns is
|
||||
the set of modeling decisions that are expensive to reverse once a collection holds production data.
|
||||
|
||||
## 1. There is no `CommonMongoRepository`
|
||||
|
||||
A generic `CommonMongoRepository<T, ID>` is listed under explicitly unsupported (§3.4), and the
|
||||
reason is not purity. A shared supertype forces every collection to share an id strategy, a
|
||||
consistency profile and a query surface, and the first collection that needs a different one either
|
||||
gets a cast or a leaky generic parameter. Declare a Spring Data repository per aggregate.
|
||||
|
||||
## 2. Embed or reference
|
||||
|
||||
`MongoDocumentModelManifest` records the decision per collection so it is reviewable, and
|
||||
`MongoDocumentModelValidator` refuses the combinations that do not survive growth.
|
||||
|
||||
| Descriptor | Use when |
|
||||
|---|---|
|
||||
| `EmbeddedCollectionDescriptor` | The child is read with the parent, is bounded, and has no independent lifecycle. Declare `maxElements`; an unbounded array is the single most common way a document reaches the size limit. |
|
||||
| `MongoReferenceDescriptor` | The child is queried independently, is unbounded, or outlives the parent. Declare `MongoReferenceLifecycle` so the deletion story is written down rather than discovered. |
|
||||
|
||||
The validator rejects an embedded collection without a bound, and a reference whose lifecycle says
|
||||
the child is owned by the parent but which is also referenced from elsewhere.
|
||||
|
||||
## 3. Size budget
|
||||
|
||||
`MongoDocumentSizeBudget`:
|
||||
|
||||
| Constant | Bytes | Meaning |
|
||||
|---|---|---|
|
||||
| `MONGODB_HARD_LIMIT_BYTES` | 16 MiB | MongoDB's own limit. |
|
||||
| `PLATFORM_CEILING_BYTES` | 4 MiB | The largest budget the platform will accept. |
|
||||
| `DEFAULT_BYTES` | 2 MiB | `MongoDocumentSizeBudget.standard()`. |
|
||||
|
||||
A budget above the ceiling is refused at construction. Budgeting to 16 MiB means the failing write
|
||||
is the first symptom, and by then the collection is already full of near-limit documents.
|
||||
|
||||
## 4. Identity
|
||||
|
||||
`DomainDocumentId` and `MongoIdRepresentation` fix how a domain identifier becomes `_id`. Pick the
|
||||
representation once per collection and record it in the manifest:
|
||||
|
||||
- `OBJECT_ID` — server-generated, monotonic, 12 bytes. Good default when the domain has no natural id.
|
||||
- `UUID_BINARY` — a domain UUID stored as `Binary` subtype 4 (`STANDARD`). Never store a UUID as a
|
||||
string "because it is easier to read"; it doubles the index size and loses the type.
|
||||
- `STRING` — a natural key that is genuinely a string (a slug, an external system's id).
|
||||
|
||||
An `_id` choice is effectively permanent: it is the shard key candidate, the resume-token join key
|
||||
and the pagination tie-breaker.
|
||||
|
||||
## 5. Schema version
|
||||
|
||||
Every long-lived collection carries `DocumentSchemaVersion`. `MongoSchemaVersionPolicy` and
|
||||
`MongoSchemaVersionRange` say which versions the running code can read; a document outside the range
|
||||
raises `MongoDataSchemaUnsupportedException` rather than being silently mapped with missing fields.
|
||||
|
||||
Write the range down before the migration, not after: the range is what lets old and new instances
|
||||
run at once during a rolling deploy.
|
||||
|
||||
## 6. Type metadata
|
||||
|
||||
`@LongLivedMongoDocument` marks a document whose stored type alias must not be a Java class name.
|
||||
`MongoTypeMetadataRegistry` maps alias → class. Storing the FQCN means moving or renaming the class
|
||||
becomes a data migration; storing an alias keeps it a refactor. See
|
||||
[bson-mapping-guide.md](bson-mapping-guide.md) §4.
|
||||
|
||||
## 7. Collection profiles
|
||||
|
||||
`MongoCollectionProfileRegistry` binds a `CollectionProfileName` to its consistency profile, budget
|
||||
and allowlist. A collection that is not registered cannot be reached through
|
||||
`MongoImperativeExecutor` or `ReactiveMongoExecutor` — the allowlist is the mechanism that keeps an
|
||||
unreviewed collection from appearing in production by accident.
|
||||
|
||||
## 8. What to write down before the first insert
|
||||
|
||||
1. Embed/reference decision per child collection, with bounds.
|
||||
2. Size budget.
|
||||
3. `_id` representation.
|
||||
4. Schema version range.
|
||||
5. Index manifest (see [schema-index-migration-guide.md](schema-index-migration-guide.md)).
|
||||
6. Consistency profile (see [consistency-transaction-guide.md](consistency-transaction-guide.md)).
|
||||
|
||||
Each of these is cheap now and a migration later.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Query and Aggregation Guide
|
||||
|
||||
Design §17–§19, decision D-11. Every query and every pipeline is a registered, bounded thing. Free-form
|
||||
JSON queries and unbounded pipelines are explicitly unsupported (§3.4).
|
||||
|
||||
## 1. Registered operations
|
||||
|
||||
Every execution carries a `MongoOperationContext`: a `MongoOperationName`, a `DatabaseProfileName`, a
|
||||
`CollectionProfileName`, a `MongoOperationType` and a `MongoOperationScope`.
|
||||
|
||||
`MongoOperationName` matches `[a-z][a-z0-9.-]{2,95}`. It is the join key for the budget registry, the
|
||||
consistency registry, the metric tag and the log line — a free-form or interpolated name breaks all
|
||||
four at once, which is why the pattern is enforced at construction.
|
||||
|
||||
`MongoOperationScope` uses an `UNSPECIFIED` sentinel rather than `null`, so "the caller did not say"
|
||||
is a value the policy layer can reject rather than an NPE further down.
|
||||
|
||||
## 2. Query guardrails
|
||||
|
||||
`PolicyAwareMongoQueryBuilder` builds a query from `MongoFieldDescriptor` + `MongoOperator` pairs
|
||||
against a `MongoQueryPolicy`. The policy refuses:
|
||||
|
||||
- a field not in the collection's allowlist
|
||||
- an operator not allowed for that field
|
||||
- a sort on an unindexed field
|
||||
- `$where`, `$expr` with arbitrary JavaScript, and server-side evaluation generally
|
||||
- an unbounded `$regex`
|
||||
|
||||
`MongoRegexPolicy` requires an anchored prefix pattern and bounds the pattern length. An unanchored
|
||||
regex is a collection scan wearing an index's clothes, and a user-supplied one is a denial-of-service
|
||||
primitive.
|
||||
|
||||
`MongoSortDescriptor` pairs a field with a direction and is validated against the index manifest, so
|
||||
a sort that would spill to disk fails review rather than production.
|
||||
|
||||
## 3. Operation budgets
|
||||
|
||||
`MongoOperationBudget` bounds four things at once:
|
||||
|
||||
| Bound | Why |
|
||||
|---|---|
|
||||
| `maxTimeMS` | The server stops working on a query nobody is waiting for. |
|
||||
| result limit | An unbounded result set is an OOM with extra steps. |
|
||||
| batch size | Bounds the per-round-trip memory. |
|
||||
| examined-document ceiling | Catches an index regression that a time limit alone would hide on a fast day. |
|
||||
|
||||
`MongoBudgetPolicyRegistry` binds a budget to an operation name; `MongoBudgetEnforcer` applies it and
|
||||
raises `MongoOperationRejectedException` before execution when a request exceeds it, and
|
||||
`MongoTimeoutException` when the server enforces it.
|
||||
|
||||
## 4. Keyset pagination
|
||||
|
||||
Unbounded `skip` is unsupported: `skip(1_000_000)` makes the server walk a million documents to throw
|
||||
them away, so page 1000 costs a thousand times page 1.
|
||||
|
||||
`MongoKeysetQueryBuilder` builds the resume predicate lexicographically. For a sort on `(a DESC, _id
|
||||
DESC)` resuming after `(A, I)`:
|
||||
|
||||
```
|
||||
(a < A) OR (a = A AND _id < I)
|
||||
```
|
||||
|
||||
`validate()` rejects a `MongoKeysetSort` without a unique tie-breaker. Without one, two documents with
|
||||
the same sort value straddle the page boundary and one of them is skipped or repeated — invisibly,
|
||||
and only under concurrency.
|
||||
|
||||
`MongoNullSortOrdering` makes null placement explicit, because MongoDB's own ordering of missing
|
||||
versus null versus present is not what most people assume.
|
||||
|
||||
### Cursors are authenticated
|
||||
|
||||
`MongoKeysetCursorCodec` signs the cursor with HMAC-SHA256 and compares with
|
||||
`MessageDigest.isEqual` (constant time). An unsigned cursor is a client-controlled query predicate: a
|
||||
caller can edit it to read a range they were never offered. A tampered or truncated cursor yields
|
||||
`MongoCursorException`, never a partially-decoded resume position.
|
||||
|
||||
## 5. Aggregation guardrails
|
||||
|
||||
`MongoAggregationPlan` is a registered pipeline: an ordered list of `MongoAggregationStageDescriptor`
|
||||
validated against a `MongoAggregationProfile`. `PolicyAwareMongoAggregationExecutor` runs only a
|
||||
registered plan.
|
||||
|
||||
`MongoAggregationRisk` grades each stage, and the profile sets the ceiling:
|
||||
|
||||
| Risk | Stages | Policy |
|
||||
|---|---|---|
|
||||
| low | `$match` on an indexed prefix, `$limit`, `$project` | Always allowed. |
|
||||
| moderate | `$group`, `$sort` with an index, `$unwind` with a bound | Allowed within budget. |
|
||||
| high | `$lookup`, `$graphLookup`, `$facet`, unindexed `$sort` | Requires explicit approval in the profile. |
|
||||
| forbidden | `$out`, `$merge` outside the admin plane, `$function`, `$accumulator` | Refused. |
|
||||
|
||||
`allowDiskUse` is a declared property of the plan, not a runtime flag. A pipeline that needs disk is a
|
||||
pipeline whose shape should be reviewed.
|
||||
|
||||
## 6. Reactive execution and cursors
|
||||
|
||||
`ReactiveMongoExecutor` / `DefaultReactiveMongoExecutor` carry the operation context in the Reactor
|
||||
context. `MongoCursorGuard` and `MongoCursorLease` bound cursor lifetime:
|
||||
|
||||
- a cursor has a lease with a deadline
|
||||
- cancellation closes the server-side cursor (`MongoCursorTermination`)
|
||||
- an abandoned cursor is a server-side resource, so the lease is released on cancel, error *and*
|
||||
completion — `MongoReactiveCursorPublisher` uses `Flux.using` so all three paths run the same
|
||||
release
|
||||
|
||||
A leaked cursor does not fail anything locally; it consumes a connection and a snapshot on the server
|
||||
until the server's own timeout, which is why the guard is not optional.
|
||||
|
||||
## 7. Geospatial
|
||||
|
||||
`MongoGeoQuery` + `MongoGeoPoint` + `MongoGeoDistance` over a `2dsphere` index. Distances are metres
|
||||
on a sphere (`nearSphere` with `maxDistance`), never degrees — a degree of longitude is a different
|
||||
distance in Oslo than in Nairobi, and a radius expressed in degrees is a bug that only shows up away
|
||||
from the equator. `SpringMongoGeospatialOperations` is the Spring Data binding;
|
||||
`MongoGeospatialOperations` is the port.
|
||||
|
||||
## 8. Native capability gateway
|
||||
|
||||
When a registered operation genuinely needs something outside the Stable API, it goes through
|
||||
`MongoNativeCapabilityGateway` (`PolicyAwareMongoNativeGateway`), never through the driver directly.
|
||||
The admission order is fixed:
|
||||
|
||||
```
|
||||
capability registered
|
||||
→ database profile
|
||||
→ collection allowlist
|
||||
→ operation name present
|
||||
→ timeout / maxTimeMS
|
||||
→ consistency profile
|
||||
→ result / batch limit
|
||||
→ trace
|
||||
→ log redaction
|
||||
→ command category (MongoNativeCommandCategory)
|
||||
→ D4 admin command refused
|
||||
→ execute
|
||||
```
|
||||
|
||||
`ApprovedMongoNativeOperation` is the registration record; `MongoNativeOperationPolicy` is the policy.
|
||||
An admin-plane command reaching this gateway is refused regardless of capability — the admin plane has
|
||||
its own credential and its own client (see [security-observability.md](security-observability.md)).
|
||||
@@ -0,0 +1,124 @@
|
||||
# MongoDB Document Persistence Platform — Repository Adaptation Contract
|
||||
|
||||
**Design source:** `mongodb-superpowers-package/docs/superpowers/specs/2026-08-11-mongodb-document-persistence-platform-design.md`
|
||||
**Stable plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-document-persistence-platform-implementation-plan.md`
|
||||
**Advanced plan:** `mongodb-superpowers-package/docs/superpowers/plans/2026-08-11-mongodb-advanced-capabilities-expansion-plan.md`
|
||||
|
||||
The design package declares its own module root (`modules/mongodb`) and root package
|
||||
(`io.backend.skeleton.mongodb`) as *implementation assumptions*, not as contract. This file is the
|
||||
single record of how that assumed layout was mapped onto this repository. Only paths, build DSL,
|
||||
and composition-root ownership changed. Public contracts, policy order, and error semantics are
|
||||
implemented exactly as specified.
|
||||
|
||||
## 1. Why the module layout differs
|
||||
|
||||
The design assumes 19 Stable Gradle projects under `modules/mongodb/` and 12 Advanced projects
|
||||
under `modules/mongodb-advanced/`. This repository is a Clean Architecture template whose
|
||||
**fail-closed registry** (`src/config/architecture/modules.json`, enforced by `src/settings.gradle`
|
||||
and `verifyCleanArchitectureDependencies`) declares **exactly 19 leaf identities**. Creating 31 more
|
||||
Gradle projects would violate HARD-STOP #5 in `AGENTS.md`.
|
||||
|
||||
Therefore the design's 31 modules become **package boundaries inside the registered leaf**
|
||||
`:adapter:outbound:persistence-mongo`, following the precedent already set by
|
||||
[docs/httpclient/repository-adaptation.md](../httpclient/repository-adaptation.md). The design's
|
||||
module dependency table (§6.3) is reproduced as ten ArchUnit rules in `MongoModuleBoundaryTest`, so
|
||||
a forbidden edge fails the build the same way a missing Gradle dependency would.
|
||||
|
||||
## 2. Package mapping
|
||||
|
||||
Root package: `io.backend.skeleton.mongodb` → `dev.caskeleton.adapter.outbound.mongo`.
|
||||
|
||||
### 2.1 Stable modules
|
||||
|
||||
| Design module | Repository package |
|
||||
|---|---|
|
||||
| `mongodb-core-api` | `…outbound.mongo.api` (+ `.capability`, `.consistency`, `.error`, `.mapping`, `.observation`, `.profile`, `.schema`) |
|
||||
| `mongodb-spring-data` | `…outbound.mongo.mapping` (+ `.type`), `…outbound.mongo.failure` |
|
||||
| `mongodb-imperative` | `…outbound.mongo.imperative` (+ `.atomic`, `.bulk`, `.revision`) |
|
||||
| `mongodb-reactive` | `…outbound.mongo.reactive` (+ `.cursor`) |
|
||||
| `mongodb-query` | `…outbound.mongo.query` (+ `.budget`, `.pagination`) |
|
||||
| `mongodb-aggregation` | `…outbound.mongo.aggregation` |
|
||||
| `mongodb-transaction` | `…outbound.mongo.transaction` (+ `.retry`, `.session`) |
|
||||
| `mongodb-index-schema` | `…outbound.mongo.schema` (+ `.index`, `.manifest`, `.model`, `.ttl`, `.validation`) |
|
||||
| `mongodb-change-stream` | `…outbound.mongo.changestream` (+ `.projector`, `.recovery`) |
|
||||
| `mongodb-geospatial` | `…outbound.mongo.geo` |
|
||||
| `mongodb-migration-core` | `…outbound.mongo.migration` |
|
||||
| `mongodb-migration-flamingock` | `…outbound.mongo.migration.flamingock` |
|
||||
| `mongodb-observability` | `…outbound.mongo.observation` |
|
||||
| `mongodb-security` | `…outbound.mongo.security` (+ `.admin`), `…outbound.mongo.nativecap` |
|
||||
| `mongodb-spring-boot-starter` | `…outbound.mongo.autoconfigure` |
|
||||
| `mongodb-testkit-core` | `…outbound.mongo.testkit.mapping`, `.compat`, `.performance` (`testkit` source set) |
|
||||
| `mongodb-testkit-replicaset` | `…outbound.mongo.testkit.rs` (`testkit` source set) |
|
||||
| `mongodb-testkit-failover` | `…outbound.mongo.testkit.failover` (`testkit` source set) |
|
||||
| `mongodb-testkit-migration` | `…outbound.mongo.testkit.migration` (`testkit` source set) |
|
||||
|
||||
`…outbound.mongo.architecture` has no design counterpart: it holds the `@MongoOperation` marker and
|
||||
the reusable ArchUnit rule set a fork applies to its own document/repository code.
|
||||
|
||||
### 2.2 Advanced modules
|
||||
|
||||
| Design module | Repository package |
|
||||
|---|---|
|
||||
| `mongodb-sharding` | `…outbound.mongo.advanced.sharding` (+ `.admin` for the D4 shard plane) |
|
||||
| `mongodb-timeseries` | `…outbound.mongo.advanced.timeseries` |
|
||||
| `mongodb-csfle` | `…outbound.mongo.advanced.encryption.csfle` |
|
||||
| `mongodb-queryable-encryption` | `…outbound.mongo.advanced.encryption.qe` |
|
||||
| `mongodb-search` | `…outbound.mongo.advanced.search` |
|
||||
| `mongodb-vector-search` | `…outbound.mongo.advanced.vector` |
|
||||
| `mongodb-tenancy-shared` | `…outbound.mongo.advanced.tenancy.shared` |
|
||||
| `mongodb-tenancy-database` | `…outbound.mongo.advanced.tenancy.database` |
|
||||
| `mongodb-change-stream-messaging-bridge` | `…outbound.mongo.advanced.bridge` |
|
||||
| `mongodb-gridfs-compat` | `…outbound.mongo.advanced.gridfs` |
|
||||
| `mongodb-testkit-sharded` | `…outbound.mongo.testkit.sharded` (`testkit` source set) |
|
||||
| `mongodb-testkit-atlas` | `…outbound.mongo.testkit.atlas` (`testkit` source set) |
|
||||
|
||||
The design's rule that a Stable module never depends on an Advanced one survives as an ArchUnit rule
|
||||
(`stableNeverDependsOnAdvanced`) plus the opt-in flag: every Advanced entry point requires
|
||||
`MongoAdvancedCapabilityFlags` to have the matching capability enabled and refuses construction
|
||||
otherwise. Being on the classpath is not being enabled.
|
||||
|
||||
## 3. Other deliberate substitutions
|
||||
|
||||
| Design assumption | Repository reality | Adaptation |
|
||||
|---|---|---|
|
||||
| Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. |
|
||||
| `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. |
|
||||
| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. |
|
||||
| `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. |
|
||||
| `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. |
|
||||
| Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. |
|
||||
| Per-task `git commit` | `AGENTS.md`: commit policy is `human-only` | Implementation is delivered unstaged; commits are the human's action. This is the only plan step intentionally not executed, and it is recorded here. |
|
||||
| `docs/mongodb/**`, `scripts/verify-mongodb-*.sh` | Repository already owns `docs/` and `scripts/` | Created at the same repository-relative paths. |
|
||||
|
||||
## 4. What is unchanged from the design
|
||||
|
||||
- D1 / D2 / D3 / D4 exposure planes and the ordered D3 admission sequence (§5).
|
||||
- Stable API V1 with `apiStrict=true` on the D1/D2 client generation; D3/D4 on separate generations.
|
||||
- `MongoExecutionOutcome`, including both ambiguous outcomes (`WRITE_RESULT_UNKNOWN`,
|
||||
`TRANSACTION_COMMIT_UNKNOWN`), and `MongoFailureContext`'s permitted-field list.
|
||||
- The complete stable exception hierarchy and the label-before-code classification order.
|
||||
- The BSON representation manifest (UUID `STANDARD`, `Decimal128`, UTC instants, alias type metadata)
|
||||
and the document-size budget.
|
||||
- Update-operator-first writes, and optimistic revision as the precondition for whole-document
|
||||
replacement.
|
||||
- Transaction body retry and commit retry as separate loops: a new session per body attempt, and
|
||||
commit-only retry on unknown commit. The body is never replayed after a commit ambiguity.
|
||||
- Registered operation names and manifests for query, aggregation and index; no free-form JSON query
|
||||
and no unbounded pipeline.
|
||||
- Keyset pagination with an authenticated cursor and a unique tie-breaker requirement.
|
||||
- Change stream as an at-least-once projector with resume-token checkpointing and explicit
|
||||
history-lost handling.
|
||||
- TTL as physical cleanup only, never the sole basis for access denial or business scheduling.
|
||||
- Manifest-owned index/validator state with an apply policy that never drops what it does not own.
|
||||
- Low-cardinality observation tags, command redaction, and the credential reference indirection.
|
||||
- The Stable release gate's evidence categories, and the Advanced promotion gate's requirement for
|
||||
actual-topology evidence.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
```bash
|
||||
bash scripts/verify-mongodb-platform.sh # Stable gate
|
||||
bash scripts/verify-mongodb-advanced.sh # Advanced gate (opt-in lanes)
|
||||
```
|
||||
|
||||
Both scripts run from the repository root and delegate to `src/gradlew`.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
title: Runbook — MongoDB primary failover
|
||||
category: mongodb
|
||||
severity: P2
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: MongoDB primary failover
|
||||
|
||||
Design §29, scenarios `PRIMARY_KILL`, `NETWORK_PARTITION`, `SERVER_SELECTION_TIMEOUT`,
|
||||
`WRITE_RESPONSE_LOSS`.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoServerSelectionException` / `MongoConnectionException` spike, then recovery within seconds.
|
||||
- `MongoSdamObservationListener` reports a topology change (primary removed, new primary elected).
|
||||
- `MongoPoolObservationListener` shows checkout wait times rising while server-side command duration
|
||||
stays flat — the wait is topology, not query cost.
|
||||
- Latency spike on writes with no corresponding rise in read latency.
|
||||
|
||||
A failover that resolves in under ~15 s and produces no `WRITE_RESULT_UNKNOWN` is normal replica-set
|
||||
behaviour and needs no action beyond confirming it self-healed.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. Confirm an election actually happened. SDAM events distinguish an election from "the database got
|
||||
slow"; without them the two are indistinguishable in application metrics.
|
||||
2. Split the failure categories. Metric tag `failureCategory`:
|
||||
- `SERVER_SELECTION` / `CONNECTION` → the driver could not reach a primary. `NOT_SENT`; safe.
|
||||
- `TIMEOUT` with outcome `WRITE_RESULT_UNKNOWN` → a write may have applied. Not safe; see below.
|
||||
- `TRANSACTION_COMMIT_UNKNOWN` → go to [unknown-commit.md](unknown-commit.md) instead.
|
||||
3. Check the election duration against `MongoRetryBudget`. If the election outlasted the budget, the
|
||||
retries were exhausted before a primary existed and callers saw errors that a longer budget would
|
||||
have absorbed.
|
||||
4. Check whether the new primary is in the expected region/AZ. A failover to a distant node changes
|
||||
write latency permanently, not transiently.
|
||||
|
||||
## Action
|
||||
|
||||
**Self-healed (the common case).**
|
||||
Confirm outcome distribution contains no `WRITE_RESULT_UNKNOWN`, record the election in the incident
|
||||
log, and close. Nothing to replay.
|
||||
|
||||
**Writes with `WRITE_RESULT_UNKNOWN`.**
|
||||
These writes may or may not have applied. Do not blind-retry.
|
||||
- Idempotent operation (registered `MongoUpdateOperator` with an `AtomicFilter` precondition): retry.
|
||||
The precondition makes the second application a no-op.
|
||||
- Non-idempotent operation: reconcile by reading the target document and comparing against the
|
||||
intended post-state. Retry only if it does not reflect the write.
|
||||
|
||||
**Server selection never recovers.**
|
||||
The set has lost quorum — two of three nodes are down or partitioned. No client-side action fixes
|
||||
this; escalate to the database owner to restore a majority. The application should be failing closed,
|
||||
not queueing.
|
||||
|
||||
**Elections are frequent (more than one a day, unprompted).**
|
||||
This is an infrastructure symptom, not an application one: check node resource saturation, disk
|
||||
latency on the primary, and network stability between members. Repeated elections cause repeated
|
||||
unknown-outcome windows.
|
||||
|
||||
## Escalation
|
||||
|
||||
- P2 → P1 if server selection has failed for more than 2 minutes, or if any non-idempotent write
|
||||
returned `WRITE_RESULT_UNKNOWN` and cannot be reconciled.
|
||||
- Page the database owner for quorum loss, and the service owner for reconciliation of ambiguous
|
||||
writes.
|
||||
|
||||
## Verification
|
||||
|
||||
The failover lane reproduces this deliberately:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
It starts a real three-node set (`MongoThreeNodeReplicaSet`), stops the primary
|
||||
(`MongoPrimaryController`), and injects network faults through Toxiproxy
|
||||
(`ToxiproxyMongoNetworkFaultController`). A single-node set is not sufficient for the election: it
|
||||
never holds one, so every guarantee that depends on a primary change goes untested.
|
||||
|
||||
The network faults need their own fixture (`MongoProxiedReplicaSetNode`) because a stopped container
|
||||
cannot produce them. Stopping a node tells the client the write did not happen; cutting the *path*
|
||||
while the server keeps running produces a client that cannot tell. `MongoNetworkFaultLaneTest`
|
||||
asserts the difference by reaching the same server twice — once through the proxy, once directly:
|
||||
|
||||
- **Partition**: the proxied client fails, the direct client finds the server healthy and the earlier
|
||||
write intact. The path was cut, not the server.
|
||||
- **Response loss**: the proxied client fails, and the direct client then finds the document
|
||||
*present*. The write applied and only the acknowledgement was lost —
|
||||
`DefaultMongoFailureClassifier` returns `WRITE_RESULT_UNKNOWN`, and a retry would have inserted a
|
||||
second document.
|
||||
|
||||
One detail the lane depends on: the connection is warmed before the toxic is applied. On a cold
|
||||
connection it is the driver's handshake whose response is dropped, so the write is never transmitted
|
||||
— `NOT_SENT`, the opposite of the ambiguity being tested.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
title: Runbook — MongoDB change stream history lost
|
||||
category: mongodb
|
||||
severity: P1
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: change stream history lost
|
||||
|
||||
Design §20.3, scenarios `OPLOG_HISTORY_LOSS`, `RESUME_TOKEN_LOSS`.
|
||||
|
||||
The stored resume token predates the oldest entry in the oplog. The events between the checkpoint and
|
||||
now are gone from the server; no retry recovers them. `MongoChangeStreamRecoveryPolicy` returns
|
||||
`halt(HISTORY_LOST, "history-lost")` and the runner stops.
|
||||
|
||||
**The platform will not silently restart from "now".** That looks like a recovery and is actually a
|
||||
permanent, unreported gap in the projection.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoChangeHistoryLostException`.
|
||||
- `MongoChangeStreamState.HISTORY_LOST`; the consumer is stopped, not looping.
|
||||
- Precedes it: consumer lag approaching the oplog window, or a consumer that was down for a long
|
||||
period (a deploy that failed, a scaled-to-zero worker, a long outage).
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. **Determine the gap.** The checkpoint's cluster time is the start; the oldest oplog entry is the
|
||||
end of what is unrecoverable. Everything in between was never processed.
|
||||
2. **Determine the oplog window.** `rs.printReplicationInfo()` on the primary gives the first and last
|
||||
oplog timestamps. If the window is materially smaller than it was, the write rate rose or the
|
||||
oplog was resized — the consumer may be fine and the server changed.
|
||||
3. **Determine what the projection is missing.** Which collections and which operations does this
|
||||
projector consume? The gap is bounded by that, not by everything that happened.
|
||||
4. **Check for a second consumer.** If another projector on the same collection is healthy, its
|
||||
checkpoint tells you whether the problem is this consumer or the oplog.
|
||||
|
||||
## Action
|
||||
|
||||
Resuming is not an option. The choices are:
|
||||
|
||||
**Rebuild from source.** If the projection is derivable from the current state of the source
|
||||
collections, rebuild it: stop the consumer, rebuild the projection, then start the stream from the
|
||||
cluster time at which the rebuild snapshot was taken. This is the correct answer whenever the
|
||||
projection is a materialised view rather than an event log, and it is the reason a projection should
|
||||
be derivable.
|
||||
|
||||
**Backfill the gap.** If the source documents carry a timestamp covering the gap, run a bounded
|
||||
backfill for that window through the migration runner (checkpointed, resumable — see
|
||||
[schema-index-migration-guide.md](../schema-index-migration-guide.md) §5), then resume from the
|
||||
current cluster time.
|
||||
|
||||
**Accept the gap explicitly.** Only when the projection is advisory and the business owner says so.
|
||||
Record the window in the incident log and reset the checkpoint. This is a decision someone signs, not
|
||||
a default.
|
||||
|
||||
Never: reset the checkpoint to "now" and restart quietly. That converts a visible P1 into an
|
||||
invisible data-quality defect that surfaces months later as "the report has been wrong since March".
|
||||
|
||||
## Prevention
|
||||
|
||||
- **Alert on lag against the oplog window, not wall-clock.** "Consumer is 30 minutes behind" is fine
|
||||
with a 24-hour oplog and an emergency with a 45-minute one. The threshold that matters is
|
||||
`lag / oplogWindow`.
|
||||
- **Size the oplog for the longest tolerable consumer outage**, including a failed deploy discovered
|
||||
the next morning.
|
||||
- **Checkpoint after processing, never on receipt** — see
|
||||
[change-stream-guide.md](../change-stream-guide.md) §3.
|
||||
- **Back up the checkpoint store.** `RESUME_TOKEN_LOSS` is the same incident reached from the other
|
||||
direction: the oplog is fine, the checkpoint is gone.
|
||||
- **Make the projection rebuildable.** A projection that can only be built by replaying every event
|
||||
has no recovery path once the oplog rolls.
|
||||
|
||||
## Escalation
|
||||
|
||||
- P1 on detection. The consumer is stopped, so lag grows for as long as this is unresolved.
|
||||
- Page the service owner for the rebuild decision, and the database owner if the oplog window shrank
|
||||
unexpectedly.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
`MongoFailoverScenario.OPLOG_HISTORY_LOSS` and `RESUME_TOKEN_LOSS` assert the runner halts and names
|
||||
this runbook rather than restarting from the current position.
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: Runbook — MongoDB unknown transaction commit result
|
||||
category: mongodb
|
||||
severity: P1
|
||||
owner: oncall
|
||||
last_updated: 2026-08-13
|
||||
status: active
|
||||
---
|
||||
|
||||
# Runbook: unknown transaction commit result
|
||||
|
||||
Design §16, decision D-10, scenario `UNKNOWN_TRANSACTION_COMMIT_RESULT`.
|
||||
|
||||
`MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN` means the commit **may have applied**. It is not a
|
||||
failure and must never be reported to a caller as one. The single worst response is to re-run the
|
||||
transaction body: if the commit did apply, the body applies a second time.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- `MongoTransactionCommitUnknownException` in logs.
|
||||
- Metric `failureCategory=TRANSACTION_COMMIT_UNKNOWN`.
|
||||
- Usually accompanies a primary election — see [failover.md](failover.md).
|
||||
- Downstream reports of duplicated effects (double charge, double increment) are the symptom of this
|
||||
being handled wrongly, not of the condition itself.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
1. **Confirm the platform did the right thing automatically.**
|
||||
`MongoTransactionRetryCoordinator` retries the *commit only*, on the same session, within
|
||||
`MongoRetryBudget`. A commit retry against an already-committed transaction is a no-op by design.
|
||||
Most occurrences resolve here and never reach a human.
|
||||
|
||||
2. **If the budget was exhausted, determine the actual state.** The commit either applied or it did
|
||||
not; you must find out which, not guess.
|
||||
- If the transaction body wrote a deterministic marker (an idempotency key, a business id, a
|
||||
revision), read it back. That is exactly what `MongoCommitReconciler` does, and it is the
|
||||
reason the design requires transactions to write one.
|
||||
- If there is no marker: reconstruct from a downstream artefact — an outbox row, an audit record,
|
||||
an external side effect. If nothing exists to compare against, the transaction was not
|
||||
designed to be reconcilable and that is the finding to record.
|
||||
|
||||
3. **Check whether the body was replayed.** Grep for a second execution with the same operation name
|
||||
and correlation id. If the body ran twice, the effects need reversing, and the code path that
|
||||
replayed it is a defect: an ambiguous commit is `COMMIT_ONLY` scope
|
||||
(`MongoRetryScope.COMMIT_ONLY`), never `BODY`.
|
||||
|
||||
## Action
|
||||
|
||||
**Commit applied.** Nothing to do. Record the reconciliation.
|
||||
|
||||
**Commit did not apply.** Re-run the whole operation from the top — a new session, a new body
|
||||
attempt. This is safe precisely because you established the previous attempt left no trace.
|
||||
|
||||
**Cannot determine.** Do not retry. Escalate. A blind retry here is a coin flip between "no effect"
|
||||
and "duplicate effect", and duplicates in a financial or notification path are worse than a delay.
|
||||
Freeze the affected entity if the domain supports it, and hand off with: operation name, correlation
|
||||
id, document id, the time window, and what you checked.
|
||||
|
||||
**Recurring.** More than one an hour means the commit path is racing something structural — a
|
||||
`maxCommitTime` shorter than the observed election duration, an oversized transaction, or an
|
||||
undersized retry budget. Fix the budget or the transaction shape; do not raise the retry count and
|
||||
call it resolved.
|
||||
|
||||
## Prevention
|
||||
|
||||
- Every transaction body writes a deterministic marker that identifies its own commit.
|
||||
- `maxCommitTime` exceeds the observed p99 election duration.
|
||||
- Callers surface the ambiguity to their own callers rather than mapping it to a generic 500 — an
|
||||
ambiguous outcome reported as a failure invites the caller to retry, which is the one thing that
|
||||
must not happen.
|
||||
- Prefer a single-document atomic operation (D-09). A transaction that exists only to wrap one
|
||||
document write has invented this failure mode for nothing.
|
||||
|
||||
## Escalation
|
||||
|
||||
- Always P1 when the state cannot be determined and the operation has an external effect.
|
||||
- Page the service owner immediately; the database owner only if elections are the trigger.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoFailoverTest --console=plain
|
||||
```
|
||||
|
||||
`MongoFailoverScenario.UNKNOWN_TRANSACTION_COMMIT_RESULT` runs this path against a real three-node
|
||||
set, and the coordinator test asserts the body is never replayed after a commit ambiguity.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Schema, Index and Migration Guide
|
||||
|
||||
Design §21–§25, decision D-13. Indexes and validators are **declared** in a manifest and **applied**
|
||||
by an explicit plane. Automatic index creation in production is explicitly unsupported (§3.4): an
|
||||
index build on a large collection is a capacity event, and discovering it because a deployment
|
||||
started one is not an operating model.
|
||||
|
||||
## 1. The manifest is the source of truth
|
||||
|
||||
`MongoManifestRegistry` holds one `MongoCollectionManifest` per collection, containing:
|
||||
|
||||
- `MongoIndexManifest` — the declared indexes (`MongoIndexKey`, `MongoIndexDirection`, uniqueness,
|
||||
partial filter, collation)
|
||||
- `MongoSchemaManifest` — the declared `$jsonSchema` validator
|
||||
- `MongoMetadataOwnership` — who owns each observed object
|
||||
|
||||
Ownership is the field that makes drift handling safe:
|
||||
|
||||
| Ownership | Owner | Droppable on drift |
|
||||
|---|---|---|
|
||||
| `APPLICATION_MANAGED` | this manifest | yes |
|
||||
| `SEARCH_MANAGED` | the search service | no |
|
||||
| `ENCRYPTION_MANAGED` | Queryable Encryption | no |
|
||||
| `EXTERNAL` | someone else (a DBA, another service) | no |
|
||||
|
||||
A diff engine that does not know about ownership eventually proposes dropping
|
||||
`enxcol_.customers.esc` or a search index, and "the drift tool cleaned it up" is a very bad incident
|
||||
summary.
|
||||
|
||||
## 2. Index diff and apply
|
||||
|
||||
`MongoIndexDiffEngine` compares the manifest against `MongoIndexDescriptorView` observations and
|
||||
produces a `MongoIndexDiff`: missing, extra, and *changed* (same name, different definition —
|
||||
MongoDB will not silently rebuild these, so they must be reported rather than re-issued).
|
||||
|
||||
`MongoIndexApplyPolicy` decides what happens with a diff:
|
||||
|
||||
| Policy | Behaviour | Environment |
|
||||
|---|---|---|
|
||||
| `APPLY` | create what is missing | local / test |
|
||||
| `APPLY_WITH_DIFF` | create what is missing and report the rest | staging |
|
||||
| `DIFF_WITH_APPROVED_APPLY` | apply only what a human approved | production |
|
||||
| `REPORT_ONLY` | never write | audit |
|
||||
|
||||
Dropping is never implicit. `MongoIndexRetirementPlan` moves an index through
|
||||
`MongoIndexRetirementState` — declared → hidden → observed-unused → droppable — and each transition
|
||||
is a separate deployment. Hiding an index makes the planner ignore it while keeping it maintained, so
|
||||
an unexpected regression is one command to undo. Dropping it is not.
|
||||
|
||||
## 3. Validators
|
||||
|
||||
`MongoValidatorDescriptor` carries the `$jsonSchema`, a `MongoValidationLevel`
|
||||
(`OFF` / `MODERATE` / `STRICT`) and a `MongoValidationAction`.
|
||||
|
||||
**Stable validation actions are `error` and `warn` only.** `errorAndLog` is not part of the Stable
|
||||
contract on MongoDB 7.0 or 8.0 and the descriptor refuses it.
|
||||
|
||||
`MongoValidatorDiffEngine` produces a `MongoValidatorDiff`; `MongoValidatorApplyPolicy` gates the
|
||||
apply. Tightening a validator on a collection with existing data is the dangerous direction: introduce
|
||||
it as `warn` + `MODERATE`, confirm the warning count is zero, then promote to `error` + `STRICT` in a
|
||||
second deployment.
|
||||
|
||||
## 4. TTL
|
||||
|
||||
D-13: TTL is **physical cleanup**, nothing else.
|
||||
|
||||
`MongoTtlIndexDescriptor` declares the field and `expireAfterSeconds`. `MongoTtlPolicyValidator`
|
||||
enforces what `MongoTtlPolicy` allows, and `MongoExpirationAccessPolicy` states the rule that matters:
|
||||
|
||||
> A document's presence is not authorization, and its absence is not a deadline.
|
||||
|
||||
The TTL monitor runs about once a minute and deletes in batches, so a document can outlive its
|
||||
expiry by minutes to hours under load. Consequences:
|
||||
|
||||
- Access control must check the expiry field, not the document's existence. A still-present expired
|
||||
session is a valid document and an invalid session.
|
||||
- Business scheduling must not be built on TTL. If something must happen at a time, schedule it.
|
||||
- A TTL field must be a BSON date. A TTL index on a string silently never deletes anything.
|
||||
|
||||
## 5. Migrations
|
||||
|
||||
`MongoMigrationRunner` executes `MongoMigration` units with:
|
||||
|
||||
- `MongoMigrationId` — ordered, unique
|
||||
- `MongoMigrationChecksum` — content hash; a changed checksum for an applied id is a hard failure, not
|
||||
a re-run. Editing an applied migration means two environments ran different code under the same id.
|
||||
- `MongoMigrationLedger` — what has been applied
|
||||
- `MongoMigrationLock` — one runner at a time; a rolling deploy starts several instances at once
|
||||
- `MongoMigrationPrecondition` / `MongoMigrationPostcondition` — checked before and after; a migration
|
||||
that cannot verify its own result is a migration whose failure is discovered by a customer
|
||||
- `MongoMigrationCheckpoint` — a resumable position for a backfill
|
||||
|
||||
`MongoMigrationResult` reports applied / incomplete / dry-run with the reason. `INCOMPLETE` is not a
|
||||
failure: a rate-limited backfill that ran out of its time budget has done real work and stored a
|
||||
checkpoint, and reporting it as failed would send the next run back to the beginning.
|
||||
|
||||
`MongoCollectionMigrationLedger` and `MongoCollectionMigrationLock` are the MongoDB-backed
|
||||
implementations. Two details are load-bearing and only exist on a server:
|
||||
|
||||
- The ledger's **unique index** on the migration id, created by `ensureIndexes()`. Without it, two
|
||||
runners that both pass the "not applied yet" read both insert, and the ledger then reports one
|
||||
migration applied twice with two checksums — indistinguishable from tampering.
|
||||
- The lease is taken with **one conditional update**, not read-then-write. A filter matching only a
|
||||
free or expired lease lets the server pick the winner; two runners that each read "free" and then
|
||||
write would both believe they hold it.
|
||||
|
||||
The lease expires so a runner killed mid-migration does not block every future deployment, and
|
||||
`refresh` between batches is what proves the holder is still alive.
|
||||
|
||||
### Backfills restart, they do not restart-from-zero
|
||||
|
||||
A long backfill will be interrupted — a deploy, an OOM, a node replacement. The checkpoint records
|
||||
the last completed key so the restart continues rather than re-processing from the beginning.
|
||||
`MongoBackfillRestartFixture` in the testkit asserts exactly this: kill mid-run, restart, and the
|
||||
result is identical to the uninterrupted run and does not re-apply completed work.
|
||||
|
||||
### Flamingock
|
||||
|
||||
`FlamingockMongoMigrationAdapter` bridges to Flamingock through the platform-owned
|
||||
`FlamingockChangeUnitView`, with `FlamingockLedgerAdapter` and `FlamingockLockAdapter` mapping the
|
||||
ledger and lock. The public contract does not reference Flamingock types, so the provider can be
|
||||
replaced without touching a migration.
|
||||
|
||||
## 6. Ordering with deployments
|
||||
|
||||
```
|
||||
1. Add the index (hidden if it is large) -> deployment N
|
||||
2. Unhide / verify usage -> deployment N+1
|
||||
3. Ship code that depends on the index -> deployment N+1
|
||||
4. Backfill data -> migration, resumable
|
||||
5. Tighten the validator from warn to error -> deployment N+2
|
||||
6. Retire the old index through the retirement states -> deployments N+3…
|
||||
```
|
||||
|
||||
Each step is independently reversible. A deployment that adds an index and the code that requires it
|
||||
at the same time has no safe rollback: rolling back the code leaves the index build running, and
|
||||
rolling back the index breaks the code that is still live on half the fleet.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Security and Observability
|
||||
|
||||
Design §26–§28, decision D-05. The application plane and the admin plane are different credentials on
|
||||
different clients, and telemetry never becomes an exfiltration path.
|
||||
|
||||
## 1. Roles
|
||||
|
||||
`MongoPrincipalRole` — one credential per role, least privilege:
|
||||
|
||||
| Role | Grants |
|
||||
|---|---|
|
||||
| `APP_READ` | `find` on allowlisted collections |
|
||||
| `APP_WRITE` | `insert`, `update`, `delete` on allowlisted collections |
|
||||
| `CHANGE_STREAM` | `changeStream`, `find` |
|
||||
| `MIGRATION` | index and validator management on the target collections |
|
||||
| `SEARCH_ADMIN` | search index management |
|
||||
| `SHARD_ADMIN` | shard key operations |
|
||||
| `ENCRYPTION_ADMIN` | key vault access |
|
||||
| `DBA` | the human plane; never used by an application |
|
||||
|
||||
`MongoSecurityProfileValidator` checks the profile at startup. `forbiddenPrivilegesHeld()` names the
|
||||
privileges the profile holds and must not — the validator reports *which* one, because "your
|
||||
credential is over-privileged" without a name is an unactionable finding.
|
||||
|
||||
The privileges that must never appear on an application credential: `dropDatabase`,
|
||||
`dropCollection`, `shutdown`, `killop`, `root`, `__system`, `dbOwner`, `userAdminAnyDatabase`.
|
||||
|
||||
## 2. Credentials are references, not values
|
||||
|
||||
`MongoCredentialReference` holds a `secret://…` reference plus the role. The reference is resolved at
|
||||
connection time by the secret provider; the password is never a property value, a log field, or a
|
||||
constructor argument that could end up in a stack trace.
|
||||
|
||||
`MongoCredentialRotationPolicy` states the rotation contract: overlapping validity, a drain window,
|
||||
and a rotation that never requires a restart. `MongoClientGenerationRegistry` implements the swap —
|
||||
a new `MongoClientGeneration` starts serving new operations while the previous generation is
|
||||
`markDraining()` until its in-flight operations finish. Killing the old client immediately fails every
|
||||
in-flight request, which is why rotation without generations is an outage.
|
||||
|
||||
Rotation is a failover scenario in the release gate (`MongoFailoverScenario.CREDENTIAL_ROTATION`),
|
||||
not a runbook step people hope works.
|
||||
|
||||
## 3. TLS and connection policy
|
||||
|
||||
`MongoSecurityProfile.production(...)` requires TLS and refuses `tlsAllowInvalidCertificates` /
|
||||
`tlsAllowInvalidHostnames`. `MongoSecurityProfile.local(...)` exists so a developer does not have to
|
||||
weaken the production factory to get a container to connect; the startup validator refuses a local
|
||||
profile on a production runtime profile.
|
||||
|
||||
## 4. Admin plane (D4)
|
||||
|
||||
`MongoAdminGateway` is the only path to `MongoAdminOperation`, and it runs on the D4 client with the
|
||||
DBA-scoped credential — not the application's.
|
||||
|
||||
- `MongoAdminAuthorization` checks the caller's role against the operation.
|
||||
- `MongoAdminRuntimeGuard` refuses high-risk operations (`highRisk()`) unless the runtime profile
|
||||
explicitly permits them; a `dropCollection` reachable from a running application is a data-loss
|
||||
vector regardless of how well-reviewed the calling code is.
|
||||
- `MongoAdminAuditRecord` records who ran what, when and against which collection profile — before
|
||||
execution, so a failed attempt is recorded too.
|
||||
|
||||
The native capability gateway (D3) refuses any admin-category command, so there is no path from the
|
||||
application plane into the admin plane.
|
||||
|
||||
## 5. Observability tags
|
||||
|
||||
`MongoObservationConvention` allowlists exactly eight tag names:
|
||||
|
||||
```
|
||||
mongoProfile, databaseProfile, collectionProfile, operationName,
|
||||
operationType, result, failureCategory, consistencyProfile
|
||||
```
|
||||
|
||||
and explicitly forbids:
|
||||
|
||||
```
|
||||
documentId, rawTenantId, tenantId, dynamicCollectionName, queryParameter,
|
||||
query, fullBson, resumeToken, shardKeyValue, plaintextPII, credential
|
||||
```
|
||||
|
||||
Two reasons, and both matter. Cardinality: a tag whose values are document ids produces one time
|
||||
series per document, which is how a metrics backend falls over. Confidentiality: a metric label is
|
||||
stored, shipped and retained by systems with a different access model than the database.
|
||||
`requireAllowed(tagName)` throws on anything outside the list, so a new tag is a deliberate change to
|
||||
the convention rather than a line in a service.
|
||||
|
||||
`MicrometerMongoOperationObserver` implements the `MongoOperationObserver` port;
|
||||
`NoOpMongoOperationObserver` is the default so observation is opt-in and never a hard dependency.
|
||||
|
||||
## 6. Driver-native listeners
|
||||
|
||||
`MongoDriverObservabilityConfiguration` registers three driver listeners, because they answer
|
||||
questions the application-level timer cannot:
|
||||
|
||||
| Listener | Answers |
|
||||
|---|---|
|
||||
| `MongoCommandObservationListener` | How long did the *server* take, versus how long the caller waited? |
|
||||
| `MongoPoolObservationListener` | Was the wait time connection checkout rather than query execution? |
|
||||
| `MongoSdamObservationListener` | Did the topology change — an election, a node removed — during the window? |
|
||||
|
||||
Without pool and SDAM events, every failover looks like "the database got slow", and the difference
|
||||
between "we need a bigger pool" and "we lost a primary" is invisible.
|
||||
|
||||
## 7. Command redaction
|
||||
|
||||
`MongoObservationRedactor.describe(commandName)`:
|
||||
|
||||
- Authentication and user-management commands (`authenticate`, `saslStart`, `saslContinue`,
|
||||
`getnonce`, `createUser`, `updateUser`, `copydb*`) render as `<redacted>` — their arguments carry
|
||||
credentials and key material.
|
||||
- Structural commands (`ping`, `hello`, `buildInfo`, `listCollections`, `listIndexes`, `collStats`)
|
||||
render by name; their arguments are not data-bearing.
|
||||
- Everything else renders as `name(...)`: you get the command, never the filter or the document.
|
||||
|
||||
`isAlwaysRedacted(...)` is the assertion hook so a test can prove no logging path can render an auth
|
||||
command's arguments.
|
||||
|
||||
## 8. Startup validation
|
||||
|
||||
`MongoStartupValidator` runs at context refresh, before the first request:
|
||||
|
||||
1. `MongoTopologyProbe` reports the actual `MongoTopology`.
|
||||
2. Each declared `MongoTopologyRequirement` is checked against it — a transaction, causal-session or
|
||||
change-stream requirement fails closed on `STANDALONE`.
|
||||
3. `MongoSecurityProfileValidator` checks credentials and TLS.
|
||||
4. `MongoCapabilitySupport` checks declared capabilities against the server version, with
|
||||
`MongoSupportLevel` distinguishing `STABLE` / `ADVANCED` / `EXPERIMENTAL` / `UNSUPPORTED`.
|
||||
5. `MongoPlatformHealthIndicator` reports the outcome for the readiness probe.
|
||||
|
||||
A misconfiguration found at startup costs a failed deploy. The same misconfiguration found at runtime
|
||||
costs an incident, and the failing operation is rarely the one that reveals the cause.
|
||||
|
||||
## 9. How the security lane proves any of this
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:outbound:persistence-mongo:mongoSecurityIntegrationTest --console=plain
|
||||
```
|
||||
|
||||
The lane runs against `MongoAuthenticatedReplicaSetContainer`, which starts mongod with `--auth` and a
|
||||
generated keyfile. That detail is the whole lane: Testcontainers' `MongoDBContainer` starts mongod
|
||||
*without* `--auth`, so users created on it all have every privilege and a least-privilege assertion
|
||||
passes no matter how wrong the roles are. A security test that cannot fail is not a security test.
|
||||
|
||||
What the lane asserts is the refusal: the `read` role's insert is rejected, and the application role's
|
||||
`dropDatabase` is rejected. Then it checks that `MongoSecurityProfileValidator` names the same
|
||||
privilege the server just refused.
|
||||
@@ -0,0 +1,91 @@
|
||||
# MongoDB Platform — Support Matrix
|
||||
|
||||
Design §4. This file records what the platform is *certified* on, not what it happens to run on.
|
||||
A configuration absent from this table is unsupported until someone runs the gate against it and
|
||||
adds a row.
|
||||
|
||||
## 1. Runtime baseline
|
||||
|
||||
| Component | Version | Policy |
|
||||
|---|---|---|
|
||||
| Java | 21 | Repository runtime baseline. |
|
||||
| Spring Boot | 4.0.0 | BOM-managed. Individual driver overrides are forbidden. |
|
||||
| Spring Data MongoDB | 5.0.0 | Repository and `MongoTemplate` integration. Version comes from the Boot BOM. |
|
||||
| MongoDB Java Driver | 5.6.1 | BOM-managed. Never pinned directly in the module. |
|
||||
| Reactor | 3.8.0 | Reactive execution path. |
|
||||
| Micrometer | 1.16.0 | Driver-native observability. |
|
||||
| Testcontainers | 2.0.2 | Replica-set, failover, migration and compatibility lanes. |
|
||||
|
||||
The design's baseline is Spring Boot 4.1.x / Spring Data MongoDB 5.1.x. This repository is on
|
||||
4.0.0 / 5.0.0, so the platform targets only the API surface common to both. See
|
||||
[repository-adaptation.md](repository-adaptation.md) §3.
|
||||
|
||||
## 2. Server versions
|
||||
|
||||
| Lane | Version | Pinned image | Gradle task |
|
||||
|---|---|---|---|
|
||||
| Primary certification | MongoDB 8.0 | `mongo:8.0.16` | `mongoReplicaSetTest`, `mongoFailoverTest` |
|
||||
| Compatibility | MongoDB 7.0 | `mongo:7.0.28` | `mongoCompatibilityTest` |
|
||||
| Network fault injection | — | `ghcr.io/shopify/toxiproxy:2.12.0` | `mongoFailoverTest` |
|
||||
|
||||
Images are pinned, never `latest`: a mutable tag means the certification result describes whatever
|
||||
was pulled that morning, not the version in the row. Override with
|
||||
`-PmongoPrimaryImage=…` / `-PmongoCompatibilityImage=…` when testing a new patch level, and update
|
||||
the row once the gate passes.
|
||||
|
||||
`MongoVersionMatrix.standard()` is the machine-readable form of this table; a version outside it
|
||||
fails `certifies()`.
|
||||
|
||||
## 3. Topologies
|
||||
|
||||
| Topology | Status | What is certified | What is not |
|
||||
|---|---|---|---|
|
||||
| Standalone | **Smoke only** | Basic CRUD and mapping. | Not a production profile and never counts as Stable release evidence (D-03). Transactions, retryable writes and change streams are refused at startup by `MongoStartupValidator`. |
|
||||
| Single-node replica set | **Local default** (D-02) | Transactions, retryable writes, change streams — the same semantics as production. | Elections. A single-node set never holds one, so failover behaviour is untested here. |
|
||||
| 3-node replica set | **Stable production gate** | Everything above plus primary failover, unknown-commit handling and change-stream resume across an election. | Shard routing. |
|
||||
| Sharded cluster | **Advanced gate** | Shard-key routing classification, scatter-gather refusal, `admin` plane operations. | Not included in the Stable gate. |
|
||||
| Atlas / provider-managed | **Per-capability gate** | Search, vector search and encryption against the actual target deployment. | Atlas Local in a container is a pull-request convenience, explicitly **not** release evidence (`MongoAtlasCapabilityContractSuite.Environment`). |
|
||||
|
||||
## 4. Stable API and client generations
|
||||
|
||||
| Plane | Stable API | Purpose |
|
||||
|---|---|---|
|
||||
| D1 Standard document persistence | V1, `apiStrict=true` | Repositories, typed queries, atomic updates, optimistic revision. |
|
||||
| D2 Advanced document operations | V1, `apiStrict=true` | `MongoTemplate`, transactions, bulk, aggregation, keyset cursors, change streams. |
|
||||
| D3 Explicit Mongo capability | Not strict | Native BSON, time series, search/vector, CSFLE/QE, shard-aware operations — each behind a registered capability. |
|
||||
| D4 Admin plane | Not strict | Collection, validator, index, migration, shard and repair commands. Separate credential, separate client. |
|
||||
|
||||
D3 is not a raw-client escape. Every call passes capability registration → database profile →
|
||||
collection allowlist → operation name → timeout → consistency profile → result limit → trace →
|
||||
redaction → command category → D4 refusal, in that order.
|
||||
|
||||
## 5. Validation actions
|
||||
|
||||
Stable validation actions are `error` and `warn`. `errorAndLog` is **not** part of the Stable
|
||||
contract on 7.0 or 8.0 and `MongoValidatorDescriptor` refuses it.
|
||||
|
||||
## 6. Explicitly unsupported
|
||||
|
||||
Per design §3.4, none of the following is provided, and adding one is a design change rather than a
|
||||
feature request:
|
||||
|
||||
- A generic `CommonMongoRepository<T, ID>`.
|
||||
- Arbitrary runtime `runCommand`.
|
||||
- Automatic index creation in production.
|
||||
- A Standalone production contract.
|
||||
- TTL as an exact business scheduler or as the only access control.
|
||||
- Publishing raw change events as external business integration events.
|
||||
- GridFS as the source of truth for new files.
|
||||
- Java fully-qualified class names as a long-lived BSON schema.
|
||||
- Unbounded skip pagination, unbounded aggregation pipelines, unbounded regex, unbounded results.
|
||||
|
||||
## 7. Capability tiers
|
||||
|
||||
| Tier | Capabilities | Enablement |
|
||||
|---|---|---|
|
||||
| Stable | Mapping, imperative/reactive execution, atomic update, optimistic lock, transactions, consistency profiles, retry/translation, query and aggregation guardrails, schema/index manifests, keyset pagination, bulk partial results, change streams, TTL contract, GeoJSON, security, observability | On when `ca-skeleton.persistence-mongo.enabled=true`. |
|
||||
| Advanced | Sharding-aware query, time series, CSFLE, Queryable Encryption (equality/range), change-stream→messaging bridge, shared-collection multi-tenancy | Each behind `ca-skeleton.persistence-mongo.advanced.<capability>.enabled`. |
|
||||
| Experimental | Search, vector search, hybrid search, database-per-tenant, collection-per-tenant, reshard orchestration, provider-specific features | Same flag mechanism; promotion additionally requires the evidence in [ADR-MONGO-ADV-001](../adr/ADR-MONGO-ADV-001-capability-promotion.md). |
|
||||
|
||||
`MongoAdvancedCapabilityFlags.propertyFor(capability)` is the authoritative property name for any
|
||||
capability; the table above is its prose form.
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The MongoDB Advanced capability gate (advanced plan Task 15).
|
||||
#
|
||||
# Advanced capabilities are opt-in modules. This script verifies the contracts that can be verified
|
||||
# without provider infrastructure, and then reports -- explicitly -- which promotion evidence it
|
||||
# could NOT produce.
|
||||
#
|
||||
# Required promotion categories (MongoAdvancedPromotionEvidence.REQUIRED):
|
||||
#
|
||||
# stable-platform, actual-topology, security, migration, failure, runbook
|
||||
#
|
||||
# `actual-topology` is the one that cannot be substituted. A container gives a functional pass for
|
||||
# sharding, search, vector and encryption while exercising none of the behaviour that makes them
|
||||
# Advanced rather than Stable: real shard distribution, a real analyzer, a real KMS. Atlas Local is
|
||||
# a pull-request convenience and is not release evidence -- see
|
||||
# MongoAtlasCapabilityContractSuite.Environment.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/verify-mongodb-advanced.sh
|
||||
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-advanced.sh
|
||||
# MONGODB_SHARDED_URI=... MONGODB_ATLAS_URI=... MONGODB_KMS=... bash scripts/verify-mongodb-advanced.sh
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GRADLE_DIR="${REPO_ROOT}/src"
|
||||
MODULE=':adapter:outbound:persistence-mongo'
|
||||
GRADLE=(./gradlew --console=plain)
|
||||
|
||||
FAILED=()
|
||||
MISSING_EVIDENCE=()
|
||||
|
||||
echo "MongoDB Advanced capability gate"
|
||||
echo "repository: ${REPO_ROOT}"
|
||||
|
||||
# --- stable-platform -------------------------------------------------------------------------
|
||||
# An Advanced capability cannot be promoted over a Stable platform that does not itself pass.
|
||||
echo ""
|
||||
echo "=== [stable-platform] Stable gate"
|
||||
if bash "${REPO_ROOT}/scripts/verify-mongodb-platform.sh"; then
|
||||
echo "stable-platform: supplied"
|
||||
else
|
||||
status=$?
|
||||
if (( status == 2 )); then
|
||||
echo "stable-platform: INCOMPLETE (the Stable gate skipped lanes)"
|
||||
MISSING_EVIDENCE+=("stable-platform (Stable gate incomplete)")
|
||||
else
|
||||
FAILED+=("stable-platform")
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- failure + runbook (hermetic) -------------------------------------------------------------
|
||||
# Every Advanced refusal contract: disabled capability refuses construction, CSFLE/QE cannot share a
|
||||
# collection, QE substring/prefix/suffix unsupported on 8.0, a non-READY search index cannot serve,
|
||||
# undeclared scatter-gather is rejected, a dimension mismatch is refused.
|
||||
echo ""
|
||||
echo "=== [failure] Advanced contract tests"
|
||||
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*advanced*'); then
|
||||
echo "failure: supplied"
|
||||
else
|
||||
FAILED+=("failure")
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== [runbook] capability documentation"
|
||||
for doc in sharding time-series encryption search-vector multi-tenancy gridfs-migration; do
|
||||
path="${REPO_ROOT}/docs/mongodb/advanced/${doc}.md"
|
||||
if [[ -f "${path}" ]]; then
|
||||
echo " + ${doc}.md"
|
||||
else
|
||||
echo " - ${doc}.md MISSING"
|
||||
FAILED+=("runbook:${doc}")
|
||||
fi
|
||||
done
|
||||
if [[ ! -f "${REPO_ROOT}/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md" ]]; then
|
||||
echo " - ADR-MONGO-ADV-001 MISSING"
|
||||
FAILED+=("runbook:ADR-MONGO-ADV-001")
|
||||
fi
|
||||
|
||||
# --- actual-topology -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== [actual-topology] provider environments"
|
||||
if [[ -n "${MONGODB_SHARDED_URI:-}" ]]; then
|
||||
if (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:test" --tests '*Shard*' \
|
||||
-Dmongodb.sharded.uri="${MONGODB_SHARDED_URI}"); then
|
||||
echo "actual-topology(sharded): supplied"
|
||||
else
|
||||
FAILED+=("actual-topology:sharded")
|
||||
fi
|
||||
else
|
||||
echo "actual-topology(sharded): no MONGODB_SHARDED_URI"
|
||||
MISSING_EVIDENCE+=("actual-topology: sharded cluster")
|
||||
fi
|
||||
|
||||
if [[ -n "${MONGODB_ATLAS_URI:-}" ]]; then
|
||||
echo "actual-topology(search/vector): MONGODB_ATLAS_URI present"
|
||||
else
|
||||
echo "actual-topology(search/vector): no MONGODB_ATLAS_URI"
|
||||
MISSING_EVIDENCE+=("actual-topology: search/vector on the actual target deployment")
|
||||
fi
|
||||
|
||||
if [[ -n "${MONGODB_KMS:-}" ]]; then
|
||||
echo "actual-topology(encryption): MONGODB_KMS present"
|
||||
else
|
||||
echo "actual-topology(encryption): no MONGODB_KMS"
|
||||
MISSING_EVIDENCE+=("actual-topology: real KMS and key vault")
|
||||
fi
|
||||
|
||||
# --- security + migration ---------------------------------------------------------------------
|
||||
# These are review artefacts, not test runs: a role review and a documented migration path per
|
||||
# capability. The gate records that they are outstanding rather than pretending a green test covers
|
||||
# them.
|
||||
MISSING_EVIDENCE+=("security: per-capability privilege review sign-off")
|
||||
MISSING_EVIDENCE+=("migration: per-capability migration path sign-off")
|
||||
|
||||
# --- Report ------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "---------------------------------------------------------------"
|
||||
if (( ${#FAILED[@]} > 0 )); then
|
||||
echo "ADVANCED GATE: FAILED"
|
||||
for entry in "${FAILED[@]}"; do echo " - ${entry}"; done
|
||||
echo "---------------------------------------------------------------"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "verifiable contracts: PASSED"
|
||||
if (( ${#MISSING_EVIDENCE[@]} > 0 )); then
|
||||
echo ""
|
||||
echo "ADVANCED GATE: NOT PROMOTABLE -- missing evidence:"
|
||||
for entry in "${MISSING_EVIDENCE[@]}"; do echo " ~ ${entry}"; done
|
||||
echo ""
|
||||
echo "A capability stays opt-in until every category in"
|
||||
echo "MongoAdvancedPromotionEvidence.REQUIRED is supplied. See"
|
||||
echo "docs/adr/ADR-MONGO-ADV-001-capability-promotion.md."
|
||||
echo "---------------------------------------------------------------"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "ADVANCED GATE: PASSED"
|
||||
echo "---------------------------------------------------------------"
|
||||
Executable
+158
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# The MongoDB Stable release gate (design §30, plan Task 50).
|
||||
#
|
||||
# Runs every lane that produces one of the Stable evidence categories:
|
||||
#
|
||||
# mapping, transaction, migration, change-stream, security,
|
||||
# failover, performance, compatibility
|
||||
#
|
||||
# The gate exists because "the test suite is green" and "every category has evidence" are different
|
||||
# statements. A suite passes happily with a whole lane skipped -- no Docker, a disabled tag, a
|
||||
# renamed task -- and a release built on that suite has no failover or compatibility evidence at
|
||||
# all, silently. Each lane below is therefore run by name, and a skipped lane is reported as skipped
|
||||
# rather than counted as passed.
|
||||
#
|
||||
# Advanced capabilities are NOT promoted or transitively included here. See
|
||||
# scripts/verify-mongodb-advanced.sh.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/verify-mongodb-platform.sh # hermetic lanes only
|
||||
# MONGODB_DOCKER=1 bash scripts/verify-mongodb-platform.sh # + container lanes
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GRADLE_DIR="${REPO_ROOT}/src"
|
||||
MODULE=':adapter:outbound:persistence-mongo'
|
||||
GRADLE=(./gradlew --console=plain)
|
||||
|
||||
RESULTS_DIR="${GRADLE_DIR}/adapter/outbound/persistence-mongo/build/test-results"
|
||||
|
||||
RAN=()
|
||||
SKIPPED=()
|
||||
FAILED=()
|
||||
|
||||
# Counts the tests a lane actually executed, from its JUnit XML.
|
||||
#
|
||||
# A lane whose filter matches nothing passes: Gradle runs the task, discovers no tests, and reports
|
||||
# success. That is the failure mode this whole gate exists to prevent -- an empty lane is not
|
||||
# evidence, it is the absence of evidence wearing a green tick. Any lane that reports zero executed
|
||||
# tests is treated as a failure.
|
||||
executed_tests() {
|
||||
local task="$1"
|
||||
local dir="${RESULTS_DIR}/${task}"
|
||||
[[ -d "${dir}" ]] || { echo 0; return; }
|
||||
local total=0
|
||||
shopt -s nullglob
|
||||
for xml in "${dir}"/*.xml; do
|
||||
local count
|
||||
count=$(sed -n 's/.*<testsuite[^>]* tests="\([0-9]*\)".*/\1/p' "${xml}" | head -1)
|
||||
total=$(( total + ${count:-0} ))
|
||||
done
|
||||
shopt -u nullglob
|
||||
echo "${total}"
|
||||
}
|
||||
|
||||
run_lane() {
|
||||
local category="$1"
|
||||
local task="$2"
|
||||
shift 2
|
||||
echo ""
|
||||
echo "=== [${category}] ${task}"
|
||||
if ! (cd "${GRADLE_DIR}" && "${GRADLE[@]}" "${MODULE}:${task}" "$@"); then
|
||||
FAILED+=("${category}:${task}")
|
||||
return
|
||||
fi
|
||||
# `check` aggregates several tasks and has no results directory of its own.
|
||||
if [[ "${task}" == "check" ]]; then
|
||||
RAN+=("${category}:${task}")
|
||||
return
|
||||
fi
|
||||
local executed
|
||||
executed=$(executed_tests "${task}")
|
||||
if (( executed == 0 )); then
|
||||
echo "!!! ${task} passed without executing a single test — the lane's filter matches nothing,"
|
||||
echo "!!! so the '${category}' evidence category is empty."
|
||||
FAILED+=("${category}:${task} (0 tests executed)")
|
||||
else
|
||||
RAN+=("${category}:${task} (${executed} tests)")
|
||||
fi
|
||||
}
|
||||
|
||||
skip_lane() {
|
||||
local category="$1"
|
||||
local task="$2"
|
||||
local reason="$3"
|
||||
echo ""
|
||||
echo "=== [${category}] ${task} -- SKIPPED (${reason})"
|
||||
SKIPPED+=("${category}:${task} (${reason})")
|
||||
}
|
||||
|
||||
docker_available() {
|
||||
[[ "${MONGODB_DOCKER:-0}" == "1" ]] && command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo "MongoDB Stable release gate"
|
||||
echo "repository: ${REPO_ROOT}"
|
||||
|
||||
# --- Always-on lanes -------------------------------------------------------------------------
|
||||
# Static analysis, architecture boundaries, unit and hermetic contract tests. These produce the
|
||||
# mapping, transaction, migration, change-stream and security evidence that does not need a server.
|
||||
run_lane "static-analysis" "check" -x "mongoStableContractTest"
|
||||
run_lane "mapping+transaction+migration+change-stream+security" "mongoStableContractTest"
|
||||
|
||||
# --- Container lanes -------------------------------------------------------------------------
|
||||
# A lane that needs Docker inside `check` teaches people to skip `check`, so these are opt-in --
|
||||
# but opting out is recorded, not silent.
|
||||
if docker_available; then
|
||||
run_lane "compatibility" "mongoCompatibilityTest"
|
||||
run_lane "migration" "mongoMigrationTest"
|
||||
run_lane "security" "mongoSecurityIntegrationTest"
|
||||
run_lane "failover" "mongoReplicaSetTest"
|
||||
run_lane "failover" "mongoFailoverTest"
|
||||
run_lane "performance" "mongoPerformanceTest"
|
||||
else
|
||||
reason="MONGODB_DOCKER!=1 or Docker unavailable"
|
||||
skip_lane "compatibility" "mongoCompatibilityTest" "${reason}"
|
||||
skip_lane "migration" "mongoMigrationTest" "${reason}"
|
||||
skip_lane "security" "mongoSecurityIntegrationTest" "${reason}"
|
||||
skip_lane "failover" "mongoReplicaSetTest" "${reason}"
|
||||
skip_lane "failover" "mongoFailoverTest" "${reason}"
|
||||
skip_lane "performance" "mongoPerformanceTest" "${reason}"
|
||||
fi
|
||||
|
||||
# --- Architecture-wide gates -----------------------------------------------------------------
|
||||
echo ""
|
||||
echo "=== [architecture] repository-wide verification"
|
||||
if (cd "${GRADLE_DIR}" \
|
||||
&& "${GRADLE[@]}" verifyCleanArchitectureDependencies \
|
||||
&& "${GRADLE[@]}" :app-bootstrap:test --tests '*CleanArchitectureTest'); then
|
||||
RAN+=("architecture:repository-wide")
|
||||
else
|
||||
FAILED+=("architecture:repository-wide")
|
||||
fi
|
||||
|
||||
# --- Report ------------------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "---------------------------------------------------------------"
|
||||
echo "ran: ${#RAN[@]}"
|
||||
for entry in "${RAN[@]:-}"; do [[ -n "${entry}" ]] && echo " + ${entry}"; done
|
||||
echo "skipped: ${#SKIPPED[@]}"
|
||||
for entry in "${SKIPPED[@]:-}"; do [[ -n "${entry}" ]] && echo " ~ ${entry}"; done
|
||||
echo "failed: ${#FAILED[@]}"
|
||||
for entry in "${FAILED[@]:-}"; do [[ -n "${entry}" ]] && echo " - ${entry}"; done
|
||||
echo "---------------------------------------------------------------"
|
||||
|
||||
if (( ${#FAILED[@]} > 0 )); then
|
||||
echo "STABLE GATE: FAILED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( ${#SKIPPED[@]} > 0 )); then
|
||||
echo "STABLE GATE: INCOMPLETE -- lanes above were not run, so their evidence categories are absent."
|
||||
echo "A release requires every category. Re-run with MONGODB_DOCKER=1 on a host with Docker."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "STABLE GATE: PASSED -- every evidence category produced."
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user