diff --git a/docs/adr/ADR-MONGO-001-platform-boundary.md b/docs/adr/ADR-MONGO-001-platform-boundary.md new file mode 100644 index 00000000..1a4b496e --- /dev/null +++ b/docs/adr/ADR-MONGO-001-platform-boundary.md @@ -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` 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`.** 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). diff --git a/docs/adr/ADR-MONGO-002-bson-representation.md b/docs/adr/ADR-MONGO-002-bson-representation.md new file mode 100644 index 00000000..40f9f443 --- /dev/null +++ b/docs/adr/ADR-MONGO-002-bson-representation.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. diff --git a/docs/adr/ADR-MONGO-003-transaction-retry.md b/docs/adr/ADR-MONGO-003-transaction-retry.md new file mode 100644 index 00000000..d5501285 --- /dev/null +++ b/docs/adr/ADR-MONGO-003-transaction-retry.md @@ -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. diff --git a/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md b/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md new file mode 100644 index 00000000..f39fffa8 --- /dev/null +++ b/docs/adr/ADR-MONGO-004-index-schema-admin-plane.md @@ -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. diff --git a/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md b/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md new file mode 100644 index 00000000..c8de0a0f --- /dev/null +++ b/docs/adr/ADR-MONGO-ADV-001-capability-promotion.md @@ -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 +``` diff --git a/docs/mongodb/advanced/encryption.md b/docs/mongodb/advanced/encryption.md new file mode 100644 index 00000000..a1885890 --- /dev/null +++ b/docs/mongodb/advanced/encryption.md @@ -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. diff --git a/docs/mongodb/advanced/gridfs-migration.md b/docs/mongodb/advanced/gridfs-migration.md new file mode 100644 index 00000000..6cbf32ec --- /dev/null +++ b/docs/mongodb/advanced/gridfs-migration.md @@ -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. diff --git a/docs/mongodb/advanced/multi-tenancy.md b/docs/mongodb/advanced/multi-tenancy.md new file mode 100644 index 00000000..5064a1f2 --- /dev/null +++ b/docs/mongodb/advanced/multi-tenancy.md @@ -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` 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. diff --git a/docs/mongodb/advanced/search-vector.md b/docs/mongodb/advanced/search-vector.md new file mode 100644 index 00000000..4f61294b --- /dev/null +++ b/docs/mongodb/advanced/search-vector.md @@ -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. diff --git a/docs/mongodb/advanced/sharding.md b/docs/mongodb/advanced/sharding.md new file mode 100644 index 00000000..4e2f8f1a --- /dev/null +++ b/docs/mongodb/advanced/sharding.md @@ -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. diff --git a/docs/mongodb/advanced/time-series.md b/docs/mongodb/advanced/time-series.md new file mode 100644 index 00000000..7228da3f --- /dev/null +++ b/docs/mongodb/advanced/time-series.md @@ -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. diff --git a/docs/mongodb/bson-mapping-guide.md b/docs/mongodb/bson-mapping-guide.md new file mode 100644 index 00000000..ffccb583 --- /dev/null +++ b/docs/mongodb/bson-mapping-guide.md @@ -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. diff --git a/docs/mongodb/change-stream-guide.md b/docs/mongodb/change-stream-guide.md new file mode 100644 index 00000000..f92ba7c1 --- /dev/null +++ b/docs/mongodb/change-stream-guide.md @@ -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. diff --git a/docs/mongodb/consistency-transaction-guide.md b/docs/mongodb/consistency-transaction-guide.md new file mode 100644 index 00000000..06ff6c9e --- /dev/null +++ b/docs/mongodb/consistency-transaction-guide.md @@ -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. diff --git a/docs/mongodb/document-modeling-guide.md b/docs/mongodb/document-modeling-guide.md new file mode 100644 index 00000000..492744f4 --- /dev/null +++ b/docs/mongodb/document-modeling-guide.md @@ -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` 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. diff --git a/docs/mongodb/query-aggregation-guide.md b/docs/mongodb/query-aggregation-guide.md new file mode 100644 index 00000000..812e0309 --- /dev/null +++ b/docs/mongodb/query-aggregation-guide.md @@ -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)). diff --git a/docs/mongodb/repository-adaptation.md b/docs/mongodb/repository-adaptation.md new file mode 100644 index 00000000..2d8b1609 --- /dev/null +++ b/docs/mongodb/repository-adaptation.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`. diff --git a/docs/mongodb/runbooks/failover.md b/docs/mongodb/runbooks/failover.md new file mode 100644 index 00000000..3f9df62c --- /dev/null +++ b/docs/mongodb/runbooks/failover.md @@ -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. diff --git a/docs/mongodb/runbooks/history-lost.md b/docs/mongodb/runbooks/history-lost.md new file mode 100644 index 00000000..b24cd70f --- /dev/null +++ b/docs/mongodb/runbooks/history-lost.md @@ -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. diff --git a/docs/mongodb/runbooks/unknown-commit.md b/docs/mongodb/runbooks/unknown-commit.md new file mode 100644 index 00000000..164b1576 --- /dev/null +++ b/docs/mongodb/runbooks/unknown-commit.md @@ -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. diff --git a/docs/mongodb/schema-index-migration-guide.md b/docs/mongodb/schema-index-migration-guide.md new file mode 100644 index 00000000..bf6913d6 --- /dev/null +++ b/docs/mongodb/schema-index-migration-guide.md @@ -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. diff --git a/docs/mongodb/security-observability.md b/docs/mongodb/security-observability.md new file mode 100644 index 00000000..1adb4c63 --- /dev/null +++ b/docs/mongodb/security-observability.md @@ -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 `` — 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. diff --git a/docs/mongodb/support-matrix.md b/docs/mongodb/support-matrix.md new file mode 100644 index 00000000..3398f366 --- /dev/null +++ b/docs/mongodb/support-matrix.md @@ -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`. +- 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..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. diff --git a/scripts/verify-mongodb-advanced.sh b/scripts/verify-mongodb-advanced.sh new file mode 100755 index 00000000..39abdc39 --- /dev/null +++ b/scripts/verify-mongodb-advanced.sh @@ -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 "---------------------------------------------------------------" diff --git a/scripts/verify-mongodb-platform.sh b/scripts/verify-mongodb-platform.sh new file mode 100755 index 00000000..f51255fc --- /dev/null +++ b/scripts/verify-mongodb-platform.sh @@ -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/.*]* 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." diff --git a/src/adapter/outbound/persistence-mongo/CLAUDE.md b/src/adapter/outbound/persistence-mongo/CLAUDE.md index c168845c..2a280f84 100644 --- a/src/adapter/outbound/persistence-mongo/CLAUDE.md +++ b/src/adapter/outbound/persistence-mongo/CLAUDE.md @@ -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). diff --git a/src/adapter/outbound/persistence-mongo/README.md b/src/adapter/outbound/persistence-mongo/README.md index cf66263f..16194cc4 100644 --- a/src/adapter/outbound/persistence-mongo/README.md +++ b/src/adapter/outbound/persistence-mongo/README.md @@ -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) diff --git a/src/adapter/outbound/persistence-mongo/build.gradle b/src/adapter/outbound/persistence-mongo/build.gradle index 81e07670..db23bb05 100644 --- a/src/adapter/outbound/persistence-mongo/build.gradle +++ b/src/adapter/outbound/persistence-mongo/build.gradle @@ -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 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 } } diff --git a/src/adapter/outbound/persistence-mongo/gradle.lockfile b/src/adapter/outbound/persistence-mongo/gradle.lockfile index e98141e9..2962c293 100644 --- a/src/adapter/outbound/persistence-mongo/gradle.lockfile +++ b/src/adapter/outbound/persistence-mongo/gradle.lockfile @@ -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= diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityFlags.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityFlags.java new file mode 100644 index 00000000..09730789 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedCapabilityFlags.java @@ -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). + * + *

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. + * + *

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 enabled; + + private MongoAdvancedCapabilityFlags(Map 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 flags) { + Objects.requireNonNull(flags, "flags"); + return new MongoAdvancedCapabilityFlags(new EnumMap<>(flags)); + } + + /** Returns a copy with one capability enabled. */ + public MongoAdvancedCapabilityFlags withEnabled(MongoCapability capability) { + Map 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"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionEvidence.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionEvidence.java new file mode 100644 index 00000000..1042d08e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionEvidence.java @@ -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). + * + *

{@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 requiredCategories, Set supplied) { + + /** The categories every promotion must supply. */ + public static final Set 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 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 missing() { + Set missing = new LinkedHashSet<>(requiredCategories); + missing.removeAll(supplied); + return Set.copyOf(missing); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGate.java new file mode 100644 index 00000000..104b4a1e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGate.java @@ -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). + * + *

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. + * + *

Always false; that needs its own ADR. + */ + public boolean addsStarterDependency() { + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeCheckpointPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeCheckpointPolicy.java new file mode 100644 index 00000000..879181c1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeCheckpointPolicy.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.bridge; + +/** + * When the bridge may advance its MongoDB checkpoint (advanced plan Task 12). + * + *

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. + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeOutboxPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeOutboxPolicy.java new file mode 100644 index 00000000..50563d09 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoBridgeOutboxPolicy.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.bridge; + +/** + * When a change stream bridge is not enough (advanced plan Task 12). + * + *

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. + * + *

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. + * + *

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. + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridge.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridge.java new file mode 100644 index 00000000..45748732 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridge.java @@ -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). + * + *

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. + * + *

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 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 advanceIfAllowed( + MongoResumeCheckpoint checkpoint, boolean published, boolean ambiguous) { + return checkpointPolicy.mayAdvance(published, ambiguous) + ? checkpoints.save(checkpoint) + : Mono.empty(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeToIntegrationEventMapper.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeToIntegrationEventMapper.java new file mode 100644 index 00000000..6e01fc62 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeToIntegrationEventMapper.java @@ -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). + * + *

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. + * + *

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); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventEnvelope.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventEnvelope.java new file mode 100644 index 00000000..d02e9b22 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventEnvelope.java @@ -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). + * + *

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. + * + *

{@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 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"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventPublisher.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventPublisher.java new file mode 100644 index 00000000..25c59c48 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoIntegrationEventPublisher.java @@ -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). + * + *

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 publish(MongoIntegrationEventEnvelope envelope); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleClientFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleClientFactory.java new file mode 100644 index 00000000..a036f2a2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleClientFactory.java @@ -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). + * + *

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> kmsProviders, + String encryptedFieldsMapJson) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(kmsProviders, "kmsProviders"); + Objects.requireNonNull(encryptedFieldsMapJson, "encryptedFieldsMapJson"); + + Map 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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleFieldPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleFieldPolicy.java new file mode 100644 index 00000000..16d32b92 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleFieldPolicy.java @@ -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). + * + *

{@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('.', '-'); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleMode.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleMode.java new file mode 100644 index 00000000..6ffcec5b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleMode.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle; + +/** + * How a CSFLE field is encrypted (advanced plan Task 6). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleProfile.java new file mode 100644 index 00000000..32d83861 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoCsfleProfile.java @@ -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). + * + *

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. + * + *

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 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 queryableFields() { + return fields.stream().filter(field -> field.mode().supportsEqualityQuery()).toList(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoDataKeyResolver.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoDataKeyResolver.java new file mode 100644 index 00000000..47de1e2b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/csfle/MongoDataKeyResolver.java @@ -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). + * + *

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. + * + *

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 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(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptedFieldDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptedFieldDescriptor.java new file mode 100644 index 00000000..51ccb6a7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptedFieldDescriptor.java @@ -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). + * + *

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 domain() { + return rangeMinimum == null || rangeMaximum == null + ? Optional.empty() + : Optional.of(new long[] {rangeMinimum, rangeMaximum}); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptionMetadataOwnership.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptionMetadataOwnership.java new file mode 100644 index 00000000..9ec19ac9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoEncryptionMetadataOwnership.java @@ -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). + * + *

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 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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java new file mode 100644 index 00000000..dfab5304 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionCollectionManager.java @@ -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). + * + *

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 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 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 apply) { + adminGateway.execute(MongoAdminOperation.COLL_MOD, collection, operator, reason, apply); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionProfile.java new file mode 100644 index 00000000..b553120f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionProfile.java @@ -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). + * + *

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 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 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())); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionQueryType.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionQueryType.java new file mode 100644 index 00000000..436fc308 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/qe/MongoQueryableEncryptionQueryType.java @@ -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). + * + *

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 +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsCompatibilityReader.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsCompatibilityReader.java new file mode 100644 index 00000000..7c301a09 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsCompatibilityReader.java @@ -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). + * + *

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) {} +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationCheckpoint.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationCheckpoint.java new file mode 100644 index 00000000..27393c5e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationCheckpoint.java @@ -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). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJob.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJob.java new file mode 100644 index 00000000..4ddcdc57 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJob.java @@ -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). + * + *

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. + * + *

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 referenceWriter; + + private final Clock clock; + + public MongoGridFsMigrationJob( + MongoGridFsCompatibilityReader reader, + BiFunction< + String, + MongoGridFsCompatibilityReader.GridFsLegacyContent, + MongoGridFsObjectReference> + contentStoreWriter, + Consumer 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 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()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsObjectReference.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsObjectReference.java new file mode 100644 index 00000000..58dec8cf --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsObjectReference.java @@ -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). + * + *

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); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexDescriptor.java new file mode 100644 index 00000000..3f201c08 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexDescriptor.java @@ -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). + * + *

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 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 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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexState.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexState.java new file mode 100644 index 00000000..3b4f8250 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchIndexState.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.search; + +/** + * The lifecycle of a search or vector index (advanced plan Task 8). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchOperations.java new file mode 100644 index 00000000..9cefa806 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchOperations.java @@ -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). + * + *

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. */ + List search(MongoOperationContext context, MongoSearchQuery query, Class documentType); + + /** The current state of a search index. */ + MongoSearchIndexState indexState(String indexName); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchQuery.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchQuery.java new file mode 100644 index 00000000..f371ff53 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchQuery.java @@ -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). + * + *

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 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 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"); + } + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGate.java new file mode 100644 index 00000000..d6d5c1af --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGate.java @@ -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). + * + *

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(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/MongoRoutingClassification.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/MongoRoutingClassification.java new file mode 100644 index 00000000..77525497 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/MongoRoutingClassification.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.sharding; + +/** + * How many shards an operation will reach (advanced plan Task 2). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidator.java new file mode 100644 index 00000000..092fff63 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidator.java @@ -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). + * + *

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. + * + *

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 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 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 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 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"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyDescriptor.java new file mode 100644 index 00000000..40ba3eae --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyDescriptor.java @@ -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). + * + *

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 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 fields() { + return parts.stream().map(ShardKeyPart::field).toList(); + } + + /** True when the given fields include the complete shard key. */ + public boolean isFullyCovered(java.util.Set 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 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. + * + *

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 indexFields) { + Objects.requireNonNull(indexFields, "indexFields"); + List shardFields = fields(); + if (indexFields.size() < shardFields.size()) { + return false; + } + return indexFields.subList(0, shardFields.size()).equals(shardFields); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyPart.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyPart.java new file mode 100644 index 00000000..d7772f94 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardKeyPart.java @@ -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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardStrategy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardStrategy.java new file mode 100644 index 00000000..4367c5bd --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardStrategy.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.sharding; + +/** + * How a shard key distributes documents (advanced plan Task 2). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/MongoShardingAdminGateway.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/MongoShardingAdminGateway.java new file mode 100644 index 00000000..a4824ad2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/MongoShardingAdminGateway.java @@ -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). + * + *

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 supportingIndexFields, + String operator, + String reason, + Supplier 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 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 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 apply) { + adminGateway.execute(MongoAdminOperation.BALANCER_CONTROL, scope, operator, reason, apply); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ReshardApproval.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ReshardApproval.java new file mode 100644 index 00000000..c061609d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ReshardApproval.java @@ -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). + * + *

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"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzer.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzer.java new file mode 100644 index 00000000..8f2e5ebc --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzer.java @@ -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). + * + *

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. + * + *

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 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); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyReadinessReport.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyReadinessReport.java new file mode 100644 index 00000000..46cb8bf0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyReadinessReport.java @@ -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). + * + *

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 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 reasons, double monotonicity, double readTargeting, double writeTargeting) { + return new ShardKeyReadinessReport(false, reasons, monotonicity, readTargeting, writeTargeting); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantClientRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantClientRegistry.java new file mode 100644 index 00000000..84aeb1ca --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantClientRegistry.java @@ -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). + * + *

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 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 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)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantDatabaseResolver.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantDatabaseResolver.java new file mode 100644 index 00000000..c1abb429 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantDatabaseResolver.java @@ -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). + * + *

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); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantLifecyclePolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantLifecyclePolicy.java new file mode 100644 index 00000000..395a950d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantLifecyclePolicy.java @@ -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). + * + *

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. + * + *

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"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantMigrationCoordinator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantMigrationCoordinator.java new file mode 100644 index 00000000..002bcc32 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/database/MongoTenantMigrationCoordinator.java @@ -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). + * + *

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. + * + *

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 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 nextBatch(List allTenants, List 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 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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantContext.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantContext.java new file mode 100644 index 00000000..68457ed1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantContext.java @@ -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). + * + *

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); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantManifestValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantManifestValidator.java new file mode 100644 index 00000000..eeab9d5c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantManifestValidator.java @@ -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). + * + *

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 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 keys = index.keys(); + return !keys.isEmpty() && keys.get(0).field().equals(MongoTenantContext.tenantField()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantPredicateInjector.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantPredicateInjector.java new file mode 100644 index 00000000..7c8fe18d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/MongoTenantPredicateInjector.java @@ -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). + * + *

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. + * + *

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 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 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 tenant) { + MongoTenantContext context = require(tenant); + return Criteria.where(MongoTenantContext.tenantField()).is(context.storedValue()); + } + + private static MongoTenantContext require(Optional 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")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/TenantScopedMongoOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/TenantScopedMongoOperations.java new file mode 100644 index 00000000..19c752c2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/shared/TenantScopedMongoOperations.java @@ -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). + * + *

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. */ + List find( + MongoOperationContext context, + Optional tenant, + Query query, + Class documentType); + + /** Updates one of the tenant's documents. */ + AtomicUpdateResult updateOne( + MongoOperationContext context, + Optional tenant, + AtomicFilter filter, + AtomicUpdate update, + Class documentType); + + /** Deletes one of the tenant's documents. */ + long deleteOne( + MongoOperationContext context, Optional tenant, AtomicFilter filter); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java new file mode 100644 index 00000000..1f5860ac --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidator.java @@ -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). + * + *

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."); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesDescriptor.java new file mode 100644 index 00000000..edc4892d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesDescriptor.java @@ -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). + * + *

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 meta() { + return Optional.ofNullable(metaField).filter(field -> !field.isBlank()); + } + + /** + * The retention window, when one is declared. + * + *

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 retentionWindow() { + return Optional.ofNullable(retention); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesGranularity.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesGranularity.java new file mode 100644 index 00000000..e89ca8f9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesGranularity.java @@ -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). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesOperations.java new file mode 100644 index 00000000..032e5eaf --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesOperations.java @@ -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). + * + *

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. */ + void insertAll(MongoOperationContext context, List measurements, Class measurementType); + + /** Reads measurements in a bounded time range, which is the query shape buckets are built for. */ + List findInRange( + MongoOperationContext context, + Instant from, + Instant to, + Class measurementType, + int resultLimit); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoEmbedding.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoEmbedding.java new file mode 100644 index 00000000..7dd68384 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoEmbedding.java @@ -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). + * + *

{@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 + "]"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorIndexDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorIndexDescriptor.java new file mode 100644 index 00000000..efde94ad --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorIndexDescriptor.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.vector; + +import java.util.Objects; + +/** + * A vector index declaration (advanced plan Task 9). + * + *

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 + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorQuery.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorQuery.java new file mode 100644 index 00000000..8f8a9623 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorQuery.java @@ -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). + * + *

{@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 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)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchBenchmarkGate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchBenchmarkGate.java new file mode 100644 index 00000000..a8677d6e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchBenchmarkGate.java @@ -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). + * + *

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 failures( + double measuredRecall, long measuredP99Millis, long measuredIndexMemoryBytes) { + Set 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 requiredEvidence() { + return Objects.requireNonNull( + Set.of("index-readiness", "recall", "latency", "memory", "actual-topology")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchOperations.java new file mode 100644 index 00000000..25ab27d6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/advanced/vector/MongoVectorSearchOperations.java @@ -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). + * + *

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. */ + List> search( + MongoOperationContext context, MongoVectorQuery query, Class documentType); + + /** The current state of a vector index. */ + MongoSearchIndexState indexState(String indexName); + + /** + * One result and its interpreted score. + * + * @param the document type + */ + record MongoVectorHit(T document, double normalizedScore, String scoreContract) {} +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationPlan.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationPlan.java new file mode 100644 index 00000000..bd1a00fe --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationPlan.java @@ -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). + * + *

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 operations, + List stages, + Set 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 operations = new ArrayList<>(); + + private final List stages = new ArrayList<>(); + + private final Set 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); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationProfile.java new file mode 100644 index 00000000..0d418661 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationProfile.java @@ -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). + * + *

{@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 allowedRisks, + Set reviewedStages, + Set 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 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 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); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationRisk.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationRisk.java new file mode 100644 index 00000000..77dfb812 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationRisk.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.aggregation; + +/** + * The resource class of one aggregation stage (design §17). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationStageDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationStageDescriptor.java new file mode 100644 index 00000000..e3943a28 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/MongoAggregationStageDescriptor.java @@ -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). + * + *

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 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. + * + *

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(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutor.java new file mode 100644 index 00000000..8ed663b7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutor.java @@ -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). + * + *

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. + * + *

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 List execute( + MongoOperationContext context, + MongoAggregationProfile profile, + MongoAggregationPlan plan, + String collection, + Class 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 results = operations.aggregate(aggregation, collection, outputType); + List 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(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/CollectionProfileName.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/CollectionProfileName.java new file mode 100644 index 00000000..f0ae3b24 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/CollectionProfileName.java @@ -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). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/DatabaseProfileName.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/DatabaseProfileName.java new file mode 100644 index 00000000..2c592ead --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/DatabaseProfileName.java @@ -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). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContext.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContext.java new file mode 100644 index 00000000..bb88bf4e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContext.java @@ -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). + * + *

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. + * + *

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); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationName.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationName.java new file mode 100644 index 00000000..fa9d8bd5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationName.java @@ -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). + * + *

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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationScope.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationScope.java new file mode 100644 index 00000000..812c4b90 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationScope.java @@ -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). + * + *

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()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationType.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationType.java new file mode 100644 index 00000000..c420c880 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationType.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.mongo.api; + +/** + * The kind of MongoDB operation, as a bounded telemetry dimension (design §15, §27). + * + *

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 +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapability.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapability.java new file mode 100644 index 00000000..cbacf4c7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapability.java @@ -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). + * + *

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 +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySet.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySet.java new file mode 100644 index 00000000..de5a25b8 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySet.java @@ -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). + * + *

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 supports; + + private MongoCapabilitySet(Map 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 declarations) { + Objects.requireNonNull(declarations, "declarations"); + Map 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}. + * + *

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 declared() { + return Map.copyOf(supports); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySupport.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySupport.java new file mode 100644 index 00000000..57c1cc0b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySupport.java @@ -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). + * + *

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 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 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; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoSupportLevel.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoSupportLevel.java new file mode 100644 index 00000000..5b9f4607 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoSupportLevel.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.api.capability; + +/** + * Support tier of one MongoDB capability (design §7.4). + * + *

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 +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyDescriptor.java new file mode 100644 index 00000000..7a16a63c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyDescriptor.java @@ -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). + * + *

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(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyGuarantee.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyGuarantee.java new file mode 100644 index 00000000..662267c3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyGuarantee.java @@ -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). + * + *

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"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyProfile.java new file mode 100644 index 00000000..99acdd41 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyProfile.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.api.consistency; + +/** + * Named read/write guarantee a caller asks for (design §7.2). + * + *

These are profiles rather than three independent knobs because read preference, read concern + * and write concern only mean something together. A caller that picks {@code majority} write + * concern and {@code secondaryPreferred} reads has not chosen durability, it has chosen a bug. + * + *

{@link #STALE_READ_ALLOWED} is named for its risk on purpose: the design forbids deriving + * secondary routing from a {@code readOnly=true} annotation, because that turns an optimisation + * hint into a silent correctness change. + */ +public enum MongoConsistencyProfile { + + /** primary / local / acknowledged — ordinary low-latency work. */ + PRIMARY_LOCAL, + + /** primary / majority / majority — rollback resistance and durability. */ + PRIMARY_MAJORITY, + + /** primary / majority / majority inside a causal session — read-your-writes. */ + CAUSAL_MAJORITY, + + /** secondaryPreferred — explicitly accepts stale data. Never a default. */ + STALE_READ_ALLOWED, + + /** primary / snapshot / majority — multi-document snapshot transactions. */ + SNAPSHOT_TRANSACTION, + + /** primary, profile-defined concerns — short write transactions. */ + MONGO_SHORT_WRITE +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistry.java new file mode 100644 index 00000000..0186be39 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistry.java @@ -0,0 +1,164 @@ +package dev.caskeleton.adapter.outbound.mongo.api.consistency; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +/** + * The closed registry of consistency profiles (design §7.2). + * + *

A deployment cannot invent a profile name. Every profile in the enum must be described here or + * {@link #standard()} fails to build, which turns "we added an enum constant and forgot its + * semantics" into a startup failure rather than a runtime surprise. + */ +public final class MongoConsistencyRegistry { + + private final Map descriptors; + private final MongoConsistencyProfile defaultProfile; + + private MongoConsistencyRegistry( + Map descriptors, + MongoConsistencyProfile defaultProfile) { + this.descriptors = descriptors; + this.defaultProfile = defaultProfile; + } + + /** The platform's standard registry, with {@code PRIMARY_MAJORITY} as the default. */ + public static MongoConsistencyRegistry standard() { + Map byProfile = + new EnumMap<>(MongoConsistencyProfile.class); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.PRIMARY_LOCAL, + MongoConsistencyDescriptor.PRIMARY, + "local", + "acknowledged", + false, + new MongoConsistencyGuarantee( + "Reads the primary's latest data and acknowledges writes without waiting for " + + "replication; a primary failover can roll an acknowledged write back.", + false, + true, + false))); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.PRIMARY_MAJORITY, + MongoConsistencyDescriptor.PRIMARY, + "majority", + "majority", + false, + new MongoConsistencyGuarantee( + "Reads and writes majority-committed data, so an acknowledged write survives a " + + "primary failover.", + true, + true, + false))); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.CAUSAL_MAJORITY, + MongoConsistencyDescriptor.PRIMARY, + "majority", + "majority", + true, + new MongoConsistencyGuarantee( + "Adds a causally consistent session on top of majority concerns, so a later read " + + "in the same session observes this session's earlier writes.", + true, + true, + false))); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.STALE_READ_ALLOWED, + MongoConsistencyDescriptor.SECONDARY_PREFERRED, + "local", + "acknowledged", + false, + new MongoConsistencyGuarantee( + "Explicitly accepts arbitrarily stale data from a secondary; never derived from a " + + "read-only annotation.", + false, + false, + true))); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.SNAPSHOT_TRANSACTION, + MongoConsistencyDescriptor.PRIMARY, + "snapshot", + "majority", + false, + new MongoConsistencyGuarantee( + "Reads a single majority-committed snapshot for the whole transaction, so a " + + "multi-document invariant sees one consistent point in time.", + true, + true, + false))); + put( + byProfile, + new MongoConsistencyDescriptor( + MongoConsistencyProfile.MONGO_SHORT_WRITE, + MongoConsistencyDescriptor.PRIMARY, + "majority", + "majority", + false, + new MongoConsistencyGuarantee( + "A deliberately short write transaction on majority concerns; the profile exists to " + + "bound transaction duration, not to weaken durability.", + true, + true, + false))); + requireComplete(byProfile); + return new MongoConsistencyRegistry(byProfile, MongoConsistencyProfile.PRIMARY_MAJORITY); + } + + /** Builds a registry from explicit descriptors; used by tests and by profile overrides. */ + public static MongoConsistencyRegistry of( + Collection declarations, MongoConsistencyProfile defaultProfile) { + Objects.requireNonNull(declarations, "declarations"); + Objects.requireNonNull(defaultProfile, "defaultProfile"); + Map byProfile = + new EnumMap<>(MongoConsistencyProfile.class); + declarations.forEach(declaration -> put(byProfile, declaration)); + requireComplete(byProfile); + if (defaultProfile == MongoConsistencyProfile.STALE_READ_ALLOWED) { + throw new IllegalArgumentException("stale reads must never be the default consistency"); + } + return new MongoConsistencyRegistry(byProfile, defaultProfile); + } + + private static void put( + Map target, + MongoConsistencyDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); + target.put(descriptor.profile(), descriptor); + } + + private static void requireComplete( + Map byProfile) { + for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) { + if (!byProfile.containsKey(profile)) { + throw new IllegalStateException("consistency profile is not described: " + profile); + } + } + } + + /** The descriptor for a profile; every enum constant is always present. */ + public MongoConsistencyDescriptor require(MongoConsistencyProfile profile) { + Objects.requireNonNull(profile, "profile"); + MongoConsistencyDescriptor descriptor = descriptors.get(profile); + if (descriptor == null) { + throw new IllegalStateException("unknown MongoDB consistency profile: " + profile); + } + return descriptor; + } + + /** The profile applied when an operation does not name one. Never the stale-read profile. */ + public MongoConsistencyProfile defaultProfile() { + return defaultProfile; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoBulkPartialFailureException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoBulkPartialFailureException.java new file mode 100644 index 00000000..effc3aa8 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoBulkPartialFailureException.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A bulk write partially succeeded (design §19.2). + * + *

A first-class type rather than a generic write failure, because the recovery is the opposite + * of the usual one: the successful items must not be re-run. The counts here are what a + * caller needs to decide that; the per-item detail stays in the bulk result, which the caller + * already holds. + */ +public final class MongoBulkPartialFailureException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + private final int requested; + + private final int succeeded; + + private final int failed; + + public MongoBulkPartialFailureException( + MongoFailureContext failureContext, int requested, int succeeded, int failed) { + super("the bulk write partially succeeded; do not re-run the successful items", failureContext); + this.requested = requested; + this.succeeded = succeeded; + this.failed = failed; + } + + /** How many operations the caller submitted. */ + public int requested() { + return requested; + } + + /** How many the server confirmed. These must not be replayed. */ + public int succeeded() { + return succeeded; + } + + /** How many failed and are eligible for a targeted retry. */ + public int failed() { + return failed; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoConnectionException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoConnectionException.java new file mode 100644 index 00000000..0bbfc66d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoConnectionException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A socket or connection-pool level failure (design §15). + * + *

Whether it is safe to replay depends on when the connection broke, which is why the outcome is + * carried explicitly: a failure while connecting is {@code NOT_SENT}, a failure while awaiting the + * response to a write is {@code WRITE_RESULT_UNKNOWN}. + */ +public final class MongoConnectionException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoConnectionException(MongoFailureContext failureContext) { + super("the MongoDB connection failed", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoCursorException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoCursorException.java new file mode 100644 index 00000000..61a10309 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoCursorException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A cursor was killed, expired or is otherwise unusable (design §15, §19.1). + * + *

Streaming reads never transparently retry once the first document has been emitted, because + * the consumer has already acted on a prefix and a silent restart would deliver it twice. This + * exception is how that decision is handed back to the caller. + */ +public final class MongoCursorException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoCursorException(MongoFailureContext failureContext) { + super("the MongoDB cursor is no longer usable", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDataSchemaUnsupportedException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDataSchemaUnsupportedException.java new file mode 100644 index 00000000..fd2b874a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDataSchemaUnsupportedException.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A stored document's schema version is outside the supported range (design §12.2). + * + *

Raised before domain deserialization, which is the whole value: a document written by a newer + * release, or one left behind by a retired version, must not be silently coerced into the current + * shape. Versions are integers and carry no data, so both are safe to name in the message. + */ +public final class MongoDataSchemaUnsupportedException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + private final int documentVersion; + + private final int minimumSupported; + + private final int currentVersion; + + public MongoDataSchemaUnsupportedException( + MongoFailureContext failureContext, + int documentVersion, + int minimumSupported, + int currentVersion) { + super( + "stored schema version " + + documentVersion + + " is outside the supported range [" + + minimumSupported + + ", " + + currentVersion + + "]", + failureContext); + this.documentVersion = documentVersion; + this.minimumSupported = minimumSupported; + this.currentVersion = currentVersion; + } + + /** The version found on the stored document. */ + public int documentVersion() { + return documentVersion; + } + + /** The oldest version this release can still read. */ + public int minimumSupported() { + return minimumSupported; + } + + /** The version this release writes. */ + public int currentVersion() { + return currentVersion; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDocumentTooLargeException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDocumentTooLargeException.java new file mode 100644 index 00000000..b816d765 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDocumentTooLargeException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A document exceeded the size budget (design §9.2, §15). + * + *

The platform's ceiling is below MongoDB's 16 MiB limit on purpose, so this exception normally + * fires against the project budget rather than the server limit. That difference is the point: an + * unbounded embedded array is a modelling defect, and catching it at the budget leaves room to fix + * the model before the server starts rejecting writes. + */ +public final class MongoDocumentTooLargeException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + private final long estimatedBytes; + + private final long budgetBytes; + + public MongoDocumentTooLargeException( + MongoFailureContext failureContext, long estimatedBytes, long budgetBytes) { + super("the document exceeds the configured BSON size budget", failureContext); + this.estimatedBytes = estimatedBytes; + this.budgetBytes = budgetBytes; + } + + /** Estimated serialized size. A size, not content: safe to log. */ + public long estimatedBytes() { + return estimatedBytes; + } + + /** The configured ceiling that was exceeded. */ + public long budgetBytes() { + return budgetBytes; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDuplicateKeyException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDuplicateKeyException.java new file mode 100644 index 00000000..5444197e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoDuplicateKeyException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A unique index rejected the write (design §15). + * + *

The duplicated value is deliberately absent: it is business data, and for a competing-create + * flow it is usually the natural key the caller already holds. The index name is not carried + * either, because it appears verbatim in the driver message and would reintroduce collection-shaped + * detail into telemetry. + */ +public final class MongoDuplicateKeyException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoDuplicateKeyException(MongoFailureContext failureContext) { + super( + "MongoDB rejected the write because a unique index already holds that key", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoEncryptionException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoEncryptionException.java new file mode 100644 index 00000000..d92d6e88 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoEncryptionException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * An encryption, key vault or KMS operation failed (design §23). + * + *

The strictest exception in the hierarchy for what it may carry: no plaintext, no ciphertext, + * no key material, no key alias. An operator diagnoses these from the KMS audit trail and the + * operation name, never from the application's exception message. + */ +public final class MongoEncryptionException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoEncryptionException(MongoFailureContext failureContext) { + super("a MongoDB encryption operation failed", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoExecutionOutcome.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoExecutionOutcome.java new file mode 100644 index 00000000..4a1f3c9f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoExecutionOutcome.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +/** + * What actually happened to a write, including the cases where nobody knows (design §7.3). + * + *

The two ambiguous outcomes are the reason this enum exists instead of a boolean. {@code + * WRITE_RESULT_UNKNOWN} and {@code TRANSACTION_COMMIT_UNKNOWN} are not failures: the write may well + * have been applied. Re-running the business body on either of them is how duplicate orders get + * created, so recovery reads the version, unique key, idempotency record or transaction record + * instead. + */ +public enum MongoExecutionOutcome { + + /** + * The command never left the client: budget rejection, validation failure, no server selected. + */ + NOT_SENT, + + /** The server processed the command and matched nothing, so no document changed. */ + NO_WRITE_PERFORMED, + + /** The server acknowledged the write at the requested write concern. */ + WRITE_CONFIRMED, + + /** Some bulk items succeeded and some failed; the successful ones must not be re-run. */ + PARTIAL_BULK_WRITE, + + /** The write may or may not have been applied; the response was lost. */ + WRITE_RESULT_UNKNOWN, + + /** The commit may or may not have succeeded; only the commit may be retried. */ + TRANSACTION_COMMIT_UNKNOWN; + + /** True when the caller cannot conclude whether data changed. */ + public boolean isAmbiguous() { + return this == WRITE_RESULT_UNKNOWN || this == TRANSACTION_COMMIT_UNKNOWN; + } + + /** True when re-running the same business body would risk a duplicate effect. */ + public boolean forbidsBlindReplay() { + return isAmbiguous() || this == PARTIAL_BULK_WRITE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureCategory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureCategory.java new file mode 100644 index 00000000..ddb7fe76 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureCategory.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +/** + * Stable parent category of a failure (design §15). + * + *

Server codes and driver error labels change between releases; this category does not. It is + * the value that appears in metrics and dashboards, which is why an unrecognised server code maps + * to {@link #UNCLASSIFIED} rather than inventing a new category at runtime and blowing up metric + * cardinality. + */ +public enum MongoFailureCategory { + + /** A unique index rejected the write. */ + DUPLICATE_KEY, + + /** The collection's JSON Schema validator rejected the document. */ + SCHEMA_VALIDATION, + + /** An expected-revision predicate matched nothing while the document still exists. */ + OPTIMISTIC_CONFLICT, + + /** Two concurrent writers touched the same document inside transactions. */ + WRITE_CONFLICT, + + /** The transaction can be retried in full from a new session. */ + TRANSACTION_TRANSIENT, + + /** The commit outcome is unknown; only the commit may be retried. */ + TRANSACTION_COMMIT_UNKNOWN, + + /** The write concern could not be satisfied. */ + WRITE_CONCERN, + + /** The read concern could not be satisfied. */ + READ_CONCERN, + + /** No suitable server was found within the server selection timeout. */ + SERVER_SELECTION, + + /** A socket or pool level connection failure. */ + CONNECTION, + + /** The operation exceeded its deadline. */ + TIMEOUT, + + /** A cursor was killed, expired or is otherwise unusable. */ + CURSOR, + + /** The document exceeded the BSON size limit. */ + DOCUMENT_TOO_LARGE, + + /** A bulk write partially succeeded. */ + BULK_PARTIAL_FAILURE, + + /** A sharded operation could not be routed, or was routed unacceptably. */ + SHARD_ROUTING, + + /** A change stream could not resume from its checkpoint. */ + RESUME, + + /** An encryption, key vault or KMS operation failed. */ + ENCRYPTION, + + /** The platform refused the operation locally before contacting a server. */ + OPERATION_REJECTED, + + /** The stored document's schema version is outside the supported range. */ + SCHEMA_VERSION_UNSUPPORTED, + + /** A recognised failure with no more specific stable category. */ + UNCLASSIFIED +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContext.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContext.java new file mode 100644 index 00000000..6ca027e7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContext.java @@ -0,0 +1,187 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * Everything the platform is allowed to remember about a failure (design §15). + * + *

The design lists the permitted fields exhaustively, and the list is short on purpose: the + * fields it excludes — documents, query parameters, credentials, plaintext encrypted values, resume + * tokens, shard key values — are exactly the ones that end up in an exception message, then in a + * log, then in a log aggregator that is not in the data-protection scope. Keeping them out of the + * type is stronger than keeping them out of the log statement, because there is nothing to leak. + */ +public record MongoFailureContext( + MongoOperationScope scope, + MongoOperationType operationType, + MongoConsistencyProfile consistencyProfile, + MongoFailureCategory category, + MongoExecutionOutcome outcome, + boolean retryable, + boolean ambiguous, + Set errorLabels, + String serverCode, + int attempt, + Duration elapsed, + String traceId) { + + public MongoFailureContext { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(consistencyProfile, "consistencyProfile"); + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(errorLabels, "errorLabels"); + Objects.requireNonNull(serverCode, "serverCode"); + Objects.requireNonNull(elapsed, "elapsed"); + Objects.requireNonNull(traceId, "traceId"); + errorLabels = Set.copyOf(errorLabels); + if (attempt < 1) { + throw new IllegalArgumentException("attempt must be at least 1"); + } + if (elapsed.isNegative()) { + throw new IllegalArgumentException("elapsed must not be negative"); + } + } + + /** + * The commit-unknown context of design §14.3. + * + *

{@code retryable} is false because the field means "may the business body be replayed", and + * the answer for an unknown commit is never. Commit-only retry is a separate decision made by the + * transaction retry coordinator, which is why it is not expressed as a retryable failure here. + */ + public static MongoFailureContext commitUnknown( + MongoOperationName operationName, String serverCode, Duration elapsed) { + return new MongoFailureContext( + MongoOperationScope.ofOperation(operationName), + MongoOperationType.TRANSACTION_COMMIT, + MongoConsistencyProfile.PRIMARY_MAJORITY, + MongoFailureCategory.TRANSACTION_COMMIT_UNKNOWN, + MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN, + false, + true, + Set.of("UnknownTransactionCommitResult"), + serverCode, + 1, + elapsed, + ""); + } + + /** A locally rejected operation: nothing was sent, nothing is ambiguous, nothing is retryable. */ + public static MongoFailureContext rejected(MongoOperationName operationName) { + return new MongoFailureContext( + MongoOperationScope.ofOperation(operationName), + MongoOperationType.UNKNOWN, + MongoConsistencyProfile.PRIMARY_LOCAL, + MongoFailureCategory.OPERATION_REJECTED, + MongoExecutionOutcome.NOT_SENT, + false, + false, + Set.of(), + "", + 1, + Duration.ZERO, + ""); + } + + /** + * A stored document that does not match the schema this release expects. + * + *

Nothing was written, so the outcome is {@code NO_WRITE_PERFORMED} rather than {@code + * NOT_SENT}: the read did reach the server, and what came back is unusable. + */ + public static MongoFailureContext schemaMismatch(MongoOperationName operationName) { + return new MongoFailureContext( + MongoOperationScope.ofOperation(operationName), + MongoOperationType.FIND, + MongoConsistencyProfile.PRIMARY_LOCAL, + MongoFailureCategory.SCHEMA_VALIDATION, + MongoExecutionOutcome.NO_WRITE_PERFORMED, + false, + false, + Set.of(), + "", + 1, + Duration.ZERO, + ""); + } + + /** Returns a copy carrying the trace identifier of the surrounding observation. */ + public MongoFailureContext withTraceId(String newTraceId) { + return new MongoFailureContext( + scope, + operationType, + consistencyProfile, + category, + outcome, + retryable, + ambiguous, + errorLabels, + serverCode, + attempt, + elapsed, + Objects.requireNonNull(newTraceId, "traceId")); + } + + /** Returns a copy recorded as the given attempt number. */ + public MongoFailureContext withAttempt(int newAttempt) { + return new MongoFailureContext( + scope, + operationType, + consistencyProfile, + category, + outcome, + retryable, + ambiguous, + errorLabels, + serverCode, + newAttempt, + elapsed, + traceId); + } + + /** True when the driver attached the given error label to the failure. */ + public boolean hasLabel(String label) { + return errorLabels.contains(label); + } + + /** + * A one-line, redaction-safe summary. + * + *

Every value here is already bounded and data-free, so this string is safe to put in an + * exception message without a second redaction pass. + */ + public String describe() { + return "operation=" + + scope.operationName() + + " database=" + + scope.databaseProfile() + + " collection=" + + scope.collectionProfile() + + " type=" + + operationType + + " consistency=" + + consistencyProfile + + " category=" + + category + + " outcome=" + + outcome + + " retryable=" + + retryable + + " ambiguous=" + + ambiguous + + " serverCode=" + + serverCode + + " attempt=" + + attempt + + " elapsedMs=" + + elapsed.toMillis(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOperationRejectedException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOperationRejectedException.java new file mode 100644 index 00000000..26e24e24 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOperationRejectedException.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import java.io.Serial; + +/** + * The platform refused the operation locally, before any server was contacted (design §15, §16.2). + * + *

This is the guardrail exception: unregistered field, unregistered operator, raised budget, + * write stage in a read API, deep skip, missing tenant context, admin command on a runtime client. + * It is always {@code NOT_SENT} and never retryable, because retrying a rejected request only + * repeats the rejection. + */ +public final class MongoOperationRejectedException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoOperationRejectedException(String summary, MongoFailureContext failureContext) { + super(summary, failureContext); + } + + /** Rejects an operation identified only by name, before profiles were resolved. */ + public static MongoOperationRejectedException of(String operationName, String summary) { + return new MongoOperationRejectedException( + summary, MongoFailureContext.rejected(new MongoOperationName(operationName))); + } + + /** Rejects a platform-internal policy violation that has no caller-supplied operation name. */ + public static MongoOperationRejectedException policy(String summary) { + return of("platform.policy", summary); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOptimisticConflictException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOptimisticConflictException.java new file mode 100644 index 00000000..09465ec3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoOptimisticConflictException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * An expected-revision predicate matched nothing while the document still exists (design §13.2). + * + *

Distinct from "document not found" on purpose: a conflict means someone else advanced the + * revision, and the recovery is to reload and recompute the whole use case. Recovering by re-saving + * the stale object is precisely the lost update the revision predicate exists to prevent. + */ +public final class MongoOptimisticConflictException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoOptimisticConflictException(MongoFailureContext failureContext) { + super( + "the document was modified concurrently; reload and recompute rather than resaving", + failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoPersistenceException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoPersistenceException.java new file mode 100644 index 00000000..95ad03d2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoPersistenceException.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; +import java.util.Objects; + +/** + * Root of the provider-stable MongoDB error hierarchy (design §15). + * + *

Two rules shape this class. First, the message is derived from {@link MongoFailureContext}, + * which can only hold bounded, data-free values — so no caller can accidentally put a document or a + * query parameter into an exception message. Second, no constructor accepts a {@link Throwable} + * cause: attaching the driver exception would re-expose everything the failure context deliberately + * dropped, through {@code getCause()} and through every stack trace printer. The driver's + * information survives as the error labels and server code already carried by the context. + */ +public abstract class MongoPersistenceException extends RuntimeException { + + @Serial private static final long serialVersionUID = 1L; + + private final transient MongoFailureContext failureContext; + + protected MongoPersistenceException(String summary, MongoFailureContext failureContext) { + super( + summary + " [" + Objects.requireNonNull(failureContext, "failureContext").describe() + "]"); + this.failureContext = failureContext; + } + + /** The bounded metadata for this failure. Never contains document or query data. */ + public MongoFailureContext failureContext() { + return failureContext; + } + + /** Stable parent category for metrics and dashboards. */ + public MongoFailureCategory category() { + return failureContext.category(); + } + + /** What the write actually did, including the two ambiguous outcomes. */ + public MongoExecutionOutcome outcome() { + return failureContext.outcome(); + } + + /** True when replaying the same business body is safe. */ + public boolean retryable() { + return failureContext.retryable(); + } + + /** True when the caller cannot conclude whether data changed. */ + public boolean ambiguous() { + return failureContext.ambiguous(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoReadConcernException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoReadConcernException.java new file mode 100644 index 00000000..3e6a9c51 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoReadConcernException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * The requested read concern could not be satisfied (design §15). + * + *

Most often a snapshot read whose snapshot is no longer available, or a majority read on a + * topology that cannot form a majority. Both are consistency-profile problems, not query problems, + * so the profile stays in the failure context to point at the real cause. + */ +public final class MongoReadConcernException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoReadConcernException(MongoFailureContext failureContext) { + super("MongoDB could not satisfy the requested read concern", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoResumeException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoResumeException.java new file mode 100644 index 00000000..9560b02a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoResumeException.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A change stream could not resume from its stored checkpoint (design §20.3). + * + *

The resume token is not attached. It is opaque, it is on the forbidden telemetry list, and it + * is stored encrypted; putting it in an exception message would undo all three at once. + */ +public final class MongoResumeException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoResumeException(MongoFailureContext failureContext) { + super("the change stream could not resume from its checkpoint", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoRetryScope.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoRetryScope.java new file mode 100644 index 00000000..57e6ea64 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoRetryScope.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +/** + * How much of a failed unit of work may be replayed (design §14.3, D-10). + * + *

The design's central retry rule is that {@code TransientTransactionError} and {@code + * UnknownTransactionCommitResult} demand opposite responses: the first replays the whole body from + * a new session, the second must never replay the body at all. Encoding that as a scope rather than + * a {@code retryable} boolean is what stops the two from collapsing into one flag at the call site. + */ +public enum MongoRetryScope { + + /** Nothing may be replayed. */ + NONE, + + /** The operation may be re-sent; nothing was applied. */ + WHOLE_OPERATION, + + /** The transaction body may be replayed, but only from a fresh session. */ + WHOLE_TRANSACTION, + + /** Only the commit may be retried. The business body must not run again. */ + COMMIT_ONLY, + + /** Nothing may be replayed; durable evidence must be read to settle what happened. */ + RECONCILIATION +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoSchemaValidationException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoSchemaValidationException.java new file mode 100644 index 00000000..31c37032 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoSchemaValidationException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * The collection's JSON Schema validator rejected the document (design §12.1, §15). + * + *

Reaching this exception means the last line of defence caught something Bean Validation and + * the domain invariants let through, so it is a modelling bug rather than a user error. The + * rejected document is not attached for the reason the validator exists: it is the untrusted value. + */ +public final class MongoSchemaValidationException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoSchemaValidationException(MongoFailureContext failureContext) { + super("MongoDB schema validation rejected the document", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoServerSelectionException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoServerSelectionException.java new file mode 100644 index 00000000..be2519ec --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoServerSelectionException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * No server satisfying the read preference was found before the selection timeout (design §15). + * + *

Nothing was sent, so this failure is unambiguous and safely retryable. It is kept separate + * from a connection failure because the operational fix is different: server selection points at + * topology, elections and read preference, not at sockets. + */ +public final class MongoServerSelectionException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoServerSelectionException(MongoFailureContext failureContext) { + super("no suitable MongoDB server was selected within the selection timeout", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoShardRoutingException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoShardRoutingException.java new file mode 100644 index 00000000..60ee96d1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoShardRoutingException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A sharded operation lacked the routing evidence its collection profile requires (design §22). + * + *

The shard key value is never carried: it is business data, and it is on the design's forbidden + * telemetry list. What the operator needs is which operation was unrouted, and that is the + * operation name already in the failure context. + */ +public final class MongoShardRoutingException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoShardRoutingException(MongoFailureContext failureContext) { + super( + "the operation lacks the shard key or routing evidence its profile requires", + failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTimeoutException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTimeoutException.java new file mode 100644 index 00000000..5febdb42 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTimeoutException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * The operation exceeded its deadline (design §15). + * + *

A timeout says when the client stopped waiting, not what the server did. A write that timed + * out after being sent is {@code WRITE_RESULT_UNKNOWN} and must be reconciled; only a timeout that + * fired before the command left is safe to replay. + */ +public final class MongoTimeoutException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoTimeoutException(MongoFailureContext failureContext) { + super("the MongoDB operation exceeded its deadline", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionCommitUnknownException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionCommitUnknownException.java new file mode 100644 index 00000000..f618c1ff --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionCommitUnknownException.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; +import java.util.Objects; + +/** + * The commit outcome is unknown after the commit retry budget was exhausted (design §14.3). + * + *

This is the exception the business must never respond to by re-running its body: the + * transaction may have committed. The reconciliation hint names the evidence to read instead — the + * version, unique key, idempotency record or transaction record that can settle the question. + */ +public final class MongoTransactionCommitUnknownException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + private final String reconciliationHint; + + public MongoTransactionCommitUnknownException( + MongoFailureContext failureContext, String reconciliationHint) { + super( + "the transaction commit result is unknown; reconcile instead of replaying the body", + failureContext); + this.reconciliationHint = Objects.requireNonNull(reconciliationHint, "reconciliationHint"); + } + + /** Names the durable evidence that can decide whether the commit happened. */ + public String reconciliationHint() { + return reconciliationHint; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionTransientException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionTransientException.java new file mode 100644 index 00000000..6c501031 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoTransactionTransientException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * The transaction failed with {@code TransientTransactionError} (design §14.3). + * + *

The whole body may be replayed, but only from a new {@code ClientSession}: the aborted session + * cannot be reused, and reusing it is the failure mode this dedicated type exists to make visible. + */ +public final class MongoTransactionTransientException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoTransactionTransientException(MongoFailureContext failureContext) { + super( + "the transaction failed transiently; replay the whole body from a new session", + failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoUnclassifiedFailureException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoUnclassifiedFailureException.java new file mode 100644 index 00000000..f946919a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoUnclassifiedFailureException.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * A recognised failure with no more specific stable category (design §15). + * + *

The design requires an unknown server code to "map to a stable parent category while + * preserving bounded metadata". {@link MongoPersistenceException} is that category but is abstract, + * so this is the concrete carrier: a caller can still catch the parent, and the server code, error + * labels and outcome all survive in the failure context. + * + *

Deliberately not retryable. A failure the platform cannot classify is one whose write outcome + * it cannot vouch for, and guessing in the safe-looking direction is how duplicates are created. + */ +public final class MongoUnclassifiedFailureException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoUnclassifiedFailureException(MongoFailureContext failureContext) { + super("MongoDB reported a failure this platform release does not classify", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConcernException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConcernException.java new file mode 100644 index 00000000..23ccb431 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConcernException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * The requested write concern could not be satisfied (design §15). + * + *

A write concern failure is not a write failure: the primary usually applied the write and only + * the replication acknowledgement fell short. That is why the outcome carried here is normally + * {@code WRITE_RESULT_UNKNOWN} rather than a clean failure, and why the caller must reconcile + * instead of assuming nothing happened. + */ +public final class MongoWriteConcernException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoWriteConcernException(MongoFailureContext failureContext) { + super("MongoDB could not satisfy the requested write concern", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConflictException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConflictException.java new file mode 100644 index 00000000..75d52bef --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoWriteConflictException.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import java.io.Serial; + +/** + * Two concurrent transactions wrote the same document (design §15). + * + *

The server aborts one of them. When the driver marks it {@code TransientTransactionError} the + * retry coordinator replays the whole body from a new session; when it does not, the caller + * decides, because a write conflict outside a transaction says the access pattern is contended + * rather than unlucky. + */ +public final class MongoWriteConflictException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoWriteConflictException(MongoFailureContext failureContext) { + super("MongoDB aborted the operation because of a write conflict", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/DomainDocumentId.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/DomainDocumentId.java new file mode 100644 index 00000000..f3230134 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/DomainDocumentId.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +import java.util.Objects; + +/** + * A domain identifier that must be stored as a BSON string (design §10). + * + *

Spring Data coerces a raw {@code String} {@code @Id} into an {@code ObjectId} when the value + * happens to be 24 hexadecimal characters. That heuristic is fine until a domain identifier — a + * hash, a short code, a UUIDv7 rendered without dashes — happens to match, at which point half the + * collection is stored as {@code ObjectId} and half as {@code String}, and equality queries stop + * finding rows. + * + *

Wrapping the identifier in a distinct type removes the heuristic from the picture: a + * registered converter for a non-{@code String} type always wins, so storage is decided by the + * manifest rather than by the shape of the value. + */ +public record DomainDocumentId(String value) { + + public DomainDocumentId { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("a domain document id must not be blank"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoBigIntegerRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoBigIntegerRepresentation.java new file mode 100644 index 00000000..04e10970 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoBigIntegerRepresentation.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a {@code BigInteger} is stored as BSON (design §10). + * + *

BSON has no arbitrary-precision integer, so every option is a compromise and the design + * refuses to pick one implicitly. {@code STRING} is the platform manifest's value because it never + * overflows; a collection that genuinely needs numeric comparison declares {@code DECIMAL128} and + * accepts its 34-digit ceiling. + */ +public enum MongoBigIntegerRepresentation { + + /** Lossless for any magnitude; comparisons and ranges are lexicographic, not numeric. */ + STRING, + + /** Numerically comparable up to 34 significant digits; rejects anything larger. */ + DECIMAL128, + + /** 64-bit storage for values proven to fit; rejects anything larger. */ + INT64; + + /** True when this representation can hold any magnitude without rejection. */ + public boolean unbounded() { + return this == STRING; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoDecimalRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoDecimalRepresentation.java new file mode 100644 index 00000000..f6fec09a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoDecimalRepresentation.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a {@code BigDecimal} is stored as BSON (design §10). + * + *

{@code DECIMAL128} is the platform default because the alternatives lose money: a double loses + * precision silently, and a string sorts and compares lexicographically, so {@code "10"} is less + * than {@code "9"} in a range query. Both alternatives exist here only so a legacy collection can + * declare what it already holds. + */ +public enum MongoDecimalRepresentation { + + /** IEEE 754 decimal128. Exact, comparable and the only representation for new collections. */ + DECIMAL128, + + /** Legacy string storage. Readable for migration; comparisons are lexicographic, not numeric. */ + LEGACY_STRING_READ_ONLY, + + /** + * Legacy double storage. Readable for migration; precision is already lost in the stored data. + */ + LEGACY_DOUBLE_READ_ONLY; + + /** True when new writes may use this representation. */ + public boolean writable() { + return this == DECIMAL128; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoEnumRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoEnumRepresentation.java new file mode 100644 index 00000000..efd0ea7b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoEnumRepresentation.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a Java enum is stored as BSON (design §10). + * + *

{@code STRING} is the only writable representation. Ordinal storage makes the stored data + * depend on declaration order, so inserting a constant in the middle rewrites the meaning of every + * document already written — a silent data corruption that no schema validator can catch. + */ +public enum MongoEnumRepresentation { + + /** The constant name. Renaming a constant is a migration, not a refactor. */ + STRING, + + /** Legacy ordinal storage. Readable for migration only; never written. */ + LEGACY_ORDINAL_READ_ONLY; + + /** True when new writes may use this representation. */ + public boolean writable() { + return this == STRING; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoIdRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoIdRepresentation.java new file mode 100644 index 00000000..0e1a0bfa --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoIdRepresentation.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a collection stores its {@code _id} (design §10). + * + *

Declared per collection rather than inferred, because the two representations are not + * interchangeable: {@code "507f1f77bcf86cd799439011"} and {@code + * ObjectId("507f1f77bcf86cd799439011")} are different BSON values and never match each other in a + * query. + */ +public enum MongoIdRepresentation { + + /** The identifier is stored verbatim as a BSON string. Never silently coerced. */ + STRING, + + /** The identifier is a MongoDB-generated {@code ObjectId}. Suitable for Mongo-internal keys. */ + OBJECT_ID, + + /** The identifier is a UUID stored as BSON binary subtype 4. */ + BINARY_UUID +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTemporalRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTemporalRepresentation.java new file mode 100644 index 00000000..0f3976da --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTemporalRepresentation.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a temporal value is stored as BSON (design §10). + * + *

{@code Instant} is an unambiguous point in time and maps cleanly onto BSON date. {@code + * LocalDateTime} is not: storing it requires choosing a zone, and the default choice is the JVM's, + * which means the same value written from two hosts can land hours apart. The design's answer is + * not a better default but a refusal — a named converter must make the zone explicit. + */ +public enum MongoTemporalRepresentation { + + /** {@code Instant} as BSON date, UTC milliseconds. The stable representation. */ + INSTANT_AS_BSON_DATE, + + /** + * {@code LocalDateTime} written through an explicitly registered converter. + * + *

Selecting this without registering the named converter is a startup failure, not a fallback + * to the system default zone. + */ + LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER; + + /** True when this representation is invalid unless a named converter is registered. */ + public boolean requiresRegisteredConverter() { + return this == LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeMetadataPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeMetadataPolicy.java new file mode 100644 index 00000000..55f2cbee --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeMetadataPolicy.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a collection records the type of a stored document (design §11). + * + *

The design refuses a single global answer. Spring Data's default {@code _class} field is fine + * for an internal, short-lived collection and wrong for a long-lived one: it writes a Java + * fully-qualified class name into the data, so moving a class between packages becomes a data + * migration and any non-JVM reader is left parsing someone else's package layout. + */ +public enum MongoTypeMetadataPolicy { + + /** + * Spring Data's default {@code _class} field. Acceptable for internal, short-lived collections. + */ + CLASS_METADATA_ALLOWED, + + /** A stable {@code @TypeAlias} value. Required for long-lived, multi-version collections. */ + ALIAS_FOR_LONG_LIVED, + + /** An explicit {@code documentType} field. Required for externally shared collections. */ + EXPLICIT_DOCUMENT_TYPE, + + /** No type metadata at all. Only valid for a single-type, non-polymorphic collection. */ + NO_TYPE_METADATA; + + /** True when writing a Java fully-qualified class name into the document is forbidden. */ + public boolean forbidsJavaClassName() { + return this != CLASS_METADATA_ALLOWED; + } + + /** True when the collection must declare a stable alias or document type. */ + public boolean requiresStableIdentifier() { + return this == ALIAS_FOR_LONG_LIVED || this == EXPLICIT_DOCUMENT_TYPE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifest.java new file mode 100644 index 00000000..f90eaf7b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +import java.util.Objects; + +/** + * The frozen BSON representation contract for this deployment (design §10, D-06). + * + *

Every field is mandatory. The design's rule is that a library upgrade or a deployment change + * must never move the BSON representation implicitly, and the only way to guarantee that is to make + * "not stated" impossible to express. {@link #fingerprint()} turns the whole manifest into one + * string, which startup validation and the golden BSON snapshots compare against: a representation + * change then fails the build instead of quietly rewriting the next document. + */ +public record MongoTypeRepresentationManifest( + MongoUuidRepresentation uuid, + MongoDecimalRepresentation decimal, + MongoBigIntegerRepresentation bigInteger, + MongoTemporalRepresentation temporal, + MongoEnumRepresentation enumRepresentation, + MongoTypeMetadataPolicy typeMetadata) { + + public MongoTypeRepresentationManifest { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(decimal, "decimal"); + Objects.requireNonNull(bigInteger, "bigInteger"); + Objects.requireNonNull(temporal, "temporal"); + Objects.requireNonNull(enumRepresentation, "enumRepresentation"); + Objects.requireNonNull(typeMetadata, "typeMetadata"); + } + + /** + * The design's four-axis form, with the manifest's fixed values for the remaining two. + * + *

{@code BigInteger} and enum representation are still explicit values in the resulting + * manifest — this constructor states them rather than leaving them unset, which is what + * "BigInteger requires an explicit representation" means once the platform manifest has fixed the + * answer. + */ + public MongoTypeRepresentationManifest( + MongoUuidRepresentation uuid, + MongoDecimalRepresentation decimal, + MongoTemporalRepresentation temporal, + MongoTypeMetadataPolicy typeMetadata) { + this( + uuid, + decimal, + MongoBigIntegerRepresentation.STRING, + temporal, + MongoEnumRepresentation.STRING, + typeMetadata); + } + + /** The platform manifest of design §10: UUID standard, Decimal128, BSON date, alias metadata. */ + public static MongoTypeRepresentationManifest standard() { + return new MongoTypeRepresentationManifest( + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + MongoBigIntegerRepresentation.STRING, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoEnumRepresentation.STRING, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED); + } + + /** + * Rejects a manifest that would write a representation reserved for legacy reads. + * + * @throws IllegalStateException naming the axis that is not writable + */ + public void requireWritable() { + if (!uuid.writable()) { + throw new IllegalStateException( + "UUID representation " + + uuid + + " is readable for migration only and must not be written"); + } + if (!decimal.writable()) { + throw new IllegalStateException( + "decimal representation " + + decimal + + " is readable for migration only and must not be written"); + } + if (!enumRepresentation.writable()) { + throw new IllegalStateException( + "enum representation " + + enumRepresentation + + " is readable for migration only and must not be written"); + } + } + + /** + * A stable identity for this representation set. + * + *

Deliberately human-readable rather than a hash: when a golden snapshot fails, the diff + * should say which axis moved, not that two opaque digests differ. + */ + public String fingerprint() { + return "uuid=" + + uuid + + ";decimal=" + + decimal + + ";bigInteger=" + + bigInteger + + ";temporal=" + + temporal + + ";enum=" + + enumRepresentation + + ";typeMetadata=" + + typeMetadata; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoUuidRepresentation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoUuidRepresentation.java new file mode 100644 index 00000000..bb7e8e69 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoUuidRepresentation.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +/** + * How a {@code UUID} is stored as BSON (design §10). + * + *

There is no "unspecified" constant on purpose. The driver's historical default differs between + * languages and versions, so a deployment that never states a representation can silently write + * byte-swapped subtype 3 binaries that every other language reads as a different UUID. Making the + * choice mandatory is what fixes the representation across deployments. + */ +public enum MongoUuidRepresentation { + + /** RFC 4122 byte order, BSON binary subtype 4. The only representation new data may use. */ + STANDARD, + + /** Legacy subtype 3 with Java byte order. Readable for migration; never written. */ + JAVA_LEGACY_READ_ONLY; + + /** True when new writes may use this representation. */ + public boolean writable() { + return this == STANDARD; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObservation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObservation.java new file mode 100644 index 00000000..3655b217 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObservation.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.mongo.api.observation; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; + +/** + * One in-flight operation's observation scope (design §27). + * + *

{@link AutoCloseable} so the scope closes on every path, including the one where the callback + * throws something the platform never classified. An observation that only closes on success + * produces metrics that look healthy precisely when the system is not. + */ +public interface MongoOperationObservation extends AutoCloseable { + + /** Records a successful completion and its write outcome. */ + void success(MongoExecutionOutcome outcome); + + /** Records a failure, using only the bounded metadata the failure context is allowed to carry. */ + void failure(MongoFailureContext failureContext); + + /** The trace identifier of this observation, or an empty string when tracing is off. */ + String traceId(); + + @Override + void close(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObserver.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObserver.java new file mode 100644 index 00000000..82c1768c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/MongoOperationObserver.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.mongo.api.observation; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; + +/** + * Opens an observation scope for one operation (design §27). + * + *

Declared in the framework-free core so the execution paths can observe without depending on + * the observability module — which, in the design's dependency table, they must not. Micrometer + * lives behind this interface, on the other side of the boundary. + */ +public interface MongoOperationObserver { + + /** Opens a scope. Never returns {@code null}. */ + MongoOperationObservation start(MongoOperationContext context, MongoOperationType operationType); + + /** An observer that records nothing, for tests and for deployments without telemetry. */ + static MongoOperationObserver none() { + return NoOpMongoOperationObserver.INSTANCE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/NoOpMongoOperationObserver.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/NoOpMongoOperationObserver.java new file mode 100644 index 00000000..0a581efa --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/observation/NoOpMongoOperationObserver.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.mongo.api.observation; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; + +/** + * The observer used when no telemetry is configured (design §27). + * + *

A null object rather than a nullable field: the execution paths open and close an observation + * unconditionally, so there is no branch that can be wrong, and no deployment where the untested + * "observability disabled" path behaves differently from the tested one. + */ +final class NoOpMongoOperationObserver implements MongoOperationObserver { + + static final MongoOperationObserver INSTANCE = new NoOpMongoOperationObserver(); + + private static final MongoOperationObservation SCOPE = new NoOpObservation(); + + private NoOpMongoOperationObserver() {} + + @Override + public MongoOperationObservation start( + MongoOperationContext context, MongoOperationType operationType) { + return SCOPE; + } + + private static final class NoOpObservation implements MongoOperationObservation { + + @Override + public void success(MongoExecutionOutcome outcome) { + // Nothing is recorded when telemetry is not configured. + } + + @Override + public void failure(MongoFailureContext failureContext) { + // Nothing is recorded when telemetry is not configured. + } + + @Override + public String traceId() { + return ""; + } + + @Override + public void close() { + // Nothing to release. + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/package-info.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/package-info.java new file mode 100644 index 00000000..1a6b495f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/package-info.java @@ -0,0 +1,11 @@ +/** + * The framework-free core of the MongoDB document persistence platform (design §6.3, {@code + * mongodb-core-api}). + * + *

Nothing in this package or its subpackages may reference Spring, the MongoDB Java Driver, BSON + * or Reactor. That rule is what lets the semantics — operation identity, consistency profiles, + * execution outcomes, the error hierarchy, the BSON representation manifest, schema versions — be + * shared by the imperative path, the reactive path and the tests without any of them dragging a + * transport dependency into the others. {@code MongoModuleBoundaryTest} enforces it. + */ +package dev.caskeleton.adapter.outbound.mongo.api; diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoClientPlane.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoClientPlane.java new file mode 100644 index 00000000..eb90295c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoClientPlane.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +/** + * Which of the design's four exposure layers a client serves (design §5, §8). + * + *

The planes are separate clients with separate credentials, not separate methods on one client. + * That separation is the whole point: an application that can reach {@code dropDatabase} through + * any code path has an admin plane whether or not the code calls it that. + */ +public enum MongoClientPlane { + + /** D1/D2: standard document persistence and advanced document operations. */ + RUNTIME, + + /** D3: allowlisted capability operations outside the Stable API surface. */ + CAPABILITY, + + /** D4: administrative commands, never auto-configured into an application runtime. */ + ADMIN; + + /** True when this plane must pin Stable API V1 in strict mode. */ + public boolean requiresRuntimeStrictApi() { + return this == RUNTIME; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfile.java new file mode 100644 index 00000000..4b80adce --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfile.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +import dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName; +import java.util.Objects; + +/** + * A configured client identity: which plane it serves, on which topology, under which Stable API + * declaration (design §8, §28). + * + *

The three factories are the only supported shapes. A production runtime cannot be built on a + * standalone server, and a runtime client cannot be built with a relaxed Stable API declaration — + * both are constructor failures rather than conditions checked somewhere later, because "somewhere + * later" is after the application has already started serving traffic. + */ +public record MongoRuntimeProfile( + DatabaseProfileName databaseProfile, + MongoClientPlane plane, + MongoTopology topology, + MongoStableApiProfile stableApi, + boolean production) { + + public MongoRuntimeProfile { + Objects.requireNonNull(databaseProfile, "databaseProfile"); + Objects.requireNonNull(plane, "plane"); + Objects.requireNonNull(topology, "topology"); + Objects.requireNonNull(stableApi, "stableApi"); + if (production && topology == MongoTopology.STANDALONE) { + throw new IllegalArgumentException( + "a production MongoDB profile requires REPLICA_SET, SHARDED or ATLAS, not STANDALONE"); + } + if (plane.requiresRuntimeStrictApi() && !stableApi.isRuntimeStrict()) { + throw new IllegalArgumentException( + "a D1/D2 runtime profile requires Stable API V1 with strict mode and deprecation errors"); + } + } + + /** A production D1/D2 runtime profile. Rejects standalone topologies. */ + public static MongoRuntimeProfile production(MongoTopology topology) { + return production(new DatabaseProfileName("default"), topology); + } + + /** A production D1/D2 runtime profile for a named database profile. */ + public static MongoRuntimeProfile production( + DatabaseProfileName databaseProfile, MongoTopology topology) { + return new MongoRuntimeProfile( + databaseProfile, + MongoClientPlane.RUNTIME, + topology, + MongoStableApiProfile.v1Strict(), + true); + } + + /** + * A non-production D1/D2 runtime profile. + * + *

Standalone is accepted here and only here, and only for smoke tests: the local default + * topology is still a single-node replica set so that transactions, retryable writes and change + * streams behave locally the way they behave in production (design D-02). + */ + public static MongoRuntimeProfile local(MongoTopology topology) { + return new MongoRuntimeProfile( + new DatabaseProfileName("default"), + MongoClientPlane.RUNTIME, + topology, + MongoStableApiProfile.v1Strict(), + false); + } + + /** A D3 capability profile, which may relax the Stable API declaration per capability. */ + public static MongoRuntimeProfile capability( + DatabaseProfileName databaseProfile, + MongoTopology topology, + MongoStableApiProfile stableApi) { + return new MongoRuntimeProfile( + databaseProfile, MongoClientPlane.CAPABILITY, topology, stableApi, true); + } + + /** A D4 admin profile. Never registered by the application runtime's auto-configuration. */ + public static MongoRuntimeProfile admin( + DatabaseProfileName databaseProfile, MongoTopology topology) { + return new MongoRuntimeProfile( + databaseProfile, MongoClientPlane.ADMIN, topology, MongoStableApiProfile.v1Relaxed(), true); + } + + /** Fails startup when this profile's topology cannot serve the requested feature. */ + public void require(MongoTopologyRequirement requirement) { + Objects.requireNonNull(requirement, "requirement").require(topology); + } + + /** True when this profile serves ordinary application traffic. */ + public boolean isRuntimePlane() { + return plane == MongoClientPlane.RUNTIME; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoStableApiProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoStableApiProfile.java new file mode 100644 index 00000000..aba93c02 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoStableApiProfile.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +import java.util.Objects; + +/** + * Stable API declaration for a client (design D-04, §8). + * + *

D1/D2 runtime clients pin Stable API V1 with strict mode and deprecation errors on. That is + * what stops an ordinary CRUD path from quietly depending on a command outside the versioned + * surface, and what makes a server upgrade a build-time conversation instead of a production + * incident. + */ +public record MongoStableApiProfile(String version, boolean strict, boolean deprecationErrors) { + + /** The only Stable API version this platform certifies. */ + public static final String V1 = "1"; + + public MongoStableApiProfile { + Objects.requireNonNull(version, "version"); + if (version.isBlank()) { + throw new IllegalArgumentException("stable API version must not be blank"); + } + } + + /** The D1/D2 runtime default: V1, strict, deprecation errors on. */ + public static MongoStableApiProfile v1Strict() { + return new MongoStableApiProfile(V1, true, true); + } + + /** + * A relaxed declaration for a D3 capability client. + * + *

Some capabilities — search, encryption setup probes, sharding introspection — use commands + * outside the versioned surface. They are allowed to relax strictness, but only on a client that + * is separately credentialed and allowlisted, never on the runtime client. + */ + public static MongoStableApiProfile v1Relaxed() { + return new MongoStableApiProfile(V1, false, false); + } + + /** True when this declaration satisfies the D1/D2 runtime requirement. */ + public boolean isRuntimeStrict() { + return V1.equals(version) && strict && deprecationErrors; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopology.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopology.java new file mode 100644 index 00000000..4ff1826b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopology.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +/** + * Deployment topology a runtime is connected to (design §4, D-02, D-03). + * + *

Standalone exists in the enum because smoke tests legitimately run against it, not because it + * is a supported production shape: transactions, retryable writes and change streams all require an + * oplog, so a standalone deployment silently loses the semantics the rest of the platform promises. + * {@link MongoRuntimeProfile#production} is where that becomes a startup failure. + */ +public enum MongoTopology { + + /** Single mongod, no oplog. Smoke tests only. */ + STANDALONE, + + /** + * Replica set. The local default is a single-node replica set, so local behaves like production. + */ + REPLICA_SET, + + /** Sharded cluster behind mongos. */ + SHARDED, + + /** + * A managed Atlas deployment, which is a replica set or sharded cluster plus provider features. + */ + ATLAS; + + /** True when this topology has an oplog and elections. */ + public boolean supportsSessions() { + return this != STANDALONE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopologyRequirement.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopologyRequirement.java new file mode 100644 index 00000000..71dd55c4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoTopologyRequirement.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +import java.util.Objects; +import java.util.Set; + +/** + * What a feature needs from the topology, checked at startup (design §28 startup failure list). + * + *

The design is explicit that a topology mismatch is a startup failure and not a warning log. A + * requirement object exists so the check happens once, before any repository is created, instead of + * being rediscovered by the first transaction of the day. + */ +public record MongoTopologyRequirement(String feature, Set acceptable) { + + public MongoTopologyRequirement { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(acceptable, "acceptable"); + acceptable = Set.copyOf(acceptable); + if (acceptable.isEmpty()) { + throw new IllegalArgumentException( + "a topology requirement must accept at least one topology"); + } + } + + /** Multi-document transactions need an oplog: replica set, sharded or Atlas. */ + public static MongoTopologyRequirement transactions() { + return sessionBacked("transaction"); + } + + /** Change streams need an oplog: replica set, sharded or Atlas. */ + public static MongoTopologyRequirement changeStreams() { + return sessionBacked("change-stream"); + } + + /** Causal sessions need an oplog: replica set, sharded or Atlas. */ + public static MongoTopologyRequirement causalSessions() { + return sessionBacked("causal-session"); + } + + /** Sharded routing awareness only means anything on a sharded cluster. */ + public static MongoTopologyRequirement sharding() { + return new MongoTopologyRequirement( + "sharding", Set.of(MongoTopology.SHARDED, MongoTopology.ATLAS)); + } + + private static MongoTopologyRequirement sessionBacked(String feature) { + return new MongoTopologyRequirement( + feature, Set.of(MongoTopology.REPLICA_SET, MongoTopology.SHARDED, MongoTopology.ATLAS)); + } + + /** True when the actual topology satisfies this requirement. */ + public boolean isSatisfiedBy(MongoTopology topology) { + return acceptable.contains(Objects.requireNonNull(topology, "topology")); + } + + /** + * Fails startup when the actual topology cannot serve the feature. + * + * @throws IllegalStateException naming the feature and the acceptable topologies + */ + public void require(MongoTopology topology) { + if (!isSatisfiedBy(topology)) { + throw new IllegalStateException( + "MongoDB feature '" + + feature + + "' requires one of " + + acceptable.stream().map(Enum::name).sorted().toList() + + " but the configured topology is " + + topology); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/DocumentSchemaVersion.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/DocumentSchemaVersion.java new file mode 100644 index 00000000..ef7746d3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/DocumentSchemaVersion.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.api.schema; + +/** + * The schema version stamped on a stored document (design §12.2). + * + *

An integer, compared as an integer. Semantic version strings were rejected by the design + * because a document version has exactly one axis — "can this release read it" — and a richer + * grammar only invites migrations that branch on the wrong part of it. + */ +public record DocumentSchemaVersion(int value) implements Comparable { + + /** A document with no {@code schemaVersion} field, readable only under a legacy-read policy. */ + public static final DocumentSchemaVersion LEGACY_V0 = new DocumentSchemaVersion(0); + + public DocumentSchemaVersion { + if (value < 0) { + throw new IllegalArgumentException("negative schema version"); + } + } + + @Override + public int compareTo(DocumentSchemaVersion other) { + return Integer.compare(value, other.value); + } + + /** True when this is the absent-field legacy version. */ + public boolean isLegacy() { + return value == LEGACY_V0.value; + } + + @Override + public String toString() { + return Integer.toString(value); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicy.java new file mode 100644 index 00000000..9ce465c9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicy.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.mongo.api.schema; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import java.util.Objects; + +/** + * Decides which stored schema versions a collection may read, and which it writes (design §12.2). + * + *

Two rules the design states and this class enforces. A missing {@code schemaVersion} maps to + * legacy V0 only when the collection opts into legacy reads — otherwise an unversioned + * document is a data defect, not a V0 document. And a new write always uses the current version: + * there is no API here for writing an older one, because a "temporary" downgrade path is how a + * backfill ends up never finishing. + */ +public final class MongoSchemaVersionPolicy { + + private final MongoSchemaVersionRange range; + private final boolean legacyReadsEnabled; + + public MongoSchemaVersionPolicy( + DocumentSchemaVersion minimumSupported, + DocumentSchemaVersion current, + boolean legacyReadsEnabled) { + this(new MongoSchemaVersionRange(minimumSupported, current), legacyReadsEnabled); + } + + public MongoSchemaVersionPolicy(MongoSchemaVersionRange range, boolean legacyReadsEnabled) { + this.range = Objects.requireNonNull(range, "range"); + this.legacyReadsEnabled = legacyReadsEnabled; + } + + /** The version every new write stamps. */ + public DocumentSchemaVersion writeVersion() { + return range.current(); + } + + /** The readable range. */ + public MongoSchemaVersionRange range() { + return range; + } + + /** True when a document without a {@code schemaVersion} field may be read as legacy V0. */ + public boolean legacyReadsEnabled() { + return legacyReadsEnabled; + } + + /** + * Resolves the version of a document whose {@code schemaVersion} field is absent. + * + * @throws MongoDataSchemaUnsupportedException when the collection does not allow legacy reads + */ + public DocumentSchemaVersion resolveMissingVersion() { + if (!legacyReadsEnabled) { + throw unsupported(DocumentSchemaVersion.LEGACY_V0); + } + return DocumentSchemaVersion.LEGACY_V0; + } + + /** + * Verifies a stored version before the document is deserialized into the domain type. + * + * @throws MongoDataSchemaUnsupportedException when the version is future or retired + */ + public DocumentSchemaVersion requireReadable(DocumentSchemaVersion version) { + Objects.requireNonNull(version, "version"); + if (version.isLegacy() && legacyReadsEnabled) { + return version; + } + if (!range.contains(version)) { + throw unsupported(version); + } + return version; + } + + /** True when reading this version needs a read-time conversion, which is a metered event. */ + public boolean requiresReadTimeConversion(DocumentSchemaVersion version) { + return requireReadable(version).compareTo(range.current()) < 0; + } + + private MongoDataSchemaUnsupportedException unsupported(DocumentSchemaVersion version) { + MongoFailureContext context = + MongoFailureContext.rejected(new MongoOperationName("schema.version-check")); + return new MongoDataSchemaUnsupportedException( + context, version.value(), range.minimumSupported().value(), range.current().value()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionRange.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionRange.java new file mode 100644 index 00000000..6cddb62d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionRange.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.mongo.api.schema; + +import java.util.Objects; + +/** + * The inclusive range of schema versions one release can read (design §12.2). + * + *

Separate from the version this release writes, because the migration procedure depends on the + * two being different for a while: dual reader, then new writer, then backfill, then the old reader + * is retired. Collapsing them into one number is what makes a rollout unable to roll back. + */ +public record MongoSchemaVersionRange( + DocumentSchemaVersion minimumSupported, DocumentSchemaVersion current) { + + public MongoSchemaVersionRange { + Objects.requireNonNull(minimumSupported, "minimumSupported"); + Objects.requireNonNull(current, "current"); + if (minimumSupported.compareTo(current) > 0) { + throw new IllegalArgumentException( + "minimum supported schema version must not exceed the current version"); + } + } + + /** Convenience factory over raw integers. */ + public static MongoSchemaVersionRange of(int minimumSupported, int current) { + return new MongoSchemaVersionRange( + new DocumentSchemaVersion(minimumSupported), new DocumentSchemaVersion(current)); + } + + /** True when a stored version falls inside the readable range. */ + public boolean contains(DocumentSchemaVersion version) { + Objects.requireNonNull(version, "version"); + return version.compareTo(minimumSupported) >= 0 && version.compareTo(current) <= 0; + } + + /** True when the stored version is newer than this release understands. */ + public boolean isFuture(DocumentSchemaVersion version) { + return Objects.requireNonNull(version, "version").compareTo(current) > 0; + } + + /** True when the stored version has been retired from the readable range. */ + public boolean isRetired(DocumentSchemaVersion version) { + return Objects.requireNonNull(version, "version").compareTo(minimumSupported) < 0; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoCollectionProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoCollectionProfile.java new file mode 100644 index 00000000..4356aa23 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoCollectionProfile.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.architecture; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Binds a repository or document type to a registered collection profile (design §16.2). + * + *

The annotation is what lets the architecture rules check statically what the runtime enforces + * dynamically: a repository that never names a profile is a repository whose guardrails nobody can + * find. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface MongoCollectionProfile { + + /** The registered collection profile name. */ + String value(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoOperation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoOperation.java new file mode 100644 index 00000000..58acdd2f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoOperation.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.architecture; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Names the registered operation a repository method performs (design §7.1). + * + *

Declaring the name next to the method is what keeps metric and budget keys attached to the + * code they measure. A name assembled at the call site drifts from the method within one refactor, + * and the dashboard that used it goes quiet without anything failing. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface MongoOperation { + + /** The registered operation name, matching {@code [a-z][a-z0-9.-]{2,95}}. */ + String value(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRules.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRules.java new file mode 100644 index 00000000..acfa777d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRules.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.mongo.architecture; + +import java.util.Set; + +/** + * The rules that keep repositories domain-owned (design D-01, §16.2). + * + *

The design's first decision is that there is no {@code CommonMongoRepository}. A shared + * CRUD base looks like reuse and is actually a coupling: every collection inherits every method, + * including the ones that make no sense for it, and the platform ends up owning query semantics + * that belong to a domain. The forbidden-name list is small because the pattern is easy to name and + * hard to argue with once it exists. + * + *

The class holds the rule data; the ArchUnit assertions that apply it live in the module's + * architecture tests, so production code carries no test-framework dependency. + */ +public final class MongoRepositoryArchitectureRules { + + /** Platform-wide CRUD base repositories, which this design does not have. */ + public Set forbiddenTypeNames() { + return Set.of( + "CommonMongoRepository", + "GenericMongoRepository", + "BaseMongoRepository", + "AbstractMongoRepository", + "MongoCrudRepository"); + } + + /** + * Types no inbound adapter may inject. + * + *

A controller holding a {@code MongoTemplate} has a transport layer that can write to any + * collection with any consistency, bypassing every guardrail the platform installs. + */ + public Set typesForbiddenInInboundAdapters() { + return Set.of( + "org.springframework.data.mongodb.core.MongoTemplate", + "org.springframework.data.mongodb.core.MongoOperations", + "org.springframework.data.mongodb.core.ReactiveMongoTemplate", + "org.springframework.data.mongodb.core.ReactiveMongoOperations", + "com.mongodb.client.MongoClient", + "com.mongodb.client.MongoDatabase", + "com.mongodb.client.MongoCollection", + "com.mongodb.reactivestreams.client.MongoClient", + "com.mongodb.reactivestreams.client.MongoDatabase", + "com.mongodb.reactivestreams.client.MongoCollection"); + } + + /** Spring Data base interfaces a domain repository may extend directly. */ + public Set allowedRepositorySuperTypes() { + return Set.of( + "org.springframework.data.mongodb.repository.MongoRepository", + "org.springframework.data.mongodb.repository.ReactiveMongoRepository", + "org.springframework.data.repository.Repository", + "org.springframework.data.repository.CrudRepository", + "org.springframework.data.repository.reactive.ReactiveCrudRepository"); + } + + /** True when a type name is one of the forbidden platform CRUD bases. */ + public boolean isForbiddenRepositoryName(String simpleTypeName) { + return forbiddenTypeNames().contains(simpleTypeName); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGeneration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGeneration.java new file mode 100644 index 00000000..dd4058fc --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGeneration.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import java.time.Instant; +import java.util.Objects; + +/** + * One generation of a client, created by a credential or profile change (design §28, §42). + * + *

Generations exist so a credential rotation does not break connections already in flight. The + * new generation takes new work while the old one drains, and only then closes — which is what + * makes a rotation a non-event rather than a burst of failed requests. + */ +public record MongoClientGeneration( + String profileName, + long generation, + MongoCredentialReference credential, + Instant createdAt, + boolean draining) { + + public MongoClientGeneration { + Objects.requireNonNull(profileName, "profileName"); + Objects.requireNonNull(credential, "credential"); + Objects.requireNonNull(createdAt, "createdAt"); + if (generation < 1) { + throw new IllegalArgumentException("a client generation starts at 1"); + } + } + + /** The first generation of a profile's client. */ + public static MongoClientGeneration first( + String profileName, MongoCredentialReference credential, Instant now) { + return new MongoClientGeneration(profileName, 1, credential, now, false); + } + + /** The next generation, created by a rotation. */ + public MongoClientGeneration next(MongoCredentialReference newCredential, Instant now) { + return new MongoClientGeneration(profileName, generation + 1, newCredential, now, false); + } + + /** This generation, marked as draining. */ + public MongoClientGeneration markDraining() { + return new MongoClientGeneration(profileName, generation, credential, createdAt, true); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGenerationRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGenerationRegistry.java new file mode 100644 index 00000000..09a79c09 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoClientGenerationRegistry.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Tracks which client generation is current per profile (design §28, §42). + * + *

Mapping representation is deliberately not reloadable. UUID and decimal representation are + * baked into every document already written, so changing them at runtime would split a collection + * into two encodings with no record of where the boundary is — the change needs a restart and a + * migration. + */ +public final class MongoClientGenerationRegistry { + + private final Map current = new LinkedHashMap<>(); + + private final Map draining = new LinkedHashMap<>(); + + private final MongoCredentialRotationPolicy rotationPolicy; + + private final Clock clock; + + public MongoClientGenerationRegistry(MongoCredentialRotationPolicy rotationPolicy, Clock clock) { + this.rotationPolicy = Objects.requireNonNull(rotationPolicy, "rotationPolicy"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** Registers the first generation for a profile. */ + public MongoClientGeneration register(String profileName, MongoCredentialReference credential) { + Objects.requireNonNull(profileName, "profileName"); + Objects.requireNonNull(credential, "credential"); + MongoClientGeneration generation = + MongoClientGeneration.first(profileName, credential, clock.instant()); + if (current.putIfAbsent(profileName, generation) != null) { + throw new IllegalArgumentException("profile '" + profileName + "' is already registered"); + } + return generation; + } + + /** + * Rotates a profile's credential, creating a new generation and draining the old one. + * + * @throws MongoOperationRejectedException when the profile is unknown + */ + public MongoClientGeneration rotate(String profileName, MongoCredentialReference replacement) { + MongoClientGeneration existing = require(profileName); + rotationPolicy.validateRotation(existing.credential(), replacement); + draining.put(profileName, existing.markDraining()); + MongoClientGeneration next = existing.next(replacement, clock.instant()); + current.put(profileName, next); + return next; + } + + /** + * Rejects a reload that would change the BSON representation. + * + *

A reload that leaves the representation alone is fine; one that changes it is not, because + * the old representation is already written into every existing document and nothing records + * where the boundary between the two would be. + * + * @throws MongoOperationRejectedException when the two fingerprints differ + */ + public void requireMappingUnchanged( + String profileName, String currentFingerprint, String proposedFingerprint) { + Objects.requireNonNull(profileName, "profileName"); + Objects.requireNonNull(currentFingerprint, "currentFingerprint"); + Objects.requireNonNull(proposedFingerprint, "proposedFingerprint"); + if (!currentFingerprint.equals(proposedFingerprint)) { + throw MongoOperationRejectedException.of( + "config.mapping", + "the BSON representation of profile '" + + profileName + + "' cannot be reloaded; it is baked into every document already written, so a change " + + "needs a restart and a migration"); + } + } + + /** + * The current generation for a profile. + * + * @throws MongoOperationRejectedException when the profile is unknown + */ + public MongoClientGeneration require(String profileName) { + MongoClientGeneration generation = + current.get(Objects.requireNonNull(profileName, "profileName")); + if (generation == null) { + throw MongoOperationRejectedException.of( + "config.profile", "MongoDB profile '" + profileName + "' has no registered client"); + } + return generation; + } + + /** The generation still draining for a profile, if a rotation is in progress. */ + public Optional drainingGeneration(String profileName) { + return Optional.ofNullable(draining.get(Objects.requireNonNull(profileName, "profileName"))); + } + + /** Marks a draining generation as fully closed. */ + public void drained(String profileName) { + draining.remove(Objects.requireNonNull(profileName, "profileName")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java new file mode 100644 index 00000000..8d8096ab --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformAutoConfiguration.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver; +import dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier; +import dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureTranslator; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator; +import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor; +import dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry; +import dev.caskeleton.adapter.outbound.mongo.imperative.MongoConsistencyBinder; +import dev.caskeleton.adapter.outbound.mongo.mapping.MongoMappingConfiguration; +import dev.caskeleton.adapter.outbound.mongo.observation.MicrometerMongoOperationObserver; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoBudgetEnforcer; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * Registers the Stable platform, and only the Stable platform (design §6.3, §43). + * + *

Gated on the same opt-in flag as the rest of this leaf, so a deployment that has not asked for + * MongoDB gets no beans and no driver connection — the module being on the classpath is not + * consent. + * + *

Advanced capabilities are absent by construction: sharding, time series, encryption, search + * and tenancy are separate packages that no bean here references, so they cannot arrive + * transitively. The admin gateway is absent for the same reason, and deliberately: it is + * constructed by a migration or deployment job with its own credential. + */ +@AutoConfiguration +@ConditionalOnClass(MongoTemplate.class) +@ConditionalOnProperty( + prefix = "ca-skeleton.persistence-mongo", + name = "enabled", + havingValue = "true") +@EnableConfigurationProperties(MongoPlatformProperties.class) +@Import(MongoMappingConfiguration.class) +public class MongoPlatformAutoConfiguration { + + /** The closed registry of consistency profiles. */ + @Bean + @ConditionalOnMissingBean + public MongoConsistencyRegistry mongoConsistencyRegistry() { + return MongoConsistencyRegistry.standard(); + } + + /** Binds read and write settings per profile without mutating the shared template. */ + @Bean + @ConditionalOnMissingBean + public MongoConsistencyBinder mongoConsistencyBinder( + MongoTemplate template, MongoConsistencyRegistry registry) { + return new MongoConsistencyBinder(template, registry); + } + + /** An empty collection profile registry; a consuming project registers its own collections. */ + @Bean + @ConditionalOnMissingBean + public MongoCollectionProfileRegistry mongoCollectionProfileRegistry() { + return MongoCollectionProfileRegistry.builder().build(); + } + + /** Classifies driver failures by recovery semantics. */ + @Bean + @ConditionalOnMissingBean + public MongoFailureClassifier mongoFailureClassifier() { + return new DefaultMongoFailureClassifier(); + } + + /** Translates driver failures into the platform's stable exceptions, exactly once. */ + @Bean + @ConditionalOnMissingBean + public MongoFailureTranslator mongoFailureTranslator(MongoFailureClassifier classifier) { + return new DefaultMongoFailureTranslator(classifier); + } + + /** + * The operation observer. + * + *

Falls back to the no-op observer when no meter registry is present, so the execution paths + * never branch on whether telemetry is configured. + */ + @Bean + @ConditionalOnMissingBean + public MongoOperationObserver mongoOperationObserver(ObjectProvider registries) { + MeterRegistry registry = registries.getIfAvailable(); + return registry == null + ? MongoOperationObserver.none() + : new MicrometerMongoOperationObserver(registry, "default"); + } + + /** The single blocking execution path. */ + @Bean + @ConditionalOnMissingBean + public DefaultMongoImperativeExecutor mongoImperativeExecutor( + MongoConsistencyBinder consistency, + MongoCollectionProfileRegistry collections, + MongoFailureTranslator translator, + MongoOperationObserver observer) { + return new DefaultMongoImperativeExecutor(consistency, collections, translator, observer); + } + + /** Lets a caller tighten a registered budget, never loosen it. */ + @Bean + @ConditionalOnMissingBean + public MongoBudgetEnforcer mongoBudgetEnforcer() { + return new MongoBudgetEnforcer(); + } + + /** The frozen BSON representation this deployment writes. */ + @Bean + @ConditionalOnMissingBean + public MongoTypeRepresentationManifest mongoPlatformTypeRepresentationManifest() { + return MongoTypeRepresentationManifest.standard(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthIndicator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthIndicator.java new file mode 100644 index 00000000..88638b71 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformHealthIndicator.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * What the platform reports about its own health (design §43). + * + *

Liveness and readiness are separate answers because they drive opposite actions. A topology + * mismatch means this instance should stop taking traffic; it does not mean the process should be + * killed, since restarting it will reach the same cluster and find the same mismatch. + * + *

Degraded secondary availability is reported without failing readiness: majority writes still + * work with one secondary down, and taking the whole service out of rotation would turn a + * survivable degradation into an outage. + */ +public final class MongoPlatformHealthIndicator { + + private final MongoTopologyProbe probe; + + private final MongoPlatformProperties properties; + + private final int availableSecondaries; + + public MongoPlatformHealthIndicator( + MongoTopologyProbe probe, MongoPlatformProperties properties, int availableSecondaries) { + this.probe = Objects.requireNonNull(probe, "probe"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.availableSecondaries = availableSecondaries; + } + + /** True when the process is functioning, regardless of whether it should serve traffic. */ + public boolean live() { + return true; + } + + /** True when this instance should receive traffic. */ + public boolean ready() { + return !topologyMismatch(); + } + + /** True when the observed topology differs from what any profile declared. */ + public boolean topologyMismatch() { + return properties.profiles().values().stream() + .anyMatch(profile -> profile.topology() != probe.observedTopology()); + } + + /** True when the deployment is running with fewer secondaries than a replica set expects. */ + public boolean degradedSecondaryAvailability() { + return probe.observedTopology() != MongoTopology.STANDALONE && availableSecondaries < 2; + } + + /** The health detail map, containing only bounded, non-sensitive values. */ + public Map details() { + Map details = new LinkedHashMap<>(); + details.put("topology", probe.observedTopology().name()); + details.put("serverVersion", probe.serverVersion()); + details.put("topologyMismatch", topologyMismatch()); + details.put("degradedSecondaryAvailability", degradedSecondaryAvailability()); + details.put("profiles", properties.profiles().keySet()); + return Map.copyOf(details); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformProperties.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformProperties.java new file mode 100644 index 00000000..e7f627c6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformProperties.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Map; +import java.util.Objects; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The platform's typed configuration (design §28). + * + *

Bound under {@code ca-skeleton.persistence-mongo.platform} rather than the design's {@code + * backend.mongodb}, so it sits inside this repository's existing module namespace; the schema below + * it is the design's. + * + *

Every profile is validated at binding time. A misconfigured profile that is only reached at + * runtime fails during the first request that touches it, which is both later and harder to + * attribute than a refused startup. + */ +@ConfigurationProperties("ca-skeleton.persistence-mongo.platform") +public record MongoPlatformProperties(Map profiles) { + + public MongoPlatformProperties { + // Absent rather than empty is the normal case: a deployment that has opted the module in but + // configured no platform profile yet must still start, so binding treats "no profiles" as an + // empty map instead of a binding failure. + profiles = profiles == null ? Map.of() : Map.copyOf(profiles); + } + + /** An empty configuration, for a deployment that has not opted the platform in. */ + public static MongoPlatformProperties empty() { + return new MongoPlatformProperties(Map.of()); + } + + /** + * Validates every profile and its name. + * + * @throws MongoOperationRejectedException naming the offending profile + */ + public void validate() { + profiles.forEach( + (name, profile) -> { + try { + new DatabaseProfileName(name); + } catch (IllegalArgumentException invalid) { + throw MongoOperationRejectedException.of( + "config.profile", + "'" + + name + + "' is not a valid MongoDB database profile name: " + + invalid.getMessage()); + } + profile.validate(); + }); + } + + /** + * The settings for a named profile. + * + * @throws MongoOperationRejectedException when the profile is not configured + */ + public MongoProfileProperties require(String profileName) { + MongoProfileProperties profile = + profiles.get(Objects.requireNonNull(profileName, "profileName")); + if (profile == null) { + throw MongoOperationRejectedException.of( + "config.profile", "MongoDB profile '" + profileName + "' is not configured"); + } + return profile; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoProfileProperties.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoProfileProperties.java new file mode 100644 index 00000000..bb851281 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoProfileProperties.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import java.time.Duration; +import java.util.Objects; + +/** + * One named MongoDB profile's settings (design §28). + * + *

The URI is a secret reference, not a connection string. A production configuration file + * holding {@code mongodb://user:pass@host} puts the credential in source control, in the container + * image, in every log line that dumps the environment, and in the crash report — and rotating it + * becomes a redeploy. + * + *

Mapping representation is validated here too, because it cannot be changed later: the + * representation is baked into every document already written. + */ +public record MongoProfileProperties( + String uriSecret, + MongoTopology topology, + boolean production, + boolean stableApiStrict, + MongoConsistencyProfile consistencyDefault, + Duration serverSelectionTimeout, + Duration connectTimeout, + Duration socketReadTimeout, + Duration operationTimeout, + int poolMinSize, + int poolMaxSize, + Duration poolMaxWaitTime, + MongoUuidRepresentation uuidRepresentation, + MongoDecimalRepresentation decimalRepresentation, + boolean runtimeAutoCreateIndexes, + boolean tlsRequired, + boolean authenticationRequired) { + + public MongoProfileProperties { + Objects.requireNonNull(uriSecret, "uriSecret"); + Objects.requireNonNull(topology, "topology"); + Objects.requireNonNull(consistencyDefault, "consistencyDefault"); + Objects.requireNonNull(serverSelectionTimeout, "serverSelectionTimeout"); + Objects.requireNonNull(connectTimeout, "connectTimeout"); + Objects.requireNonNull(socketReadTimeout, "socketReadTimeout"); + Objects.requireNonNull(operationTimeout, "operationTimeout"); + Objects.requireNonNull(poolMaxWaitTime, "poolMaxWaitTime"); + Objects.requireNonNull(uuidRepresentation, "uuidRepresentation"); + Objects.requireNonNull(decimalRepresentation, "decimalRepresentation"); + } + + /** The platform's production defaults, with the URI as supplied. */ + public static MongoProfileProperties production(String uriSecret) { + return new MongoProfileProperties( + uriSecret, + MongoTopology.REPLICA_SET, + true, + true, + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 2, + 40, + Duration.ofMillis(500), + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + false, + true, + true); + } + + /** The local development defaults: a single-node replica set, no TLS, indexes applied freely. */ + public static MongoProfileProperties local(String uriSecret) { + return new MongoProfileProperties( + uriSecret, + MongoTopology.REPLICA_SET, + false, + true, + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 1, + 10, + Duration.ofMillis(500), + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + true, + false, + false); + } + + /** + * Validates one profile's settings. + * + * @throws MongoOperationRejectedException naming the first violated requirement + */ + public void validate() { + if (production && !uriSecret.startsWith(MongoCredentialReference.SECRET_SCHEME)) { + throw MongoOperationRejectedException.of( + "config.uri", + "a production MongoDB URI must be a '" + + MongoCredentialReference.SECRET_SCHEME + + "' reference; an inline connection string puts the credential in configuration, in " + + "the image and in every environment dump"); + } + if (production && topology == MongoTopology.STANDALONE) { + throw MongoOperationRejectedException.of( + "config.topology", + "a production MongoDB profile requires REPLICA_SET, SHARDED or ATLAS; STANDALONE has no " + + "oplog, so transactions, retryable writes and change streams are unavailable"); + } + if (production && !stableApiStrict) { + throw MongoOperationRejectedException.of( + "config.stable-api", "a production D1/D2 profile must pin Stable API V1 in strict mode"); + } + if (production && runtimeAutoCreateIndexes) { + throw MongoOperationRejectedException.of( + "config.index", + "runtime auto index creation is not permitted in production; an index build is an " + + "I/O-heavy operation that would start on every replica at whatever moment the " + + "rollout reached it"); + } + if (!uuidRepresentation.writable() || !decimalRepresentation.writable()) { + throw MongoOperationRejectedException.of( + "config.mapping", + "the profile selects a read-only representation for UUID or decimal; new writes need " + + "STANDARD and DECIMAL128"); + } + if (poolMinSize < 0 || poolMaxSize <= 0 || poolMinSize > poolMaxSize) { + throw MongoOperationRejectedException.of( + "config.pool", "the connection pool bounds are inconsistent"); + } + if (operationTimeout.compareTo(socketReadTimeout) > 0) { + throw MongoOperationRejectedException.of( + "config.timeout", + "the operation timeout exceeds the socket read timeout, so the socket would give up first " + + "and the operation deadline would never be the effective one"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseEvidence.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseEvidence.java new file mode 100644 index 00000000..60f28108 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseEvidence.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * The evidence categories a Stable release must produce (design §30, Task 50). + * + *

Named categories rather than "all tests pass", because a suite can be green and still be + * missing a lane. Requiring a category to be present makes the absence of failover or compatibility + * evidence a failure rather than a silence. + */ +public record MongoStableReleaseEvidence(Set categories) { + + /** Every category the Stable gate requires. */ + public static final Set REQUIRED = + Set.of( + "mapping", + "transaction", + "migration", + "change-stream", + "security", + "failover", + "performance", + "compatibility"); + + public MongoStableReleaseEvidence { + Objects.requireNonNull(categories, "categories"); + categories = Set.copyOf(categories); + } + + /** Evidence with nothing recorded. */ + public static MongoStableReleaseEvidence empty() { + return new MongoStableReleaseEvidence(Set.of()); + } + + /** The evidence a complete release run produces. */ + public static MongoStableReleaseEvidence complete() { + return new MongoStableReleaseEvidence(REQUIRED); + } + + /** Returns a copy with one more category recorded. */ + public MongoStableReleaseEvidence with(String category) { + Set updated = new LinkedHashSet<>(categories); + updated.add(Objects.requireNonNull(category, "category")); + return new MongoStableReleaseEvidence(updated); + } + + /** + * Asserts one category is present. + * + * @throws IllegalStateException naming the missing category + */ + public void require(String category) { + if (!categories.contains(Objects.requireNonNull(category, "category"))) { + throw new IllegalStateException( + "the MongoDB Stable release gate is missing '" + + category + + "' evidence; the required categories are " + + REQUIRED); + } + } + + /** The categories that are still missing. */ + public Set missing() { + Set missing = new LinkedHashSet<>(REQUIRED); + missing.removeAll(categories); + return Set.copyOf(missing); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGate.java new file mode 100644 index 00000000..6224d0be --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGate.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import java.util.Objects; + +/** + * The single check that decides whether the Stable platform may ship (design §30, Task 50). + * + *

Every category is required, including the two teams most often skip. Compatibility, because it + * only fails for the customers still on the older server. Failover, because it only fails during an + * election — which is exactly when nobody is reading test reports. + */ +public final class MongoStableReleaseGate { + + /** + * Verifies a release run. + * + * @throws IllegalStateException naming the first missing category + */ + public void verify(MongoStableReleaseEvidence evidence) { + Objects.requireNonNull(evidence, "evidence"); + evidence.require("mapping"); + evidence.require("transaction"); + evidence.require("migration"); + evidence.require("change-stream"); + evidence.require("security"); + evidence.require("failover"); + evidence.require("performance"); + evidence.require("compatibility"); + } + + /** True when the release run produced every required category. */ + public boolean passes(MongoStableReleaseEvidence evidence) { + return Objects.requireNonNull(evidence, "evidence").missing().isEmpty(); + } + + /** + * Whether an Advanced capability may be part of this release. + * + *

Always false. Advanced capabilities have their own promotion gate with their own evidence, + * and folding them into the Stable gate would mean shipping something whose real topology was + * never exercised. + */ + public boolean includesAdvancedCapabilities() { + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidator.java new file mode 100644 index 00000000..f6f4cbf6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidator.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile; +import dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfileValidator; +import java.util.Objects; +import java.util.Optional; + +/** + * Refuses to start on any of the design's listed misconfigurations (design §28). + * + *

All of it runs before the first repository is created, which is the property that matters: a + * standalone production deployment, a runtime that can create indexes, or an admin client sharing + * the application's credential are all conditions that work fine until the day they do not, and by + * then they have been in production long enough to be load-bearing. + */ +public final class MongoStartupValidator { + + private final MongoPlatformProperties properties; + + private final MongoTopologyProbe probe; + + private final MongoSecurityProfile runtimeSecurity; + + private final MongoCredentialReference adminCredential; + + private final boolean transactionsEnabled; + + private final boolean changeStreamsEnabled; + + private final MongoSchemaVersionRange schemaVersionRange; + + public MongoStartupValidator( + MongoPlatformProperties properties, + MongoTopologyProbe probe, + MongoSecurityProfile runtimeSecurity, + MongoCredentialReference adminCredential, + boolean transactionsEnabled, + boolean changeStreamsEnabled, + MongoSchemaVersionRange schemaVersionRange) { + this.properties = Objects.requireNonNull(properties, "properties"); + this.probe = Objects.requireNonNull(probe, "probe"); + this.runtimeSecurity = Objects.requireNonNull(runtimeSecurity, "runtimeSecurity"); + this.adminCredential = adminCredential; + this.transactionsEnabled = transactionsEnabled; + this.changeStreamsEnabled = changeStreamsEnabled; + this.schemaVersionRange = Objects.requireNonNull(schemaVersionRange, "schemaVersionRange"); + } + + /** + * Runs every startup check. + * + * @throws MongoOperationRejectedException naming the first failed condition + */ + public void validate() { + properties.validate(); + new MongoSecurityProfileValidator().validate(runtimeSecurity); + requireDeclaredTopologyMatchesReality(); + requireCapabilitiesForEnabledFeatures(); + requireDistinctAdminCredential(); + requireSupportedSchemaRange(); + } + + private void requireDeclaredTopologyMatchesReality() { + properties + .profiles() + .forEach( + (name, profile) -> { + if (profile.production() && probe.observedTopology() == MongoTopology.STANDALONE) { + throw MongoOperationRejectedException.of( + "startup.topology", + "profile '" + + name + + "' is production but is connected to a STANDALONE server; a production " + + "profile requires REPLICA_SET, SHARDED or ATLAS"); + } + if (profile.topology() != probe.observedTopology()) { + throw MongoOperationRejectedException.of( + "startup.topology", + "profile '" + + name + + "' declares topology " + + profile.topology() + + " but is connected to " + + probe.observedTopology()); + } + }); + } + + private void requireCapabilitiesForEnabledFeatures() { + MongoCapabilitySet capabilities = probe.capabilities(); + if (transactionsEnabled && !capabilities.isStable(MongoCapability.TRANSACTION)) { + throw MongoOperationRejectedException.of( + "startup.capability", + "transactions are enabled but unavailable here: " + + capabilities.require(MongoCapability.TRANSACTION).constraints() + + "; a REPLICA_SET, SHARDED or ATLAS topology is required"); + } + if (changeStreamsEnabled && !capabilities.isStable(MongoCapability.CHANGE_STREAM)) { + throw MongoOperationRejectedException.of( + "startup.capability", + "change streams are enabled but unavailable here: " + + capabilities.require(MongoCapability.CHANGE_STREAM).constraints() + + "; a REPLICA_SET, SHARDED or ATLAS topology is required"); + } + } + + private void requireDistinctAdminCredential() { + Optional.ofNullable(adminCredential) + .ifPresent( + admin -> + new MongoSecurityProfileValidator() + .requireDistinctCredentials(runtimeSecurity.credential(), admin)); + } + + private void requireSupportedSchemaRange() { + if (schemaVersionRange.minimumSupported().compareTo(schemaVersionRange.current()) > 0) { + throw MongoOperationRejectedException.of( + "startup.schema", "the configured schema version range is inverted"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoTopologyProbe.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoTopologyProbe.java new file mode 100644 index 00000000..ed27fa3f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoTopologyProbe.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel; +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Derives the capability report from the observed topology (design §7.4, §28). + * + *

Configuration says which topology a deployment believes it has; the probe reports what it + * actually connected to. The startup validator compares the two, because "we configured a replica + * set" and "we are talking to one" are different claims and only the second one determines whether + * transactions work. + */ +public final class MongoTopologyProbe { + + private final MongoTopology observedTopology; + + private final String serverVersion; + + public MongoTopologyProbe(MongoTopology observedTopology, String serverVersion) { + this.observedTopology = Objects.requireNonNull(observedTopology, "observedTopology"); + this.serverVersion = Objects.requireNonNull(serverVersion, "serverVersion"); + } + + /** The topology this client is actually connected to. */ + public MongoTopology observedTopology() { + return observedTopology; + } + + /** The server version this client is actually talking to. */ + public String serverVersion() { + return serverVersion; + } + + /** The capabilities this topology and version support. */ + public MongoCapabilitySet capabilities() { + List supports = new ArrayList<>(); + supports.add(sessionBacked(MongoCapability.TRANSACTION)); + supports.add(sessionBacked(MongoCapability.CAUSAL_SESSION)); + supports.add(sessionBacked(MongoCapability.CHANGE_STREAM)); + supports.add(MongoCapabilitySupport.of(MongoCapability.GEOSPATIAL, MongoSupportLevel.STABLE)); + supports.add(MongoCapabilitySupport.of(MongoCapability.TTL_CLEANUP, MongoSupportLevel.STABLE)); + supports.add( + observedTopology == MongoTopology.SHARDED || observedTopology == MongoTopology.ATLAS + ? MongoCapabilitySupport.of(MongoCapability.SHARDING, MongoSupportLevel.ADVANCED) + : MongoCapabilitySupport.unsupported( + MongoCapability.SHARDING, "topology is " + observedTopology)); + supports.add( + MongoCapabilitySupport.of(MongoCapability.TIME_SERIES, MongoSupportLevel.ADVANCED) + .withConstraint(MongoCapabilitySupport.SERVER_VERSION, serverVersion)); + supports.add(MongoCapabilitySupport.of(MongoCapability.CSFLE, MongoSupportLevel.ADVANCED)); + supports.add( + MongoCapabilitySupport.of( + MongoCapability.QUERYABLE_ENCRYPTION, MongoSupportLevel.ADVANCED)); + supports.add( + observedTopology == MongoTopology.ATLAS + ? MongoCapabilitySupport.of(MongoCapability.SEARCH, MongoSupportLevel.ADVANCED) + : MongoCapabilitySupport.unsupported( + MongoCapability.SEARCH, "search requires an Atlas deployment")); + supports.add( + observedTopology == MongoTopology.ATLAS + ? MongoCapabilitySupport.of(MongoCapability.VECTOR_SEARCH, MongoSupportLevel.ADVANCED) + : MongoCapabilitySupport.unsupported( + MongoCapability.VECTOR_SEARCH, "vector search requires an Atlas deployment")); + supports.add( + MongoCapabilitySupport.of( + MongoCapability.SHARED_COLLECTION_TENANCY, MongoSupportLevel.ADVANCED)); + supports.add( + MongoCapabilitySupport.of( + MongoCapability.DATABASE_PER_TENANT, MongoSupportLevel.EXPERIMENTAL)); + supports.add( + MongoCapabilitySupport.of( + MongoCapability.GRIDFS_COMPATIBILITY, MongoSupportLevel.ADVANCED)); + supports.add( + MongoCapabilitySupport.unsupported( + MongoCapability.ADMIN_PLANE, + "the admin plane is not registered in an application runtime")); + return MongoCapabilitySet.of(supports); + } + + private MongoCapabilitySupport sessionBacked(MongoCapability capability) { + if (!observedTopology.supportsSessions()) { + return MongoCapabilitySupport.unsupported( + capability, "topology is STANDALONE, which has no oplog"); + } + return MongoCapabilitySupport.of(capability, MongoSupportLevel.STABLE) + .withConstraint(MongoCapabilitySupport.TOPOLOGY, observedTopology.name()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeEventIdentity.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeEventIdentity.java new file mode 100644 index 00000000..0ce1a549 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeEventIdentity.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * A stable identity for one change event (design §20.2, §35). + * + *

Change streams are at-least-once: a resume after a failover redelivers events the projector + * may already have applied. Deduplication therefore needs an identity that is the same across + * redeliveries — which the resume token is not, since it encodes the reading position rather than + * the event. + * + *

The identity is derived from cluster time, namespace, document key and operation type, and + * then hashed. Hashing is what lets the identity be stored and compared without the document key — + * a business identifier — sitting in a deduplication table in plaintext. + */ +public record MongoChangeEventIdentity(String value) { + + /** + * ASCII unit separator. + * + *

A control character cannot appear in a namespace, a cluster time or an operation type, so no + * combination of field values can be rearranged to forge another event's identity. + */ + private static final String SEPARATOR = String.valueOf((char) 0x1F); + + public MongoChangeEventIdentity { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("a change event identity must not be blank"); + } + } + + /** + * Derives the identity of one event. + * + * @param clusterTime the event's cluster time, as {@code seconds.increment} + * @param namespace the database and collection the event came from + * @param documentKey the event's document key, in canonical extended JSON + * @param operationType the change operation, such as {@code insert} or {@code update} + */ + public static MongoChangeEventIdentity of( + String clusterTime, String namespace, String documentKey, String operationType) { + Objects.requireNonNull(clusterTime, "clusterTime"); + Objects.requireNonNull(namespace, "namespace"); + Objects.requireNonNull(documentKey, "documentKey"); + Objects.requireNonNull(operationType, "operationType"); + return new MongoChangeEventIdentity( + sha256(String.join(SEPARATOR, clusterTime, namespace, documentKey, operationType))); + } + + private static String sha256(String material) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(material.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is required to derive change event identities"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamState.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamState.java new file mode 100644 index 00000000..462398af --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamState.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +/** + * The lifecycle state of one change stream subscription (design §20.1). + * + *

{@code HISTORY_LOST} is separate from {@code FAILED} because it is the one state the platform + * refuses to recover from on its own. Resuming from "now" after the oplog has rolled past the + * stored token silently discards every change in between — the projection then looks healthy and is + * quietly wrong, which is worse than a stopped consumer somebody has to look at. + */ +public enum MongoChangeStreamState { + + /** Opening the stream and resolving the resume position. */ + STARTING, + + /** Consuming events. */ + RUNNING, + + /** Recovering from a resumable failure such as a primary failover. */ + RESUMING, + + /** The oplog no longer contains the stored resume position. Consumption stops. */ + HISTORY_LOST, + + /** A non-resumable failure. Consumption stops. */ + FAILED, + + /** Stopped deliberately. */ + STOPPED; + + /** True when the platform may resume without an operator decision. */ + public boolean autoResumable() { + return this == RUNNING || this == RESUMING || this == STARTING; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamSubscription.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamSubscription.java new file mode 100644 index 00000000..d6a13716 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoChangeStreamSubscription.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName; +import java.time.Duration; +import java.util.Objects; + +/** + * How one change stream subscription is configured (design §20.1). + * + *

Batch size and maximum await time are both bounded. A change stream is a long-lived cursor: an + * unbounded batch buys throughput with heap, and an unbounded await time makes a stalled stream + * indistinguishable from an idle one, so nothing ever alerts. + */ +public record MongoChangeStreamSubscription( + String subscriptionProfile, + CollectionProfileName collectionProfile, + int batchSize, + Duration maxAwaitTime, + boolean fullDocumentBeforeChange) { + + /** The largest batch a change stream subscription may request. */ + public static final int MAX_BATCH_SIZE = 1000; + + public MongoChangeStreamSubscription { + Objects.requireNonNull(subscriptionProfile, "subscriptionProfile"); + Objects.requireNonNull(collectionProfile, "collectionProfile"); + Objects.requireNonNull(maxAwaitTime, "maxAwaitTime"); + if (subscriptionProfile.isBlank()) { + throw new IllegalArgumentException("a subscription needs a profile name"); + } + if (batchSize <= 0 || batchSize > MAX_BATCH_SIZE) { + throw new IllegalArgumentException( + "a change stream batch size must be between 1 and " + MAX_BATCH_SIZE); + } + if (maxAwaitTime.isZero() || maxAwaitTime.isNegative()) { + throw new IllegalArgumentException("a change stream max await time must be positive"); + } + } + + /** A subscription with the platform's default bounds. */ + public static MongoChangeStreamSubscription of( + String subscriptionProfile, String collectionProfile) { + return new MongoChangeStreamSubscription( + subscriptionProfile, + new CollectionProfileName(collectionProfile), + 100, + Duration.ofSeconds(1), + false); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpoint.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpoint.java new file mode 100644 index 00000000..240d4117 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpoint.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +import dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion; +import java.util.Base64; +import java.util.Objects; + +/** + * A durable, opaque change stream position (design §20.2, §20.3). + * + *

The token is held as encoded ciphertext and never rendered. A resume token encodes the cluster + * time and the document key of the last event, so printing one into a log leaks both the shape and + * the timing of production writes — which is why the design lists it among the forbidden telemetry + * values. {@link #toString()} reports only the token's length. + * + *

The cluster identity and schema version are stored alongside it. Replaying a token issued by a + * different cluster does not fail cleanly at the server, so the check has to happen here. + */ +public record MongoResumeCheckpoint( + String subscriptionProfile, + String encodedToken, + String clusterIdentity, + DocumentSchemaVersion schemaVersion, + MongoResumePosition position) { + + public MongoResumeCheckpoint { + Objects.requireNonNull(subscriptionProfile, "subscriptionProfile"); + Objects.requireNonNull(encodedToken, "encodedToken"); + Objects.requireNonNull(clusterIdentity, "clusterIdentity"); + Objects.requireNonNull(schemaVersion, "schemaVersion"); + Objects.requireNonNull(position, "position"); + if (encodedToken.isEmpty()) { + throw new IllegalArgumentException("a resume checkpoint needs a token"); + } + } + + /** A checkpoint for an ordinary resume, with the cluster identity still to be filled in. */ + public static MongoResumeCheckpoint encrypted(String subscriptionProfile, byte[] ciphertext) { + return new MongoResumeCheckpoint( + subscriptionProfile, + encode(ciphertext), + "", + new DocumentSchemaVersion(1), + MongoResumePosition.RESUME_AFTER); + } + + /** A checkpoint recorded after an invalidate event, which must be resumed with startAfter. */ + public static MongoResumeCheckpoint afterInvalidate( + String subscriptionProfile, byte[] ciphertext) { + return new MongoResumeCheckpoint( + subscriptionProfile, + encode(ciphertext), + "", + new DocumentSchemaVersion(1), + MongoResumePosition.START_AFTER); + } + + private static String encode(byte[] ciphertext) { + Objects.requireNonNull(ciphertext, "ciphertext"); + if (ciphertext.length == 0) { + throw new IllegalArgumentException("a resume checkpoint needs a token"); + } + return Base64.getEncoder().withoutPadding().encodeToString(ciphertext); + } + + /** The stored ciphertext. Decrypting it is the checkpoint store's responsibility. */ + public byte[] ciphertext() { + return Base64.getDecoder().decode(encodedToken); + } + + /** Returns a copy bound to a cluster identity. */ + public MongoResumeCheckpoint withClusterIdentity(String identity) { + return new MongoResumeCheckpoint( + subscriptionProfile, + encodedToken, + Objects.requireNonNull(identity, "identity"), + schemaVersion, + position); + } + + /** True when this checkpoint was issued by the given cluster. */ + public boolean belongsTo(String identity) { + return clusterIdentity.equals(Objects.requireNonNull(identity, "identity")); + } + + /** Describes the checkpoint without any part of the token. */ + @Override + public String toString() { + return "MongoResumeCheckpoint[subscription=" + + subscriptionProfile + + ", cluster=" + + clusterIdentity + + ", schemaVersion=" + + schemaVersion + + ", position=" + + position + + ", tokenChars=" + + encodedToken.length() + + "]"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStore.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStore.java new file mode 100644 index 00000000..ccac8af5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStore.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +import java.util.Optional; +import reactor.core.publisher.Mono; + +/** + * Durable storage for change stream positions (design §20.2). + * + *

The store must survive a process restart, because that is the only thing standing between a + * consumer restart and a full reprojection. It also has to be written after the projection + * succeeds — the runner enforces that ordering, and this interface exists so nothing else can write + * a checkpoint out of band. + */ +public interface MongoResumeCheckpointStore { + + /** The stored position for a subscription, if one exists. */ + Mono> load(String subscriptionProfile); + + /** Stores a position. Called only after the projection for that event has completed. */ + Mono save(MongoResumeCheckpoint checkpoint); + + /** Discards a stored position, used by an operator-driven rebuild. */ + Mono clear(String subscriptionProfile); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumePosition.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumePosition.java new file mode 100644 index 00000000..22ee8f03 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumePosition.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +/** + * Which resume option a stored checkpoint must be replayed with (design §20.3). + * + *

The two are not interchangeable. {@code resumeAfter} refuses a token taken from an invalidate + * event, so a subscription that stores the position without also storing which option produced it + * cannot be restarted after a collection was dropped or renamed — precisely the case where + * restarting matters. + */ +public enum MongoResumePosition { + + /** Ordinary resume from the last processed event. */ + RESUME_AFTER, + + /** Resume after an invalidate event, which {@code resumeAfter} cannot express. */ + START_AFTER +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeDeduplicationStore.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeDeduplicationStore.java new file mode 100644 index 00000000..1a6d4cc9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeDeduplicationStore.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.projector; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity; +import reactor.core.publisher.Mono; + +/** + * Remembers which change events have already been projected (design §20.2). + * + *

Needed because the checkpoint is written after the projection, which is what prevents event + * loss and therefore guarantees duplicates on restart. The store is what keeps those duplicates + * from repeating a side effect. + * + *

Entries are expected to expire: the window that matters is bounded by how far a resume can + * rewind, not by the retention of the projection itself. + */ +public interface MongoChangeDeduplicationStore { + + /** True when this event has already been projected. */ + Mono alreadyProjected(MongoChangeEventIdentity identity); + + /** Records that this event has been projected. */ + Mono markProjected(MongoChangeEventIdentity identity); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjectionResult.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjectionResult.java new file mode 100644 index 00000000..e9dd66c8 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjectionResult.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.projector; + +import java.util.Objects; + +/** + * What a projector did with one event (design §20.2, §35). + * + *

{@code SKIPPED_DUPLICATE} is distinct from {@code APPLIED} because both are successes and only + * one of them changed anything. Reporting them separately is what lets a duplicate rate be observed + * — and a duplicate rate that suddenly rises is the first visible sign of a resume loop. + */ +public record MongoChangeProjectionResult(Outcome outcome, String detail) { + + public MongoChangeProjectionResult { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(detail, "detail"); + } + + /** The projection ran and changed state. */ + public static MongoChangeProjectionResult applied() { + return new MongoChangeProjectionResult(Outcome.APPLIED, ""); + } + + /** The event had already been applied; nothing changed. */ + public static MongoChangeProjectionResult duplicate() { + return new MongoChangeProjectionResult(Outcome.SKIPPED_DUPLICATE, ""); + } + + /** The event could not be interpreted and was routed to the parking workflow. */ + public static MongoChangeProjectionResult parked(String reason) { + return new MongoChangeProjectionResult(Outcome.PARKED, reason); + } + + /** True when the checkpoint may advance past this event. */ + public boolean allowsCheckpointAdvance() { + return outcome != Outcome.PARKED; + } + + /** What happened to the event. */ + public enum Outcome { + + /** The projection ran and changed state. */ + APPLIED, + + /** The event was already applied. */ + SKIPPED_DUPLICATE, + + /** The event was malformed and was parked for operator review. */ + PARKED + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjector.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjector.java new file mode 100644 index 00000000..75e656da --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeProjector.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.projector; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeEventIdentity; +import org.bson.BsonDocument; +import reactor.core.publisher.Mono; + +/** + * Applies one physical change event to an internal projection (design §20.4, D-12). + * + *

The projector receives the raw BSON change and must not forward it anywhere external. A change + * event's shape is a MongoDB implementation detail — field names, update descriptions, the presence + * of {@code fullDocument} — and publishing it as an integration contract couples every consumer to + * this collection's storage layout, so a schema migration becomes a breaking API change. + * + *

Implementations must be idempotent. The runner checkpoints after a successful projection, so + * redelivery of an already-applied event is normal rather than exceptional. + */ +@FunctionalInterface +public interface MongoChangeProjector { + + /** Projects one event. Must be idempotent with respect to the identity. */ + Mono project(MongoChangeEventIdentity identity, BsonDocument change); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunner.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunner.java new file mode 100644 index 00000000..c0129036 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunner.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.projector; + +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; + +/** + * Projects one event, then checkpoints — in that order (design §20.2). + * + *

The ordering is the entire contract. Checkpointing first would mean a crash between the two + * loses the event permanently, with no trace: the stream resumes past a change that was never + * projected. Projecting first means a crash redelivers the event, which the deduplication store and + * an idempotent projector absorb. + * + *

So the platform chooses duplicates over loss, and then removes the duplicates. A failed + * projection leaves the previous checkpoint exactly where it was. + */ +public final class MongoChangeStreamRunner { + + private final MongoChangeProjector projector; + + private final MongoChangeDeduplicationStore deduplication; + + private final MongoResumeCheckpointStore checkpoints; + + public MongoChangeStreamRunner( + MongoChangeProjector projector, + MongoChangeDeduplicationStore deduplication, + MongoResumeCheckpointStore checkpoints) { + this.projector = Objects.requireNonNull(projector, "projector"); + this.deduplication = Objects.requireNonNull(deduplication, "deduplication"); + this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints"); + } + + /** + * Handles one event end to end. + * + * @param identity the redelivery-stable identity of the event + * @param change the raw change document + * @param checkpoint the position to store once the projection has succeeded + */ + public Mono runOne( + MongoChangeEventIdentity identity, BsonDocument change, MongoResumeCheckpoint checkpoint) { + Objects.requireNonNull(identity, "identity"); + Objects.requireNonNull(change, "change"); + Objects.requireNonNull(checkpoint, "checkpoint"); + + return deduplication + .alreadyProjected(identity) + .flatMap( + alreadyProjected -> + Boolean.TRUE.equals(alreadyProjected) + ? Mono.just(MongoChangeProjectionResult.duplicate()) + : projectAndRemember(identity, change)) + .flatMap( + result -> + result.allowsCheckpointAdvance() + ? checkpoints.save(checkpoint).thenReturn(result) + : Mono.just(result)); + } + + private Mono projectAndRemember( + MongoChangeEventIdentity identity, BsonDocument change) { + return projector + .project(identity, change) + .flatMap( + result -> + result.allowsCheckpointAdvance() + ? deduplication.markProjected(identity).thenReturn(result) + : Mono.just(result)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeHistoryLostException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeHistoryLostException.java new file mode 100644 index 00000000..a956f31f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeHistoryLostException.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.recovery; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import java.io.Serial; +import java.util.Objects; + +/** + * The oplog no longer contains the stored resume position (design §20.3). + * + *

A dedicated type because the recovery is a business decision, not a technical one. The + * platform could resume from "now" — the driver makes it easy — and the projection would silently + * miss every change that fell off the oplog. The only honest options are rebuilding the projection + * or reconciling it against the source, and both need someone to choose. + */ +public final class MongoChangeHistoryLostException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + private final String subscriptionProfile; + + public MongoChangeHistoryLostException( + MongoFailureContext failureContext, String subscriptionProfile) { + super( + "the change stream's resume position is no longer in the oplog; consumption stopped rather " + + "than restarting from now and silently skipping the gap", + failureContext); + this.subscriptionProfile = Objects.requireNonNull(subscriptionProfile, "subscriptionProfile"); + } + + /** The subscription whose history was lost. */ + public String subscriptionProfile() { + return subscriptionProfile; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryDecision.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryDecision.java new file mode 100644 index 00000000..8b02975c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryDecision.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.recovery; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState; +import java.util.Objects; + +/** + * What to do about a change stream failure (design §20.3). + * + *

{@code autoResume} and {@code requiredRunbook} are both present because the interesting case + * needs both answers: the platform will not resume, and a human needs to know which procedure + * applies. A decision that only says "stopped" leaves the on-call engineer to work out whether the + * projection can be rebuilt, and from what. + */ +public record MongoChangeStreamRecoveryDecision( + MongoChangeStreamState state, boolean autoResume, String requiredRunbook) { + + public MongoChangeStreamRecoveryDecision { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(requiredRunbook, "requiredRunbook"); + if (!autoResume && requiredRunbook.isBlank() && state != MongoChangeStreamState.STOPPED) { + throw new IllegalArgumentException( + "a decision that stops consumption must name the runbook that resolves it"); + } + } + + /** Resume automatically from the stored checkpoint. */ + public static MongoChangeStreamRecoveryDecision resume() { + return new MongoChangeStreamRecoveryDecision(MongoChangeStreamState.RESUMING, true, ""); + } + + /** Stop and wait for an operator. */ + public static MongoChangeStreamRecoveryDecision halt( + MongoChangeStreamState state, String runbook) { + return new MongoChangeStreamRecoveryDecision(state, false, runbook); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicy.java new file mode 100644 index 00000000..fe947e6c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicy.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.recovery; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import java.util.Objects; + +/** + * Decides how a change stream reacts to each kind of failure (design §20.3). + * + *

Three classes with three different answers. A resumable network or election failure resumes + * automatically, because the stored token is still valid. An invalidate — a dropped or renamed + * collection — needs {@code startAfter} rather than {@code resumeAfter}, which the driver will not + * do implicitly. A lost history needs a human. + */ +public final class MongoChangeStreamRecoveryPolicy { + + /** Runbook for a subscription whose oplog history was lost. */ + public static final String HISTORY_LOST_RUNBOOK = "docs/mongodb/runbooks/history-lost.md"; + + /** Runbook for a subscription that failed for a non-resumable reason. */ + public static final String FAILURE_RUNBOOK = "docs/mongodb/runbooks/failover.md"; + + /** + * The decision for a lost oplog history. + * + *

Never auto-resumes. The alternative — restarting from the current time — produces a + * projection that is missing an unknown range of changes and reports itself as healthy. + */ + public MongoChangeStreamRecoveryDecision onHistoryLost(String subscriptionProfile) { + Objects.requireNonNull(subscriptionProfile, "subscriptionProfile"); + return MongoChangeStreamRecoveryDecision.halt( + MongoChangeStreamState.HISTORY_LOST, HISTORY_LOST_RUNBOOK); + } + + /** The decision for a resumable failure such as a primary failover. */ + public MongoChangeStreamRecoveryDecision onResumableFailure() { + return MongoChangeStreamRecoveryDecision.resume(); + } + + /** + * The decision for an invalidate event. + * + *

Resumes, but the checkpoint the caller stores must be a {@code startAfter} position — the + * subscription is otherwise unresumable, because {@code resumeAfter} rejects an invalidate token. + */ + public MongoChangeStreamRecoveryDecision onInvalidate() { + return MongoChangeStreamRecoveryDecision.resume(); + } + + /** The decision for a failure the platform cannot classify as resumable. */ + public MongoChangeStreamRecoveryDecision onFailure(MongoDriverFailureView failure) { + Objects.requireNonNull(failure, "failure"); + if (failure.hasServerCode() && isHistoryLost(failure.serverCode())) { + return MongoChangeStreamRecoveryDecision.halt( + MongoChangeStreamState.HISTORY_LOST, HISTORY_LOST_RUNBOOK); + } + if (failure.hasLabel("ResumableChangeStreamError")) { + return MongoChangeStreamRecoveryDecision.resume(); + } + return MongoChangeStreamRecoveryDecision.halt(MongoChangeStreamState.FAILED, FAILURE_RUNBOOK); + } + + /** {@code ChangeStreamHistoryLost} and {@code ChangeStreamFatalError}. */ + private static boolean isHistoryLost(int serverCode) { + return serverCode == 286 || serverCode == 280; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoInvalidateRecovery.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoInvalidateRecovery.java new file mode 100644 index 00000000..88ca505e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoInvalidateRecovery.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.recovery; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint; +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition; +import java.util.Objects; + +/** + * Turns an invalidate event into a resumable position (design §20.3). + * + *

An invalidate closes the change stream — the collection was dropped, renamed, or the database + * dropped. Its token cannot be replayed with {@code resumeAfter}; only {@code startAfter} accepts + * it. Recording that at the moment the invalidate arrives is what keeps the subscription + * restartable, because by the time the restart is attempted the distinction is no longer + * recoverable. + */ +public final class MongoInvalidateRecovery { + + /** Converts the invalidate token into a checkpoint marked for {@code startAfter}. */ + public MongoResumeCheckpoint checkpointFor(String subscriptionProfile, byte[] invalidateToken) { + Objects.requireNonNull(subscriptionProfile, "subscriptionProfile"); + Objects.requireNonNull(invalidateToken, "invalidateToken"); + return MongoResumeCheckpoint.afterInvalidate(subscriptionProfile, invalidateToken); + } + + /** + * Rejects a checkpoint that would be replayed with the wrong resume option. + * + * @throws IllegalStateException when an invalidate checkpoint is about to be used with {@code + * resumeAfter} + */ + public void requireCorrectResumeOption( + MongoResumeCheckpoint checkpoint, MongoResumePosition intended) { + Objects.requireNonNull(checkpoint, "checkpoint"); + Objects.requireNonNull(intended, "intended"); + if (checkpoint.position() != intended) { + throw new IllegalStateException( + "checkpoint for subscription '" + + checkpoint.subscriptionProfile() + + "' must be replayed with " + + checkpoint.position() + + " but " + + intended + + " was requested"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifier.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifier.java new file mode 100644 index 00000000..74175d3b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifier.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import java.util.Map; +import java.util.Objects; + +/** + * The platform's server-code and error-label mapping (design §15). + * + *

Order matters and is fixed here: labels first, then numeric codes, and never message text. The + * labels are the server's own statement about recoverability, and they are the only signal that + * distinguishes the two transaction failures whose correct responses are opposite. Message text is + * excluded because it is unstable across releases and because it is the one field that contains + * data. + * + *

An unrecognised code lands in {@link MongoFailureCategory#UNCLASSIFIED} rather than becoming a + * new category. The code itself still reaches the failure context, so diagnosis keeps the detail + * while metrics keep a bounded dimension. + */ +public final class DefaultMongoFailureClassifier implements MongoFailureClassifier { + + /** Server codes whose meaning the platform maps to a stable category. */ + private static final Map CATEGORY_BY_CODE = + Map.ofEntries( + Map.entry(11000, MongoFailureCategory.DUPLICATE_KEY), + Map.entry(11001, MongoFailureCategory.DUPLICATE_KEY), + Map.entry(12582, MongoFailureCategory.DUPLICATE_KEY), + Map.entry(121, MongoFailureCategory.SCHEMA_VALIDATION), + Map.entry(112, MongoFailureCategory.WRITE_CONFLICT), + Map.entry(64, MongoFailureCategory.WRITE_CONCERN), + Map.entry(79, MongoFailureCategory.WRITE_CONCERN), + Map.entry(100, MongoFailureCategory.WRITE_CONCERN), + Map.entry(148, MongoFailureCategory.READ_CONCERN), + Map.entry(246, MongoFailureCategory.READ_CONCERN), + Map.entry(133, MongoFailureCategory.SERVER_SELECTION), + Map.entry(6, MongoFailureCategory.CONNECTION), + Map.entry(7, MongoFailureCategory.CONNECTION), + Map.entry(9001, MongoFailureCategory.CONNECTION), + Map.entry(89, MongoFailureCategory.TIMEOUT), + Map.entry(50, MongoFailureCategory.TIMEOUT), + Map.entry(262, MongoFailureCategory.TIMEOUT), + Map.entry(43, MongoFailureCategory.CURSOR), + Map.entry(237, MongoFailureCategory.CURSOR), + Map.entry(10334, MongoFailureCategory.DOCUMENT_TOO_LARGE), + Map.entry(17420, MongoFailureCategory.DOCUMENT_TOO_LARGE), + Map.entry(63, MongoFailureCategory.SHARD_ROUTING), + Map.entry(13388, MongoFailureCategory.SHARD_ROUTING), + Map.entry(286, MongoFailureCategory.RESUME), + Map.entry(280, MongoFailureCategory.RESUME), + Map.entry(31, MongoFailureCategory.ENCRYPTION)); + + /** Codes that mean the server never applied anything, so the operation may simply be re-sent. */ + private static final Map SAFE_TO_RESEND = + Map.of( + 133, true, + 6, true, + 7, true, + 9001, true, + 91, true, + 189, true, + 11602, true); + + @Override + public MongoFailureClassification classify(MongoDriverFailureView failure) { + Objects.requireNonNull(failure, "failure"); + + // Labels first: the server's own recoverability statement outranks any code table, and these + // two labels are the ones whose correct responses are opposite. + if (failure.hasLabel(MongoDriverFailureView.UNKNOWN_TRANSACTION_COMMIT_RESULT)) { + return MongoFailureClassification.commitUnknown(); + } + if (failure.hasLabel(MongoDriverFailureView.TRANSIENT_TRANSACTION_ERROR)) { + return MongoFailureClassification.transientTransaction(); + } + if (failure.hasLabel(MongoDriverFailureView.NO_WRITES_PERFORMED)) { + return new MongoFailureClassification( + categoryOf(failure), MongoExecutionOutcome.NO_WRITE_PERFORMED, MongoRetryScope.NONE); + } + + MongoFailureCategory category = categoryOf(failure); + + // A sent command with no response is ambiguous whatever the code says: the write may have been + // applied and only the acknowledgement lost. Replaying the body here is how duplicates are + // made. + if (failure.outcomeIsUnknown() && !isDefinitelyUnapplied(failure)) { + return MongoFailureClassification.ambiguousWrite(category); + } + if (isDefinitelyUnapplied(failure)) { + return MongoFailureClassification.retryableOperation(category); + } + if (category == MongoFailureCategory.WRITE_CONCERN) { + // The primary usually applied the write; only replication acknowledgement fell short. + return MongoFailureClassification.ambiguousWrite(category); + } + return MongoFailureClassification.nonRetryable(category); + } + + private static MongoFailureCategory categoryOf(MongoDriverFailureView failure) { + if (!failure.hasServerCode()) { + return failure.hasLabel(MongoDriverFailureView.RETRYABLE_WRITE_ERROR) + ? MongoFailureCategory.CONNECTION + : MongoFailureCategory.UNCLASSIFIED; + } + return CATEGORY_BY_CODE.getOrDefault(failure.serverCode(), MongoFailureCategory.UNCLASSIFIED); + } + + private static boolean isDefinitelyUnapplied(MongoDriverFailureView failure) { + if (!failure.commandWasSent()) { + return true; + } + return failure.hasServerCode() && Boolean.TRUE.equals(SAFE_TO_RESEND.get(failure.serverCode())); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureTranslator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureTranslator.java new file mode 100644 index 00000000..64c46682 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureTranslator.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoConnectionException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoCursorException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoDocumentTooLargeException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoDuplicateKeyException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoEncryptionException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoReadConcernException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoResumeException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoServerSelectionException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoShardRoutingException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTimeoutException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoUnclassifiedFailureException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConcernException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoWriteConflictException; +import java.time.Duration; +import java.util.Objects; + +/** + * The one place a driver failure becomes a platform exception (design §15). + * + *

Classification and construction are separate steps on purpose. The classifier decides the + * recovery semantics from labels and codes; this class only chooses which stable type carries them. + * That split keeps the recovery rules testable without constructing exceptions, and keeps the + * exception hierarchy free of classification logic. + */ +public final class DefaultMongoFailureTranslator implements MongoFailureTranslator { + + private final MongoFailureClassifier classifier; + + public DefaultMongoFailureTranslator(MongoFailureClassifier classifier) { + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + /** A translator over the platform's standard classifier. */ + public static DefaultMongoFailureTranslator standard() { + return new DefaultMongoFailureTranslator(new DefaultMongoFailureClassifier()); + } + + @Override + public MongoPersistenceException translate( + MongoOperationContext context, + MongoOperationType operationType, + Duration elapsed, + MongoDriverFailureView failure, + int attempt) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(elapsed, "elapsed"); + Objects.requireNonNull(failure, "failure"); + + MongoFailureClassification classification = classifier.classify(failure); + MongoFailureContext failureContext = + new MongoFailureContext( + MongoOperationScope.of(context), + operationType, + context.consistency(), + classification.category(), + classification.outcome(), + classification.bodyReplayAllowed(), + classification.ambiguous(), + failure.errorLabels(), + failure.hasServerCode() ? Integer.toString(failure.serverCode()) : "", + attempt, + elapsed, + ""); + + return switch (classification.category()) { + case DUPLICATE_KEY -> new MongoDuplicateKeyException(failureContext); + case SCHEMA_VALIDATION -> new MongoSchemaValidationException(failureContext); + case OPTIMISTIC_CONFLICT -> new MongoOptimisticConflictException(failureContext); + case WRITE_CONFLICT -> new MongoWriteConflictException(failureContext); + case TRANSACTION_TRANSIENT -> new MongoTransactionTransientException(failureContext); + case TRANSACTION_COMMIT_UNKNOWN -> + new MongoTransactionCommitUnknownException( + failureContext, "read the transaction record, version or idempotency key"); + case WRITE_CONCERN -> new MongoWriteConcernException(failureContext); + case READ_CONCERN -> new MongoReadConcernException(failureContext); + case SERVER_SELECTION -> new MongoServerSelectionException(failureContext); + case CONNECTION -> new MongoConnectionException(failureContext); + case TIMEOUT -> new MongoTimeoutException(failureContext); + case CURSOR -> new MongoCursorException(failureContext); + case DOCUMENT_TOO_LARGE -> new MongoDocumentTooLargeException(failureContext, -1L, -1L); + case SHARD_ROUTING -> new MongoShardRoutingException(failureContext); + case RESUME -> new MongoResumeException(failureContext); + case ENCRYPTION -> new MongoEncryptionException(failureContext); + case SCHEMA_VERSION_UNSUPPORTED -> + new MongoDataSchemaUnsupportedException(failureContext, -1, -1, -1); + // Bulk partial failure and local rejection are raised by the layers that own their detail — + // per-item results and the guardrail that refused — so a failure that reaches translation + // carrying either category is one this release does not recognise. + case BULK_PARTIAL_FAILURE, OPERATION_REJECTED, UNCLASSIFIED -> + new MongoUnclassifiedFailureException(failureContext); + }; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoDriverFailureView.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoDriverFailureView.java new file mode 100644 index 00000000..42607f5e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoDriverFailureView.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import com.mongodb.MongoCommandException; +import com.mongodb.MongoException; +import com.mongodb.MongoSocketException; +import com.mongodb.MongoWriteException; +import java.util.Objects; +import java.util.Set; + +/** + * The only part of a driver exception the platform is willing to look at (design §15). + * + *

A driver exception carries the failed command, and the failed command carries the document, + * the filter and sometimes the credential. Narrowing it to labels, a numeric code and two booleans + * at the single point where the driver type is still in scope means no later layer can reach the + * rest, because no later layer is ever handed it. + * + *

The message text is deliberately absent. Classification reads labels and codes first precisely + * so it never has to parse a message, and a message is the one field that reliably contains data. + */ +public record MongoDriverFailureView( + Set errorLabels, int serverCode, boolean commandWasSent, boolean responseWasReceived) { + + /** Driver label: the whole transaction may be replayed from a new session. */ + public static final String TRANSIENT_TRANSACTION_ERROR = "TransientTransactionError"; + + /** Driver label: the commit outcome is unknown; only the commit may be retried. */ + public static final String UNKNOWN_TRANSACTION_COMMIT_RESULT = "UnknownTransactionCommitResult"; + + /** Driver label: the server confirmed that no write was applied. */ + public static final String NO_WRITES_PERFORMED = "NoWritesPerformed"; + + /** Driver label: the driver considers the write safe to retry. */ + public static final String RETRYABLE_WRITE_ERROR = "RetryableWriteError"; + + /** Sentinel used when the failure carries no server code. */ + public static final int NO_SERVER_CODE = -1; + + public MongoDriverFailureView { + Objects.requireNonNull(errorLabels, "errorLabels"); + errorLabels = Set.copyOf(errorLabels); + } + + /** + * A view carrying only the given label; used by classification tests and by label-only failures. + */ + public static MongoDriverFailureView withLabel(String label) { + return new MongoDriverFailureView(Set.of(label), NO_SERVER_CODE, true, false); + } + + /** A view carrying only a server code. */ + public static MongoDriverFailureView withServerCode(int serverCode) { + return new MongoDriverFailureView(Set.of(), serverCode, true, true); + } + + /** + * Narrows a driver exception, dropping the command, the response document and the message. + * + *

{@code commandWasSent} is conservative on purpose: a socket failure that happened while + * opening a connection is knowable, but any other socket failure could have been a lost response + * to a write that did apply, so it is treated as sent. + */ + public static MongoDriverFailureView from(MongoException exception) { + Objects.requireNonNull(exception, "exception"); + int serverCode = serverCodeOf(exception); + boolean socketFailure = exception instanceof MongoSocketException; + return new MongoDriverFailureView( + Set.copyOf(exception.getErrorLabels()), serverCode, true, !socketFailure); + } + + private static int serverCodeOf(MongoException exception) { + if (exception instanceof MongoWriteException writeException) { + return writeException.getError().getCode(); + } + if (exception instanceof MongoCommandException commandException) { + return commandException.getErrorCode(); + } + int code = exception.getCode(); + return code == 0 ? NO_SERVER_CODE : code; + } + + /** True when the driver attached the given label. */ + public boolean hasLabel(String label) { + return errorLabels.contains(label); + } + + /** True when a server code is present. */ + public boolean hasServerCode() { + return serverCode != NO_SERVER_CODE; + } + + /** True when the platform cannot know whether the write was applied. */ + public boolean outcomeIsUnknown() { + return commandWasSent && !responseWasReceived; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassification.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassification.java new file mode 100644 index 00000000..305db8f2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassification.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import java.util.Objects; + +/** + * What one driver failure means in the platform's own vocabulary (design §15). + * + *

Category, outcome and retry scope travel together because reading any one of them alone leads + * to the wrong recovery. "Timeout" alone says nothing about whether the write applied; the outcome + * does. "Retryable" alone says nothing about what may be retried; the scope does. + */ +public record MongoFailureClassification( + MongoFailureCategory category, MongoExecutionOutcome outcome, MongoRetryScope retryScope) { + + public MongoFailureClassification { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(retryScope, "retryScope"); + if (retryScope == MongoRetryScope.COMMIT_ONLY + && outcome != MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN) { + throw new IllegalArgumentException("commit-only retry only applies to an unknown commit"); + } + } + + /** {@code UnknownTransactionCommitResult}: retry the commit, never the body. */ + public static MongoFailureClassification commitUnknown() { + return new MongoFailureClassification( + MongoFailureCategory.TRANSACTION_COMMIT_UNKNOWN, + MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN, + MongoRetryScope.COMMIT_ONLY); + } + + /** {@code TransientTransactionError}: replay the whole body from a new session. */ + public static MongoFailureClassification transientTransaction() { + return new MongoFailureClassification( + MongoFailureCategory.TRANSACTION_TRANSIENT, + MongoExecutionOutcome.NO_WRITE_PERFORMED, + MongoRetryScope.WHOLE_TRANSACTION); + } + + /** A failure that is safe to re-send because nothing was applied. */ + public static MongoFailureClassification retryableOperation(MongoFailureCategory category) { + return new MongoFailureClassification( + category, MongoExecutionOutcome.NOT_SENT, MongoRetryScope.WHOLE_OPERATION); + } + + /** A failure whose write outcome is unknown; recovery reads durable evidence. */ + public static MongoFailureClassification ambiguousWrite(MongoFailureCategory category) { + return new MongoFailureClassification( + category, MongoExecutionOutcome.WRITE_RESULT_UNKNOWN, MongoRetryScope.RECONCILIATION); + } + + /** A definite failure that must not be retried by the platform. */ + public static MongoFailureClassification nonRetryable(MongoFailureCategory category) { + return new MongoFailureClassification( + category, MongoExecutionOutcome.NO_WRITE_PERFORMED, MongoRetryScope.NONE); + } + + /** + * The fallback for a server code the platform does not recognise. + * + *

Every unrecognised code maps to the same category on purpose. Deriving a category from the + * code would let an unknown server release invent new metric dimensions at runtime; the code + * itself still travels to the failure context, so diagnosis keeps the detail that dashboards must + * not carry. + */ + public static MongoFailureClassification unrecognisedServerCode() { + return nonRetryable(MongoFailureCategory.UNCLASSIFIED); + } + + /** True when the caller may replay the same business body. */ + public boolean bodyReplayAllowed() { + return retryScope == MongoRetryScope.WHOLE_OPERATION + || retryScope == MongoRetryScope.WHOLE_TRANSACTION; + } + + /** True when the caller cannot conclude whether data changed. */ + public boolean ambiguous() { + return outcome.isAmbiguous(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassifier.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassifier.java new file mode 100644 index 00000000..c280434f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureClassifier.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +/** + * Turns a narrowed driver failure into the platform's stable recovery semantics (design §15). + * + *

An interface rather than a static utility so a deployment can extend the mapping for a + * provider-specific code without forking the platform — and so tests can classify a synthetic + * failure without a server. + */ +@FunctionalInterface +public interface MongoFailureClassifier { + + /** Classifies one failure. Never returns {@code null}. */ + MongoFailureClassification classify(MongoDriverFailureView failure); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureTranslator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureTranslator.java new file mode 100644 index 00000000..247affc2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoFailureTranslator.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import java.time.Duration; + +/** + * Turns a driver failure into the platform's stable exception, exactly once (design §15). + * + *

"Exactly once" is the contract that matters. Translating at more than one layer produces an + * exception whose category was decided by whichever layer got there first, and translating at none + * lets a driver type — with the failed command still attached — escape into application code. + */ +public interface MongoFailureTranslator { + + /** + * Translates a failure raised while running an operation. + * + * @param context the operation that failed + * @param operationType the shape of database work it was + * @param elapsed how long the attempt took + * @param failure the narrowed driver failure + * @param attempt the 1-based attempt number + */ + MongoPersistenceException translate( + MongoOperationContext context, + MongoOperationType operationType, + Duration elapsed, + MongoDriverFailureView failure, + int attempt); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoDistance.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoDistance.java new file mode 100644 index 00000000..72953a64 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoDistance.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import java.util.Objects; +import org.springframework.data.geo.Distance; +import org.springframework.data.geo.Metrics; + +/** + * A distance with its unit attached (design §21.2). + * + *

MongoDB's spherical operators take metres, its legacy operators take radians, and Spring Data + * takes a {@code Distance} with a metric. A bare {@code double} passed between them is off by a + * factor of six million and still returns results, so the unit is part of the type here. + */ +public record MongoGeoDistance(double value, MongoDistanceUnit unit) { + + public MongoGeoDistance { + Objects.requireNonNull(unit, "unit"); + if (value <= 0) { + throw new IllegalArgumentException("a geo distance must be positive"); + } + } + + /** A distance in metres. */ + public static MongoGeoDistance ofMeters(double meters) { + return new MongoGeoDistance(meters, MongoDistanceUnit.METERS); + } + + /** A distance in kilometres. */ + public static MongoGeoDistance ofKilometers(double kilometers) { + return new MongoGeoDistance(kilometers, MongoDistanceUnit.KILOMETERS); + } + + /** This distance in metres, whatever unit it was declared in. */ + public double toMeters() { + return unit == MongoDistanceUnit.KILOMETERS ? value * 1000 : value; + } + + /** The Spring Data distance for this value. */ + public Distance toSpringDistance() { + return unit == MongoDistanceUnit.KILOMETERS + ? new Distance(value, Metrics.KILOMETERS) + : new Distance(value / 1000, Metrics.KILOMETERS); + } + + /** The unit a geo distance is expressed in. */ + public enum MongoDistanceUnit { + + /** Metres, the unit MongoDB's spherical operators use. */ + METERS, + + /** Kilometres. */ + KILOMETERS + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPoint.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPoint.java new file mode 100644 index 00000000..944c7b86 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPoint.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import org.springframework.data.mongodb.core.geo.GeoJsonPoint; + +/** + * A GeoJSON point, longitude first (design §21.2). + * + *

The component order is the single most common source of silent geospatial bugs. GeoJSON is + * {@code [longitude, latitude]}; almost every human-facing format, map URL and API is {@code + * (latitude, longitude)}. Swapped coordinates usually stay inside valid ranges, so nothing fails — + * the query simply returns results from somewhere else on the planet. + * + *

Range validation catches the subset of swaps that can be caught: a latitude above 90 in the + * longitude position. The naming catches the rest, which is why the record component is {@code + * longitude} rather than {@code x}. + */ +public record MongoGeoPoint(double longitude, double latitude) { + + public MongoGeoPoint { + if (longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) { + throw new IllegalArgumentException( + "invalid GeoJSON coordinate (" + + longitude + + ", " + + latitude + + "); GeoJSON is longitude first, and longitude is [-180, 180], latitude is [-90, 90]"); + } + } + + /** The Spring Data GeoJSON point for this coordinate. */ + public GeoJsonPoint toGeoJsonPoint() { + return new GeoJsonPoint(longitude, latitude); + } + + @Override + public String toString() { + return "[" + longitude + ", " + latitude + "]"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoQuery.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoQuery.java new file mode 100644 index 00000000..214513c7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoQuery.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; + +/** + * A bounded proximity query (design §21.2). + * + *

Both a maximum distance and a result limit are mandatory. {@code $near} sorts the entire + * collection by distance and streams it: without a distance bound the query is a full scan that + * happens to return the closest documents first, which looks fast in a small test dataset and + * degrades continuously as the collection grows. + */ +public record MongoGeoQuery( + String field, MongoGeoPoint center, MongoGeoDistance maxDistance, int resultLimit) { + + /** The largest result set a proximity query may request. */ + public static final int MAX_RESULT_LIMIT = 500; + + public MongoGeoQuery { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(center, "center"); + Objects.requireNonNull(maxDistance, "maxDistance"); + if (field.isBlank()) { + throw new IllegalArgumentException("a geo query needs a field"); + } + if (resultLimit <= 0 || resultLimit > MAX_RESULT_LIMIT) { + throw MongoOperationRejectedException.of( + "geo.query", "a proximity query needs a result limit between 1 and " + MAX_RESULT_LIMIT); + } + } + + /** A near query around a point. */ + public static MongoGeoQuery near( + String field, MongoGeoPoint center, MongoGeoDistance maxDistance, int resultLimit) { + return new MongoGeoQuery(field, center, maxDistance, resultLimit); + } + + /** The name a 2dsphere index on this field must have in the collection manifest. */ + public String requiredIndexName() { + return "ix_geo_" + field.replace('.', '_'); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeospatialOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeospatialOperations.java new file mode 100644 index 00000000..c437f2e6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeospatialOperations.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import java.util.List; + +/** + * Bounded GeoJSON proximity operations (design §21.2). + * + *

Only 2dsphere and GeoJSON. Legacy 2d coordinate pairs are readable for compatibility but are + * never mixed into these operations: the two index types use different distance semantics — planar + * versus spherical — and a query that silently falls back from one to the other returns wrong + * distances rather than an error. + */ +public interface MongoGeospatialOperations { + + /** Documents within the query's bounded radius, nearest first. */ + List findNear(MongoOperationContext context, MongoGeoQuery query, Class documentType); + + /** Documents inside the circle defined by the query. */ + List findWithin(MongoOperationContext context, MongoGeoQuery query, Class documentType); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/SpringMongoGeospatialOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/SpringMongoGeospatialOperations.java new file mode 100644 index 00000000..dda6f9ad --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/geo/SpringMongoGeospatialOperations.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexDirection; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexKey; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoManifestRegistry; +import java.util.List; +import java.util.Objects; +import org.springframework.data.geo.Circle; +import org.springframework.data.geo.Point; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Proximity queries that refuse to run without the index that supports them (design §21.2). + * + *

MongoDB rejects {@code $near} without a geospatial index, but {@code $geoWithin} does not — it + * falls back to a collection scan and returns correct results slowly. Checking the manifest before + * dispatch makes both cases fail the same way, at the same time, with the same message. + */ +public final class SpringMongoGeospatialOperations implements MongoGeospatialOperations { + + private final DefaultMongoImperativeExecutor executor; + + private final MongoManifestRegistry manifests; + + public SpringMongoGeospatialOperations( + DefaultMongoImperativeExecutor executor, MongoManifestRegistry manifests) { + this.executor = Objects.requireNonNull(executor, "executor"); + this.manifests = Objects.requireNonNull(manifests, "manifests"); + } + + @Override + public List findNear( + MongoOperationContext context, MongoGeoQuery query, Class documentType) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(documentType, "documentType"); + + return executor + .execute( + context, + MongoOperationType.FIND, + access -> { + requireSphereIndex(access.collection(), query.field()); + // $nearSphere over a GeoJSON point takes metres and already returns nearest-first, so + // no sort stage is needed — and adding one would discard the index-ordered scan. + Query near = + new Query( + Criteria.where(query.field()) + .nearSphere(query.center().toGeoJsonPoint()) + .maxDistance(query.maxDistance().toMeters())) + .limit(query.resultLimit()); + return access.operations().find(near, documentType, access.collection()); + }) + .result() + .orElse(List.of()); + } + + @Override + public List findWithin( + MongoOperationContext context, MongoGeoQuery query, Class documentType) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(documentType, "documentType"); + + return executor + .execute( + context, + MongoOperationType.FIND, + access -> { + requireSphereIndex(access.collection(), query.field()); + Circle circle = + new Circle(toPoint(query.center()), query.maxDistance().toSpringDistance()); + Query within = + new Query(Criteria.where(query.field()).withinSphere(circle)) + .limit(query.resultLimit()); + return access.operations().find(within, documentType, access.collection()); + }) + .result() + .orElse(List.of()); + } + + private static Point toPoint(MongoGeoPoint center) { + return new Point(center.longitude(), center.latitude()); + } + + private void requireSphereIndex(String collection, String field) { + boolean supported = + manifests + .find(collection) + .map( + manifest -> + manifest.indexes().stream() + .anyMatch(index -> coversFieldWith2dsphere(index, field))) + .orElse(false); + if (!supported) { + throw MongoOperationRejectedException.of( + "geo.index", + "collection '" + + collection + + "' declares no 2dsphere index on '" + + field + + "'; $geoWithin would silently fall back to a collection scan"); + } + } + + private static boolean coversFieldWith2dsphere(MongoIndexManifest index, String field) { + for (MongoIndexKey key : index.keys()) { + if (key.field().equals(field) && key.direction() == MongoIndexDirection.GEO_2DSPHERE) { + return true; + } + } + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/DefaultMongoImperativeExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/DefaultMongoImperativeExecutor.java new file mode 100644 index 00000000..e294de65 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/DefaultMongoImperativeExecutor.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import com.mongodb.MongoException; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator; +import java.time.Duration; +import java.util.Objects; +import org.springframework.dao.DataAccessException; +import org.springframework.data.mongodb.core.MongoOperations; + +/** + * The blocking execution scope every imperative operation runs inside (design §25). + * + *

The order is fixed: resolve the collection profile, open the observation, bind consistency, + * run the callback, translate at most one failure, close the observation. Fixing it here is what + * makes the invariants hold for operations nobody has written yet. + * + *

An already-translated {@link MongoPersistenceException} passes through untouched. + * Re-translating would overwrite a category the layer that raised it knew more about — a bulk + * partial failure, a guardrail rejection — with a generic one derived from a driver code it never + * had. + */ +public final class DefaultMongoImperativeExecutor implements MongoImperativeExecutor { + + private final MongoConsistencyBinder consistency; + + private final MongoCollectionProfileRegistry collections; + + private final MongoFailureTranslator translator; + + private final MongoOperationObserver observer; + + public DefaultMongoImperativeExecutor( + MongoConsistencyBinder consistency, + MongoCollectionProfileRegistry collections, + MongoFailureTranslator translator, + MongoOperationObserver observer) { + this.consistency = Objects.requireNonNull(consistency, "consistency"); + this.collections = Objects.requireNonNull(collections, "collections"); + this.translator = Objects.requireNonNull(translator, "translator"); + this.observer = Objects.requireNonNull(observer, "observer"); + } + + @Override + public MongoOperationResult execute( + MongoOperationContext context, MongoImperativeCallback callback) { + return execute(context, MongoOperationType.UNKNOWN, callback); + } + + /** Runs one operation, declaring the shape of database work it performs. */ + public MongoOperationResult execute( + MongoOperationContext context, + MongoOperationType operationType, + MongoImperativeCallback callback) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(callback, "callback"); + + String physicalCollection = collections.require(context.collectionProfile()); + MongoOperations operations = consistency.templateFor(context.consistency()); + ScopedAccess access = new ScopedAccess(physicalCollection, operations); + + long startedAt = System.nanoTime(); + try (MongoOperationObservation observation = observer.start(context, operationType)) { + try { + T value = callback.doInMongo(access); + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + observation.success(MongoExecutionOutcome.WRITE_CONFIRMED); + return new MongoOperationResult<>( + context, value, MongoExecutionOutcome.WRITE_CONFIRMED, elapsed); + } catch (MongoPersistenceException alreadyTranslated) { + observation.failure(alreadyTranslated.failureContext()); + throw alreadyTranslated; + } catch (MongoException driverFailure) { + throw translated(context, operationType, startedAt, observation, driverFailure); + } catch (DataAccessException springFailure) { + throw translated(context, operationType, startedAt, observation, unwrap(springFailure)); + } + } + } + + private MongoPersistenceException translated( + MongoOperationContext context, + MongoOperationType operationType, + long startedAt, + MongoOperationObservation observation, + MongoException driverFailure) { + Duration elapsed = Duration.ofNanos(System.nanoTime() - startedAt); + MongoPersistenceException translated = + translator.translate( + context, operationType, elapsed, MongoDriverFailureView.from(driverFailure), 1); + observation.failure(translated.failureContext().withTraceId(observation.traceId())); + return translated; + } + + /** + * Recovers the driver failure Spring Data wrapped. + * + *

Spring's own translation loses the error labels, and the labels are what separate a + * replayable transaction from an unknown commit. Unwrapping restores the distinction. + */ + private static MongoException unwrap(DataAccessException springFailure) { + Throwable cause = springFailure.getCause(); + if (cause instanceof MongoException driverFailure) { + return driverFailure; + } + return new MongoException(springFailure.getMessage() == null ? "" : springFailure.getMessage()); + } + + /** The collection access handed to a callback: exactly one collection, nothing else. */ + private static final class ScopedAccess implements MongoCollectionAccess { + + private final String physicalCollection; + + private final MongoOperations operations; + + private ScopedAccess(String physicalCollection, MongoOperations operations) { + this.physicalCollection = physicalCollection; + this.operations = operations; + } + + @Override + public String collection() { + return physicalCollection; + } + + @Override + public String collection(String requestedCollection) { + Objects.requireNonNull(requestedCollection, "requestedCollection"); + if (!physicalCollection.equals(requestedCollection)) { + throw MongoOperationRejectedException.of( + "collection.scope", + "this operation is scoped to collection '" + + physicalCollection + + "' and may not reach '" + + requestedCollection + + "'"); + } + return physicalCollection; + } + + @Override + public MongoOperations operations() { + return operations; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionAccess.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionAccess.java new file mode 100644 index 00000000..a29bf616 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionAccess.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.springframework.data.mongodb.core.MongoOperations; + +/** + * What a callback running inside an operation scope is allowed to touch (design §16.2, §25). + * + *

The scope is deliberately narrow: the operation named one collection profile, so the callback + * gets that collection and no other. Widening it — even to "any collection in the same database" — + * would let a tenant-scoped or permission-scoped operation read from a collection whose guardrails + * were never checked for this caller. + */ +public interface MongoCollectionAccess { + + /** The physical collection the operation's profile resolves to. */ + String collection(); + + /** + * Confirms that the callback is asking for the collection this operation was scoped to. + * + * @throws MongoOperationRejectedException when the requested collection is a different one + */ + String collection(String requestedCollection); + + /** Template operations already bound to the operation's consistency profile. */ + MongoOperations operations(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistry.java new file mode 100644 index 00000000..f434b20b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistry.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Maps registered collection profiles to physical collection names (design §16.2, §28). + * + *

This indirection is what makes "no dynamic collection names" enforceable. Application code + * names a profile; only this registry knows the physical name, and it is fixed at startup. A + * collection name assembled from a request value therefore cannot reach the driver, because there + * is no path from a string to a collection that does not pass through here. + */ +public final class MongoCollectionProfileRegistry { + + private final Map physicalNames; + + private MongoCollectionProfileRegistry(Map physicalNames) { + this.physicalNames = physicalNames; + } + + /** Starts a registry declaration. */ + public static Builder builder() { + return new Builder(); + } + + /** + * The physical collection a profile maps to. + * + * @throws MongoOperationRejectedException when the profile was never registered + */ + public String require(CollectionProfileName profile) { + Objects.requireNonNull(profile, "profile"); + String physical = physicalNames.get(profile); + if (physical == null) { + throw MongoOperationRejectedException.of( + "collection.profile", "collection profile '" + profile + "' is not registered"); + } + return physical; + } + + /** True when the profile is registered. */ + public boolean isRegistered(CollectionProfileName profile) { + return physicalNames.containsKey(Objects.requireNonNull(profile, "profile")); + } + + /** Collects profile registrations. */ + public static final class Builder { + + private final Map physicalNames = new LinkedHashMap<>(); + + private Builder() {} + + /** Registers a profile against its physical collection name. */ + public Builder register(String profile, String physicalCollection) { + Objects.requireNonNull(physicalCollection, "physicalCollection"); + if (physicalCollection.isBlank()) { + throw new IllegalArgumentException("a physical collection name must not be blank"); + } + CollectionProfileName name = new CollectionProfileName(profile); + if (physicalNames.putIfAbsent(name, physicalCollection) != null) { + throw new IllegalArgumentException("duplicate collection profile registration: " + profile); + } + return this; + } + + /** Builds the immutable registry. */ + public MongoCollectionProfileRegistry build() { + return new MongoCollectionProfileRegistry(Map.copyOf(physicalNames)); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoConsistencyBinder.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoConsistencyBinder.java new file mode 100644 index 00000000..53a3ff58 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoConsistencyBinder.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadConcernLevel; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Applies a consistency profile without mutating the shared template (design §7.2, §25). + * + *

{@code MongoTemplate.setWriteConcern} mutates a bean the whole application shares, so an + * operation that set it for itself would change the durability of every concurrent operation on + * another thread. Read settings are therefore applied per query, and write settings come from a + * small set of templates derived once at construction — one per profile, never per call. + */ +public final class MongoConsistencyBinder { + + private final MongoConsistencyRegistry registry; + + private final Map templatesByProfile; + + public MongoConsistencyBinder(MongoTemplate template, MongoConsistencyRegistry registry) { + Objects.requireNonNull(template, "template"); + this.registry = Objects.requireNonNull(registry, "registry"); + Map derived = + new EnumMap<>(MongoConsistencyProfile.class); + for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) { + derived.put(profile, derive(template, registry.require(profile))); + } + this.templatesByProfile = Map.copyOf(derived); + } + + private static MongoTemplate derive( + MongoTemplate template, MongoConsistencyDescriptor descriptor) { + MongoTemplate bound = + new MongoTemplate(template.getMongoDatabaseFactory(), template.getConverter()); + bound.setReadPreference(readPreferenceOf(descriptor)); + bound.setWriteConcern(writeConcernOf(descriptor)); + return bound; + } + + /** The template bound to a profile's read preference and write concern. */ + public MongoTemplate templateFor(MongoConsistencyProfile profile) { + MongoTemplate bound = templatesByProfile.get(Objects.requireNonNull(profile, "profile")); + if (bound == null) { + throw new IllegalStateException("no template bound for consistency profile " + profile); + } + return bound; + } + + /** + * Applies a profile's read settings to one query. + * + *

Per query rather than per template, because a read concern is a property of the read and + * setting it globally would silently change every other read sharing the template. + */ + public Query applyReadSettings(Query query, MongoConsistencyProfile profile) { + Objects.requireNonNull(query, "query"); + MongoConsistencyDescriptor descriptor = registry.require(profile); + return query + .withReadPreference(readPreferenceOf(descriptor)) + .withReadConcern(readConcernOf(descriptor)); + } + + /** The descriptor behind a profile. */ + public MongoConsistencyDescriptor describe(MongoConsistencyProfile profile) { + return registry.require(profile); + } + + private static ReadPreference readPreferenceOf(MongoConsistencyDescriptor descriptor) { + return descriptor.readsFromSecondary() + ? ReadPreference.secondaryPreferred() + : ReadPreference.primary(); + } + + private static ReadConcern readConcernOf(MongoConsistencyDescriptor descriptor) { + return new ReadConcern(ReadConcernLevel.fromString(descriptor.readConcern())); + } + + private static WriteConcern writeConcernOf(MongoConsistencyDescriptor descriptor) { + return "majority".equals(descriptor.writeConcern()) + ? WriteConcern.MAJORITY + : WriteConcern.ACKNOWLEDGED; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeCallback.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeCallback.java new file mode 100644 index 00000000..5d0ef74d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeCallback.java @@ -0,0 +1,13 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +/** + * The body of one imperative operation (design §25). + * + * @param the result type + */ +@FunctionalInterface +public interface MongoImperativeCallback { + + /** Runs against the scoped collection access. */ + T doInMongo(MongoCollectionAccess access); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeExecutor.java new file mode 100644 index 00000000..ff5920b0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoImperativeExecutor.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; + +/** + * The single imperative execution path (design §25). + * + *

Every blocking operation goes through here, which is what makes the cross-cutting rules + * enforceable in one place: registered operation name, registered collection profile, consistency, + * deadline, observation, and exactly one failure translation. + * + *

Retry is deliberately absent. Whether an operation may be replayed depends on its outcome, and + * an executor that retried would make that decision before the caller ever sees the outcome. + */ +public interface MongoImperativeExecutor { + + /** Runs one operation inside its scope. */ + MongoOperationResult execute( + MongoOperationContext context, MongoImperativeCallback callback); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoOperationResult.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoOperationResult.java new file mode 100644 index 00000000..b0a1fedb --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoOperationResult.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * The value an operation produced, plus the evidence of how it ran (design §25). + * + *

Carrying the outcome next to the value means a caller that must distinguish "confirmed" from + * "unknown" can, without a second lookup and without inspecting an exception. The elapsed time is + * measured around the whole scope, so it includes the connection checkout that a server-side timing + * metric would miss. + * + * @param the result type + */ +public record MongoOperationResult( + MongoOperationContext context, T value, MongoExecutionOutcome outcome, Duration elapsed) { + + public MongoOperationResult { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(elapsed, "elapsed"); + } + + /** The value, when the operation produced one. */ + public Optional result() { + return Optional.ofNullable(value); + } + + /** True when the caller cannot conclude whether data changed. */ + public boolean ambiguous() { + return outcome.isAmbiguous(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicFilter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicFilter.java new file mode 100644 index 00000000..30cd3961 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicFilter.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +/** + * The match half of an atomic update (design §13.1). + * + *

Equality predicates only, and always including {@code _id}. That restriction is what makes the + * update genuinely single-document and therefore genuinely atomic: a filter that can match a range + * turns "update this order" into "update whichever orders currently look like this", which is a + * different operation with a different failure mode. + * + *

Expressing the expected current state in the filter — {@code status = PENDING} — is also how a + * state transition becomes race-free without a transaction: the server evaluates the predicate and + * the update as one operation, so a concurrent transition matches nothing rather than overwriting. + */ +public record AtomicFilter(Map equalities) { + + /** The document identifier field. Always present in an atomic filter. */ + public static final String ID_FIELD = "_id"; + + public AtomicFilter { + Objects.requireNonNull(equalities, "equalities"); + equalities = new LinkedHashMap<>(equalities); + if (!equalities.containsKey(ID_FIELD)) { + throw new IllegalArgumentException( + "an atomic filter must include _id; without it the update is not single-document"); + } + equalities = Map.copyOf(equalities); + } + + /** A filter matching one document by identifier. */ + public static AtomicFilter id(Object id) { + Map equalities = new LinkedHashMap<>(); + equalities.put(ID_FIELD, Objects.requireNonNull(id, "id")); + return new AtomicFilter(equalities); + } + + /** Adds an expected-current-state predicate. */ + public AtomicFilter andEquals(String field, Object value) { + Objects.requireNonNull(field, "field"); + if (ID_FIELD.equals(field)) { + throw new IllegalArgumentException("_id is already part of every atomic filter"); + } + Map extended = new LinkedHashMap<>(equalities); + extended.put(field, value); + return new AtomicFilter(extended); + } + + /** Every field this filter constrains. */ + public Set fields() { + return equalities.keySet(); + } + + /** The expected value of one field, or {@code null} when the filter does not constrain it. */ + public Object value(String field) { + return equalities.get(Objects.requireNonNull(field, "field")); + } + + /** The Spring Data query for this filter. */ + public Query toQuery() { + Criteria criteria = Criteria.where(ID_FIELD).is(equalities.get(ID_FIELD)); + for (Map.Entry entry : equalities.entrySet()) { + if (!ID_FIELD.equals(entry.getKey())) { + criteria = criteria.and(entry.getKey()).is(entry.getValue()); + } + } + return new Query(criteria); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdate.java new file mode 100644 index 00000000..f6f17f6b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdate.java @@ -0,0 +1,204 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.springframework.data.mongodb.core.query.Update; + +/** + * The change half of an atomic update (design §13, §13.1). + * + *

This type exists to make partial update the easy path. Reading a document, mutating the object + * and calling {@code save()} replaces the whole document, so any field a concurrent writer changed + * in between is silently reverted — a lost update that leaves no trace, because both writes + * succeeded. + * + *

Only registered operators can be expressed, and {@code $push} is only available in its bounded + * form, so an update cannot make a document grow without saying how far. + */ +public record AtomicUpdate(List operations) { + + public AtomicUpdate { + Objects.requireNonNull(operations, "operations"); + operations = List.copyOf(operations); + if (operations.isEmpty()) { + throw new IllegalArgumentException("an atomic update needs at least one operation"); + } + } + + /** An update that sets one field. */ + public static AtomicUpdate set(String field, Object value) { + return new AtomicUpdate( + List.of(new AtomicUpdateOperation(MongoUpdateOperator.SET, field, value))); + } + + /** An update that increments one field. */ + public static AtomicUpdate increment(String field, long delta) { + return new AtomicUpdate( + List.of(new AtomicUpdateOperation(MongoUpdateOperator.INCREMENT, field, delta))); + } + + /** An update that removes one field. */ + public static AtomicUpdate unset(String field) { + return new AtomicUpdate( + List.of(new AtomicUpdateOperation(MongoUpdateOperator.UNSET, field, 1))); + } + + /** Adds a set operation. */ + public AtomicUpdate andSet(String field, Object value) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.SET, field, value)); + } + + /** Adds an increment operation. */ + public AtomicUpdate andIncrement(String field, long delta) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.INCREMENT, field, delta)); + } + + /** Adds an unset operation. */ + public AtomicUpdate andUnset(String field) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.UNSET, field, 1)); + } + + /** Adds a minimum-wins operation. */ + public AtomicUpdate andMin(String field, Object value) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.MIN, field, value)); + } + + /** Adds a maximum-wins operation. */ + public AtomicUpdate andMax(String field, Object value) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.MAX, field, value)); + } + + /** Stamps the server's current time into a field. */ + public AtomicUpdate andCurrentDate(String field) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.CURRENT_DATE, field, true)); + } + + /** Adds a value to a set. */ + public AtomicUpdate andAddToSet(String field, Object value) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.ADD_TO_SET, field, value)); + } + + /** Removes matching values from an array. */ + public AtomicUpdate andPull(String field, Object value) { + return and(new AtomicUpdateOperation(MongoUpdateOperator.PULL, field, value)); + } + + /** + * Appends to an array and truncates it to at most {@code maxElements} in the same operation. + * + *

The truncation is not optional. An append without one is how a bounded array quietly becomes + * an unbounded one. + */ + public AtomicUpdate andPushBounded(String field, Object value, int maxElements) { + if (maxElements <= 0) { + throw MongoOperationRejectedException.of( + "atomic.push", "a bounded push needs a positive element ceiling"); + } + return and( + new AtomicUpdateOperation( + MongoUpdateOperator.PUSH_BOUNDED, field, new BoundedPush(value, maxElements))); + } + + private AtomicUpdate and(AtomicUpdateOperation operation) { + List extended = new ArrayList<>(operations); + extended.add(operation); + return new AtomicUpdate(extended); + } + + /** The BSON operator names used, in declaration order and without duplicates. */ + public List operators() { + Set distinct = new LinkedHashSet<>(); + operations.forEach(operation -> distinct.add(operation.operator().bsonOperator())); + return List.copyOf(distinct); + } + + /** Every field this update touches. */ + public Set fields() { + Set fields = new LinkedHashSet<>(); + operations.forEach(operation -> fields.add(operation.field())); + return Set.copyOf(fields); + } + + /** The increment applied to a field, or {@code 0} when the update does not increment it. */ + public long increment(String field) { + Objects.requireNonNull(field, "field"); + for (AtomicUpdateOperation operation : operations) { + if (operation.operator() == MongoUpdateOperator.INCREMENT + && operation.field().equals(field)) { + return ((Number) operation.value()).longValue(); + } + } + return 0L; + } + + /** The Spring Data update document. */ + public Update toUpdate() { + Update update = new Update(); + for (AtomicUpdateOperation operation : operations) { + apply(update, operation); + } + return update; + } + + private static void apply(Update update, AtomicUpdateOperation operation) { + String field = operation.field(); + Object value = operation.value(); + switch (operation.operator()) { + case SET, SET_POSITIONAL -> update.set(field, value); + case UNSET -> update.unset(field); + case INCREMENT -> update.inc(field, (Number) value); + case MIN -> update.min(field, value); + case MAX -> update.max(field, value); + case CURRENT_DATE -> update.currentDate(field); + case ADD_TO_SET -> update.addToSet(field, value); + case PULL -> update.pull(field, value); + case PUSH_BOUNDED -> { + BoundedPush push = (BoundedPush) value; + update.push(field).slice(-push.maxElements()).each(pushValues(push.value())); + } + default -> + throw new IllegalStateException("unhandled update operator: " + operation.operator()); + } + } + + private static Object[] pushValues(Object value) { + if (value instanceof Collection collection) { + return collection.toArray(); + } + return new Object[] {value}; + } + + /** The value and ceiling of a bounded push. */ + private record BoundedPush(Object value, int maxElements) {} + + /** One operator applied to one field. */ + public record AtomicUpdateOperation(MongoUpdateOperator operator, String field, Object value) { + + public AtomicUpdateOperation { + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(field, "field"); + if (field.isBlank()) { + throw new IllegalArgumentException("an update operation needs a field"); + } + } + + /** A rendering used by policy checks and diagnostics. Carries no value. */ + public String describe() { + return operator.bsonOperator() + ' ' + field; + } + } + + /** The operations keyed by field, for policy checks that iterate per field. */ + public Map operatorsByField() { + Map byField = new LinkedHashMap<>(); + operations.forEach(operation -> byField.put(operation.field(), operation.operator())); + return Map.copyOf(byField); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdateResult.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdateResult.java new file mode 100644 index 00000000..082693c7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/AtomicUpdateResult.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import java.util.Objects; +import java.util.Optional; + +/** + * The evidence one atomic update produced (design §13.1). + * + *

{@code matched} and {@code modified} are reported separately because their difference is the + * signal a state transition depends on. Matched but not modified means the document was already in + * the target state; matched zero means the expected state was wrong — a different document, a + * different outcome, and only one of them is a conflict. + * + * @param the document type, when the caller asked for it back + */ +public record AtomicUpdateResult( + long matched, long modified, boolean upserted, T document, MongoExecutionOutcome outcome) { + + public AtomicUpdateResult { + Objects.requireNonNull(outcome, "outcome"); + if (matched < 0 || modified < 0) { + throw new IllegalArgumentException("matched and modified counts must not be negative"); + } + } + + /** Nothing matched the filter. */ + public static AtomicUpdateResult noMatch() { + return new AtomicUpdateResult<>(0, 0, false, null, MongoExecutionOutcome.NO_WRITE_PERFORMED); + } + + /** The update matched and the server confirmed it. */ + public static AtomicUpdateResult applied(long matched, long modified, T document) { + return new AtomicUpdateResult<>( + matched, modified, false, document, MongoExecutionOutcome.WRITE_CONFIRMED); + } + + /** An upsert created a new document. */ + public static AtomicUpdateResult created(T document) { + return new AtomicUpdateResult<>(0, 0, true, document, MongoExecutionOutcome.WRITE_CONFIRMED); + } + + /** The returned document, when one was requested and one exists. */ + public Optional returnedDocument() { + return Optional.ofNullable(document); + } + + /** True when the filter matched a document. */ + public boolean matchedAnything() { + return matched > 0 || upserted; + } + + /** True when the document already held the target state, so nothing changed. */ + public boolean matchedButUnchanged() { + return matched > 0 && modified == 0; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperations.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperations.java new file mode 100644 index 00000000..b639ab67 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperations.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; + +/** + * Typed single-document atomic operations (design §13.1, D-07, D-09). + * + *

The API takes registered field descriptors and an operator allowlist rather than BSON, so the + * set of expressible updates is the set that was reviewed. It is also the operation the design + * wants reached for first: a single-document update is atomic on its own, so a multi-document + * transaction is only warranted when the invariant genuinely spans documents. + */ +public interface MongoAtomicOperations { + + /** + * Applies one update to at most one document. + * + * @param context the operation identity, profiles, consistency and deadline + * @param documentType the mapped document type + * @param filter the identifier and expected current state + * @param update the operators to apply + * @param returnMode whether to return the document, and which version + * @param the document type + */ + AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode); + + /** + * Creates the document if the filter matches nothing, otherwise applies the update. + * + *

The competing-create pattern of design §13: paired with a unique index, an upsert makes + * "create if absent" safe under concurrency without a transaction and without a read-then-write + * race. + */ + AtomicUpdateResult upsertOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplate.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplate.java new file mode 100644 index 00000000..7bbd5aec --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplate.java @@ -0,0 +1,154 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.data.mongodb.core.query.UpdateDefinition; + +/** + * Runs typed atomic updates through the imperative execution scope (design §13.1). + * + *

The policy check happens before the operation is dispatched, so an update naming an + * unregistered field or operator never reaches a connection. Whether the server is asked for the + * document back is the caller's decision, because {@code findAndModify} costs more than a plain + * update and only pays for itself when the caller needs the post-update state atomically. + */ +public final class MongoAtomicOperationsTemplate implements MongoAtomicOperations { + + private final DefaultMongoImperativeExecutor executor; + + private final Map policies; + + public MongoAtomicOperationsTemplate( + DefaultMongoImperativeExecutor executor, + Map policies) { + this.executor = Objects.requireNonNull(executor, "executor"); + this.policies = Map.copyOf(Objects.requireNonNull(policies, "policies")); + } + + @Override + public AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + return apply(context, documentType, filter, update, returnMode, false); + } + + @Override + public AtomicUpdateResult upsertOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + return apply(context, documentType, filter, update, returnMode, true); + } + + private AtomicUpdateResult apply( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode, + boolean upsert) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(filter, "filter"); + Objects.requireNonNull(update, "update"); + Objects.requireNonNull(returnMode, "returnMode"); + + MongoAtomicPolicy policy = requirePolicy(context.collectionProfile()); + policy.requireFilter(filter); + policy.requireUpdate(update); + + Query query = filter.toQuery(); + Update updateDefinition = update.toUpdate(); + MongoOperationType operationType = + returnMode.returnsDocument() + ? MongoOperationType.FIND_AND_MODIFY + : MongoOperationType.UPDATE; + + return executor + .execute( + context, + operationType, + access -> + returnMode.returnsDocument() + ? findAndModify( + access.operations(), + access.collection(), + documentType, + query, + updateDefinition, + returnMode, + upsert) + : update( + access.operations(), + access.collection(), + documentType, + query, + updateDefinition, + upsert)) + .result() + .orElseGet(AtomicUpdateResult::noMatch); + } + + private static AtomicUpdateResult findAndModify( + MongoOperations operations, + String collection, + Class documentType, + Query query, + UpdateDefinition update, + ReturnDocumentMode returnMode, + boolean upsert) { + FindAndModifyOptions options = + FindAndModifyOptions.options() + .returnNew(returnMode == ReturnDocumentMode.AFTER) + .upsert(upsert); + T document = operations.findAndModify(query, update, options, documentType, collection); + if (document == null) { + return upsert ? AtomicUpdateResult.created(null) : AtomicUpdateResult.noMatch(); + } + return AtomicUpdateResult.applied(1, 1, document); + } + + private static AtomicUpdateResult update( + MongoOperations operations, + String collection, + Class documentType, + Query query, + UpdateDefinition update, + boolean upsert) { + var result = + upsert + ? operations.upsert(query, update, documentType, collection) + : operations.updateFirst(query, update, documentType, collection); + if (result.getUpsertedId() != null) { + return AtomicUpdateResult.created(null); + } + if (result.getMatchedCount() == 0) { + return AtomicUpdateResult.noMatch(); + } + return AtomicUpdateResult.applied(result.getMatchedCount(), result.getModifiedCount(), null); + } + + private MongoAtomicPolicy requirePolicy(CollectionProfileName profile) { + MongoAtomicPolicy policy = policies.get(profile); + if (policy == null) { + throw MongoOperationRejectedException.of( + "atomic.policy", + "collection profile '" + profile + "' has no registered atomic update policy"); + } + return policy; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicy.java new file mode 100644 index 00000000..40794c26 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicPolicy.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Which fields an atomic update may touch, and with which operators (design §13.1). + * + *

The design forbids free-form BSON updates, and this is the registry that makes the restriction + * enforceable: an update naming an unregistered path is refused locally, so a caller cannot reach a + * field the collection never meant to expose to partial modification — an audit stamp, a computed + * projection, an encrypted value whose ciphertext only the encryption layer may write. + */ +public final class MongoAtomicPolicy { + + private final Map> operatorsByField; + + private final Set filterableFields; + + private MongoAtomicPolicy( + Map> operatorsByField, Set filterableFields) { + this.operatorsByField = operatorsByField; + this.filterableFields = filterableFields; + } + + /** Starts a policy declaration. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Checks a filter against the registered predicate fields. + * + * @throws MongoOperationRejectedException when the filter names an unregistered field + */ + public void requireFilter(AtomicFilter filter) { + Objects.requireNonNull(filter, "filter"); + for (String field : filter.fields()) { + if (AtomicFilter.ID_FIELD.equals(field)) { + continue; + } + if (!filterableFields.contains(field)) { + throw MongoOperationRejectedException.of( + "atomic.filter", + "field '" + field + "' is not registered as an atomic-update predicate field"); + } + } + } + + /** + * Checks an update against the registered field and operator allowlist. + * + * @throws MongoOperationRejectedException when a field or operator is not registered + */ + public void requireUpdate(AtomicUpdate update) { + Objects.requireNonNull(update, "update"); + for (AtomicUpdate.AtomicUpdateOperation operation : update.operations()) { + Set allowed = operatorsByField.get(operation.field()); + if (allowed == null) { + throw MongoOperationRejectedException.of( + "atomic.update", + "field '" + operation.field() + "' is not registered for atomic update"); + } + if (!allowed.contains(operation.operator())) { + throw MongoOperationRejectedException.of( + "atomic.update", + "operator " + + operation.operator().bsonOperator() + + " is not registered for field '" + + operation.field() + + "'"); + } + } + } + + /** Collects field and operator registrations. */ + public static final class Builder { + + private final Map> operatorsByField = new LinkedHashMap<>(); + + private final Set filterableFields = new java.util.LinkedHashSet<>(); + + private Builder() {} + + /** Registers a field with the operators it accepts. */ + public Builder updatable(String field, MongoUpdateOperator... operators) { + Objects.requireNonNull(field, "field"); + operatorsByField.put(field, Set.of(operators)); + return this; + } + + /** Registers a field usable as an expected-current-state predicate. */ + public Builder filterable(String field) { + filterableFields.add(Objects.requireNonNull(field, "field")); + return this; + } + + /** Builds the immutable policy. */ + public MongoAtomicPolicy build() { + return new MongoAtomicPolicy(Map.copyOf(operatorsByField), Set.copyOf(filterableFields)); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoUpdateOperator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoUpdateOperator.java new file mode 100644 index 00000000..b0f2ecb2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoUpdateOperator.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +/** + * The update operators a field may be changed with (design §13, §13.1). + * + *

{@code $push} is absent. An unbounded push is the single most common way a document grows past + * its budget, and the bounded form — {@code $push} with {@code $slice} — is expressed by {@link + * #PUSH_BOUNDED} so that the bound is part of the operator rather than an argument a caller can + * forget. + */ +public enum MongoUpdateOperator { + + /** Sets a field to a value. */ + SET("$set"), + + /** Removes a field. */ + UNSET("$unset"), + + /** Adds a delta to a numeric field, atomically. */ + INCREMENT("$inc"), + + /** Sets the field only if the new value is smaller. */ + MIN("$min"), + + /** Sets the field only if the new value is larger. */ + MAX("$max"), + + /** Stamps the server's current time. */ + CURRENT_DATE("$currentDate"), + + /** Adds a value to a set, with no duplicate. */ + ADD_TO_SET("$addToSet"), + + /** Removes matching values from an array. */ + PULL("$pull"), + + /** Appends to an array and truncates it in the same operation. */ + PUSH_BOUNDED("$push"), + + /** Sets a field inside a matched array element. */ + SET_POSITIONAL("$set"); + + private final String bsonOperator; + + MongoUpdateOperator(String bsonOperator) { + this.bsonOperator = bsonOperator; + } + + /** The BSON operator name. */ + public String bsonOperator() { + return bsonOperator; + } + + /** True when this operator can make a document grow. */ + public boolean grows() { + return this == ADD_TO_SET || this == PUSH_BOUNDED; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/ReturnDocumentMode.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/ReturnDocumentMode.java new file mode 100644 index 00000000..df6b8648 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/ReturnDocumentMode.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +/** + * Whether an atomic update returns the document, and which version of it (design §13). + * + *

Returning the document is what makes {@code findAndModify} worth its extra cost: the caller + * learns the post-update state in the same atomic operation, instead of issuing a follow-up read + * that a concurrent writer can invalidate before it runs. + */ +public enum ReturnDocumentMode { + + /** Return only the counts. The cheapest option and the right default. */ + NONE, + + /** Return the document as it was before the update. */ + BEFORE, + + /** Return the document as it is after the update. */ + AFTER; + + /** True when the server must send a document back. */ + public boolean returnsDocument() { + return this != NONE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutor.java new file mode 100644 index 00000000..9e602f77 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutor.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import com.mongodb.MongoBulkWriteException; +import com.mongodb.bulk.BulkWriteError; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.imperative.DefaultMongoImperativeExecutor; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.query.Update; + +/** + * Executes a bulk plan and preserves what actually happened to each item (design §19.2). + * + *

A bulk failure is caught and turned into a result, not rethrown as a single error. The driver + * reports both the counts of what succeeded and the per-item errors; discarding either half is what + * leads a caller to re-run a batch whose first nine hundred items already applied. + * + *

Nothing here retries. The successful items must not be re-sent, and which of the failed ones + * may be is a business decision the result exposes rather than makes. + */ +public final class MongoBulkExecutor { + + private final DefaultMongoImperativeExecutor executor; + + public MongoBulkExecutor(DefaultMongoImperativeExecutor executor) { + this.executor = Objects.requireNonNull(executor, "executor"); + } + + /** Runs one bulk plan against the operation's collection. */ + public MongoBulkResult execute(MongoOperationContext context, MongoBulkWritePlan plan) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(plan, "plan"); + + return executor + .execute( + context, + MongoOperationType.BULK_WRITE, + access -> { + BulkOperations bulk = + access.operations().bulkOps(plan.mode().toSpringMode(), access.collection()); + for (MongoBulkWritePlan.MongoBulkItem item : plan.items()) { + Update update = item.update().toUpdate(); + if (item.upsert()) { + bulk.upsert(item.filter().toQuery(), update); + } else { + bulk.updateOne(item.filter().toQuery(), update); + } + } + try { + var result = bulk.execute(); + return MongoBulkResult.complete( + plan.items().size(), + result.getInsertedCount(), + result.getModifiedCount(), + result.getDeletedCount()); + } catch (MongoBulkWriteException partialFailure) { + return toPartialResult(plan, partialFailure); + } + }) + .result() + .orElseThrow(() -> new IllegalStateException("the bulk executor produced no result")); + } + + private static MongoBulkResult toPartialResult( + MongoBulkWritePlan plan, MongoBulkWriteException failure) { + List failures = new ArrayList<>(); + for (BulkWriteError error : failure.getWriteErrors()) { + failures.add( + new MongoBulkItemFailure(error.getIndex(), categoryOf(error.getCode()), error.getCode())); + } + var result = failure.getWriteResult(); + return MongoBulkResult.partial( + plan.items().size(), + result.getInsertedCount(), + result.getModifiedCount(), + result.getDeletedCount(), + result.getUpserts().size(), + failures); + } + + private static MongoFailureCategory categoryOf(int serverCode) { + return switch (serverCode) { + case 11000, 11001, 12582 -> MongoFailureCategory.DUPLICATE_KEY; + case 121 -> MongoFailureCategory.SCHEMA_VALIDATION; + case 112 -> MongoFailureCategory.WRITE_CONFLICT; + case 10334, 17420 -> MongoFailureCategory.DOCUMENT_TOO_LARGE; + case 50, 89, 262 -> MongoFailureCategory.TIMEOUT; + default -> MongoFailureCategory.UNCLASSIFIED; + }; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkItemFailure.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkItemFailure.java new file mode 100644 index 00000000..f56bcdce --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkItemFailure.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import java.util.Objects; + +/** + * One failed item in a bulk write (design §19.2). + * + *

Addressed by request index rather than by document, so the caller can map the failure back to + * its own input without the platform carrying business data. The index is also the only identifier + * that stays meaningful when the document was never inserted and therefore has no server-side + * identity. + */ +public record MongoBulkItemFailure( + int requestIndex, MongoFailureCategory category, int serverCode) { + + public MongoBulkItemFailure { + Objects.requireNonNull(category, "category"); + if (requestIndex < 0) { + throw new IllegalArgumentException("a bulk item index must not be negative"); + } + } + + /** True when re-submitting only this item is safe. */ + public boolean retryableAlone() { + return category == MongoFailureCategory.WRITE_CONFLICT + || category == MongoFailureCategory.CONNECTION + || category == MongoFailureCategory.TIMEOUT; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkMode.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkMode.java new file mode 100644 index 00000000..a6398ac5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkMode.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import org.springframework.data.mongodb.core.BulkOperations; + +/** + * Whether a bulk write stops at the first failure (design §19.2). + * + *

Ordered stops, so the successful prefix is exactly the items before the failure. Unordered + * continues, so successes and failures are interleaved. Both preserve partial success — the + * difference is only which items were attempted, and a caller that needs to know cannot infer it + * from the counts alone. + */ +public enum MongoBulkMode { + + /** Stop at the first failure. Items after it were never attempted. */ + ORDERED, + + /** Attempt every item. Failures do not stop the ones after them. */ + UNORDERED; + + /** The Spring Data bulk mode this maps to. */ + public BulkOperations.BulkMode toSpringMode() { + return this == ORDERED ? BulkOperations.BulkMode.ORDERED : BulkOperations.BulkMode.UNORDERED; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkResult.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkResult.java new file mode 100644 index 00000000..0e6a0966 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkResult.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import java.util.List; +import java.util.Objects; + +/** + * What a bulk write actually did, item by item (design §19.2). + * + *

The result is deliberately not reduced to success-or-failure. A bulk write that reports only + * "failed" invites the caller to re-run the whole batch, which re-applies every item that already + * succeeded — and for anything non-idempotent, that is duplicate data created by the error handler + * rather than by the error. + */ +public record MongoBulkResult( + int requested, + int inserted, + int modified, + int deleted, + int upserted, + List failures, + boolean partial) { + + public MongoBulkResult { + Objects.requireNonNull(failures, "failures"); + failures = List.copyOf(failures); + if (requested < 0) { + throw new IllegalArgumentException("requested must not be negative"); + } + } + + /** Every item succeeded. */ + public static MongoBulkResult complete(int requested, int inserted, int modified, int deleted) { + return new MongoBulkResult(requested, inserted, modified, deleted, 0, List.of(), false); + } + + /** Some items succeeded and some failed. */ + public static MongoBulkResult partial( + int requested, + int inserted, + int modified, + int deleted, + int upserted, + List failures) { + return new MongoBulkResult(requested, inserted, modified, deleted, upserted, failures, true); + } + + /** The number of items the server confirmed. These must never be re-submitted. */ + public int succeeded() { + return inserted + modified + deleted + upserted; + } + + /** The request indexes that failed and may be re-submitted on their own. */ + public List retryableIndexes() { + return failures.stream() + .filter(MongoBulkItemFailure::retryableAlone) + .map(MongoBulkItemFailure::requestIndex) + .toList(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkWritePlan.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkWritePlan.java new file mode 100644 index 00000000..18883bc1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkWritePlan.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * A bounded batch of single-document operations (design §19.2). + * + *

The item ceiling is enforced when the plan is built rather than when it is sent. A batch + * assembled in a loop grows with its input, and the first sign that the input got larger should not + * be a 16 MiB command rejected by the server after the whole batch was serialized. + */ +public record MongoBulkWritePlan(MongoBulkMode mode, List items) { + + /** The largest batch the platform sends in one command. */ + public static final int MAX_ITEMS = 1000; + + public MongoBulkWritePlan { + Objects.requireNonNull(mode, "mode"); + Objects.requireNonNull(items, "items"); + items = List.copyOf(items); + if (items.isEmpty()) { + throw new IllegalArgumentException("a bulk plan needs at least one item"); + } + if (items.size() > MAX_ITEMS) { + throw MongoOperationRejectedException.of( + "bulk.plan", + "a bulk plan of " + items.size() + " items is above the ceiling of " + MAX_ITEMS); + } + } + + /** Starts a plan. */ + public static Builder builder(MongoBulkMode mode) { + return new Builder(mode); + } + + /** Collects bulk items. */ + public static final class Builder { + + private final MongoBulkMode mode; + + private final List items = new ArrayList<>(); + + private Builder(MongoBulkMode mode) { + this.mode = Objects.requireNonNull(mode, "mode"); + } + + /** Appends an update item. */ + public Builder update(AtomicFilter filter, AtomicUpdate update) { + items.add(new MongoBulkItem(items.size(), filter, update, false)); + return this; + } + + /** Appends an upsert item. */ + public Builder upsert(AtomicFilter filter, AtomicUpdate update) { + items.add(new MongoBulkItem(items.size(), filter, update, true)); + return this; + } + + /** Builds the immutable plan. */ + public MongoBulkWritePlan build() { + return new MongoBulkWritePlan(mode, items); + } + } + + /** One item of a bulk plan, carrying the index its failure will be reported under. */ + public record MongoBulkItem( + int requestIndex, AtomicFilter filter, AtomicUpdate update, boolean upsert) { + + public MongoBulkItem { + Objects.requireNonNull(filter, "filter"); + Objects.requireNonNull(update, "update"); + if (requestIndex < 0) { + throw new IllegalArgumentException("a bulk item index must not be negative"); + } + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoDocumentNotFoundException.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoDocumentNotFoundException.java new file mode 100644 index 00000000..c391a685 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoDocumentNotFoundException.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import java.io.Serial; + +/** + * The document a versioned update targeted does not exist (design §13.2). + * + *

Kept apart from an optimistic conflict because the recoveries are opposite. A conflict means + * reload and recompute; a missing document means the entity was deleted or never created, and + * reloading will never succeed. Collapsing the two produces a retry loop that cannot terminate. + */ +public final class MongoDocumentNotFoundException extends MongoPersistenceException { + + @Serial private static final long serialVersionUID = 1L; + + public MongoDocumentNotFoundException(MongoFailureContext failureContext) { + super("the document the versioned update targeted does not exist", failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoOptimisticConflictTranslator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoOptimisticConflictTranslator.java new file mode 100644 index 00000000..d06f3e5d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoOptimisticConflictTranslator.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationScope; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException; +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * Decides what "matched nothing" means for a versioned update (design §13.2). + * + *

The server reports the same result for two different situations: the document exists with a + * different revision, or it does not exist at all. Only the first is a conflict worth retrying, so + * the translator asks whether the document is there before choosing — one extra read on a path that + * has already failed, in exchange for a retry loop that terminates. + */ +public final class MongoOptimisticConflictTranslator { + + /** + * Raises the right exception for an update that matched nothing. + * + * @param documentExists whether a document with that identifier is present, at any revision + * @throws MongoOptimisticConflictException when the document exists at another revision + * @throws MongoDocumentNotFoundException when the document is absent + */ + public RuntimeException translateNoMatch(MongoOperationContext context, boolean documentExists) { + Objects.requireNonNull(context, "context"); + MongoFailureContext failureContext = + new MongoFailureContext( + MongoOperationScope.of(context), + MongoOperationType.UPDATE, + context.consistency(), + documentExists + ? MongoFailureCategory.OPTIMISTIC_CONFLICT + : MongoFailureCategory.UNCLASSIFIED, + MongoExecutionOutcome.NO_WRITE_PERFORMED, + false, + false, + Set.of(), + "", + 1, + Duration.ZERO, + ""); + return documentExists + ? new MongoOptimisticConflictException(failureContext) + : new MongoDocumentNotFoundException(failureContext); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoRevision.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoRevision.java new file mode 100644 index 00000000..2bf3dacb --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/MongoRevision.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +/** + * The optimistic revision of one document (design §13.2, D-08). + * + *

A monotonically increasing counter stored alongside the business fields. It is what turns a + * full-document replacement from "write whatever I computed" into "write this only if nobody else + * has written since I read", which is the difference between a correct update and a lost one. + */ +public record MongoRevision(long value) implements Comparable { + + /** The revision a freshly inserted document carries. */ + public static final MongoRevision INITIAL = new MongoRevision(0); + + /** The document field the revision is stored in. */ + public static final String FIELD = "version"; + + public MongoRevision { + if (value < 0) { + throw new IllegalArgumentException("negative revision"); + } + } + + /** The revision a successful update advances to. */ + public MongoRevision next() { + return new MongoRevision(value + 1); + } + + @Override + public int compareTo(MongoRevision other) { + return Long.compare(value, other.value); + } + + @Override + public String toString() { + return Long.toString(value); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdater.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdater.java new file mode 100644 index 00000000..eb751838 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdater.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdateResult; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Applies versioned updates and retries them the only way that is safe (design §13.2). + * + *

{@link #applyWithRetry} reloads the document and re-runs the caller's computation on each + * attempt. That is the whole point: retrying by re-sending the previously computed update would + * write a value derived from state that is now known to be stale — which is the lost update the + * revision predicate was added to prevent, arriving through the retry path instead. + * + *

The caller must confirm there is no external side effect to replay before using the retry + * helper. The platform cannot know whether the computation sent an email. + */ +public final class VersionedMongoUpdater { + + private final MongoAtomicOperations atomicOperations; + + private final MongoOptimisticConflictTranslator conflictTranslator; + + public VersionedMongoUpdater( + MongoAtomicOperations atomicOperations, + MongoOptimisticConflictTranslator conflictTranslator) { + this.atomicOperations = Objects.requireNonNull(atomicOperations, "atomicOperations"); + this.conflictTranslator = Objects.requireNonNull(conflictTranslator, "conflictTranslator"); + } + + /** + * Applies one versioned update. + * + * @throws MongoOptimisticConflictException when the document moved on + * @throws MongoDocumentNotFoundException when the document is gone + */ + public AtomicUpdateResult apply( + MongoOperationContext context, + Class documentType, + VersionedUpdateCommand command, + Supplier documentExistsProbe) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(command, "command"); + Objects.requireNonNull(documentExistsProbe, "documentExistsProbe"); + + AtomicUpdateResult result = + atomicOperations.updateOne( + context, documentType, command.filter(), command.update(), ReturnDocumentMode.NONE); + if (!result.matchedAnything()) { + throw conflictTranslator.translateNoMatch(context, documentExistsProbe.get()); + } + return result; + } + + /** + * Reloads, recomputes and retries a bounded number of times. + * + * @param reload reads the current document, or empty when it no longer exists + * @param recompute derives the update from the freshly loaded state + * @param maxAttempts the attempt ceiling; conflicts beyond it are surfaced to the caller + */ + public AtomicUpdateResult applyWithRetry( + MongoOperationContext context, + Class documentType, + Supplier> reload, + Function recompute, + int maxAttempts) { + Objects.requireNonNull(reload, "reload"); + Objects.requireNonNull(recompute, "recompute"); + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + + RuntimeException lastConflict = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + Optional current = reload.get(); + if (current.isEmpty()) { + throw conflictTranslator.translateNoMatch(context, false); + } + VersionedUpdateCommand command = recompute.apply(current.get()); + AtomicUpdateResult result = + atomicOperations.updateOne( + context, documentType, command.filter(), command.update(), ReturnDocumentMode.NONE); + if (result.matchedAnything()) { + return result; + } + lastConflict = conflictTranslator.translateNoMatch(context, true); + } + throw lastConflict; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedUpdateCommand.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedUpdateCommand.java new file mode 100644 index 00000000..5a4099a2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedUpdateCommand.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate; +import java.util.Objects; + +/** + * A partial update guarded by an expected revision (design §13.2). + * + *

The predicate and the increment are assembled together and cannot be assembled apart. That is + * the entire safety property: a filter carrying the expected version without an update that + * advances it lets two writers both succeed, and an increment without the predicate advances a + * version that was never checked. + * + *

Spring Data's {@code @Version} covers {@code save()}. It does not cover a custom partial + * update, which is exactly the case this command exists for. + */ +public record VersionedUpdateCommand(AtomicFilter filter, AtomicUpdate update) { + + public VersionedUpdateCommand { + Objects.requireNonNull(filter, "filter"); + Objects.requireNonNull(update, "update"); + if (filter.value(MongoRevision.FIELD) == null) { + throw new IllegalArgumentException( + "a versioned update must constrain '" + MongoRevision.FIELD + "' in its filter"); + } + if (update.increment(MongoRevision.FIELD) != 1L) { + throw new IllegalArgumentException( + "a versioned update must increment '" + MongoRevision.FIELD + "' exactly once"); + } + } + + /** Builds the predicate and the increment together from an id, expected revision and change. */ + public static VersionedUpdateCommand of( + Object id, MongoRevision expected, AtomicUpdate businessUpdate) { + Objects.requireNonNull(expected, "expected"); + Objects.requireNonNull(businessUpdate, "businessUpdate"); + if (businessUpdate.fields().contains(MongoRevision.FIELD)) { + throw new IllegalArgumentException( + "the business update must not touch '" + + MongoRevision.FIELD + + "'; the revision is advanced by this command"); + } + return new VersionedUpdateCommand( + AtomicFilter.id(id).andEquals(MongoRevision.FIELD, expected.value()), + businessUpdate.andIncrement(MongoRevision.FIELD, 1L)); + } + + /** The revision this command expects to find. */ + public MongoRevision expectedRevision() { + return new MongoRevision(((Number) filter.value(MongoRevision.FIELD)).longValue()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigDecimalToDecimal128Converter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigDecimalToDecimal128Converter.java new file mode 100644 index 00000000..767ddc4c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/BigDecimalToDecimal128Converter.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.math.BigDecimal; +import org.bson.types.Decimal128; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.WritingConverter; + +/** + * Writes a {@code BigDecimal} as {@code Decimal128}, rejecting values it cannot hold (design §10). + * + *

The range check runs before the driver is invoked. {@code Decimal128} silently rounds a value + * with more than 34 significant digits, so a monetary amount that overflows would be stored as a + * slightly different number and no error would ever surface. Failing locally turns that into a + * rejected write the caller can see. + */ +@WritingConverter +public final class BigDecimalToDecimal128Converter implements Converter { + + /** IEEE 754 decimal128 holds at most 34 significant digits. */ + static final int MAX_SIGNIFICANT_DIGITS = 34; + + /** Smallest representable exponent for decimal128. */ + static final int MIN_EXPONENT = -6143; + + /** Largest representable exponent for decimal128. */ + static final int MAX_EXPONENT = 6144; + + @Override + public Decimal128 convert(BigDecimal source) { + requireRepresentable(source); + return new Decimal128(source); + } + + /** + * Fails the write when the value cannot round-trip through decimal128. + * + * @throws MongoOperationRejectedException when precision or exponent is out of range + */ + static void requireRepresentable(BigDecimal value) { + if (value.precision() > MAX_SIGNIFICANT_DIGITS) { + throw MongoOperationRejectedException.of( + "mapping.decimal128", + "the decimal value needs " + + value.precision() + + " significant digits but Decimal128 holds at most " + + MAX_SIGNIFICANT_DIGITS); + } + int exponent = -value.scale(); + if (exponent < MIN_EXPONENT || exponent > MAX_EXPONENT) { + throw MongoOperationRejectedException.of( + "mapping.decimal128", + "the decimal exponent " + exponent + " is outside the Decimal128 range"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/Decimal128ToBigDecimalConverter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/Decimal128ToBigDecimalConverter.java new file mode 100644 index 00000000..7c1f3498 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/Decimal128ToBigDecimalConverter.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.math.BigDecimal; +import org.bson.types.Decimal128; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; + +/** + * Reads a {@code Decimal128} back into a {@code BigDecimal} (design §10). + * + *

{@code Decimal128} can hold NaN and infinities, which {@code BigDecimal} cannot. Those values + * can only reach a collection through a writer outside this platform, so surfacing them as a + * rejection is the honest outcome: silently substituting zero would let corrupt data flow into a + * monetary calculation. + */ +@ReadingConverter +public final class Decimal128ToBigDecimalConverter implements Converter { + + @Override + public BigDecimal convert(Decimal128 source) { + if (source.isNaN() || source.isInfinite()) { + throw MongoOperationRejectedException.of( + "mapping.decimal128", + "the stored Decimal128 value is NaN or infinite and has no BigDecimal representation"); + } + return source.bigDecimalValue(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdReadConverter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdReadConverter.java new file mode 100644 index 00000000..5bf3289e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdReadConverter.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; + +/** + * Reads a BSON string back into a {@link DomainDocumentId} (design §10). + * + *

The inverse of {@link DomainIdWriteConverter}, registered as a pair so the round trip is + * exact: a representation that only converts in one direction is how a collection ends up + * unreadable by the release that wrote it. + */ +@ReadingConverter +public final class DomainIdReadConverter implements Converter { + + @Override + public DomainDocumentId convert(String source) { + return new DomainDocumentId(source); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdWriteConverter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdWriteConverter.java new file mode 100644 index 00000000..65ac609e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/DomainIdWriteConverter.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.WritingConverter; + +/** + * Writes a {@link DomainDocumentId} as a BSON string (design §10). + * + *

The value is passed through untouched. That is the whole behaviour and the whole point: no + * {@code ObjectId} probe, no hex detection, no length heuristic. + */ +@WritingConverter +public final class DomainIdWriteConverter implements Converter { + + @Override + public String convert(DomainDocumentId source) { + return source.value(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/LocalDateTimeMappingGuard.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/LocalDateTimeMappingGuard.java new file mode 100644 index 00000000..1d11c752 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/LocalDateTimeMappingGuard.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import java.util.Objects; +import java.util.Set; + +/** + * Refuses implicit {@code LocalDateTime} storage (design §10). + * + *

Spring Data will happily store a {@code LocalDateTime} by applying the JVM's default zone. The + * result is data whose meaning depends on the host that wrote it: the same wall-clock value written + * from two regions lands hours apart, and nothing in the document records which zone was assumed. + * + *

The guard's answer is not a better default zone — any default is a guess about business + * meaning — but a startup failure that names the converter the deployment must register. + */ +public final class LocalDateTimeMappingGuard { + + /** The converter name a deployment must register to store {@code LocalDateTime} at all. */ + public static final String REQUIRED_CONVERTER = "mongodb.local-date-time"; + + private final Set registeredConverterNames; + + public LocalDateTimeMappingGuard(Set registeredConverterNames) { + this.registeredConverterNames = + Set.copyOf(Objects.requireNonNull(registeredConverterNames, "registeredConverterNames")); + } + + /** A guard for a deployment that registered no temporal converters. */ + public static LocalDateTimeMappingGuard withoutConverters() { + return new LocalDateTimeMappingGuard(Set.of()); + } + + /** + * Validates the manifest's temporal axis against what is actually registered. + * + * @throws MongoOperationRejectedException when the manifest asks for {@code LocalDateTime} + * storage without the named converter + */ + public void validate(MongoTypeRepresentationManifest manifest) { + Objects.requireNonNull(manifest, "manifest"); + MongoTemporalRepresentation temporal = manifest.temporal(); + if (temporal.requiresRegisteredConverter() + && !registeredConverterNames.contains(REQUIRED_CONVERTER)) { + throw MongoOperationRejectedException.policy( + "the mapping manifest selects " + + temporal + + " but converter '" + + REQUIRED_CONVERTER + + "' is not registered; MongoDB has no zone-free date type, so the zone must be " + + "stated explicitly rather than taken from the JVM default"); + } + } + + /** True when a deployment may store {@code LocalDateTime} values. */ + public boolean localDateTimeStorageAllowed() { + return registeredConverterNames.contains(REQUIRED_CONVERTER); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoCustomConversionsFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoCustomConversionsFactory.java new file mode 100644 index 00000000..caf51775 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoCustomConversionsFactory.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +/** + * Builds the converter set the manifest describes, in a deterministic order (design §10). + * + *

Order matters for more than tidiness: Spring's conversion service resolves the first matching + * converter, so a set assembled from a {@code Set} or from classpath scanning can silently pick a + * different converter between two JVM runs. Building the list explicitly, and publishing a + * fingerprint over it, is what lets startup validation and the golden BSON snapshots assert that + * the mapping in force is the mapping that was certified. + */ +public final class MongoCustomConversionsFactory { + + private MongoCustomConversionsFactory() {} + + /** The converter list for the platform's standard manifest. */ + public static MongoCustomConversions standard() { + return forManifest(MongoTypeRepresentationManifest.standard()); + } + + /** The converter list the given manifest calls for. */ + public static MongoCustomConversions forManifest(MongoTypeRepresentationManifest manifest) { + return new MongoCustomConversions(converters(manifest)); + } + + /** + * The converters, in registration order. + * + *

Package-visible so the fingerprint and the tests read exactly the list that is registered + * rather than a second copy that can drift from it. + */ + static List converters(MongoTypeRepresentationManifest manifest) { + Objects.requireNonNull(manifest, "manifest"); + manifest.requireWritable(); + List converters = new ArrayList<>(); + converters.add(new BigDecimalToDecimal128Converter()); + converters.add(new Decimal128ToBigDecimalConverter()); + converters.add(new DomainIdWriteConverter()); + converters.add(new DomainIdReadConverter()); + return List.copyOf(converters); + } + + /** + * A stable identity for the registered converter set. + * + *

Combined with the manifest fingerprint, this is what a golden BSON snapshot compares + * against: adding or reordering a converter changes the representation, so it must change the + * snapshot too. + */ + public static String fingerprint(MongoTypeRepresentationManifest manifest) { + StringBuilder fingerprint = new StringBuilder(manifest.fingerprint()); + for (Object converter : converters(manifest)) { + fingerprint.append(';').append(converter.getClass().getSimpleName()); + } + return fingerprint.toString(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfiguration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfiguration.java new file mode 100644 index 00000000..d0de2d3b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfiguration.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import dev.caskeleton.adapter.outbound.mongo.mapping.type.MongoTypeMetadataRegistry; +import dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +/** + * Wires the frozen BSON representation into Spring Data's converter (design §10, §11). + * + *

Everything here is deliberately explicit rather than defaulted. The manifest is a bean so a + * deployment states its representation instead of inheriting whatever the current library version + * happens to do; the type mapper is replaced so {@code _class} is governed per collection; and the + * guard runs at wiring time so a manifest that asks for {@code LocalDateTime} without a registered + * converter fails before the first document is written rather than after. + */ +@Configuration(proxyBeanMethods = false) +public class MongoMappingConfiguration { + + /** The deployment's representation manifest. Override the bean to change it deliberately. */ + @Bean + @ConditionalOnMissingBean + public MongoTypeRepresentationManifest mongoTypeRepresentationManifest() { + return MongoTypeRepresentationManifest.standard(); + } + + /** The converter set the manifest calls for, in deterministic registration order. */ + @Bean + @ConditionalOnMissingBean + public MongoCustomConversions mongoCustomConversions(MongoTypeRepresentationManifest manifest) { + return MongoCustomConversionsFactory.forManifest(manifest); + } + + /** Refuses implicit, JVM-zone-dependent {@code LocalDateTime} storage. */ + @Bean + @ConditionalOnMissingBean + public LocalDateTimeMappingGuard localDateTimeMappingGuard( + MongoTypeRepresentationManifest manifest) { + LocalDateTimeMappingGuard guard = LocalDateTimeMappingGuard.withoutConverters(); + guard.validate(manifest); + return guard; + } + + /** + * Applies the per-collection type metadata policy to the live converter. + * + *

A customizer rather than a replacement converter bean: Boot already builds {@link + * MappingMongoConverter} with the mapping context and the DBRef resolver, and rebuilding it here + * would mean re-deriving that wiring and drifting from it at the next upgrade. + */ + @Bean + @ConditionalOnMissingBean + public MongoTypeMetadataConfigurer mongoTypeMetadataConfigurer( + ObjectProvider converters, MongoTypeMetadataRegistry registry) { + return new MongoTypeMetadataConfigurer(converters, new PolicyAwareMongoTypeMapper(registry)); + } + + /** An empty registry so a deployment with no long-lived collection still starts. */ + @Bean + @ConditionalOnMissingBean + public MongoTypeMetadataRegistry mongoTypeMetadataRegistry() { + return MongoTypeMetadataRegistry.empty(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoTypeMetadataConfigurer.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoTypeMetadataConfigurer.java new file mode 100644 index 00000000..ab6677dc --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoTypeMetadataConfigurer.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import dev.caskeleton.adapter.outbound.mongo.mapping.type.PolicyAwareMongoTypeMapper; +import java.util.Objects; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; + +/** + * Installs the policy-aware type mapper onto the converter Boot already built (design §11). + * + *

Runs as an {@link InitializingBean} rather than inside a converter factory method because the + * converter is created by Boot's own auto-configuration; hooking it after construction keeps the + * rest of that wiring — mapping context, DBRef resolver, custom conversions — exactly as Boot + * defines it, so a Boot upgrade cannot silently diverge from a copy maintained here. + */ +public final class MongoTypeMetadataConfigurer implements InitializingBean { + + private final ObjectProvider converters; + + private final PolicyAwareMongoTypeMapper typeMapper; + + public MongoTypeMetadataConfigurer( + ObjectProvider converters, PolicyAwareMongoTypeMapper typeMapper) { + this.converters = Objects.requireNonNull(converters, "converters"); + this.typeMapper = Objects.requireNonNull(typeMapper, "typeMapper"); + } + + @Override + public void afterPropertiesSet() { + converters.forEach(converter -> converter.setTypeMapper(typeMapper)); + } + + /** The mapper this configurer installs. Exposed for startup assertions and tests. */ + public PolicyAwareMongoTypeMapper typeMapper() { + return typeMapper; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/LongLivedMongoDocument.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/LongLivedMongoDocument.java new file mode 100644 index 00000000..d1ed747d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/LongLivedMongoDocument.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping.type; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a document type whose stored representation must outlive its Java class name (design §11). + * + *

The annotation exists so the decision is recorded next to the type rather than in a + * configuration file nobody reads before renaming a package. A collection carrying this annotation + * can never write a Java fully-qualified class name into its documents, which is what makes moving + * the class a refactor instead of a data migration. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface LongLivedMongoDocument { + + /** The registered collection profile this document belongs to. */ + String collectionProfile(); + + /** The stable identifier written into the document instead of the class name. */ + String alias(); + + /** Which metadata field carries the alias. Defaults to an explicit {@code documentType} field. */ + MongoTypeMetadataPolicy policy() default MongoTypeMetadataPolicy.EXPLICIT_DOCUMENT_TYPE; +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataDescriptor.java new file mode 100644 index 00000000..29ddc389 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataDescriptor.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping.type; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * How one document type records its identity in stored BSON (design §11). + * + *

The alias is constrained to a short, lowercase, dot-free token for the same reason a metric + * tag is: it is written into every document of the collection and read by every consumer, including + * ones that are not JVM programs. A dotted value would look like a package name and invite exactly + * the class-name coupling the policy exists to remove. + */ +public record MongoTypeMetadataDescriptor( + String collectionProfile, String stableAlias, MongoTypeMetadataPolicy policy) { + + private static final Pattern ALIAS_FORMAT = Pattern.compile("[a-z][a-z0-9-]{1,63}"); + + public MongoTypeMetadataDescriptor { + Objects.requireNonNull(collectionProfile, "collectionProfile"); + Objects.requireNonNull(stableAlias, "stableAlias"); + Objects.requireNonNull(policy, "policy"); + if (policy.requiresStableIdentifier() && !ALIAS_FORMAT.matcher(stableAlias).matches()) { + throw new IllegalArgumentException( + "a long-lived MongoDB document needs a stable lowercase alias, got: " + stableAlias); + } + } + + /** A descriptor for a long-lived collection using an explicit {@code documentType} field. */ + public static MongoTypeMetadataDescriptor documentType(String collectionProfile, String alias) { + return new MongoTypeMetadataDescriptor( + collectionProfile, alias, MongoTypeMetadataPolicy.EXPLICIT_DOCUMENT_TYPE); + } + + /** A descriptor for a long-lived collection using a stable alias in the {@code _class} field. */ + public static MongoTypeMetadataDescriptor alias(String collectionProfile, String alias) { + return new MongoTypeMetadataDescriptor( + collectionProfile, alias, MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED); + } + + /** True when this type must never write a Java class name into its documents. */ + public boolean forbidsJavaClassName() { + return policy.forbidsJavaClassName(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataRegistry.java new file mode 100644 index 00000000..1e9b1bb1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/MongoTypeMetadataRegistry.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping.type; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The bidirectional alias table for stored type metadata (design §11). + * + *

Duplicate aliases are rejected when the registry is built, not when a document is read. Two + * types sharing an alias is a data-corrupting configuration: writes succeed, and reads resolve to + * whichever type happened to register last — silently, and differently between deployments. + */ +public final class MongoTypeMetadataRegistry { + + private final Map, MongoTypeMetadataDescriptor> byType; + + private final Map> byAlias; + + private MongoTypeMetadataRegistry( + Map, MongoTypeMetadataDescriptor> byType, Map> byAlias) { + this.byType = byType; + this.byAlias = byAlias; + } + + /** A registry with no long-lived documents: every type keeps Spring Data's default behaviour. */ + public static MongoTypeMetadataRegistry empty() { + return new MongoTypeMetadataRegistry(Map.of(), Map.of()); + } + + /** Starts an explicit registration. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builds a registry from the {@link LongLivedMongoDocument} annotations on the given types. + * + *

Annotation-driven rather than classpath-scanned: the platform never discovers document types + * on its own, because a type that appears in the registry only because it happened to be on the + * classpath is a type whose storage contract nobody reviewed. + */ + public static MongoTypeMetadataRegistry fromAnnotations(Collection> documentTypes) { + Objects.requireNonNull(documentTypes, "documentTypes"); + Builder builder = builder(); + for (Class documentType : documentTypes) { + LongLivedMongoDocument annotation = documentType.getAnnotation(LongLivedMongoDocument.class); + if (annotation == null) { + throw new IllegalArgumentException( + documentType.getName() + " is not annotated with @LongLivedMongoDocument"); + } + builder.register( + documentType, + new MongoTypeMetadataDescriptor( + annotation.collectionProfile(), annotation.alias(), annotation.policy())); + } + return builder.build(); + } + + /** The descriptor for a document type, if it declared one. */ + public Optional find(Class documentType) { + return Optional.ofNullable(byType.get(Objects.requireNonNull(documentType, "documentType"))); + } + + /** The type an alias resolves to, if the alias is registered. */ + public Optional> resolveAlias(String alias) { + return Optional.ofNullable(byAlias.get(Objects.requireNonNull(alias, "alias"))); + } + + /** The effective policy for a type; unregistered types keep Spring Data's default. */ + public MongoTypeMetadataPolicy policyFor(Class documentType) { + return find(documentType) + .map(MongoTypeMetadataDescriptor::policy) + .orElse(MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED); + } + + /** All registered types. */ + public Map, MongoTypeMetadataDescriptor> declared() { + return Map.copyOf(byType); + } + + /** Collects registrations and rejects duplicate aliases. */ + public static final class Builder { + + private final Map, MongoTypeMetadataDescriptor> byType = new LinkedHashMap<>(); + + private final Map> byAlias = new LinkedHashMap<>(); + + private Builder() {} + + /** Registers one document type. */ + public Builder register(Class documentType, MongoTypeMetadataDescriptor descriptor) { + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(descriptor, "descriptor"); + if (byType.putIfAbsent(documentType, descriptor) != null) { + throw new IllegalArgumentException( + "duplicate MongoDB type metadata registration for " + documentType.getName()); + } + if (descriptor.policy().requiresStableIdentifier()) { + Class existing = byAlias.putIfAbsent(descriptor.stableAlias(), documentType); + if (existing != null) { + throw new IllegalArgumentException( + "duplicate MongoDB type alias '" + + descriptor.stableAlias() + + "' for " + + existing.getName() + + " and " + + documentType.getName()); + } + } + return this; + } + + /** Builds the immutable registry. */ + public MongoTypeMetadataRegistry build() { + return new MongoTypeMetadataRegistry(Map.copyOf(byType), Map.copyOf(byAlias)); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapper.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapper.java new file mode 100644 index 00000000..36ca30ae --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapper.java @@ -0,0 +1,188 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping.type; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.springframework.data.core.TypeInformation; +import org.springframework.data.mongodb.core.convert.MongoTypeMapper; + +/** + * Writes and reads type metadata according to each collection's declared policy (design §11). + * + *

Spring Data's default writes the Java fully-qualified class name into every polymorphic + * document. For an internal, short-lived collection that is harmless. For a long-lived one it means + * the stored data encodes this application's package layout, so moving a class becomes a data + * migration and any non-JVM consumer has to parse Java package names to know what it is reading. + * + *

Two rules make the mapper strict where the default is lenient. Aliases never contain a dot, so + * a dotted value is unambiguously a legacy class name and an undotted one is unambiguously an alias + * — which lets an unregistered alias fail with a schema error instead of falling back to class + * loading, where a hostile or stale value would decide which class gets instantiated. + */ +public final class PolicyAwareMongoTypeMapper implements MongoTypeMapper { + + /** Spring Data's default type key. */ + public static final String CLASS_KEY = "_class"; + + /** The explicit, framework-neutral type key for externally shared collections. */ + public static final String DOCUMENT_TYPE_KEY = "documentType"; + + private static final MongoOperationName READ_OPERATION = + new MongoOperationName("mapping.type-metadata"); + + private final MongoTypeMetadataRegistry registry; + + public PolicyAwareMongoTypeMapper(MongoTypeMetadataRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public boolean isTypeKey(String key) { + return CLASS_KEY.equals(key) || DOCUMENT_TYPE_KEY.equals(key); + } + + @Override + public void writeType(Class type, Bson sink) { + writeType(TypeInformation.of(type), sink); + } + + @Override + public void writeType(TypeInformation type, Bson sink) { + Objects.requireNonNull(type, "type"); + if (!(sink instanceof Document document)) { + return; + } + Class rawType = type.getType(); + Optional descriptor = registry.find(rawType); + MongoTypeMetadataPolicy policy = + descriptor + .map(MongoTypeMetadataDescriptor::policy) + .orElse(MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED); + switch (policy) { + case NO_TYPE_METADATA -> { + // A single-type collection carries no metadata at all. + } + case EXPLICIT_DOCUMENT_TYPE -> + document.put(DOCUMENT_TYPE_KEY, requiredAlias(descriptor, rawType)); + case ALIAS_FOR_LONG_LIVED -> document.put(CLASS_KEY, requiredAlias(descriptor, rawType)); + case CLASS_METADATA_ALLOWED -> document.put(CLASS_KEY, rawType.getName()); + default -> throw new IllegalStateException("unhandled type metadata policy: " + policy); + } + } + + @Override + public TypeInformation readType(Bson source) { + return readStoredType(source).orElse(null); + } + + @Override + public TypeInformation readType(Bson source, TypeInformation basicType) { + Objects.requireNonNull(basicType, "basicType"); + Optional> stored = readStoredType(source); + if (stored.isEmpty()) { + return basicType; + } + TypeInformation resolved = stored.get(); + if (!basicType.getType().isAssignableFrom(resolved.getType())) { + return basicType; + } + @SuppressWarnings("unchecked") + TypeInformation narrowed = (TypeInformation) resolved; + return narrowed; + } + + @Override + public void writeTypeRestrictions(Document result, Set> restrictedTypes) { + Objects.requireNonNull(result, "result"); + if (restrictedTypes == null || restrictedTypes.isEmpty()) { + return; + } + Set keys = new LinkedHashSet<>(); + List values = new ArrayList<>(); + for (Class restrictedType : restrictedTypes) { + Optional descriptor = registry.find(restrictedType); + MongoTypeMetadataPolicy policy = + descriptor + .map(MongoTypeMetadataDescriptor::policy) + .orElse(MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED); + if (policy == MongoTypeMetadataPolicy.NO_TYPE_METADATA) { + throw MongoOperationRejectedException.policy( + "cannot restrict a query by type for " + + restrictedType.getName() + + ": its collection stores no type metadata"); + } + keys.add( + policy == MongoTypeMetadataPolicy.EXPLICIT_DOCUMENT_TYPE ? DOCUMENT_TYPE_KEY : CLASS_KEY); + values.add( + policy == MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED + ? restrictedType.getName() + : requiredAlias(descriptor, restrictedType)); + } + if (keys.size() != 1) { + // One query predicate cannot span two metadata fields, and silently picking one would filter + // out documents of the other type instead of failing. + throw MongoOperationRejectedException.policy( + "a type-restricted query cannot mix " + + CLASS_KEY + + " and " + + DOCUMENT_TYPE_KEY + + " metadata policies"); + } + result.put(keys.iterator().next(), new Document("$in", values)); + } + + private Optional> readStoredType(Bson source) { + if (!(source instanceof Document document)) { + return Optional.empty(); + } + Object documentType = document.get(DOCUMENT_TYPE_KEY); + if (documentType instanceof String alias) { + return Optional.of(TypeInformation.of(resolveAlias(alias))); + } + Object classValue = document.get(CLASS_KEY); + if (!(classValue instanceof String value)) { + return Optional.empty(); + } + // A dot can only appear in a Java class name: the alias format forbids it. That is what makes + // "unknown alias" and "legacy class name" distinguishable without guessing. + Class resolved = value.indexOf('.') >= 0 ? loadLegacyClass(value) : resolveAlias(value); + return Optional.of(TypeInformation.of(resolved)); + } + + private Class resolveAlias(String alias) { + return registry + .resolveAlias(alias) + .orElseThrow( + () -> + new MongoSchemaValidationException( + MongoFailureContext.schemaMismatch(READ_OPERATION))); + } + + private static Class loadLegacyClass(String className) { + try { + return Class.forName(className, false, PolicyAwareMongoTypeMapper.class.getClassLoader()); + } catch (ClassNotFoundException notFound) { + throw new MongoSchemaValidationException(MongoFailureContext.schemaMismatch(READ_OPERATION)); + } + } + + private static String requiredAlias( + Optional descriptor, Class rawType) { + return descriptor + .map(MongoTypeMetadataDescriptor::stableAlias) + .orElseThrow( + () -> + MongoOperationRejectedException.policy( + rawType.getName() + " requires a registered stable type alias")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLedger.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLedger.java new file mode 100644 index 00000000..d3ad3856 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLedger.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import com.mongodb.client.model.ReplaceOptions; +import java.time.Instant; +import java.util.Date; +import java.util.Objects; +import java.util.Optional; +import org.bson.Document; + +/** + * The migration ledger, stored in the database being migrated (design §12.4). + * + *

In the database rather than in the deployment, because a redeployment must not forget what has + * already been applied, and two instances of a rolling deployment must get the same answer to "has + * this run". + * + *

The unique index on the migration id is the part that matters. Without it, two runners that + * both pass the "not applied yet" check both insert, and the ledger then reports a migration as + * applied twice with two different checksums — which is indistinguishable from tampering. + */ +public final class MongoCollectionMigrationLedger implements MongoMigrationLedger { + + /** The default collection name; a fork may namespace it. */ + public static final String DEFAULT_COLLECTION = "mongoMigrationLedger"; + + /** The default checkpoint collection name. */ + public static final String DEFAULT_CHECKPOINT_COLLECTION = "mongoMigrationCheckpoint"; + + private static final String MIGRATION_ID = "migrationId"; + + private final MongoCollection ledger; + + private final MongoCollection checkpoints; + + public MongoCollectionMigrationLedger(MongoDatabase database) { + this( + Objects.requireNonNull(database, "database").getCollection(DEFAULT_COLLECTION), + database.getCollection(DEFAULT_CHECKPOINT_COLLECTION)); + } + + public MongoCollectionMigrationLedger( + MongoCollection ledger, MongoCollection checkpoints) { + this.ledger = Objects.requireNonNull(ledger, "ledger"); + this.checkpoints = Objects.requireNonNull(checkpoints, "checkpoints"); + } + + /** + * Creates the uniqueness the ledger depends on. + * + *

Separate from the constructor: index creation is an admin-plane action, and a ledger that + * silently creates indexes on first use is the auto-index-creation behaviour the platform refuses + * everywhere else. + */ + public void ensureIndexes() { + ledger.createIndex(Indexes.ascending(MIGRATION_ID), new IndexOptions().unique(true)); + checkpoints.createIndex(Indexes.ascending(MIGRATION_ID), new IndexOptions().unique(true)); + } + + @Override + public Optional find(MongoMigrationId migrationId) { + Objects.requireNonNull(migrationId, "migrationId"); + Document found = ledger.find(Filters.eq(MIGRATION_ID, migrationId.value())).first(); + if (found == null) { + return Optional.empty(); + } + return Optional.of( + new AppliedMigration( + migrationId, + new MongoMigrationChecksum(found.getString("checksum")), + found.getString("operator"), + found.getDate("appliedAt").toInstant())); + } + + @Override + public void recordApplied( + MongoMigrationId migrationId, + MongoMigrationChecksum checksum, + String operator, + Instant appliedAt) { + Objects.requireNonNull(migrationId, "migrationId"); + Objects.requireNonNull(checksum, "checksum"); + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(appliedAt, "appliedAt"); + ledger.insertOne( + new Document(MIGRATION_ID, migrationId.value()) + .append("checksum", checksum.value()) + .append("operator", operator) + .append("appliedAt", Date.from(appliedAt))); + } + + @Override + public Optional findCheckpoint(MongoMigrationId migrationId) { + Objects.requireNonNull(migrationId, "migrationId"); + Document found = checkpoints.find(Filters.eq(MIGRATION_ID, migrationId.value())).first(); + if (found == null) { + return Optional.empty(); + } + return Optional.of( + new MongoMigrationCheckpoint( + migrationId, + found.getString("resumePosition"), + found.getLong("processedCount"), + found.getDate("updatedAt").toInstant())); + } + + @Override + public void saveCheckpoint(MongoMigrationCheckpoint checkpoint) { + Objects.requireNonNull(checkpoint, "checkpoint"); + checkpoints.replaceOne( + Filters.eq(MIGRATION_ID, checkpoint.migrationId().value()), + new Document(MIGRATION_ID, checkpoint.migrationId().value()) + .append("resumePosition", checkpoint.resumePosition()) + .append("processedCount", checkpoint.processedCount()) + .append("updatedAt", Date.from(checkpoint.updatedAt())), + new ReplaceOptions().upsert(true)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLock.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLock.java new file mode 100644 index 00000000..08eac1c4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoCollectionMigrationLock.java @@ -0,0 +1,146 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import com.mongodb.MongoWriteException; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Updates; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Objects; +import java.util.Optional; +import org.bson.Document; +import org.bson.conversions.Bson; + +/** + * A single-document migration lease held in MongoDB (design §12.4). + * + *

Acquisition is one conditional update, not read-then-write. Two runners that both read "free" + * and then both write would both believe they hold the lease; a filter that matches only a free or + * expired lease makes the server decide, and exactly one update matches. + * + *

A lease with an expiry rather than a lock, because a runner that is killed mid-migration must + * not block every future deployment. The expiry is refreshed between batches, so a still-running + * migration keeps its lease and a dead one loses it. + */ +public final class MongoCollectionMigrationLock implements MongoMigrationLock { + + /** The default collection name; a fork may namespace it. */ + public static final String DEFAULT_COLLECTION = "mongoMigrationLock"; + + private static final String LOCK_ID = "migration-runner"; + + private final MongoCollection locks; + + private final String owner; + + private final Clock clock; + + private boolean held = true; + + private MongoCollectionMigrationLock(MongoCollection locks, String owner, Clock clock) { + this.locks = locks; + this.owner = owner; + this.clock = clock; + } + + /** + * Tries to take the lease. + * + * @return the held lease, or empty when another runner holds an unexpired one + */ + public static Optional tryAcquire( + MongoDatabase database, String owner, Duration leaseDuration, Clock clock) { + Objects.requireNonNull(database, "database"); + return tryAcquire(database.getCollection(DEFAULT_COLLECTION), owner, leaseDuration, clock); + } + + /** Tries to take the lease on an explicit collection. */ + public static Optional tryAcquire( + MongoCollection locks, String owner, Duration leaseDuration, Clock clock) { + Objects.requireNonNull(locks, "locks"); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(leaseDuration, "leaseDuration"); + Objects.requireNonNull(clock, "clock"); + if (leaseDuration.isZero() || leaseDuration.isNegative()) { + throw new IllegalArgumentException("a migration lease must have a positive duration"); + } + + Instant now = clock.instant(); + Bson freeOrExpired = + Filters.and( + Filters.eq("_id", LOCK_ID), + Filters.or(Filters.eq("owner", null), Filters.lte("expiresAt", Date.from(now)))); + Bson take = + Updates.combine( + Updates.set("owner", owner), + Updates.set("acquiredAt", Date.from(now)), + Updates.set("expiresAt", Date.from(now.plus(leaseDuration)))); + + // matchedCount, not modifiedCount: the filter matching is what decides ownership. A re-acquire + // that happens to write identical values reports zero modified documents, and treating that as + // "someone else holds it" would be wrong. + if (locks.updateOne(freeOrExpired, take).getMatchedCount() == 1) { + return Optional.of(new MongoCollectionMigrationLock(locks, owner, clock)); + } + + // No document yet: the insert is the acquisition. A duplicate key means another runner won + // the race, which is the correct answer rather than an error to surface. + try { + locks.insertOne( + new Document("_id", LOCK_ID) + .append("owner", owner) + .append("acquiredAt", Date.from(now)) + .append("expiresAt", Date.from(now.plus(leaseDuration)))); + return Optional.of(new MongoCollectionMigrationLock(locks, owner, clock)); + } catch (MongoWriteException lostTheRace) { + return Optional.empty(); + } + } + + @Override + public boolean held() { + if (!held) { + return false; + } + Document current = locks.find(Filters.eq("_id", LOCK_ID)).first(); + if (current == null || !owner.equals(current.getString("owner"))) { + return false; + } + Date expiresAt = current.getDate("expiresAt"); + return expiresAt != null && expiresAt.toInstant().isAfter(clock.instant()); + } + + @Override + public void refresh(Duration extension) { + Objects.requireNonNull(extension, "extension"); + // matchedCount again: two refreshes inside the same millisecond write the same expiry, and + // MongoDB reports an unchanged value as not modified. Losing the lease over that would abort a + // healthy migration. + long stillOwned = + locks + .updateOne( + Filters.and(Filters.eq("_id", LOCK_ID), Filters.eq("owner", owner)), + Updates.set("expiresAt", Date.from(clock.instant().plus(extension)))) + .getMatchedCount(); + if (stillOwned != 1) { + held = false; + throw new IllegalStateException( + "the migration lease was lost before it could be refreshed; another runner may have " + + "taken it after this one appeared to stall"); + } + } + + @Override + public void close() { + if (!held) { + return; + } + held = false; + locks.updateOne( + Filters.and(Filters.eq("_id", LOCK_ID), Filters.eq("owner", owner)), + Updates.combine(Updates.set("owner", null), Updates.unset("expiresAt"))); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigration.java new file mode 100644 index 00000000..a3a6d5ec --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigration.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +/** + * One migration change unit (design §12.4). + * + *

Preconditions and postconditions are part of the interface rather than something a migration + * asserts internally, so the runner can check them, record them and report them uniformly. A + * migration that verifies its own outcome in a comment has verified nothing. + * + *

There is no {@code rollback}. The design is explicit: a failed production change is repaired + * by a new forward-fix migration. A rollback method implies the reverse operation is always safe + * and always possible, and for a backfill that dropped a column's old values it is neither. + */ +public interface MongoMigration { + + /** The permanent identity of this migration. */ + MongoMigrationId id(); + + /** The content fingerprint the ledger compares against. */ + MongoMigrationChecksum checksum(); + + /** What must be true before this migration runs. */ + MongoMigrationPrecondition precondition(); + + /** Applies the change, honouring the context's batch size, rate limit and dry-run flag. */ + MongoMigrationResult execute(MongoMigrationContext context); + + /** What must be true afterwards for the migration to count as applied. */ + MongoMigrationPostcondition postcondition(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationCheckpoint.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationCheckpoint.java new file mode 100644 index 00000000..769ec5d1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationCheckpoint.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.time.Instant; +import java.util.Objects; + +/** + * How far a long backfill has progressed (design §12.4). + * + *

A backfill over a large collection outlives deployments, restarts and node failures. Without a + * durable checkpoint every interruption restarts it from zero, so the migration that takes six + * hours never finishes in an environment that redeploys every four. + * + *

The resume position is a sort key, not an offset, for the same reason list endpoints use + * keyset pagination: an offset is invalidated by the very writes the backfill is performing. + */ +public record MongoMigrationCheckpoint( + MongoMigrationId migrationId, String resumePosition, long processedCount, Instant updatedAt) { + + public MongoMigrationCheckpoint { + Objects.requireNonNull(migrationId, "migrationId"); + Objects.requireNonNull(resumePosition, "resumePosition"); + Objects.requireNonNull(updatedAt, "updatedAt"); + if (processedCount < 0) { + throw new IllegalArgumentException("processedCount must not be negative"); + } + } + + /** The checkpoint for a migration that has not started. */ + public static MongoMigrationCheckpoint start(MongoMigrationId migrationId, Instant now) { + return new MongoMigrationCheckpoint(migrationId, "", 0, now); + } + + /** The checkpoint after one batch. */ + public MongoMigrationCheckpoint advancedTo( + String newResumePosition, long batchSize, Instant now) { + return new MongoMigrationCheckpoint( + migrationId, newResumePosition, processedCount + batchSize, now); + } + + /** True when the migration has not processed anything yet. */ + public boolean atStart() { + return resumePosition.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationChecksum.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationChecksum.java new file mode 100644 index 00000000..4682c0cd --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationChecksum.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.util.Objects; + +/** + * The content fingerprint of a migration (design §12.4). + * + *

Compared against the ledger on every run. An applied migration whose checksum has changed + * means the code and the database no longer agree about what was done — and since the change + * already ran, the only safe response is to fail rather than to re-run it or to trust the new text. + */ +public record MongoMigrationChecksum(String value) { + + public MongoMigrationChecksum { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("a migration checksum must not be blank"); + } + } + + /** True when a stored checksum still matches this one. */ + public boolean matches(MongoMigrationChecksum other) { + return value.equals(Objects.requireNonNull(other, "other").value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationContext.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationContext.java new file mode 100644 index 00000000..47a6f744 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationContext.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * What a migration is given to work with (design §12.4). + * + *

Deliberately includes the admin gateway rather than a raw client: a migration changes + * collections, validators and indexes, and those are D4 operations that need an operator, a reason + * and an audit record even when a migration is the thing performing them. + * + *

The rate limit and batch size are inputs rather than choices a migration makes for itself, + * because a backfill that saturates the primary is indistinguishable from an outage to everything + * else using it. + */ +public record MongoMigrationContext( + MongoMigrationId migrationId, + MongoAdminGateway adminGateway, + MongoMigrationCheckpoint checkpoint, + int batchSize, + Duration batchPause, + Duration maxTime, + boolean dryRun, + String operator) { + + public MongoMigrationContext { + Objects.requireNonNull(migrationId, "migrationId"); + Objects.requireNonNull(adminGateway, "adminGateway"); + Objects.requireNonNull(batchPause, "batchPause"); + Objects.requireNonNull(maxTime, "maxTime"); + Objects.requireNonNull(operator, "operator"); + if (batchSize <= 0) { + throw new IllegalArgumentException("a migration batch size must be positive"); + } + if (operator.isBlank()) { + throw new IllegalArgumentException("a migration must name its operator"); + } + } + + /** The stored progress, when this migration is resuming. */ + public Optional resumeFrom() { + return Optional.ofNullable(checkpoint); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationId.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationId.java new file mode 100644 index 00000000..a1f751ae --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationId.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.util.regex.Pattern; + +/** + * The immutable identity of one migration (design §12.4). + * + *

Date-ordered and sequenced, because migrations have to apply in a defined order across + * branches that were developed in parallel. Once applied the id is permanent: it is the key the + * ledger uses to decide whether this change has already run. + */ +public record MongoMigrationId(String value) implements Comparable { + + private static final Pattern FORMAT = Pattern.compile("\\d{8}-\\d{3}"); + + public MongoMigrationId { + if (value == null || !FORMAT.matcher(value).matches()) { + throw new IllegalArgumentException( + "a migration id must be yyyyMMdd-nnn so ordering is defined across branches, got: " + + value); + } + } + + @Override + public int compareTo(MongoMigrationId other) { + return value.compareTo(other.value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLedger.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLedger.java new file mode 100644 index 00000000..6906d30a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLedger.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.time.Instant; +import java.util.Optional; + +/** + * The record of which migrations have been applied (design §12.4). + * + *

Stored in the database being migrated, not in the deployment, so it survives a redeployment + * and so two application instances see the same answer. The operator and timestamp are recorded + * because the question "who applied this and when" is only ever asked during an incident. + */ +public interface MongoMigrationLedger { + + /** The ledger entry for a migration, if it has been applied. */ + Optional find(MongoMigrationId migrationId); + + /** Records a completed migration. */ + void recordApplied( + MongoMigrationId migrationId, + MongoMigrationChecksum checksum, + String operator, + Instant appliedAt); + + /** The checkpoint of a resumable migration, if one exists. */ + Optional findCheckpoint(MongoMigrationId migrationId); + + /** Stores a checkpoint after a completed batch. */ + void saveCheckpoint(MongoMigrationCheckpoint checkpoint); + + /** One applied migration. */ + record AppliedMigration( + MongoMigrationId migrationId, + MongoMigrationChecksum checksum, + String operator, + Instant appliedAt) {} +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLock.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLock.java new file mode 100644 index 00000000..544e08c5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLock.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.time.Duration; + +/** + * A distributed lease that lets only one migration runner apply changes (design §12.4). + * + *

Every instance of a rolling deployment starts at once and every one of them will try to + * migrate. Without a lease the same {@code collMod} runs concurrently from several nodes, and a + * backfill runs as many times as there are replicas. + * + *

A lease with a TTL rather than a lock, so a runner that dies mid-migration does not block the + * next one forever — and so the refresh is what proves the holder is still alive. + */ +public interface MongoMigrationLock extends AutoCloseable { + + /** True when this process currently holds the lease. */ + boolean held(); + + /** Extends the lease. Called between batches by a long-running migration. */ + void refresh(Duration extension); + + /** Releases the lease. */ + @Override + void close(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPostcondition.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPostcondition.java new file mode 100644 index 00000000..5db2ea1c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPostcondition.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +/** + * What must hold after a migration completes (design §12.4). + * + *

This is what makes "the migration ran without throwing" different from "the migration worked". + * A backfill that silently matched zero documents — because its filter referenced a field that had + * already been renamed — completes successfully and changes nothing, and only a postcondition + * notices. + */ +@FunctionalInterface +public interface MongoMigrationPostcondition { + + /** True when the migration's intended effect is present. */ + boolean isSatisfied(MongoMigrationContext context, MongoMigrationResult result); + + /** A postcondition that is always satisfied. */ + static MongoMigrationPostcondition none() { + return (context, result) -> true; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPrecondition.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPrecondition.java new file mode 100644 index 00000000..83f02072 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationPrecondition.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +/** + * What must hold before a migration runs (design §12.4). + * + *

Checked by the runner, so a migration that would corrupt data given the current state does not + * start. The common case is a migration written against a schema version that is no longer what the + * collection holds — a rollout that skipped a release, or a branch merged out of order. + */ +@FunctionalInterface +public interface MongoMigrationPrecondition { + + /** True when the migration may run. */ + boolean isSatisfied(MongoMigrationContext context); + + /** A precondition that is always satisfied. */ + static MongoMigrationPrecondition none() { + return context -> true; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationResult.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationResult.java new file mode 100644 index 00000000..5718f215 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationResult.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import java.util.Objects; +import java.util.Optional; + +/** + * What one migration execution did (design §12.4). + * + *

{@code INCOMPLETE} is a first-class outcome, not a failure. A rate-limited backfill that ran + * out of its time budget has done real work and stored a checkpoint; reporting it as failed would + * send the next run back to the beginning. + */ +public record MongoMigrationResult( + Status status, long processedCount, MongoMigrationCheckpoint checkpoint, String detail) { + + public MongoMigrationResult { + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(detail, "detail"); + if (processedCount < 0) { + throw new IllegalArgumentException("processedCount must not be negative"); + } + } + + /** The migration finished. */ + public static MongoMigrationResult completed(long processedCount) { + return new MongoMigrationResult(Status.COMPLETED, processedCount, null, ""); + } + + /** The migration made progress and stored a checkpoint to resume from. */ + public static MongoMigrationResult incomplete( + long processedCount, MongoMigrationCheckpoint checkpoint) { + return new MongoMigrationResult( + Status.INCOMPLETE, processedCount, Objects.requireNonNull(checkpoint, "checkpoint"), ""); + } + + /** The migration validated everything and applied nothing. */ + public static MongoMigrationResult dryRun(String detail) { + return new MongoMigrationResult(Status.DRY_RUN, 0, null, detail); + } + + /** The checkpoint to resume from, when the run was incomplete. */ + public Optional resumePoint() { + return Optional.ofNullable(checkpoint); + } + + /** Whether the run finished, needs resuming, or applied nothing. */ + public enum Status { + + /** Everything the migration had to do is done. */ + COMPLETED, + + /** Progress was made and stored; another run continues from the checkpoint. */ + INCOMPLETE, + + /** Nothing was applied. */ + DRY_RUN + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunner.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunner.java new file mode 100644 index 00000000..cc2eeb33 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunner.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Clock; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * Applies migrations under a lease, in order, with the ledger as the source of truth (design + * §12.4). + * + *

Validation runs for every migration before any of them applies. A batch that fails halfway + * leaves the database in a state nobody planned; failing before the first change means the only + * thing to fix is the code. + * + *

An applied migration whose checksum changed is a hard stop. The change already ran, so the new + * text was never executed — re-running it could be destructive, and skipping it means the code and + * the database disagree about what the schema is. + */ +public final class MongoMigrationRunner { + + private final MongoMigrationLedger ledger; + + private final Clock clock; + + public MongoMigrationRunner(MongoMigrationLedger ledger) { + this(ledger, Clock.systemUTC()); + } + + public MongoMigrationRunner(MongoMigrationLedger ledger, Clock clock) { + this.ledger = Objects.requireNonNull(ledger, "ledger"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * Checks one migration against the ledger. + * + * @throws MongoOperationRejectedException when an applied migration's checksum has changed + */ + public void validate(MongoMigration migration) { + Objects.requireNonNull(migration, "migration"); + Optional applied = ledger.find(migration.id()); + if (applied.isEmpty()) { + return; + } + MongoMigrationChecksum recorded = applied.get().checksum(); + if (!recorded.matches(migration.checksum())) { + throw MongoOperationRejectedException.of( + "migration.checksum", + "migration " + + migration.id() + + " was applied with checksum " + + recorded + + " but the code now hashes to " + + migration.checksum() + + "; an applied migration is immutable, so this needs a new forward-fix migration"); + } + } + + /** + * Applies the migrations that have not run yet, in id order, holding the lease throughout. + * + * @param migrations the declared migrations, in any order + * @param lock the distributed lease; the run aborts if it is not held + * @param context a template context; the migration id and checkpoint are filled in per migration + */ + public List apply( + List migrations, MongoMigrationLock lock, MongoMigrationContext context) { + Objects.requireNonNull(migrations, "migrations"); + Objects.requireNonNull(lock, "lock"); + Objects.requireNonNull(context, "context"); + if (!lock.held()) { + throw MongoOperationRejectedException.of( + "migration.lock", + "the migration lease is not held; only one runner may apply changes at a time"); + } + + migrations.forEach(this::validate); + + return migrations.stream() + .sorted((left, right) -> left.id().compareTo(right.id())) + .filter(migration -> ledger.find(migration.id()).isEmpty()) + .map(migration -> applyOne(migration, lock, context)) + .toList(); + } + + private MongoMigrationResult applyOne( + MongoMigration migration, MongoMigrationLock lock, MongoMigrationContext template) { + MongoMigrationContext context = + new MongoMigrationContext( + migration.id(), + template.adminGateway(), + ledger.findCheckpoint(migration.id()).orElse(null), + template.batchSize(), + template.batchPause(), + template.maxTime(), + template.dryRun(), + template.operator()); + + if (!migration.precondition().isSatisfied(context)) { + throw MongoOperationRejectedException.of( + "migration.precondition", + "migration " + migration.id() + " cannot run: its precondition is not satisfied"); + } + + MongoMigrationResult result = migration.execute(context); + lock.refresh(template.maxTime()); + + if (!migration.postcondition().isSatisfied(context, result)) { + throw MongoOperationRejectedException.of( + "migration.postcondition", + "migration " + + migration.id() + + " reported " + + result.status() + + " but its postcondition does not hold, so the intended change is not present"); + } + + result.resumePoint().ifPresent(ledger::saveCheckpoint); + if (result.status() == MongoMigrationResult.Status.COMPLETED && !context.dryRun()) { + ledger.recordApplied( + migration.id(), migration.checksum(), context.operator(), clock.instant()); + } + return result; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockChangeUnitView.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockChangeUnitView.java new file mode 100644 index 00000000..8de0ca45 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockChangeUnitView.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import java.util.Objects; + +/** + * The platform's own view of a Flamingock change unit (design §12.4). + * + *

A view type rather than a Flamingock class, and that is the whole point of the adapter: the + * platform's migration contract does not depend on Flamingock, so a deployment can replace the + * migration engine without touching a single application-facing type. Mongock is explicitly not + * adopted for new projects, and this boundary is what makes that decision reversible. + */ +public record FlamingockChangeUnitView( + String id, String checksum, String author, boolean transactional) { + + public FlamingockChangeUnitView { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(checksum, "checksum"); + Objects.requireNonNull(author, "author"); + if (id.isBlank()) { + throw new IllegalArgumentException("a change unit needs an id"); + } + } + + /** A change unit view carrying only the identity and checksum. */ + public FlamingockChangeUnitView(String id, String checksum) { + this(id, checksum, "", false); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLedgerAdapter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLedgerAdapter.java new file mode 100644 index 00000000..5bb24929 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLedgerAdapter.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationCheckpoint; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLedger; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +/** + * Presents an engine's audit log as the platform's ledger (design §12.4). + * + *

The engine already records what it applied. Reading its log through this adapter, rather than + * keeping a parallel platform ledger, avoids the failure where the two disagree — which happens the + * first time a migration is applied outside the platform's runner. + * + *

Checkpoints stay platform-owned, because they describe progress within a change unit + * and no engine models that. + */ +public final class FlamingockLedgerAdapter implements MongoMigrationLedger { + + private final Function> engineAuditLog; + + private final Map checkpoints = new LinkedHashMap<>(); + + private final Map recorded = new LinkedHashMap<>(); + + public FlamingockLedgerAdapter( + Function> engineAuditLog) { + this.engineAuditLog = Objects.requireNonNull(engineAuditLog, "engineAuditLog"); + } + + @Override + public Optional find(MongoMigrationId migrationId) { + Objects.requireNonNull(migrationId, "migrationId"); + AppliedMigration local = recorded.get(migrationId); + if (local != null) { + return Optional.of(local); + } + return engineAuditLog + .apply(migrationId.value()) + .map( + view -> + new AppliedMigration( + migrationId, + new MongoMigrationChecksum(view.checksum()), + view.author(), + Instant.EPOCH)); + } + + @Override + public void recordApplied( + MongoMigrationId migrationId, + MongoMigrationChecksum checksum, + String operator, + Instant appliedAt) { + recorded.put( + Objects.requireNonNull(migrationId, "migrationId"), + new AppliedMigration(migrationId, checksum, operator, appliedAt)); + } + + @Override + public Optional findCheckpoint(MongoMigrationId migrationId) { + return Optional.ofNullable(checkpoints.get(Objects.requireNonNull(migrationId, "migrationId"))); + } + + @Override + public void saveCheckpoint(MongoMigrationCheckpoint checkpoint) { + Objects.requireNonNull(checkpoint, "checkpoint"); + checkpoints.put(checkpoint.migrationId(), checkpoint); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLockAdapter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLockAdapter.java new file mode 100644 index 00000000..0dbef535 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockLockAdapter.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationLock; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * Presents an engine's distributed lock as the platform's lease (design §12.4). + * + *

Acquisition happens at construction so there is no window in which the adapter exists but the + * lease does not — a window in which a caller would reasonably believe it was safe to migrate. + */ +public final class FlamingockLockAdapter implements MongoMigrationLock { + + private final AtomicBoolean held = new AtomicBoolean(); + + private final Consumer extend; + + private final Runnable release; + + public FlamingockLockAdapter( + Supplier acquire, Consumer extend, Runnable release) { + Objects.requireNonNull(acquire, "acquire"); + this.extend = Objects.requireNonNull(extend, "extend"); + this.release = Objects.requireNonNull(release, "release"); + if (!Boolean.TRUE.equals(acquire.get())) { + throw MongoOperationRejectedException.of( + "migration.lock", + "another migration runner holds the lease; only one may apply changes at a time"); + } + held.set(true); + } + + @Override + public boolean held() { + return held.get(); + } + + @Override + public void refresh(Duration extension) { + Objects.requireNonNull(extension, "extension"); + if (!held.get()) { + throw MongoOperationRejectedException.of( + "migration.lock", "the migration lease has been released and cannot be extended"); + } + extend.accept(extension); + } + + @Override + public void close() { + if (held.compareAndSet(true, false)) { + release.run(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMigrationConfiguration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMigrationConfiguration.java new file mode 100644 index 00000000..40127b93 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMigrationConfiguration.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import java.time.Duration; +import java.util.Objects; + +/** + * The settings the migration engine adapter runs under (design §12.4). + * + *

The lease timeout is longer than a single batch and shorter than the whole migration, so a + * dead runner's lease expires within a batch while a live runner keeps it by refreshing. Setting it + * to cover the whole migration would block every subsequent deployment after one crash. + */ +public record FlamingockMigrationConfiguration( + String migrationPackage, + Duration leaseTimeout, + Duration leaseRefreshInterval, + boolean mongockCompatibility) { + + public FlamingockMigrationConfiguration { + Objects.requireNonNull(migrationPackage, "migrationPackage"); + Objects.requireNonNull(leaseTimeout, "leaseTimeout"); + Objects.requireNonNull(leaseRefreshInterval, "leaseRefreshInterval"); + if (mongockCompatibility) { + throw new IllegalArgumentException( + "Mongock compatibility is not adopted for new projects; enabling it recreates the legacy " + + "ledger state it exists to migrate away from"); + } + if (leaseRefreshInterval.compareTo(leaseTimeout) >= 0) { + throw new IllegalArgumentException( + "the lease refresh interval must be shorter than the lease timeout, or the lease expires " + + "under a live runner"); + } + } + + /** The platform default: a 3-minute lease refreshed every minute. */ + public static FlamingockMigrationConfiguration standard(String migrationPackage) { + return new FlamingockMigrationConfiguration( + migrationPackage, Duration.ofMinutes(3), Duration.ofMinutes(1), false); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapter.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapter.java new file mode 100644 index 00000000..d9bd3c5d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapter.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration; +import java.util.Objects; + +/** + * Maps a platform migration onto a Flamingock change unit (design §12.4). + * + *

Identity and checksum are carried across unchanged, so the two ledgers agree about what has + * been applied. Long backfills keep the platform's own checkpoints rather than hiding progress + * inside one change unit: a change unit is atomic from Flamingock's point of view, and a six-hour + * backfill that restarts from zero on every interruption never finishes. + */ +public final class FlamingockMongoMigrationAdapter { + + /** The view of a platform migration as a change unit. */ + public FlamingockChangeUnitView adapt(MongoMigration migration) { + Objects.requireNonNull(migration, "migration"); + return new FlamingockChangeUnitView( + migration.id().value(), migration.checksum().value(), "", false); + } + + /** + * Whether Mongock compatibility mode may be enabled. + * + *

Always false. Mongock is not adopted for new projects, and its compatibility mode exists to + * read a legacy ledger — enabling it on a new deployment creates the legacy state it was meant to + * migrate away from. + */ + public boolean mongockCompatibilityEnabled() { + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/ApprovedMongoNativeOperation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/ApprovedMongoNativeOperation.java new file mode 100644 index 00000000..8ddb11c5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/ApprovedMongoNativeOperation.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +import com.mongodb.client.MongoDatabase; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Function; + +/** + * A pre-registered native operation the capability gateway may run (design §5, §16.2). + * + *

The design refuses {@code runCommand(Map)} and {@code executeUserBson(String)}. The gap that + * leaves is real — search, encryption setup probes and sharding introspection all need commands + * outside the versioned surface — and this type fills it without reopening it: the operation body + * is written in the platform, reviewed with its capability and category, and identified by a + * registered id. A caller selects an operation; it never supplies one. + * + * @param the operation's result type + */ +public record ApprovedMongoNativeOperation( + String operationId, + MongoCapability capability, + MongoNativeCommandCategory category, + String databaseProfile, + String collectionProfile, + Duration timeout, + int maxResults, + Function body) { + + public ApprovedMongoNativeOperation { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(capability, "capability"); + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(databaseProfile, "databaseProfile"); + Objects.requireNonNull(collectionProfile, "collectionProfile"); + Objects.requireNonNull(timeout, "timeout"); + if (operationId.isBlank()) { + throw new IllegalArgumentException("a native operation needs an id"); + } + if (timeout.isNegative()) { + throw new IllegalArgumentException("a native operation timeout must not be negative"); + } + } + + /** + * A named operation with no body. + * + *

Exists so the gateway's rejection path is reachable and testable: an id nobody registered + * cannot be executed, and this is what asking for one looks like. + */ + public static ApprovedMongoNativeOperation unregistered(String operationId) { + return new ApprovedMongoNativeOperation<>( + operationId, + MongoCapability.ADMIN_PLANE, + MongoNativeCommandCategory.ADMIN, + "unspecified", + "unspecified", + Duration.ZERO, + 0, + database -> null); + } + + /** True when this operation carries an executable body. */ + public boolean hasBody() { + return timeout.isPositive(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCapabilityGateway.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCapabilityGateway.java new file mode 100644 index 00000000..d39bcca0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCapabilityGateway.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +/** + * The only door to a native MongoDB operation (design §5). + * + *

There is no {@code MongoClient}, {@code MongoDatabase} or {@code MongoCollection} + * bean an application can inject. Everything outside the Stable API surface comes through here, in + * the shape of an operation somebody registered and reviewed. + */ +public interface MongoNativeCapabilityGateway { + + /** Runs one approved native operation. */ + T execute(ApprovedMongoNativeOperation operation); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCommandCategory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCommandCategory.java new file mode 100644 index 00000000..b83fc163 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeCommandCategory.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +/** + * What kind of command a native operation issues (design §5, §8). + * + *

The gateway checks the category before the operation runs, so a D4 command cannot reach the + * capability plane by being registered under a harmless-looking operation id. Category is declared + * at registration and reviewed there; the check is what makes the declaration binding. + */ +public enum MongoNativeCommandCategory { + + /** Reads only. */ + READ, + + /** Writes documents in a registered collection. */ + WRITE, + + /** Reads server or collection metadata without changing it. */ + METADATA_READ, + + /** Changes collections, indexes, validators, users, shards or topology. D4 only. */ + ADMIN; + + /** True when this category may execute through the D3 capability gateway. */ + public boolean allowedOnCapabilityPlane() { + return this != ADMIN; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeOperationPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeOperationPolicy.java new file mode 100644 index 00000000..5f759fa4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/MongoNativeOperationPolicy.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Which native operations exist, and what each is allowed to touch (design §5). + * + *

The checks run in the design's stated order — registration, capability, database, collection, + * category — so the most specific refusal wins and the message names the first thing that was wrong + * rather than the last. + */ +public final class MongoNativeOperationPolicy { + + private final Map capabilityByOperation; + + private final Set allowedDatabaseProfiles; + + private final Set allowedCollectionProfiles; + + private final MongoCapabilitySet capabilities; + + private MongoNativeOperationPolicy( + Map capabilityByOperation, + Set allowedDatabaseProfiles, + Set allowedCollectionProfiles, + MongoCapabilitySet capabilities) { + this.capabilityByOperation = capabilityByOperation; + this.allowedDatabaseProfiles = allowedDatabaseProfiles; + this.allowedCollectionProfiles = allowedCollectionProfiles; + this.capabilities = capabilities; + } + + /** Starts a policy declaration. */ + public static Builder builder(MongoCapabilitySet capabilities) { + return new Builder(capabilities); + } + + /** + * Checks one operation before it is dispatched. + * + * @throws MongoOperationRejectedException naming the first constraint the operation violated + */ + public void require(ApprovedMongoNativeOperation operation) { + Objects.requireNonNull(operation, "operation"); + MongoCapability registered = capabilityByOperation.get(operation.operationId()); + if (registered == null) { + throw MongoOperationRejectedException.of( + "native.operation", + "native operation '" + + operation.operationId() + + "' is not registered; the platform executes only pre-registered operation " + + "implementations and never a caller-supplied command"); + } + if (registered != operation.capability()) { + throw MongoOperationRejectedException.of( + "native.operation", + "native operation '" + + operation.operationId() + + "' is registered under capability " + + registered + + " but was submitted as " + + operation.capability()); + } + MongoCapabilitySupport support = capabilities.require(registered); + if (support.level() == MongoSupportLevel.UNSUPPORTED) { + throw MongoOperationRejectedException.of( + "native.operation", + "capability " + registered + " is unsupported here: " + support.constraints()); + } + if (!allowedDatabaseProfiles.contains(operation.databaseProfile())) { + throw MongoOperationRejectedException.of( + "native.operation", + "database profile '" + operation.databaseProfile() + "' is not on the native allowlist"); + } + if (!allowedCollectionProfiles.contains(operation.collectionProfile())) { + throw MongoOperationRejectedException.of( + "native.operation", + "collection profile '" + + operation.collectionProfile() + + "' is not on the native allowlist"); + } + if (!operation.category().allowedOnCapabilityPlane()) { + throw MongoOperationRejectedException.of( + "native.operation", + "operation '" + + operation.operationId() + + "' is an administrative command; drop, collMod, shard and user management run on " + + "the D4 admin plane with its own credential"); + } + } + + /** Collects native operation registrations. */ + public static final class Builder { + + private final Map capabilityByOperation = new LinkedHashMap<>(); + + private final Set allowedDatabaseProfiles = new LinkedHashSet<>(); + + private final Set allowedCollectionProfiles = new LinkedHashSet<>(); + + private final MongoCapabilitySet capabilities; + + private Builder(MongoCapabilitySet capabilities) { + this.capabilities = Objects.requireNonNull(capabilities, "capabilities"); + } + + /** Registers an operation id under the capability it belongs to. */ + public Builder register(String operationId, MongoCapability capability) { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(capability, "capability"); + if (capabilityByOperation.putIfAbsent(operationId, capability) != null) { + throw new IllegalArgumentException( + "duplicate native operation registration: " + operationId); + } + return this; + } + + /** Allows a database profile. */ + public Builder allowDatabase(String databaseProfile) { + allowedDatabaseProfiles.add(Objects.requireNonNull(databaseProfile, "databaseProfile")); + return this; + } + + /** Allows a collection profile. */ + public Builder allowCollection(String collectionProfile) { + allowedCollectionProfiles.add(Objects.requireNonNull(collectionProfile, "collectionProfile")); + return this; + } + + /** Builds the immutable policy. */ + public MongoNativeOperationPolicy build() { + return new MongoNativeOperationPolicy( + Map.copyOf(capabilityByOperation), + Set.copyOf(allowedDatabaseProfiles), + Set.copyOf(allowedCollectionProfiles), + capabilities); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGateway.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGateway.java new file mode 100644 index 00000000..a4b61e84 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGateway.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +import com.mongodb.client.MongoDatabase; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.Function; + +/** + * The D3 capability gateway (design §5). + * + *

Runs the design's stated sequence and stops at the first refusal: registration, capability, + * database profile, collection profile, timeout, category, then execution. The audit record carries + * the operation id and the outcome and never the BSON arguments — the arguments are the part that + * contains data, and an audit trail that leaks data is a liability rather than a control. + */ +public final class PolicyAwareMongoNativeGateway implements MongoNativeCapabilityGateway { + + private final MongoNativeOperationPolicy policy; + + private final Function databaseResolver; + + private final BiConsumer auditRecorder; + + public PolicyAwareMongoNativeGateway( + MongoNativeOperationPolicy policy, + Function databaseResolver, + BiConsumer auditRecorder) { + this.policy = Objects.requireNonNull(policy, "policy"); + this.databaseResolver = Objects.requireNonNull(databaseResolver, "databaseResolver"); + this.auditRecorder = Objects.requireNonNull(auditRecorder, "auditRecorder"); + } + + @Override + public T execute(ApprovedMongoNativeOperation operation) { + Objects.requireNonNull(operation, "operation"); + policy.require(operation); + boolean succeeded = false; + try { + T result = operation.body().apply(databaseResolver.apply(operation.databaseProfile())); + succeeded = true; + return result; + } finally { + auditRecorder.accept(operation.operationId(), succeeded); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MicrometerMongoOperationObserver.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MicrometerMongoOperationObserver.java new file mode 100644 index 00000000..cb66127e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MicrometerMongoOperationObserver.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; +import java.util.Objects; + +/** + * Records operation timing and outcomes through Micrometer (design §27). + * + *

Tags come from the operation context and the failure context, both of which are already + * constrained to bounded, data-free values. Nothing here reads a document or a query, so there is + * no redaction step to forget. + * + *

The ambiguous outcomes get their own {@code result} values rather than collapsing into + * "failure". An unknown commit is an operational event worth alerting on separately: it means + * something needs reconciling, not that something failed. + */ +public final class MicrometerMongoOperationObserver implements MongoOperationObserver { + + private static final String TIMER_NAME = "mongodb.operation"; + + private final MeterRegistry registry; + + private final String mongoProfile; + + public MicrometerMongoOperationObserver(MeterRegistry registry, String mongoProfile) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.mongoProfile = Objects.requireNonNull(mongoProfile, "mongoProfile"); + } + + @Override + public MongoOperationObservation start( + MongoOperationContext context, MongoOperationType operationType) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operationType, "operationType"); + return new MicrometerObservation( + registry, + Timer.start(registry), + Tags.of( + "mongoProfile", mongoProfile, + "databaseProfile", context.databaseProfile().value(), + "collectionProfile", context.collectionProfile().value(), + "operationName", context.operationName().value(), + "operationType", operationType.name(), + "consistencyProfile", context.consistency().name())); + } + + /** One timed operation. */ + private static final class MicrometerObservation implements MongoOperationObservation { + + private final MeterRegistry registry; + + private final Timer.Sample sample; + + private final Tags baseTags; + + private Tags outcomeTags; + + private boolean stopped; + + private MicrometerObservation(MeterRegistry registry, Timer.Sample sample, Tags baseTags) { + this.registry = registry; + this.sample = sample; + this.baseTags = baseTags; + this.outcomeTags = Tags.of("result", "unknown", "failureCategory", "none"); + } + + @Override + public void success(MongoExecutionOutcome outcome) { + outcomeTags = Tags.of("result", outcome.name(), "failureCategory", "none"); + } + + @Override + public void failure(MongoFailureContext failureContext) { + outcomeTags = + Tags.of( + "result", + failureContext.outcome().name(), + "failureCategory", + failureContext.category().name()); + } + + @Override + public String traceId() { + return ""; + } + + @Override + public void close() { + if (stopped) { + return; + } + stopped = true; + sample.stop(registry.timer(TIMER_NAME, baseTags.and(outcomeTags))); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoCommandObservationListener.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoCommandObservationListener.java new file mode 100644 index 00000000..d58c2b11 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoCommandObservationListener.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import com.mongodb.event.CommandFailedEvent; +import com.mongodb.event.CommandListener; +import com.mongodb.event.CommandStartedEvent; +import com.mongodb.event.CommandSucceededEvent; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Driver-level command timing (design §27). + * + *

Complements the platform's operation timer rather than duplicating it: this measures what the + * driver did, the operation timer measures what the caller experienced, and the difference between + * them is connection checkout and server selection — the part that explains a slow request when the + * server says it was fast. + * + *

The command name is the only tag taken from the event. Database and collection names are on + * the event too, and are exactly the dynamic values the tag allowlist excludes. + */ +public final class MongoCommandObservationListener implements CommandListener { + + private static final String METRIC_NAME = "mongodb.driver.command"; + + private final MeterRegistry registry; + + private final MongoObservationRedactor redactor; + + private final String mongoProfile; + + public MongoCommandObservationListener(MeterRegistry registry, String mongoProfile) { + this(registry, mongoProfile, new MongoObservationRedactor()); + } + + public MongoCommandObservationListener( + MeterRegistry registry, String mongoProfile, MongoObservationRedactor redactor) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.mongoProfile = Objects.requireNonNull(mongoProfile, "mongoProfile"); + this.redactor = Objects.requireNonNull(redactor, "redactor"); + } + + @Override + public void commandStarted(CommandStartedEvent event) { + // Timing is taken from the succeeded and failed events, which carry the elapsed duration. + } + + @Override + public void commandSucceeded(CommandSucceededEvent event) { + record(event.getCommandName(), "success", event.getElapsedTime(TimeUnit.NANOSECONDS)); + } + + @Override + public void commandFailed(CommandFailedEvent event) { + record(event.getCommandName(), "failure", event.getElapsedTime(TimeUnit.NANOSECONDS)); + } + + private void record(String commandName, String result, long elapsedNanos) { + registry + .timer( + METRIC_NAME, + Tags.of( + "mongoProfile", + mongoProfile, + "operationType", + redactor.isAlwaysRedacted(commandName) ? "" : commandName, + "result", + result)) + .record(elapsedNanos, TimeUnit.NANOSECONDS); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoDriverObservabilityConfiguration.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoDriverObservabilityConfiguration.java new file mode 100644 index 00000000..6833f38c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoDriverObservabilityConfiguration.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import com.mongodb.MongoClientSettings; +import io.micrometer.core.instrument.MeterRegistry; +import java.util.Objects; + +/** + * Installs the driver's own listeners onto a client (design §27). + * + *

Driver-native listeners rather than a Spring Data observability layer, because pool checkout, + * server selection and topology changes are not visible above the driver at all. An + * application-level timer can only report that the operation was slow. + */ +public final class MongoDriverObservabilityConfiguration { + + private final MeterRegistry registry; + + private final String mongoProfile; + + public MongoDriverObservabilityConfiguration(MeterRegistry registry, String mongoProfile) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.mongoProfile = Objects.requireNonNull(mongoProfile, "mongoProfile"); + } + + /** Adds the command, SDAM and pool listeners to a client settings builder. */ + public MongoClientSettings.Builder apply(MongoClientSettings.Builder builder) { + Objects.requireNonNull(builder, "builder"); + builder.addCommandListener(new MongoCommandObservationListener(registry, mongoProfile)); + builder.applyToClusterSettings( + cluster -> + cluster.addClusterListener(new MongoSdamObservationListener(registry, mongoProfile))); + builder.applyToConnectionPoolSettings( + pool -> + pool.addConnectionPoolListener( + new MongoPoolObservationListener(registry, mongoProfile))); + return builder; + } + + /** The tag convention these listeners obey. */ + public MongoObservationConvention convention() { + return MongoObservationConvention.standard(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConvention.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConvention.java new file mode 100644 index 00000000..95d47f6c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConvention.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import java.util.Objects; +import java.util.Set; + +/** + * The only tags MongoDB telemetry may carry (design §27). + * + *

Two problems, one allowlist. High-cardinality tags — a document id, a tenant id, a query + * parameter — multiply time series until the metrics backend either drops data or bills for it. And + * the same values are the ones that turn a metrics store into an unmanaged copy of production data, + * outside whatever retention and access rules the database is under. + * + *

Both are prevented the same way: a tag not on this list cannot be attached, so the mistake has + * to be made in this file rather than at a call site. + */ +public final class MongoObservationConvention { + + private static final Set ALLOWED_TAGS = + Set.of( + "mongoProfile", + "databaseProfile", + "collectionProfile", + "operationName", + "operationType", + "result", + "failureCategory", + "consistencyProfile"); + + private static final Set FORBIDDEN_TAGS = + Set.of( + "documentId", + "rawTenantId", + "tenantId", + "dynamicCollectionName", + "queryParameter", + "query", + "fullBson", + "resumeToken", + "shardKeyValue", + "plaintextPII", + "credential"); + + private MongoObservationConvention() {} + + /** The platform's standard convention. */ + public static MongoObservationConvention standard() { + return new MongoObservationConvention(); + } + + /** The tag names telemetry may use. */ + public Set allowedTagNames() { + return ALLOWED_TAGS; + } + + /** The tag names the design explicitly forbids, kept for assertions and review. */ + public Set forbiddenTagNames() { + return FORBIDDEN_TAGS; + } + + /** + * Rejects a tag that is not on the allowlist. + * + * @throws IllegalArgumentException naming the rejected tag + */ + public void requireAllowed(String tagName) { + Objects.requireNonNull(tagName, "tagName"); + if (!ALLOWED_TAGS.contains(tagName)) { + throw new IllegalArgumentException( + "tag '" + + tagName + + "' is not on the MongoDB observation allowlist; the allowed tags are " + + ALLOWED_TAGS); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationRedactor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationRedactor.java new file mode 100644 index 00000000..b31b95da --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationRedactor.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import java.util.Objects; +import java.util.Set; + +/** + * Decides which commands may be described at all (design §26, §27). + * + *

The driver redacts authentication commands. This redactor is stricter, because the platform's + * exposure is different: an application log is read by more people than a driver's debug output, + * and it is retained for longer. + * + *

Anything not explicitly safe is reduced to the command name. That direction matters — a + * denylist would have to anticipate the next command MongoDB adds that happens to carry a secret. + */ +public final class MongoObservationRedactor { + + /** Commands whose arguments are structural rather than data-bearing. */ + private static final Set DESCRIBABLE_COMMANDS = + Set.of("ping", "hello", "buildInfo", "listCollections", "listIndexes", "collStats"); + + /** Commands whose arguments always carry credentials or key material. */ + private static final Set ALWAYS_REDACTED = + Set.of( + "authenticate", + "saslStart", + "saslContinue", + "getnonce", + "createUser", + "updateUser", + "copydbgetnonce", + "copydbsaslstart", + "copydb"); + + /** The redacted rendering of a command. */ + public String describe(String commandName) { + Objects.requireNonNull(commandName, "commandName"); + if (ALWAYS_REDACTED.contains(commandName)) { + return ""; + } + return DESCRIBABLE_COMMANDS.contains(commandName) ? commandName : commandName + "(...)"; + } + + /** True when a command's arguments must never be described. */ + public boolean isAlwaysRedacted(String commandName) { + return ALWAYS_REDACTED.contains(Objects.requireNonNull(commandName, "commandName")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoPoolObservationListener.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoPoolObservationListener.java new file mode 100644 index 00000000..862d03f5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoPoolObservationListener.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import com.mongodb.event.ConnectionCheckOutFailedEvent; +import com.mongodb.event.ConnectionCheckOutStartedEvent; +import com.mongodb.event.ConnectionCheckedInEvent; +import com.mongodb.event.ConnectionCheckedOutEvent; +import com.mongodb.event.ConnectionPoolListener; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Connection pool telemetry (design §27). + * + *

Checkout wait is the metric that distinguishes "the database is slow" from "we ran out of + * connections". They present identically at the application edge — requests take longer — and have + * opposite fixes, so a pool without this instrumentation reliably produces the wrong remediation. + */ +public final class MongoPoolObservationListener implements ConnectionPoolListener { + + private static final String CHECKED_OUT_METRIC = "mongodb.driver.pool.checkedout"; + + private static final String WAIT_METRIC = "mongodb.driver.pool.checkout.waiting"; + + private static final String FAILURE_METRIC = "mongodb.driver.pool.checkout.failed"; + + private final AtomicInteger checkedOut = new AtomicInteger(); + + private final AtomicInteger waiting = new AtomicInteger(); + + private final MeterRegistry registry; + + private final String mongoProfile; + + public MongoPoolObservationListener(MeterRegistry registry, String mongoProfile) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.mongoProfile = Objects.requireNonNull(mongoProfile, "mongoProfile"); + Tags tags = Tags.of("mongoProfile", mongoProfile); + registry.gauge(CHECKED_OUT_METRIC, tags, checkedOut, AtomicInteger::doubleValue); + registry.gauge(WAIT_METRIC, tags, waiting, AtomicInteger::doubleValue); + } + + @Override + public void connectionCheckOutStarted(ConnectionCheckOutStartedEvent event) { + waiting.incrementAndGet(); + } + + @Override + public void connectionCheckedOut(ConnectionCheckedOutEvent event) { + waiting.decrementAndGet(); + checkedOut.incrementAndGet(); + } + + @Override + public void connectionCheckOutFailed(ConnectionCheckOutFailedEvent event) { + waiting.decrementAndGet(); + Counter.builder(FAILURE_METRIC) + .tags(Tags.of("mongoProfile", mongoProfile, "failureCategory", event.getReason().name())) + .register(registry) + .increment(); + } + + @Override + public void connectionCheckedIn(ConnectionCheckedInEvent event) { + checkedOut.decrementAndGet(); + } + + /** Connections currently checked out. */ + public int checkedOut() { + return checkedOut.get(); + } + + /** Threads currently waiting for a connection. */ + public int waiting() { + return waiting.get(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoSdamObservationListener.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoSdamObservationListener.java new file mode 100644 index 00000000..da98d619 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoSdamObservationListener.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import com.mongodb.event.ClusterClosedEvent; +import com.mongodb.event.ClusterDescriptionChangedEvent; +import com.mongodb.event.ClusterListener; +import com.mongodb.event.ClusterOpeningEvent; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import java.util.Objects; + +/** + * Topology and primary-change telemetry (design §27). + * + *

Primary changes are counted separately from failures because they are the fact that explains a + * cluster of otherwise unrelated symptoms: a burst of write-concern errors, a change stream resume + * and a transaction retry that all happened at the same second are one election, not three + * incidents. + */ +public final class MongoSdamObservationListener implements ClusterListener { + + private static final String PRIMARY_CHANGE_METRIC = "mongodb.driver.primary.changed"; + + private static final String TOPOLOGY_METRIC = "mongodb.driver.topology.changed"; + + private final MeterRegistry registry; + + private final String mongoProfile; + + private String currentPrimary = ""; + + public MongoSdamObservationListener(MeterRegistry registry, String mongoProfile) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.mongoProfile = Objects.requireNonNull(mongoProfile, "mongoProfile"); + } + + @Override + public void clusterOpening(ClusterOpeningEvent event) { + // The opening event carries no description yet; the first change event establishes the + // baseline. + } + + @Override + public void clusterDescriptionChanged(ClusterDescriptionChangedEvent event) { + Counter.builder(TOPOLOGY_METRIC) + .tags(Tags.of("mongoProfile", mongoProfile)) + .register(registry) + .increment(); + + String primary = + event.getNewDescription().getServerDescriptions().stream() + .filter(server -> server.isPrimary()) + .map(server -> server.getAddress().toString()) + .findFirst() + .orElse(""); + // The address is a deployment topology fact, not application data, but it is still unbounded + // enough to be a bad tag — so the change is counted and the address is not attached. + if (!primary.equals(currentPrimary)) { + currentPrimary = primary; + Counter.builder(PRIMARY_CHANGE_METRIC) + .tags(Tags.of("mongoProfile", mongoProfile)) + .register(registry) + .increment(); + } + } + + @Override + public void clusterClosed(ClusterClosedEvent event) { + currentPrimary = ""; + } + + /** The address of the primary this listener last observed, for health reporting. */ + public String currentPrimary() { + return currentPrimary; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoFieldDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoFieldDescriptor.java new file mode 100644 index 00000000..fa19ea3a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoFieldDescriptor.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import java.util.Objects; +import java.util.Set; + +/** + * One queryable field and what may be done to it (design §16.2). + * + *

Registering the path, not just the field name, is what closes the dotted-path hole: without it + * a caller can reach {@code customer.paymentMethod.token} through a field allowlist that only ever + * meant to expose {@code customer}. + */ +public record MongoFieldDescriptor( + String path, Set operators, boolean sortable, boolean projectable) { + + public MongoFieldDescriptor { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(operators, "operators"); + operators = Set.copyOf(operators); + if (path.isBlank()) { + throw new IllegalArgumentException("a field descriptor needs a path"); + } + if (path.startsWith("$")) { + throw new IllegalArgumentException( + "a field path must not start with an operator sigil: " + path); + } + } + + /** A field filterable with the default operator set, sortable and projectable. */ + public static MongoFieldDescriptor of(String path) { + return new MongoFieldDescriptor(path, MongoOperator.DEFAULT_SET, true, true); + } + + /** A field filterable with an explicit operator set. */ + public static MongoFieldDescriptor withOperators(String path, Set operators) { + return new MongoFieldDescriptor(path, operators, true, true); + } + + /** A field that may be filtered but never sorted on — typically because no index supports it. */ + public static MongoFieldDescriptor filterOnly(String path) { + return new MongoFieldDescriptor(path, MongoOperator.DEFAULT_SET, false, true); + } + + /** True when this field allows the given operator. */ + public boolean allows(MongoOperator operator) { + return operators.contains(Objects.requireNonNull(operator, "operator")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoOperator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoOperator.java new file mode 100644 index 00000000..37aad70d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoOperator.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import java.util.Set; + +/** + * The query operators a field may be filtered with (design §16.2). + * + *

A closed set, because "which operators may this field take" is a per-field decision with real + * consequences. A regex on an unindexed field is a collection scan; {@code $where} and {@code + * $expr} evaluate expressions server-side; {@code $ne} and {@code $nin} cannot use an index + * efficiently. None of those are visible in a query string, so they are decided in the policy + * instead. + */ +public enum MongoOperator { + + /** Equality. */ + EQ("$eq"), + + /** Inequality. Cannot use an index selectively. */ + NE("$ne"), + + /** Greater than. */ + GT("$gt"), + + /** Greater than or equal. */ + GTE("$gte"), + + /** Less than. */ + LT("$lt"), + + /** Less than or equal. */ + LTE("$lte"), + + /** Membership in a bounded list. */ + IN("$in"), + + /** Absence from a bounded list. Cannot use an index selectively. */ + NIN("$nin"), + + /** Field presence. */ + EXISTS("$exists"), + + /** Pattern match. Requires an explicit regex policy. */ + REGEX("$regex"), + + /** All elements present in an array. */ + ALL("$all"), + + /** Array element matching a compound predicate. */ + ELEM_MATCH("$elemMatch"), + + /** Array length. */ + SIZE("$size"); + + /** The operators a field allows unless its descriptor says otherwise. */ + public static final Set DEFAULT_SET = Set.of(EQ, GT, GTE, LT, LTE, IN, EXISTS); + + private final String bsonOperator; + + MongoOperator(String bsonOperator) { + this.bsonOperator = bsonOperator; + } + + /** The BSON operator name. */ + public String bsonOperator() { + return bsonOperator; + } + + /** True when this operator cannot use an index selectively and needs a deliberate decision. */ + public boolean scanProne() { + return this == NE || this == NIN || this == REGEX || this == SIZE; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoQueryPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoQueryPolicy.java new file mode 100644 index 00000000..0df5cb53 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoQueryPolicy.java @@ -0,0 +1,197 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * What one collection's dynamic queries are allowed to say (design §16.2). + * + *

The design forbids {@code executeUserBson(String)} and {@code runCommand(Map)}, and this class + * is what makes that refusal survivable: a dynamic query still has to be expressible, so it is + * expressed against registered field descriptors instead of parsed from caller-supplied BSON. + * + *

Everything is an allowlist. A denylist has to anticipate the next operator MongoDB adds; an + * allowlist does not. + */ +public final class MongoQueryPolicy { + + private final Map fields; + + private final Set hintAllowlist; + + private final String collationProfile; + + private final MongoRegexPolicy regexPolicy; + + private final int maxSkip; + + private MongoQueryPolicy( + Map fields, + Set hintAllowlist, + String collationProfile, + MongoRegexPolicy regexPolicy, + int maxSkip) { + this.fields = fields; + this.hintAllowlist = hintAllowlist; + this.collationProfile = collationProfile; + this.regexPolicy = regexPolicy; + this.maxSkip = maxSkip; + } + + /** Deep skip beyond this offset is rejected in favour of keyset pagination. */ + public static final int DEFAULT_MAX_SKIP = 1000; + + /** A policy allowing the named fields with the default operator set. */ + public static MongoQueryPolicy allowingFields(String... paths) { + return allowing(Arrays.stream(paths).map(MongoFieldDescriptor::of).toList()); + } + + /** A policy built from explicit field descriptors. */ + public static MongoQueryPolicy allowing(Collection descriptors) { + Objects.requireNonNull(descriptors, "descriptors"); + Map byPath = new LinkedHashMap<>(); + for (MongoFieldDescriptor descriptor : descriptors) { + if (byPath.putIfAbsent(descriptor.path(), descriptor) != null) { + throw new IllegalArgumentException( + "duplicate query field descriptor: " + descriptor.path()); + } + } + return new MongoQueryPolicy( + Map.copyOf(byPath), Set.of(), "", MongoRegexPolicy.standard(), DEFAULT_MAX_SKIP); + } + + /** Returns a copy that also allows the named index hints. */ + public MongoQueryPolicy withHints(String... indexNames) { + Set hints = new LinkedHashSet<>(hintAllowlist); + hints.addAll(Arrays.asList(indexNames)); + return new MongoQueryPolicy(fields, Set.copyOf(hints), collationProfile, regexPolicy, maxSkip); + } + + /** Returns a copy bound to a registered collation profile. */ + public MongoQueryPolicy withCollation(String profile) { + return new MongoQueryPolicy( + fields, hintAllowlist, Objects.requireNonNull(profile, "profile"), regexPolicy, maxSkip); + } + + /** Returns a copy with a different regex policy. */ + public MongoQueryPolicy withRegexPolicy(MongoRegexPolicy policy) { + return new MongoQueryPolicy( + fields, hintAllowlist, collationProfile, Objects.requireNonNull(policy, "policy"), maxSkip); + } + + /** Returns a copy with a different deep-skip threshold. */ + public MongoQueryPolicy withMaxSkip(int newMaxSkip) { + return new MongoQueryPolicy(fields, hintAllowlist, collationProfile, regexPolicy, newMaxSkip); + } + + /** + * Resolves a filterable field. + * + * @throws MongoOperationRejectedException when the path is not registered + */ + public MongoFieldDescriptor requireField(String path) { + MongoFieldDescriptor descriptor = fields.get(Objects.requireNonNull(path, "path")); + if (descriptor == null) { + throw MongoOperationRejectedException.of( + "query.field", "field path '" + path + "' is not registered for this collection"); + } + return descriptor; + } + + /** + * Resolves a field and checks the operator against it. + * + * @throws MongoOperationRejectedException when the field or the operator is not registered + */ + public MongoFieldDescriptor requireOperator(String path, MongoOperator operator) { + MongoFieldDescriptor descriptor = requireField(path); + if (!descriptor.allows(operator)) { + throw MongoOperationRejectedException.of( + "query.operator", + "operator " + operator + " is not registered for field path '" + path + "'"); + } + return descriptor; + } + + /** + * Resolves a sortable field. + * + * @throws MongoOperationRejectedException when the path is not registered or not sortable + */ + public MongoFieldDescriptor requireSortable(String path) { + MongoFieldDescriptor descriptor = requireField(path); + if (!descriptor.sortable()) { + throw MongoOperationRejectedException.of( + "query.sort", + "field path '" + + path + + "' is registered but not sortable; an unindexed sort is performed " + + "in memory and fails once the sort buffer is exceeded"); + } + return descriptor; + } + + /** + * Resolves a projectable field. + * + * @throws MongoOperationRejectedException when the path is not registered or not projectable + */ + public MongoFieldDescriptor requireProjectable(String path) { + MongoFieldDescriptor descriptor = requireField(path); + if (!descriptor.projectable()) { + throw MongoOperationRejectedException.of( + "query.projection", "field path '" + path + "' is not projectable"); + } + return descriptor; + } + + /** + * Checks an index hint. + * + * @throws MongoOperationRejectedException when the index is not on the hint allowlist + */ + public void requireHint(String indexName) { + if (!hintAllowlist.contains(Objects.requireNonNull(indexName, "indexName"))) { + throw MongoOperationRejectedException.of( + "query.hint", "index '" + indexName + "' is not on this collection's hint allowlist"); + } + } + + /** + * Checks a skip offset. + * + * @throws MongoOperationRejectedException when the offset is past the deep-skip threshold + */ + public void requireSkipWithinThreshold(long skip) { + if (skip > maxSkip) { + throw MongoOperationRejectedException.of( + "query.skip", + "a skip of " + + skip + + " is past the threshold of " + + maxSkip + + "; the server walks every skipped document, so use keyset pagination instead"); + } + } + + /** The regex bounds for this collection. */ + public MongoRegexPolicy regexPolicy() { + return regexPolicy; + } + + /** The registered collation profile, or an empty string when the collection uses none. */ + public String collationProfile() { + return collationProfile; + } + + /** Every registered field, keyed by path. */ + public Map fields() { + return fields; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoRegexPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoRegexPolicy.java new file mode 100644 index 00000000..f3cfa153 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoRegexPolicy.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; +import java.util.Set; + +/** + * The bounds a user-influenced regular expression must respect (design §16.2, §26). + * + *

Two separate risks. A regex that is not anchored at the start cannot use an index, so it scans + * the collection; and a regex with nested quantifiers can take exponential time to evaluate, which + * turns a search box into a denial of service. Length and character-class limits address the first, + * and refusing nested quantifiers addresses the second. + */ +public record MongoRegexPolicy( + int maxLength, Set allowedFlags, boolean requireAnchored) { + + /** The platform default: short, anchored, case-insensitivity as the only flag. */ + public static final int DEFAULT_MAX_LENGTH = 64; + + public MongoRegexPolicy { + Objects.requireNonNull(allowedFlags, "allowedFlags"); + allowedFlags = Set.copyOf(allowedFlags); + if (maxLength <= 0) { + throw new IllegalArgumentException("a regex policy needs a positive maximum length"); + } + } + + /** The platform default policy. */ + public static MongoRegexPolicy standard() { + return new MongoRegexPolicy(DEFAULT_MAX_LENGTH, Set.of('i'), true); + } + + /** A policy that forbids regular expressions entirely. */ + public static MongoRegexPolicy forbidden() { + return new MongoRegexPolicy(1, Set.of(), true); + } + + /** + * Validates one pattern before it reaches the server. + * + * @throws MongoOperationRejectedException naming the bound the pattern violated + */ + public void validate(String pattern, String flags) { + Objects.requireNonNull(pattern, "pattern"); + Objects.requireNonNull(flags, "flags"); + if (pattern.length() > maxLength) { + throw MongoOperationRejectedException.of( + "query.regex", + "the pattern is " + pattern.length() + " characters, above the limit of " + maxLength); + } + for (int index = 0; index < flags.length(); index++) { + char flag = flags.charAt(index); + if (!allowedFlags.contains(flag)) { + throw MongoOperationRejectedException.of( + "query.regex", "regex flag '" + flag + "' is not allowed by this collection's policy"); + } + } + if (requireAnchored && !pattern.startsWith("^")) { + throw MongoOperationRejectedException.of( + "query.regex", + "the pattern is not anchored at the start, so it cannot use an index and will scan the " + + "collection"); + } + if (hasNestedQuantifier(pattern)) { + throw MongoOperationRejectedException.of( + "query.regex", + "the pattern nests quantifiers, which can take exponential time to evaluate"); + } + } + + /** + * Detects a quantifier applied to a group that already contains one. + * + *

Deliberately conservative and syntactic. A precise catastrophic-backtracking analysis is a + * research problem; refusing the shape that causes almost all of it in practice is a bound that + * can actually be relied on. + */ + private static boolean hasNestedQuantifier(String pattern) { + int depth = 0; + boolean quantifierInsideGroup = false; + for (int index = 0; index < pattern.length(); index++) { + char character = pattern.charAt(index); + if (character == '\\') { + index++; + continue; + } + if (character == '(') { + depth++; + quantifierInsideGroup = false; + } else if (character == ')') { + depth--; + boolean quantified = + index + 1 < pattern.length() && isQuantifier(pattern.charAt(index + 1)); + if (quantified && quantifierInsideGroup) { + return true; + } + quantifierInsideGroup = false; + } else if (depth > 0 && isQuantifier(character)) { + quantifierInsideGroup = true; + } + } + return false; + } + + private static boolean isQuantifier(char character) { + return character == '*' || character == '+' || character == '{'; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoSortDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoSortDescriptor.java new file mode 100644 index 00000000..aa6db890 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/MongoSortDescriptor.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import java.util.Objects; + +/** + * One registered sort key (design §16.2). + * + *

Sort fields are allowlisted separately from filter fields because an unindexed sort is a + * different failure: MongoDB performs it in memory and aborts the query once the sort buffer is + * exceeded, so it fails under exactly the conditions — a large result set — where it matters most. + */ +public record MongoSortDescriptor(String field, boolean ascending) { + + public MongoSortDescriptor { + Objects.requireNonNull(field, "field"); + if (field.isBlank()) { + throw new IllegalArgumentException("a sort descriptor needs a field"); + } + } + + /** Ascending order. */ + public static MongoSortDescriptor asc(String field) { + return new MongoSortDescriptor(field, true); + } + + /** Descending order. */ + public static MongoSortDescriptor desc(String field) { + return new MongoSortDescriptor(field, false); + } + + /** The reverse of this sort key. */ + public MongoSortDescriptor reversed() { + return new MongoSortDescriptor(field, !ascending); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilder.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilder.java new file mode 100644 index 00000000..03d5a88b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilder.java @@ -0,0 +1,183 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Builds a Spring Data {@link Query} that can only say what the policy permits (design §16.2). + * + *

The builder never parses caller-supplied BSON or JSON. Every predicate names a registered + * field path and a registered operator, so the set of expressible queries is exactly the set that + * was reviewed — which is what makes NoSQL operator injection a compile-time impossibility rather + * than a validation problem. + * + *

{@link #build(MongoOperationBudget)} is the only way to finish, because a query without a + * result limit and a {@code maxTimeMS} is a query with no upper bound on what it can consume. + */ +public final class PolicyAwareMongoQueryBuilder { + + private final MongoQueryPolicy policy; + + private final List criteria = new ArrayList<>(); + + private final List sort = new ArrayList<>(); + + private final List projection = new ArrayList<>(); + + private String hint = ""; + + private long skip; + + public PolicyAwareMongoQueryBuilder(MongoQueryPolicy policy) { + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** Adds an equality predicate. */ + public PolicyAwareMongoQueryBuilder whereEquals(String path, Object value) { + policy.requireOperator(path, MongoOperator.EQ); + criteria.add(Criteria.where(path).is(value)); + return this; + } + + /** Adds a greater-than-or-equal predicate. */ + public PolicyAwareMongoQueryBuilder whereAtLeast(String path, Object value) { + policy.requireOperator(path, MongoOperator.GTE); + criteria.add(Criteria.where(path).gte(value)); + return this; + } + + /** Adds a less-than predicate. */ + public PolicyAwareMongoQueryBuilder whereBefore(String path, Object value) { + policy.requireOperator(path, MongoOperator.LT); + criteria.add(Criteria.where(path).lt(value)); + return this; + } + + /** Adds a bounded membership predicate. */ + public PolicyAwareMongoQueryBuilder whereIn(String path, Collection values) { + policy.requireOperator(path, MongoOperator.IN); + Objects.requireNonNull(values, "values"); + if (values.isEmpty()) { + throw MongoOperationRejectedException.of( + "query.in", "an $in predicate on '" + path + "' needs at least one value"); + } + criteria.add(Criteria.where(path).in(values)); + return this; + } + + /** Adds a field-presence predicate. */ + public PolicyAwareMongoQueryBuilder whereExists(String path, boolean exists) { + policy.requireOperator(path, MongoOperator.EXISTS); + criteria.add(Criteria.where(path).exists(exists)); + return this; + } + + /** + * Adds a pattern predicate, after the collection's regex policy has accepted the pattern. + * + *

The pattern is checked before it reaches the driver, so an unanchored or exponentially + * backtracking expression never becomes the server's problem. + */ + public PolicyAwareMongoQueryBuilder whereMatches(String path, String pattern, String flags) { + policy.requireOperator(path, MongoOperator.REGEX); + policy.regexPolicy().validate(pattern, flags); + criteria.add(Criteria.where(path).regex(pattern, flags)); + return this; + } + + /** + * Adds a sort key. + * + * @throws MongoOperationRejectedException when the field is not a registered sort field + */ + public PolicyAwareMongoQueryBuilder sortBy(String path) { + return sortBy(MongoSortDescriptor.asc(path)); + } + + /** + * Adds a sort key. + * + * @throws MongoOperationRejectedException when the field is not a registered sort field + */ + public PolicyAwareMongoQueryBuilder sortBy(MongoSortDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); + policy.requireSortable(descriptor.field()); + sort.add(descriptor); + return this; + } + + /** Restricts the returned fields to registered, projectable paths. */ + public PolicyAwareMongoQueryBuilder project(String... paths) { + for (String path : paths) { + policy.requireProjectable(path); + projection.add(path); + } + return this; + } + + /** Pins the query to an allowlisted index. */ + public PolicyAwareMongoQueryBuilder useIndex(String indexName) { + policy.requireHint(indexName); + this.hint = indexName; + return this; + } + + /** Skips a bounded number of documents. Deep skip is rejected in favour of keyset pagination. */ + public PolicyAwareMongoQueryBuilder skip(long documents) { + if (documents < 0) { + throw MongoOperationRejectedException.of("query.skip", "skip must not be negative"); + } + policy.requireSkipWithinThreshold(documents); + this.skip = documents; + return this; + } + + /** + * Builds the query with its resource bounds applied. + * + * @param budget the effective budget, already resolved against the registered one + */ + public Query build(MongoOperationBudget budget) { + Objects.requireNonNull(budget, "budget"); + Query query = + criteria.isEmpty() ? new Query() : new Query(new Criteria().andOperator(criteria)); + if (!sort.isEmpty()) { + query.with(toSort()); + } + for (String path : projection) { + query.fields().include(path); + } + if (!hint.isEmpty()) { + query.withHint(hint); + } + if (skip > 0) { + query.skip(skip); + } + if (!policy.collationProfile().isEmpty()) { + query.collation( + org.springframework.data.mongodb.core.query.Collation.of(policy.collationProfile())); + } + query.limit(budget.maxResults()); + query.maxTimeMsec(budget.maxTimeMillis()); + query.cursorBatchSize(budget.cursorBatchSize()); + return query; + } + + private Sort toSort() { + List orders = new ArrayList<>(); + for (MongoSortDescriptor descriptor : sort) { + orders.add( + descriptor.ascending() + ? Sort.Order.asc(descriptor.field()) + : Sort.Order.desc(descriptor.field())); + } + return Sort.by(orders); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcer.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcer.java new file mode 100644 index 00000000..52caac4c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcer.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.mongo.query.budget; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; + +/** + * Lets a caller tighten a registered budget, never loosen it (design §17). + * + *

The asymmetry is the point. A caller that knows it only needs ten results should be able to + * say so; a caller that wants a thousand is asking to change a capacity decision, and capacity + * decisions belong in the registry where they were reviewed, not in the call that happens to want + * more. + * + *

A budget rejection is local and non-retryable: nothing was sent, and repeating the request + * would only repeat the refusal. + */ +public final class MongoBudgetEnforcer { + + /** + * Resolves the effective budget for one call. + * + * @throws MongoOperationRejectedException when the request exceeds the registered budget + */ + public MongoOperationBudget resolve( + MongoOperationBudget registered, MongoOperationBudget requested) { + Objects.requireNonNull(registered, "registered"); + Objects.requireNonNull(requested, "requested"); + if (!registered.covers(requested)) { + throw MongoOperationRejectedException.of( + "query.budget", + "the requested budget exceeds the registered one (requested maxResults=" + + requested.maxResults() + + ", maxResultBytes=" + + requested.maxResultBytes() + + ", maxTimeMillis=" + + requested.maxTimeMillis() + + ", cursorBatchSize=" + + requested.cursorBatchSize() + + "; registered maxResults=" + + registered.maxResults() + + ", maxResultBytes=" + + registered.maxResultBytes() + + ", maxTimeMillis=" + + registered.maxTimeMillis() + + ", cursorBatchSize=" + + registered.cursorBatchSize() + + ")"); + } + return requested; + } + + /** Resolves the effective budget, silently tightening instead of failing. */ + public MongoOperationBudget narrow( + MongoOperationBudget registered, MongoOperationBudget requested) { + Objects.requireNonNull(registered, "registered"); + Objects.requireNonNull(requested, "requested"); + return registered.narrowedTo(requested); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetPolicyRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetPolicyRegistry.java new file mode 100644 index 00000000..22727670 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetPolicyRegistry.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.mongo.query.budget; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * The registered budget for each named operation (design §17). + * + *

An unregistered operation is refused rather than defaulted. A default would mean every new + * query silently inherits limits nobody chose for it, and the first time anyone examines them is + * during the incident where they turned out to be wrong. + */ +public final class MongoBudgetPolicyRegistry { + + private final Map budgets; + + private MongoBudgetPolicyRegistry(Map budgets) { + this.budgets = budgets; + } + + /** Starts a registry declaration. */ + public static Builder builder() { + return new Builder(); + } + + /** + * The budget registered for an operation. + * + * @throws MongoOperationRejectedException when the operation has no registered budget + */ + public MongoOperationBudget require(MongoOperationName operationName) { + Objects.requireNonNull(operationName, "operationName"); + MongoOperationBudget budget = budgets.get(operationName); + if (budget == null) { + throw MongoOperationRejectedException.of( + "query.budget", + "operation '" + + operationName + + "' has no registered resource budget; every dynamic query and aggregation must " + + "resolve one"); + } + return budget; + } + + /** True when the operation has a registered budget. */ + public boolean isRegistered(MongoOperationName operationName) { + return budgets.containsKey(Objects.requireNonNull(operationName, "operationName")); + } + + /** Collects budget registrations. */ + public static final class Builder { + + private final Map budgets = new LinkedHashMap<>(); + + private Builder() {} + + /** Registers a budget for an operation. */ + public Builder register(String operationName, MongoOperationBudget budget) { + return register(new MongoOperationName(operationName), budget); + } + + /** Registers a budget for an operation. */ + public Builder register(MongoOperationName operationName, MongoOperationBudget budget) { + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(budget, "budget"); + if (budgets.putIfAbsent(operationName, budget) != null) { + throw new IllegalArgumentException("duplicate budget registration for " + operationName); + } + return this; + } + + /** Builds the immutable registry. */ + public MongoBudgetPolicyRegistry build() { + return new MongoBudgetPolicyRegistry(Map.copyOf(budgets)); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoOperationBudget.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoOperationBudget.java new file mode 100644 index 00000000..874b21ad --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoOperationBudget.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.mongo.query.budget; + +/** + * The resource ceiling one named operation may consume (design §17). + * + *

Four bounds because a query can exhaust four different resources independently: too many + * documents fills the heap, documents that are individually large fill it faster, a long-running + * query holds a connection and a snapshot, and a large cursor batch buys throughput with memory. A + * single "limit" would leave three of them uncapped. + */ +public record MongoOperationBudget( + int maxResults, long maxResultBytes, long maxTimeMillis, int cursorBatchSize) { + + public MongoOperationBudget { + if (maxResults <= 0) { + throw new IllegalArgumentException("maxResults must be positive"); + } + if (maxResultBytes <= 0) { + throw new IllegalArgumentException("maxResultBytes must be positive"); + } + if (maxTimeMillis <= 0) { + throw new IllegalArgumentException("maxTimeMillis must be positive"); + } + if (cursorBatchSize <= 0) { + throw new IllegalArgumentException("cursorBatchSize must be positive"); + } + } + + /** A conservative default for a typical bounded list read. */ + public static MongoOperationBudget standard() { + return new MongoOperationBudget(100, 1_048_576L, 500L, 50); + } + + /** True when every bound of {@code other} is at most this budget's. */ + public boolean covers(MongoOperationBudget other) { + return other.maxResults <= maxResults + && other.maxResultBytes <= maxResultBytes + && other.maxTimeMillis <= maxTimeMillis + && other.cursorBatchSize <= cursorBatchSize; + } + + /** The element-wise minimum of two budgets. */ + public MongoOperationBudget narrowedTo(MongoOperationBudget other) { + return new MongoOperationBudget( + Math.min(maxResults, other.maxResults), + Math.min(maxResultBytes, other.maxResultBytes), + Math.min(maxTimeMillis, other.maxTimeMillis), + Math.min(cursorBatchSize, other.cursorBatchSize)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursor.java new file mode 100644 index 00000000..8e8d0f24 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursor.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * The opaque position a keyset page resumes from (design §19.1). + * + *

A cursor is a client-held query predicate, which makes it attacker-controlled input: without + * the authentication tag a caller could edit the values and read rows the query was never meant to + * return. The tag is verified before the values are used. + * + *

{@code toString} deliberately omits both the values and the tag. The values are business data + * — the timestamps and identifiers of real rows — and printing them into a request log is exactly + * the leak the design's telemetry rules forbid. + */ +public record MongoKeysetCursor( + int sortVersion, Map values, String authenticationTag) { + + public MongoKeysetCursor { + Objects.requireNonNull(values, "values"); + Objects.requireNonNull(authenticationTag, "authenticationTag"); + values = Map.copyOf(values); + if (sortVersion < 1) { + throw new IllegalArgumentException("a cursor's sort version starts at 1"); + } + if (values.isEmpty()) { + throw new IllegalArgumentException("a keyset cursor needs at least one sort value"); + } + } + + /** Builds an unsigned cursor; the codec attaches the tag when it encodes. */ + public static MongoKeysetCursor unsigned(int sortVersion, Map values) { + return new MongoKeysetCursor(sortVersion, values, ""); + } + + /** Returns a copy carrying the given authentication tag. */ + public MongoKeysetCursor withAuthenticationTag(String tag) { + return new MongoKeysetCursor(sortVersion, values, Objects.requireNonNull(tag, "tag")); + } + + /** The sort values in the order the sort declares them. */ + public Map orderedValues(MongoKeysetSort sort) { + Objects.requireNonNull(sort, "sort"); + Map ordered = new LinkedHashMap<>(); + for (String field : sort.fields()) { + if (!values.containsKey(field)) { + throw new IllegalArgumentException("the cursor carries no value for sort field: " + field); + } + ordered.put(field, values.get(field)); + } + return ordered; + } + + @Override + public String toString() { + return "MongoKeysetCursor[sortVersion=" + sortVersion + ", fields=" + values.keySet() + "]"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursorCodec.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursorCodec.java new file mode 100644 index 00000000..2d8a57b2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetCursorCodec.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Encodes and verifies keyset cursors (design §19.1). + * + *

A cursor travels to the client and comes back as a query predicate, so it is + * attacker-controlled input in the ordinary case. Without authentication a caller can edit the + * boundary values and read from anywhere in the collection the sort reaches — which, on a + * tenant-scoped or permission-scoped listing, is a data leak rather than a paging bug. + * + *

Verification uses a constant-time comparison so the tag cannot be recovered by timing repeated + * requests. + */ +public final class MongoKeysetCursorCodec { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + /** + * ASCII unit separator. A control character cannot appear in a field name or a rendered value. + */ + private static final char FIELD_SEPARATOR = ''; + + private static final char PAYLOAD_SEPARATOR = '.'; + + /** HMAC-SHA256 needs a key at least as long as its output to offer its full strength. */ + private static final int MINIMUM_KEY_BYTES = 32; + + private final SecretKeySpec key; + + public MongoKeysetCursorCodec(byte[] secret) { + Objects.requireNonNull(secret, "secret"); + if (secret.length < MINIMUM_KEY_BYTES) { + throw new IllegalArgumentException( + "a cursor signing key needs at least " + MINIMUM_KEY_BYTES + " bytes"); + } + this.key = new SecretKeySpec(secret.clone(), HMAC_ALGORITHM); + } + + /** Encodes a cursor as an opaque, authenticated token. */ + public String encode(MongoKeysetCursor cursor) { + Objects.requireNonNull(cursor, "cursor"); + String payload = payloadOf(cursor); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)) + + PAYLOAD_SEPARATOR + + sign(payload); + } + + /** + * Decodes and verifies a token. + * + * @throws MongoOperationRejectedException when the token is malformed or its tag does not verify + */ + public MongoKeysetCursor decode(String token) { + Objects.requireNonNull(token, "token"); + int separator = token.lastIndexOf(PAYLOAD_SEPARATOR); + if (separator <= 0 || separator == token.length() - 1) { + throw malformed(); + } + String encodedPayload = token.substring(0, separator); + String tag = token.substring(separator + 1); + String payload; + try { + payload = new String(Base64.getUrlDecoder().decode(encodedPayload), StandardCharsets.UTF_8); + } catch (IllegalArgumentException malformedEncoding) { + throw malformed(); + } + if (!MessageDigest.isEqual( + sign(payload).getBytes(StandardCharsets.UTF_8), tag.getBytes(StandardCharsets.UTF_8))) { + throw MongoOperationRejectedException.of( + "query.cursor", "the page cursor failed authentication and was rejected"); + } + return parse(payload, tag); + } + + private String payloadOf(MongoKeysetCursor cursor) { + StringBuilder payload = new StringBuilder().append(cursor.sortVersion()); + cursor + .values() + .forEach( + (field, value) -> + payload + .append(FIELD_SEPARATOR) + .append(field) + .append(FIELD_SEPARATOR) + .append(value)); + return payload.toString(); + } + + private static MongoKeysetCursor parse(String payload, String tag) { + String[] parts = payload.split(String.valueOf(FIELD_SEPARATOR), -1); + if (parts.length < 3 || (parts.length - 1) % 2 != 0) { + throw malformed(); + } + int sortVersion; + try { + sortVersion = Integer.parseInt(parts[0]); + } catch (NumberFormatException notANumber) { + throw malformed(); + } + Map values = new LinkedHashMap<>(); + for (int index = 1; index < parts.length; index += 2) { + values.put(parts[index], parts[index + 1]); + } + return new MongoKeysetCursor(sortVersion, values, tag); + } + + private String sign(String payload) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(key); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException unavailable) { + throw new IllegalStateException("HmacSHA256 is required to sign keyset cursors"); + } + } + + /** + * One message for every malformed shape. + * + *

A token that fails to decode and a token whose tag is wrong get the same answer, so the + * error cannot be used to learn anything about the signing key or the payload format. + */ + private static MongoOperationRejectedException malformed() { + return MongoOperationRejectedException.of("query.cursor", "the page cursor is malformed"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetPageRequest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetPageRequest.java new file mode 100644 index 00000000..a92e7713 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetPageRequest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import java.util.Objects; +import java.util.Optional; + +/** + * One keyset page request (design §19.1). + * + *

The first page carries no cursor, which is the only difference between it and any other page. + * There is deliberately no page number: an offset is what forces the server to walk every + * skipped document, and keyset pagination exists precisely to avoid that. + */ +public record MongoKeysetPageRequest( + MongoKeysetSort sort, + MongoKeysetCursor cursor, + int pageSize, + MongoNullSortOrdering nullOrdering) { + + public MongoKeysetPageRequest { + Objects.requireNonNull(sort, "sort"); + Objects.requireNonNull(nullOrdering, "nullOrdering"); + if (pageSize <= 0) { + throw new IllegalArgumentException("page size must be positive"); + } + if (cursor != null && cursor.sortVersion() != sort.sortVersion()) { + throw new IllegalArgumentException( + "the cursor was issued for sort version " + + cursor.sortVersion() + + " but this request sorts by version " + + sort.sortVersion()); + } + } + + /** The first page of a keyset listing. */ + public static MongoKeysetPageRequest first(MongoKeysetSort sort, int pageSize) { + return new MongoKeysetPageRequest(sort, null, pageSize, MongoNullSortOrdering.NOT_APPLICABLE); + } + + /** The page following the given cursor. */ + public static MongoKeysetPageRequest after( + MongoKeysetSort sort, MongoKeysetCursor cursor, int pageSize) { + return new MongoKeysetPageRequest( + sort, + Objects.requireNonNull(cursor, "cursor"), + pageSize, + MongoNullSortOrdering.NOT_APPLICABLE); + } + + /** The cursor this page resumes from, when it is not the first page. */ + public Optional resumeFrom() { + return Optional.ofNullable(cursor); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilder.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilder.java new file mode 100644 index 00000000..495a87b1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilder.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.query.MongoQueryPolicy; +import dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +/** + * Turns a keyset page request into the predicate that resumes exactly where the last page ended + * (design §19.1). + * + *

The predicate is the lexicographic "strictly after" comparison written out longhand: + * + *

{@code (a < A) OR (a = A AND b < B) OR (a = A AND b = B AND _id < I)}
+ * + *

Writing it as {@code a <= A AND _id < I} is the common shortcut and it is wrong — it drops + * every row whose {@code a} is smaller but whose {@code _id} is larger. The mistake only shows up + * as missing rows in the middle of a listing, which is why it survives so long in production. + */ +public final class MongoKeysetQueryBuilder { + + private MongoKeysetQueryBuilder() {} + + /** + * Rejects a keyset sort that is not a total order. + * + * @throws MongoOperationRejectedException when the final sort key is not unique + */ + public static MongoKeysetSort validate(MongoKeysetSort sort) { + Objects.requireNonNull(sort, "sort"); + if (!sort.hasUniqueTieBreaker()) { + throw MongoOperationRejectedException.of( + "query.keyset", + "keyset sort " + + sort.fields() + + " has no unique final tie-breaker; ties make the page boundary ambiguous, so rows " + + "are silently skipped or repeated as the caller pages through"); + } + return sort; + } + + /** Checks every sort field against the collection's sort allowlist as well. */ + public static MongoKeysetSort validate(MongoKeysetSort sort, MongoQueryPolicy policy) { + Objects.requireNonNull(policy, "policy"); + validate(sort); + sort.fields().forEach(policy::requireSortable); + return sort; + } + + /** + * Builds the "strictly after the cursor" predicate. + * + * @throws MongoOperationRejectedException when the sort is not a total order + */ + public static Criteria resumeCriteria(MongoKeysetSort sort, MongoKeysetCursor cursor) { + validate(sort); + Objects.requireNonNull(cursor, "cursor"); + Map values = cursor.orderedValues(sort); + List keys = sort.keys(); + + List alternatives = new ArrayList<>(); + for (int boundary = 0; boundary < keys.size(); boundary++) { + List conjunction = new ArrayList<>(); + for (int prefix = 0; prefix < boundary; prefix++) { + MongoSortDescriptor equalKey = keys.get(prefix); + conjunction.add(Criteria.where(equalKey.field()).is(values.get(equalKey.field()))); + } + MongoSortDescriptor strictKey = keys.get(boundary); + Object strictValue = values.get(strictKey.field()); + conjunction.add( + strictKey.ascending() + ? Criteria.where(strictKey.field()).gt(strictValue) + : Criteria.where(strictKey.field()).lt(strictValue)); + alternatives.add(new Criteria().andOperator(conjunction)); + } + return new Criteria().orOperator(alternatives); + } + + /** + * Builds the query for one keyset page. + * + *

Fetches one extra document so {@code hasNext} is answered by the same round trip rather than + * by a second count query. + */ + public static Query pageQuery(MongoKeysetPageRequest request) { + Objects.requireNonNull(request, "request"); + validate(request.sort()); + Query query = + request + .resumeFrom() + .map(cursor -> new Query(resumeCriteria(request.sort(), cursor))) + .orElseGet(Query::new); + query.with(toSort(request.sort())); + query.limit(request.pageSize() + 1); + return query; + } + + /** The Spring Data sort for a keyset definition. */ + public static Sort toSort(MongoKeysetSort sort) { + validate(sort); + List orders = new ArrayList<>(); + for (MongoSortDescriptor descriptor : sort.keys()) { + orders.add( + descriptor.ascending() + ? Sort.Order.asc(descriptor.field()) + : Sort.Order.desc(descriptor.field())); + } + return Sort.by(orders); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSlice.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSlice.java new file mode 100644 index 00000000..c8ed45c5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSlice.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * One page of a keyset listing (design §19.1). + * + *

A slice rather than a page: there is no total count, because counting matching documents is a + * second full query over the same predicate, and on a large collection it costs more than the page + * itself. A caller that needs an exact total is asking for a different, deliberately expensive + * operation. + * + * @param the element type + */ +public record MongoKeysetSlice(List items, MongoKeysetCursor nextCursor, boolean hasNext) { + + public MongoKeysetSlice { + Objects.requireNonNull(items, "items"); + items = List.copyOf(items); + if (hasNext && nextCursor == null) { + throw new IllegalArgumentException("a slice with a further page must carry its next cursor"); + } + } + + /** The final page of a listing. */ + public static MongoKeysetSlice last(List items) { + return new MongoKeysetSlice<>(items, null, false); + } + + /** A page with a further page after it. */ + public static MongoKeysetSlice of(List items, MongoKeysetCursor nextCursor) { + return new MongoKeysetSlice<>(items, Objects.requireNonNull(nextCursor, "nextCursor"), true); + } + + /** The cursor for the next page, when there is one. */ + public Optional next() { + return Optional.ofNullable(nextCursor); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSort.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSort.java new file mode 100644 index 00000000..c5e4dcc1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetSort.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import dev.caskeleton.adapter.outbound.mongo.query.MongoSortDescriptor; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * An ordered keyset sort definition (design §19.1). + * + *

Keyset pagination works by asking for "everything after this row", which only has a single + * answer if the sort is a total order. Sorting by {@code createdAt} alone is not: two documents + * written in the same millisecond can come back in either order, so one of them is silently skipped + * and the other silently repeated as the reader pages through. + * + *

{@link #sortVersion()} exists because changing the sort invalidates every cursor already + * issued. Stamping the version into the cursor turns a stale cursor into a clean rejection rather + * than a page that reads from the wrong position. + */ +public record MongoKeysetSort(List keys, int sortVersion) { + + /** The field that makes a keyset sort a total order in the common case. */ + public static final String DEFAULT_TIE_BREAKER = "_id"; + + public MongoKeysetSort { + Objects.requireNonNull(keys, "keys"); + keys = List.copyOf(keys); + if (keys.isEmpty()) { + throw new IllegalArgumentException("a keyset sort needs at least one key"); + } + if (sortVersion < 1) { + throw new IllegalArgumentException("a keyset sort version starts at 1"); + } + } + + /** A single descending key. Not yet a total order; add a tie-breaker before use. */ + public static MongoKeysetSort desc(String field) { + return new MongoKeysetSort(List.of(MongoSortDescriptor.desc(field)), 1); + } + + /** A single ascending key. Not yet a total order; add a tie-breaker before use. */ + public static MongoKeysetSort asc(String field) { + return new MongoKeysetSort(List.of(MongoSortDescriptor.asc(field)), 1); + } + + /** Appends a key. */ + public MongoKeysetSort then(MongoSortDescriptor descriptor) { + List extended = new ArrayList<>(keys); + extended.add(Objects.requireNonNull(descriptor, "descriptor")); + return new MongoKeysetSort(extended, sortVersion); + } + + /** Appends the default unique tie-breaker, matching the last key's direction. */ + public MongoKeysetSort withIdTieBreaker() { + MongoSortDescriptor last = keys.get(keys.size() - 1); + return then(new MongoSortDescriptor(DEFAULT_TIE_BREAKER, last.ascending())); + } + + /** Returns a copy stamped with a new sort version, invalidating cursors issued before it. */ + public MongoKeysetSort withSortVersion(int newSortVersion) { + return new MongoKeysetSort(keys, newSortVersion); + } + + /** The field names, in sort order. */ + public List fields() { + return keys.stream().map(MongoSortDescriptor::field).toList(); + } + + /** True when the final key is unique, which is what makes the sort a total order. */ + public boolean hasUniqueTieBreaker() { + return DEFAULT_TIE_BREAKER.equals(keys.get(keys.size() - 1).field()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoNullSortOrdering.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoNullSortOrdering.java new file mode 100644 index 00000000..2eba3b36 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoNullSortOrdering.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +/** + * Where missing and null values sort (design §19.1). + * + *

MongoDB sorts a missing field and an explicit {@code null} together, below every other BSON + * type. That is a defined order, but it is not usually the one a business list wants, and a keyset + * predicate built without deciding it will skip or repeat exactly those documents. Declaring the + * policy is what makes the boundary reproducible. + */ +public enum MongoNullSortOrdering { + + /** Missing and null sort before every present value, matching MongoDB's own BSON type order. */ + NULLS_FIRST, + + /** Missing and null sort after every present value; requires an explicit predicate to achieve. */ + NULLS_LAST, + + /** The sort field is required, so missing and null cannot occur. */ + NOT_APPLICABLE +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/DefaultReactiveMongoExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/DefaultReactiveMongoExecutor.java new file mode 100644 index 00000000..1dca5751 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/DefaultReactiveMongoExecutor.java @@ -0,0 +1,199 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +import com.mongodb.MongoException; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoPersistenceException; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObservation; +import dev.caskeleton.adapter.outbound.mongo.api.observation.MongoOperationObserver; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureTranslator; +import dev.caskeleton.adapter.outbound.mongo.imperative.MongoCollectionProfileRegistry; +import java.time.Duration; +import java.util.Objects; +import org.springframework.dao.DataAccessException; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +/** + * The reactive execution scope (design §26). + * + *

Three properties the blocking path gets for free and this one has to arrange explicitly. + * + *

The observation scope is a Reactor resource, so it closes on completion, on error and + * on cancellation — and cancellation is the common case here, because an HTTP client that + * disconnects cancels the chain. + * + *

The timeout is applied to the assembled publisher, so it covers the work rather than the + * assembly. A deadline applied before subscription would measure how long it took to build a + * lambda. + * + *

Context travels through Reactor Context, because the chain changes threads at every operator + * boundary and a thread-local set at subscription is gone by the time the driver responds. + */ +public final class DefaultReactiveMongoExecutor implements ReactiveMongoExecutor { + + private final ReactiveMongoConsistencyBinder consistency; + + private final MongoCollectionProfileRegistry collections; + + private final MongoFailureTranslator translator; + + private final MongoOperationObserver observer; + + public DefaultReactiveMongoExecutor( + ReactiveMongoConsistencyBinder consistency, + MongoCollectionProfileRegistry collections, + MongoFailureTranslator translator, + MongoOperationObserver observer) { + this.consistency = Objects.requireNonNull(consistency, "consistency"); + this.collections = Objects.requireNonNull(collections, "collections"); + this.translator = Objects.requireNonNull(translator, "translator"); + this.observer = Objects.requireNonNull(observer, "observer"); + } + + @Override + public Mono execute(MongoOperationContext context, ReactiveMongoCallback callback) { + return execute(context, MongoOperationType.UNKNOWN, callback); + } + + /** Runs an operation that produces at most one element, declaring its operation type. */ + public Mono execute( + MongoOperationContext context, + MongoOperationType operationType, + ReactiveMongoCallback callback) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(callback, "callback"); + + return Mono.using( + () -> observer.start(context, operationType), + observation -> + Mono.from(callback.doInMongo(access(context))) + .timeout(context.timeout()) + .doOnSuccess( + value -> observation.success(MongoExecutionOutcome.WRITE_CONFIRMED)) + .onErrorMap(failure -> translate(context, operationType, observation, failure)) + .contextWrite(contextOf(context, observation)), + MongoOperationObservation::close) + .onErrorMap(failure -> translate(context, operationType, null, failure)); + } + + @Override + public Flux executeMany(MongoOperationContext context, ReactiveMongoCallback callback) { + return executeMany(context, MongoOperationType.FIND, callback); + } + + /** Runs an operation that produces a bounded stream, declaring its operation type. */ + public Flux executeMany( + MongoOperationContext context, + MongoOperationType operationType, + ReactiveMongoCallback callback) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operationType, "operationType"); + Objects.requireNonNull(callback, "callback"); + + return Flux.using( + () -> observer.start(context, operationType), + observation -> + Flux.from(callback.doInMongo(access(context))) + .timeout(context.timeout()) + .doOnComplete(() -> observation.success(MongoExecutionOutcome.WRITE_CONFIRMED)) + .onErrorMap(failure -> translate(context, operationType, observation, failure)) + .contextWrite(contextOf(context, observation)), + MongoOperationObservation::close) + .onErrorMap(failure -> translate(context, operationType, null, failure)); + } + + private static Context contextOf( + MongoOperationContext context, MongoOperationObservation observation) { + return Context.of( + ReactiveMongoContextKeys.OPERATION_CONTEXT, + context, + ReactiveMongoContextKeys.OBSERVATION, + observation); + } + + private ReactiveMongoCollectionAccess access(MongoOperationContext context) { + String physicalCollection = collections.require(context.collectionProfile()); + ReactiveMongoOperations operations = consistency.templateFor(context.consistency()); + return new ScopedAccess(physicalCollection, operations); + } + + private Throwable translate( + MongoOperationContext context, + MongoOperationType operationType, + MongoOperationObservation observation, + Throwable failure) { + if (failure instanceof MongoPersistenceException alreadyTranslated) { + if (observation != null) { + observation.failure(alreadyTranslated.failureContext()); + } + return alreadyTranslated; + } + MongoException driverFailure = asDriverFailure(failure); + if (driverFailure == null) { + return failure; + } + MongoPersistenceException translated = + translator.translate( + context, operationType, Duration.ZERO, MongoDriverFailureView.from(driverFailure), 1); + if (observation != null) { + observation.failure(translated.failureContext().withTraceId(observation.traceId())); + } + return translated; + } + + private static MongoException asDriverFailure(Throwable failure) { + if (failure instanceof MongoException driverFailure) { + return driverFailure; + } + if (failure instanceof DataAccessException springFailure + && springFailure.getCause() instanceof MongoException driverFailure) { + return driverFailure; + } + return null; + } + + /** The collection access handed to a reactive callback. */ + private static final class ScopedAccess implements ReactiveMongoCollectionAccess { + + private final String physicalCollection; + + private final ReactiveMongoOperations operations; + + private ScopedAccess(String physicalCollection, ReactiveMongoOperations operations) { + this.physicalCollection = physicalCollection; + this.operations = operations; + } + + @Override + public String collection() { + return physicalCollection; + } + + @Override + public String collection(String requestedCollection) { + Objects.requireNonNull(requestedCollection, "requestedCollection"); + if (!physicalCollection.equals(requestedCollection)) { + throw MongoOperationRejectedException.of( + "collection.scope", + "this operation is scoped to collection '" + + physicalCollection + + "' and may not reach '" + + requestedCollection + + "'"); + } + return physicalCollection; + } + + @Override + public ReactiveMongoOperations operations() { + return operations; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCallback.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCallback.java new file mode 100644 index 00000000..76bd226f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCallback.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +import org.reactivestreams.Publisher; + +/** + * The body of one reactive operation (design §26). + * + *

Returns a {@link Publisher} rather than a value, so the work is deferred until subscription + * and the timeout, context and cancellation the executor applies actually cover it. + * + * @param the element type + */ +@FunctionalInterface +public interface ReactiveMongoCallback { + + /** Builds the publisher for this operation. */ + Publisher doInMongo(ReactiveMongoCollectionAccess access); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCollectionAccess.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCollectionAccess.java new file mode 100644 index 00000000..ef599f3d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoCollectionAccess.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; + +/** + * The reactive counterpart of the imperative collection scope (design §26). + * + *

Same rule, same reason: the operation named one collection profile, so the callback reaches + * that collection and no other. Keeping the two APIs symmetrical is what lets a team move a use + * case between the blocking and reactive paths without re-deriving its guardrails. + */ +public interface ReactiveMongoCollectionAccess { + + /** The physical collection the operation's profile resolves to. */ + String collection(); + + /** + * Confirms that the callback is asking for the collection this operation was scoped to. + * + * @throws MongoOperationRejectedException when the requested collection is a different one + */ + String collection(String requestedCollection); + + /** Reactive template operations bound to the operation's consistency profile. */ + ReactiveMongoOperations operations(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoConsistencyBinder.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoConsistencyBinder.java new file mode 100644 index 00000000..d1dc06cf --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoConsistencyBinder.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +import com.mongodb.ReadConcern; +import com.mongodb.ReadConcernLevel; +import com.mongodb.ReadPreference; +import com.mongodb.WriteConcern; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.query.Query; + +/** + * The reactive counterpart of the imperative consistency binder (design §7.2, §26). + * + *

Sharing the descriptor registry with the blocking path is what keeps the two from drifting: a + * profile means the same read preference, read concern and write concern on both, so moving a use + * case between them cannot silently change its durability. + */ +public final class ReactiveMongoConsistencyBinder { + + private final MongoConsistencyRegistry registry; + + private final Map templatesByProfile; + + public ReactiveMongoConsistencyBinder( + ReactiveMongoTemplate template, MongoConsistencyRegistry registry) { + Objects.requireNonNull(template, "template"); + this.registry = Objects.requireNonNull(registry, "registry"); + Map derived = + new EnumMap<>(MongoConsistencyProfile.class); + for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) { + derived.put(profile, derive(template, registry.require(profile))); + } + this.templatesByProfile = Map.copyOf(derived); + } + + private static ReactiveMongoTemplate derive( + ReactiveMongoTemplate template, MongoConsistencyDescriptor descriptor) { + ReactiveMongoTemplate bound = + new ReactiveMongoTemplate(template.getMongoDatabaseFactory(), template.getConverter()); + bound.setReadPreference(readPreferenceOf(descriptor)); + bound.setWriteConcern(writeConcernOf(descriptor)); + return bound; + } + + /** The reactive template bound to a profile's read preference and write concern. */ + public ReactiveMongoTemplate templateFor(MongoConsistencyProfile profile) { + ReactiveMongoTemplate bound = + templatesByProfile.get(Objects.requireNonNull(profile, "profile")); + if (bound == null) { + throw new IllegalStateException( + "no reactive template bound for consistency profile " + profile); + } + return bound; + } + + /** Applies a profile's read settings to one query. */ + public Query applyReadSettings(Query query, MongoConsistencyProfile profile) { + Objects.requireNonNull(query, "query"); + MongoConsistencyDescriptor descriptor = registry.require(profile); + return query + .withReadPreference(readPreferenceOf(descriptor)) + .withReadConcern(readConcernOf(descriptor)); + } + + private static ReadPreference readPreferenceOf(MongoConsistencyDescriptor descriptor) { + return descriptor.readsFromSecondary() + ? ReadPreference.secondaryPreferred() + : ReadPreference.primary(); + } + + private static ReadConcern readConcernOf(MongoConsistencyDescriptor descriptor) { + return new ReadConcern(ReadConcernLevel.fromString(descriptor.readConcern())); + } + + private static WriteConcern writeConcernOf(MongoConsistencyDescriptor descriptor) { + return "majority".equals(descriptor.writeConcern()) + ? WriteConcern.MAJORITY + : WriteConcern.ACKNOWLEDGED; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoContextKeys.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoContextKeys.java new file mode 100644 index 00000000..2f0860eb --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoContextKeys.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +/** + * The Reactor Context keys the reactive path propagates operation state under (design §26). + * + *

Reactor Context rather than {@code ThreadLocal}, because a reactive chain changes threads at + * every operator boundary. A {@code ThreadLocal} set at subscription time is simply absent by the + * time the driver callback runs, so operation identity and tracing would silently disappear from + * exactly the signals that need them. + */ +public final class ReactiveMongoContextKeys { + + /** Reactor Context key holding the {@code MongoOperationContext} of the current operation. */ + public static final String OPERATION_CONTEXT = "mongodb.operationContext"; + + /** Reactor Context key holding the observation scope of the current operation. */ + public static final String OBSERVATION = "mongodb.observation"; + + /** Reactor Context key holding the causal session bound to the current chain. */ + public static final String CAUSAL_SESSION = "mongodb.causalSession"; + + private ReactiveMongoContextKeys() {} +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoExecutor.java new file mode 100644 index 00000000..0bcb6a5c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/ReactiveMongoExecutor.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The single reactive execution path (design §26). + * + *

Mirrors the imperative executor's guarantees — registered operation, registered collection, + * consistency, deadline, observation, one translation — and adds the two the reactive model needs: + * cancellation must release resources, and context must travel through Reactor Context rather than + * a thread. + */ +public interface ReactiveMongoExecutor { + + /** Runs an operation that produces at most one element. */ + Mono execute(MongoOperationContext context, ReactiveMongoCallback callback); + + /** Runs an operation that produces a bounded stream of elements. */ + Flux executeMany(MongoOperationContext context, ReactiveMongoCallback callback); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuard.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuard.java new file mode 100644 index 00000000..969bc616 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuard.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive.cursor; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; +import reactor.core.publisher.Flux; + +/** + * Bounds a cursor's lifetime and guarantees it is released (design §32). + * + *

{@link Flux#using} is what makes the guarantee hold: the cleanup runs on completion, on error + * and on cancellation, which is the path a plain {@code doFinally} chain is most likely to get + * wrong. The lease's own idempotence means the cleanup can fire more than once without killing a + * cursor twice. + * + *

No transparent retry is offered. Once the first document has been emitted the consumer has + * already acted on a prefix, and silently restarting the cursor would deliver it again. + */ +public final class MongoCursorGuard { + + private final Duration maximumLifetime; + + private final Consumer terminationRecorder; + + public MongoCursorGuard( + Duration maximumLifetime, Consumer terminationRecorder) { + this.maximumLifetime = Objects.requireNonNull(maximumLifetime, "maximumLifetime"); + this.terminationRecorder = Objects.requireNonNull(terminationRecorder, "terminationRecorder"); + if (maximumLifetime.isZero() || maximumLifetime.isNegative()) { + throw new IllegalArgumentException("a cursor lifetime must be positive"); + } + } + + /** A guard that records nothing, for callers without telemetry. */ + public static MongoCursorGuard withLifetime(Duration maximumLifetime) { + return new MongoCursorGuard(maximumLifetime, termination -> {}); + } + + /** + * Wraps a cursor-backed stream so its lease is always released. + * + * @param leaseSupplier opens the cursor at subscription time + * @param streamFactory builds the element stream from the open lease + */ + public Flux guard( + Supplier leaseSupplier, Function> streamFactory) { + Objects.requireNonNull(leaseSupplier, "leaseSupplier"); + Objects.requireNonNull(streamFactory, "streamFactory"); + return Flux.using( + leaseSupplier::get, + lease -> + streamFactory + .apply(lease) + .timeout(maximumLifetime) + .doOnComplete(() -> terminationRecorder.accept(MongoCursorTermination.COMPLETED)) + .doOnCancel(() -> terminationRecorder.accept(MongoCursorTermination.CANCELLED)) + .doOnError( + failure -> + terminationRecorder.accept( + failure instanceof TimeoutException + ? MongoCursorTermination.LIFETIME_EXPIRED + : MongoCursorTermination.FAILED)), + MongoCursorLease::close); + } + + /** + * Checks that a cursor read declares the bounds it needs. + * + * @throws MongoOperationRejectedException when the batch size is unset or above the budget + */ + public void requireBoundedBatch(MongoOperationBudget budget, int requestedBatchSize) { + Objects.requireNonNull(budget, "budget"); + if (requestedBatchSize <= 0) { + throw MongoOperationRejectedException.of( + "cursor.batch", "a cursor read must declare a positive batch size"); + } + if (requestedBatchSize > budget.cursorBatchSize()) { + throw MongoOperationRejectedException.of( + "cursor.batch", + "a batch size of " + + requestedBatchSize + + " is above the registered budget of " + + budget.cursorBatchSize()); + } + } + + /** The maximum time a cursor may stay open. */ + public Duration maximumLifetime() { + return maximumLifetime; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorLease.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorLease.java new file mode 100644 index 00000000..ed74a65d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorLease.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive.cursor; + +/** + * A borrowed server-side cursor (design §19.1, §32). + * + *

A cursor is server state, not client state: it holds a connection, a snapshot and memory on + * the server until it is exhausted, killed or times out. A stream that ends early — cancelled, + * failed, or simply not fully consumed — leaves that state behind, and enough of them exhaust the + * pool. + * + *

{@link #close()} is idempotent by contract so the guard can call it from every termination + * path without having to work out which one ran first. + */ +public interface MongoCursorLease extends AutoCloseable { + + /** True once the cursor has been released. */ + boolean closed(); + + /** Releases the cursor. Calling it more than once has no further effect. */ + @Override + void close(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorTermination.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorTermination.java new file mode 100644 index 00000000..4a5a5cb0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorTermination.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive.cursor; + +/** + * How a cursor stream ended (design §32). + * + *

Recorded separately from the operation's outcome because the distinctions matter operationally + * and are invisible from the caller's side. Cancellation is normal for a disconnected client; + * lifetime expiry means the consumer is slower than the cursor's budget allows; a query timeout + * means the server gave up. Collapsing all three into "error" hides the only one that is a capacity + * problem. + */ +public enum MongoCursorTermination { + + /** The stream was fully consumed. */ + COMPLETED, + + /** The subscriber cancelled; usually a disconnected client. */ + CANCELLED, + + /** The stream failed. */ + FAILED, + + /** The cursor outlived its configured maximum lifetime. */ + LIFETIME_EXPIRED, + + /** The operation's own deadline fired. */ + OPERATION_TIMEOUT; + + /** True when the termination indicates the consumer could not keep up. */ + public boolean indicatesBackpressureProblem() { + return this == LIFETIME_EXPIRED; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoReactiveCursorPublisher.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoReactiveCursorPublisher.java new file mode 100644 index 00000000..94ec056b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoReactiveCursorPublisher.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive.cursor; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.query.Query; +import reactor.core.publisher.Flux; + +/** + * A bounded, always-released reactive cursor read (design §32). + * + *

The platform never collects a cursor into a list. A streaming read exists precisely because + * the result does not fit comfortably in memory, and buffering it inside the platform would + * reintroduce the problem the caller was avoiding — with the added twist that the caller cannot see + * it happening. + */ +public final class MongoReactiveCursorPublisher { + + private final MongoCursorGuard guard; + + public MongoReactiveCursorPublisher(MongoCursorGuard guard) { + this.guard = Objects.requireNonNull(guard, "guard"); + } + + /** + * Streams a query's results under the operation's budget. + * + * @param batchSize how many documents the server sends per round trip + */ + public Flux stream( + MongoOperationContext context, + ReactiveMongoOperations operations, + Query query, + Class documentType, + String collection, + MongoOperationBudget budget, + int batchSize) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(operations, "operations"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(collection, "collection"); + guard.requireBoundedBatch(budget, batchSize); + + Query bounded = query.cursorBatchSize(batchSize).maxTimeMsec(budget.maxTimeMillis()); + return guard.guard( + TrackingLease::new, lease -> Flux.from(operations.find(bounded, documentType, collection))); + } + + /** + * The lease for a Spring Data reactive find. + * + *

Spring Data closes the underlying driver cursor when its publisher terminates, so this lease + * records the release rather than performing it. Keeping it in the lease shape means the guard's + * termination handling is identical whether the cursor is driver-managed or platform-managed. + */ + private static final class TrackingLease implements MongoCursorLease { + + private final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public boolean closed() { + return closed.get(); + } + + @Override + public void close() { + closed.set(true); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexApplyPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexApplyPolicy.java new file mode 100644 index 00000000..b61b21de --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexApplyPolicy.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; + +/** + * What an environment is allowed to do about index drift (design §18). + * + *

The gradient exists because building an index is not a free operation: on a large collection + * it consumes I/O and memory for minutes or hours, and a foreground build blocks writes. An + * application that creates indexes at startup therefore turns a deployment into an unplanned + * maintenance window — and does it again on every replica, at whatever moment the rollout reaches + * them. + */ +public enum MongoIndexApplyPolicy { + + /** Local and test: apply freely; the data is disposable. */ + APPLY, + + /** Dev: apply, but publish the diff so drift is visible. */ + APPLY_WITH_DIFF, + + /** Staging: report the diff and apply only after approval. */ + DIFF_WITH_APPROVED_APPLY, + + /** Production: the runtime reports drift and never changes index state. */ + REPORT_ONLY; + + /** True when the application runtime may create or drop an index under this policy. */ + public boolean runtimeMayApply() { + return this == APPLY || this == APPLY_WITH_DIFF; + } + + /** + * Fails when a runtime tries to change index state where only the D4 admin plane may. + * + * @throws MongoOperationRejectedException naming the policy that forbids it + */ + public void requireRuntimeApplyAllowed(String collection) { + if (!runtimeMayApply()) { + throw MongoOperationRejectedException.of( + "index.apply", + "index apply is not permitted for collection '" + + collection + + "' under policy " + + this + + "; create, drop and collMod are D4 admin-plane operations here"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDescriptorView.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDescriptorView.java new file mode 100644 index 00000000..3070de52 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDescriptorView.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership; +import java.util.Objects; + +/** + * An index as it actually exists on the server (design §18). + * + *

Read through the D4 admin client and reduced to the fields a diff can compare. Ownership is + * inferred here rather than trusted from the manifest, because the whole purpose of the diff is to + * find indexes the manifest does not know about — and some of those belong to encryption or search + * and must never be proposed for deletion. + */ +public record MongoIndexDescriptorView( + String collection, + String name, + String keySignature, + boolean unique, + boolean hidden, + MongoMetadataOwnership metadataOwnership) { + + /** MongoDB's own index on {@code _id}. */ + private static final String ID_INDEX = "_id_"; + + /** Prefix of the internal index Queryable Encryption maintains. */ + private static final String SAFE_CONTENT_PREFIX = "__safeContent__"; + + public MongoIndexDescriptorView { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(keySignature, "keySignature"); + Objects.requireNonNull(metadataOwnership, "metadataOwnership"); + } + + /** + * Classifies an index read from the server. + * + *

The two recognised non-application shapes are MongoDB's {@code _id_} index and Queryable + * Encryption's {@code __safeContent__} index. Both would otherwise look exactly like an orphan + * the diff should clean up, and dropping either is unrecoverable without a rebuild. + */ + public static MongoIndexDescriptorView observed( + String collection, String name, String keySignature, boolean unique, boolean hidden) { + return new MongoIndexDescriptorView( + collection, name, keySignature, unique, hidden, inferOwnership(name, keySignature)); + } + + private static MongoMetadataOwnership inferOwnership(String name, String keySignature) { + if (ID_INDEX.equals(name)) { + return MongoMetadataOwnership.MONGODB_MANAGED; + } + if (name.startsWith(SAFE_CONTENT_PREFIX) || keySignature.contains(SAFE_CONTENT_PREFIX)) { + return MongoMetadataOwnership.ENCRYPTION_MANAGED; + } + return MongoMetadataOwnership.APPLICATION; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiff.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiff.java new file mode 100644 index 00000000..3a239627 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiff.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import java.util.List; +import java.util.Objects; + +/** + * The difference between declared and observed indexes (design §18). + * + *

Every list is sorted so the diff is byte-identical for the same inputs. A CI artifact that + * reorders between runs cannot be diffed against the previous run, which is most of what an index + * drift report is for. + * + *

{@code dropCandidates} is named for what it is: a proposal that still has to pass deprecation, + * hiding, observation and approval before anything is dropped. + */ +public record MongoIndexDiff( + List create, List change, List hide, List dropCandidates) { + + public MongoIndexDiff { + Objects.requireNonNull(create, "create"); + Objects.requireNonNull(change, "change"); + Objects.requireNonNull(hide, "hide"); + Objects.requireNonNull(dropCandidates, "dropCandidates"); + create = List.copyOf(create); + change = List.copyOf(change); + hide = List.copyOf(hide); + dropCandidates = List.copyOf(dropCandidates); + } + + /** A diff with no differences. */ + public static MongoIndexDiff empty() { + return new MongoIndexDiff(List.of(), List.of(), List.of(), List.of()); + } + + /** True when declared and observed state already agree. */ + public boolean isClean() { + return create.isEmpty() && change.isEmpty() && hide.isEmpty() && dropCandidates.isEmpty(); + } + + /** A stable, line-oriented rendering suitable for a CI artifact. */ + public String render() { + StringBuilder report = new StringBuilder(); + appendSection(report, "create", create); + appendSection(report, "change", change); + appendSection(report, "hide", hide); + appendSection(report, "drop-candidate", dropCandidates); + return report.toString(); + } + + private static void appendSection(StringBuilder report, String label, List entries) { + for (String entry : entries) { + report.append(label).append(' ').append(entry).append(System.lineSeparator()); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngine.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngine.java new file mode 100644 index 00000000..dece468e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngine.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Compares declared indexes against what the server actually has (design §18). + * + *

The engine only ever produces a report. Applying it is a separate, environment-gated decision, + * because index creation on a large collection is an I/O-heavy operation and index deletion is + * effectively irreversible within an incident's timescale. + * + *

The rule that does the most work is the ownership filter: an observed index that the manifest + * does not declare is only a drop candidate if the application created it. Encryption and search + * indexes look identical to orphans and dropping either breaks the feature until a full rebuild. + */ +public final class MongoIndexDiffEngine { + + /** Compares one collection's declared indexes with the observed ones. */ + public MongoIndexDiff compare( + MongoCollectionManifest manifest, Collection observed) { + Objects.requireNonNull(manifest, "manifest"); + Objects.requireNonNull(observed, "observed"); + + Map observedByName = new LinkedHashMap<>(); + for (MongoIndexDescriptorView view : observed) { + observedByName.put(view.name(), view); + } + + List create = new ArrayList<>(); + List change = new ArrayList<>(); + List hide = new ArrayList<>(); + List dropCandidates = new ArrayList<>(); + + for (MongoIndexManifest declared : manifest.indexes()) { + MongoIndexDescriptorView actual = observedByName.get(declared.name()); + if (actual == null) { + if (!declared.deprecated()) { + create.add(qualify(manifest, declared.name())); + } + continue; + } + if (!actual.keySignature().equals(declared.keySignature()) + || actual.unique() != declared.unique()) { + // Same name, different definition: MongoDB will not silently re-create it, so a rebuild has + // to be planned rather than discovered when a query starts scanning. + change.add(qualify(manifest, declared.name())); + } + if (declared.hidden() && !actual.hidden()) { + hide.add(qualify(manifest, declared.name())); + } + } + + for (MongoIndexDescriptorView actual : observedByName.values()) { + if (!actual.metadataOwnership().droppableByApplicationDrift()) { + continue; + } + if (manifest.index(actual.name()).isEmpty()) { + dropCandidates.add(qualify(manifest, actual.name())); + } + } + + create.sort(String::compareTo); + change.sort(String::compareTo); + hide.sort(String::compareTo); + dropCandidates.sort(String::compareTo); + return new MongoIndexDiff(create, change, hide, dropCandidates); + } + + private static String qualify(MongoCollectionManifest manifest, String indexName) { + return manifest.collection() + '.' + indexName; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementPlan.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementPlan.java new file mode 100644 index 00000000..4d8e976d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementPlan.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; + +/** + * One index's position in the retirement workflow (design §18). + * + *

The transition rules are the plan's whole content: states may only advance, and only by one + * step. Skipping from {@code DEPRECATED} straight to {@code APPROVED} would mean approving a drop + * that was never observed with the index hidden, which is the failure this workflow exists to + * prevent. + */ +public record MongoIndexRetirementPlan( + String collection, String indexName, MongoIndexRetirementState state, String approvedBy) { + + public MongoIndexRetirementPlan { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(indexName, "indexName"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(approvedBy, "approvedBy"); + if (state == MongoIndexRetirementState.APPROVED && approvedBy.isBlank()) { + throw new IllegalArgumentException("an approved index drop must name its approver"); + } + } + + /** Starts the workflow for an index the manifest no longer declares. */ + public static MongoIndexRetirementPlan deprecate(String collection, String indexName) { + return new MongoIndexRetirementPlan( + collection, indexName, MongoIndexRetirementState.DEPRECATED, ""); + } + + /** + * Advances exactly one step. + * + * @throws MongoOperationRejectedException when the transition skips a step or moves backwards + */ + public MongoIndexRetirementPlan advanceTo(MongoIndexRetirementState next, String operator) { + Objects.requireNonNull(next, "next"); + Objects.requireNonNull(operator, "operator"); + if (next != state.successor()) { + throw MongoOperationRejectedException.of( + "index.retire", + "index '" + + indexName + + "' cannot move from " + + state + + " to " + + next + + "; the retirement workflow advances one step at a time"); + } + String approver = next == MongoIndexRetirementState.APPROVED ? operator : approvedBy; + return new MongoIndexRetirementPlan(collection, indexName, next, approver); + } + + /** True when this index may actually be dropped. */ + public boolean droppable() { + return state == MongoIndexRetirementState.APPROVED && !approvedBy.isBlank(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementState.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementState.java new file mode 100644 index 00000000..be34f2f9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexRetirementState.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +/** + * The stages an index passes through on its way out (design §18). + * + *

Dropping an index is the one index operation that cannot be undone quickly: rebuilding a large + * index takes as long as it took to build the first time, and by then the queries that needed it + * are already timing out in production. Hiding it first produces the same query-planner behaviour + * as dropping it while keeping the structure, so the decision is reversible in seconds. + */ +public enum MongoIndexRetirementState { + + /** Declared unnecessary, still built and still used by the planner. */ + DEPRECATED, + + /** Usage statistics are being collected to confirm nothing depends on it. */ + USAGE_OBSERVED, + + /** Invisible to the planner but still maintained; instantly reversible. */ + HIDDEN, + + /** Explain plans have been re-checked with the index hidden and show no regression. */ + REGRESSION_CHECKED, + + /** An operator approved the drop. */ + APPROVED, + + /** The index is gone. */ + DROPPED; + + /** The state that must be reached before a drop may be approved. */ + public boolean readyForApproval() { + return this == REGRESSION_CHECKED; + } + + /** + * The single state that may follow this one, or {@code null} at the end of the workflow. + * + *

Written out rather than derived from declaration order: an ordinal-based successor silently + * changes meaning the moment someone inserts a constant, and this sequence is a safety procedure. + */ + public MongoIndexRetirementState successor() { + return switch (this) { + case DEPRECATED -> USAGE_OBSERVED; + case USAGE_OBSERVED -> HIDDEN; + case HIDDEN -> REGRESSION_CHECKED; + case REGRESSION_CHECKED -> APPROVED; + case APPROVED -> DROPPED; + case DROPPED -> null; + }; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoCollectionManifest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoCollectionManifest.java new file mode 100644 index 00000000..f35defc3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoCollectionManifest.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelManifest; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The single source of truth for one collection's validator, indexes and ownership (design §18). + * + *

Deliberately not derived from annotations. Spring Data's {@code @Indexed} can create an index + * as a side effect of a class being on the classpath, which means production index state depends on + * deployment order and on which module happened to be loaded. Declaring the collection here makes + * the index set reviewable before it exists, and comparable against reality afterwards. + */ +public record MongoCollectionManifest( + String collection, + String owner, + MongoSchemaManifest schema, + MongoDocumentModelManifest documentModel, + List indexes) { + + public MongoCollectionManifest { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(indexes, "indexes"); + indexes = List.copyOf(indexes); + if (collection.isBlank()) { + throw new IllegalArgumentException("a collection manifest needs a collection name"); + } + } + + /** Starts a collection declaration. */ + public static Builder builder(String collection) { + return new Builder(collection); + } + + /** The collection's validator, when it declares one. */ + public Optional validator() { + return Optional.ofNullable(schema); + } + + /** The collection's document model manifest, when it declares one. */ + public Optional model() { + return Optional.ofNullable(documentModel); + } + + /** The declared index with the given name. */ + public Optional index(String name) { + Objects.requireNonNull(name, "name"); + return indexes.stream().filter(index -> index.name().equals(name)).findFirst(); + } + + /** Collects the parts of one collection declaration. */ + public static final class Builder { + + private final String collection; + + private final List indexes = new ArrayList<>(); + + private String owner = ""; + + private MongoSchemaManifest schema; + + private MongoDocumentModelManifest documentModel; + + private Builder(String collection) { + this.collection = Objects.requireNonNull(collection, "collection"); + } + + /** Names the team or domain that owns this collection. */ + public Builder owner(String value) { + this.owner = Objects.requireNonNull(value, "owner"); + return this; + } + + /** Declares the collection's JSON Schema validator. */ + public Builder schema(MongoSchemaManifest value) { + this.schema = Objects.requireNonNull(value, "schema"); + return this; + } + + /** Declares the collection's document model, including its growth bounds. */ + public Builder documentModel(MongoDocumentModelManifest value) { + this.documentModel = Objects.requireNonNull(value, "documentModel"); + return this; + } + + /** + * Appends an index declaration. + * + *

Duplicates are accepted here and rejected by {@link MongoManifestRegistry}: a builder that + * threw on the second call would report the problem at the wrong place, since a duplicate is + * only meaningful once the whole set is known. + */ + public Builder index(MongoIndexManifest value) { + indexes.add(Objects.requireNonNull(value, "index")); + return this; + } + + /** Builds the immutable collection manifest. */ + public MongoCollectionManifest build() { + return new MongoCollectionManifest(collection, owner, schema, documentModel, indexes); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexDirection.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexDirection.java new file mode 100644 index 00000000..6e003397 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexDirection.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +/** + * The kind of one key in an index (design §18). + * + *

Direction is part of the index identity, not a formatting detail: a compound index on {@code + * (a asc, b desc)} serves a different sort than {@code (a asc, b asc)}, and a query planner will + * silently fall back to an in-memory sort rather than report the mismatch. + */ +public enum MongoIndexDirection { + + /** Ascending b-tree key. */ + ASCENDING("1"), + + /** Descending b-tree key. */ + DESCENDING("-1"), + + /** Hashed key, used for hashed shard keys and equality-only lookups. */ + HASHED("hashed"), + + /** Legacy text key. Compatibility only; full-text search is an Advanced capability. */ + TEXT("text"), + + /** GeoJSON spherical key. */ + GEO_2DSPHERE("2dsphere"); + + private final String bsonValue; + + MongoIndexDirection(String bsonValue) { + this.bsonValue = bsonValue; + } + + /** The value MongoDB stores for this key kind. */ + public String bsonValue() { + return bsonValue; + } + + /** True when this key participates in a sort order. */ + public boolean ordered() { + return this == ASCENDING || this == DESCENDING; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexKey.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexKey.java new file mode 100644 index 00000000..20ba2c85 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexKey.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import java.util.Objects; + +/** One key of a compound index, in declaration order (design §18). */ +public record MongoIndexKey(String field, MongoIndexDirection direction) { + + public MongoIndexKey { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(direction, "direction"); + if (field.isBlank()) { + throw new IllegalArgumentException("an index key needs a field path"); + } + } + + @Override + public String toString() { + return field + ":" + direction.bsonValue(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexManifest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexManifest.java new file mode 100644 index 00000000..3a1533d1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoIndexManifest.java @@ -0,0 +1,243 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * One declared index, with everything a diff or a review needs (design §18). + * + *

Two fields carry most of the weight. {@code expectedUsage} names the operations the index + * exists for, which is what makes "is this index still needed" answerable without guessing from + * production statistics. {@code metadataOwnership} says who created it, which is what keeps drift + * cleanup from proposing to drop an encryption or search index it did not declare. + * + *

Key order is preserved because it is part of the index's identity, not a presentation detail. + */ +public record MongoIndexManifest( + String name, + List keys, + boolean unique, + boolean sparse, + boolean hidden, + boolean deprecated, + String partialFilterExpression, + String collationProfile, + Duration expireAfter, + String wildcardProjection, + boolean shardKeySupport, + Set expectedUsage, + String owner, + MongoMetadataOwnership metadataOwnership) { + + public MongoIndexManifest { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(keys, "keys"); + Objects.requireNonNull(partialFilterExpression, "partialFilterExpression"); + Objects.requireNonNull(collationProfile, "collationProfile"); + Objects.requireNonNull(wildcardProjection, "wildcardProjection"); + Objects.requireNonNull(expectedUsage, "expectedUsage"); + Objects.requireNonNull(owner, "owner"); + Objects.requireNonNull(metadataOwnership, "metadataOwnership"); + keys = List.copyOf(keys); + expectedUsage = Set.copyOf(expectedUsage); + if (name.isBlank()) { + throw new IllegalArgumentException("an index needs a name"); + } + if (keys.isEmpty()) { + throw new IllegalArgumentException("index '" + name + "' declares no keys"); + } + if (expireAfter != null && expireAfter.isNegative()) { + throw new IllegalArgumentException("index '" + name + "' declares a negative TTL"); + } + if (metadataOwnership == MongoMetadataOwnership.APPLICATION && expectedUsage.isEmpty()) { + throw new IllegalArgumentException( + "index '" + + name + + "' declares no expected usage; an index nobody can name a query for cannot be " + + "reviewed for removal later"); + } + } + + /** Starts an index declaration. */ + public static Builder named(String name) { + return new Builder(name); + } + + /** The TTL, when this is a TTL index. */ + public Optional ttl() { + return Optional.ofNullable(expireAfter); + } + + /** True when this index is a TTL index. */ + public boolean isTtlIndex() { + return expireAfter != null; + } + + /** The key signature used to detect an index whose keys changed under the same name. */ + public String keySignature() { + StringBuilder signature = new StringBuilder(); + for (MongoIndexKey key : keys) { + if (signature.length() > 0) { + signature.append(','); + } + signature.append(key); + } + return signature.toString(); + } + + /** Collects the parts of one index declaration. */ + public static final class Builder { + + private final String name; + + private final List keys = new ArrayList<>(); + + private final Set expectedUsage = new LinkedHashSet<>(); + + private boolean unique; + + private boolean sparse; + + private boolean hidden; + + private boolean deprecated; + + private String partialFilterExpression = ""; + + private String collationProfile = ""; + + private Duration expireAfter; + + private String wildcardProjection = ""; + + private boolean shardKeySupport; + + private String owner = ""; + + private MongoMetadataOwnership metadataOwnership = MongoMetadataOwnership.APPLICATION; + + private Builder(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + /** Appends an ascending key. */ + public Builder ascending(String field) { + keys.add(new MongoIndexKey(field, MongoIndexDirection.ASCENDING)); + return this; + } + + /** Appends a descending key. */ + public Builder descending(String field) { + keys.add(new MongoIndexKey(field, MongoIndexDirection.DESCENDING)); + return this; + } + + /** Appends a hashed key. */ + public Builder hashed(String field) { + keys.add(new MongoIndexKey(field, MongoIndexDirection.HASHED)); + return this; + } + + /** Appends a 2dsphere key. */ + public Builder geo2dsphere(String field) { + keys.add(new MongoIndexKey(field, MongoIndexDirection.GEO_2DSPHERE)); + return this; + } + + /** Marks the index unique. */ + public Builder unique() { + this.unique = true; + return this; + } + + /** Marks the index sparse. */ + public Builder sparse() { + this.sparse = true; + return this; + } + + /** Marks the index hidden, the observation step before an approved drop. */ + public Builder hidden() { + this.hidden = true; + return this; + } + + /** Marks the index deprecated, the first step of the retirement workflow. */ + public Builder deprecated() { + this.deprecated = true; + return this; + } + + /** Declares a partial filter expression, as canonical extended JSON. */ + public Builder partialFilter(String expression) { + this.partialFilterExpression = Objects.requireNonNull(expression, "expression"); + return this; + } + + /** Declares the registered collation profile this index uses. */ + public Builder collation(String profile) { + this.collationProfile = Objects.requireNonNull(profile, "profile"); + return this; + } + + /** Declares this a TTL index with the given retention. */ + public Builder expireAfter(Duration retention) { + this.expireAfter = Objects.requireNonNull(retention, "retention"); + return this; + } + + /** Declares a wildcard projection, as canonical extended JSON. */ + public Builder wildcardProjection(String projection) { + this.wildcardProjection = Objects.requireNonNull(projection, "projection"); + return this; + } + + /** Marks the index as one that supports the collection's shard key. */ + public Builder shardKeySupport() { + this.shardKeySupport = true; + return this; + } + + /** Names an operation this index exists to serve. */ + public Builder expectedUsage(String operationName) { + expectedUsage.add(Objects.requireNonNull(operationName, "operationName")); + return this; + } + + /** Names the team or domain that owns this index. */ + public Builder owner(String value) { + this.owner = Objects.requireNonNull(value, "owner"); + return this; + } + + /** Declares who created this index. Defaults to the application. */ + public Builder metadataOwnership(MongoMetadataOwnership ownership) { + this.metadataOwnership = Objects.requireNonNull(ownership, "ownership"); + return this; + } + + /** Builds the immutable index manifest. */ + public MongoIndexManifest build() { + return new MongoIndexManifest( + name, + keys, + unique, + sparse, + hidden, + deprecated, + partialFilterExpression, + collationProfile, + expireAfter, + wildcardProjection, + shardKeySupport, + expectedUsage, + owner, + metadataOwnership); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistry.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistry.java new file mode 100644 index 00000000..6ffafda6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistry.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import dev.caskeleton.adapter.outbound.mongo.schema.model.MongoDocumentModelValidator; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * All declared collections, validated as a set (design §18). + * + *

The checks that matter here can only run once every manifest is present: a duplicate + * collection name, an index name reused inside one collection, a document model that exceeds its + * budget. Each of those is silent at the point of declaration and only becomes visible when the + * declarations are compared, which is why the registry validates rather than the builders. + */ +public final class MongoManifestRegistry { + + private final Map byCollection; + + private MongoManifestRegistry(Map byCollection) { + this.byCollection = byCollection; + } + + /** Builds and validates a registry from collection manifests. */ + public static MongoManifestRegistry of(MongoCollectionManifest... manifests) { + return of(Arrays.asList(manifests)); + } + + /** Builds and validates a registry from collection manifests. */ + public static MongoManifestRegistry of(Collection manifests) { + Objects.requireNonNull(manifests, "manifests"); + Map byCollection = new LinkedHashMap<>(); + MongoDocumentModelValidator modelValidator = new MongoDocumentModelValidator(); + for (MongoCollectionManifest manifest : manifests) { + Objects.requireNonNull(manifest, "manifest"); + if (byCollection.putIfAbsent(manifest.collection(), manifest) != null) { + throw new IllegalArgumentException( + "duplicate MongoDB collection manifest: " + manifest.collection()); + } + requireUniqueIndexNames(manifest); + manifest.model().ifPresent(modelValidator::validate); + } + return new MongoManifestRegistry(Map.copyOf(byCollection)); + } + + private static void requireUniqueIndexNames(MongoCollectionManifest manifest) { + Set names = new LinkedHashSet<>(); + for (MongoIndexManifest index : manifest.indexes()) { + if (!names.add(index.name())) { + throw new IllegalArgumentException( + "duplicate index name '" + + index.name() + + "' in collection '" + + manifest.collection() + + "'; MongoDB would keep only one of them and the diff could not tell which"); + } + } + } + + /** The manifest for a collection, if it is declared. */ + public Optional find(String collection) { + return Optional.ofNullable(byCollection.get(Objects.requireNonNull(collection, "collection"))); + } + + /** + * The manifest for a collection. + * + * @throws IllegalArgumentException when the collection was never declared + */ + public MongoCollectionManifest require(String collection) { + return find(collection) + .orElseThrow( + () -> + new IllegalArgumentException( + "no MongoDB collection manifest declared for '" + collection + "'")); + } + + /** Every declared collection, keyed by name. */ + public Map declared() { + return byCollection; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoMetadataOwnership.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoMetadataOwnership.java new file mode 100644 index 00000000..e5000ec3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoMetadataOwnership.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +/** + * Who owns an index or collection-level piece of metadata (design §18). + * + *

This enum is what stops drift cleanup from deleting something it did not create. An index diff + * that treats every index it did not find in the application manifest as an orphan will happily + * drop a Queryable Encryption metadata index or a search index — and both are unrecoverable without + * a full rebuild. Ownership makes "I did not declare it" and "nobody declared it" different + * answers. + */ +public enum MongoMetadataOwnership { + + /** Declared by this application's manifest. Eligible for diff, hide and approved drop. */ + APPLICATION, + + /** Created by MongoDB itself, such as the {@code _id} index. Never dropped. */ + MONGODB_MANAGED, + + /** Created by CSFLE or Queryable Encryption. Never dropped by application drift cleanup. */ + ENCRYPTION_MANAGED, + + /** Created by the search or vector index subsystem. Managed through its own admin plane. */ + SEARCH_MANAGED; + + /** True when application-side drift cleanup may propose dropping this metadata. */ + public boolean droppableByApplicationDrift() { + return this == APPLICATION; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoSchemaManifest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoSchemaManifest.java new file mode 100644 index 00000000..6c071727 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoSchemaManifest.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion; +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction; +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The JSON Schema validator a collection runs, and how strictly (design §12.1, §18). + * + *

The migration window is an expiry date rather than a flag. A collection left on {@code + * MODERATE}/{@code WARN} indefinitely has a validator that documents an intention and enforces + * nothing; giving the relaxation a deadline is what makes the temporary state actually temporary. + */ +public record MongoSchemaManifest( + String validatorResource, + MongoValidationLevel validationLevel, + MongoValidationAction validationAction, + DocumentSchemaVersion schemaVersion, + Instant migrationWindowExpiresAt) { + + public MongoSchemaManifest { + Objects.requireNonNull(validatorResource, "validatorResource"); + Objects.requireNonNull(validationLevel, "validationLevel"); + Objects.requireNonNull(validationAction, "validationAction"); + Objects.requireNonNull(schemaVersion, "schemaVersion"); + if (isRelaxed(validationLevel, validationAction) && migrationWindowExpiresAt == null) { + throw new IllegalArgumentException( + "a relaxed validator (" + + validationLevel + + "/" + + validationAction + + ") is only valid inside an explicit migration window with an expiry"); + } + } + + /** The target state for a new collection: strict validation that rejects invalid writes. */ + public static MongoSchemaManifest strict(String validatorResource, int schemaVersion) { + return new MongoSchemaManifest( + validatorResource, + MongoValidationLevel.STRICT, + MongoValidationAction.ERROR, + new DocumentSchemaVersion(schemaVersion), + null); + } + + /** A time-boxed relaxation for a legacy collection being brought up to the validator. */ + public static MongoSchemaManifest migrationWindow( + String validatorResource, int schemaVersion, Instant expiresAt) { + return new MongoSchemaManifest( + validatorResource, + MongoValidationLevel.MODERATE, + MongoValidationAction.WARN, + new DocumentSchemaVersion(schemaVersion), + Objects.requireNonNull(expiresAt, "expiresAt")); + } + + /** The migration window expiry, when this manifest is relaxed. */ + public Optional migrationWindow() { + return Optional.ofNullable(migrationWindowExpiresAt); + } + + /** True when the validator is not yet at the strict target state. */ + public boolean isRelaxed() { + return isRelaxed(validationLevel, validationAction); + } + + /** True when a relaxed validator has outlived its declared migration window. */ + public boolean migrationWindowExpired(Instant now) { + Objects.requireNonNull(now, "now"); + return migrationWindowExpiresAt != null && now.isAfter(migrationWindowExpiresAt); + } + + private static boolean isRelaxed(MongoValidationLevel level, MongoValidationAction action) { + return level != MongoValidationLevel.STRICT || action != MongoValidationAction.ERROR; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/EmbeddedCollectionDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/EmbeddedCollectionDescriptor.java new file mode 100644 index 00000000..0fde15ea --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/EmbeddedCollectionDescriptor.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import java.util.Objects; + +/** + * The declared growth bound of one embedded array (design §9.1, §9.2). + * + *

An embedded array is the single most common way a MongoDB document becomes unusable: comments, + * events, history and attachments all start small and none of them stop. The failure is gradual — + * the document keeps working, then rewrites get expensive, then one day it crosses 16 MiB and the + * write fails with no incremental warning. + * + *

Declaring a bound turns that into a design-time question. {@link #unbounded()} exists so the + * answer "we do not know" can be written down and rejected, rather than expressed by omission. + */ +public record EmbeddedCollectionDescriptor( + String field, int maxElements, int estimatedElementBytes) { + + /** Sentinel for an array whose growth was never bounded. */ + public static final int UNBOUNDED = -1; + + public EmbeddedCollectionDescriptor { + Objects.requireNonNull(field, "field"); + if (maxElements == 0 || maxElements < UNBOUNDED) { + throw new IllegalArgumentException("maxElements must be positive or UNBOUNDED"); + } + if (estimatedElementBytes < 0) { + throw new IllegalArgumentException("estimatedElementBytes must not be negative"); + } + } + + /** An array with a declared element ceiling and a per-element size estimate. */ + public static EmbeddedCollectionDescriptor bounded( + String field, int maxElements, int estimatedElementBytes) { + if (maxElements <= 0) { + throw new IllegalArgumentException("a bounded embedded collection needs a positive maximum"); + } + return new EmbeddedCollectionDescriptor(field, maxElements, estimatedElementBytes); + } + + /** An array whose growth was never bounded. Always rejected by the validator. */ + public static EmbeddedCollectionDescriptor unbounded() { + return new EmbeddedCollectionDescriptor("", UNBOUNDED, 0); + } + + /** Associates this descriptor with the field name the manifest registered it under. */ + public EmbeddedCollectionDescriptor withField(String newField) { + return new EmbeddedCollectionDescriptor(newField, maxElements, estimatedElementBytes); + } + + /** True when this array has no declared ceiling. */ + public boolean isUnbounded() { + return maxElements == UNBOUNDED; + } + + /** The worst-case contribution of this array to the document size. */ + public long worstCaseBytes() { + return isUnbounded() ? Long.MAX_VALUE : (long) maxElements * estimatedElementBytes; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoBinaryFieldDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoBinaryFieldDescriptor.java new file mode 100644 index 00000000..b4eb96dd --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoBinaryFieldDescriptor.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import java.util.Objects; + +/** + * An inline binary field and its declared maximum size (design §9.3, D-14). + * + *

The platform's file source of truth is the Fileserver / Object Storage adapter, and a MongoDB + * document holds a reference to it. Small binaries — a checksum, a short token, an encrypted field + * — are legitimate inline; a payload is not, because it makes every read of the document pay for + * bytes the reader usually does not want. + */ +public record MongoBinaryFieldDescriptor(String field, int maxBytes) { + + /** The largest inline binary the platform accepts without a storage reference. */ + public static final int INLINE_CEILING_BYTES = 64 * 1024; + + public MongoBinaryFieldDescriptor { + Objects.requireNonNull(field, "field"); + if (field.isBlank()) { + throw new IllegalArgumentException("a binary field needs a name"); + } + if (maxBytes <= 0) { + throw new IllegalArgumentException("a binary field needs a positive maximum size"); + } + } + + /** True when this field exceeds what may reasonably live inside a document. */ + public boolean exceedsInlineCeiling() { + return maxBytes > INLINE_CEILING_BYTES; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelManifest.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelManifest.java new file mode 100644 index 00000000..cc6e4463 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelManifest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The machine-checkable statement of how one collection is modelled (design §9.2). + * + *

The design's embed-or-reference decision depends on facts a reviewer cannot see in the entity + * class: whether the data is always read together, whether it grows without limit, whether it is + * shared. This manifest is where those facts are written down, which is what lets {@link + * MongoDocumentModelValidator} answer "is this document bounded" with evidence rather than + * judgement. + */ +public record MongoDocumentModelManifest( + String collection, + String documentType, + DocumentSchemaVersion schemaVersion, + MongoDocumentSizeBudget sizeBudget, + int baseDocumentBytes, + Map embeddedCollections, + List references, + List binaryFields) { + + public MongoDocumentModelManifest { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(documentType, "documentType"); + Objects.requireNonNull(schemaVersion, "schemaVersion"); + Objects.requireNonNull(sizeBudget, "sizeBudget"); + Objects.requireNonNull(embeddedCollections, "embeddedCollections"); + Objects.requireNonNull(references, "references"); + Objects.requireNonNull(binaryFields, "binaryFields"); + embeddedCollections = Map.copyOf(embeddedCollections); + references = List.copyOf(references); + binaryFields = List.copyOf(binaryFields); + if (collection.isBlank()) { + throw new IllegalArgumentException("a document model manifest needs a collection name"); + } + if (baseDocumentBytes < 0) { + throw new IllegalArgumentException("baseDocumentBytes must not be negative"); + } + } + + /** Starts a manifest for a collection. */ + public static Builder builder(String collection) { + return new Builder(collection); + } + + /** + * The worst-case serialized size this model permits. + * + *

Saturates rather than overflowing: an unbounded array contributes {@link Long#MAX_VALUE}, + * and a silent wraparound would turn "infinitely large" into "comfortably small". + */ + public long worstCaseDocumentBytes() { + long total = baseDocumentBytes; + for (EmbeddedCollectionDescriptor descriptor : embeddedCollections.values()) { + long contribution = descriptor.worstCaseBytes(); + if (contribution == Long.MAX_VALUE || total > Long.MAX_VALUE - contribution) { + return Long.MAX_VALUE; + } + total += contribution; + } + for (MongoBinaryFieldDescriptor binaryField : binaryFields) { + total += binaryField.maxBytes(); + } + return total; + } + + /** Collects the manifest's parts. */ + public static final class Builder { + + private final String collection; + + private final Map embeddedCollections = + new LinkedHashMap<>(); + + private final List references = new ArrayList<>(); + + private final List binaryFields = new ArrayList<>(); + + private String documentType; + + private DocumentSchemaVersion schemaVersion = new DocumentSchemaVersion(1); + + private MongoDocumentSizeBudget sizeBudget = MongoDocumentSizeBudget.standard(); + + private int baseDocumentBytes = 1024; + + private Builder(String collection) { + this.collection = Objects.requireNonNull(collection, "collection"); + this.documentType = collection; + } + + /** The stable document type written into the collection's type metadata. */ + public Builder documentType(String value) { + this.documentType = Objects.requireNonNull(value, "documentType"); + return this; + } + + /** The schema version new writes stamp. */ + public Builder schemaVersion(int value) { + this.schemaVersion = new DocumentSchemaVersion(value); + return this; + } + + /** The project ceiling for one document of this collection. */ + public Builder sizeBudget(long maxDocumentBytes) { + this.sizeBudget = new MongoDocumentSizeBudget(maxDocumentBytes); + return this; + } + + /** Estimated size of the document excluding embedded arrays and binary fields. */ + public Builder baseDocumentBytes(int value) { + this.baseDocumentBytes = value; + return this; + } + + /** Declares an embedded array under the given field. */ + public Builder embedded(String field, EmbeddedCollectionDescriptor descriptor) { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(descriptor, "descriptor"); + embeddedCollections.put(field, descriptor.withField(field)); + return this; + } + + /** Declares a manual reference to another collection. */ + public Builder reference(MongoReferenceDescriptor descriptor) { + references.add(Objects.requireNonNull(descriptor, "descriptor")); + return this; + } + + /** Declares an inline binary field. */ + public Builder binaryField(MongoBinaryFieldDescriptor descriptor) { + binaryFields.add(Objects.requireNonNull(descriptor, "descriptor")); + return this; + } + + /** Builds the immutable manifest. */ + public MongoDocumentModelManifest build() { + return new MongoDocumentModelManifest( + collection, + documentType, + schemaVersion, + sizeBudget, + baseDocumentBytes, + embeddedCollections, + references, + binaryFields); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidator.java new file mode 100644 index 00000000..e186785b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidator.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Rejects document models that cannot stay bounded (design §9.2). + * + *

Every rule here corresponds to a way a MongoDB collection becomes unmaintainable slowly enough + * that nobody notices until it is expensive to fix: an array that grows with traffic, a document + * budget set so close to the server limit that there is no time to react, a binary payload stored + * inline, a reference nobody can validate. + * + *

All violations are collected before throwing. A modelling review that surfaces one problem per + * run turns a five-minute fix into five rounds. + */ +public final class MongoDocumentModelValidator { + + /** + * Validates one manifest. + * + * @throws IllegalArgumentException listing every violation found + */ + public void validate(MongoDocumentModelManifest manifest) { + Objects.requireNonNull(manifest, "manifest"); + List violations = new ArrayList<>(); + + for (Map.Entry entry : + manifest.embeddedCollections().entrySet()) { + EmbeddedCollectionDescriptor descriptor = entry.getValue(); + if (descriptor.isUnbounded()) { + violations.add( + "embedded collection '" + + entry.getKey() + + "' declares no maximum element count; an array that grows with traffic must be a " + + "separate collection or a bounded bucket"); + } else if (descriptor.estimatedElementBytes() <= 0) { + violations.add( + "embedded collection '" + + entry.getKey() + + "' declares no per-element size estimate, so its contribution to the document " + + "budget cannot be checked"); + } + } + + long worstCase = manifest.worstCaseDocumentBytes(); + if (!manifest.sizeBudget().accommodates(worstCase)) { + violations.add( + "the worst-case document is " + + (worstCase == Long.MAX_VALUE ? "unbounded" : worstCase + " bytes") + + ", above the declared budget of " + + manifest.sizeBudget().maxDocumentBytes() + + " bytes"); + } + + for (MongoBinaryFieldDescriptor binaryField : manifest.binaryFields()) { + if (binaryField.exceedsInlineCeiling()) { + violations.add( + "binary field '" + + binaryField.field() + + "' declares up to " + + binaryField.maxBytes() + + " bytes inline; store the bytes through the Fileserver / Object Storage adapter " + + "and keep only a FileId or ContentKey reference in the document"); + } + } + + for (MongoReferenceDescriptor reference : manifest.references()) { + if (reference.required() && reference.lifecycle() == MongoReferenceLifecycle.WEAK) { + violations.add( + "reference '" + + reference.field() + + "' is required but declared WEAK; a required reference that may dangle has no " + + "defined read behaviour"); + } + } + + if (!violations.isEmpty()) { + throw new IllegalArgumentException( + "invalid MongoDB document model for collection '" + + manifest.collection() + + "': " + + String.join("; ", violations)); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentSizeBudget.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentSizeBudget.java new file mode 100644 index 00000000..8c91b34f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentSizeBudget.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +/** + * The project's own document size ceiling, well below MongoDB's (design §9.2). + * + *

MongoDB rejects a document at 16 MiB. Budgeting to that number leaves no room to notice a + * problem: the write that fails is the first symptom, and by then the collection already holds + * documents that are almost as large. The platform ceiling is a quarter of the hard limit so a + * growing document trips a build-time budget check long before it trips the server. + */ +public record MongoDocumentSizeBudget(long maxDocumentBytes) { + + /** MongoDB's hard BSON document limit. */ + public static final long MONGODB_HARD_LIMIT_BYTES = 16L * 1024 * 1024; + + /** The platform's ceiling: a quarter of the hard limit, leaving room to react. */ + public static final long PLATFORM_CEILING_BYTES = 4L * 1024 * 1024; + + /** The design's worked example: 2 MiB. */ + public static final long DEFAULT_BYTES = 2L * 1024 * 1024; + + public MongoDocumentSizeBudget { + if (maxDocumentBytes <= 0) { + throw new IllegalArgumentException("a document size budget must be positive"); + } + if (maxDocumentBytes > PLATFORM_CEILING_BYTES) { + throw new IllegalArgumentException( + "a document size budget of " + + maxDocumentBytes + + " bytes is above the platform ceiling of " + + PLATFORM_CEILING_BYTES + + " bytes; sizing close to MongoDB's " + + MONGODB_HARD_LIMIT_BYTES + + " byte limit leaves no margin to react before writes start failing"); + } + } + + /** The platform default budget. */ + public static MongoDocumentSizeBudget standard() { + return new MongoDocumentSizeBudget(DEFAULT_BYTES); + } + + /** True when an estimated document size fits inside this budget. */ + public boolean accommodates(long estimatedBytes) { + return estimatedBytes <= maxDocumentBytes; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceDescriptor.java new file mode 100644 index 00000000..cf1dd7ad --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceDescriptor.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import java.util.Objects; + +/** + * A manual reference from one collection to another (design §9.1, §9.2). + * + *

The target collection and the lifecycle are both mandatory. A reference without a named target + * cannot be validated, and a reference without a lifecycle leaves "what happens when the target is + * deleted" to whoever writes the next delete path. + */ +public record MongoReferenceDescriptor( + String field, String targetCollection, boolean required, MongoReferenceLifecycle lifecycle) { + + public MongoReferenceDescriptor { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(targetCollection, "targetCollection"); + Objects.requireNonNull(lifecycle, "lifecycle"); + if (field.isBlank()) { + throw new IllegalArgumentException("a reference needs a field name"); + } + if (targetCollection.isBlank()) { + throw new IllegalArgumentException( + "reference '" + field + "' must name the collection it points at"); + } + } + + /** A required reference to a document with an independent lifecycle. */ + public static MongoReferenceDescriptor required(String field, String targetCollection) { + return new MongoReferenceDescriptor( + field, targetCollection, true, MongoReferenceLifecycle.INDEPENDENT); + } + + /** An optional reference to a document with an independent lifecycle. */ + public static MongoReferenceDescriptor optional(String field, String targetCollection) { + return new MongoReferenceDescriptor( + field, targetCollection, false, MongoReferenceLifecycle.INDEPENDENT); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceLifecycle.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceLifecycle.java new file mode 100644 index 00000000..6c589c88 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoReferenceLifecycle.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +/** + * Who owns the lifecycle of a referenced document (design §9.1). + * + *

MongoDB has no foreign keys, so a manual reference is only as safe as the lifecycle rule + * written next to it. Recording that rule is what makes a dangling reference a known state with a + * defined repair rather than a surprise at read time. + */ +public enum MongoReferenceLifecycle { + + /** The referenced document outlives this one; the reference may be read at any time. */ + INDEPENDENT, + + /** This document owns the referenced one and must delete it when it is deleted. */ + OWNED, + + /** The reference may dangle; readers must handle a missing target explicitly. */ + WEAK +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoExpirationAccessPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoExpirationAccessPolicy.java new file mode 100644 index 00000000..786b5e19 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoExpirationAccessPolicy.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.ttl; + +import java.util.Objects; + +/** + * The query-time predicate that decides whether an expired document is visible (design §21.1). + * + *

This is the half of TTL that actually provides a guarantee. The index reclaims space + * eventually; the predicate is what makes an expired document invisible at the instant it expires, + * regardless of when the TTL monitor gets to it. + * + *

The comparison uses the application's clock rather than the server's, so a read is evaluated + * against the same notion of "now" the business logic used, and so tests can control it. + */ +public record MongoExpirationAccessPolicy(String expiryField, boolean applied) { + + public MongoExpirationAccessPolicy { + Objects.requireNonNull(expiryField, "expiryField"); + if (expiryField.isBlank()) { + throw new IllegalArgumentException("an expiration access policy needs an expiry field"); + } + } + + /** Reads filter on {@code expiryField > applicationNow}. */ + public static MongoExpirationAccessPolicy filtering(String expiryField) { + return new MongoExpirationAccessPolicy(expiryField, true); + } + + /** Reads do not filter; visibility depends entirely on the TTL monitor's timing. */ + public static MongoExpirationAccessPolicy none(String expiryField) { + return new MongoExpirationAccessPolicy(expiryField, false); + } + + /** A human-readable rendering of the predicate reads must carry. */ + public String describePredicate() { + return expiryField + " > applicationNow"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlIndexDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlIndexDescriptor.java new file mode 100644 index 00000000..2bee1ae5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlIndexDescriptor.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.ttl; + +import java.time.Duration; +import java.util.Objects; + +/** + * A TTL index and the BSON type of the field it expires on (design §21.1). + * + *

MongoDB only expires documents whose TTL field holds a BSON date, or an array of them. A field + * stored as a string or an epoch number is ignored silently: the index exists, the monitor runs, + * and nothing is ever deleted. Recording the field's BSON type is what turns that silence into a + * validation failure. + */ +public record MongoTtlIndexDescriptor( + String collection, String indexName, MongoTtlPolicy policy, String expiryFieldBsonType) { + + /** The only BSON type MongoDB's TTL monitor acts on. */ + public static final String BSON_DATE = "date"; + + public MongoTtlIndexDescriptor { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(indexName, "indexName"); + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(expiryFieldBsonType, "expiryFieldBsonType"); + } + + /** A TTL index over a BSON date field. */ + public static MongoTtlIndexDescriptor onDateField( + String collection, String indexName, String field, Duration retention) { + return new MongoTtlIndexDescriptor( + collection, indexName, MongoTtlPolicy.physicalCleanup(field, retention), BSON_DATE); + } + + /** True when the declared field type is one the TTL monitor will actually expire. */ + public boolean expiryFieldTypeIsSupported() { + return BSON_DATE.equals(expiryFieldBsonType); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicy.java new file mode 100644 index 00000000..d554d62f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicy.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.ttl; + +import java.time.Duration; +import java.util.Objects; + +/** + * What a TTL index is being used for (design §21.1, D-13). + * + *

MongoDB's TTL monitor runs roughly once a minute and deletes in batches, so an expired + * document stays readable for an unspecified interval after its expiry — longer under load, longer + * still on a busy secondary. That is fine for reclaiming space and wrong for anything that must + * stop being visible at a particular moment. + * + *

Both booleans are therefore part of the declaration. {@code physicalCleanupOnly} says the TTL + * is only reclaiming space; {@code queryChecksLogicalExpiry} says the application filters on {@code + * expiresAt > now} so visibility does not depend on the monitor's timing. + */ +public record MongoTtlPolicy( + String field, + Duration retention, + boolean physicalCleanupOnly, + boolean queryChecksLogicalExpiry) { + + public MongoTtlPolicy { + Objects.requireNonNull(field, "field"); + Objects.requireNonNull(retention, "retention"); + if (field.isBlank()) { + throw new IllegalArgumentException("a TTL policy needs an expiry field"); + } + if (retention.isNegative()) { + throw new IllegalArgumentException("a TTL retention must not be negative"); + } + } + + /** The supported shape: physical cleanup, with the application filtering on logical expiry. */ + public static MongoTtlPolicy physicalCleanup(String field, Duration retention) { + return new MongoTtlPolicy(field, retention, true, true); + } + + /** + * The unsupported shape: a TTL index used as a business scheduler. + * + *

Constructible so the validator can name and reject it. A policy that cannot be expressed + * cannot be explained, and this misuse is common enough to deserve a specific error rather than a + * generic one. + */ + public static MongoTtlPolicy exactBusinessTransition(String field) { + return new MongoTtlPolicy(field, Duration.ZERO, false, false); + } + + /** True when this policy claims a guarantee MongoDB's TTL monitor does not provide. */ + public boolean claimsExactExpiry() { + return !physicalCleanupOnly; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidator.java new file mode 100644 index 00000000..5bf37530 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidator.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.ttl; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Duration; +import java.util.Objects; + +/** + * Rejects the two ways a TTL index is misused (design §21.1, D-13). + * + *

The first is treating it as a scheduler: the TTL monitor's sweep interval is unspecified and + * load-dependent, so "delete this at 09:00" is not something it promises. The second is relying on + * it for access control: a document that still exists is still readable, so if expiry must hide + * data the read has to say so. + */ +public final class MongoTtlPolicyValidator { + + /** + * A TTL reduction below this threshold would expire a large population in one sweep, turning a + * configuration change into an unplanned bulk delete. + */ + public static final Duration MINIMUM_SAFE_RETENTION = Duration.ofMinutes(1); + + /** + * Validates one TTL policy. + * + * @throws MongoOperationRejectedException when the policy claims a guarantee TTL does not provide + */ + public void validate(MongoTtlPolicy policy) { + Objects.requireNonNull(policy, "policy"); + if (policy.claimsExactExpiry()) { + throw MongoOperationRejectedException.of( + "ttl.policy", + "the TTL policy on '" + + policy.field() + + "' claims an exact business transition; MongoDB's TTL monitor sweeps on an " + + "unspecified interval, so an exact transition needs its own scheduler and the TTL " + + "index stays physical cleanup"); + } + if (!policy.queryChecksLogicalExpiry()) { + throw MongoOperationRejectedException.of( + "ttl.policy", + "the TTL policy on '" + + policy.field() + + "' does not declare a query-time expiry check; an expired document remains readable " + + "until the monitor deletes it, so reads must filter on " + + policy.field() + + " > applicationNow"); + } + if (!policy.retention().isZero() && policy.retention().compareTo(MINIMUM_SAFE_RETENTION) < 0) { + throw MongoOperationRejectedException.of( + "ttl.policy", + "a TTL retention of " + + policy.retention() + + " is below the safe minimum of " + + MINIMUM_SAFE_RETENTION + + "; it would expire the existing population in a single sweep"); + } + } + + /** + * Validates a TTL index descriptor, including the BSON type of its expiry field. + * + * @throws MongoOperationRejectedException when the field type cannot be expired + */ + public void validate(MongoTtlIndexDescriptor descriptor) { + Objects.requireNonNull(descriptor, "descriptor"); + validate(descriptor.policy()); + if (!descriptor.expiryFieldTypeIsSupported()) { + throw MongoOperationRejectedException.of( + "ttl.policy", + "TTL index '" + + descriptor.indexName() + + "' expires on a field stored as " + + descriptor.expiryFieldBsonType() + + "; MongoDB only expires BSON date fields and ignores the rest silently"); + } + } + + /** + * Validates the read-side predicate that gives expiry its actual guarantee. + * + * @throws MongoOperationRejectedException when reads do not filter on logical expiry + */ + public void validate(MongoExpirationAccessPolicy accessPolicy) { + Objects.requireNonNull(accessPolicy, "accessPolicy"); + if (!accessPolicy.applied()) { + throw MongoOperationRejectedException.of( + "ttl.access", + "reads of this collection do not apply '" + + accessPolicy.describePredicate() + + "', so an expired document stays visible until the TTL monitor happens to delete it"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationAction.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationAction.java new file mode 100644 index 00000000..9bac3235 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationAction.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +/** + * What the server does when a document fails validation (design §12.1, §4). + * + *

{@code ERROR_AND_LOG} is declared so it can be rejected. It is not part of the + * MongoDB 7.0 / 8.0 Stable contract this platform certifies, and a deployment that configures it + * would get behaviour that varies by server build. Modelling it as an unsupported constant makes + * that a startup failure with a clear message instead of a silent server-side difference. + */ +public enum MongoValidationAction { + + /** Reject the write. The target state for every collection. */ + ERROR, + + /** Accept the write and log the violation. Valid only inside an explicit migration window. */ + WARN, + + /** Not part of the MongoDB 7.0 / 8.0 Stable contract; always rejected by the apply policy. */ + ERROR_AND_LOG; + + /** True when this action is part of the certified Stable contract. */ + public boolean supportedOnStableLane() { + return this != ERROR_AND_LOG; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationLevel.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationLevel.java new file mode 100644 index 00000000..8d305f83 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidationLevel.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +/** + * Which documents a collection validator applies to (design §12.1). + * + *

{@code MODERATE} exists for one situation only: a legacy collection whose existing documents + * would fail the new validator. It applies the rule to inserts and to updates of already-valid + * documents, which lets a backfill run without the validator blocking the very writes that fix the + * data. + */ +public enum MongoValidationLevel { + + /** No validation. Only meaningful while a validator is being removed. */ + OFF, + + /** Validate inserts and updates of documents that already satisfy the validator. */ + MODERATE, + + /** Validate every insert and update. The target state for every collection. */ + STRICT +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicy.java new file mode 100644 index 00000000..69aa6af9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicy.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest; +import java.time.Instant; +import java.util.Objects; +import java.util.Set; + +/** + * What validator settings a given server version accepts, and who may apply them (design §12.1, + * §4). + * + *

{@code errorAndLog} is rejected on both certified lanes. It is not part of the MongoDB 7.0 / + * 8.0 Stable contract this platform certifies, so a deployment that configured it would be relying + * on behaviour that varies by server build — which is exactly what a certification lane exists to + * prevent. + * + *

The runtime application credential can read a validator and report drift but cannot call + * {@code collMod}. Validator changes rewrite the acceptance rules for every future write, so they + * go through the D4 admin plane with precondition and postcondition evidence. + */ +public final class MongoValidatorApplyPolicy { + + /** Server versions this platform certifies. */ + private static final Set CERTIFIED_SERVER_VERSIONS = Set.of("7.0", "8.0"); + + private final String serverVersion; + + private MongoValidatorApplyPolicy(String serverVersion) { + this.serverVersion = serverVersion; + } + + /** The policy for a given server version. */ + public static MongoValidatorApplyPolicy forServer(String serverVersion) { + return new MongoValidatorApplyPolicy(Objects.requireNonNull(serverVersion, "serverVersion")); + } + + /** The server version this policy was built for. */ + public String serverVersion() { + return serverVersion; + } + + /** True when this policy targets a version the platform certifies. */ + public boolean certifiedLane() { + return CERTIFIED_SERVER_VERSIONS.contains(serverVersion); + } + + /** + * Rejects a validation action the certified lanes do not support. + * + * @throws UnsupportedOperationException for {@code ERROR_AND_LOG} + */ + public void validate(MongoValidationAction action) { + Objects.requireNonNull(action, "action"); + if (!action.supportedOnStableLane()) { + throw new UnsupportedOperationException( + "validationAction " + + action + + " is not part of the MongoDB " + + serverVersion + + " Stable contract; the certified actions are ERROR and WARN"); + } + } + + /** + * Validates a whole schema manifest before it is applied. + * + * @throws UnsupportedOperationException when the action is uncertified + * @throws IllegalStateException when a relaxed validator has outlived its migration window + */ + public void validate(MongoSchemaManifest manifest, Instant now) { + Objects.requireNonNull(manifest, "manifest"); + Objects.requireNonNull(now, "now"); + validate(manifest.validationAction()); + if (manifest.migrationWindowExpired(now)) { + throw new IllegalStateException( + "the relaxed validator for this collection outlived its migration window, which expired at " + + manifest.migrationWindowExpiresAt() + + "; a permanent MODERATE/WARN validator documents an intention and enforces nothing"); + } + } + + /** True when a runtime application credential may apply this change. Always false. */ + public boolean runtimeMayApply() { + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDescriptor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDescriptor.java new file mode 100644 index 00000000..56cfc5a4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDescriptor.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +import java.util.Objects; + +/** + * A collection validator as it exists on the server (design §12.1). + * + *

The schema itself is carried as a canonical extended-JSON string so a diff compares text + * rather than two parsed trees whose key order happens to differ. Key order is not semantically + * meaningful in a JSON Schema, and a diff that reports it as a change trains people to ignore the + * diff. + */ +public record MongoValidatorDescriptor( + String collection, + String canonicalSchemaJson, + MongoValidationLevel level, + MongoValidationAction action) { + + public MongoValidatorDescriptor { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(canonicalSchemaJson, "canonicalSchemaJson"); + Objects.requireNonNull(level, "level"); + Objects.requireNonNull(action, "action"); + } + + /** A collection with no validator at all. */ + public static MongoValidatorDescriptor absent(String collection) { + return new MongoValidatorDescriptor( + collection, "", MongoValidationLevel.OFF, MongoValidationAction.ERROR); + } + + /** True when the server currently enforces nothing. */ + public boolean isAbsent() { + return canonicalSchemaJson.isEmpty() || level == MongoValidationLevel.OFF; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiff.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiff.java new file mode 100644 index 00000000..03b2f002 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiff.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +import java.util.List; +import java.util.Objects; + +/** + * The difference between a declared and an observed validator (design §12.1). + * + *

{@code requiresAdminApply} is the field callers act on: a runtime that finds drift reports it + * and stops, because changing a validator changes the acceptance rules for every subsequent write + * and is therefore an admin-plane operation with its own evidence. + */ +public record MongoValidatorDiff( + String collection, List differences, boolean requiresAdminApply) { + + public MongoValidatorDiff { + Objects.requireNonNull(collection, "collection"); + Objects.requireNonNull(differences, "differences"); + differences = List.copyOf(differences); + } + + /** No drift. */ + public static MongoValidatorDiff clean(String collection) { + return new MongoValidatorDiff(collection, List.of(), false); + } + + /** True when declared and observed validators already agree. */ + public boolean isClean() { + return differences.isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiffEngine.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiffEngine.java new file mode 100644 index 00000000..34ac7d74 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorDiffEngine.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Compares a declared validator with the one the server is enforcing (design §12.1). + * + *

Reports rather than repairs, for the same reason the index engine does: a validator change + * takes effect for every future write on the collection, so it needs a review and an admin + * credential rather than a startup routine. + */ +public final class MongoValidatorDiffEngine { + + /** Compares one collection's declared validator against the observed one. */ + public MongoValidatorDiff compare( + MongoSchemaManifest declared, + MongoValidatorDescriptor observed, + String canonicalDeclaredJson) { + Objects.requireNonNull(declared, "declared"); + Objects.requireNonNull(observed, "observed"); + Objects.requireNonNull(canonicalDeclaredJson, "canonicalDeclaredJson"); + + List differences = new ArrayList<>(); + if (observed.isAbsent()) { + differences.add("no validator is installed on the server"); + } else if (!observed.canonicalSchemaJson().equals(canonicalDeclaredJson)) { + differences.add("the installed JSON Schema differs from the declared one"); + } + if (observed.level() != declared.validationLevel()) { + differences.add( + "validationLevel is " + + observed.level() + + " but " + + declared.validationLevel() + + " is declared"); + } + if (observed.action() != declared.validationAction()) { + differences.add( + "validationAction is " + + observed.action() + + " but " + + declared.validationAction() + + " is declared"); + } + return new MongoValidatorDiff(observed.collection(), differences, !differences.isEmpty()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialReference.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialReference.java new file mode 100644 index 00000000..dda56066 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialReference.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * A pointer to a credential, never the credential (design §26, §28). + * + *

The design forbids a connection string with an embedded credential in configuration. This type + * is how that is enforced structurally: it holds a secret reference such as {@code + * secret://mongodb/default-uri}, and there is no field a password could occupy. + * + *

The fingerprint exists for one specific check — that the runtime and admin clients are not + * using the same credential — which needs to compare two credentials without ever holding either. + */ +public record MongoCredentialReference(String secretReference, MongoPrincipalRole role) { + + /** The scheme a secret reference must use. */ + public static final String SECRET_SCHEME = "secret://"; + + public MongoCredentialReference { + Objects.requireNonNull(secretReference, "secretReference"); + Objects.requireNonNull(role, "role"); + if (!secretReference.startsWith(SECRET_SCHEME)) { + throw new IllegalArgumentException( + "a MongoDB credential must be a '" + + SECRET_SCHEME + + "' reference, not an inline value: the platform never holds a password or a " + + "connection string containing one"); + } + if (secretReference.indexOf('@') >= 0 + || secretReference.indexOf(':', SECRET_SCHEME.length()) >= 0) { + throw new IllegalArgumentException( + "the secret reference looks like a connection string with embedded credentials"); + } + } + + /** A stable, non-reversible identity for this credential. */ + public String fingerprint() { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = + digest.digest((role.name() + '|' + secretReference).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash).substring(0, 16); + } catch (NoSuchAlgorithmException unavailable) { + throw new IllegalStateException("SHA-256 is required to fingerprint credentials"); + } + } + + /** True when two references point at the same credential. */ + public boolean sameCredentialAs(MongoCredentialReference other) { + return fingerprint().equals(Objects.requireNonNull(other, "other").fingerprint()); + } + + @Override + public String toString() { + return "MongoCredentialReference[role=" + role + ", fingerprint=" + fingerprint() + "]"; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialRotationPolicy.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialRotationPolicy.java new file mode 100644 index 00000000..07772c45 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoCredentialRotationPolicy.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import java.time.Duration; +import java.util.Objects; + +/** + * How a credential rotation reaches a running application (design §26, §42). + * + *

Rotation creates a new client generation and drains the old one rather than swapping the + * credential in place. Swapping in place would break every connection already checked out of the + * pool, so a rotation would show up as a burst of failures on live requests — which is why + * rotations get postponed, and why credentials end up long-lived. + */ +public record MongoCredentialRotationPolicy(Duration drainTimeout, boolean failClosedOnOverlap) { + + public MongoCredentialRotationPolicy { + Objects.requireNonNull(drainTimeout, "drainTimeout"); + if (drainTimeout.isNegative()) { + throw new IllegalArgumentException("a drain timeout must not be negative"); + } + } + + /** The platform default: drain the previous generation for 30 seconds. */ + public static MongoCredentialRotationPolicy standard() { + return new MongoCredentialRotationPolicy(Duration.ofSeconds(30), true); + } + + /** + * Checks a rotation before it is applied. + * + * @throws IllegalArgumentException when the new credential is the one already in use, or belongs + * to a different principal + */ + public void validateRotation( + MongoCredentialReference current, MongoCredentialReference replacement) { + Objects.requireNonNull(current, "current"); + Objects.requireNonNull(replacement, "replacement"); + if (current.sameCredentialAs(replacement)) { + throw new IllegalArgumentException( + "the replacement credential is the one already in use; nothing would be rotated"); + } + if (current.role() != replacement.role()) { + throw new IllegalArgumentException( + "a rotation must not change the principal: " + + current.role() + + " cannot be rotated to " + + replacement.role()); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoPrincipalRole.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoPrincipalRole.java new file mode 100644 index 00000000..97e58d6d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoPrincipalRole.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import java.util.Set; + +/** + * The separate principals a deployment uses (design §26). + * + *

Separate credentials rather than one credential with every privilege, because the blast radius + * of a leaked credential is exactly the set of privileges it carries. A change stream consumer that + * can drop a database is a change stream consumer whose compromise is a data-loss incident. + */ +public enum MongoPrincipalRole { + + /** Reads application collections. */ + APP_READ, + + /** Reads and writes application collections. */ + APP_WRITE, + + /** Watches change streams. */ + CHANGE_STREAM, + + /** Applies migrations: collections, validators, indexes, backfills. */ + MIGRATION, + + /** Manages search and vector indexes. */ + SEARCH_ADMIN, + + /** Manages sharding: shardCollection, refine, reshard, balancer, zones. */ + SHARD_ADMIN, + + /** Manages encryption keys and encrypted collection setup. */ + ENCRYPTION_ADMIN, + + /** Full database administration. Never used by an application runtime. */ + DBA; + + /** The roles an ordinary application runtime may use. */ + public static Set runtimeRoles() { + return Set.of(APP_READ, APP_WRITE, CHANGE_STREAM); + } + + /** True when this role belongs to the runtime rather than to an operator or a job. */ + public boolean usableByApplicationRuntime() { + return runtimeRoles().contains(this); + } + + /** Privileges a runtime principal must not hold. */ + public static Set forbiddenRuntimePrivileges() { + return Set.of( + "dropDatabase", + "dropCollection", + "createUser", + "dropUser", + "grantRole", + "revokeRole", + "enableSharding", + "shardCollection", + "reshardCollection", + "collMod", + "repairDatabase"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfile.java new file mode 100644 index 00000000..ff75d970 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfile.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import java.util.Objects; +import java.util.Set; + +/** + * The security posture of one configured client (design §26, §28). + * + *

The production flag is part of the value rather than an ambient environment lookup, so the + * validator can decide without consulting anything global — and so a test can construct the + * production posture and assert that it is refused. + */ +public record MongoSecurityProfile( + boolean production, + boolean tlsRequired, + boolean authenticationRequired, + MongoCredentialReference credential, + Set grantedPrivileges) { + + public MongoSecurityProfile { + Objects.requireNonNull(credential, "credential"); + Objects.requireNonNull(grantedPrivileges, "grantedPrivileges"); + grantedPrivileges = Set.copyOf(grantedPrivileges); + } + + /** A production profile with the given TLS and authentication settings. */ + public static MongoSecurityProfile production( + boolean tlsRequired, boolean authenticationRequired) { + return new MongoSecurityProfile( + true, + tlsRequired, + authenticationRequired, + new MongoCredentialReference("secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE), + Set.of()); + } + + /** A production profile for a specific principal. */ + public static MongoSecurityProfile production( + MongoCredentialReference credential, Set grantedPrivileges) { + return new MongoSecurityProfile(true, true, true, credential, grantedPrivileges); + } + + /** A local profile, where TLS and authentication may legitimately be off. */ + public static MongoSecurityProfile local(MongoCredentialReference credential) { + return new MongoSecurityProfile(false, false, false, credential, Set.of()); + } + + /** The privileges this principal holds that a runtime must never have. */ + public Set forbiddenPrivilegesHeld() { + return grantedPrivileges.stream() + .filter(MongoPrincipalRole.forbiddenRuntimePrivileges()::contains) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidator.java new file mode 100644 index 00000000..5966a708 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidator.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; + +/** + * Fails a production deployment closed (design §26, §28). + * + *

Every check here is a startup failure rather than a warning. A warning about disabled TLS is + * indistinguishable from the hundred other lines a service logs at boot, and the failure mode it + * describes — credentials and documents on the wire in plaintext — is not one that announces itself + * later. + */ +public final class MongoSecurityProfileValidator { + + /** + * Validates one client's posture. + * + * @throws MongoOperationRejectedException naming the first violated requirement + */ + public void validate(MongoSecurityProfile profile) { + Objects.requireNonNull(profile, "profile"); + if (!profile.production()) { + return; + } + if (!profile.tlsRequired()) { + throw MongoOperationRejectedException.of( + "security.tls", + "TLS is disabled on a production MongoDB profile; credentials and documents would travel " + + "in plaintext"); + } + if (!profile.authenticationRequired()) { + throw MongoOperationRejectedException.of( + "security.auth", + "authentication is disabled on a production MongoDB profile; every network peer would " + + "have full access"); + } + if (profile.credential().role() == MongoPrincipalRole.DBA) { + throw MongoOperationRejectedException.of( + "security.principal", + "a production runtime must not use the DBA principal; the runtime roles are " + + MongoPrincipalRole.runtimeRoles()); + } + if (!profile.forbiddenPrivilegesHeld().isEmpty()) { + throw MongoOperationRejectedException.of( + "security.privilege", + "the runtime principal holds administrative privileges it must not have: " + + profile.forbiddenPrivilegesHeld()); + } + } + + /** + * Rejects a deployment where the runtime and admin clients share a credential. + * + * @throws MongoOperationRejectedException when the two fingerprints match + */ + public void requireDistinctCredentials( + MongoCredentialReference runtime, MongoCredentialReference admin) { + Objects.requireNonNull(runtime, "runtime"); + Objects.requireNonNull(admin, "admin"); + if (runtime.sameCredentialAs(admin)) { + throw MongoOperationRejectedException.of( + "security.principal", + "the runtime and admin MongoDB clients share credential " + + runtime.fingerprint() + + "; separating the planes means nothing if one credential opens both"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditRecord.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditRecord.java new file mode 100644 index 00000000..7efb8a5b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuditRecord.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +import java.time.Instant; +import java.util.Objects; + +/** + * The record every admin-plane operation leaves behind (design §5, §39). + * + *

Operator and reason are mandatory strings. An audit trail whose reason field is optional is an + * audit trail whose reason field is empty, and the question it exists to answer — why was this + * collection dropped — is only ever asked afterwards. + */ +public record MongoAdminAuditRecord( + MongoAdminOperation operation, + String target, + String operator, + String reason, + boolean dryRun, + Instant requestedAt) { + + public MongoAdminAuditRecord { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(operator, "operator"); + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(requestedAt, "requestedAt"); + if (operator.isBlank()) { + throw new IllegalArgumentException("an admin operation must name its operator"); + } + if (reason.isBlank()) { + throw new IllegalArgumentException("an admin operation must state its reason"); + } + } + + /** A dry run: everything is validated and nothing is applied. */ + public static MongoAdminAuditRecord dryRun( + MongoAdminOperation operation, String target, String operator, String reason, Instant now) { + return new MongoAdminAuditRecord(operation, target, operator, reason, true, now); + } + + /** A real application. */ + public static MongoAdminAuditRecord applied( + MongoAdminOperation operation, String target, String operator, String reason, Instant now) { + return new MongoAdminAuditRecord(operation, target, operator, reason, false, now); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuthorization.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuthorization.java new file mode 100644 index 00000000..365d9d2a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminAuthorization.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; +import java.util.Set; + +/** + * Who approved a high-risk admin operation (design §5, §39). + * + *

High-risk operations need a named approver in addition to a named operator, because the two + * are different questions: who is running it, and who agreed it should be run. A reshard executed + * by the person who proposed it, alone, at 2am, is the shape of incident this separation is for. + */ +public record MongoAdminAuthorization( + Set permitted, String approver, boolean dryRunCompleted) { + + public MongoAdminAuthorization { + Objects.requireNonNull(permitted, "permitted"); + Objects.requireNonNull(approver, "approver"); + permitted = Set.copyOf(permitted); + } + + /** Authorization for operations that are not high risk. */ + public static MongoAdminAuthorization routine(Set permitted) { + return new MongoAdminAuthorization(permitted, "", false); + } + + /** Authorization for a high-risk operation, naming the approver and the completed dry run. */ + public static MongoAdminAuthorization approved( + Set permitted, String approver) { + if (approver.isBlank()) { + throw new IllegalArgumentException("a high-risk admin authorization must name its approver"); + } + return new MongoAdminAuthorization(permitted, approver, true); + } + + /** + * Checks one operation against this authorization. + * + * @throws MongoOperationRejectedException when the operation is not permitted, or is high risk + * without an approver and a completed dry run + */ + public void require(MongoAdminOperation operation) { + Objects.requireNonNull(operation, "operation"); + if (!permitted.contains(operation)) { + throw MongoOperationRejectedException.of( + "admin.authorization", "admin operation " + operation + " is not authorized"); + } + if (operation.highRisk() && (approver.isBlank() || !dryRunCompleted)) { + throw MongoOperationRejectedException.of( + "admin.authorization", + "admin operation " + + operation + + " destroys data or rewrites a collection; it needs a named approver and a " + + "completed dry run"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminGateway.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminGateway.java new file mode 100644 index 00000000..9d58e08e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminGateway.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Clock; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; + +/** + * The D4 administrative plane (design §5, §39). + * + *

Guard, authorization and audit run in that order before anything executes, and the audit + * record is written whether the operation succeeded or not — a failed drop is exactly as + * interesting to an investigation as a successful one. + * + *

Never auto-configured. The starter does not register this bean; a migration or deployment job + * constructs it explicitly, with its own credential. + */ +public final class MongoAdminGateway { + + private final MongoAdminRuntimeGuard guard; + + private final MongoAdminAuthorization authorization; + + private final Consumer auditSink; + + private final Clock clock; + + public MongoAdminGateway( + MongoAdminRuntimeGuard guard, + MongoAdminAuthorization authorization, + Consumer auditSink, + Clock clock) { + this.guard = Objects.requireNonNull(guard, "guard"); + this.authorization = Objects.requireNonNull(authorization, "authorization"); + this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); + this.clock = Objects.requireNonNull(clock, "clock"); + guard.validate(); + } + + /** + * Runs one administrative operation. + * + * @param operation what is being done + * @param target the collection, index or database it is being done to + * @param operator who is running it + * @param reason why + * @param body the work + * @throws MongoOperationRejectedException when the operation is not authorized + */ + public T execute( + MongoAdminOperation operation, + String target, + String operator, + String reason, + Supplier body) { + Objects.requireNonNull(body, "body"); + authorization.require(operation); + auditSink.accept( + MongoAdminAuditRecord.applied(operation, target, operator, reason, clock.instant())); + return body.get(); + } + + /** + * Validates an operation and records the intent without executing it. + * + *

The dry run is a prerequisite for every high-risk operation, so it has to be a first-class + * call rather than a flag somebody remembers to pass. + */ + public void dryRun(MongoAdminOperation operation, String target, String operator, String reason) { + authorization.require(operation); + auditSink.accept( + MongoAdminAuditRecord.dryRun(operation, target, operator, reason, clock.instant())); + } + + /** The guard this gateway was constructed under. */ + public MongoAdminRuntimeGuard guard() { + return guard; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminOperation.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminOperation.java new file mode 100644 index 00000000..bb4c1d80 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminOperation.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +/** + * The administrative operations the D4 plane can perform (design §5, §22). + * + *

A closed enum, and every constant is something no application runtime should be able to reach. + * Grouping them here makes the boundary a list somebody can review rather than an emergent property + * of which methods happen to exist. + */ +public enum MongoAdminOperation { + + /** Create a collection, with or without a validator. */ + CREATE_COLLECTION(false), + + /** Change a collection's validator or options. */ + COLL_MOD(false), + + /** Create an index. */ + CREATE_INDEX(false), + + /** Hide an index from the query planner. Reversible in seconds. */ + HIDE_INDEX(false), + + /** Drop an index. Rebuilding takes as long as building did. */ + DROP_INDEX(true), + + /** Drop a collection. */ + DROP_COLLECTION(true), + + /** Drop a database. */ + DROP_DATABASE(true), + + /** Apply a migration change unit. */ + APPLY_MIGRATION(false), + + /** Enable sharding on a collection. */ + SHARD_COLLECTION(true), + + /** Add a field to an existing shard key. */ + REFINE_SHARD_KEY(true), + + /** Change a collection's shard key, rewriting the whole collection. */ + RESHARD_COLLECTION(true), + + /** Start or stop the balancer. */ + BALANCER_CONTROL(false), + + /** Create, rotate or delete an encryption data key. */ + MANAGE_ENCRYPTION_KEY(true), + + /** Create, update or delete a search or vector index. */ + MANAGE_SEARCH_INDEX(false), + + /** Repair or verify a database. */ + REPAIR(true); + + private final boolean highRisk; + + MongoAdminOperation(boolean highRisk) { + this.highRisk = highRisk; + } + + /** + * True when the operation destroys data or rewrites a collection, and needs explicit approval. + */ + public boolean highRisk() { + return highRisk; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuard.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuard.java new file mode 100644 index 00000000..2ea0b696 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuard.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; + +/** + * Refuses to build an admin gateway inside an ordinary application runtime (design §8, §39). + * + *

Two independent conditions, because either alone is enough to make the plane separation + * decorative. An admin gateway that exists in the application context can be injected by any bean + * in it; an admin gateway using the application's credential has the application's privileges no + * matter which class it lives in. + */ +public final class MongoAdminRuntimeGuard { + + private final boolean deploymentJob; + + private final String runtimeCredentialFingerprint; + + private final String adminCredentialFingerprint; + + public MongoAdminRuntimeGuard( + boolean deploymentJob, + String runtimeCredentialFingerprint, + String adminCredentialFingerprint) { + this.deploymentJob = deploymentJob; + this.runtimeCredentialFingerprint = + Objects.requireNonNull(runtimeCredentialFingerprint, "runtimeCredentialFingerprint"); + this.adminCredentialFingerprint = + Objects.requireNonNull(adminCredentialFingerprint, "adminCredentialFingerprint"); + } + + /** + * Validates that an admin gateway may exist here. + * + * @throws MongoOperationRejectedException when this is an application runtime, or when the two + * credentials are the same + */ + public void validate() { + if (!deploymentJob) { + throw MongoOperationRejectedException.of( + "admin.runtime", + "the MongoDB admin gateway is not available in an application runtime; it is registered " + + "only by a migration or deployment job"); + } + if (runtimeCredentialFingerprint.equals(adminCredentialFingerprint)) { + throw MongoOperationRejectedException.of( + "admin.runtime", + "the admin gateway is using the runtime credential; separating the admin plane means " + + "nothing if one credential opens both"); + } + } + + /** True when an admin gateway may be constructed in this process. */ + public boolean adminGatewayAllowed() { + return deploymentJob && !runtimeCredentialFingerprint.equals(adminCredentialFingerprint); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionExecutor.java new file mode 100644 index 00000000..6f5b843b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionExecutor.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import java.util.function.Supplier; + +/** + * Runs a multi-document invariant inside one transaction (design §14.2). + * + *

The callback is a plain {@link Supplier} and never receives the {@code ClientSession}. Handing + * it out would let application code start a second transaction, keep the session past the callback, + * or pass it somewhere the platform can no longer bound — and the session is what carries the + * transaction's identity, so all three break the retry semantics. + */ +public interface MongoTransactionExecutor { + + /** Runs the body in a transaction, retrying and committing according to the profile. */ + T execute(MongoTransactionProfile profile, Supplier work); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfile.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfile.java new file mode 100644 index 00000000..23a83b40 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfile.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.time.Duration; +import java.util.Objects; + +/** + * The bounds one transaction runs under (design §14.2). + * + *

Secondary reads are rejected at construction. A transaction reads and writes against a single + * primary; asking it to read a secondary either fails at the server or silently reads outside the + * transaction's snapshot, and neither is what the caller who wrote {@code STALE_READ_ALLOWED} + * meant. + * + *

The duration bound is not advisory either. MongoDB aborts a transaction that exceeds {@code + * transactionLifetimeLimitSeconds} — 60 by default — and a transaction that holds locks for that + * long has already degraded every other writer on the same documents. + */ +public record MongoTransactionProfile( + MongoConsistencyProfile consistency, Duration timeout, int maxAttempts, Duration maxElapsed) { + + /** The server's default transaction lifetime limit. A profile may not exceed it. */ + public static final Duration SERVER_LIFETIME_LIMIT = Duration.ofSeconds(60); + + public MongoTransactionProfile { + Objects.requireNonNull(consistency, "consistency"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(maxElapsed, "maxElapsed"); + if (consistency == MongoConsistencyProfile.STALE_READ_ALLOWED) { + throw new IllegalArgumentException( + "a transaction cannot use " + + MongoConsistencyProfile.STALE_READ_ALLOWED + + "; transactional reads are served by the primary"); + } + if (timeout.isZero() || timeout.isNegative()) { + throw new IllegalArgumentException("a transaction timeout must be positive"); + } + if (timeout.compareTo(SERVER_LIFETIME_LIMIT) > 0) { + throw new IllegalArgumentException( + "a transaction timeout of " + + timeout + + " exceeds the server's default lifetime limit of " + + SERVER_LIFETIME_LIMIT); + } + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + if (maxElapsed.compareTo(timeout) < 0) { + throw new IllegalArgumentException( + "maxElapsed must cover at least one full attempt of " + timeout); + } + } + + /** + * A profile from a consistency choice and a timeout. + * + * @throws IllegalArgumentException when the consistency profile permits secondary reads + */ + public static MongoTransactionProfile of(MongoConsistencyProfile consistency, Duration timeout) { + return new MongoTransactionProfile(consistency, timeout, 3, timeout.multipliedBy(4)); + } + + /** The durable default: majority reads and writes, three attempts. */ + public static MongoTransactionProfile majority() { + return of(MongoConsistencyProfile.PRIMARY_MAJORITY, Duration.ofSeconds(5)); + } + + /** A snapshot transaction, for a multi-document invariant that must see one point in time. */ + public static MongoTransactionProfile snapshot() { + return of(MongoConsistencyProfile.SNAPSHOT_TRANSACTION, Duration.ofSeconds(5)); + } + + /** A deliberately short write transaction. */ + public static MongoTransactionProfile shortWrite() { + return of(MongoConsistencyProfile.MONGO_SHORT_WRITE, Duration.ofSeconds(2)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionScope.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionScope.java new file mode 100644 index 00000000..6bdbecd2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionScope.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Objects; +import org.springframework.data.mongodb.core.MongoOperations; + +/** + * Publishes the session-bound operations for the transaction body to use (design §14.2). + * + *

The design forbids handing the {@code ClientSession} itself to application code, but the body + * still has to run inside the transaction. This scope is the narrow channel that allows + * both: the body asks for operations, gets a template already bound to the session, and never sees + * the session. + * + *

Thread-bound because the imperative path is thread-bound. The reactive path uses Reactor + * Context for the same purpose, since a reactive chain has no stable thread to bind to. + */ +public final class MongoTransactionScope { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private MongoTransactionScope() {} + + /** + * The operations bound to the transaction currently running on this thread. + * + * @throws MongoOperationRejectedException when called outside a transaction body + */ + public static MongoOperations require() { + MongoOperations operations = CURRENT.get(); + if (operations == null) { + throw MongoOperationRejectedException.of( + "transaction.scope", + "no MongoDB transaction is active on this thread; a transaction body must obtain its " + + "operations from the scope so its writes join the transaction"); + } + return operations; + } + + /** True when a transaction is active on this thread. */ + public static boolean isActive() { + return CURRENT.get() != null; + } + + static void bind(MongoOperations operations) { + CURRENT.set(Objects.requireNonNull(operations, "operations")); + } + + static void unbind() { + CURRENT.remove(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSession.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSession.java new file mode 100644 index 00000000..f44497ef --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSession.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import java.util.function.Supplier; + +/** + * One transaction attempt, with the body and the commit as separate steps (design §14.3). + * + *

Splitting them is what makes the design's central retry rule expressible at all. A {@code + * transaction { ... }} block that commits implicitly can only offer "retry everything", and + * "everything" is exactly what must not be replayed after an unknown commit. + * + *

The session itself never leaves the platform: application code sees the {@link Supplier} it + * passed in and nothing else. + */ +public interface MongoTransactionSession extends AutoCloseable { + + /** Runs the business body inside this transaction, without committing. */ + T runBody(Supplier body); + + /** Commits this transaction. May be called again after an unknown commit result. */ + void commit(); + + /** Aborts this transaction. */ + void abort(); + + /** Releases the session. Aborts first when the transaction is still open. */ + @Override + void close(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSessionFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSessionFactory.java new file mode 100644 index 00000000..d5937274 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionSessionFactory.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +/** + * Opens a fresh session and starts a transaction on it (design §14.3). + * + *

Every whole-transaction retry calls this again. Reusing an aborted session is not permitted by + * the driver and, when it appears to work, produces a transaction whose identity the server has + * already discarded — so the factory exists to make "new session per attempt" the only shape the + * coordinator can express. + */ +public interface MongoTransactionSessionFactory { + + /** Opens a session and starts a transaction under the profile's consistency and timeout. */ + MongoTransactionSession open(MongoTransactionProfile profile); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionExecutor.java new file mode 100644 index 00000000..1b799b6a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionExecutor.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import java.util.function.Supplier; +import org.reactivestreams.Publisher; + +/** + * The reactive counterpart of {@link MongoTransactionExecutor} (design §14.2). + * + *

Carries identical retry metadata to the blocking executor. A team that moves a use case from + * one to the other must not have to rediscover that an unknown commit may not be replayed. + */ +public interface ReactiveMongoTransactionExecutor { + + /** Runs the body in a transaction, retrying and committing according to the profile. */ + Publisher execute(MongoTransactionProfile profile, Supplier> work); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSession.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSession.java new file mode 100644 index 00000000..3eb67be5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSession.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import java.util.function.Supplier; +import org.reactivestreams.Publisher; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import reactor.core.publisher.Mono; + +/** + * The reactive counterpart of {@link MongoTransactionSession} (design §14.2, §14.3). + * + *

Body and commit stay separate here for the same reason as in the blocking path, and the + * session still never reaches application code: the body is handed session-bound operations + * instead. + */ +public interface ReactiveMongoTransactionSession { + + /** Runs the business body inside this transaction, without committing. */ + Publisher runBody(Supplier> body, ReactiveMongoOperations bound); + + /** Session-bound operations the body must use so its writes join the transaction. */ + ReactiveMongoOperations operations(); + + /** Commits this transaction. May be subscribed again after an unknown commit result. */ + Mono commit(); + + /** Aborts this transaction. */ + Mono abort(); + + /** Releases the session. */ + Mono release(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSessionFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSessionFactory.java new file mode 100644 index 00000000..7cb91360 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/ReactiveMongoTransactionSessionFactory.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import reactor.core.publisher.Mono; + +/** + * Opens a reactive session with a started transaction (design §14.3). + * + *

Returns a {@link Mono} so the session is opened at subscription time. Opening it eagerly would + * leak a server-side session whenever the resulting chain is assembled but never subscribed — which + * happens routinely in a reactive codebase. + */ +public interface ReactiveMongoTransactionSessionFactory { + + /** Opens a session and starts a transaction under the profile's consistency and timeout. */ + Mono open(MongoTransactionProfile profile); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionExecutor.java new file mode 100644 index 00000000..4d0aca18 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionExecutor.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoTransactionRetryCoordinator; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * The Spring-wired blocking transaction entry point (design §14.2). + * + *

A thin composition of the driver-backed session factory and the retry coordinator. Kept as its + * own type so application wiring depends on a stable name rather than on how the retry machinery is + * currently assembled. + */ +public final class SpringMongoTransactionExecutor implements MongoTransactionExecutor { + + private final MongoTransactionRetryCoordinator coordinator; + + public SpringMongoTransactionExecutor(MongoTransactionRetryCoordinator coordinator) { + this.coordinator = Objects.requireNonNull(coordinator, "coordinator"); + } + + /** Builds an executor over the platform's standard retry budget and reconciler. */ + public static SpringMongoTransactionExecutor standard( + MongoTemplate template, MongoConsistencyRegistry consistency, String operationName) { + return create( + template, consistency, operationName, MongoRetryBudget.standard(), decision -> {}); + } + + /** Builds an executor with an explicit retry budget and decision recorder. */ + public static SpringMongoTransactionExecutor create( + MongoTemplate template, + MongoConsistencyRegistry consistency, + String operationName, + MongoRetryBudget budget, + Consumer decisionRecorder) { + return new SpringMongoTransactionExecutor( + new MongoTransactionRetryCoordinator( + new SpringMongoTransactionSessionFactory(template, consistency), + budget, + MongoCommitReconciler.standard(), + RandomGenerator.getDefault(), + decisionRecorder, + new MongoOperationName(operationName))); + } + + @Override + public T execute(MongoTransactionProfile profile, Supplier work) { + return coordinator.execute(profile, work); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionSessionFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionSessionFactory.java new file mode 100644 index 00000000..8baaa7f0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringMongoTransactionSessionFactory.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.MongoException; +import com.mongodb.ReadConcern; +import com.mongodb.ReadConcernLevel; +import com.mongodb.ReadPreference; +import com.mongodb.TransactionOptions; +import com.mongodb.WriteConcern; +import com.mongodb.client.ClientSession; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * Opens driver sessions and drives the transaction one step at a time (design §14.2, §14.3). + * + *

Deliberately not built on {@code MongoTransactionManager} or {@code TransactionTemplate}. Both + * commit implicitly when the callback returns, which collapses body and commit into one step — and + * the whole design rests on those two steps failing differently and being retried differently. + * + *

Driver failures are classified here, at the boundary where the labels still exist, so the + * coordinator above sees only the platform's two transaction exceptions. + */ +public final class SpringMongoTransactionSessionFactory implements MongoTransactionSessionFactory { + + private static final MongoOperationName SESSION_OPERATION = + new MongoOperationName("transaction.session"); + + private final MongoTemplate template; + + private final MongoConsistencyRegistry consistency; + + private final MongoFailureClassifier classifier; + + public SpringMongoTransactionSessionFactory( + MongoTemplate template, MongoConsistencyRegistry consistency) { + this(template, consistency, new DefaultMongoFailureClassifier()); + } + + public SpringMongoTransactionSessionFactory( + MongoTemplate template, + MongoConsistencyRegistry consistency, + MongoFailureClassifier classifier) { + this.template = Objects.requireNonNull(template, "template"); + this.consistency = Objects.requireNonNull(consistency, "consistency"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + @Override + public MongoTransactionSession open(MongoTransactionProfile profile) { + Objects.requireNonNull(profile, "profile"); + MongoConsistencyDescriptor descriptor = consistency.require(profile.consistency()); + ClientSession session = + template + .getMongoDatabaseFactory() + .getSession( + ClientSessionOptions.builder() + .causallyConsistent(descriptor.requiresCausalSession()) + .defaultTransactionOptions(transactionOptions(descriptor, profile.timeout())) + .build()); + session.startTransaction(); + return new DriverBackedSession(session, template.withSession(session), classifier); + } + + private static TransactionOptions transactionOptions( + MongoConsistencyDescriptor descriptor, Duration timeout) { + return TransactionOptions.builder() + .readPreference(ReadPreference.primary()) + .readConcern(new ReadConcern(ReadConcernLevel.fromString(descriptor.readConcern()))) + .writeConcern( + "majority".equals(descriptor.writeConcern()) + ? WriteConcern.MAJORITY + : WriteConcern.ACKNOWLEDGED) + .maxCommitTime(timeout.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS) + .build(); + } + + /** One driver session, with body and commit kept as separate steps. */ + private static final class DriverBackedSession implements MongoTransactionSession { + + private final ClientSession session; + + private final MongoTemplate sessionBound; + + private final MongoFailureClassifier classifier; + + private boolean committed; + + private DriverBackedSession( + ClientSession session, MongoTemplate sessionBound, MongoFailureClassifier classifier) { + this.session = session; + this.sessionBound = sessionBound; + this.classifier = classifier; + } + + @Override + public T runBody(Supplier body) { + Objects.requireNonNull(body, "body"); + MongoTransactionScope.bind(sessionBound); + try { + return body.get(); + } catch (MongoException driverFailure) { + throw translate(driverFailure); + } finally { + MongoTransactionScope.unbind(); + } + } + + @Override + public void commit() { + try { + session.commitTransaction(); + committed = true; + } catch (MongoException driverFailure) { + throw translate(driverFailure); + } + } + + @Override + public void abort() { + if (!committed && session.hasActiveTransaction()) { + session.abortTransaction(); + } + } + + @Override + public void close() { + try { + abort(); + } finally { + session.close(); + } + } + + private RuntimeException translate(MongoException driverFailure) { + MongoDriverFailureView view = MongoDriverFailureView.from(driverFailure); + MongoRetryScope scope = classifier.classify(view).retryScope(); + MongoFailureContext context = + MongoFailureContext.commitUnknown( + SESSION_OPERATION, + view.hasServerCode() ? Integer.toString(view.serverCode()) : "", + Duration.ZERO); + if (scope == MongoRetryScope.COMMIT_ONLY) { + return new MongoTransactionCommitUnknownException( + context, "read the transaction record, version or idempotency key"); + } + if (scope == MongoRetryScope.WHOLE_TRANSACTION) { + return new MongoTransactionTransientException(context); + } + return driverFailure; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionExecutor.java new file mode 100644 index 00000000..6e633a5c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionExecutor.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoCommitReconciler; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryBudget; +import dev.caskeleton.adapter.outbound.mongo.transaction.retry.MongoRetryDecision; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The reactive transaction entry point, with the same retry rule as the blocking one (design + * §14.3). + * + *

Body retry re-subscribes the caller's publisher from a fresh session. Commit retry + * re-subscribes only {@code commit()} — the body's publisher is never touched again, because + * re-subscribing a publisher is exactly how a reactive codebase replays work that may already have + * been committed. + * + *

The session is released on every path, including cancellation, through {@link Flux#usingWhen}. + */ +public final class SpringReactiveMongoTransactionExecutor + implements ReactiveMongoTransactionExecutor { + + private final ReactiveMongoTransactionSessionFactory sessions; + + private final MongoRetryBudget budget; + + private final MongoCommitReconciler reconciler; + + private final RandomGenerator random; + + private final Consumer decisionRecorder; + + private final MongoOperationName operationName; + + public SpringReactiveMongoTransactionExecutor( + ReactiveMongoTransactionSessionFactory sessions, + MongoRetryBudget budget, + MongoCommitReconciler reconciler, + RandomGenerator random, + Consumer decisionRecorder, + MongoOperationName operationName) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.budget = Objects.requireNonNull(budget, "budget"); + this.reconciler = Objects.requireNonNull(reconciler, "reconciler"); + this.random = Objects.requireNonNull(random, "random"); + this.decisionRecorder = Objects.requireNonNull(decisionRecorder, "decisionRecorder"); + this.operationName = Objects.requireNonNull(operationName, "operationName"); + } + + /** An executor over the platform's standard retry budget and reconciler. */ + public static SpringReactiveMongoTransactionExecutor standard( + ReactiveMongoTransactionSessionFactory sessions, String operationName) { + return new SpringReactiveMongoTransactionExecutor( + sessions, + MongoRetryBudget.standard(), + MongoCommitReconciler.standard(), + RandomGenerator.getDefault(), + decision -> {}, + new MongoOperationName(operationName)); + } + + @Override + public Publisher execute( + MongoTransactionProfile profile, Supplier> work) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(work, "work"); + return attempt(profile, work, 1); + } + + private Flux attempt( + MongoTransactionProfile profile, Supplier> work, int attempt) { + return Flux.usingWhen( + sessions.open(profile), + session -> + Flux.from(session.runBody(work, session.operations())) + .collectList() + .flatMapMany( + values -> + commitWithRetry(session, values, 1) + .thenMany(Flux.fromIterable(values))), + ReactiveMongoTransactionSession::release, + (session, failure) -> session.abort().then(session.release()), + session -> session.abort().then(session.release())) + .onErrorResume( + MongoTransactionTransientException.class, + transientFailure -> { + if (!budget.allowsAttempt(attempt + 1, Duration.ZERO.plusNanos(1))) { + return Flux.error(transientFailure); + } + decisionRecorder.accept( + MongoRetryDecision.retryBody(budget.delayBefore(attempt + 1, random))); + return Mono.delay(budget.delayBefore(attempt + 1, random)) + .thenMany(attempt(profile, work, attempt + 1)); + }); + } + + private Mono commitWithRetry( + ReactiveMongoTransactionSession session, Object bodyResult, int commitAttempt) { + return session + .commit() + .onErrorResume( + MongoTransactionCommitUnknownException.class, + unknown -> { + if (!budget.allowsAttempt(commitAttempt + 1, Duration.ZERO.plusNanos(1))) { + decisionRecorder.accept(MongoRetryDecision.reconcile("commit remained unknown")); + return Mono.error( + new MongoTransactionCommitUnknownException( + unknown.failureContext(), + reconciler.reconciliationHintFor(operationName.value()))); + } + decisionRecorder.accept( + MongoRetryDecision.retryCommit(budget.delayBefore(commitAttempt + 1, random))); + return Mono.delay(budget.delayBefore(commitAttempt + 1, random)) + .then(commitWithRetry(session, bodyResult, commitAttempt + 1)); + }); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionSessionFactory.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionSessionFactory.java new file mode 100644 index 00000000..9ab93e35 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/SpringReactiveMongoTransactionSessionFactory.java @@ -0,0 +1,162 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.MongoException; +import com.mongodb.ReadConcern; +import com.mongodb.ReadConcernLevel; +import com.mongodb.ReadPreference; +import com.mongodb.TransactionOptions; +import com.mongodb.WriteConcern; +import com.mongodb.reactivestreams.client.ClientSession; +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyDescriptor; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyRegistry; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.failure.DefaultMongoFailureClassifier; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoFailureClassifier; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.reactivestreams.Publisher; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import reactor.core.publisher.Mono; + +/** + * Opens reactive driver sessions, keeping body and commit separate (design §14.2, §14.3). + * + *

Built on the driver's reactive session rather than on {@code + * ReactiveMongoTemplate.inTransaction}, which commits implicitly. The design's retry rule needs the + * commit to be a step the platform can repeat on its own, without the body coming with it. + */ +public final class SpringReactiveMongoTransactionSessionFactory + implements ReactiveMongoTransactionSessionFactory { + + private static final MongoOperationName SESSION_OPERATION = + new MongoOperationName("transaction.reactive-session"); + + private final ReactiveMongoTemplate template; + + private final MongoConsistencyRegistry consistency; + + private final MongoFailureClassifier classifier; + + public SpringReactiveMongoTransactionSessionFactory( + ReactiveMongoTemplate template, MongoConsistencyRegistry consistency) { + this(template, consistency, new DefaultMongoFailureClassifier()); + } + + public SpringReactiveMongoTransactionSessionFactory( + ReactiveMongoTemplate template, + MongoConsistencyRegistry consistency, + MongoFailureClassifier classifier) { + this.template = Objects.requireNonNull(template, "template"); + this.consistency = Objects.requireNonNull(consistency, "consistency"); + this.classifier = Objects.requireNonNull(classifier, "classifier"); + } + + @Override + public Mono open(MongoTransactionProfile profile) { + Objects.requireNonNull(profile, "profile"); + MongoConsistencyDescriptor descriptor = consistency.require(profile.consistency()); + return Mono.from( + template + .getMongoDatabaseFactory() + .getSession( + ClientSessionOptions.builder() + .causallyConsistent(descriptor.requiresCausalSession()) + .defaultTransactionOptions( + transactionOptions(descriptor, profile.timeout())) + .build())) + .map( + session -> { + session.startTransaction(); + return new DriverBackedReactiveSession( + session, template.withSession(session), classifier); + }); + } + + private static TransactionOptions transactionOptions( + MongoConsistencyDescriptor descriptor, Duration timeout) { + return TransactionOptions.builder() + .readPreference(ReadPreference.primary()) + .readConcern(new ReadConcern(ReadConcernLevel.fromString(descriptor.readConcern()))) + .writeConcern( + "majority".equals(descriptor.writeConcern()) + ? WriteConcern.MAJORITY + : WriteConcern.ACKNOWLEDGED) + .maxCommitTime(timeout.toMillis(), TimeUnit.MILLISECONDS) + .build(); + } + + /** One reactive driver session. */ + private static final class DriverBackedReactiveSession + implements ReactiveMongoTransactionSession { + + private final ClientSession session; + + private final ReactiveMongoOperations bound; + + private final MongoFailureClassifier classifier; + + private DriverBackedReactiveSession( + ClientSession session, ReactiveMongoOperations bound, MongoFailureClassifier classifier) { + this.session = session; + this.bound = bound; + this.classifier = classifier; + } + + @Override + public Publisher runBody( + Supplier> body, ReactiveMongoOperations operations) { + return Mono.defer(() -> Mono.just(body)) + .flatMapMany(Supplier::get) + .onErrorMap(MongoException.class, this::translate); + } + + @Override + public ReactiveMongoOperations operations() { + return bound; + } + + @Override + public Mono commit() { + return Mono.from(session.commitTransaction()) + .onErrorMap(MongoException.class, this::translate) + .then(); + } + + @Override + public Mono abort() { + return Mono.from(session.abortTransaction()).onErrorResume(failure -> Mono.empty()).then(); + } + + @Override + public Mono release() { + return Mono.fromRunnable(session::close); + } + + private RuntimeException translate(MongoException driverFailure) { + MongoDriverFailureView view = MongoDriverFailureView.from(driverFailure); + MongoRetryScope scope = classifier.classify(view).retryScope(); + MongoFailureContext context = + MongoFailureContext.commitUnknown( + SESSION_OPERATION, + view.hasServerCode() ? Integer.toString(view.serverCode()) : "", + Duration.ZERO); + if (scope == MongoRetryScope.COMMIT_ONLY) { + return new MongoTransactionCommitUnknownException( + context, "read the transaction record, version or idempotency key"); + } + if (scope == MongoRetryScope.WHOLE_TRANSACTION) { + return new MongoTransactionTransientException(context); + } + return driverFailure; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoCommitReconciler.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoCommitReconciler.java new file mode 100644 index 00000000..dfb46a08 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoCommitReconciler.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.retry; + +/** + * Decides what a caller should read to settle an unknown commit (design §14.3, §7.3). + * + *

The platform cannot answer "did it commit" on its own — that answer lives in the business + * data. What it can do is refuse to guess, and name the durable evidence that does answer it: a + * version, a unique key, an idempotency record, a transaction record. + * + *

The default hint names all four rather than nothing, because an operator reading an alert at + * 3am needs a starting point more than they need precision. + */ +@FunctionalInterface +public interface MongoCommitReconciler { + + /** The evidence to read for an operation whose commit result is unknown. */ + String reconciliationHintFor(String operationName); + + /** The default reconciler, naming the four kinds of evidence the design lists. */ + static MongoCommitReconciler standard() { + return operationName -> + "read the durable evidence for '" + + operationName + + "': the document version, the unique key, the idempotency record or the transaction " + + "record. Do not replay the business body."; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryBudget.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryBudget.java new file mode 100644 index 00000000..e6b87723 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryBudget.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.retry; + +import java.time.Duration; +import java.util.Objects; +import java.util.random.RandomGenerator; + +/** + * How much retrying one operation is allowed to do (design §14.3). + * + *

Both an attempt count and a wall-clock ceiling, because either alone fails. Attempts alone let + * three retries with growing backoff outlive a caller's deadline; elapsed time alone lets a fast + * failure retry hundreds of times in a second and turn a struggling primary into an overloaded one. + * + *

Jitter is not decoration. Without it every client that failed at the same moment retries at + * the same moment, and the retry storm reproduces the load that caused the failure. + */ +public record MongoRetryBudget( + int maxAttempts, + Duration maxElapsed, + Duration baseBackoff, + Duration maxBackoff, + double jitter) { + + public MongoRetryBudget { + Objects.requireNonNull(maxElapsed, "maxElapsed"); + Objects.requireNonNull(baseBackoff, "baseBackoff"); + Objects.requireNonNull(maxBackoff, "maxBackoff"); + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + if (maxElapsed.isNegative() || baseBackoff.isNegative() || maxBackoff.isNegative()) { + throw new IllegalArgumentException("retry durations must not be negative"); + } + if (baseBackoff.compareTo(maxBackoff) > 0) { + throw new IllegalArgumentException("baseBackoff must not exceed maxBackoff"); + } + if (jitter < 0 || jitter > 1) { + throw new IllegalArgumentException("jitter must be a fraction between 0 and 1"); + } + } + + /** The platform default: three attempts, exponential backoff from 20 ms, full-ish jitter. */ + public static MongoRetryBudget standard() { + return new MongoRetryBudget( + 3, Duration.ofSeconds(10), Duration.ofMillis(20), Duration.ofMillis(500), 0.5); + } + + /** A budget that never retries. */ + public static MongoRetryBudget none() { + return new MongoRetryBudget(1, Duration.ZERO, Duration.ZERO, Duration.ZERO, 0); + } + + /** + * The delay before the given attempt. + * + * @param attempt the 1-based number of the attempt that is about to run + */ + public Duration delayBefore(int attempt, RandomGenerator random) { + Objects.requireNonNull(random, "random"); + if (attempt <= 1) { + return Duration.ZERO; + } + long exponential = baseBackoff.toMillis() << Math.min(attempt - 2, 20); + long capped = Math.min(exponential, maxBackoff.toMillis()); + long jittered = (long) (capped * (1 - jitter + jitter * random.nextDouble())); + return Duration.ofMillis(Math.max(0, jittered)); + } + + /** True when another attempt fits inside both bounds. */ + public boolean allowsAttempt(int nextAttempt, Duration elapsedSoFar) { + Objects.requireNonNull(elapsedSoFar, "elapsedSoFar"); + return nextAttempt <= maxAttempts && elapsedSoFar.compareTo(maxElapsed) < 0; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryDecision.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryDecision.java new file mode 100644 index 00000000..c3973b14 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoRetryDecision.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.retry; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import java.time.Duration; +import java.util.Objects; + +/** + * What the coordinator decided to do about one failed attempt (design §14.3). + * + *

Materialising the decision rather than branching inline is what lets the three retry metrics + * the design asks for — body retry, commit retry, reconciliation — be recorded from one place, and + * lets the decision logic be tested without running a transaction. + */ +public record MongoRetryDecision(MongoRetryScope scope, Duration delay, String reason) { + + public MongoRetryDecision { + Objects.requireNonNull(scope, "scope"); + Objects.requireNonNull(delay, "delay"); + Objects.requireNonNull(reason, "reason"); + if (delay.isNegative()) { + throw new IllegalArgumentException("a retry delay must not be negative"); + } + } + + /** Replay the whole body from a new session. */ + public static MongoRetryDecision retryBody(Duration delay) { + return new MongoRetryDecision( + MongoRetryScope.WHOLE_TRANSACTION, delay, "TransientTransactionError"); + } + + /** Retry only the commit; the body must not run again. */ + public static MongoRetryDecision retryCommit(Duration delay) { + return new MongoRetryDecision( + MongoRetryScope.COMMIT_ONLY, delay, "UnknownTransactionCommitResult"); + } + + /** Stop retrying and read durable evidence to settle what happened. */ + public static MongoRetryDecision reconcile(String reason) { + return new MongoRetryDecision(MongoRetryScope.RECONCILIATION, Duration.ZERO, reason); + } + + /** Stop; the failure is definite. */ + public static MongoRetryDecision stop(String reason) { + return new MongoRetryDecision(MongoRetryScope.NONE, Duration.ZERO, reason); + } + + /** True when the business body may run again. */ + public boolean replaysBody() { + return scope == MongoRetryScope.WHOLE_TRANSACTION; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinator.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinator.java new file mode 100644 index 00000000..d0ad21d8 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinator.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.retry; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionExecutor; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +/** + * Separates whole-transaction retry from commit-only retry (design §14.3, D-10). + * + *

This is the class the design's most consequential rule lives in. {@code + * TransientTransactionError} means nothing was committed, so the body may run again — from a new + * session, because the old one is finished. {@code UnknownTransactionCommitResult} means the commit + * may already have succeeded, so the body must not run again; only the commit is + * retried, and if it stays unknown the caller is handed reconciliation metadata instead of a + * plausible-looking retry. + * + *

Getting this wrong does not fail loudly. It produces a second order, a double refund, or a + * duplicate ledger entry — during a failover, when nobody is reading the logs. + */ +public final class MongoTransactionRetryCoordinator implements MongoTransactionExecutor { + + private final MongoTransactionSessionFactory sessions; + + private final MongoRetryBudget budget; + + private final MongoCommitReconciler reconciler; + + private final RandomGenerator random; + + private final Consumer decisionRecorder; + + private final MongoOperationName operationName; + + public MongoTransactionRetryCoordinator( + MongoTransactionSessionFactory sessions, + MongoRetryBudget budget, + MongoCommitReconciler reconciler, + RandomGenerator random, + Consumer decisionRecorder, + MongoOperationName operationName) { + this.sessions = Objects.requireNonNull(sessions, "sessions"); + this.budget = Objects.requireNonNull(budget, "budget"); + this.reconciler = Objects.requireNonNull(reconciler, "reconciler"); + this.random = Objects.requireNonNull(random, "random"); + this.decisionRecorder = Objects.requireNonNull(decisionRecorder, "decisionRecorder"); + this.operationName = Objects.requireNonNull(operationName, "operationName"); + } + + /** A coordinator with the standard budget and reconciler. */ + public static MongoTransactionRetryCoordinator standard( + MongoTransactionSessionFactory sessions, String operationName) { + return new MongoTransactionRetryCoordinator( + sessions, + MongoRetryBudget.standard(), + MongoCommitReconciler.standard(), + RandomGenerator.getDefault(), + decision -> {}, + new MongoOperationName(operationName)); + } + + @Override + public T execute(MongoTransactionProfile profile, Supplier work) { + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(work, "work"); + + long startedAt = System.nanoTime(); + MongoTransactionTransientException lastTransient = null; + + for (int attempt = 1; budget.allowsAttempt(attempt, elapsedSince(startedAt)); attempt++) { + sleep(budget.delayBefore(attempt, random)); + try (MongoTransactionSession session = sessions.open(profile)) { + T value; + try { + value = session.runBody(work); + } catch (MongoTransactionTransientException transientFailure) { + lastTransient = transientFailure; + decisionRecorder.accept( + MongoRetryDecision.retryBody(budget.delayBefore(attempt + 1, random))); + continue; + } + return commitWithRetry(session, value, startedAt); + } + } + + if (lastTransient != null) { + throw lastTransient; + } + throw new IllegalStateException( + "the transaction retry budget did not permit a single attempt for " + operationName); + } + + /** + * Commits, retrying only the commit. + * + *

The body's result is already computed and is returned unchanged: nothing here may recompute + * it, because recomputing is indistinguishable from replaying. + */ + private T commitWithRetry(MongoTransactionSession session, T value, long startedAt) { + MongoTransactionCommitUnknownException lastUnknown = null; + for (int commitAttempt = 1; + budget.allowsAttempt(commitAttempt, elapsedSince(startedAt)); + commitAttempt++) { + sleep(budget.delayBefore(commitAttempt, random)); + try { + session.commit(); + return value; + } catch (MongoTransactionCommitUnknownException unknown) { + lastUnknown = unknown; + decisionRecorder.accept( + MongoRetryDecision.retryCommit(budget.delayBefore(commitAttempt + 1, random))); + } + } + decisionRecorder.accept(MongoRetryDecision.reconcile("commit remained unknown")); + throw commitUnknown(lastUnknown); + } + + private MongoTransactionCommitUnknownException commitUnknown( + MongoTransactionCommitUnknownException lastUnknown) { + MongoFailureContext context = + lastUnknown != null + ? lastUnknown.failureContext() + : MongoFailureContext.commitUnknown(operationName, "", Duration.ZERO); + return new MongoTransactionCommitUnknownException( + context, reconciler.reconciliationHintFor(operationName.value())); + } + + private static Duration elapsedSince(long startedAtNanos) { + return Duration.ofNanos(System.nanoTime() - startedAtNanos); + } + + private static void sleep(Duration delay) { + if (delay.isZero() || delay.isNegative()) { + return; + } + try { + Thread.sleep(delay.toMillis()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("the transaction retry backoff was interrupted"); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionContext.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionContext.java new file mode 100644 index 00000000..774883c3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionContext.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.session; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.util.Objects; + +/** + * The consistency contract a causal session runs under (design §14, §7.2). + * + *

Causal consistency only holds with majority read and write concerns. On anything weaker the + * session still tracks operation times and the reads still return, but a later read can be served + * by a node that has not applied this session's earlier write — which is precisely the guarantee + * the caller asked for. Constructing the context is therefore where the requirement is checked, not + * where it is documented. + */ +public record MongoCausalSessionContext(MongoConsistencyProfile consistency) { + + public MongoCausalSessionContext { + Objects.requireNonNull(consistency, "consistency"); + } + + /** + * The context for a profile. + * + * @throws IllegalArgumentException when the profile does not provide majority read and write + * concerns + */ + public static MongoCausalSessionContext forProfile(MongoConsistencyProfile consistency) { + if (consistency != MongoConsistencyProfile.CAUSAL_MAJORITY) { + throw new IllegalArgumentException( + "a causal session requires " + + MongoConsistencyProfile.CAUSAL_MAJORITY + + "; read-your-writes does not hold without majority read and write concerns, and " + + consistency + + " does not provide them"); + } + return new MongoCausalSessionContext(consistency); + } + + /** + * States what a causal session is not. + * + *

Read-your-writes within a session is not atomicity across documents. A caller that needs a + * multi-document invariant needs a transaction, and the two are easy to confuse because both + * involve a session. + */ + public boolean replacesTransactions() { + return false; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutor.java new file mode 100644 index 00000000..8a25a7c2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutor.java @@ -0,0 +1,16 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.session; + +import java.util.function.Supplier; + +/** + * Runs work inside one causally consistent session (design §14). + * + *

Scoped explicitly rather than derived from a request or a thread. A session that outlives its + * scope keeps advancing its cluster time, so a later unrelated read waits for a write it has no + * reason to care about — a correctness-preserving but latency-destroying kind of leak. + */ +public interface MongoCausalSessionExecutor { + + /** Runs the body in a causal session, closing it on every exit path. */ + T execute(Supplier work); +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/ReactiveMongoCausalSessionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/ReactiveMongoCausalSessionExecutor.java new file mode 100644 index 00000000..795c67ae --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/ReactiveMongoCausalSessionExecutor.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.session; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.reactivestreams.client.ClientSession; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.reactive.ReactiveMongoContextKeys; +import java.util.Objects; +import java.util.function.Function; +import org.reactivestreams.Publisher; +import org.springframework.data.mongodb.core.ReactiveMongoOperations; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * The reactive causal session scope (design §14, §26). + * + *

The session travels in Reactor Context rather than a thread-local, because a reactive chain + * has no stable thread. {@link Flux#usingWhen} releases it on completion, error and cancellation — + * and cancellation is not the exceptional case here, it is what happens whenever a client + * disconnects. + */ +public final class ReactiveMongoCausalSessionExecutor { + + private final ReactiveMongoTemplate template; + + private final MongoCausalSessionContext context; + + public ReactiveMongoCausalSessionExecutor(ReactiveMongoTemplate template) { + this(template, MongoCausalSessionContext.forProfile(MongoConsistencyProfile.CAUSAL_MAJORITY)); + } + + public ReactiveMongoCausalSessionExecutor( + ReactiveMongoTemplate template, MongoCausalSessionContext context) { + this.template = Objects.requireNonNull(template, "template"); + this.context = Objects.requireNonNull(context, "context"); + } + + /** + * Runs the body inside a causal session, handing it session-bound operations. + * + * @param body receives operations already bound to the session + */ + public Flux execute(Function> body) { + Objects.requireNonNull(body, "body"); + return Flux.usingWhen( + Mono.from( + template + .getMongoDatabaseFactory() + .getSession(ClientSessionOptions.builder().causallyConsistent(true).build())), + session -> + Flux.from(body.apply(template.withSession(session))) + .contextWrite( + reactorContext -> + reactorContext.put(ReactiveMongoContextKeys.CAUSAL_SESSION, session)), + ReactiveMongoCausalSessionExecutor::release, + (session, failure) -> release(session), + ReactiveMongoCausalSessionExecutor::release); + } + + private static Mono release(ClientSession session) { + return Mono.fromRunnable(session::close); + } + + /** The consistency contract this executor enforces. */ + public MongoCausalSessionContext context() { + return context; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/SpringMongoCausalSessionExecutor.java b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/SpringMongoCausalSessionExecutor.java new file mode 100644 index 00000000..26206c5f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/SpringMongoCausalSessionExecutor.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.session; + +import com.mongodb.ClientSessionOptions; +import com.mongodb.client.ClientSession; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.data.mongodb.core.MongoOperations; +import org.springframework.data.mongodb.core.MongoTemplate; + +/** + * The blocking causal session scope (design §14). + * + *

The session is closed in a {@code finally}, so it is released on success, on failure and on + * any exception the body throws. The scope publishes session-bound operations the same way the + * transaction scope does, which is what makes the reads inside actually causal rather than merely + * surrounded by a session object nobody used. + */ +public final class SpringMongoCausalSessionExecutor implements MongoCausalSessionExecutor { + + private static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final MongoTemplate template; + + private final MongoCausalSessionContext context; + + public SpringMongoCausalSessionExecutor(MongoTemplate template) { + this(template, MongoCausalSessionContext.forProfile(MongoConsistencyProfile.CAUSAL_MAJORITY)); + } + + public SpringMongoCausalSessionExecutor( + MongoTemplate template, MongoCausalSessionContext context) { + this.template = Objects.requireNonNull(template, "template"); + this.context = Objects.requireNonNull(context, "context"); + } + + /** + * The session-bound operations for the causal scope currently running on this thread. + * + * @throws IllegalStateException when called outside a causal session + */ + public static MongoOperations requireSessionOperations() { + MongoOperations operations = CURRENT.get(); + if (operations == null) { + throw new IllegalStateException( + "no causal session is active on this thread; reads outside the session are not causal"); + } + return operations; + } + + @Override + public T execute(Supplier work) { + Objects.requireNonNull(work, "work"); + ClientSession session = + template + .getMongoDatabaseFactory() + .getSession(ClientSessionOptions.builder().causallyConsistent(true).build()); + try { + CURRENT.set(template.withSession(session)); + return work.get(); + } finally { + CURRENT.remove(); + session.close(); + } + } + + /** The consistency contract this executor enforces. */ + public MongoCausalSessionContext context() { + return context; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..4a538275 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +dev.caskeleton.adapter.outbound.mongo.autoconfigure.MongoPlatformAutoConfiguration diff --git a/src/adapter/outbound/persistence-mongo/src/mongoPerformanceTest/java/dev/caskeleton/adapter/outbound/mongo/performance/MongoResourceBudgetLaneTest.java b/src/adapter/outbound/persistence-mongo/src/mongoPerformanceTest/java/dev/caskeleton/adapter/outbound/mongo/performance/MongoResourceBudgetLaneTest.java new file mode 100644 index 00000000..7407ef3f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/mongoPerformanceTest/java/dev/caskeleton/adapter/outbound/mongo/performance/MongoResourceBudgetLaneTest.java @@ -0,0 +1,159 @@ +package dev.caskeleton.adapter.outbound.mongo.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.IndexOptions; +import com.mongodb.client.model.Indexes; +import dev.caskeleton.adapter.outbound.mongo.testkit.performance.MongoPerformanceGate; +import dev.caskeleton.adapter.outbound.mongo.testkit.performance.MongoResourceBudgetReport; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoSingleReplicaSetContainer; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * The resource-budget lane against a real server (design §29, Task 49). + * + *

Measures examined-versus-returned rather than latency, because latency on shared CI hardware + * says more about what else was running than about the query. The ratio is a property of the plan: + * it is the same on an idle machine and a busy one, and it is what actually predicts the query + * falling over as the collection grows. + * + *

Timing bounds are asserted only when {@code -Pperformance.assertions.enabled=true}, for the + * same reason. The ratio bound is always asserted. + */ +class MongoResourceBudgetLaneTest { + + private static final int DOCUMENT_COUNT = 5_000; + + private static final boolean TIMING_ASSERTIONS = + Boolean.parseBoolean(System.getProperty("performance.assertions.enabled", "false")); + + private static MongoSingleReplicaSetContainer container; + + private static MongoClient client; + + private static MongoCollection orders; + + @BeforeAll + static void seed() { + container = MongoSingleReplicaSetContainer.mongoEight(); + container.start(); + client = MongoClients.create(container.connectionString()); + MongoDatabase database = client.getDatabase("performance"); + orders = database.getCollection("orders"); + + List batch = new ArrayList<>(DOCUMENT_COUNT); + for (int index = 0; index < DOCUMENT_COUNT; index++) { + batch.add( + new Document("orderNumber", "A-" + index) + .append("status", index % 50 == 0 ? "PENDING" : "COMPLETE") + .append("region", "r" + (index % 7))); + } + orders.insertMany(batch); + orders.createIndex(Indexes.ascending("status"), new IndexOptions().name("ix_status")); + } + + @AfterAll + static void stop() { + if (client != null) { + client.close(); + } + if (container != null) { + container.close(); + } + } + + @Test + void anIndexedQueryStaysInsideTheResourceBudget() { + MongoResourceBudgetReport report = measure(new Document("status", "PENDING"), "ix_status"); + + assertThat(report.documentsReturned()).isEqualTo(DOCUMENT_COUNT / 50); + assertThat(report.examinedToReturnedRatio()) + .as("an index seek examines about what it returns") + .isLessThanOrEqualTo(MongoPerformanceGate.standard().maximumExaminedToReturnedRatio()); + assertThat(MongoPerformanceGate.standard().violations(report)) + .filteredOn(violation -> violation.startsWith("examined/returned")) + .isEmpty(); + + if (TIMING_ASSERTIONS) { + assertThat(MongoPerformanceGate.standard().passes(report)).isTrue(); + } + } + + @Test + void anUnindexedQueryViolatesTheBudgetTheGateEnforces() { + // A selective predicate on an unindexed field: the server walks every document to return one. + // A low-selectivity scan (one of seven regions, say) is also a scan, but its ratio is only 7, + // inside the bound — which is why the bound is on the ratio rather than on the plan shape. + MongoResourceBudgetReport report = measure(new Document("orderNumber", "A-4999"), null); + + assertThat(report.documentsExamined()).isEqualTo(DOCUMENT_COUNT); + assertThat(report.examinedToReturnedRatio()) + .as("a collection scan examines the whole collection to return one document") + .isGreaterThan(MongoPerformanceGate.standard().maximumExaminedToReturnedRatio()); + + Set violations = MongoPerformanceGate.standard().violations(report); + assertThat(violations) + .as("a gate that cannot fail certifies nothing") + .anyMatch(violation -> violation.contains("scanning rather than seeking")); + } + + @Test + void theReportIsAMachineReadableReleaseArtifact() { + MongoResourceBudgetReport report = measure(new Document("status", "PENDING"), "ix_status"); + + assertThat(report.asArtifact()) + .containsKeys( + "p99Millis", + "documentsExamined", + "documentsReturned", + "keysExamined", + "examinedToReturnedRatio", + "aggregationSpilledToDisk"); + } + + /** + * Runs one query and reads the server's own execution statistics. + * + *

{@code executionStats} rather than a stopwatch: the numbers that matter — documents + * examined, keys examined, whether the plan spilled — are the server's, and no client-side + * measurement can recover them. + */ + private static MongoResourceBudgetReport measure(Document filter, String expectedIndexName) { + long startedAt = System.nanoTime(); + long returned = orders.countDocuments(filter); + long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000; + + Document explain = orders.find(filter).explain(com.mongodb.ExplainVerbosity.EXECUTION_STATS); + Document executionStats = explain.get("executionStats", Document.class); + long documentsExamined = ((Number) executionStats.get("totalDocsExamined")).longValue(); + long keysExamined = ((Number) executionStats.get("totalKeysExamined")).longValue(); + long nReturned = ((Number) executionStats.get("nReturned")).longValue(); + + if (expectedIndexName != null) { + assertThat(explain.toJson()) + .as("the query must actually use the index the budget assumes") + .contains(expectedIndexName); + } + + return new MongoResourceBudgetReport( + elapsedMillis, + elapsedMillis, + elapsedMillis, + 0, + Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(), + documentsExamined, + nReturned == 0 ? returned : nReturned, + keysExamined, + false); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGateTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGateTest.java new file mode 100644 index 00000000..cce5a22c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/MongoAdvancedPromotionGateTest.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.testkit.atlas.MongoAtlasCapabilityContractSuite; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Tasks 1, 14, 15 — promotion needs real-environment evidence, and stays opt-in. */ +@Tag("mongodb-contract") +class MongoAdvancedPromotionGateTest { + + private final MongoAdvancedPromotionGate gate = new MongoAdvancedPromotionGate(); + + @Test + void everyPromotionRequiresActualEnvironmentEvidence() { + MongoAdvancedPromotionEvidence evidence = MongoAdvancedPromotionEvidence.fixture(); + + assertThat(evidence.requiredCategories()) + .contains("actual-topology", "security", "migration", "failure", "runbook"); + } + + @Test + void anIncompleteEvidencePackageStopsPromotion() { + MongoAdvancedPromotionEvidence evidence = + MongoAdvancedPromotionEvidence.fixture() + .with("stable-platform") + .with("security") + .with("failure") + .with("runbook"); + + assertThatThrownBy(() -> gate.verify(evidence)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("actual-topology"); + assertThat(gate.passes(evidence)).isFalse(); + } + + @Test + void aCompleteEvidencePackagePasses() { + MongoAdvancedPromotionEvidence evidence = + MongoAdvancedPromotionEvidence.fixture() + .with("stable-platform") + .with("actual-topology") + .with("security") + .with("migration") + .with("failure") + .with("runbook"); + + assertThatCode(() -> gate.verify(evidence)).doesNotThrowAnyException(); + assertThat(gate.passes(evidence)).isTrue(); + } + + @Test + void promotionDoesNotAddTheCapabilityToTheStableStarter() { + assertThat(gate.addsStarterDependency()).isFalse(); + } + + @Test + void everyAdvancedCapabilityIsOffUntilExplicitlyEnabled() { + MongoAdvancedCapabilityFlags flags = MongoAdvancedCapabilityFlags.allDisabled(); + + assertThat(flags.isEnabled(MongoCapability.SHARDING)).isFalse(); + assertThatThrownBy(() -> flags.require(MongoCapability.SHARDING)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("ca-skeleton.persistence-mongo.advanced.sharding.enabled"); + assertThatCode( + () -> flags.withEnabled(MongoCapability.SHARDING).require(MongoCapability.SHARDING)) + .doesNotThrowAnyException(); + } + + @Test + void atlasLocalIsNotReleaseEvidence() { + MongoAtlasCapabilityContractSuite.MongoAtlasCapabilityReport local = + MongoAtlasCapabilityContractSuite.vectorReadiness( + false, MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL); + MongoAtlasCapabilityContractSuite.MongoAtlasCapabilityReport target = + MongoAtlasCapabilityContractSuite.vectorReadiness( + false, MongoAtlasCapabilityContractSuite.Environment.ACTUAL_TARGET); + + assertThat(local.certifies()).isFalse(); + assertThat(target.certifies()).isTrue(); + } + + @Test + void aQueryAgainstANotReadyIndexFailsTheReadinessContract() { + MongoAtlasCapabilityContractSuite.MongoAtlasCapabilityReport report = + MongoAtlasCapabilityContractSuite.vectorReadiness( + true, MongoAtlasCapabilityContractSuite.Environment.ACTUAL_TARGET); + + assertThat(report.queriedBeforeReady()).isTrue(); + assertThat(report.certifies()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridgeTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridgeTest.java new file mode 100644 index 00000000..e802ecef --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/bridge/MongoChangeMessagingBridgeTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.bridge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.bson.BsonDocument; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** Advanced plan Task 12 — a failed publish never advances the MongoDB checkpoint. */ +@Tag("mongodb-contract") +class MongoChangeMessagingBridgeTest { + + private static final MongoChangeEventIdentity IDENTITY = + MongoChangeEventIdentity.of("1700000000.1", "shop.orders", "{\"_id\":\"o-1\"}", "insert"); + + private static final MongoResumeCheckpoint CHECKPOINT = + MongoResumeCheckpoint.encrypted("orders", new byte[] {1, 2, 3}); + + @Test + void failedPublishDoesNotAdvanceMongoCheckpoint() { + CountingCheckpointStore checkpoints = new CountingCheckpointStore(); + MongoChangeMessagingBridge bridge = + new MongoChangeMessagingBridge( + (identity, change) -> envelope(), + envelope -> Mono.error(new IllegalStateException("broker unavailable")), + checkpoints, + MongoBridgeCheckpointPolicy.AFTER_PUBLISH_CONFIRMED); + + bridge + .handle(IDENTITY, new BsonDocument(), CHECKPOINT) + .onErrorResume(failure -> Mono.empty()) + .block(); + + assertThat(checkpoints.writes()).isZero(); + } + + @Test + void aConfirmedPublishAdvancesTheCheckpoint() { + CountingCheckpointStore checkpoints = new CountingCheckpointStore(); + MongoChangeMessagingBridge bridge = + new MongoChangeMessagingBridge( + (identity, change) -> envelope(), + envelope -> Mono.empty(), + checkpoints, + MongoBridgeCheckpointPolicy.AFTER_PUBLISH_CONFIRMED); + + bridge.handle(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + + assertThat(checkpoints.writes()).isEqualTo(1); + } + + @Test + void anUninterestingChangeStillAdvancesTheCheckpoint() { + CountingCheckpointStore checkpoints = new CountingCheckpointStore(); + MongoChangeMessagingBridge bridge = + new MongoChangeMessagingBridge( + (identity, change) -> null, + envelope -> Mono.empty(), + checkpoints, + MongoBridgeCheckpointPolicy.AFTER_PUBLISH_CONFIRMED); + + bridge.handle(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + + assertThat(checkpoints.writes()).isEqualTo(1); + } + + @Test + void anIntegrationEventNeedsADeterministicMessageId() { + assertThatThrownBy(() -> new MongoIntegrationEventEnvelope("order.paid", 1, "", Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deterministic"); + } + + @Test + void theCheckpointPolicyDecidesWhatAnAmbiguousPublishMeans() { + assertThat(MongoBridgeCheckpointPolicy.AFTER_PUBLISH_CONFIRMED.mayAdvance(false, true)) + .isFalse(); + assertThat( + MongoBridgeCheckpointPolicy.AFTER_PUBLISH_AMBIGUOUS_WITH_DEDUPLICATION.mayAdvance( + false, true)) + .isTrue(); + } + + @Test + void anAtomicIntegrationNeedsAnOutboxRatherThanTheBridge() { + assertThat(MongoBridgeOutboxPolicy.forRequirement(true)) + .isEqualTo(MongoBridgeOutboxPolicy.OUTBOX_REQUIRED); + assertThat(MongoBridgeOutboxPolicy.forRequirement(false).bridgeSufficient()).isTrue(); + } + + private static MongoIntegrationEventEnvelope envelope() { + return new MongoIntegrationEventEnvelope( + "order.paid", 1, IDENTITY.value(), Map.of("orderId", "o-1")); + } + + /** Counts checkpoint writes. */ + private static final class CountingCheckpointStore implements MongoResumeCheckpointStore { + + private final AtomicInteger writes = new AtomicInteger(); + + @Override + public Mono> load(String subscriptionProfile) { + return Mono.just(Optional.empty()); + } + + @Override + public Mono save(MongoResumeCheckpoint checkpoint) { + writes.incrementAndGet(); + return Mono.empty(); + } + + @Override + public Mono clear(String subscriptionProfile) { + return Mono.empty(); + } + + private int writes() { + return writes.get(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/MongoEncryptionContractTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/MongoEncryptionContractTest.java new file mode 100644 index 00000000..2ceff391 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/encryption/MongoEncryptionContractTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.encryption; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleFieldPolicy; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleMode; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.csfle.MongoCsfleProfile; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptedFieldDescriptor; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoEncryptionMetadataOwnership; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionProfile; +import dev.caskeleton.adapter.outbound.mongo.advanced.encryption.qe.MongoQueryableEncryptionQueryType; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Tasks 6-7 — CSFLE defaults safe, QE is equality and range only. */ +@Tag("mongodb-contract") +class MongoEncryptionContractTest { + + private static final MongoCredentialReference KEY_VAULT = + new MongoCredentialReference( + "secret://mongodb/key-vault", MongoPrincipalRole.ENCRYPTION_ADMIN); + + @Test + void nonQueryablePiiDefaultsToRandomizedEncryption() { + MongoCsfleFieldPolicy policy = MongoCsfleFieldPolicy.forPii("ssn", false); + + assertThat(policy.mode()).isEqualTo(MongoCsfleMode.RANDOMIZED); + } + + @Test + void deterministicEncryptionNeedsADocumentedEqualityRequirement() { + assertThatThrownBy( + () -> new MongoCsfleFieldPolicy("email", MongoCsfleMode.DETERMINISTIC, "key-email", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("frequency analysis"); + } + + @Test + void aQueryablePiiFieldGetsDeterministicEncryptionWithAStatedReason() { + MongoCsfleFieldPolicy policy = MongoCsfleFieldPolicy.forPii("email", true); + + assertThat(policy.mode()).isEqualTo(MongoCsfleMode.DETERMINISTIC); + assertThat(policy.equalityQueryJustification()).isNotBlank(); + } + + @Test + void csfleAndQueryableEncryptionCannotShareACollection() { + assertThatThrownBy( + () -> + new MongoCsfleProfile( + "customers", + List.of(MongoCsfleFieldPolicy.forPii("ssn", false)), + KEY_VAULT, + "encryption.__keyVault", + true)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new MongoQueryableEncryptionProfile( + "customers", + List.of(MongoEncryptedFieldDescriptor.equality("ssn", "key-ssn", "string")), + true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void csfleIsRefusedOnATimeSeriesCollection() { + MongoCsfleProfile profile = + new MongoCsfleProfile( + "readings", + List.of(MongoCsfleFieldPolicy.forPii("patientId", false)), + KEY_VAULT, + "encryption.__keyVault", + false); + + assertThatThrownBy(() -> profile.requireNotTimeSeries(true)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatCode(() -> profile.requireNotTimeSeries(false)).doesNotThrowAnyException(); + } + + @Test + void mongoEightRejectsSubstringQueryableEncryption() { + assertThatThrownBy(() -> MongoQueryableEncryptionProfile.substring("name")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> MongoQueryableEncryptionProfile.prefix("name")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> MongoQueryableEncryptionProfile.suffix("name")) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void onlyEqualityAndRangeAreQueryTypes() { + assertThat(MongoQueryableEncryptionQueryType.values()) + .containsExactly( + MongoQueryableEncryptionQueryType.EQUALITY, MongoQueryableEncryptionQueryType.RANGE); + } + + @Test + void aRangeFieldMustDeclareItsDomain() { + assertThatThrownBy( + () -> + new MongoEncryptedFieldDescriptor( + "amount", + MongoQueryableEncryptionQueryType.RANGE, + "key-amount", + "long", + null, + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("re-encrypting"); + assertThat( + MongoEncryptedFieldDescriptor.range("amount", "key-amount", "long", 0, 1_000_000) + .domain()) + .isPresent(); + } + + @Test + void queryableEncryptionMetadataIsNeverApplicationOwned() { + assertThat(MongoEncryptionMetadataOwnership.metadataCollectionsFor("customers")) + .containsExactlyInAnyOrder("enxcol_.customers.esc", "enxcol_.customers.ecoc"); + assertThat(MongoEncryptionMetadataOwnership.ownershipOf("enxcol_.customers.esc")) + .isEqualTo(MongoMetadataOwnership.ENCRYPTION_MANAGED); + assertThat(MongoEncryptionMetadataOwnership.isEncryptionManaged("__safeContent___1")).isTrue(); + assertThat(MongoEncryptionMetadataOwnership.isEncryptionManaged("ix_status")).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJobTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJobTest.java new file mode 100644 index 00000000..9c58b3da --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/gridfs/MongoGridFsMigrationJobTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.gridfs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.advanced.MongoAdvancedCapabilityFlags; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Advanced plan Task 13 — the source stays until the copy verifies, and nothing is deleted here. + */ +@Tag("mongodb-contract") +class MongoGridFsMigrationJobTest { + + private static final Clock FIXED = + Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC); + + private static final MongoAdvancedCapabilityFlags ENABLED = + MongoAdvancedCapabilityFlags.allDisabled().withEnabled(MongoCapability.GRIDFS_COMPATIBILITY); + + @Test + void sourceIsRetainedUntilTargetChecksumIsVerified() { + List writtenReferences = new ArrayList<>(); + MongoGridFsMigrationJob job = + new MongoGridFsMigrationJob( + legacyId -> legacyContent("sha256:source"), + (legacyId, source) -> + new MongoGridFsObjectReference(legacyId, "content/" + legacyId, 5, "sha256:other"), + writtenReferences::add, + ENABLED, + FIXED); + + Optional migrated = job.migrate("legacy-1"); + + assertThat(migrated).isEmpty(); + assertThat(writtenReferences).isEmpty(); + } + + @Test + void aVerifiedCopySwitchesTheReference() { + List writtenReferences = new ArrayList<>(); + MongoGridFsMigrationJob job = + new MongoGridFsMigrationJob( + legacyId -> legacyContent("sha256:source"), + (legacyId, source) -> + new MongoGridFsObjectReference( + legacyId, "content/" + legacyId, source.sizeBytes(), source.checksum()), + writtenReferences::add, + ENABLED, + FIXED); + + assertThat(job.migrate("legacy-1")).isPresent(); + assertThat(writtenReferences).hasSize(1); + assertThat(writtenReferences.get(0).contentKey()).isEqualTo("content/legacy-1"); + } + + @Test + void theCapabilityMustBeEnabledBeforeTheJobExists() { + assertThatThrownBy( + () -> + new MongoGridFsMigrationJob( + legacyId -> legacyContent("sha256:source"), + (legacyId, source) -> null, + reference -> {}, + MongoAdvancedCapabilityFlags.allDisabled(), + FIXED)) + .hasMessageContaining("opt-in Advanced module"); + } + + @Test + void aReferenceWithoutAChecksumCannotBeVerified() { + assertThatThrownBy(() -> new MongoGridFsObjectReference("legacy-1", "content/legacy-1", 5, "")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theCheckpointSeparatesMigratedFromFailed() { + MongoGridFsMigrationCheckpoint checkpoint = + MongoGridFsMigrationCheckpoint.start(FIXED.instant()) + .migrated("legacy-1", FIXED.instant()) + .failed("legacy-2", FIXED.instant()); + + assertThat(checkpoint.migratedCount()).isEqualTo(1); + assertThat(checkpoint.failedCount()).isEqualTo(1); + assertThat(checkpoint.clean()).isFalse(); + assertThat(checkpoint.lastMigratedLegacyId()).isEqualTo("legacy-2"); + } + + @Test + void theCapabilityFlagNamesThePropertyThatEnablesIt() { + assertThat(MongoAdvancedCapabilityFlags.propertyFor(MongoCapability.GRIDFS_COMPATIBILITY)) + .isEqualTo("ca-skeleton.persistence-mongo.advanced.gridfs-compatibility.enabled"); + } + + private static MongoGridFsCompatibilityReader.GridFsLegacyContent legacyContent(String checksum) { + byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8); + return new MongoGridFsCompatibilityReader.GridFsLegacyContent( + "legacy-1", "invoice.pdf", bytes.length, checksum, new ByteArrayInputStream(bytes)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGateTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGateTest.java new file mode 100644 index 00000000..862fe567 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/search/MongoSearchReadinessGateTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.search; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoEmbedding; +import dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorIndexDescriptor; +import dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorQuery; +import dev.caskeleton.adapter.outbound.mongo.advanced.vector.MongoVectorSearchBenchmarkGate; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Tasks 8-9 — created is not ready, and an embedding is bound to its index. */ +@Tag("mongodb-contract") +class MongoSearchReadinessGateTest { + + private final MongoSearchReadinessGate gate = new MongoSearchReadinessGate(); + + @Test + void buildingIndexCannotServeTraffic() { + assertThatThrownBy(() -> gate.requireReady(MongoSearchIndexState.BUILDING)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void onlyAReadyIndexIsQueryable() { + assertThat(gate.queryable(MongoSearchIndexState.READY)).isTrue(); + assertThat(gate.queryable(MongoSearchIndexState.CREATED)).isFalse(); + assertThat(gate.queryable(MongoSearchIndexState.FAILED)).isFalse(); + assertThatCode(() -> gate.requireReady(MongoSearchIndexState.READY)).doesNotThrowAnyException(); + } + + @Test + void aSearchIndexIsOwnedBySearchRatherThanTheApplication() { + MongoSearchIndexDescriptor descriptor = + MongoSearchIndexDescriptor.standard("ix_search_orders", "orders", List.of("customerName")); + + assertThat(descriptor.metadataOwnership()).isEqualTo(MongoMetadataOwnership.SEARCH_MANAGED); + assertThat(descriptor.metadataOwnership().droppableByApplicationDrift()).isFalse(); + } + + @Test + void searchPathsAreAllowlisted() { + MongoSearchQuery query = + new MongoSearchQuery("ix_search_orders", List.of("customerName"), "alice", 20); + + assertThatCode(() -> query.requireAllowedPaths(Set.of("customerName"))) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> query.requireAllowedPaths(Set.of("orderNumber"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aSearchQueryIsBoundedInTextLengthAndResults() { + assertThatThrownBy( + () -> new MongoSearchQuery("ix", List.of("customerName"), "a".repeat(1000), 20)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatThrownBy(() -> new MongoSearchQuery("ix", List.of("customerName"), "a", 10_000)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void rejectsDimensionMismatch() { + MongoVectorIndexDescriptor index = MongoVectorIndexDescriptor.cosine("embedding", 3); + + assertThatThrownBy(() -> MongoEmbedding.forIndex(index, new float[] {1f, 2f})) + .isInstanceOf(IllegalArgumentException.class); + assertThat(MongoEmbedding.forIndex(index, new float[] {1f, 2f, 3f}).dimensions()).isEqualTo(3); + } + + @Test + void anEmbeddingDoesNotLeakItsBackingArray() { + MongoVectorIndexDescriptor index = MongoVectorIndexDescriptor.cosine("embedding", 3); + float[] source = {1f, 2f, 3f}; + MongoEmbedding embedding = MongoEmbedding.forIndex(index, source); + + source[0] = 99f; + embedding.values()[1] = 99f; + + assertThat(embedding.values()).containsExactly(1f, 2f, 3f); + } + + @Test + void aVectorQueryNeedsMoreCandidatesThanResults() { + MongoEmbedding embedding = + MongoEmbedding.forIndex( + MongoVectorIndexDescriptor.cosine("embedding", 3), new float[] {1f, 2f, 3f}); + + assertThatThrownBy( + () -> + new MongoVectorQuery(embedding, 10, 10, Set.of(), java.time.Duration.ofSeconds(1))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("nearest"); + assertThat(MongoVectorQuery.nearest(embedding, 10).numCandidates()).isEqualTo(200); + } + + @Test + void vectorPromotionNeedsRecallNotJustFunctionalSuccess() { + MongoVectorSearchBenchmarkGate benchmark = MongoVectorSearchBenchmarkGate.standard(); + + assertThat(benchmark.passes(0.95, 100, 1024)).isTrue(); + assertThat(benchmark.failures(0.5, 100, 1024)).anyMatch(entry -> entry.startsWith("recall")); + assertThat(MongoVectorSearchBenchmarkGate.requiredEvidence()).contains("recall"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidatorTest.java new file mode 100644 index 00000000..e504b3a3 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/ShardAwareQueryValidatorTest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.sharding; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Task 2 — routing is classified before it becomes a performance surprise. */ +@Tag("mongodb-contract") +class ShardAwareQueryValidatorTest { + + private final ShardAwareQueryValidator validator = new ShardAwareQueryValidator(); + + private final ShardKeyDescriptor key = ShardKeyDescriptor.range("tenantId", "orderId"); + + @Test + void classifiesMissingShardKeyAsScatterGather() { + assertThat(validator.classify(key, Set.of("status"))) + .isEqualTo(MongoRoutingClassification.SCATTER_GATHER); + } + + @Test + void aFullShardKeyIsTargeted() { + assertThat(validator.classify(key, Set.of("tenantId", "orderId"))) + .isEqualTo(MongoRoutingClassification.TARGETED); + } + + @Test + void aPrefixOfTheShardKeyIsPrefixTargeted() { + assertThat(validator.classify(key, Set.of("tenantId"))) + .isEqualTo(MongoRoutingClassification.PREFIX_TARGETED); + } + + @Test + void keyOrderDecidesWhetherAPrefixTargets() { + ShardKeyDescriptor reversed = ShardKeyDescriptor.range("orderId", "tenantId"); + + assertThat(validator.classify(reversed, Set.of("tenantId"))) + .isEqualTo(MongoRoutingClassification.SCATTER_GATHER); + } + + @Test + void aSingleDocumentWriteWithoutTheShardKeyIsRefused() { + assertThatThrownBy(() -> validator.requireRoutedWrite(key, Set.of("tenantId"))) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatCode(() -> validator.requireRoutedWrite(key, Set.of("tenantId", "orderId"))) + .doesNotThrowAnyException(); + } + + @Test + void anUndeclaredScatterGatherReadIsRefused() { + assertThatThrownBy(() -> validator.requireAllowedRead(key, Set.of("status"), false)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("every shard"); + assertThatCode(() -> validator.requireAllowedRead(key, Set.of("status"), true)) + .doesNotThrowAnyException(); + } + + @Test + void aUniqueIndexNotPrefixedByTheShardKeyIsRefused() { + assertThatThrownBy(() -> validator.requireCompatibleUniqueIndex(key, List.of("email"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("per shard"); + assertThatCode( + () -> + validator.requireCompatibleUniqueIndex( + key, List.of("tenantId", "orderId", "email"))) + .doesNotThrowAnyException(); + } + + @Test + void onlyRangedKeysSupportTargetedRangeQueries() { + assertThat(ShardStrategy.RANGE.supportsTargetedRangeQueries()).isTrue(); + assertThat(ShardStrategy.HASHED.supportsTargetedRangeQueries()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzerTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzerTest.java new file mode 100644 index 00000000..77f7540a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/sharding/admin/ShardKeyAnalyzerTest.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.sharding.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.ShardKeyDescriptor; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Task 3 — a shard key is committed to on evidence, not on intuition. */ +@Tag("mongodb-contract") +class ShardKeyAnalyzerTest { + + private final ShardKeyAnalyzer analyzer = new ShardKeyAnalyzer(); + + @Test + void rejectsLowCardinalityCandidate() { + ShardKeyReadinessReport report = ShardKeyReadinessReport.lowCardinality("status"); + + assertThat(report.approved()).isFalse(); + assertThat(report.reasons()).contains(ShardKeyReadinessReport.LOW_CARDINALITY); + } + + @Test + void tooFewDistinctValuesForTheShardCountIsLowCardinality() { + ShardKeyReadinessReport report = + analyzer.analyze(ShardKeyDescriptor.range("status"), 50, 4, 0.01, 0.1, 0.99, 0.99); + + assertThat(report.reasons()).contains(ShardKeyReadinessReport.LOW_CARDINALITY); + } + + @Test + void oneDominantValueIsAHotspot() { + ShardKeyReadinessReport report = + analyzer.analyze(ShardKeyDescriptor.range("tenantId"), 1_000_000, 4, 0.4, 0.1, 0.99, 0.99); + + assertThat(report.reasons()).contains(ShardKeyReadinessReport.HIGH_FREQUENCY); + } + + @Test + void aMonotonicKeySendsEveryInsertToOneChunk() { + ShardKeyReadinessReport report = + analyzer.analyze( + ShardKeyDescriptor.range("createdAt"), 1_000_000, 4, 0.001, 0.99, 0.99, 0.99); + + assertThat(report.reasons()).contains(ShardKeyReadinessReport.MONOTONIC); + } + + @Test + void poorTargetingIsReportedEvenWhenTheKeySpreadsWell() { + ShardKeyReadinessReport report = + analyzer.analyze(ShardKeyDescriptor.hashed("orderId"), 1_000_000, 4, 0.001, 0.1, 0.2, 0.99); + + assertThat(report.reasons()).contains(ShardKeyReadinessReport.POOR_TARGETING); + } + + @Test + void aGoodCandidateIsApproved() { + ShardKeyReadinessReport report = + analyzer.analyze( + ShardKeyDescriptor.range("tenantId", "orderId"), 1_000_000, 4, 0.001, 0.1, 0.99, 0.99); + + assertThat(report.approved()).isTrue(); + assertThat(report.reasons()).isEmpty(); + } + + @Test + void aReshardNeedsApprovalDryRunAndAForwardStrategy() { + ShardKeyReadinessReport approved = ShardKeyReadinessReport.approved(0.1, 0.99, 0.99); + ShardKeyDescriptor newKey = ShardKeyDescriptor.range("tenantId", "orderId"); + + assertThatThrownBy( + () -> new ReshardApproval(newKey, approved, false, "operator", "forward").require()) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("dry run"); + assertThatThrownBy(() -> new ReshardApproval(newKey, approved, true, "", "forward").require()) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("approver"); + assertThatThrownBy(() -> new ReshardApproval(newKey, approved, true, "operator", "").require()) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("forward strategy"); + assertThatCode( + () -> new ReshardApproval(newKey, approved, true, "operator", "forward").require()) + .doesNotThrowAnyException(); + } + + @Test + void aReshardOntoAnUnapprovedKeyIsRefused() { + assertThatThrownBy( + () -> + new ReshardApproval( + ShardKeyDescriptor.range("status"), + ShardKeyReadinessReport.lowCardinality("status"), + true, + "operator", + "forward") + .require()) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("LOW_CARDINALITY"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/MongoTenancyContractTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/MongoTenancyContractTest.java new file mode 100644 index 00000000..5813daf0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/tenancy/MongoTenancyContractTest.java @@ -0,0 +1,162 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.tenancy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantClientRegistry; +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantLifecyclePolicy; +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.database.MongoTenantMigrationCoordinator; +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantContext; +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantManifestValidator; +import dev.caskeleton.adapter.outbound.mongo.advanced.tenancy.shared.MongoTenantPredicateInjector; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoCollectionManifest; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoIndexManifest; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Tasks 10-11 — tenancy fails closed, and tenant clients are bounded. */ +@Tag("mongodb-contract") +class MongoTenancyContractTest { + + private final MongoTenantPredicateInjector injector = new MongoTenantPredicateInjector(); + + @Test + void missingTenantContextFailsClosed() { + assertThatThrownBy(() -> injector.apply(Optional.empty(), AtomicFilter.id("o-1"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void thePredicateIsAddedToEveryTenantScopedFilter() { + AtomicFilter scoped = + injector.apply(Optional.of(new MongoTenantContext("tenant-a")), AtomicFilter.id("o-1")); + + assertThat(scoped.fields()).contains(MongoTenantContext.tenantField()); + assertThat(scoped.value(MongoTenantContext.tenantField())).isEqualTo("tenant-a"); + } + + @Test + void anAggregationGetsTheTenantMatchAsItsFirstStage() { + assertThat( + injector + .firstStageMatch(Optional.of(new MongoTenantContext("tenant-a"))) + .getCriteriaObject() + .getString(MongoTenantContext.tenantField())) + .isEqualTo("tenant-a"); + } + + @Test + void theRawTenantIdNeverReachesTelemetry() { + MongoTenantContext tenant = new MongoTenantContext("acme-corporation"); + + assertThat(tenant.toString()).doesNotContain("acme-corporation"); + assertThat(tenant.observableKey()).hasSize(12).isNotEqualTo("acme-corporation"); + } + + @Test + void aTenantScopedUniqueIndexMustStartWithTheTenantField() { + MongoCollectionManifest manifest = + MongoCollectionManifest.builder("users") + .index( + MongoIndexManifest.named("uq_email") + .ascending("email") + .unique() + .expectedUsage("user.find-by-email") + .build()) + .build(); + + assertThatThrownBy( + () -> new MongoTenantManifestValidator().validate(manifest, List.of("uq_email"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("block every other tenant"); + } + + @Test + void aTenantPrefixedUniqueIndexIsAccepted() { + MongoCollectionManifest manifest = + MongoCollectionManifest.builder("users") + .index( + MongoIndexManifest.named("uq_tenant_email") + .ascending(MongoTenantContext.tenantField()) + .ascending("email") + .unique() + .expectedUsage("user.find-by-email") + .build()) + .build(); + + assertThatCode( + () -> new MongoTenantManifestValidator().validate(manifest, List.of("uq_tenant_email"))) + .doesNotThrowAnyException(); + } + + @Test + void theTenantFieldIsNotAssumedToBeAGoodShardKey() { + assertThatThrownBy( + () -> new MongoTenantManifestValidator().requireShardKeyAnalysed("users", false)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("one hot shard"); + } + + @Test + void registryEnforcesMaximumActiveClients() { + MongoTenantClientRegistry registry = new MongoTenantClientRegistry(2); + Instant now = Instant.parse("2026-08-13T00:00:00Z"); + + registry.acquire("t1", now); + registry.acquire("t2", now); + + assertThatThrownBy(() -> registry.acquire("t3", now)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThat(registry.activeCount()).isEqualTo(2); + } + + @Test + void anIdleClientIsEvictedToMakeRoom() { + MongoTenantClientRegistry registry = new MongoTenantClientRegistry(1, Duration.ofMinutes(5)); + Instant start = Instant.parse("2026-08-13T00:00:00Z"); + + registry.acquire("t1", start); + registry.acquire("t2", start.plus(Duration.ofMinutes(10))); + + assertThat(registry.activeTenants()).containsExactly("t2"); + } + + @Test + void aTenantDatabaseCannotServeBeforeItsSchemaIsValidated() { + MongoTenantLifecyclePolicy policy = MongoTenantLifecyclePolicy.standard(); + + assertThatThrownBy(() -> policy.requireActivationReady("t1", false)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatCode(() -> policy.requireActivationReady("t1", true)).doesNotThrowAnyException(); + } + + @Test + void offboardingNeedsRetentionAndAnExport() { + MongoTenantLifecyclePolicy policy = MongoTenantLifecyclePolicy.standard(); + + assertThatThrownBy(() -> policy.requireDeleteAllowed("t1", Duration.ofDays(1), true)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("retention window"); + assertThatThrownBy(() -> policy.requireDeleteAllowed("t1", Duration.ofDays(60), false)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("export"); + assertThatCode(() -> policy.requireDeleteAllowed("t1", Duration.ofDays(60), true)) + .doesNotThrowAnyException(); + } + + @Test + void aMigrationFanOutIsBoundedAndSkipsCompletedTenants() { + MongoTenantMigrationCoordinator coordinator = MongoTenantMigrationCoordinator.standard(); + + assertThat(coordinator.nextBatch(List.of("t1", "t2", "t3", "t4", "t5", "t6"), List.of("t1"))) + .containsExactly("t2", "t3", "t4", "t5"); + assertThat(coordinator.maxConcurrentTenants()).isEqualTo(4); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidatorTest.java new file mode 100644 index 00000000..e7675e65 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/advanced/timeseries/MongoTimeSeriesCapabilityValidatorTest.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.mongo.advanced.timeseries; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Task 5 — a time series collection does not inherit the ordinary contract. */ +@Tag("mongodb-contract") +class MongoTimeSeriesCapabilityValidatorTest { + + private final MongoTimeSeriesCapabilityValidator validator = + new MongoTimeSeriesCapabilityValidator(); + + private final MongoTimeSeriesDescriptor descriptor = + MongoTimeSeriesDescriptor.standard("observedAt", "sensor"); + + @Test + void rejectsChangeStreamOnTimeSeries() { + assertThatThrownBy(() -> validator.requireChangeStream(descriptor)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void rejectsSchemaValidatorsCsfleAndTransactionalWrites() { + assertThatThrownBy(() -> validator.requireSchemaValidator(descriptor)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> validator.requireFieldLevelEncryption(descriptor)) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> validator.requireTransactionalWrite(descriptor)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void aTimeFieldIsMandatory() { + assertThatThrownBy(() -> MongoTimeSeriesDescriptor.standard("", "sensor")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aMetadataFieldIsOptional() { + assertThat(MongoTimeSeriesDescriptor.standard("observedAt", null).meta()).isEmpty(); + assertThat(MongoTimeSeriesDescriptor.standard("observedAt", "sensor").meta()) + .contains("sensor"); + } + + @Test + void aRetentionShorterThanOneBucketIsRefused() { + MongoTimeSeriesDescriptor tooShort = + new MongoTimeSeriesDescriptor( + "observedAt", "sensor", MongoTimeSeriesGranularity.HOURS, Duration.ofMinutes(5)); + + assertThatThrownBy(() -> validator.validate(tooShort)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bucket"); + } + + @Test + void aRetentionCoveringABucketIsAccepted() { + MongoTimeSeriesDescriptor valid = + new MongoTimeSeriesDescriptor( + "observedAt", "sensor", MongoTimeSeriesGranularity.MINUTES, Duration.ofDays(30)); + + assertThatCode(() -> validator.validate(valid)).doesNotThrowAnyException(); + assertThat(valid.retentionWindow()).isPresent(); + } + + @Test + void shardingSupportDependsOnTheServerVersion() { + assertThat(validator.shardingSupported("8.0")).isTrue(); + assertThat(validator.shardingSupported("5.0")).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutorTest.java new file mode 100644 index 00000000..89fd5ff0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/aggregation/PolicyAwareMongoAggregationExecutorTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.outbound.mongo.aggregation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §17 — write stages are D4, and stage cost is graded rather than assumed. */ +@Tag("mongodb-contract") +class PolicyAwareMongoAggregationExecutorTest { + + @Test + void rejectsWriteStageInReadApi() { + MongoAggregationProfile profile = MongoAggregationProfile.stableRead(); + + assertThatThrownBy(() -> profile.requireAllowed(MongoAggregationStageDescriptor.of("$merge"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void outIsRefusedForTheSameReasonAsMerge() { + assertThatThrownBy( + () -> + MongoAggregationProfile.stableRead() + .requireAllowed(MongoAggregationStageDescriptor.of("$out"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void streamingStagesAreAllowedByDefault() { + MongoAggregationProfile profile = MongoAggregationProfile.stableRead(); + + assertThatCode(() -> profile.requireAllowed(MongoAggregationStageDescriptor.of("$match"))) + .doesNotThrowAnyException(); + assertThatCode(() -> profile.requireAllowed(MongoAggregationStageDescriptor.of("$project"))) + .doesNotThrowAnyException(); + } + + @Test + void accumulatingStagesNeedADeclaredResourceProfile() { + assertThatThrownBy( + () -> + MongoAggregationProfile.stableRead() + .requireAllowed(MongoAggregationStageDescriptor.of("$group"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("A2_BUDGETED"); + + assertThatCode( + () -> + MongoAggregationProfile.budgetedRead(false) + .requireAllowed(MongoAggregationStageDescriptor.of("$group"))) + .doesNotThrowAnyException(); + } + + @Test + void multiplyingStagesNeedExplicitReviewRegistration() { + MongoAggregationProfile reviewed = + MongoAggregationProfile.budgetedRead(false).withReviewedStages("$facet"); + + assertThatCode(() -> reviewed.requireAllowed(MongoAggregationStageDescriptor.of("$facet"))) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> reviewed.requireAllowed(MongoAggregationStageDescriptor.of("$graphLookup"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void anUnknownStageIsGradedAsNeedingReviewRatherThanCheap() { + assertThat(MongoAggregationStageDescriptor.of("$somethingNew").risk()) + .isEqualTo(MongoAggregationRisk.A3_REVIEWED); + } + + @Test + void lookupTargetsAreAllowlisted() { + MongoAggregationProfile profile = + MongoAggregationProfile.budgetedRead(false).withLookupCollections("customers"); + + assertThatCode(() -> profile.requireLookupCollection("customers")).doesNotThrowAnyException(); + assertThatThrownBy(() -> profile.requireLookupCollection("payments")) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aPipelineLongerThanTheProfileAllowsIsRefused() { + assertThatThrownBy(() -> MongoAggregationProfile.stableRead().requireStageCount(50)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aProfileCannotDeclareAdminStagesAllowed() { + assertThatThrownBy( + () -> + new MongoAggregationProfile( + Set.of(MongoAggregationRisk.A4_ADMIN), Set.of(), Set.of(), 5, false, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void diskUseIsADeclaredPropertyRatherThanAFallback() { + assertThat(MongoAggregationProfile.budgetedRead(true).allowDiskUse()).isTrue(); + assertThat(MongoAggregationProfile.stableRead().allowDiskUse()).isFalse(); + assertThat(MongoAggregationProfile.stableRead().strictMapping()).isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContextTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContextTest.java new file mode 100644 index 00000000..8cb49d3a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationContextTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.outbound.mongo.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §7.1 — every operation carries an identity, profiles, a guarantee and a deadline. */ +@Tag("mongodb-contract") +class MongoOperationContextTest { + + @Test + void requiresAPositiveTimeout() { + assertThatThrownBy( + () -> + MongoOperationContext.of( + "order.find", + "default", + "orders", + MongoConsistencyProfile.PRIMARY_LOCAL, + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void narrowingConsistencyProducesANewContext() { + MongoOperationContext original = + MongoOperationContext.of( + "order.find", + "default", + "orders", + MongoConsistencyProfile.PRIMARY_LOCAL, + Duration.ofSeconds(1)); + + MongoOperationContext narrowed = + original.withConsistency(MongoConsistencyProfile.PRIMARY_MAJORITY); + + assertThat(original.consistency()).isEqualTo(MongoConsistencyProfile.PRIMARY_LOCAL); + assertThat(narrowed.consistency()).isEqualTo(MongoConsistencyProfile.PRIMARY_MAJORITY); + } + + @Test + void scopeMarksUnresolvedProfilesRatherThanUsingNull() { + MongoOperationScope scope = + MongoOperationScope.ofOperation(new MongoOperationName("order.reserve")); + + assertThat(scope.isProfileResolved()).isFalse(); + assertThat(scope.databaseProfile().value()).isEqualTo(MongoOperationScope.UNSPECIFIED); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationNameTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationNameTest.java new file mode 100644 index 00000000..84724b7a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/MongoOperationNameTest.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.outbound.mongo.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §7.1 — operation names are bounded, low-cardinality policy and metric keys. */ +@Tag("mongodb-contract") +class MongoOperationNameTest { + + @Test + void rejectsDynamicIdentifier() { + assertThatThrownBy(() -> new MongoOperationName("order/" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsBoundedOperationName() { + assertThat(new MongoOperationName("order.find-recent").value()).isEqualTo("order.find-recent"); + } + + @Test + void rejectsUppercaseAndWhitespace() { + assertThatThrownBy(() -> new MongoOperationName("Order.FindRecent")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new MongoOperationName("order find")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsNamesTooShortOrTooLongToBeAStableKey() { + assertThatThrownBy(() -> new MongoOperationName("ab")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new MongoOperationName("o".repeat(97))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void profileNamesRejectGeneratedIdentifiers() { + assertThatThrownBy(() -> new DatabaseProfileName("tenant-" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CollectionProfileName("orders-" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySetTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySetTest.java new file mode 100644 index 00000000..3d8919f5 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/capability/MongoCapabilitySetTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.mongo.api.capability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §7.4 — an unsupported capability answers with a reason, not a bare false. */ +@Tag("mongodb-contract") +class MongoCapabilitySetTest { + + @Test + void unsupportedCapabilityPreservesReason() { + MongoCapabilitySet set = + MongoCapabilitySet.of( + new MongoCapabilitySupport( + MongoCapability.TIME_SERIES, + MongoSupportLevel.UNSUPPORTED, + Map.of("reason", "profile-disabled"))); + + assertThat(set.require(MongoCapability.TIME_SERIES).constraints()) + .containsEntry("reason", "profile-disabled"); + } + + @Test + void anUnreportedCapabilityStillCarriesAReason() { + assertThat(MongoCapabilitySet.empty().require(MongoCapability.SEARCH).constraints()) + .containsEntry(MongoCapabilitySupport.REASON, "not-reported"); + } + + @Test + void declaringUnsupportedWithoutAReasonIsRefused() { + assertThatThrownBy( + () -> + new MongoCapabilitySupport( + MongoCapability.CSFLE, MongoSupportLevel.UNSUPPORTED, Map.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void topologyVersionAndPrivilegeConstraintsAreSeparate() { + MongoCapabilitySupport support = + MongoCapabilitySupport.of(MongoCapability.TRANSACTION, MongoSupportLevel.STABLE) + .withConstraint(MongoCapabilitySupport.TOPOLOGY, "REPLICA_SET") + .withConstraint(MongoCapabilitySupport.SERVER_VERSION, "8.0") + .withConstraint(MongoCapabilitySupport.PRIVILEGE, "mongo-app-write"); + + assertThat(support.requiredTopology()).isEqualTo("REPLICA_SET"); + assertThat(support.requiredServerVersion()).isEqualTo("8.0"); + assertThat(support.requiredPrivilege()).isEqualTo("mongo-app-write"); + } + + @Test + void onlyStableCapabilitiesAreUsableOnTheStableLane() { + MongoCapabilitySet set = + MongoCapabilitySet.of( + MongoCapabilitySupport.of(MongoCapability.TRANSACTION, MongoSupportLevel.STABLE), + MongoCapabilitySupport.of(MongoCapability.SHARDING, MongoSupportLevel.ADVANCED)); + + assertThat(set.isStable(MongoCapability.TRANSACTION)).isTrue(); + assertThat(set.isStable(MongoCapability.SHARDING)).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistryTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistryTest.java new file mode 100644 index 00000000..346d49e7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/consistency/MongoConsistencyRegistryTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.mongo.api.consistency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §7.2 — stale reads are opt-in, and every profile states its real guarantee. */ +@Tag("mongodb-contract") +class MongoConsistencyRegistryTest { + + private final MongoConsistencyRegistry registry = MongoConsistencyRegistry.standard(); + + @Test + void staleReadIsExplicitAndNotDefault() { + assertThat(registry.defaultProfile()).isNotEqualTo(MongoConsistencyProfile.STALE_READ_ALLOWED); + assertThat(registry.require(MongoConsistencyProfile.STALE_READ_ALLOWED).staleReadsPossible()) + .isTrue(); + } + + @Test + void everyProfileIsDescribed() { + for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) { + assertThat(registry.require(profile).guarantee().summary()).isNotBlank(); + } + } + + @Test + void onlyTheStaleProfileReadsFromASecondary() { + for (MongoConsistencyProfile profile : MongoConsistencyProfile.values()) { + boolean expectedSecondary = profile == MongoConsistencyProfile.STALE_READ_ALLOWED; + assertThat(registry.require(profile).readsFromSecondary()).isEqualTo(expectedSecondary); + } + } + + @Test + void causalConsistencyRequiresMajorityOnBothSides() { + MongoConsistencyDescriptor causal = registry.require(MongoConsistencyProfile.CAUSAL_MAJORITY); + + assertThat(causal.requiresCausalSession()).isTrue(); + assertThat(causal.readConcern()).isEqualTo("majority"); + assertThat(causal.writeConcern()).isEqualTo("majority"); + } + + @Test + void aCausalDescriptorWithoutMajorityConcernsIsRefused() { + assertThatThrownBy( + () -> + new MongoConsistencyDescriptor( + MongoConsistencyProfile.CAUSAL_MAJORITY, + MongoConsistencyDescriptor.PRIMARY, + "local", + "majority", + true, + new MongoConsistencyGuarantee("x", true, true, false))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aRegistryCannotMakeStaleReadsTheDefault() { + List descriptors = + Arrays.stream(MongoConsistencyProfile.values()).map(registry::require).toList(); + + assertThatThrownBy( + () -> + MongoConsistencyRegistry.of( + descriptors, MongoConsistencyProfile.STALE_READ_ALLOWED)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void onlyMajorityProfilesSurviveAPrimaryFailover() { + assertThat( + registry + .require(MongoConsistencyProfile.PRIMARY_LOCAL) + .guarantee() + .durableAgainstPrimaryFailover()) + .isFalse(); + assertThat( + registry + .require(MongoConsistencyProfile.PRIMARY_MAJORITY) + .guarantee() + .durableAgainstPrimaryFailover()) + .isTrue(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContextTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContextTest.java new file mode 100644 index 00000000..71551f23 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/error/MongoFailureContextTest.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.mongo.api.error; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §7.3, §15 — ambiguous outcomes are not failures, and failure metadata carries no data. */ +@Tag("mongodb-contract") +class MongoFailureContextTest { + + @Test + void commitUnknownIsAmbiguousAndNotRetryableByDefault() { + MongoFailureContext context = + MongoFailureContext.commitUnknown( + new MongoOperationName("order.reserve"), "251", Duration.ofMillis(40)); + + assertThat(context.outcome()).isEqualTo(MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN); + assertThat(context.retryable()).isFalse(); + assertThat(context.ambiguous()).isTrue(); + } + + @Test + void bothAmbiguousOutcomesForbidBlindReplay() { + assertThat(MongoExecutionOutcome.WRITE_RESULT_UNKNOWN.forbidsBlindReplay()).isTrue(); + assertThat(MongoExecutionOutcome.TRANSACTION_COMMIT_UNKNOWN.forbidsBlindReplay()).isTrue(); + assertThat(MongoExecutionOutcome.PARTIAL_BULK_WRITE.forbidsBlindReplay()).isTrue(); + assertThat(MongoExecutionOutcome.WRITE_CONFIRMED.forbidsBlindReplay()).isFalse(); + } + + @Test + void theDescriptionCarriesNoDocumentQueryOrCredentialValue() { + MongoFailureContext context = + MongoFailureContext.commitUnknown( + new MongoOperationName("order.reserve"), "251", Duration.ofMillis(40)); + + String described = context.describe(); + + assertThat(described) + .contains("operation=order.reserve") + .contains("outcome=TRANSACTION_COMMIT_UNKNOWN") + .doesNotContain("password") + .doesNotContain("_id"); + } + + @Test + void aRejectedOperationWasNeverSent() { + MongoFailureContext context = + MongoFailureContext.rejected(new MongoOperationName("order.find")); + + assertThat(context.outcome()).isEqualTo(MongoExecutionOutcome.NOT_SENT); + assertThat(context.ambiguous()).isFalse(); + } + + @Test + void exceptionsDoNotExposeADriverCause() { + MongoPersistenceException exception = + new MongoTransactionCommitUnknownException( + MongoFailureContext.commitUnknown( + new MongoOperationName("order.reserve"), "251", Duration.ZERO), + "read the transaction record"); + + assertThat(exception.getCause()).isNull(); + assertThat(exception.category()).isEqualTo(MongoFailureCategory.TRANSACTION_COMMIT_UNKNOWN); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifestTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifestTest.java new file mode 100644 index 00000000..eb0bbf7c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/mapping/MongoTypeRepresentationManifestTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.mongo.api.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §10, D-06 — the BSON representation is fixed and cannot be left unstated. */ +@Tag("mongodb-contract") +class MongoTypeRepresentationManifestTest { + + @Test + void refusesUnspecifiedUuidRepresentation() { + assertThatThrownBy( + () -> + new MongoTypeRepresentationManifest( + null, + MongoDecimalRepresentation.DECIMAL128, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void theStandardManifestIsUuidStandardAndDecimal128() { + MongoTypeRepresentationManifest manifest = MongoTypeRepresentationManifest.standard(); + + assertThat(manifest.uuid()).isEqualTo(MongoUuidRepresentation.STANDARD); + assertThat(manifest.decimal()).isEqualTo(MongoDecimalRepresentation.DECIMAL128); + assertThat(manifest.temporal()).isEqualTo(MongoTemporalRepresentation.INSTANT_AS_BSON_DATE); + assertThat(manifest.enumRepresentation()).isEqualTo(MongoEnumRepresentation.STRING); + } + + @Test + void bigIntegerIsStatedExplicitlyEvenInTheFourAxisForm() { + MongoTypeRepresentationManifest manifest = + new MongoTypeRepresentationManifest( + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED); + + assertThat(manifest.bigInteger()).isEqualTo(MongoBigIntegerRepresentation.STRING); + } + + @Test + void aLegacyReadOnlyRepresentationCannotBeWritten() { + MongoTypeRepresentationManifest legacy = + new MongoTypeRepresentationManifest( + MongoUuidRepresentation.JAVA_LEGACY_READ_ONLY, + MongoDecimalRepresentation.DECIMAL128, + MongoBigIntegerRepresentation.STRING, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoEnumRepresentation.STRING, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED); + + assertThatThrownBy(legacy::requireWritable) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("JAVA_LEGACY_READ_ONLY"); + } + + @Test + void theFingerprintChangesWhenAnyAxisChanges() { + String standard = MongoTypeRepresentationManifest.standard().fingerprint(); + String different = + new MongoTypeRepresentationManifest( + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + MongoBigIntegerRepresentation.DECIMAL128, + MongoTemporalRepresentation.INSTANT_AS_BSON_DATE, + MongoEnumRepresentation.STRING, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED) + .fingerprint(); + + assertThat(standard).isNotEqualTo(different); + } + + @Test + void localDateTimeRequiresANamedConverter() { + assertThat( + MongoTemporalRepresentation.LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER + .requiresRegisteredConverter()) + .isTrue(); + assertThat(MongoTemporalRepresentation.INSTANT_AS_BSON_DATE.requiresRegisteredConverter()) + .isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfileTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfileTest.java new file mode 100644 index 00000000..6b245a78 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/profile/MongoRuntimeProfileTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.api.profile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.DatabaseProfileName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §4, §8, D-03, D-04 — topology and Stable API are startup decisions, not warnings. */ +@Tag("mongodb-contract") +class MongoRuntimeProfileTest { + + @Test + void productionRejectsStandalone() { + assertThatThrownBy(() -> MongoRuntimeProfile.production(MongoTopology.STANDALONE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void localAcceptsStandaloneForSmokeTests() { + assertThat(MongoRuntimeProfile.local(MongoTopology.STANDALONE).production()).isFalse(); + } + + @Test + void runtimeProfilesPinStableApiV1Strict() { + MongoRuntimeProfile profile = MongoRuntimeProfile.production(MongoTopology.REPLICA_SET); + + assertThat(profile.stableApi().isRuntimeStrict()).isTrue(); + assertThat(profile.stableApi().version()).isEqualTo(MongoStableApiProfile.V1); + } + + @Test + void aRuntimeProfileCannotRelaxTheStableApi() { + assertThatThrownBy( + () -> + new MongoRuntimeProfile( + new DatabaseProfileName("default"), + MongoClientPlane.RUNTIME, + MongoTopology.REPLICA_SET, + MongoStableApiProfile.v1Relaxed(), + true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void capabilityAndAdminProfilesAreDistinctFromTheRuntimePlane() { + DatabaseProfileName database = new DatabaseProfileName("default"); + + assertThat( + MongoRuntimeProfile.capability( + database, MongoTopology.ATLAS, MongoStableApiProfile.v1Relaxed()) + .isRuntimePlane()) + .isFalse(); + assertThat(MongoRuntimeProfile.admin(database, MongoTopology.REPLICA_SET).isRuntimePlane()) + .isFalse(); + } + + @Test + void transactionsAndChangeStreamsRequireAnOplogTopology() { + MongoRuntimeProfile standalone = MongoRuntimeProfile.local(MongoTopology.STANDALONE); + + assertThatThrownBy(() -> standalone.require(MongoTopologyRequirement.transactions())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("REPLICA_SET"); + assertThatThrownBy(() -> standalone.require(MongoTopologyRequirement.changeStreams())) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicyTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicyTest.java new file mode 100644 index 00000000..d2f1d043 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/api/schema/MongoSchemaVersionPolicyTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.mongo.api.schema; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoDataSchemaUnsupportedException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §12.2 — an unreadable schema version fails before domain deserialization. */ +@Tag("mongodb-contract") +class MongoSchemaVersionPolicyTest { + + @Test + void rejectsFutureVersion() { + MongoSchemaVersionPolicy policy = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(2), new DocumentSchemaVersion(4), true); + + assertThatThrownBy(() -> policy.requireReadable(new DocumentSchemaVersion(5))) + .isInstanceOf(MongoDataSchemaUnsupportedException.class); + } + + @Test + void rejectsRetiredVersion() { + MongoSchemaVersionPolicy policy = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(2), new DocumentSchemaVersion(4), false); + + assertThatThrownBy(() -> policy.requireReadable(new DocumentSchemaVersion(1))) + .isInstanceOf(MongoDataSchemaUnsupportedException.class); + } + + @Test + void missingVersionIsLegacyOnlyWhenTheCollectionAllowsIt() { + MongoSchemaVersionPolicy permissive = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(0), new DocumentSchemaVersion(3), true); + MongoSchemaVersionPolicy strict = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(1), new DocumentSchemaVersion(3), false); + + assertThat(permissive.resolveMissingVersion()).isEqualTo(DocumentSchemaVersion.LEGACY_V0); + assertThatThrownBy(strict::resolveMissingVersion) + .isInstanceOf(MongoDataSchemaUnsupportedException.class); + } + + @Test + void newWritesAlwaysUseTheCurrentVersion() { + MongoSchemaVersionPolicy policy = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(2), new DocumentSchemaVersion(4), true); + + assertThat(policy.writeVersion()).isEqualTo(new DocumentSchemaVersion(4)); + } + + @Test + void readingAnOlderSupportedVersionIsAMeteredConversion() { + MongoSchemaVersionPolicy policy = + new MongoSchemaVersionPolicy( + new DocumentSchemaVersion(2), new DocumentSchemaVersion(4), false); + + assertThat(policy.requiresReadTimeConversion(new DocumentSchemaVersion(3))).isTrue(); + assertThat(policy.requiresReadTimeConversion(new DocumentSchemaVersion(4))).isFalse(); + } + + @Test + void aNegativeVersionIsNotAVersion() { + assertThatThrownBy(() -> new DocumentSchemaVersion(-1)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoModuleBoundaryTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoModuleBoundaryTest.java new file mode 100644 index 00000000..a4319a9c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoModuleBoundaryTest.java @@ -0,0 +1,170 @@ +package dev.caskeleton.adapter.outbound.mongo.architecture; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.syntax.ArchRuleDefinition; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The design's module dependency table, enforced as package rules (design §6.3). + * + *

This repository's fail-closed 19-leaf registry outranks the design's 19-Gradle-module layout, + * so the boundaries live in packages. These rules are what keeps that adaptation honest: without + * them, "packages instead of modules" would mean "no boundary at all". + */ +@Tag("mongodb-contract") +class MongoModuleBoundaryTest { + + private static final String ROOT = "dev.caskeleton.adapter.outbound.mongo"; + + private static final JavaClasses PLATFORM = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS) + .importPackages(ROOT); + + @Test + void coreApiIsFreeOfSpringDriverBsonAndReactor() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.api..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.springframework..", "com.mongodb..", "org.bson..", "reactor..", "io.micrometer..") + .as("mongodb-core-api depends on the Java standard library only (design §6.3)") + .check(PLATFORM); + } + + @Test + void coreApiDependsOnNoOtherPlatformPackage() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.api..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..outbound.mongo.springdata..", + "..outbound.mongo.mapping..", + "..outbound.mongo.failure..", + "..outbound.mongo.imperative..", + "..outbound.mongo.reactive..", + "..outbound.mongo.query..", + "..outbound.mongo.aggregation..", + "..outbound.mongo.transaction..", + "..outbound.mongo.schema..", + "..outbound.mongo.changestream..", + "..outbound.mongo.geo..", + "..outbound.mongo.migration..", + "..outbound.mongo.observation..", + "..outbound.mongo.security..", + "..outbound.mongo.autoconfigure..", + "..outbound.mongo.advanced..") + .as("mongodb-core-api has no project dependency (design §6.3)") + .check(PLATFORM); + } + + @Test + void theStableStarterNeverReachesIntoAnAdvancedCapability() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.autoconfigure..") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.advanced..") + .as("the Stable starter has no advanced dependency (advanced plan Task 1)") + .check(PLATFORM); + } + + @Test + void noStablePackageDependsOnAnAdvancedCapability() { + // The testkit is excluded on both sides: it is not production code, and its sharded and Atlas + // fixtures exist precisely to exercise the advanced capabilities. + ArchRuleDefinition.noClasses() + .that() + .resideOutsideOfPackages("..outbound.mongo.advanced..", "..outbound.mongo.testkit..") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.advanced..") + .as("Advanced capabilities are never a transitive dependency of Stable (design D-15)") + .check(PLATFORM); + } + + @Test + void theImperativePathDoesNotDependOnTheReactiveOne() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.imperative..") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.reactive..") + .as("mongodb-imperative depends on core-api and spring-data only (design §6.3)") + .check(PLATFORM); + } + + @Test + void theAggregationModuleDependsOnQueryRatherThanTheOtherWayRound() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.query..") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.aggregation..") + .as("mongodb-aggregation depends on mongodb-query, not the reverse (design §6.3)") + .check(PLATFORM); + } + + @Test + void noProductionPackageDependsOnTheTestkit() { + ArchRuleDefinition.noClasses() + .that() + .resideOutsideOfPackage("..outbound.mongo.testkit..") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.testkit..") + .as("production code never depends on the testkit (design §6.3)") + .check(PLATFORM); + } + + @Test + void theSchemaAndIndexModuleDoesNotDependOnAnExecutionPath() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.schema..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("..outbound.mongo.imperative..", "..outbound.mongo.reactive..") + .as("mongodb-index-schema depends on core-api and spring-data only (design §6.3)") + .check(PLATFORM); + } + + @Test + void theObservabilityModuleDependsOnCoreApiOnly() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.observation..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "..outbound.mongo.imperative..", + "..outbound.mongo.reactive..", + "..outbound.mongo.transaction..", + "..outbound.mongo.query..") + .as("mongodb-observability depends on core-api (design §6.3)") + .check(PLATFORM); + } + + @Test + void theMigrationCoreDoesNotDependOnTheEngineAdapter() { + ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..outbound.mongo.migration") + .should() + .dependOnClassesThat() + .resideInAPackage("..outbound.mongo.migration.flamingock..") + .as("the platform migration contract does not depend on the engine adapter (design §12.4)") + .check(PLATFORM); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRulesTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRulesTest.java new file mode 100644 index 00000000..4dff114b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/architecture/MongoRepositoryArchitectureRulesTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.mongo.architecture; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design D-01 — there is no platform CRUD base repository, and no template in a controller. */ +@Tag("mongodb-contract") +class MongoRepositoryArchitectureRulesTest { + + private final MongoRepositoryArchitectureRules rules = new MongoRepositoryArchitectureRules(); + + @Test + void platformDoesNotDeclareGenericCrudRepository() { + assertThat(rules.forbiddenTypeNames()) + .contains("CommonMongoRepository", "GenericMongoRepository"); + } + + @Test + void domainRepositoriesMayExtendSpringDataDirectly() { + assertThat(rules.allowedRepositorySuperTypes()) + .contains( + "org.springframework.data.mongodb.repository.MongoRepository", + "org.springframework.data.mongodb.repository.ReactiveMongoRepository"); + } + + @Test + void inboundAdaptersMayNotInjectTemplatesOrDriverHandles() { + assertThat(rules.typesForbiddenInInboundAdapters()) + .contains( + "org.springframework.data.mongodb.core.MongoTemplate", + "com.mongodb.client.MongoClient", + "com.mongodb.client.MongoDatabase", + "com.mongodb.client.MongoCollection"); + } + + @Test + void theForbiddenNameCheckIsUsableDirectly() { + assertThat(rules.isForbiddenRepositoryName("CommonMongoRepository")).isTrue(); + assertThat(rules.isForbiddenRepositoryName("OrderRepository")).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformPropertiesTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformPropertiesTest.java new file mode 100644 index 00000000..485f2b5f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoPlatformPropertiesTest.java @@ -0,0 +1,186 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialRotationPolicy; +import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §28 — production configuration is validated at binding time, not at first use. */ +@Tag("mongodb-contract") +class MongoPlatformPropertiesTest { + + @Test + void productionUriMustBeSecretReference() { + MongoProfileProperties profile = MongoProfileProperties.production("mongodb://user:pass@db"); + + assertThatThrownBy(profile::validate).isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aSecretReferenceIsAccepted() { + assertThatCode( + () -> MongoProfileProperties.production("secret://mongodb/default-uri").validate()) + .doesNotThrowAnyException(); + } + + @Test + void productionRejectsStandaloneRuntimeAutoIndexAndRelaxedStableApi() { + MongoProfileProperties standalone = + withTopology( + MongoProfileProperties.production("secret://mongodb/uri"), MongoTopology.STANDALONE); + + assertThatThrownBy(standalone::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("STANDALONE"); + } + + @Test + void productionRefusesRuntimeIndexCreation() { + MongoProfileProperties autoIndex = + new MongoProfileProperties( + "secret://mongodb/uri", + MongoTopology.REPLICA_SET, + true, + true, + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(5), + Duration.ofSeconds(3), + 2, + 40, + Duration.ofMillis(500), + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + true, + true, + true); + + assertThatThrownBy(autoIndex::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("auto index creation"); + } + + @Test + void anOperationDeadlineLongerThanTheSocketReadIsARedundantDeadline() { + MongoProfileProperties inconsistent = + new MongoProfileProperties( + "secret://mongodb/uri", + MongoTopology.REPLICA_SET, + true, + true, + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(3), + Duration.ofSeconds(2), + Duration.ofSeconds(1), + Duration.ofSeconds(10), + 2, + 40, + Duration.ofMillis(500), + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + false, + true, + true); + + assertThatThrownBy(inconsistent::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("socket read"); + } + + @Test + void anInvalidProfileNameIsRejectedWithTheProfileNamed() { + MongoPlatformProperties properties = + new MongoPlatformProperties( + Map.of("Tenant_A", MongoProfileProperties.local("secret://mongodb/uri"))); + + assertThatThrownBy(properties::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("Tenant_A"); + } + + @Test + void anAbsentProfileMapBindsToAnEmptyConfiguration() { + assertThat(new MongoPlatformProperties(null).profiles()).isEmpty(); + assertThat(MongoPlatformProperties.empty().profiles()).isEmpty(); + } + + @Test + void anUnconfiguredProfileIsRefusedRatherThanDefaulted() { + assertThatThrownBy(() -> MongoPlatformProperties.empty().require("default")) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void rotationCreatesANewGenerationAndDrainsThePrevious() { + MongoClientGenerationRegistry registry = + new MongoClientGenerationRegistry( + MongoCredentialRotationPolicy.standard(), + Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC)); + registry.register( + "default", + new MongoCredentialReference("secret://mongodb/app-v1", MongoPrincipalRole.APP_WRITE)); + + MongoClientGeneration next = + registry.rotate( + "default", + new MongoCredentialReference("secret://mongodb/app-v2", MongoPrincipalRole.APP_WRITE)); + + assertThat(next.generation()).isEqualTo(2); + assertThat(registry.drainingGeneration("default")).isPresent(); + assertThat(registry.drainingGeneration("default").orElseThrow().draining()).isTrue(); + } + + @Test + void aMappingRepresentationChangeCannotBeHotReloaded() { + MongoClientGenerationRegistry registry = + new MongoClientGenerationRegistry( + MongoCredentialRotationPolicy.standard(), + Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC)); + + assertThatCode( + () -> registry.requireMappingUnchanged("default", "uuid=STANDARD", "uuid=STANDARD")) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> registry.requireMappingUnchanged("default", "uuid=STANDARD", "uuid=JAVA_LEGACY")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("restart and a migration"); + } + + private static MongoProfileProperties withTopology( + MongoProfileProperties profile, MongoTopology topology) { + return new MongoProfileProperties( + profile.uriSecret(), + topology, + profile.production(), + profile.stableApiStrict(), + profile.consistencyDefault(), + profile.serverSelectionTimeout(), + profile.connectTimeout(), + profile.socketReadTimeout(), + profile.operationTimeout(), + profile.poolMinSize(), + profile.poolMaxSize(), + profile.poolMaxWaitTime(), + profile.uuidRepresentation(), + profile.decimalRepresentation(), + profile.runtimeAutoCreateIndexes(), + profile.tlsRequired(), + profile.authenticationRequired()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGateTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGateTest.java new file mode 100644 index 00000000..e1c8dbfa --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStableReleaseGateTest.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §30, Task 50 — a Stable release needs every evidence category, not a green suite. */ +@Tag("mongodb-contract") +class MongoStableReleaseGateTest { + + private final MongoStableReleaseGate gate = new MongoStableReleaseGate(); + + @Test + void stableReleaseRequiresEveryEvidenceCategory() { + MongoStableReleaseEvidence evidence = MongoStableReleaseEvidence.complete(); + + assertThat(evidence.categories()) + .contains( + "mapping", + "transaction", + "migration", + "change-stream", + "security", + "failover", + "performance", + "compatibility"); + assertThatCode(() -> gate.verify(evidence)).doesNotThrowAnyException(); + } + + @Test + void missingFailoverEvidenceStopsTheRelease() { + MongoStableReleaseEvidence evidence = + MongoStableReleaseEvidence.empty() + .with("mapping") + .with("transaction") + .with("migration") + .with("change-stream") + .with("security") + .with("performance") + .with("compatibility"); + + assertThatThrownBy(() -> gate.verify(evidence)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("failover"); + assertThat(evidence.missing()).containsExactly("failover"); + } + + @Test + void missingCompatibilityEvidenceStopsTheReleaseToo() { + MongoStableReleaseEvidence evidence = + MongoStableReleaseEvidence.complete().categories().contains("compatibility") + ? MongoStableReleaseEvidence.empty() + .with("mapping") + .with("transaction") + .with("migration") + .with("change-stream") + .with("security") + .with("failover") + .with("performance") + : MongoStableReleaseEvidence.complete(); + + assertThat(gate.passes(evidence)).isFalse(); + } + + @Test + void theStableGateNeverCertifiesAnAdvancedCapability() { + assertThat(gate.includesAdvancedCapabilities()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidatorTest.java new file mode 100644 index 00000000..b5e5efd7 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/autoconfigure/MongoStartupValidatorTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.mongo.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.profile.MongoTopology; +import dev.caskeleton.adapter.outbound.mongo.api.schema.MongoSchemaVersionRange; +import dev.caskeleton.adapter.outbound.mongo.security.MongoCredentialReference; +import dev.caskeleton.adapter.outbound.mongo.security.MongoPrincipalRole; +import dev.caskeleton.adapter.outbound.mongo.security.MongoSecurityProfile; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §28, §43 — every listed misconfiguration fails before the first repository exists. */ +@Tag("mongodb-contract") +class MongoStartupValidatorTest { + + private static final MongoCredentialReference RUNTIME_CREDENTIAL = + new MongoCredentialReference("secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE); + + private static final MongoCredentialReference ADMIN_CREDENTIAL = + new MongoCredentialReference("secret://mongodb/admin", MongoPrincipalRole.DBA); + + @Test + void productionStandaloneFailsBeforeRepositoryCreation() { + MongoStartupValidator validator = + validator( + MongoProfileProperties.production("secret://mongodb/uri"), + new MongoTopologyProbe(MongoTopology.STANDALONE, "8.0"), + true, + true); + + assertThatThrownBy(validator::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("REPLICA_SET"); + } + + @Test + void aDeclaredTopologyThatDoesNotMatchRealityFails() { + MongoStartupValidator validator = + validator( + MongoProfileProperties.production("secret://mongodb/uri"), + new MongoTopologyProbe(MongoTopology.SHARDED, "8.0"), + false, + false); + + assertThatThrownBy(validator::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("connected to SHARDED"); + } + + @Test + void transactionsEnabledWithoutTheTopologyFails() { + MongoStartupValidator validator = + validator( + MongoProfileProperties.local("secret://mongodb/uri"), + new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"), + true, + false); + + assertThatCode(validator::validate).doesNotThrowAnyException(); + } + + @Test + void aHealthyReplicaSetProfileStartsCleanly() { + MongoStartupValidator validator = + validator( + MongoProfileProperties.production("secret://mongodb/uri"), + new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"), + true, + true); + + assertThatCode(validator::validate).doesNotThrowAnyException(); + } + + @Test + void theProbeReportsSessionCapabilitiesAsUnsupportedOnStandalone() { + MongoTopologyProbe probe = new MongoTopologyProbe(MongoTopology.STANDALONE, "8.0"); + + assertThat(probe.capabilities().isStable(MongoCapability.TRANSACTION)).isFalse(); + assertThat(probe.capabilities().require(MongoCapability.CHANGE_STREAM).constraints()) + .containsEntry("reason", "topology is STANDALONE, which has no oplog"); + } + + @Test + void searchAndVectorAreUnavailableOutsideAtlas() { + MongoTopologyProbe probe = new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"); + + assertThat(probe.capabilities().require(MongoCapability.SEARCH).constraints()) + .containsEntry("reason", "search requires an Atlas deployment"); + assertThat(probe.capabilities().require(MongoCapability.VECTOR_SEARCH).constraints()) + .containsKey("reason"); + } + + @Test + void theAdminPlaneIsNeverAvailableInAnApplicationRuntime() { + MongoTopologyProbe probe = new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"); + + assertThat(probe.capabilities().isStable(MongoCapability.ADMIN_PLANE)).isFalse(); + } + + @Test + void aTopologyMismatchMakesTheInstanceUnreadyWithoutKillingIt() { + MongoPlatformHealthIndicator health = + new MongoPlatformHealthIndicator( + new MongoTopologyProbe(MongoTopology.SHARDED, "8.0"), + new MongoPlatformProperties( + Map.of("default", MongoProfileProperties.production("secret://mongodb/uri"))), + 2); + + assertThat(health.live()).isTrue(); + assertThat(health.ready()).isFalse(); + assertThat(health.details()).containsEntry("topologyMismatch", true); + } + + @Test + void degradedSecondaryAvailabilityIsReportedWithoutFailingReadiness() { + MongoPlatformHealthIndicator health = + new MongoPlatformHealthIndicator( + new MongoTopologyProbe(MongoTopology.REPLICA_SET, "8.0"), + new MongoPlatformProperties( + Map.of("default", MongoProfileProperties.production("secret://mongodb/uri"))), + 1); + + assertThat(health.ready()).isTrue(); + assertThat(health.degradedSecondaryAvailability()).isTrue(); + } + + private static MongoStartupValidator validator( + MongoProfileProperties profile, + MongoTopologyProbe probe, + boolean transactionsEnabled, + boolean changeStreamsEnabled) { + return new MongoStartupValidator( + new MongoPlatformProperties(Map.of("default", profile)), + probe, + MongoSecurityProfile.production(RUNTIME_CREDENTIAL, Set.of()), + ADMIN_CREDENTIAL, + transactionsEnabled, + changeStreamsEnabled, + MongoSchemaVersionRange.of(1, 3)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStoreTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStoreTest.java new file mode 100644 index 00000000..60a0312b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/MongoResumeCheckpointStoreTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName; +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §20.2, §20.3 — a resume token is opaque, never logged, and knows how to be replayed. */ +@Tag("mongodb-contract") +class MongoResumeCheckpointStoreTest { + + @Test + void checkpointNeverExposesRawTokenInToString() { + MongoResumeCheckpoint checkpoint = + MongoResumeCheckpoint.encrypted("orders", new byte[] {1, 2, 3}); + + assertThat(checkpoint.toString()).doesNotContain("1, 2, 3"); + assertThat(checkpoint.toString()).contains("subscription=orders").contains("tokenChars="); + } + + @Test + void anInvalidateCheckpointRemembersItNeedsStartAfter() { + MongoResumeCheckpoint ordinary = + MongoResumeCheckpoint.encrypted("orders", new byte[] {1, 2, 3}); + MongoResumeCheckpoint afterInvalidate = + MongoResumeCheckpoint.afterInvalidate("orders", new byte[] {1, 2, 3}); + + assertThat(ordinary.position()).isEqualTo(MongoResumePosition.RESUME_AFTER); + assertThat(afterInvalidate.position()).isEqualTo(MongoResumePosition.START_AFTER); + } + + @Test + void aCheckpointFromAnotherClusterIsDetectable() { + MongoResumeCheckpoint checkpoint = + MongoResumeCheckpoint.encrypted("orders", new byte[] {1, 2, 3}) + .withClusterIdentity("cluster-a"); + + assertThat(checkpoint.belongsTo("cluster-a")).isTrue(); + assertThat(checkpoint.belongsTo("cluster-b")).isFalse(); + } + + @Test + void anEmptyTokenIsNotACheckpoint() { + assertThatThrownBy(() -> MongoResumeCheckpoint.encrypted("orders", new byte[0])) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theStoredCiphertextRoundTrips() { + byte[] ciphertext = {9, 8, 7, 6}; + + assertThat(MongoResumeCheckpoint.encrypted("orders", ciphertext).ciphertext()) + .containsExactly(ciphertext); + } + + @Test + void aSubscriptionBoundsItsBatchAndAwaitTime() { + assertThatThrownBy( + () -> + new MongoChangeStreamSubscription( + "orders", + new CollectionProfileName("orders"), + 5_000, + Duration.ofSeconds(1), + false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void eventIdentityIsStableAcrossRedeliveryAndHidesTheDocumentKey() { + MongoChangeEventIdentity first = + MongoChangeEventIdentity.of("1700000000.1", "shop.orders", "{\"_id\":\"o-1\"}", "insert"); + MongoChangeEventIdentity second = + MongoChangeEventIdentity.of("1700000000.1", "shop.orders", "{\"_id\":\"o-1\"}", "insert"); + MongoChangeEventIdentity other = + MongoChangeEventIdentity.of("1700000000.2", "shop.orders", "{\"_id\":\"o-1\"}", "insert"); + + assertThat(first).isEqualTo(second); + assertThat(first).isNotEqualTo(other); + assertThat(first.value()).doesNotContain("o-1"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunnerTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunnerTest.java new file mode 100644 index 00000000..17c4bd49 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/projector/MongoChangeStreamRunnerTest.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.projector; + +import static org.assertj.core.api.Assertions.assertThat; + +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.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.bson.BsonDocument; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** Design §20.2 — project first, checkpoint second, and never repeat a side effect. */ +@Tag("mongodb-contract") +class MongoChangeStreamRunnerTest { + + private static final MongoChangeEventIdentity IDENTITY = + MongoChangeEventIdentity.of("1700000000.1", "shop.orders", "{\"_id\":\"o-1\"}", "insert"); + + private static final MongoResumeCheckpoint CHECKPOINT = + MongoResumeCheckpoint.encrypted("orders", new byte[] {1, 2, 3}); + + @Test + void failedProjectionDoesNotAdvanceCheckpoint() { + RecordingCheckpointStore checkpoints = new RecordingCheckpointStore(); + MongoChangeStreamRunner runner = + new MongoChangeStreamRunner( + (identity, change) -> Mono.error(new IllegalStateException("projector failed")), + new InMemoryDeduplicationStore(), + checkpoints); + + runner + .runOne(IDENTITY, new BsonDocument(), CHECKPOINT) + .onErrorResume(failure -> Mono.empty()) + .block(); + + assertThat(checkpoints.saveCount()).isZero(); + } + + @Test + void aSuccessfulProjectionAdvancesTheCheckpointAfterwards() { + RecordingCheckpointStore checkpoints = new RecordingCheckpointStore(); + AtomicInteger projections = new AtomicInteger(); + MongoChangeStreamRunner runner = + new MongoChangeStreamRunner( + (identity, change) -> { + projections.incrementAndGet(); + return Mono.just(MongoChangeProjectionResult.applied()); + }, + new InMemoryDeduplicationStore(), + checkpoints); + + runner.runOne(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + + assertThat(projections).hasValue(1); + assertThat(checkpoints.saveCount()).isEqualTo(1); + } + + @Test + void aRedeliveredEventDoesNotRepeatTheSideEffect() { + RecordingCheckpointStore checkpoints = new RecordingCheckpointStore(); + AtomicInteger projections = new AtomicInteger(); + InMemoryDeduplicationStore deduplication = new InMemoryDeduplicationStore(); + MongoChangeStreamRunner runner = + new MongoChangeStreamRunner( + (identity, change) -> { + projections.incrementAndGet(); + return Mono.just(MongoChangeProjectionResult.applied()); + }, + deduplication, + checkpoints); + + runner.runOne(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + MongoChangeProjectionResult second = + runner.runOne(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + + assertThat(projections).hasValue(1); + assertThat(second.outcome()).isEqualTo(MongoChangeProjectionResult.Outcome.SKIPPED_DUPLICATE); + assertThat(checkpoints.saveCount()).isEqualTo(2); + } + + @Test + void aParkedEventDoesNotAdvanceTheCheckpoint() { + RecordingCheckpointStore checkpoints = new RecordingCheckpointStore(); + MongoChangeStreamRunner runner = + new MongoChangeStreamRunner( + (identity, change) -> Mono.just(MongoChangeProjectionResult.parked("malformed")), + new InMemoryDeduplicationStore(), + checkpoints); + + runner.runOne(IDENTITY, new BsonDocument(), CHECKPOINT).block(); + + assertThat(checkpoints.saveCount()).isZero(); + } + + /** Counts checkpoint saves. */ + private static final class RecordingCheckpointStore implements MongoResumeCheckpointStore { + + private final AtomicInteger saves = new AtomicInteger(); + + @Override + public Mono> load(String subscriptionProfile) { + return Mono.just(Optional.empty()); + } + + @Override + public Mono save(MongoResumeCheckpoint checkpoint) { + saves.incrementAndGet(); + return Mono.empty(); + } + + @Override + public Mono clear(String subscriptionProfile) { + return Mono.empty(); + } + + private int saveCount() { + return saves.get(); + } + } + + /** Remembers identities in memory. */ + private static final class InMemoryDeduplicationStore implements MongoChangeDeduplicationStore { + + private final Set seen = new HashSet<>(); + + @Override + public Mono alreadyProjected(MongoChangeEventIdentity identity) { + return Mono.just(seen.contains(identity)); + } + + @Override + public Mono markProjected(MongoChangeEventIdentity identity) { + seen.add(identity); + return Mono.empty(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicyTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicyTest.java new file mode 100644 index 00000000..d20e9e47 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/changestream/recovery/MongoChangeStreamRecoveryPolicyTest.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.changestream.recovery; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoChangeStreamState; +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumeCheckpoint; +import dev.caskeleton.adapter.outbound.mongo.changestream.MongoResumePosition; +import dev.caskeleton.adapter.outbound.mongo.failure.MongoDriverFailureView; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §20.3 — a lost oplog history stops consumption instead of silently skipping a gap. */ +@Tag("mongodb-contract") +class MongoChangeStreamRecoveryPolicyTest { + + private final MongoChangeStreamRecoveryPolicy policy = new MongoChangeStreamRecoveryPolicy(); + + @Test + void historyLostNeverStartsFromNowAutomatically() { + MongoChangeStreamRecoveryDecision decision = policy.onHistoryLost("orders"); + + assertThat(decision.state()).isEqualTo(MongoChangeStreamState.HISTORY_LOST); + assertThat(decision.autoResume()).isFalse(); + assertThat(decision.requiredRunbook()) + .isEqualTo(MongoChangeStreamRecoveryPolicy.HISTORY_LOST_RUNBOOK); + } + + @Test + void aResumableFailureResumesWithoutAnOperator() { + assertThat(policy.onResumableFailure().autoResume()).isTrue(); + } + + @Test + void theHistoryLostServerCodesAreRecognised() { + assertThat(policy.onFailure(MongoDriverFailureView.withServerCode(286)).state()) + .isEqualTo(MongoChangeStreamState.HISTORY_LOST); + assertThat(policy.onFailure(MongoDriverFailureView.withServerCode(280)).state()) + .isEqualTo(MongoChangeStreamState.HISTORY_LOST); + } + + @Test + void anUnclassifiedFailureStopsAndNamesARunbook() { + MongoChangeStreamRecoveryDecision decision = + policy.onFailure(MongoDriverFailureView.withServerCode(1)); + + assertThat(decision.state()).isEqualTo(MongoChangeStreamState.FAILED); + assertThat(decision.autoResume()).isFalse(); + assertThat(decision.requiredRunbook()).isNotBlank(); + } + + @Test + void anInvalidateCheckpointMustBeReplayedWithStartAfter() { + MongoInvalidateRecovery recovery = new MongoInvalidateRecovery(); + MongoResumeCheckpoint checkpoint = recovery.checkpointFor("orders", new byte[] {1, 2, 3}); + + assertThat(checkpoint.position()).isEqualTo(MongoResumePosition.START_AFTER); + assertThatThrownBy( + () -> recovery.requireCorrectResumeOption(checkpoint, MongoResumePosition.RESUME_AFTER)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void onlyTheRunningStatesResumeOnTheirOwn() { + assertThat(MongoChangeStreamState.RUNNING.autoResumable()).isTrue(); + assertThat(MongoChangeStreamState.HISTORY_LOST.autoResumable()).isFalse(); + assertThat(MongoChangeStreamState.FAILED.autoResumable()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/compat/MongoVersionCompatibilityLaneTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/compat/MongoVersionCompatibilityLaneTest.java new file mode 100644 index 00000000..32e6fa48 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/compat/MongoVersionCompatibilityLaneTest.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.outbound.mongo.compat; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mongodb.client.ChangeStreamIterable; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCursor; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.changestream.ChangeStreamDocument; +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction; +import dev.caskeleton.adapter.outbound.mongo.testkit.compat.MongoVersionCapabilityReport; +import dev.caskeleton.adapter.outbound.mongo.testkit.compat.MongoVersionMatrix; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoSingleReplicaSetContainer; +import java.time.Duration; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; +import org.bson.Document; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The compatibility lane: both certified server versions, against real servers (design §4, §30). + * + *

The support matrix is generated here rather than written by hand. A hand-written matrix + * records what someone believed when they wrote it, and the first version whose behaviour changes + * leaves it silently wrong; a matrix produced by a run is evidence. + * + *

Tagged {@code mongodb-compatibility}: it starts one container per certified version and runs + * in the {@code mongoCompatibilityTest} lane. + */ +@Tag("mongodb-compatibility") +class MongoVersionCompatibilityLaneTest { + + private static final Duration CHANGE_EVENT_TIMEOUT = Duration.ofSeconds(30); + + @Test + void mongoEightIsTheCertifiedPrimaryLane() { + MongoVersionCapabilityReport report = + certify(MongoSingleReplicaSetContainer.mongoEight(), "8.0"); + + assertThat(report.supports("transaction")).isTrue(); + assertThat(report.supports("changeStream")).isTrue(); + assertThat(MongoVersionMatrix.standard().primaryCertificationLane()).isEqualTo("8.0"); + } + + @Test + void mongoSevenRemainsACertifiedCompatibilityLane() { + MongoVersionCapabilityReport report = + certify(MongoSingleReplicaSetContainer.mongoSeven(), "7.0"); + + assertThat(report.supports("transaction")).isTrue(); + assertThat(report.supports("changeStream")).isTrue(); + assertThat(MongoVersionMatrix.standard().compatibilityLanes()).containsExactly("7.0"); + } + + /** Runs the Stable contract against one server and returns what it actually supported. */ + private static MongoVersionCapabilityReport certify( + MongoSingleReplicaSetContainer container, String expectedMajorMinor) { + try (MongoSingleReplicaSetContainer mongo = container) { + mongo.start(); + try (MongoClient client = MongoClients.create(mongo.connectionString())) { + MongoDatabase database = client.getDatabase("compatibility"); + String serverVersion = + database.runCommand(new Document("buildInfo", 1)).getString("version"); + + assertThat(serverVersion).startsWith(expectedMajorMinor); + assertThat(MongoVersionMatrix.standard().certifies(expectedMajorMinor)).isTrue(); + + boolean transactions = supportsTransaction(client, database); + boolean changeStreams = observesAChangeEvent(database); + Set accepted = acceptedValidationActions(database); + + // The platform's Stable contract needs error and warn to exist on every certified lane. + // Whether the server also accepts errorAndLog is recorded, not asserted: the apply policy + // refuses that action regardless of what the server would tolerate. + assertThat(accepted) + .as("both Stable validation actions must work on MongoDB %s", serverVersion) + .contains(MongoValidationAction.ERROR, MongoValidationAction.WARN); + assertThat(MongoValidationAction.ERROR_AND_LOG.supportedOnStableLane()).isFalse(); + + return MongoVersionCapabilityReport.forVersion(serverVersion) + .observed("transaction", transactions, "replica set oplog present") + .observed("changeStream", changeStreams) + .observedValidationActions(accepted) + .build(); + } + } + } + + private static boolean supportsTransaction(MongoClient client, MongoDatabase database) { + database.getCollection("txn").insertOne(new Document("seed", 1)); + try (var session = client.startSession()) { + session.startTransaction(); + database.getCollection("txn").insertOne(session, new Document("inTransaction", true)); + session.commitTransaction(); + } + return database.getCollection("txn").countDocuments() == 2; + } + + /** + * Opens a change stream, writes, and waits for the event. + * + *

Asserting that the cursor opened would pass on a deployment where change streams are + * configured but never deliver. The evidence is the event. + */ + private static boolean observesAChangeEvent(MongoDatabase database) { + database.createCollection("watched"); + ChangeStreamIterable stream = database.getCollection("watched").watch(); + try (MongoCursor> cursor = stream.cursor()) { + database.getCollection("watched").insertOne(new Document("observed", true)); + long deadline = System.nanoTime() + CHANGE_EVENT_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + ChangeStreamDocument event = cursor.tryNext(); + if (event != null) { + return true; + } + } + return false; + } + } + + /** + * Which validation actions the server accepts, probed through the raw {@code create} command. + * + *

The driver's own {@code ValidationAction} enum cannot express {@code errorAndLog}, so a + * typed probe could not tell the difference between "the server refused it" and "the driver could + * not ask". The raw command can. + */ + private static Set acceptedValidationActions(MongoDatabase database) { + Set accepted = EnumSet.noneOf(MongoValidationAction.class); + for (MongoValidationAction action : MongoValidationAction.values()) { + String collection = "validation-" + action.name().toLowerCase(Locale.ROOT); + try { + database.runCommand( + new Document("create", collection) + .append("validator", new Document("required", new Document("$exists", true))) + .append("validationAction", wireNameOf(action))); + accepted.add(action); + } catch (RuntimeException rejected) { + // An unsupported action is exactly what this probe is looking for. + } + } + return accepted; + } + + private static String wireNameOf(MongoValidationAction action) { + return switch (action) { + case ERROR -> "error"; + case WARN -> "warn"; + case ERROR_AND_LOG -> "errorAndLog"; + }; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifierTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifierTest.java new file mode 100644 index 00000000..8cade411 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/DefaultMongoFailureClassifierTest.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §15, D-10 — the two transaction labels demand opposite responses. */ +@Tag("mongodb-contract") +class DefaultMongoFailureClassifierTest { + + private final DefaultMongoFailureClassifier classifier = new DefaultMongoFailureClassifier(); + + @Test + void separatesTransactionBodyRetryFromCommitRetry() { + assertThat( + classifier + .classify( + MongoDriverFailureView.withLabel( + MongoDriverFailureView.TRANSIENT_TRANSACTION_ERROR)) + .retryScope()) + .isEqualTo(MongoRetryScope.WHOLE_TRANSACTION); + + assertThat( + classifier + .classify( + MongoDriverFailureView.withLabel( + MongoDriverFailureView.UNKNOWN_TRANSACTION_COMMIT_RESULT)) + .retryScope()) + .isEqualTo(MongoRetryScope.COMMIT_ONLY); + } + + @Test + void labelsOutrankServerCodes() { + MongoDriverFailureView failure = + new MongoDriverFailureView( + Set.of(MongoDriverFailureView.UNKNOWN_TRANSACTION_COMMIT_RESULT), 11000, true, true); + + assertThat(classifier.classify(failure).category()) + .isEqualTo(MongoFailureCategory.TRANSACTION_COMMIT_UNKNOWN); + } + + @Test + void noWritesPerformedMapsToNoWritePerformed() { + MongoDriverFailureView failure = + MongoDriverFailureView.withLabel(MongoDriverFailureView.NO_WRITES_PERFORMED); + + assertThat(classifier.classify(failure).outcome()) + .isEqualTo(MongoExecutionOutcome.NO_WRITE_PERFORMED); + } + + @Test + void duplicateKeyIsClassifiedByServerCode() { + assertThat(classifier.classify(MongoDriverFailureView.withServerCode(11000)).category()) + .isEqualTo(MongoFailureCategory.DUPLICATE_KEY); + } + + @Test + void aSentCommandWithNoResponseIsAmbiguousRatherThanFailed() { + MongoDriverFailureView lostResponse = + new MongoDriverFailureView(Set.of(), MongoDriverFailureView.NO_SERVER_CODE, true, false); + + MongoFailureClassification classification = classifier.classify(lostResponse); + + assertThat(classification.outcome()).isEqualTo(MongoExecutionOutcome.WRITE_RESULT_UNKNOWN); + assertThat(classification.retryScope()).isEqualTo(MongoRetryScope.RECONCILIATION); + assertThat(classification.bodyReplayAllowed()).isFalse(); + } + + @Test + void anUnknownServerCodeKeepsAStableCategory() { + assertThat(classifier.classify(MongoDriverFailureView.withServerCode(999_999)).category()) + .isEqualTo(MongoFailureCategory.UNCLASSIFIED); + } + + @Test + void aWriteConcernFailureIsAmbiguousBecauseThePrimaryUsuallyApplied() { + assertThat(classifier.classify(MongoDriverFailureView.withServerCode(64)).outcome()) + .isEqualTo(MongoExecutionOutcome.WRITE_RESULT_UNKNOWN); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoNetworkFaultLaneTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoNetworkFaultLaneTest.java new file mode 100644 index 00000000..9cd42c63 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/failure/MongoNetworkFaultLaneTest.java @@ -0,0 +1,206 @@ +package dev.caskeleton.adapter.outbound.mongo.failure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoExecutionOutcome; +import dev.caskeleton.adapter.outbound.mongo.testkit.failover.MongoFailoverScenario; +import dev.caskeleton.adapter.outbound.mongo.testkit.failover.MongoProxiedReplicaSetNode; +import dev.caskeleton.adapter.outbound.mongo.testkit.performance.MongoChaosGate; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The network-fault scenarios, against a real server behind a real proxy (design §29, Task 45). + * + *

These are the two failures a stopped container cannot produce. 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 — and {@code WRITE_RESULT_UNKNOWN} exists for exactly that state, so it + * is only ever really tested here. + * + *

Tagged {@code mongodb-failover}: it starts a MongoDB node and a Toxiproxy container and runs + * in the {@code mongoFailoverTest} lane. + */ +@Tag("mongodb-failover") +class MongoNetworkFaultLaneTest { + + private static final Duration CLIENT_PATIENCE = Duration.ofSeconds(5); + + private static MongoProxiedReplicaSetNode node; + + private static MongoClient directClient; + + private static MongoClient proxiedClient; + + @BeforeAll + static void start() { + node = MongoProxiedReplicaSetNode.startMongoEight(); + directClient = MongoClients.create(node.directConnectionString()); + proxiedClient = MongoClients.create(impatientSettings(node.proxiedConnectionString())); + } + + @AfterAll + static void stop() { + if (directClient != null) { + directClient.close(); + } + if (proxiedClient != null) { + proxiedClient.close(); + } + if (node != null) { + node.close(); + } + } + + @Test + void aPartitionedClientCannotReachAServerThatIsPerfectlyHealthy() { + MongoCollection throughProxy = proxied("partition"); + MongoCollection direct = direct("partition"); + assertThatCode(() -> throughProxy.insertOne(new Document("before", true))) + .doesNotThrowAnyException(); + + node.faults().partitionClientFromPrimary(); + try { + MongoException partitioned = + catchThrowableOfType( + MongoException.class, () -> throughProxy.insertOne(new Document("during", true))); + + assertThat(partitioned).as("the cut path must surface as a failure").isNotNull(); + assertThat(direct.countDocuments()) + .as("the server itself never stopped serving; only this client's path was cut") + .isEqualTo(1); + } finally { + node.faults().healPartition(); + } + + assertThatCode(() -> throughProxy.insertOne(new Document("after", true))) + .as("healing the path restores the client without restarting anything") + .doesNotThrowAnyException(); + assertThat(direct.countDocuments()).isEqualTo(2); + } + + @Test + void aDroppedResponseLeavesTheWriteAppliedAndTheClientUnableToTell() { + MongoCollection throughProxy = proxied("responseloss"); + MongoCollection direct = direct("responseloss"); + + // Warm the pool first. On a cold connection it is the driver's handshake whose response gets + // dropped, so the insert is never transmitted — that is NOT_SENT, the opposite of the ambiguity + // under test. The scenario only exists on an already-established connection. + throughProxy.insertOne(new Document("_id", "warms-the-connection")); + + node.faults().dropResponses(); + MongoException lostAcknowledgement; + try { + lostAcknowledgement = + catchThrowableOfType( + MongoException.class, + () -> throughProxy.insertOne(new Document("_id", "applied-but-unacknowledged"))); + } finally { + node.faults().deliverResponses(); + } + + assertThat(lostAcknowledgement).as("the client must not report success").isNotNull(); + + // The evidence that this is the ambiguous case and not a plain failure: the write is there. + assertThat(direct.countDocuments(new Document("_id", "applied-but-unacknowledged"))) + .as("the server applied the write; only the acknowledgement was lost") + .isEqualTo(1); + + MongoFailureClassification classification = + new DefaultMongoFailureClassifier() + .classify(MongoDriverFailureView.from(lostAcknowledgement)); + + assertThat(classification.outcome()) + .as("a retry here would insert a second document, so the outcome must stay unknown") + .isEqualTo(MongoExecutionOutcome.WRITE_RESULT_UNKNOWN); + } + + @Test + void addedLatencyIsSurvivableUntilItPassesTheClientDeadline() { + MongoCollection throughProxy = proxied("latency"); + + node.faults().delayResponses(Duration.ofMillis(200)); + try { + assertThatCode(() -> throughProxy.insertOne(new Document("slow", true))) + .as("latency inside the deadline is not a failure") + .doesNotThrowAnyException(); + } finally { + node.faults().deliverResponses(); + } + + node.faults().delayResponses(CLIENT_PATIENCE.plusSeconds(5)); + try { + assertThat( + catchThrowableOfType( + MongoException.class, + () -> throughProxy.insertOne(new Document("tooSlow", true)))) + .as("latency past the deadline becomes a timeout, not a hang") + .isNotNull(); + } finally { + node.faults().deliverResponses(); + } + } + + @Test + void theChaosGateAcceptsTheseTwoScenariosAndStillRefusesPartialEvidence() { + MongoChaosGate.MongoChaosReport report = + new MongoChaosGate() + .record(MongoFailoverScenario.NETWORK_PARTITION, true) + .record(MongoFailoverScenario.WRITE_RESPONSE_LOSS, true) + .report(); + + assertThat(report.failures()) + .as("the two scenarios this lane actually ran are not among the failures") + .doesNotContain( + MongoFailoverScenario.NETWORK_PARTITION.name(), + MongoFailoverScenario.WRITE_RESPONSE_LOSS.name()); + assertThat(report.passed()) + .as("and the gate still refuses to pass while the other scenarios have not run") + .isFalse(); + assertThat(report.failures()) + .as("a scenario nobody ran is reported as missing, not silently treated as passing") + .contains(MongoFailoverScenario.OPLOG_HISTORY_LOSS.name() + " (not executed)"); + } + + private static MongoCollection proxied(String collection) { + return proxiedClient.getDatabase("faults").getCollection(collection); + } + + private static MongoCollection direct(String collection) { + return directClient.getDatabase("faults").getCollection(collection); + } + + /** + * A client that gives up quickly. + * + *

The driver's defaults wait 30 seconds for server selection and never time out a socket read. + * A test that inherits those spends minutes proving something it could prove in seconds, and a + * dropped response would hang forever rather than becoming the ambiguous outcome under test. + */ + private static MongoClientSettings impatientSettings(String connectionString) { + return MongoClientSettings.builder() + .applyConnectionString(new com.mongodb.ConnectionString(connectionString)) + .applyToClusterSettings( + cluster -> + cluster.serverSelectionTimeout(CLIENT_PATIENCE.toMillis(), TimeUnit.MILLISECONDS)) + .applyToSocketSettings( + socket -> { + socket.connectTimeout(CLIENT_PATIENCE.toMillis(), TimeUnit.MILLISECONDS); + socket.readTimeout(CLIENT_PATIENCE.toMillis(), TimeUnit.MILLISECONDS); + }) + .retryWrites(false) + .build(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPointTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPointTest.java new file mode 100644 index 00000000..57693cee --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/geo/MongoGeoPointTest.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.outbound.mongo.geo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §21.2 — GeoJSON is longitude first, and every proximity query is bounded. */ +@Tag("mongodb-contract") +class MongoGeoPointTest { + + @Test + void rejectsReversedOrOutOfRangeCoordinates() { + assertThatThrownBy(() -> new MongoGeoPoint(37.5, 200.0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsAValidLongitudeLatitudePair() { + MongoGeoPoint seoul = new MongoGeoPoint(126.9780, 37.5665); + + assertThat(seoul.longitude()).isEqualTo(126.9780); + assertThat(seoul.latitude()).isEqualTo(37.5665); + assertThat(seoul.toGeoJsonPoint().getX()).isEqualTo(126.9780); + } + + @Test + void aDistanceCarriesItsUnit() { + assertThat(MongoGeoDistance.ofKilometers(2).toMeters()).isEqualTo(2000.0); + assertThat(MongoGeoDistance.ofMeters(500).toMeters()).isEqualTo(500.0); + } + + @Test + void aNearQueryNeedsABoundedResultLimit() { + MongoGeoPoint centre = new MongoGeoPoint(126.9780, 37.5665); + + assertThatThrownBy( + () -> MongoGeoQuery.near("location", centre, MongoGeoDistance.ofKilometers(2), 0)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatThrownBy( + () -> MongoGeoQuery.near("location", centre, MongoGeoDistance.ofKilometers(2), 10_000)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aGeoQueryNamesTheIndexItRequires() { + MongoGeoQuery query = + MongoGeoQuery.near( + "delivery.location", + new MongoGeoPoint(126.9780, 37.5665), + MongoGeoDistance.ofKilometers(2), + 50); + + assertThat(query.requiredIndexName()).isEqualTo("ix_geo_delivery_location"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistryTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistryTest.java new file mode 100644 index 00000000..a59cd62d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/MongoCollectionProfileRegistryTest.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.CollectionProfileName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.util.UUID; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §16.2, §25 — a collection name never comes from a caller. */ +@Tag("mongodb-contract") +class MongoCollectionProfileRegistryTest { + + private final MongoCollectionProfileRegistry registry = + MongoCollectionProfileRegistry.builder().register("orders", "orders_v3").build(); + + @Test + void aProfileResolvesToItsPhysicalCollection() { + assertThat(registry.require(new CollectionProfileName("orders"))).isEqualTo("orders_v3"); + } + + @Test + void anUnregisteredProfileIsRefused() { + assertThatThrownBy(() -> registry.require(new CollectionProfileName("payments"))) + .isInstanceOf(MongoOperationRejectedException.class); + assertThat(registry.isRegistered(new CollectionProfileName("payments"))).isFalse(); + } + + @Test + void aRequestShapedProfileNameCannotEvenBeConstructed() { + assertThatThrownBy(() -> new CollectionProfileName("orders-" + UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void duplicateRegistrationsAreRefused() { + assertThatThrownBy( + () -> + MongoCollectionProfileRegistry.builder() + .register("orders", "orders_v3") + .register("orders", "orders_v4") + .build()) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aBlankPhysicalNameIsRefused() { + assertThatThrownBy( + () -> MongoCollectionProfileRegistry.builder().register("orders", " ").build()) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplateTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplateTest.java new file mode 100644 index 00000000..89a38653 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/atomic/MongoAtomicOperationsTemplateTest.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.atomic; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §13.1, D-07 — partial updates carry expected state and a registered operator set. */ +@Tag("mongodb-contract") +class MongoAtomicOperationsTemplateTest { + + @Test + void statusTransitionIncludesExpectedCurrentState() { + AtomicFilter filter = AtomicFilter.id("o-1").andEquals("status", "PENDING"); + AtomicUpdate update = AtomicUpdate.set("status", "PAID"); + + assertThat(filter.fields()).containsExactlyInAnyOrder("_id", "status"); + assertThat(update.operators()).containsExactly("$set"); + } + + @Test + void anAtomicFilterAlwaysConstrainsTheIdentifier() { + assertThatThrownBy(() -> new AtomicFilter(java.util.Map.of("status", "PENDING"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("_id"); + } + + @Test + void aPushMustDeclareItsCeiling() { + AtomicUpdate bounded = AtomicUpdate.set("status", "PAID").andPushBounded("events", "paid", 100); + + assertThat(bounded.operators()).containsExactly("$set", "$push"); + assertThatThrownBy(() -> AtomicUpdate.set("status", "PAID").andPushBounded("events", "x", 0)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void anUnregisteredFieldIsRefusedBeforeTheDriverIsCalled() { + MongoAtomicPolicy policy = + MongoAtomicPolicy.builder() + .filterable("status") + .updatable("status", MongoUpdateOperator.SET) + .build(); + + assertThatThrownBy(() -> policy.requireUpdate(AtomicUpdate.set("internalAuditStamp", "x"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("internalAuditStamp"); + } + + @Test + void anUnregisteredOperatorOnARegisteredFieldIsAlsoRefused() { + MongoAtomicPolicy policy = + MongoAtomicPolicy.builder() + .filterable("status") + .updatable("counter", MongoUpdateOperator.INCREMENT) + .build(); + + assertThatThrownBy(() -> policy.requireUpdate(AtomicUpdate.set("counter", 5))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("$set"); + } + + @Test + void anUnregisteredPredicateFieldIsRefused() { + MongoAtomicPolicy policy = + MongoAtomicPolicy.builder().updatable("status", MongoUpdateOperator.SET).build(); + + assertThatThrownBy( + () -> policy.requireFilter(AtomicFilter.id("o-1").andEquals("secretFlag", true))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void matchedButUnchangedIsDistinguishableFromNoMatch() { + AtomicUpdateResult unchanged = AtomicUpdateResult.applied(1, 0, null); + AtomicUpdateResult noMatch = AtomicUpdateResult.noMatch(); + + assertThat(unchanged.matchedButUnchanged()).isTrue(); + assertThat(unchanged.matchedAnything()).isTrue(); + assertThat(noMatch.matchedAnything()).isFalse(); + } + + @Test + void theUpdateDocumentRendersEveryDeclaredOperator() { + AtomicUpdate update = + AtomicUpdate.set("status", "PAID") + .andIncrement("version", 1) + .andCurrentDate("updatedAt") + .andAddToSet("tags", "paid"); + + assertThat(update.toUpdate().getUpdateObject().keySet()) + .contains("$set", "$inc", "$currentDate", "$addToSet"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutorTest.java new file mode 100644 index 00000000..cc9c45c4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/bulk/MongoBulkExecutorTest.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.bulk; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureCategory; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicFilter; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.AtomicUpdate; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §19.2 — partial success is preserved, and successful items are never re-run. */ +@Tag("mongodb-contract") +class MongoBulkExecutorTest { + + @Test + void partialResultPreservesSuccessfulItems() { + MongoBulkResult result = + MongoBulkResult.partial( + 3, + 2, + 0, + 0, + 0, + List.of(new MongoBulkItemFailure(2, MongoFailureCategory.DUPLICATE_KEY, 11000))); + + assertThat(result.requested()).isEqualTo(3); + assertThat(result.inserted()).isEqualTo(2); + assertThat(result.failures()).hasSize(1); + assertThat(result.partial()).isTrue(); + assertThat(result.succeeded()).isEqualTo(2); + } + + @Test + void onlyTransientItemFailuresAreOfferedForRetry() { + MongoBulkResult result = + MongoBulkResult.partial( + 3, + 1, + 0, + 0, + 0, + List.of( + new MongoBulkItemFailure(1, MongoFailureCategory.DUPLICATE_KEY, 11000), + new MongoBulkItemFailure(2, MongoFailureCategory.WRITE_CONFLICT, 112))); + + assertThat(result.retryableIndexes()).containsExactly(2); + } + + @Test + void aBatchLargerThanTheCeilingIsRefusedWhenTheP1anIsBuilt() { + MongoBulkWritePlan.Builder builder = MongoBulkWritePlan.builder(MongoBulkMode.UNORDERED); + for (int index = 0; index <= MongoBulkWritePlan.MAX_ITEMS; index++) { + builder.update(AtomicFilter.id("o-" + index), AtomicUpdate.set("status", "PAID")); + } + + assertThatThrownBy(builder::build).isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void everyItemCarriesTheIndexItsFailureWillBeReportedUnder() { + MongoBulkWritePlan plan = + MongoBulkWritePlan.builder(MongoBulkMode.ORDERED) + .update(AtomicFilter.id("o-1"), AtomicUpdate.set("status", "PAID")) + .upsert(AtomicFilter.id("o-2"), AtomicUpdate.set("status", "PAID")) + .build(); + + assertThat(plan.items()) + .extracting(MongoBulkWritePlan.MongoBulkItem::requestIndex) + .containsExactly(0, 1); + assertThat(plan.items().get(1).upsert()).isTrue(); + } + + @Test + void orderedAndUnorderedMapToSpringModes() { + assertThat(MongoBulkMode.ORDERED.toSpringMode().name()).isEqualTo("ORDERED"); + assertThat(MongoBulkMode.UNORDERED.toSpringMode().name()).isEqualTo("UNORDERED"); + } + + @Test + void anEmptyPlanIsNotAPlan() { + assertThatThrownBy(() -> MongoBulkWritePlan.builder(MongoBulkMode.ORDERED).build()) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdaterTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdaterTest.java new file mode 100644 index 00000000..f00e9d9a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/imperative/revision/VersionedMongoUpdaterTest.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.outbound.mongo.imperative.revision; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationContext; +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOptimisticConflictException; +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 dev.caskeleton.adapter.outbound.mongo.imperative.atomic.MongoAtomicOperations; +import dev.caskeleton.adapter.outbound.mongo.imperative.atomic.ReturnDocumentMode; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §13.2, D-08 — the revision predicate and its increment are inseparable. */ +@Tag("mongodb-contract") +class VersionedMongoUpdaterTest { + + private static final MongoOperationContext CONTEXT = + MongoOperationContext.of( + "order.pay", + "default", + "orders", + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(1)); + + @Test + void createsExpectedVersionPredicateAndIncrement() { + VersionedUpdateCommand command = + VersionedUpdateCommand.of("o-1", new MongoRevision(7), AtomicUpdate.set("status", "PAID")); + + assertThat(command.filter().value("version")).isEqualTo(7L); + assertThat(command.update().increment("version")).isEqualTo(1L); + } + + @Test + void aBusinessUpdateMayNotTouchTheRevisionItself() { + assertThatThrownBy( + () -> + VersionedUpdateCommand.of( + "o-1", new MongoRevision(7), AtomicUpdate.set("version", 99))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aFilterWithoutTheExpectedVersionIsNotAVersionedCommand() { + assertThatThrownBy( + () -> + new VersionedUpdateCommand( + AtomicFilter.id("o-1"), AtomicUpdate.increment("version", 1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void noMatchOnAnExistingDocumentIsAConflict() { + VersionedMongoUpdater updater = + new VersionedMongoUpdater( + new NeverMatchingOperations(), new MongoOptimisticConflictTranslator()); + + assertThatThrownBy( + () -> + updater.apply( + CONTEXT, + String.class, + VersionedUpdateCommand.of( + "o-1", new MongoRevision(7), AtomicUpdate.set("status", "PAID")), + () -> true)) + .isInstanceOf(MongoOptimisticConflictException.class); + } + + @Test + void noMatchOnAMissingDocumentIsNotAConflict() { + VersionedMongoUpdater updater = + new VersionedMongoUpdater( + new NeverMatchingOperations(), new MongoOptimisticConflictTranslator()); + + assertThatThrownBy( + () -> + updater.apply( + CONTEXT, + String.class, + VersionedUpdateCommand.of( + "o-1", new MongoRevision(7), AtomicUpdate.set("status", "PAID")), + () -> false)) + .isInstanceOf(MongoDocumentNotFoundException.class); + } + + @Test + void retryReloadsAndRecomputesRatherThanResendingTheStaleUpdate() { + AtomicInteger recomputeCount = new AtomicInteger(); + MatchOnAttemptOperations operations = new MatchOnAttemptOperations(2); + VersionedMongoUpdater updater = + new VersionedMongoUpdater(operations, new MongoOptimisticConflictTranslator()); + + AtomicUpdateResult result = + updater.applyWithRetry( + CONTEXT, + String.class, + () -> Optional.of("current"), + current -> { + recomputeCount.incrementAndGet(); + return VersionedUpdateCommand.of( + "o-1", + new MongoRevision(recomputeCount.get()), + AtomicUpdate.set("status", "PAID")); + }, + 3); + + assertThat(result.matchedAnything()).isTrue(); + assertThat(recomputeCount.get()).isEqualTo(2); + assertThat(operations.expectedVersions()).containsExactly(1L, 2L); + } + + /** Never matches, so every attempt is a conflict. */ + private static final class NeverMatchingOperations implements MongoAtomicOperations { + + @Override + public AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + return AtomicUpdateResult.noMatch(); + } + + @Override + public AtomicUpdateResult upsertOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + return AtomicUpdateResult.noMatch(); + } + } + + /** Matches only on the given attempt, and records the expected version each attempt carried. */ + private static final class MatchOnAttemptOperations implements MongoAtomicOperations { + + private final int matchOnAttempt; + + private final List expectedVersions = new java.util.ArrayList<>(); + + private MatchOnAttemptOperations(int matchOnAttempt) { + this.matchOnAttempt = matchOnAttempt; + } + + @Override + public AtomicUpdateResult updateOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + expectedVersions.add(((Number) filter.value("version")).longValue()); + return expectedVersions.size() == matchOnAttempt + ? AtomicUpdateResult.applied(1, 1, null) + : AtomicUpdateResult.noMatch(); + } + + @Override + public AtomicUpdateResult upsertOne( + MongoOperationContext context, + Class documentType, + AtomicFilter filter, + AtomicUpdate update, + ReturnDocumentMode returnMode) { + return AtomicUpdateResult.noMatch(); + } + + private List expectedVersions() { + return List.copyOf(expectedVersions); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfigurationTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfigurationTest.java new file mode 100644 index 00000000..5c79a22c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/MongoMappingConfigurationTest.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.DomainDocumentId; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoBigIntegerRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoDecimalRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoEnumRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTemporalRepresentation; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoUuidRepresentation; +import java.math.BigDecimal; +import org.bson.types.Decimal128; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §10 — deterministic converters, Decimal128 money, and no implicit id coercion. */ +@Tag("mongodb-contract") +class MongoMappingConfigurationTest { + + private final BigDecimalToDecimal128Converter toDecimal128 = + new BigDecimalToDecimal128Converter(); + + @Test + void decimalIsWrittenAsDecimal128() { + Decimal128 written = toDecimal128.convert(new BigDecimal("12.30")); + + assertThat(written).isInstanceOf(Decimal128.class); + assertThat(written.bigDecimalValue()).isEqualByComparingTo(new BigDecimal("12.30")); + } + + @Test + void aDecimalTooPreciseForDecimal128IsRejectedBeforeTheDriverSeesIt() { + BigDecimal tooPrecise = new BigDecimal("1." + "1".repeat(40)); + + assertThatThrownBy(() -> toDecimal128.convert(tooPrecise)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("34"); + } + + @Test + void decimal128NaNHasNoBigDecimalRepresentationAndIsRefused() { + assertThatThrownBy(() -> new Decimal128ToBigDecimalConverter().convert(Decimal128.NaN)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aDomainIdRoundTripsAsAStringWithoutObjectIdCoercion() { + String objectIdShaped = "507f1f77bcf86cd799439011"; + DomainDocumentId id = new DomainDocumentId(objectIdShaped); + + String written = new DomainIdWriteConverter().convert(id); + + assertThat(written).isEqualTo(objectIdShaped); + assertThat(new DomainIdReadConverter().convert(written)).isEqualTo(id); + } + + @Test + void convertersAreRegisteredInADeterministicOrder() { + MongoTypeRepresentationManifest manifest = MongoTypeRepresentationManifest.standard(); + + assertThat(MongoCustomConversionsFactory.converters(manifest)) + .extracting(converter -> converter.getClass().getSimpleName()) + .containsExactly( + "BigDecimalToDecimal128Converter", + "Decimal128ToBigDecimalConverter", + "DomainIdWriteConverter", + "DomainIdReadConverter"); + } + + @Test + void theConverterFingerprintCoversBothManifestAndConverterSet() { + String fingerprint = + MongoCustomConversionsFactory.fingerprint(MongoTypeRepresentationManifest.standard()); + + assertThat(fingerprint).contains("uuid=STANDARD").contains("DomainIdWriteConverter"); + } + + @Test + void localDateTimeStorageWithoutARegisteredConverterFailsAtWiringTime() { + MongoTypeRepresentationManifest manifest = + new MongoTypeRepresentationManifest( + MongoUuidRepresentation.STANDARD, + MongoDecimalRepresentation.DECIMAL128, + MongoBigIntegerRepresentation.STRING, + MongoTemporalRepresentation.LOCAL_DATE_TIME_WITH_REGISTERED_CONVERTER, + MongoEnumRepresentation.STRING, + MongoTypeMetadataPolicy.ALIAS_FOR_LONG_LIVED); + + assertThatThrownBy(() -> LocalDateTimeMappingGuard.withoutConverters().validate(manifest)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining(LocalDateTimeMappingGuard.REQUIRED_CONVERTER); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapperTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapperTest.java new file mode 100644 index 00000000..59b3c3f2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/mapping/type/PolicyAwareMongoTypeMapperTest.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.outbound.mongo.mapping.type; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoSchemaValidationException; +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeMetadataPolicy; +import java.util.List; +import org.bson.Document; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.data.core.TypeInformation; + +/** Design §11 — a long-lived collection never stores a Java class name. */ +@Tag("mongodb-contract") +class PolicyAwareMongoTypeMapperTest { + + private final MongoTypeMetadataRegistry registry = + MongoTypeMetadataRegistry.fromAnnotations( + List.of(LongLivedOrder.class, ShortLivedAudit.class)); + + private final PolicyAwareMongoTypeMapper mapper = new PolicyAwareMongoTypeMapper(registry); + + @Test + void longLivedDocumentNeverWritesJavaClassName() { + Document bson = new Document(); + + mapper.writeType(TypeInformation.of(LongLivedOrder.class), bson); + + assertThat(bson.toJson()).doesNotContain("dev.caskeleton"); + assertThat(bson.getString(PolicyAwareMongoTypeMapper.DOCUMENT_TYPE_KEY)).isEqualTo("order"); + } + + @Test + void aShortLivedCollectionMayKeepTheDefaultClassMetadata() { + Document bson = new Document(); + + mapper.writeType(TypeInformation.of(ShortLivedAudit.class), bson); + + assertThat(bson.getString(PolicyAwareMongoTypeMapper.CLASS_KEY)) + .isEqualTo(ShortLivedAudit.class.getName()); + } + + @Test + void aRegisteredAliasResolvesBackToItsType() { + Document stored = new Document(PolicyAwareMongoTypeMapper.DOCUMENT_TYPE_KEY, "order"); + + assertThat(mapper.readType(stored).getType()).isEqualTo(LongLivedOrder.class); + } + + @Test + void anUnknownAliasFailsWithASchemaErrorRatherThanLoadingAClass() { + Document stored = new Document(PolicyAwareMongoTypeMapper.DOCUMENT_TYPE_KEY, "invoice"); + + assertThatThrownBy(() -> mapper.readType(stored)) + .isInstanceOf(MongoSchemaValidationException.class); + } + + @Test + void duplicateAliasesAreRejectedWhenTheRegistryIsBuilt() { + assertThatThrownBy( + () -> + MongoTypeMetadataRegistry.builder() + .register( + LongLivedOrder.class, + MongoTypeMetadataDescriptor.documentType("orders", "order")) + .register( + ShortLivedAudit.class, + MongoTypeMetadataDescriptor.documentType("audits", "order")) + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("duplicate MongoDB type alias"); + } + + @Test + void anUnregisteredTypeKeepsSpringDataDefaultBehaviour() { + assertThat(registry.policyFor(String.class)) + .isEqualTo(MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED); + } + + @LongLivedMongoDocument(collectionProfile = "orders", alias = "order") + private record LongLivedOrder(String id) {} + + @LongLivedMongoDocument( + collectionProfile = "audits", + alias = "audit", + policy = MongoTypeMetadataPolicy.CLASS_METADATA_ALLOWED) + private record ShortLivedAudit(String id) {} +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLaneTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLaneTest.java new file mode 100644 index 00000000..acf71bcf --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationLaneTest.java @@ -0,0 +1,303 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminAuthorization; +import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminGateway; +import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminOperation; +import dev.caskeleton.adapter.outbound.mongo.security.admin.MongoAdminRuntimeGuard; +import dev.caskeleton.adapter.outbound.mongo.testkit.migration.MongoMigrationSnapshotFixture; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoSingleReplicaSetContainer; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The migration lane against a real server (design §12.3, §12.4, Task 47). + * + *

The in-memory ledger test proves the runner's logic. This one proves the parts that only exist + * on a server: the unique index that makes a double-apply impossible, the conditional update that + * makes the lease exclusive, and a checkpoint that survives the process that wrote it. + * + *

Tagged {@code mongodb-migration}: it needs a container and runs in the {@code + * mongoMigrationTest} lane. + */ +@Tag("mongodb-migration") +class MongoMigrationLaneTest { + + private static final Clock FIXED = + Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC); + + private static final Duration LEASE = Duration.ofMinutes(5); + + private static MongoSingleReplicaSetContainer container; + + private static MongoClient client; + + private static final AtomicInteger DATABASE_SEQUENCE = new AtomicInteger(); + + @BeforeAll + static void startServer() { + container = MongoSingleReplicaSetContainer.mongoEight(); + container.start(); + client = MongoClients.create(container.connectionString()); + } + + @AfterAll + static void stopServer() { + if (client != null) { + client.close(); + } + if (container != null) { + container.close(); + } + } + + @Test + void everySnapshotStateUpgradesToTheSameResult() { + for (MongoMigrationSnapshotFixture fixture : MongoMigrationSnapshotFixture.all()) { + MongoDatabase database = freshDatabase(); + seed(database, fixture); + + MongoCollectionMigrationLedger ledger = new MongoCollectionMigrationLedger(database); + ledger.ensureIndexes(); + + try (MongoCollectionMigrationLock lock = acquire(database)) { + List results = + new MongoMigrationRunner(ledger, FIXED) + .apply(List.of(addStatusField(database)), lock, context()); + + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo(MongoMigrationResult.Status.COMPLETED); + } + + assertThat(database.getCollection("orders").countDocuments(new Document("status", "NEW"))) + .as("every seeded document carries the new field after %s", fixture.snapshot()) + .isEqualTo(database.getCollection("orders").countDocuments()); + } + } + + @Test + void aSecondRunnerCannotTakeAHeldLease() { + MongoDatabase database = freshDatabase(); + + try (MongoCollectionMigrationLock first = acquire(database)) { + assertThat(first.held()).isTrue(); + assertThat(MongoCollectionMigrationLock.tryAcquire(database, "second-runner", LEASE, FIXED)) + .as("a rolling deployment starts every instance at once; only one may migrate") + .isEmpty(); + } + + assertThat(MongoCollectionMigrationLock.tryAcquire(database, "third-runner", LEASE, FIXED)) + .as("a released lease is available again") + .isPresent(); + } + + @Test + void anExpiredLeaseIsTakeableSoADeadRunnerDoesNotBlockDeployments() { + MongoDatabase database = freshDatabase(); + Clock early = Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC); + Clock afterExpiry = Clock.fixed(Instant.parse("2026-08-13T01:00:00Z"), ZoneOffset.UTC); + + Optional abandoned = + MongoCollectionMigrationLock.tryAcquire(database, "killed-runner", LEASE, early); + assertThat(abandoned).isPresent(); + + assertThat(MongoCollectionMigrationLock.tryAcquire(database, "next-runner", LEASE, afterExpiry)) + .isPresent(); + } + + @Test + void anAppliedMigrationIsImmutableAcrossProcesses() { + MongoDatabase database = freshDatabase(); + MongoCollectionMigrationLedger ledger = new MongoCollectionMigrationLedger(database); + ledger.ensureIndexes(); + ledger.recordApplied( + new MongoMigrationId("20260811-001"), + new MongoMigrationChecksum("original"), + "release-engineer", + FIXED.instant()); + + // A different process reading the same database sees the same ledger. + MongoCollectionMigrationLedger reopened = new MongoCollectionMigrationLedger(database); + + assertThatThrownBy( + () -> + new MongoMigrationRunner(reopened, FIXED) + .validate(constantMigration("20260811-001", "edited"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("forward-fix"); + } + + @Test + void aCheckpointSurvivesTheProcessThatWroteIt() { + MongoDatabase database = freshDatabase(); + MongoCollectionMigrationLedger writer = new MongoCollectionMigrationLedger(database); + writer.ensureIndexes(); + MongoMigrationId id = new MongoMigrationId("20260811-002"); + + writer.saveCheckpoint( + MongoMigrationCheckpoint.start(id, FIXED.instant()) + .advancedTo("o-500", 500, FIXED.instant())); + + MongoCollectionMigrationLedger reader = new MongoCollectionMigrationLedger(database); + Optional resumed = reader.findCheckpoint(id); + + assertThat(resumed).isPresent(); + assertThat(resumed.get().resumePosition()).isEqualTo("o-500"); + assertThat(resumed.get().processedCount()).isEqualTo(500); + assertThat(resumed.get().atStart()).isFalse(); + } + + @Test + void theLedgerRefusesADoubleApplyOfTheSameId() { + MongoDatabase database = freshDatabase(); + MongoCollectionMigrationLedger ledger = new MongoCollectionMigrationLedger(database); + ledger.ensureIndexes(); + MongoMigrationId id = new MongoMigrationId("20260811-003"); + ledger.recordApplied(id, new MongoMigrationChecksum("abc"), "operator", FIXED.instant()); + + assertThatThrownBy( + () -> + ledger.recordApplied( + id, new MongoMigrationChecksum("abc"), "operator", FIXED.instant())) + .as("the unique index, not the read-then-write check, is what makes this impossible") + .isInstanceOf(RuntimeException.class); + } + + private static MongoDatabase freshDatabase() { + return client.getDatabase("migration" + DATABASE_SEQUENCE.incrementAndGet()); + } + + private static MongoCollectionMigrationLock acquire(MongoDatabase database) { + return MongoCollectionMigrationLock.tryAcquire(database, "first-runner", LEASE, FIXED) + .orElseThrow(() -> new IllegalStateException("the lease should have been free")); + } + + /** Seeds the starting state a migration has to upgrade from. */ + private static void seed(MongoDatabase database, MongoMigrationSnapshotFixture fixture) { + switch (fixture.snapshot()) { + case EMPTY -> { + // Nothing: the only starting state most suites test and the only one that never happens. + } + case PREVIOUS_RELEASE -> + database + .getCollection("orders") + .insertMany( + List.of(new Document("orderNumber", "A-1"), new Document("orderNumber", "A-2"))); + case OLDEST_SUPPORTED -> + database + .getCollection("orders") + .insertMany( + List.of( + new Document("order_number", "A-1"), + new Document("order_number", "A-2"), + new Document("order_number", "A-3"))); + default -> + throw new IllegalStateException( + "unhandled migration snapshot state: " + fixture.snapshot()); + } + } + + /** A migration that adds the new field to every document that lacks it. */ + private static MongoMigration addStatusField(MongoDatabase database) { + return new MongoMigration() { + @Override + public MongoMigrationId id() { + return new MongoMigrationId("20260811-010"); + } + + @Override + public MongoMigrationChecksum checksum() { + return new MongoMigrationChecksum("add-status-field-v1"); + } + + @Override + public MongoMigrationPrecondition precondition() { + return context -> true; + } + + @Override + public MongoMigrationResult execute(MongoMigrationContext context) { + long updated = + database + .getCollection("orders") + .updateMany( + new Document("status", new Document("$exists", false)), + new Document("$set", new Document("status", "NEW"))) + .getModifiedCount(); + return MongoMigrationResult.completed(updated); + } + + @Override + public MongoMigrationPostcondition postcondition() { + return (context, result) -> + database.getCollection("orders").countDocuments(new Document("status", "NEW")) + == database.getCollection("orders").countDocuments(); + } + }; + } + + private static MongoMigration constantMigration(String id, String checksum) { + return new MongoMigration() { + @Override + public MongoMigrationId id() { + return new MongoMigrationId(id); + } + + @Override + public MongoMigrationChecksum checksum() { + return new MongoMigrationChecksum(checksum); + } + + @Override + public MongoMigrationPrecondition precondition() { + return context -> true; + } + + @Override + public MongoMigrationResult execute(MongoMigrationContext context) { + return MongoMigrationResult.completed(0); + } + + @Override + public MongoMigrationPostcondition postcondition() { + return (context, result) -> true; + } + }; + } + + private static MongoMigrationContext context() { + MongoAdminGateway gateway = + new MongoAdminGateway( + new MongoAdminRuntimeGuard(true, "app-credential", "migration-credential"), + MongoAdminAuthorization.routine( + Set.of(MongoAdminOperation.APPLY_MIGRATION, MongoAdminOperation.COLL_MOD)), + record -> {}, + FIXED); + return new MongoMigrationContext( + new MongoMigrationId("20260811-010"), + gateway, + null, + 500, + Duration.ofMillis(1), + Duration.ofMinutes(5), + false, + "release-engineer"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunnerTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunnerTest.java new file mode 100644 index 00000000..b736d506 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/MongoMigrationRunnerTest.java @@ -0,0 +1,158 @@ +package dev.caskeleton.adapter.outbound.mongo.migration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.Optional; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §12.4 — an applied migration is immutable, and a run needs the lease. */ +@Tag("mongodb-contract") +class MongoMigrationRunnerTest { + + @Test + void checksumChangeOnAppliedMigrationFails() { + InMemoryLedger ledger = new InMemoryLedger(); + ledger.recordApplied( + new MongoMigrationId("20260811-001"), + new MongoMigrationChecksum("abc"), + "release-engineer", + Instant.EPOCH); + + MongoMigration migration = migration("20260811-001", "def"); + + assertThatThrownBy(() -> new MongoMigrationRunner(ledger).validate(migration)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("forward-fix"); + } + + @Test + void anUnchangedChecksumValidatesCleanly() { + InMemoryLedger ledger = new InMemoryLedger(); + ledger.recordApplied( + new MongoMigrationId("20260811-001"), + new MongoMigrationChecksum("abc"), + "release-engineer", + Instant.EPOCH); + + assertThatCode( + () -> new MongoMigrationRunner(ledger).validate(migration("20260811-001", "abc"))) + .doesNotThrowAnyException(); + } + + @Test + void anIdWithoutTheOrderedFormatIsRefused() { + assertThatThrownBy(() -> new MongoMigrationId("add-status-index")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aCheckpointAdvancesWithoutLosingItsCount() { + MongoMigrationCheckpoint start = + MongoMigrationCheckpoint.start(new MongoMigrationId("20260811-001"), Instant.EPOCH); + + MongoMigrationCheckpoint advanced = + start.advancedTo("o-500", 500, Instant.EPOCH).advancedTo("o-1000", 500, Instant.EPOCH); + + assertThat(start.atStart()).isTrue(); + assertThat(advanced.processedCount()).isEqualTo(1000); + assertThat(advanced.resumePosition()).isEqualTo("o-1000"); + } + + @Test + void anIncompleteRunIsNotAFailure() { + MongoMigrationResult result = + MongoMigrationResult.incomplete( + 500, + MongoMigrationCheckpoint.start(new MongoMigrationId("20260811-001"), Instant.EPOCH)); + + assertThat(result.status()).isEqualTo(MongoMigrationResult.Status.INCOMPLETE); + assertThat(result.resumePoint()).isPresent(); + } + + @Test + void aMigrationContextMustNameItsOperator() { + assertThatThrownBy( + () -> + new MongoMigrationContext( + new MongoMigrationId("20260811-001"), + null, + null, + 100, + Duration.ZERO, + Duration.ofMinutes(1), + false, + "operator")) + .isInstanceOf(NullPointerException.class); + } + + private static MongoMigration migration(String id, String checksum) { + return new MongoMigration() { + + @Override + public MongoMigrationId id() { + return new MongoMigrationId(id); + } + + @Override + public MongoMigrationChecksum checksum() { + return new MongoMigrationChecksum(checksum); + } + + @Override + public MongoMigrationPrecondition precondition() { + return MongoMigrationPrecondition.none(); + } + + @Override + public MongoMigrationResult execute(MongoMigrationContext context) { + return MongoMigrationResult.completed(0); + } + + @Override + public MongoMigrationPostcondition postcondition() { + return MongoMigrationPostcondition.none(); + } + }; + } + + /** A ledger held in memory, so the runner's rules are testable without a server. */ + private static final class InMemoryLedger implements MongoMigrationLedger { + + private final Map applied = new LinkedHashMap<>(); + + private final Map checkpoints = + new LinkedHashMap<>(); + + @Override + public Optional find(MongoMigrationId migrationId) { + return Optional.ofNullable(applied.get(migrationId)); + } + + @Override + public void recordApplied( + MongoMigrationId migrationId, + MongoMigrationChecksum checksum, + String operator, + Instant appliedAt) { + applied.put(migrationId, new AppliedMigration(migrationId, checksum, operator, appliedAt)); + } + + @Override + public Optional findCheckpoint(MongoMigrationId migrationId) { + return Optional.ofNullable(checkpoints.get(migrationId)); + } + + @Override + public void saveCheckpoint(MongoMigrationCheckpoint checkpoint) { + checkpoints.put(checkpoint.migrationId(), checkpoint); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapterTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapterTest.java new file mode 100644 index 00000000..8aab7156 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/migration/flamingock/FlamingockMongoMigrationAdapterTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.mongo.migration.flamingock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigration; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationChecksum; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationContext; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationId; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPostcondition; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationPrecondition; +import dev.caskeleton.adapter.outbound.mongo.migration.MongoMigrationResult; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §12.4 — the platform contract does not depend on the migration engine's types. */ +@Tag("mongodb-contract") +class FlamingockMongoMigrationAdapterTest { + + @Test + void platformMigrationMetadataIsPreserved() { + MongoMigration migration = migration("20260811-010", "sha256:1"); + + FlamingockChangeUnitView view = new FlamingockMongoMigrationAdapter().adapt(migration); + + assertThat(view.id()).isEqualTo("20260811-010"); + assertThat(view.checksum()).isEqualTo("sha256:1"); + } + + @Test + void mongockCompatibilityIsNotEnabledForNewProjects() { + assertThat(new FlamingockMongoMigrationAdapter().mongockCompatibilityEnabled()).isFalse(); + assertThatThrownBy( + () -> + new FlamingockMigrationConfiguration( + "com.example.migrations", Duration.ofMinutes(3), Duration.ofMinutes(1), true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theLeaseRefreshMustBeShorterThanTheLeaseItself() { + assertThatThrownBy( + () -> + new FlamingockMigrationConfiguration( + "com.example.migrations", Duration.ofMinutes(1), Duration.ofMinutes(3), false)) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> FlamingockMigrationConfiguration.standard("com.example.migrations")) + .doesNotThrowAnyException(); + } + + @Test + void aLockThatCannotBeAcquiredRefusesRatherThanProceeding() { + assertThatThrownBy(() -> new FlamingockLockAdapter(() -> false, extension -> {}, () -> {})) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aReleasedLeaseCannotBeExtended() { + AtomicBoolean released = new AtomicBoolean(); + FlamingockLockAdapter lock = + new FlamingockLockAdapter(() -> true, extension -> {}, () -> released.set(true)); + + assertThat(lock.held()).isTrue(); + lock.close(); + + assertThat(released).isTrue(); + assertThatThrownBy(() -> lock.refresh(Duration.ofMinutes(1))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void theLedgerAdapterReadsTheEnginesAuditLog() { + FlamingockLedgerAdapter ledger = + new FlamingockLedgerAdapter( + id -> + "20260811-010".equals(id) + ? Optional.of(new FlamingockChangeUnitView("20260811-010", "sha256:1")) + : Optional.empty()); + + assertThat(ledger.find(new MongoMigrationId("20260811-010"))).isPresent(); + assertThat(ledger.find(new MongoMigrationId("20260811-011"))).isEmpty(); + } + + private static MongoMigration migration(String id, String checksum) { + return new MongoMigration() { + + @Override + public MongoMigrationId id() { + return new MongoMigrationId(id); + } + + @Override + public MongoMigrationChecksum checksum() { + return new MongoMigrationChecksum(checksum); + } + + @Override + public MongoMigrationPrecondition precondition() { + return MongoMigrationPrecondition.none(); + } + + @Override + public MongoMigrationResult execute(MongoMigrationContext context) { + return MongoMigrationResult.completed(0); + } + + @Override + public MongoMigrationPostcondition postcondition() { + return MongoMigrationPostcondition.none(); + } + }; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGatewayTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGatewayTest.java new file mode 100644 index 00000000..f1ccfd0e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/nativecap/PolicyAwareMongoNativeGatewayTest.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.outbound.mongo.nativecap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapability; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySet; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoCapabilitySupport; +import dev.caskeleton.adapter.outbound.mongo.api.capability.MongoSupportLevel; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §5 — nothing runs natively unless it was registered, and D4 never runs here. */ +@Tag("mongodb-contract") +class PolicyAwareMongoNativeGatewayTest { + + private final MongoCapabilitySet capabilities = + MongoCapabilitySet.of( + MongoCapabilitySupport.of(MongoCapability.SEARCH, MongoSupportLevel.ADVANCED), + MongoCapabilitySupport.unsupported(MongoCapability.VECTOR_SEARCH, "no atlas deployment")); + + @Test + void rejectsUnregisteredRunCommand() { + PolicyAwareMongoNativeGateway gateway = gateway(); + + assertThatThrownBy( + () -> gateway.execute(ApprovedMongoNativeOperation.unregistered("dropDatabase"))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void anAdminCategoryOperationIsRefusedEvenWhenRegisteredAndAvailable() { + // The capability is reported as available, so the refusal can only come from the category + // check — which is the point: the admin plane is separate even where the server supports it. + MongoCapabilitySet withAdminAvailable = + MongoCapabilitySet.of( + MongoCapabilitySupport.of(MongoCapability.ADMIN_PLANE, MongoSupportLevel.ADVANCED)); + MongoNativeOperationPolicy policy = + MongoNativeOperationPolicy.builder(withAdminAvailable) + .register("collection.drop", MongoCapability.ADMIN_PLANE) + .allowDatabase("default") + .allowCollection("orders") + .build(); + + ApprovedMongoNativeOperation operation = + new ApprovedMongoNativeOperation<>( + "collection.drop", + MongoCapability.ADMIN_PLANE, + MongoNativeCommandCategory.ADMIN, + "default", + "orders", + Duration.ofSeconds(1), + 1, + database -> null); + + assertThatThrownBy(() -> policy.require(operation)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("D4 admin plane"); + } + + @Test + void anUnsupportedCapabilityIsRefusedWithItsReason() { + MongoNativeOperationPolicy policy = + MongoNativeOperationPolicy.builder(capabilities) + .register("vector.probe", MongoCapability.VECTOR_SEARCH) + .allowDatabase("default") + .allowCollection("orders") + .build(); + + assertThatThrownBy( + () -> policy.require(readOperation("vector.probe", MongoCapability.VECTOR_SEARCH))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("no atlas deployment"); + } + + @Test + void anOffAllowlistCollectionIsRefused() { + MongoNativeOperationPolicy policy = + MongoNativeOperationPolicy.builder(capabilities) + .register("search.probe", MongoCapability.SEARCH) + .allowDatabase("default") + .allowCollection("orders") + .build(); + + ApprovedMongoNativeOperation operation = + new ApprovedMongoNativeOperation<>( + "search.probe", + MongoCapability.SEARCH, + MongoNativeCommandCategory.METADATA_READ, + "default", + "payments", + Duration.ofSeconds(1), + 1, + database -> "ok"); + + assertThatThrownBy(() -> policy.require(operation)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("payments"); + } + + @Test + void aRegisteredOperationRunsAndIsAudited() { + List audit = new ArrayList<>(); + MongoNativeOperationPolicy policy = + MongoNativeOperationPolicy.builder(capabilities) + .register("search.probe", MongoCapability.SEARCH) + .allowDatabase("default") + .allowCollection("orders") + .build(); + PolicyAwareMongoNativeGateway gateway = + new PolicyAwareMongoNativeGateway( + policy, + profile -> null, + (operationId, succeeded) -> audit.add(operationId + '=' + succeeded)); + + String result = gateway.execute(readOperation("search.probe", MongoCapability.SEARCH)); + + assertThat(result).isEqualTo("ok"); + assertThat(audit).containsExactly("search.probe=true"); + } + + private static ApprovedMongoNativeOperation readOperation( + String operationId, MongoCapability capability) { + return new ApprovedMongoNativeOperation<>( + operationId, + capability, + MongoNativeCommandCategory.METADATA_READ, + "default", + "orders", + Duration.ofSeconds(1), + 1, + database -> "ok"); + } + + private PolicyAwareMongoNativeGateway gateway() { + return new PolicyAwareMongoNativeGateway( + MongoNativeOperationPolicy.builder(capabilities) + .allowDatabase("default") + .allowCollection("orders") + .build(), + profile -> null, + (operationId, succeeded) -> {}); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConventionTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConventionTest.java new file mode 100644 index 00000000..ec8897a4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/observation/MongoObservationConventionTest.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.outbound.mongo.observation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §27 — telemetry carries bounded dimensions and never production data. */ +@Tag("mongodb-contract") +class MongoObservationConventionTest { + + private final MongoObservationConvention convention = MongoObservationConvention.standard(); + + @Test + void forbiddenHighCardinalityValuesAreNeverTags() { + assertThat(convention.allowedTagNames()) + .doesNotContain("documentId", "tenantId", "resumeToken", "query"); + } + + @Test + void theAllowedTagsAreExactlyTheDesignsList() { + assertThat(convention.allowedTagNames()) + .containsExactlyInAnyOrder( + "mongoProfile", + "databaseProfile", + "collectionProfile", + "operationName", + "operationType", + "result", + "failureCategory", + "consistencyProfile"); + } + + @Test + void aTagOutsideTheAllowlistIsRefused() { + assertThatCode(() -> convention.requireAllowed("operationName")).doesNotThrowAnyException(); + assertThatThrownBy(() -> convention.requireAllowed("shardKeyValue")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theForbiddenListCoversEveryValueTheDesignNames() { + assertThat(convention.forbiddenTagNames()) + .contains( + "documentId", + "rawTenantId", + "dynamicCollectionName", + "queryParameter", + "fullBson", + "resumeToken", + "shardKeyValue", + "plaintextPII", + "credential"); + } + + @Test + void authenticationCommandsAreAlwaysRedacted() { + MongoObservationRedactor redactor = new MongoObservationRedactor(); + + assertThat(redactor.describe("saslStart")).isEqualTo(""); + assertThat(redactor.isAlwaysRedacted("createUser")).isTrue(); + assertThat(redactor.describe("ping")).isEqualTo("ping"); + assertThat(redactor.describe("find")).isEqualTo("find(...)"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilderTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilderTest.java new file mode 100644 index 00000000..f64f31f1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/PolicyAwareMongoQueryBuilderTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.mongo.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.query.Query; + +/** Design §16.2 — a dynamic query can only say what the collection registered. */ +@Tag("mongodb-contract") +class PolicyAwareMongoQueryBuilderTest { + + private final MongoQueryPolicy policy = + MongoQueryPolicy.allowingFields("status", "createdAt", "_id"); + + @Test + void rejectsUnregisteredSortField() { + assertThatThrownBy(() -> new PolicyAwareMongoQueryBuilder(policy).sortBy("userSuppliedField")) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void rejectsAnUnregisteredFilterField() { + assertThatThrownBy( + () -> new PolicyAwareMongoQueryBuilder(policy).whereEquals("internalFlag", true)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void rejectsAnOperatorTheFieldDidNotRegister() { + MongoQueryPolicy equalityOnly = + MongoQueryPolicy.allowing( + List.of( + MongoFieldDescriptor.withOperators("status", Set.of(MongoOperator.EQ)), + MongoFieldDescriptor.of("_id"))); + + assertThatThrownBy( + () -> new PolicyAwareMongoQueryBuilder(equalityOnly).whereAtLeast("status", "PAID")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("GTE"); + } + + @Test + void aBuiltQueryAlwaysCarriesItsLimitAndDeadline() { + Query query = + new PolicyAwareMongoQueryBuilder(policy) + .whereEquals("status", "PAID") + .sortBy(MongoSortDescriptor.desc("createdAt")) + .build(MongoOperationBudget.standard()); + + assertThat(query.getLimit()).isEqualTo(MongoOperationBudget.standard().maxResults()); + assertThat(query.getMeta().getMaxTimeMsec()) + .isEqualTo(MongoOperationBudget.standard().maxTimeMillis()); + } + + @Test + void deepSkipIsRefusedInFavourOfKeysetPagination() { + assertThatThrownBy(() -> new PolicyAwareMongoQueryBuilder(policy).skip(50_000)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("keyset"); + } + + @Test + void anUnallowlistedHintIsRefused() { + assertThatThrownBy(() -> new PolicyAwareMongoQueryBuilder(policy).useIndex("ix_anything")) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatCode( + () -> + new PolicyAwareMongoQueryBuilder(policy.withHints("ix_status")) + .useIndex("ix_status")) + .doesNotThrowAnyException(); + } + + @Test + void anUnanchoredOrExplosiveRegexIsRefusedBeforeItReachesTheServer() { + MongoQueryPolicy regexPolicy = + MongoQueryPolicy.allowing( + List.of( + MongoFieldDescriptor.withOperators("status", Set.of(MongoOperator.REGEX)), + MongoFieldDescriptor.of("_id"))) + .withRegexPolicy(MongoRegexPolicy.standard()); + + assertThatThrownBy( + () -> new PolicyAwareMongoQueryBuilder(regexPolicy).whereMatches("status", "PAID", "")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("anchored"); + + assertThatThrownBy( + () -> + new PolicyAwareMongoQueryBuilder(regexPolicy).whereMatches("status", "^(a+)+$", "")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("exponential"); + } + + @Test + void anEmptyMembershipPredicateIsRefused() { + assertThatThrownBy(() -> new PolicyAwareMongoQueryBuilder(policy).whereIn("status", List.of())) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aFieldMayBeFilterableWithoutBeingSortable() { + MongoQueryPolicy filterOnly = + MongoQueryPolicy.allowing( + List.of(MongoFieldDescriptor.filterOnly("notes"), MongoFieldDescriptor.of("_id"))); + + assertThatCode(() -> filterOnly.requireField("notes")).doesNotThrowAnyException(); + assertThatThrownBy(() -> filterOnly.requireSortable("notes")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("in memory"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcerTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcerTest.java new file mode 100644 index 00000000..16b5af36 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/budget/MongoBudgetEnforcerTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.mongo.query.budget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §17 — a caller may tighten a registered budget, never loosen it. */ +@Tag("mongodb-contract") +class MongoBudgetEnforcerTest { + + private final MongoBudgetEnforcer enforcer = new MongoBudgetEnforcer(); + + @Test + void callerCannotRaiseRegisteredResultLimit() { + MongoOperationBudget registered = new MongoOperationBudget(100, 1_048_576, 500, 50); + + assertThatThrownBy( + () -> enforcer.resolve(registered, new MongoOperationBudget(1_000, 1_048_576, 500, 50))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void callerMayTightenEveryBound() { + MongoOperationBudget registered = new MongoOperationBudget(100, 1_048_576, 500, 50); + MongoOperationBudget requested = new MongoOperationBudget(10, 65_536, 100, 10); + + assertThatCode(() -> enforcer.resolve(registered, requested)).doesNotThrowAnyException(); + assertThat(enforcer.narrow(registered, requested)).isEqualTo(requested); + } + + @Test + void raisingAnyOneBoundIsEnoughToBeRefused() { + MongoOperationBudget registered = new MongoOperationBudget(100, 1_048_576, 500, 50); + + assertThatThrownBy( + () -> enforcer.resolve(registered, new MongoOperationBudget(100, 1_048_576, 5_000, 50))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void anUnregisteredOperationHasNoDefaultBudget() { + MongoBudgetPolicyRegistry registry = + MongoBudgetPolicyRegistry.builder() + .register("order.find-recent", MongoOperationBudget.standard()) + .build(); + + assertThat(registry.isRegistered(new MongoOperationName("order.find-recent"))).isTrue(); + assertThatThrownBy(() -> registry.require(new MongoOperationName("order.find-all"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("no registered resource budget"); + } + + @Test + void aBudgetWithANonPositiveBoundIsNotABudget() { + assertThatThrownBy(() -> new MongoOperationBudget(0, 1, 1, 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new MongoOperationBudget(1, 1, 0, 1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void narrowingTakesTheElementWiseMinimum() { + MongoOperationBudget registered = new MongoOperationBudget(100, 1_048_576, 500, 50); + MongoOperationBudget requested = new MongoOperationBudget(1_000, 64, 5_000, 5); + + assertThat(enforcer.narrow(registered, requested)) + .isEqualTo(new MongoOperationBudget(100, 64, 500, 5)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilderTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilderTest.java new file mode 100644 index 00000000..8882413c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/query/pagination/MongoKeysetQueryBuilderTest.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.outbound.mongo.query.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.bson.Document; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §19.1 — a keyset sort must be a total order, and a cursor must be authenticated. */ +@Tag("mongodb-contract") +class MongoKeysetQueryBuilderTest { + + private static final byte[] SIGNING_KEY = + "a-test-signing-key-of-at-least-32-bytes".getBytes(StandardCharsets.UTF_8); + + @Test + void rejectsSortWithoutUniqueTieBreaker() { + MongoKeysetSort sort = MongoKeysetSort.desc("createdAt"); + + assertThatThrownBy(() -> MongoKeysetQueryBuilder.validate(sort)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void acceptsASortWithTheIdTieBreaker() { + MongoKeysetSort sort = MongoKeysetSort.desc("createdAt").withIdTieBreaker(); + + assertThatCode(() -> MongoKeysetQueryBuilder.validate(sort)).doesNotThrowAnyException(); + assertThat(sort.fields()).containsExactly("createdAt", "_id"); + } + + @Test + void theResumePredicateIsLexicographicRatherThanFlattened() { + MongoKeysetSort sort = MongoKeysetSort.desc("createdAt").withIdTieBreaker(); + Map values = new LinkedHashMap<>(); + values.put("createdAt", "2026-08-13"); + values.put("_id", "o-100"); + + Document criteria = + MongoKeysetQueryBuilder.resumeCriteria(sort, MongoKeysetCursor.unsigned(1, values)) + .getCriteriaObject(); + + // Two alternatives: strictly smaller createdAt, or equal createdAt and strictly smaller _id. + assertThat(criteria.getList("$or", Object.class)).hasSize(2); + } + + @Test + void aCursorIssuedForAnotherSortVersionIsRefused() { + MongoKeysetSort version2 = + MongoKeysetSort.desc("createdAt").withIdTieBreaker().withSortVersion(2); + MongoKeysetCursor staleCursor = + MongoKeysetCursor.unsigned(1, Map.of("createdAt", "x", "_id", "y")); + + assertThatThrownBy(() -> MongoKeysetPageRequest.after(version2, staleCursor, 20)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aTamperedCursorFailsAuthentication() { + MongoKeysetCursorCodec codec = new MongoKeysetCursorCodec(SIGNING_KEY); + String token = + codec.encode( + MongoKeysetCursor.unsigned(1, Map.of("createdAt", "2026-08-13", "_id", "o-1"))); + String tampered = token.substring(0, token.length() - 2) + "xy"; + + assertThatThrownBy(() -> codec.decode(tampered)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void anAuthenticCursorRoundTrips() { + MongoKeysetCursorCodec codec = new MongoKeysetCursorCodec(SIGNING_KEY); + Map values = new LinkedHashMap<>(); + values.put("createdAt", "2026-08-13"); + values.put("_id", "o-1"); + + MongoKeysetCursor decoded = codec.decode(codec.encode(MongoKeysetCursor.unsigned(1, values))); + + assertThat(decoded.sortVersion()).isEqualTo(1); + assertThat(decoded.values()).containsEntry("_id", "o-1"); + } + + @Test + void theCursorNeverRendersItsValues() { + MongoKeysetCursor cursor = + MongoKeysetCursor.unsigned(1, Map.of("createdAt", "2026-08-13", "_id", "o-secret")); + + assertThat(cursor.toString()).doesNotContain("o-secret").contains("sortVersion=1"); + } + + @Test + void aPageQueryFetchesOneExtraDocumentToAnswerHasNext() { + MongoKeysetSort sort = MongoKeysetSort.desc("createdAt").withIdTieBreaker(); + + assertThat(MongoKeysetQueryBuilder.pageQuery(MongoKeysetPageRequest.first(sort, 20)).getLimit()) + .isEqualTo(21); + } + + @Test + void aSliceWithAFurtherPageMustCarryItsCursor() { + assertThatThrownBy(() -> new MongoKeysetSlice<>(java.util.List.of("a"), null, true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aShortSigningKeyIsRefused() { + assertThatThrownBy(() -> new MongoKeysetCursorCodec(new byte[16])) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuardTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuardTest.java new file mode 100644 index 00000000..f5ae5750 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/reactive/cursor/MongoCursorGuardTest.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.outbound.mongo.reactive.cursor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import dev.caskeleton.adapter.outbound.mongo.query.budget.MongoOperationBudget; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +/** Design §32 — a cursor is released on every termination path, cancellation included. */ +@Tag("mongodb-contract") +class MongoCursorGuardTest { + + @Test + void cancelClosesCursorExactlyOnce() { + CountingLease lease = new CountingLease(); + MongoCursorGuard guard = MongoCursorGuard.withLifetime(Duration.ofSeconds(5)); + + StepVerifier.create(guard.guard(() -> lease, ignored -> Flux.never())).thenCancel().verify(); + + assertThat(lease.closeCount()).isEqualTo(1); + } + + @Test + void completionAlsoClosesTheCursor() { + CountingLease lease = new CountingLease(); + MongoCursorGuard guard = MongoCursorGuard.withLifetime(Duration.ofSeconds(5)); + + StepVerifier.create(guard.guard(() -> lease, ignored -> Flux.just("a", "b"))) + .expectNext("a", "b") + .verifyComplete(); + + assertThat(lease.closeCount()).isEqualTo(1); + assertThat(lease.closed()).isTrue(); + } + + @Test + void anErrorAlsoClosesTheCursor() { + CountingLease lease = new CountingLease(); + MongoCursorGuard guard = MongoCursorGuard.withLifetime(Duration.ofSeconds(5)); + + StepVerifier.create( + guard.guard(() -> lease, ignored -> Flux.error(new IllegalStateException()))) + .verifyError(IllegalStateException.class); + + assertThat(lease.closeCount()).isEqualTo(1); + } + + @Test + void terminationKindIsRecordedSeparatelyFromTheOperationOutcome() { + List recorded = new ArrayList<>(); + MongoCursorGuard guard = new MongoCursorGuard(Duration.ofSeconds(5), recorded::add); + + StepVerifier.create(guard.guard(CountingLease::new, ignored -> Flux.just("a"))) + .expectNext("a") + .verifyComplete(); + StepVerifier.create(guard.guard(CountingLease::new, ignored -> Flux.never())) + .thenCancel() + .verify(); + + assertThat(recorded) + .containsExactly(MongoCursorTermination.COMPLETED, MongoCursorTermination.CANCELLED); + } + + @Test + void aStreamWithoutABatchSizeIsRefused() { + MongoCursorGuard guard = MongoCursorGuard.withLifetime(Duration.ofSeconds(5)); + + assertThatThrownBy(() -> guard.requireBoundedBatch(MongoOperationBudget.standard(), 0)) + .isInstanceOf(MongoOperationRejectedException.class); + assertThatThrownBy(() -> guard.requireBoundedBatch(MongoOperationBudget.standard(), 10_000)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aLifetimeExpiryIsDistinguishableFromAnOrdinaryFailure() { + assertThat(MongoCursorTermination.LIFETIME_EXPIRED.indicatesBackpressureProblem()).isTrue(); + assertThat(MongoCursorTermination.FAILED.indicatesBackpressureProblem()).isFalse(); + } + + /** A lease that records how often it was released. */ + private static final class CountingLease implements MongoCursorLease { + + private final AtomicInteger closeCount = new AtomicInteger(); + + @Override + public boolean closed() { + return closeCount.get() > 0; + } + + @Override + public void close() { + closeCount.incrementAndGet(); + } + + private int closeCount() { + return closeCount.get(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoSingleReplicaSetContainerTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoSingleReplicaSetContainerTest.java new file mode 100644 index 00000000..7672b70b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoSingleReplicaSetContainerTest.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.outbound.mongo.rs; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mongodb.client.ChangeStreamIterable; +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCursor; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.changestream.ChangeStreamDocument; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoReplicaSetFixture; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoSingleReplicaSetContainer; +import java.time.Duration; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Design D-02, Task 44 — the local topology is a replica set so local behaves like production. + * + *

The assertions are behavioural, not textual. A connection string containing {@code + * replicaSet=} proves nothing: a standalone with the parameter appended looks identical and refuses + * every operation the design chose a replica set for. Starting a transaction and receiving a change + * event are what distinguish the two. + * + *

Tagged {@code mongodb-replicaset}: it needs Docker and runs in the {@code mongoReplicaSetTest} + * lane, not in the default unit run. A lane that needs Docker inside {@code check} teaches people + * to skip {@code check}. + */ +@Tag("mongodb-replicaset") +class MongoSingleReplicaSetContainerTest { + + private static final Duration CHANGE_EVENT_TIMEOUT = Duration.ofSeconds(30); + + private static MongoSingleReplicaSetContainer mongo; + + private static MongoClient client; + + @BeforeAll + static void start() { + mongo = MongoSingleReplicaSetContainer.mongoEight(); + mongo.start(); + client = MongoClients.create(mongo.connectionString()); + } + + @AfterAll + static void stop() { + if (client != null) { + client.close(); + } + if (mongo != null) { + mongo.close(); + } + } + + @Test + void theDeploymentSupportsTransactions() { + MongoDatabase database = client.getDatabase("rscontract"); + database.getCollection("orders").insertOne(new Document("orderNumber", "A-1")); + + try (ClientSession session = client.startSession()) { + session.startTransaction(); + database.getCollection("orders").insertOne(session, new Document("orderNumber", "A-2")); + session.commitTransaction(); + } + + assertThat(database.getCollection("orders").countDocuments()) + .as("a standalone would have refused startTransaction") + .isEqualTo(2); + } + + @Test + void theDeploymentDeliversChangeEvents() { + MongoDatabase database = client.getDatabase("rscontract"); + database.createCollection("watched"); + ChangeStreamIterable stream = database.getCollection("watched").watch(); + + try (MongoCursor> cursor = stream.cursor()) { + database.getCollection("watched").insertOne(new Document("observed", true)); + + long deadline = System.nanoTime() + CHANGE_EVENT_TIMEOUT.toNanos(); + ChangeStreamDocument event = null; + while (event == null && System.nanoTime() < deadline) { + event = cursor.tryNext(); + } + + assertThat(event) + .as("a change stream needs an oplog, which a standalone does not have") + .isNotNull(); + } + } + + @Test + void aSingleNodeSetIsNotFailoverEvidence() { + assertThat(mongo.providesFailoverEvidence()) + .as("a single-node set never holds an election, so it certifies nothing about failover") + .isFalse(); + } + + @Test + void eachTestClassGetsAnIsolatedDatabase() { + MongoReplicaSetFixture first = MongoReplicaSetFixture.forTest(mongo, getClass()); + MongoReplicaSetFixture second = MongoReplicaSetFixture.forTest(mongo, getClass()); + + assertThat(first.databaseName()).isNotEqualTo(second.databaseName()); + assertThat(first.connectionString()).contains(first.databaseName()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoThreeNodeReplicaSetTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoThreeNodeReplicaSetTest.java new file mode 100644 index 00000000..c0dfd07b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/rs/MongoThreeNodeReplicaSetTest.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.outbound.mongo.rs; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.mongo.testkit.failover.MongoThreeNodeReplicaSet; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Design §29, Task 45 — an election only happens where there is more than one node. + * + *

Tagged {@code mongodb-failover}: it starts three containers and runs in the {@code + * mongoFailoverTest} lane. + */ +@Tag("mongodb-failover") +class MongoThreeNodeReplicaSetTest { + + @Test + void electsANewPrimaryAfterCurrentPrimaryStops() { + try (MongoThreeNodeReplicaSet replicaSet = MongoThreeNodeReplicaSet.startMongoEight()) { + String first = replicaSet.primaryAddress(); + + replicaSet.stopPrimary(); + String second = replicaSet.awaitNewPrimary(); + + assertThat(second).isNotEqualTo(first); + } + } + + @Test + void theConnectionStringCoversEveryNode() { + try (MongoThreeNodeReplicaSet replicaSet = MongoThreeNodeReplicaSet.startMongoEight()) { + assertThat(replicaSet.connectionString()).contains("replicaSet=").contains(","); + assertThat(replicaSet.nodes()).hasSize(3); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngineTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngineTest.java new file mode 100644 index 00000000..45b9b786 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/index/MongoIndexDiffEngineTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.index; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +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.MongoIndexManifest; +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoMetadataOwnership; +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §18 — drift is reported, and encryption metadata is never proposed for deletion. */ +@Tag("mongodb-contract") +class MongoIndexDiffEngineTest { + + private final MongoIndexDiffEngine engine = new MongoIndexDiffEngine(); + + @Test + void encryptionManagedIndexIsNotMarkedForDeletion() { + MongoCollectionManifest manifest = applicationManifest(); + List observed = + List.of( + MongoIndexDescriptorView.observed("orders", "ix_status", "status:1", false, false), + MongoIndexDescriptorView.observed("orders", "_id_", "_id:1", true, false), + MongoIndexDescriptorView.observed( + "orders", "__safeContent___1", "__safeContent__:1", false, false)); + + MongoIndexDiff diff = engine.compare(manifest, observed); + + assertThat(diff.dropCandidates()).isEmpty(); + } + + @Test + void anOrphanApplicationIndexBecomesADropCandidate() { + List observed = + List.of( + MongoIndexDescriptorView.observed("orders", "ix_status", "status:1", false, false), + MongoIndexDescriptorView.observed("orders", "ix_legacy", "legacy:1", false, false)); + + MongoIndexDiff diff = engine.compare(applicationManifest(), observed); + + assertThat(diff.dropCandidates()).containsExactly("orders.ix_legacy"); + } + + @Test + void aMissingDeclaredIndexIsACreate() { + MongoIndexDiff diff = engine.compare(applicationManifest(), List.of()); + + assertThat(diff.create()).containsExactly("orders.ix_status"); + assertThat(diff.isClean()).isFalse(); + } + + @Test + void sameNameDifferentKeysIsAChangeRatherThanARecreate() { + List observed = + List.of( + MongoIndexDescriptorView.observed("orders", "ix_status", "createdAt:1", false, false)); + + MongoIndexDiff diff = engine.compare(applicationManifest(), observed); + + assertThat(diff.change()).containsExactly("orders.ix_status"); + } + + @Test + void theDiffRenderingIsDeterministic() { + MongoIndexDiff diff = + new MongoIndexDiff(List.of("a.ix1"), List.of(), List.of(), List.of("a.ix2")); + + assertThat(diff.render()).isEqualTo(diff.render()); + assertThat(diff.render()).contains("create a.ix1").contains("drop-candidate a.ix2"); + } + + @Test + void productionRuntimeMayNotApplyIndexChanges() { + assertThat(MongoIndexApplyPolicy.REPORT_ONLY.runtimeMayApply()).isFalse(); + assertThatThrownBy(() -> MongoIndexApplyPolicy.REPORT_ONLY.requireRuntimeApplyAllowed("orders")) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("D4"); + assertThat(MongoIndexApplyPolicy.APPLY.runtimeMayApply()).isTrue(); + } + + @Test + void retirementAdvancesOneStepAtATime() { + MongoIndexRetirementPlan plan = MongoIndexRetirementPlan.deprecate("orders", "ix_legacy"); + + assertThatThrownBy(() -> plan.advanceTo(MongoIndexRetirementState.APPROVED, "release-engineer")) + .isInstanceOf(MongoOperationRejectedException.class); + + MongoIndexRetirementPlan approved = + plan.advanceTo(MongoIndexRetirementState.USAGE_OBSERVED, "release-engineer") + .advanceTo(MongoIndexRetirementState.HIDDEN, "release-engineer") + .advanceTo(MongoIndexRetirementState.REGRESSION_CHECKED, "release-engineer") + .advanceTo(MongoIndexRetirementState.APPROVED, "release-engineer"); + + assertThat(approved.droppable()).isTrue(); + assertThat(approved.approvedBy()).isEqualTo("release-engineer"); + } + + private static MongoCollectionManifest applicationManifest() { + return MongoCollectionManifest.builder("orders") + .owner("order-domain") + .index( + MongoIndexManifest.named("ix_status") + .ascending("status") + .expectedUsage("order.find-by-status") + .metadataOwnership(MongoMetadataOwnership.APPLICATION) + .build()) + .build(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistryTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistryTest.java new file mode 100644 index 00000000..e25a040a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/manifest/MongoManifestRegistryTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.manifest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.schema.DocumentSchemaVersion; +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction; +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationLevel; +import java.time.Instant; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §18 — the manifest is the single source of truth, validated as a set. */ +@Tag("mongodb-contract") +class MongoManifestRegistryTest { + + @Test + void rejectsDuplicateIndexNames() { + MongoCollectionManifest collection = + MongoCollectionManifest.builder("orders") + .index( + MongoIndexManifest.named("ix_status") + .ascending("status") + .expectedUsage("order.find-by-status") + .build()) + .index( + MongoIndexManifest.named("ix_status") + .ascending("createdAt") + .expectedUsage("order.find-recent") + .build()) + .build(); + + assertThatThrownBy(() -> MongoManifestRegistry.of(collection)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsDuplicateCollectionNames() { + MongoCollectionManifest first = MongoCollectionManifest.builder("orders").build(); + MongoCollectionManifest second = MongoCollectionManifest.builder("orders").build(); + + assertThatThrownBy(() -> MongoManifestRegistry.of(first, second)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void indexKeyOrderIsPreserved() { + MongoIndexManifest index = + MongoIndexManifest.named("ix_customer_created_id") + .ascending("customerId") + .descending("createdAt") + .descending("_id") + .expectedUsage("order.find-recent") + .build(); + + assertThat(index.keys()) + .extracting(MongoIndexKey::field) + .containsExactly("customerId", "createdAt", "_id"); + assertThat(index.keySignature()).isEqualTo("customerId:1,createdAt:-1,_id:-1"); + } + + @Test + void anApplicationIndexMustNameTheQueryItServes() { + assertThatThrownBy(() -> MongoIndexManifest.named("ix_orphan").ascending("status").build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("expected usage"); + } + + @Test + void metadataOwnershipDistinguishesWhoMayDropAnIndex() { + assertThat(MongoMetadataOwnership.APPLICATION.droppableByApplicationDrift()).isTrue(); + assertThat(MongoMetadataOwnership.ENCRYPTION_MANAGED.droppableByApplicationDrift()).isFalse(); + assertThat(MongoMetadataOwnership.SEARCH_MANAGED.droppableByApplicationDrift()).isFalse(); + assertThat(MongoMetadataOwnership.MONGODB_MANAGED.droppableByApplicationDrift()).isFalse(); + } + + @Test + void aRelaxedValidatorNeedsAnExpiringMigrationWindow() { + assertThatThrownBy( + () -> + new MongoSchemaManifest( + "classpath:/mongodb/orders-schema-v3.json", + MongoValidationLevel.MODERATE, + MongoValidationAction.WARN, + new DocumentSchemaVersion(3), + null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("migration window"); + } + + @Test + void anExpiredMigrationWindowIsDetectable() { + MongoSchemaManifest manifest = + MongoSchemaManifest.migrationWindow( + "classpath:/mongodb/orders-schema-v3.json", 3, Instant.parse("2026-01-01T00:00:00Z")); + + assertThat(manifest.migrationWindowExpired(Instant.parse("2026-02-01T00:00:00Z"))).isTrue(); + assertThat(manifest.isRelaxed()).isTrue(); + } + + @Test + void requiringAnUndeclaredCollectionFails() { + MongoManifestRegistry registry = + MongoManifestRegistry.of(MongoCollectionManifest.builder("orders").build()); + + assertThat(registry.find("orders")).isPresent(); + assertThatThrownBy(() -> registry.require("customers")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidatorTest.java new file mode 100644 index 00000000..095780bb --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/model/MongoDocumentModelValidatorTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §9.2 — a document has to be provably bounded, not plausibly bounded. */ +@Tag("mongodb-contract") +class MongoDocumentModelValidatorTest { + + private final MongoDocumentModelValidator validator = new MongoDocumentModelValidator(); + + @Test + void rejectsUnboundedEmbeddedArray() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("posts") + .embedded("comments", EmbeddedCollectionDescriptor.unbounded()) + .build(); + + assertThatThrownBy(() -> validator.validate(manifest)).hasMessageContaining("comments"); + } + + @Test + void acceptsABoundedArrayThatFitsTheBudget() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("orders") + .baseDocumentBytes(2048) + .sizeBudget(2L * 1024 * 1024) + .embedded("lineItems", EmbeddedCollectionDescriptor.bounded("lineItems", 200, 512)) + .build(); + + assertThatCode(() -> validator.validate(manifest)).doesNotThrowAnyException(); + } + + @Test + void rejectsABoundedArrayThatStillExceedsTheBudget() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("orders") + .sizeBudget(64 * 1024) + .embedded( + "stateHistory", EmbeddedCollectionDescriptor.bounded("stateHistory", 5000, 512)) + .build(); + + assertThatThrownBy(() -> validator.validate(manifest)).hasMessageContaining("budget"); + } + + @Test + void aBudgetCloseToMongodbsHardLimitIsRefused() { + assertThatThrownBy(() -> new MongoDocumentSizeBudget(15L * 1024 * 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("platform ceiling"); + } + + @Test + void aLargeInlineBinaryFieldIsRedirectedToObjectStorage() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("attachments") + .binaryField(new MongoBinaryFieldDescriptor("payload", 512 * 1024)) + .build(); + + assertThatThrownBy(() -> validator.validate(manifest)) + .hasMessageContaining("Fileserver / Object Storage"); + } + + @Test + void aRequiredReferenceCannotBeWeak() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("orders") + .reference( + new MongoReferenceDescriptor( + "customerId", "customers", true, MongoReferenceLifecycle.WEAK)) + .build(); + + assertThatThrownBy(() -> validator.validate(manifest)).hasMessageContaining("customerId"); + } + + @Test + void anUnboundedArrayMakesTheWorstCaseSaturateRatherThanOverflow() { + MongoDocumentModelManifest manifest = + MongoDocumentModelManifest.builder("posts") + .embedded("comments", EmbeddedCollectionDescriptor.unbounded()) + .build(); + + assertThat(manifest.worstCaseDocumentBytes()).isEqualTo(Long.MAX_VALUE); + } + + @Test + void aReferenceMustNameItsTargetCollection() { + assertThatThrownBy(() -> MongoReferenceDescriptor.required("customerId", "")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidatorTest.java new file mode 100644 index 00000000..ee2cd514 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/ttl/MongoTtlPolicyValidatorTest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.ttl; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §21.1, D-13 — TTL is physical cleanup, not a scheduler and not access control. */ +@Tag("mongodb-contract") +class MongoTtlPolicyValidatorTest { + + private final MongoTtlPolicyValidator validator = new MongoTtlPolicyValidator(); + + @Test + void rejectsTtlAsExactScheduler() { + MongoTtlPolicy policy = MongoTtlPolicy.exactBusinessTransition("expiresAt"); + + assertThatThrownBy(() -> validator.validate(policy)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void acceptsPhysicalCleanupWithAQueryTimeCheck() { + MongoTtlPolicy policy = MongoTtlPolicy.physicalCleanup("expiresAt", Duration.ofDays(30)); + + assertThatCode(() -> validator.validate(policy)).doesNotThrowAnyException(); + } + + @Test + void aVeryShortRetentionWouldExpireThePopulationInOneSweep() { + MongoTtlPolicy policy = MongoTtlPolicy.physicalCleanup("expiresAt", Duration.ofSeconds(1)); + + assertThatThrownBy(() -> validator.validate(policy)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("single sweep"); + } + + @Test + void aNonDateExpiryFieldIsNeverExpired() { + MongoTtlIndexDescriptor descriptor = + new MongoTtlIndexDescriptor( + "sessions", + "ix_ttl_expires", + MongoTtlPolicy.physicalCleanup("expiresAt", Duration.ofDays(1)), + "string"); + + assertThatThrownBy(() -> validator.validate(descriptor)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("BSON date"); + } + + @Test + void readsWithoutTheLogicalExpiryPredicateAreRefused() { + assertThatThrownBy(() -> validator.validate(MongoExpirationAccessPolicy.none("expiresAt"))) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("expiresAt > applicationNow"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicyTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicyTest.java new file mode 100644 index 00000000..4282df2d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/schema/validation/MongoValidatorApplyPolicyTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.mongo.schema.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.schema.manifest.MongoSchemaManifest; +import java.time.Instant; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §4, §12.1 — errorAndLog is outside the certified contract, and collMod is D4. */ +@Tag("mongodb-contract") +class MongoValidatorApplyPolicyTest { + + @Test + void mongoEightZeroRejectsErrorAndLog() { + MongoValidatorApplyPolicy policy = MongoValidatorApplyPolicy.forServer("8.0"); + + assertThatThrownBy(() -> policy.validate(MongoValidationAction.ERROR_AND_LOG)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void mongoSevenZeroRejectsErrorAndLogToo() { + assertThatThrownBy( + () -> + MongoValidatorApplyPolicy.forServer("7.0") + .validate(MongoValidationAction.ERROR_AND_LOG)) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void errorAndWarnAreBothCertified() { + MongoValidatorApplyPolicy policy = MongoValidatorApplyPolicy.forServer("8.0"); + + assertThatCode(() -> policy.validate(MongoValidationAction.ERROR)).doesNotThrowAnyException(); + assertThatCode(() -> policy.validate(MongoValidationAction.WARN)).doesNotThrowAnyException(); + assertThat(policy.certifiedLane()).isTrue(); + } + + @Test + void aRuntimeCredentialMayNeverApplyAValidatorChange() { + assertThat(MongoValidatorApplyPolicy.forServer("8.0").runtimeMayApply()).isFalse(); + } + + @Test + void anExpiredMigrationWindowFailsValidation() { + MongoSchemaManifest relaxed = + MongoSchemaManifest.migrationWindow( + "classpath:/mongodb/orders-schema-v3.json", 3, Instant.parse("2026-01-01T00:00:00Z")); + + assertThatThrownBy( + () -> + MongoValidatorApplyPolicy.forServer("8.0") + .validate(relaxed, Instant.parse("2026-03-01T00:00:00Z"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("migration window"); + } + + @Test + void driftAgainstAnAbsentValidatorIsReportedForAdminApply() { + MongoValidatorDiff diff = + new MongoValidatorDiffEngine() + .compare( + MongoSchemaManifest.strict("classpath:/mongodb/orders-schema-v3.json", 3), + MongoValidatorDescriptor.absent("orders"), + "{\"bsonType\":\"object\"}"); + + assertThat(diff.isClean()).isFalse(); + assertThat(diff.requiresAdminApply()).isTrue(); + assertThat(diff.differences()).anyMatch(entry -> entry.contains("no validator")); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityIntegrationLaneTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityIntegrationLaneTest.java new file mode 100644 index 00000000..6773f9e1 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityIntegrationLaneTest.java @@ -0,0 +1,150 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.mongodb.MongoCommandException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationConvention; +import dev.caskeleton.adapter.outbound.mongo.observation.MongoObservationRedactor; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoAuthenticatedReplicaSetContainer; +import java.util.List; +import java.util.Set; +import org.bson.Document; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The security lane against a server with authorization enforced (design §26, §27). + * + *

Least privilege is only ever tested by the operation that is supposed to fail. A unit test can + * assert that the platform models a read-only role; only a server with {@code --auth} can prove + * that a credential holding that role is actually refused when it writes. + * + *

Tagged {@code mongodb-security-integration}: it needs a container and runs in the {@code + * mongoSecurityIntegrationTest} lane. + */ +@Tag("mongodb-security-integration") +class MongoSecurityIntegrationLaneTest { + + private static final String DATABASE = "securitylane"; + + private static MongoAuthenticatedReplicaSetContainer container; + + private static MongoClient rootClient; + + @BeforeAll + static void startServer() { + container = MongoAuthenticatedReplicaSetContainer.mongoEight(); + container.start(); + rootClient = MongoClients.create(container.rootConnectionString()); + + MongoDatabase database = rootClient.getDatabase(DATABASE); + database.getCollection("orders").insertOne(new Document("orderNumber", "A-1")); + + database.runCommand( + new Document("createUser", "app-read") + .append("pwd", "read-secret") + .append("roles", List.of(new Document("role", "read").append("db", DATABASE)))); + database.runCommand( + new Document("createUser", "app-write") + .append("pwd", "write-secret") + .append("roles", List.of(new Document("role", "readWrite").append("db", DATABASE)))); + } + + @AfterAll + static void stopServer() { + if (rootClient != null) { + rootClient.close(); + } + if (container != null) { + container.close(); + } + } + + @Test + void aReadRoleCannotWrite() { + try (MongoClient readOnly = clientFor("app-read", "read-secret")) { + MongoDatabase database = readOnly.getDatabase(DATABASE); + + assertThatCode(() -> database.getCollection("orders").countDocuments()) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> database.getCollection("orders").insertOne(new Document("orderNumber", "B-1"))) + .as("APP_READ is only least privilege if the server refuses the write") + .isInstanceOf(MongoCommandException.class); + } + } + + @Test + void anApplicationRoleCannotReachTheAdminPlane() { + try (MongoClient application = clientFor("app-write", "write-secret")) { + MongoDatabase database = application.getDatabase(DATABASE); + + assertThatCode( + () -> database.getCollection("orders").insertOne(new Document("orderNumber", "B-2"))) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> database.runCommand(new Document("dropDatabase", 1))) + .as("no application credential may drop a database, whatever the calling code does") + .isInstanceOf(MongoCommandException.class); + } + } + + @Test + void aWrongPasswordFailsClosedRatherThanConnectingAnonymously() { + assertThatThrownBy( + () -> { + try (MongoClient wrong = clientFor("app-read", "not-the-password")) { + wrong.getDatabase(DATABASE).getCollection("orders").countDocuments(); + } + }) + .isInstanceOf(RuntimeException.class); + } + + @Test + void theProfileValidatorAgreesWithWhatTheServerEnforces() { + MongoCredentialReference credential = + new MongoCredentialReference("secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE); + + assertThat( + MongoSecurityProfile.production(credential, Set.of("find", "insert", "update")) + .forbiddenPrivilegesHeld()) + .as("the roles the server actually granted app-write are all permitted") + .isEmpty(); + assertThat( + MongoSecurityProfile.production(credential, Set.of("find", "dropDatabase")) + .forbiddenPrivilegesHeld()) + .as("and the one the server just refused is the one the validator names") + .contains("dropDatabase"); + } + + @Test + void authenticationCommandsAreNeverDescribedInTelemetry() { + MongoObservationRedactor redactor = new MongoObservationRedactor(); + + assertThat(redactor.describe("saslStart")).isEqualTo(""); + assertThat(redactor.describe("createUser")).isEqualTo(""); + assertThat(redactor.isAlwaysRedacted("authenticate")).isTrue(); + assertThat(redactor.describe("find")).isEqualTo("find(...)"); + } + + @Test + void telemetryCannotCarryATenantOrDocumentIdentifier() { + MongoObservationConvention convention = MongoObservationConvention.standard(); + + assertThatThrownBy(() -> convention.requireAllowed("documentId")) + .isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> convention.requireAllowed("tenantId")) + .isInstanceOf(RuntimeException.class); + assertThatCode(() -> convention.requireAllowed("operationName")).doesNotThrowAnyException(); + } + + private static MongoClient clientFor(String user, String password) { + return MongoClients.create(container.connectionStringFor(user, password, DATABASE)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidatorTest.java new file mode 100644 index 00000000..bd76cc9d --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/MongoSecurityProfileValidatorTest.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.outbound.mongo.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §26 — production fails closed on TLS, auth, principal and privilege. */ +@Tag("mongodb-contract") +class MongoSecurityProfileValidatorTest { + + private final MongoSecurityProfileValidator validator = new MongoSecurityProfileValidator(); + + @Test + void productionRejectsTlsDisabled() { + MongoSecurityProfile profile = MongoSecurityProfile.production(false, true); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void productionRejectsAuthenticationDisabled() { + assertThatThrownBy(() -> validator.validate(MongoSecurityProfile.production(true, false))) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void localMayRunWithoutTlsOrAuthentication() { + MongoSecurityProfile local = + MongoSecurityProfile.local( + new MongoCredentialReference("secret://mongodb/local", MongoPrincipalRole.APP_WRITE)); + + assertThatCode(() -> validator.validate(local)).doesNotThrowAnyException(); + } + + @Test + void anInlineConnectionStringIsNotACredentialReference() { + assertThatThrownBy( + () -> + new MongoCredentialReference( + "mongodb://user:pass@db", MongoPrincipalRole.APP_WRITE)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aRuntimePrincipalHoldingAdminPrivilegesIsRefused() { + MongoSecurityProfile profile = + MongoSecurityProfile.production( + new MongoCredentialReference( + "secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE), + Set.of("dropDatabase")); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("dropDatabase"); + } + + @Test + void aProductionRuntimeMayNotUseTheDbaPrincipal() { + MongoSecurityProfile profile = + MongoSecurityProfile.production( + new MongoCredentialReference("secret://mongodb/dba", MongoPrincipalRole.DBA), Set.of()); + + assertThatThrownBy(() -> validator.validate(profile)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("DBA"); + } + + @Test + void runtimeAndAdminMustNotShareACredential() { + MongoCredentialReference shared = + new MongoCredentialReference("secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE); + + assertThatThrownBy(() -> validator.requireDistinctCredentials(shared, shared)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aCredentialReferenceNeverRendersASecret() { + MongoCredentialReference credential = + new MongoCredentialReference("secret://mongodb/app-write", MongoPrincipalRole.APP_WRITE); + + assertThat(credential.toString()).doesNotContain("secret://").contains("fingerprint="); + } + + @Test + void onlyTheApplicationRolesAreUsableByARuntime() { + assertThat(MongoPrincipalRole.APP_WRITE.usableByApplicationRuntime()).isTrue(); + assertThat(MongoPrincipalRole.MIGRATION.usableByApplicationRuntime()).isFalse(); + assertThat(MongoPrincipalRole.DBA.usableByApplicationRuntime()).isFalse(); + } + + @Test + void rotationKeepsThePrincipalAndActuallyChangesTheCredential() { + MongoCredentialRotationPolicy policy = MongoCredentialRotationPolicy.standard(); + MongoCredentialReference current = + new MongoCredentialReference("secret://mongodb/app-write-v1", MongoPrincipalRole.APP_WRITE); + + assertThatThrownBy(() -> policy.validateRotation(current, current)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + policy.validateRotation( + current, + new MongoCredentialReference("secret://mongodb/dba", MongoPrincipalRole.DBA))) + .isInstanceOf(IllegalArgumentException.class); + assertThat(policy.drainTimeout()).isEqualTo(Duration.ofSeconds(30)); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuardTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuardTest.java new file mode 100644 index 00000000..163d3a5a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/security/admin/MongoAdminRuntimeGuardTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.outbound.mongo.security.admin; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoOperationRejectedException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §5, §39 — the admin plane is not reachable from an application runtime. */ +@Tag("mongodb-contract") +class MongoAdminRuntimeGuardTest { + + private static final Clock FIXED = + Clock.fixed(Instant.parse("2026-08-13T00:00:00Z"), ZoneOffset.UTC); + + @Test + void normalRuntimeCannotCreateAdminGateway() { + MongoAdminRuntimeGuard guard = + new MongoAdminRuntimeGuard(false, "app-credential", "app-credential"); + + assertThatThrownBy(guard::validate).isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void aDeploymentJobSharingTheRuntimeCredentialIsAlsoRefused() { + MongoAdminRuntimeGuard guard = + new MongoAdminRuntimeGuard(true, "app-credential", "app-credential"); + + assertThatThrownBy(guard::validate) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("one credential opens both"); + } + + @Test + void aDeploymentJobWithItsOwnCredentialMayBuildTheGateway() { + MongoAdminRuntimeGuard guard = + new MongoAdminRuntimeGuard(true, "app-credential", "admin-credential"); + + assertThatCode(guard::validate).doesNotThrowAnyException(); + assertThat(guard.adminGatewayAllowed()).isTrue(); + } + + @Test + void aHighRiskOperationNeedsAnApproverAndADryRun() { + MongoAdminAuthorization routine = + MongoAdminAuthorization.routine(Set.of(MongoAdminOperation.DROP_COLLECTION)); + + assertThatThrownBy(() -> routine.require(MongoAdminOperation.DROP_COLLECTION)) + .isInstanceOf(MongoOperationRejectedException.class) + .hasMessageContaining("approver"); + } + + @Test + void anUnauthorizedOperationIsRefusedBeforeAnythingRuns() { + MongoAdminAuthorization authorization = + MongoAdminAuthorization.routine(Set.of(MongoAdminOperation.CREATE_INDEX)); + + assertThatThrownBy(() -> authorization.require(MongoAdminOperation.DROP_DATABASE)) + .isInstanceOf(MongoOperationRejectedException.class); + } + + @Test + void everyExecutedOperationLeavesAnAuditRecord() { + List audit = new ArrayList<>(); + MongoAdminGateway gateway = + new MongoAdminGateway( + new MongoAdminRuntimeGuard(true, "app", "admin"), + MongoAdminAuthorization.routine(Set.of(MongoAdminOperation.CREATE_INDEX)), + audit::add, + FIXED); + + gateway.execute( + MongoAdminOperation.CREATE_INDEX, + "orders.ix_status", + "release-engineer", + "new query", + () -> null); + + assertThat(audit).hasSize(1); + assertThat(audit.get(0).operator()).isEqualTo("release-engineer"); + assertThat(audit.get(0).dryRun()).isFalse(); + } + + @Test + void anAuditRecordWithoutAnOperatorOrReasonIsNotARecord() { + assertThatThrownBy( + () -> + MongoAdminAuditRecord.applied( + MongoAdminOperation.CREATE_INDEX, "orders", "", "reason", Instant.EPOCH)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + MongoAdminAuditRecord.applied( + MongoAdminOperation.CREATE_INDEX, "orders", "operator", "", Instant.EPOCH)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theDestructiveOperationsAreMarkedHighRisk() { + assertThat(MongoAdminOperation.DROP_DATABASE.highRisk()).isTrue(); + assertThat(MongoAdminOperation.RESHARD_COLLECTION.highRisk()).isTrue(); + assertThat(MongoAdminOperation.HIDE_INDEX.highRisk()).isFalse(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrixTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrixTest.java new file mode 100644 index 00000000..8d84d6c9 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrixTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.compat; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction; +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoReplicaSetContract; +import java.util.Set; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** Design §30, Task 46 — every Stable contract runs on both certified lanes. */ +@Tag("mongodb-contract") +class MongoVersionMatrixTest { + + @ParameterizedTest + @ValueSource(strings = {"7.0", "8.0"}) + void stableContractsRunOnEverySupportedLane(String version) { + MongoStableContractSuite.MongoStableContractReport report = + MongoStableContractSuite.standard().run(version, contract -> true); + + assertThat(report.failures()).isEmpty(); + assertThat(report.certified()).isTrue(); + assertThat(report.executed()).containsAll(MongoReplicaSetContract.all()); + } + + @Test + void aFailedContractIsReportedWithItsLane() { + MongoStableContractSuite.MongoStableContractReport report = + MongoStableContractSuite.standard() + .run("8.0", contract -> contract != MongoReplicaSetContract.TRANSACTION); + + assertThat(report.failures()).containsExactly("8.0/TRANSACTION"); + assertThat(report.certified()).isFalse(); + } + + @Test + void anUncertifiedVersionCannotBeRun() { + assertThatThrownBy(() -> MongoStableContractSuite.standard().run("6.0", contract -> true)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void theMatrixNamesEightAsThePrimaryLaneAndSevenAsCompatibility() { + MongoVersionMatrix matrix = MongoVersionMatrix.standard(); + + assertThat(matrix.primaryCertificationLane()).isEqualTo("8.0"); + assertThat(matrix.compatibilityLanes()).containsExactly("7.0"); + assertThat(matrix.certifies("7.0")).isTrue(); + assertThat(matrix.certifies("6.0")).isFalse(); + } + + @Test + void theCapabilityReportRecordsThatErrorAndLogIsUnavailable() { + MongoVersionCapabilityReport report = + MongoVersionCapabilityReport.forVersion("8.0") + .observedValidationActions( + Set.of(MongoValidationAction.ERROR, MongoValidationAction.WARN)) + .observed("transaction", true) + .build(); + + assertThat(report.supports("validationAction.ERROR")).isTrue(); + assertThat(report.supports("validationAction.ERROR_AND_LOG")).isFalse(); + assertThat(report.render()).contains("MongoDB 8.0"); + } + + @Test + void everyContractDeclaresTheEvidenceCategoryItFeeds() { + assertThat(MongoReplicaSetContract.all()) + .allSatisfy(contract -> assertThat(contract.evidenceCategory()).isNotBlank()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssertTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssertTest.java new file mode 100644 index 00000000..4ecc7c43 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssertTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.mapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import org.bson.Document; +import org.bson.types.Decimal128; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §10, §10.1 — the distinctions a JSON comparison would destroy. */ +@Tag("mongodb-contract") +class MongoBsonSnapshotAssertTest { + + @Test + void distinguishesMissingFromNull() { + Document missing = new Document(); + Document nullable = new Document("value", null); + + assertThat(MongoBsonSnapshot.of(missing).canonical()) + .isNotEqualTo(MongoBsonSnapshot.of(nullable).canonical()); + } + + @Test + void distinguishesAnAbsentArrayFromAnEmptyOne() { + MongoBsonSnapshot absent = MongoBsonSnapshot.of(new Document()); + MongoBsonSnapshot empty = MongoBsonSnapshot.of(new Document("items", List.of())); + + MongoBsonSnapshotAssert.assertThat(absent).hasNoField("items"); + MongoBsonSnapshotAssert.assertThat(empty).hasBsonType("items", "ARRAY"); + } + + @Test + void distinguishesAStringDecimalFromDecimal128() { + MongoBsonSnapshot asString = MongoBsonSnapshot.of(new Document("amount", "12.30")); + MongoBsonSnapshot asDecimal = + MongoBsonSnapshot.of(new Document("amount", new Decimal128(new BigDecimal("12.30")))); + + assertThat(asString.bsonTypeOf("amount")).isEqualTo("STRING"); + assertThat(asDecimal.bsonTypeOf("amount")).isEqualTo("DECIMAL128"); + } + + @Test + void distinguishesAStringIdFromAnObjectId() { + ObjectId objectId = new ObjectId(); + MongoBsonSnapshot asString = MongoBsonSnapshot.of(new Document("_id", objectId.toHexString())); + MongoBsonSnapshot asObjectId = MongoBsonSnapshot.of(new Document("_id", objectId)); + + assertThat(asString.bsonTypeOf("_id")).isEqualTo("STRING"); + assertThat(asObjectId.bsonTypeOf("_id")).isEqualTo("OBJECT_ID"); + } + + @Test + void aUuidIsStoredAsBinaryRatherThanText() { + MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(new Document("id", UUID.randomUUID())); + + assertThat(snapshot.bsonTypeOf("id")).isEqualTo("BINARY"); + } + + @Test + void theTypeSignatureIsStableAndOrderIndependent() { + Document first = new Document("b", 1).append("a", "x"); + Document second = new Document("a", "x").append("b", 1); + + assertThat(MongoBsonSnapshot.of(first).typeSignature()) + .isEqualTo(MongoBsonSnapshot.of(second).typeSignature()); + } + + @Test + void aChangedRepresentationFailsTheSignatureAssertion() { + MongoBsonSnapshot snapshot = MongoBsonSnapshot.of(new Document("amount", "12.30")); + + assertThatThrownBy( + () -> + MongoBsonSnapshotAssert.assertThat(snapshot).hasTypeSignature("amount:DECIMAL128")) + .isInstanceOf(AssertionError.class) + .hasMessageContaining("representation changed"); + } + + @Test + void aStoredJavaClassNameIsDetected() { + MongoBsonSnapshot snapshot = + MongoBsonSnapshot.of(new Document("_class", "dev.caskeleton.example.Order")); + + assertThatThrownBy( + () -> MongoBsonSnapshotAssert.assertThat(snapshot).hasNoJavaClassName("dev.caskeleton")) + .isInstanceOf(AssertionError.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuiteTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuiteTest.java new file mode 100644 index 00000000..9a46b518 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuiteTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.migration; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §12.4, Task 47 — a killed backfill resumes without reprocessing a completed batch. */ +@Tag("mongodb-contract") +class MongoMigrationContractSuiteTest { + + @Test + void interruptedBackfillResumesWithoutReprocessingCompletedBatch() { + MongoBackfillRestartFixture fixture = MongoBackfillRestartFixture.killAfterBatch(3); + + fixture.recordBatch(List.of("o-1", "o-2")); + fixture.recordBatch(List.of("o-3", "o-4")); + fixture.recordBatch(List.of("o-5", "o-6")); + fixture.restart(); + fixture.recordBatch(List.of("o-7", "o-8")); + + assertThat(fixture.completedIds()).doesNotHaveDuplicates(); + assertThat(fixture.batchesRun()).isEqualTo(4); + } + + @Test + void everyStartingSnapshotIsCovered() { + assertThat(MongoMigrationSnapshotFixture.all()) + .extracting(MongoMigrationSnapshotFixture::snapshot) + .containsExactly( + MongoMigrationSnapshotFixture.Snapshot.EMPTY, + MongoMigrationSnapshotFixture.Snapshot.PREVIOUS_RELEASE, + MongoMigrationSnapshotFixture.Snapshot.OLDEST_SUPPORTED); + } + + @Test + void everyMigrationCheckRunsForEverySnapshot() { + MongoMigrationContractSuite suite = MongoMigrationContractSuite.of(check -> true); + + assertThat(MongoMigrationSnapshotFixture.all()) + .allSatisfy(snapshot -> assertThat(suite.run(snapshot).passed()).isTrue()); + } + + @Test + void aFailedCheckIsReportedWithItsSnapshot() { + MongoMigrationContractSuite suite = + MongoMigrationContractSuite.of( + check -> check != MongoMigrationContractSuite.Check.CHECKSUM_MUTATION_REFUSED); + + MongoMigrationContractSuite.MongoMigrationReport report = + suite.run(MongoMigrationSnapshotFixture.oldestSupported()); + + assertThat(report.passed()).isFalse(); + assertThat(report.failures()).containsExactly("OLDEST_SUPPORTED/CHECKSUM_MUTATION_REFUSED"); + } + + @Test + void theApplicationCredentialCheckIsPartOfTheSuite() { + assertThat(MongoMigrationContractSuite.Check.values()) + .contains( + MongoMigrationContractSuite.Check.APPLICATION_CREDENTIAL_CANNOT_MIGRATE, + MongoMigrationContractSuite.Check.DISTRIBUTED_LOCK_EXCLUDES_SECOND_RUNNER); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoReleaseEvidenceTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoReleaseEvidenceTest.java new file mode 100644 index 00000000..cab451aa --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoReleaseEvidenceTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.mongo.testkit.failover.MongoFailoverScenario; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §29, Task 49 — the release evidence's one correctness assertion, and its bounds. */ +@Tag("mongodb-contract") +class MongoReleaseEvidenceTest { + + @Test + void releaseEvidenceContainsNoUnknownCommitBodyRetry() { + MongoChaosGate gate = new MongoChaosGate(); + MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true)); + + MongoChaosGate.MongoChaosReport report = gate.report(); + + assertThat(report.businessBodyRetriesAfterUnknownCommit()).isZero(); + assertThat(report.passed()).isTrue(); + } + + @Test + void oneReplayedBodyAfterAnUnknownCommitFailsTheGate() { + MongoChaosGate gate = new MongoChaosGate(); + MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true)); + gate.recordBodyRetryAfterUnknownCommit(); + + assertThat(gate.report().passed()).isFalse(); + } + + @Test + void aScenarioThatNeverRanIsAFailureRatherThanASilence() { + MongoChaosGate gate = new MongoChaosGate(); + gate.record(MongoFailoverScenario.PRIMARY_KILL, true); + + assertThat(gate.report().failures()) + .anyMatch(failure -> failure.endsWith("(not executed)")) + .anyMatch(failure -> failure.startsWith("OPLOG_HISTORY_LOSS")); + } + + @Test + void duplicateProjectionsAreExpectedRatherThanFatal() { + MongoChaosGate gate = new MongoChaosGate(); + MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true)); + gate.recordDuplicateProjection(); + + MongoChaosGate.MongoChaosReport report = gate.report(); + + assertThat(report.duplicateProjections()).isEqualTo(1); + assertThat(report.passed()).isTrue(); + } + + @Test + void aLostChangeEventFailsTheGate() { + MongoChaosGate gate = new MongoChaosGate(); + MongoFailoverScenario.all().forEach(scenario -> gate.record(scenario, true)); + gate.recordLostChangeEvent(); + + assertThat(gate.report().passed()).isFalse(); + } + + @Test + void aScanningQueryFailsThePerformanceGateEvenWhenItIsFast() { + MongoResourceBudgetReport scanning = + new MongoResourceBudgetReport(1, 2, 3, 0, 1024, 1_000_000, 10, 0, false); + + assertThat(MongoPerformanceGate.standard().violations(scanning)) + .anyMatch(violation -> violation.contains("scanning rather than seeking")); + } + + @Test + void anUnplannedDiskSpillFailsThePerformanceGate() { + MongoResourceBudgetReport spilled = + new MongoResourceBudgetReport(1, 2, 3, 0, 1024, 100, 100, 100, true); + + assertThat(MongoPerformanceGate.standard().passes(spilled)).isFalse(); + } + + @Test + void aHealthyRunPassesAndRendersAsAnArtifact() { + MongoResourceBudgetReport healthy = + new MongoResourceBudgetReport(5, 20, 100, 10, 1024, 120, 100, 100, false); + + assertThat(MongoPerformanceGate.standard().passes(healthy)).isTrue(); + assertThat(healthy.asArtifact()).containsKeys("p99Millis", "examinedToReturnedRatio"); + } + + @Test + void everyFailoverScenarioDeclaresItsEvidenceCategory() { + assertThat(MongoFailoverScenario.all()) + .allSatisfy(scenario -> assertThat(scenario.evidenceCategory()).isNotBlank()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuiteTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuiteTest.java new file mode 100644 index 00000000..f640940e --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuiteTest.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.sharded; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Advanced plan Task 4 — targeting is asserted from explain output, not assumed. */ +@Tag("mongodb-contract") +class MongoShardingContractSuiteTest { + + @Test + void targetedQueryTouchesOneShard() { + MongoShardingContractSuite suite = + new MongoShardingContractSuite( + scenario -> + new MongoShardingContractSuite.MongoShardingReport( + scenario.expectedShardsExamined(), scenario.expectedRouting())); + + MongoShardingContractSuite.MongoShardingReport report = + suite.run(MongoShardingContractSuite.Scenario.TARGETED_QUERY); + + assertThat(report.shardsExamined()).isEqualTo(1); + assertThat(report.routing()).isEqualTo(MongoRoutingClassification.TARGETED); + } + + @Test + void aScatterGatherQueryReachingOneShardIsAMismatchWorthFailing() { + MongoShardingContractSuite suite = + new MongoShardingContractSuite( + scenario -> + new MongoShardingContractSuite.MongoShardingReport( + 1, MongoRoutingClassification.TARGETED)); + + assertThat(suite.runAll()) + .anyMatch(failure -> failure.startsWith("SCATTER_GATHER_QUERY observed")); + } + + @Test + void everyScenarioRunsAndPassesWhenRoutingMatches() { + MongoShardingContractSuite suite = + new MongoShardingContractSuite( + scenario -> + new MongoShardingContractSuite.MongoShardingReport( + scenario.expectedShardsExamined(), scenario.expectedRouting())); + + assertThat(suite.runAll()).isEmpty(); + } + + @Test + void anUnroutedWriteIsExpectedToBeRejectedRatherThanScattered() { + assertThat(MongoShardingContractSuite.Scenario.UNROUTED_WRITE.expectedRouting()) + .isEqualTo(MongoRoutingClassification.REJECTED); + } + + @Test + void chunkMigrationUnderLoadIsPartOfTheSuite() { + assertThat(MongoShardingContractSuite.Scenario.values()) + .contains( + MongoShardingContractSuite.Scenario.CHUNK_MIGRATION_UNDER_LOAD, + MongoShardingContractSuite.Scenario.CROSS_SHARD_TRANSACTION); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfileTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfileTest.java new file mode 100644 index 00000000..30619b8f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/MongoTransactionProfileTest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import java.time.Duration; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §14.2 — a transaction reads the primary and is bounded in time. */ +@Tag("mongodb-contract") +class MongoTransactionProfileTest { + + @Test + void transactionRejectsSecondaryReadPreference() { + assertThatThrownBy( + () -> + MongoTransactionProfile.of( + MongoConsistencyProfile.STALE_READ_ALLOWED, Duration.ofSeconds(5))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void aTimeoutBeyondTheServerLifetimeLimitIsRefused() { + assertThatThrownBy( + () -> + MongoTransactionProfile.of( + MongoConsistencyProfile.PRIMARY_MAJORITY, Duration.ofMinutes(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lifetime limit"); + } + + @Test + void theThreeNamedProfilesAreAvailable() { + assertThat(MongoTransactionProfile.majority().consistency()) + .isEqualTo(MongoConsistencyProfile.PRIMARY_MAJORITY); + assertThat(MongoTransactionProfile.snapshot().consistency()) + .isEqualTo(MongoConsistencyProfile.SNAPSHOT_TRANSACTION); + assertThat(MongoTransactionProfile.shortWrite().consistency()) + .isEqualTo(MongoConsistencyProfile.MONGO_SHORT_WRITE); + } + + @Test + void theElapsedBudgetMustCoverAtLeastOneAttempt() { + assertThatThrownBy( + () -> + new MongoTransactionProfile( + MongoConsistencyProfile.PRIMARY_MAJORITY, + Duration.ofSeconds(5), + 3, + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinatorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinatorTest.java new file mode 100644 index 00000000..7bbaa2ea --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/retry/MongoTransactionRetryCoordinatorTest.java @@ -0,0 +1,214 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.MongoOperationName; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoFailureContext; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoRetryScope; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionCommitUnknownException; +import dev.caskeleton.adapter.outbound.mongo.api.error.MongoTransactionTransientException; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionProfile; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSession; +import dev.caskeleton.adapter.outbound.mongo.transaction.MongoTransactionSessionFactory; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §14.3, D-10 — an unknown commit retries the commit and never the body. */ +@Tag("mongodb-contract") +class MongoTransactionRetryCoordinatorTest { + + private static final MongoOperationName OPERATION = new MongoOperationName("order.reserve"); + + @Test + void unknownCommitRetriesCommitWithoutReinvokingBody() { + AtomicInteger bodyCalls = new AtomicInteger(); + RecordingSessionFactory sessions = RecordingSessionFactory.unknownCommitOnce(); + MongoTransactionRetryCoordinator coordinator = coordinator(sessions); + + String result = + coordinator.execute( + MongoTransactionProfile.majority(), + () -> { + bodyCalls.incrementAndGet(); + return "ok"; + }); + + assertThat(result).isEqualTo("ok"); + assertThat(bodyCalls).hasValue(1); + assertThat(sessions.commitCalls()).isEqualTo(2); + assertThat(sessions.sessionsOpened()).isEqualTo(1); + } + + @Test + void aTransientErrorReplaysTheBodyFromANewSession() { + AtomicInteger bodyCalls = new AtomicInteger(); + RecordingSessionFactory sessions = RecordingSessionFactory.transientBodyOnce(); + MongoTransactionRetryCoordinator coordinator = coordinator(sessions); + + coordinator.execute( + MongoTransactionProfile.majority(), + () -> { + bodyCalls.incrementAndGet(); + return "ok"; + }); + + assertThat(bodyCalls).hasValue(2); + assertThat(sessions.sessionsOpened()).isEqualTo(2); + } + + @Test + void aCommitThatStaysUnknownSurfacesReconciliationMetadata() { + RecordingSessionFactory sessions = RecordingSessionFactory.commitAlwaysUnknown(); + + assertThatThrownBy( + () -> coordinator(sessions).execute(MongoTransactionProfile.majority(), () -> "ok")) + .isInstanceOf(MongoTransactionCommitUnknownException.class) + .hasMessageContaining("reconcile"); + } + + @Test + void everyRetryDecisionIsRecordedWithItsScope() { + List decisions = new ArrayList<>(); + RecordingSessionFactory sessions = RecordingSessionFactory.unknownCommitOnce(); + + new MongoTransactionRetryCoordinator( + sessions, + noDelayBudget(), + MongoCommitReconciler.standard(), + RandomGenerator.getDefault(), + decisions::add, + OPERATION) + .execute(MongoTransactionProfile.majority(), () -> "ok"); + + assertThat(decisions) + .extracting(MongoRetryDecision::scope) + .containsExactly(MongoRetryScope.COMMIT_ONLY); + } + + @Test + void theRetryBudgetBoundsAttemptsAndElapsedTime() { + MongoRetryBudget budget = MongoRetryBudget.standard(); + + assertThat(budget.allowsAttempt(4, Duration.ZERO)).isFalse(); + assertThat(budget.allowsAttempt(2, Duration.ofMinutes(1))).isFalse(); + assertThat(budget.allowsAttempt(2, Duration.ofMillis(10))).isTrue(); + } + + @Test + void backoffGrowsAndStaysInsideTheCeiling() { + MongoRetryBudget budget = MongoRetryBudget.standard(); + RandomGenerator fixed = RandomGenerator.getDefault(); + + assertThat(budget.delayBefore(1, fixed)).isEqualTo(Duration.ZERO); + assertThat(budget.delayBefore(5, fixed)).isLessThanOrEqualTo(budget.maxBackoff()); + } + + private static MongoTransactionRetryCoordinator coordinator( + MongoTransactionSessionFactory sessions) { + return new MongoTransactionRetryCoordinator( + sessions, + noDelayBudget(), + MongoCommitReconciler.standard(), + RandomGenerator.getDefault(), + decision -> {}, + OPERATION); + } + + private static MongoRetryBudget noDelayBudget() { + return new MongoRetryBudget(3, Duration.ofSeconds(30), Duration.ZERO, Duration.ZERO, 0); + } + + /** A session factory that fails in a scripted way and counts what happened. */ + private static final class RecordingSessionFactory implements MongoTransactionSessionFactory { + + private final boolean transientBodyOnce; + + private final int unknownCommits; + + private final boolean commitAlwaysUnknown; + + private int sessionsOpened; + + private int commitCalls; + + private int bodyFailures; + + private int unknownCommitsSoFar; + + private RecordingSessionFactory( + boolean transientBodyOnce, int unknownCommits, boolean commitAlwaysUnknown) { + this.transientBodyOnce = transientBodyOnce; + this.unknownCommits = unknownCommits; + this.commitAlwaysUnknown = commitAlwaysUnknown; + } + + private static RecordingSessionFactory unknownCommitOnce() { + return new RecordingSessionFactory(false, 1, false); + } + + private static RecordingSessionFactory transientBodyOnce() { + return new RecordingSessionFactory(true, 0, false); + } + + private static RecordingSessionFactory commitAlwaysUnknown() { + return new RecordingSessionFactory(false, 0, true); + } + + @Override + public MongoTransactionSession open(MongoTransactionProfile profile) { + sessionsOpened++; + return new ScriptedSession(); + } + + private int sessionsOpened() { + return sessionsOpened; + } + + private int commitCalls() { + return commitCalls; + } + + /** One scripted attempt. */ + private final class ScriptedSession implements MongoTransactionSession { + + @Override + public T runBody(Supplier body) { + T value = body.get(); + if (transientBodyOnce && bodyFailures == 0) { + bodyFailures++; + throw new MongoTransactionTransientException( + MongoFailureContext.commitUnknown(OPERATION, "112", Duration.ZERO)); + } + return value; + } + + @Override + public void commit() { + commitCalls++; + if (commitAlwaysUnknown || unknownCommitsSoFar < unknownCommits) { + unknownCommitsSoFar++; + throw new MongoTransactionCommitUnknownException( + MongoFailureContext.commitUnknown(OPERATION, "251", Duration.ZERO), + "read the transaction record"); + } + } + + @Override + public void abort() { + // Nothing to undo in the scripted session. + } + + @Override + public void close() { + // Nothing to release in the scripted session. + } + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutorTest.java b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutorTest.java new file mode 100644 index 00000000..f49adb30 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/test/java/dev/caskeleton/adapter/outbound/mongo/transaction/session/MongoCausalSessionExecutorTest.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.outbound.mongo.transaction.session; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.mongo.api.consistency.MongoConsistencyProfile; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Design §14 — causal consistency only holds on majority concerns. */ +@Tag("mongodb-contract") +class MongoCausalSessionExecutorTest { + + @Test + void requiresMajorityConsistency() { + assertThatThrownBy( + () -> MongoCausalSessionContext.forProfile(MongoConsistencyProfile.PRIMARY_LOCAL)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void acceptsTheCausalMajorityProfile() { + assertThatCode( + () -> MongoCausalSessionContext.forProfile(MongoConsistencyProfile.CAUSAL_MAJORITY)) + .doesNotThrowAnyException(); + } + + @Test + void aCausalSessionIsNotASubstituteForATransaction() { + MongoCausalSessionContext context = + MongoCausalSessionContext.forProfile(MongoConsistencyProfile.CAUSAL_MAJORITY); + + assertThat(context.replacesTransactions()).isFalse(); + } + + @Test + void readsOutsideTheSessionAreNotCausal() { + assertThatThrownBy(SpringMongoCausalSessionExecutor::requireSessionOperations) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("not causal"); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasCapabilityContractSuite.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasCapabilityContractSuite.java new file mode 100644 index 00000000..9334ecc2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasCapabilityContractSuite.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.atlas; + +import java.util.List; +import java.util.Objects; + +/** + * Search, vector and encryption contracts against a provider environment (advanced plan Task 14). + * + *

Atlas Local is fast enough for pull requests and is explicitly not the release evidence: it + * does not exercise the provider's analyzers, its index build behaviour or its KMS. Recording which + * environment a report came from is what keeps a quick local pass from being mistaken for + * certification. + */ +public final class MongoAtlasCapabilityContractSuite { + + private MongoAtlasCapabilityContractSuite() {} + + /** + * The vector readiness contract. + * + * @param queriedBeforeReady whether any query ran against a non-READY index + * @param environment where the run happened + */ + public static MongoAtlasCapabilityReport vectorReadiness( + boolean queriedBeforeReady, Environment environment) { + return new MongoAtlasCapabilityReport( + queriedBeforeReady, true, environment, List.of("vector-readiness")); + } + + /** The KMS contract: wrong key, missing permission and rotation all fail closed. */ + public static MongoAtlasCapabilityReport kmsFailureModes( + boolean allFailedClosed, Environment environment) { + return new MongoAtlasCapabilityReport( + false, + allFailedClosed, + environment, + List.of("kms-wrong-key", "kms-permission", "kms-rotation")); + } + + /** Where a capability run happened. */ + public enum Environment { + + /** Atlas Local in a container: fast, and not release evidence. */ + ATLAS_LOCAL(false), + + /** The actual target deployment, behind credentials. */ + ACTUAL_TARGET(true); + + private final boolean releaseEvidence; + + Environment(boolean releaseEvidence) { + this.releaseEvidence = releaseEvidence; + } + + /** True when a run here counts towards promotion. */ + public boolean countsAsReleaseEvidence() { + return releaseEvidence; + } + } + + /** + * What one capability run produced. + * + * @param queriedBeforeReady whether a query ran against a non-READY index + * @param failedClosed whether every negative case failed closed + * @param environment where the run happened + * @param contracts which contracts ran + */ + public record MongoAtlasCapabilityReport( + boolean queriedBeforeReady, + boolean failedClosed, + Environment environment, + List contracts) { + + public MongoAtlasCapabilityReport { + Objects.requireNonNull(environment, "environment"); + contracts = List.copyOf(Objects.requireNonNull(contracts, "contracts")); + } + + /** True when the run passed and counts towards promotion. */ + public boolean certifies() { + return !queriedBeforeReady && failedClosed && environment.countsAsReleaseEvidence(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasLocalContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasLocalContainer.java new file mode 100644 index 00000000..3c600c75 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/atlas/MongoAtlasLocalContainer.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.atlas; + +import java.util.Objects; +import org.testcontainers.mongodb.MongoDBAtlasLocalContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Atlas Local, for fast search and vector contracts (advanced plan Task 14). + * + *

Useful for pull-request feedback and explicitly not sufficient for release. Analyzer + * behaviour, index build timing and KMS integration are all provider-side, and Atlas Local + * reproduces none of them faithfully enough to certify against. + */ +public final class MongoAtlasLocalContainer implements AutoCloseable { + + /** System property carrying the pinned Atlas Local image. */ + public static final String IMAGE_PROPERTY = "mongodb.atlas.local.image"; + + private final MongoDBAtlasLocalContainer container; + + private MongoAtlasLocalContainer(String image) { + this.container = new MongoDBAtlasLocalContainer(DockerImageName.parse(image)); + } + + /** An Atlas Local container on the pinned image. */ + public static MongoAtlasLocalContainer pinned() { + return new MongoAtlasLocalContainer( + System.getProperty(IMAGE_PROPERTY, "mongodb/mongodb-atlas-local:8.0.0")); + } + + /** An explicitly pinned image. */ + public static MongoAtlasLocalContainer image(String pinnedImage) { + return new MongoAtlasLocalContainer(Objects.requireNonNull(pinnedImage, "pinnedImage")); + } + + /** Starts the container. */ + public void start() { + container.start(); + } + + /** The connection string. */ + public String connectionString() { + return container.getConnectionString(); + } + + /** Which environment a report from this container should record. */ + public MongoAtlasCapabilityContractSuite.Environment environment() { + return MongoAtlasCapabilityContractSuite.Environment.ATLAS_LOCAL; + } + + @Override + public void close() { + container.stop(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoStableContractSuite.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoStableContractSuite.java new file mode 100644 index 00000000..d462535c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoStableContractSuite.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.compat; + +import dev.caskeleton.adapter.outbound.mongo.testkit.rs.MongoReplicaSetContract; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Predicate; + +/** + * Runs every Stable contract on one server lane (design §30). + * + *

The report distinguishes a failed contract from a contract that never ran. A suite that + * reports "no failures" because half of it was skipped is exactly the shape of green build that + * certifies nothing, so a missing contract is a failure here. + */ +public final class MongoStableContractSuite { + + private final MongoVersionMatrix matrix; + + public MongoStableContractSuite(MongoVersionMatrix matrix) { + this.matrix = Objects.requireNonNull(matrix, "matrix"); + } + + /** A suite over the standard 7.0 / 8.0 matrix. */ + public static MongoStableContractSuite standard() { + return new MongoStableContractSuite(MongoVersionMatrix.standard()); + } + + /** + * Runs every contract for one version. + * + * @param version the server lane + * @param contractRunner returns true when the contract passed; called once per contract + */ + public MongoStableContractReport run( + String version, Predicate contractRunner) { + Objects.requireNonNull(version, "version"); + Objects.requireNonNull(contractRunner, "contractRunner"); + if (!matrix.certifies(version)) { + throw new IllegalArgumentException( + "version " + version + " is not one of the certified lanes " + matrix.stableVersions()); + } + + List failures = new ArrayList<>(); + Set executed = new LinkedHashSet<>(); + for (MongoReplicaSetContract contract : MongoReplicaSetContract.all()) { + executed.add(contract); + if (!contractRunner.test(contract)) { + failures.add(version + '/' + contract.name()); + } + } + Set missing = new LinkedHashSet<>(MongoReplicaSetContract.all()); + missing.removeAll(executed); + missing.forEach(contract -> failures.add(version + '/' + contract.name() + " (not executed)")); + return new MongoStableContractReport(version, List.copyOf(failures), Set.copyOf(executed)); + } + + /** + * What one lane's run produced. + * + * @param version the server lane + * @param failures the contracts that failed or never ran + * @param executed the contracts that ran + */ + public record MongoStableContractReport( + String version, List failures, Set executed) { + + public MongoStableContractReport { + Objects.requireNonNull(version, "version"); + failures = List.copyOf(Objects.requireNonNull(failures, "failures")); + executed = Set.copyOf(Objects.requireNonNull(executed, "executed")); + } + + /** True when every contract ran and passed. */ + public boolean certified() { + return failures.isEmpty() && executed.containsAll(MongoReplicaSetContract.all()); + } + + /** The evidence categories this run covered. */ + public Set evidenceCategories() { + Set categories = new LinkedHashSet<>(); + executed.forEach(contract -> categories.add(contract.evidenceCategory())); + return Set.copyOf(categories); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionCapabilityReport.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionCapabilityReport.java new file mode 100644 index 00000000..1a00293b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionCapabilityReport.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.compat; + +import dev.caskeleton.adapter.outbound.mongo.schema.validation.MongoValidationAction; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * What one server version actually supported (design §30). + * + *

Generated from a real run rather than written by hand, so the published support matrix is + * evidence rather than intent. Hand-written matrices drift the moment a version's behaviour changes + * and nobody re-reads the document. + */ +public record MongoVersionCapabilityReport( + String serverVersion, Map capabilities, Map notes) { + + public MongoVersionCapabilityReport { + Objects.requireNonNull(serverVersion, "serverVersion"); + Objects.requireNonNull(capabilities, "capabilities"); + Objects.requireNonNull(notes, "notes"); + capabilities = Map.copyOf(capabilities); + notes = Map.copyOf(notes); + } + + /** Starts a report for one lane. */ + public static Builder forVersion(String serverVersion) { + return new Builder(serverVersion); + } + + /** True when the report records the capability as available. */ + public boolean supports(String capability) { + return Boolean.TRUE.equals(capabilities.get(Objects.requireNonNull(capability, "capability"))); + } + + /** A stable rendering for a generated support matrix. */ + public String render() { + StringBuilder rendered = new StringBuilder("MongoDB ").append(serverVersion).append('\n'); + capabilities.forEach( + (capability, supported) -> + rendered + .append(" ") + .append(capability) + .append(": ") + .append(Boolean.TRUE.equals(supported) ? "yes" : "no") + .append(notes.containsKey(capability) ? " (" + notes.get(capability) + ")" : "") + .append('\n')); + return rendered.toString(); + } + + /** Collects observations for one lane. */ + public static final class Builder { + + private final String serverVersion; + + private final Map capabilities = new LinkedHashMap<>(); + + private final Map notes = new LinkedHashMap<>(); + + private Builder(String serverVersion) { + this.serverVersion = Objects.requireNonNull(serverVersion, "serverVersion"); + } + + /** Records an observed capability. */ + public Builder observed(String capability, boolean supported) { + capabilities.put(capability, supported); + return this; + } + + /** Records an observed capability with a note. */ + public Builder observed(String capability, boolean supported, String note) { + notes.put(capability, note); + return observed(capability, supported); + } + + /** + * Records the validation actions this lane accepts. + * + *

{@code errorAndLog} is expected to be absent on both certified lanes, so recording it is + * what turns "the design says it is unsupported" into "the run confirmed it". + */ + public Builder observedValidationActions(java.util.Set accepted) { + for (MongoValidationAction action : MongoValidationAction.values()) { + observed("validationAction." + action.name(), accepted.contains(action)); + } + return this; + } + + /** Builds the immutable report. */ + public MongoVersionCapabilityReport build() { + return new MongoVersionCapabilityReport(serverVersion, capabilities, notes); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrix.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrix.java new file mode 100644 index 00000000..e323742f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/compat/MongoVersionMatrix.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.compat; + +import java.util.List; +import java.util.Objects; + +/** + * The server versions every Stable contract must pass on (design §4, §30). + * + *

Two lanes rather than one. Certifying only the newest version means the first customer still + * on 7.0 is the one who discovers the incompatibility; certifying only the oldest means the + * platform never exercises what it is actually deployed against. + */ +public record MongoVersionMatrix(List stableVersions, String primaryCertificationLane) { + + public MongoVersionMatrix { + Objects.requireNonNull(stableVersions, "stableVersions"); + Objects.requireNonNull(primaryCertificationLane, "primaryCertificationLane"); + stableVersions = List.copyOf(stableVersions); + if (!stableVersions.contains(primaryCertificationLane)) { + throw new IllegalArgumentException( + "the primary certification lane must be one of the stable versions"); + } + } + + /** MongoDB 7.0 compatibility and 8.0 primary certification. */ + public static MongoVersionMatrix standard() { + return new MongoVersionMatrix(List.of("7.0", "8.0"), "8.0"); + } + + /** True when a version is one of the certified lanes. */ + public boolean certifies(String version) { + return stableVersions.contains(Objects.requireNonNull(version, "version")); + } + + /** The compatibility lanes, excluding the primary one. */ + public List compatibilityLanes() { + return stableVersions.stream() + .filter(version -> !version.equals(primaryCertificationLane)) + .toList(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoFailoverScenario.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoFailoverScenario.java new file mode 100644 index 00000000..6d49ff04 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoFailoverScenario.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +import java.util.List; + +/** + * The failure scenarios the release gate requires (design §29). + * + *

Each one corresponds to a platform guarantee that cannot be demonstrated any other way. They + * are enumerated so a release can assert that all of them ran, rather than that none of the ones + * that ran failed. + */ +public enum MongoFailoverScenario { + + /** The primary is killed mid-write. */ + PRIMARY_KILL("failover"), + + /** The client is partitioned from a healthy primary. */ + NETWORK_PARTITION("failover"), + + /** No server can be selected within the timeout. */ + SERVER_SELECTION_TIMEOUT("failover"), + + /** The write applies and its acknowledgement is lost. */ + WRITE_RESPONSE_LOSS("failover"), + + /** The transaction aborts with a transient error. */ + TRANSIENT_TRANSACTION_ERROR("transaction"), + + /** The commit result is unknown. */ + UNKNOWN_TRANSACTION_COMMIT_RESULT("transaction"), + + /** A bulk write partially succeeds. */ + BULK_PARTIAL_FAILURE("mapping"), + + /** The change stream consumer is killed mid-batch. */ + CHANGE_STREAM_PROCESS_KILL("change-stream"), + + /** The stored resume token is lost. */ + RESUME_TOKEN_LOSS("change-stream"), + + /** The oplog no longer contains the resume position. */ + OPLOG_HISTORY_LOSS("change-stream"), + + /** The observed indexes drift from the manifest. */ + INDEX_DRIFT("migration"), + + /** The installed validator drifts from the manifest. */ + SCHEMA_VALIDATION_MISMATCH("migration"), + + /** A credential is rotated under load. */ + CREDENTIAL_ROTATION("security"); + + private final String evidenceCategory; + + MongoFailoverScenario(String evidenceCategory) { + this.evidenceCategory = evidenceCategory; + } + + /** The release-gate evidence category this scenario contributes to. */ + public String evidenceCategory() { + return evidenceCategory; + } + + /** Every scenario the failover lane must run. */ + public static List all() { + return List.of(values()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoNetworkFaultController.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoNetworkFaultController.java new file mode 100644 index 00000000..0537852c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoNetworkFaultController.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +import java.time.Duration; + +/** + * Injects the network faults that produce ambiguous outcomes (design §29). + * + *

A clean node crash is the easy failure: the client learns the write did not happen. The hard + * one is a response that never arrives — the write was applied and the acknowledgement was lost — + * and it can only be produced by interfering with the network rather than with the server. + */ +public interface MongoNetworkFaultController { + + /** Cuts the client's path to the primary while the server keeps running. */ + void partitionClientFromPrimary(); + + /** Restores every cut path. */ + void healPartition(); + + /** Adds latency to every response, up to the point of client timeouts. */ + void delayResponses(Duration latency); + + /** + * Drops responses while still delivering requests. + * + *

This is what produces {@code WRITE_RESULT_UNKNOWN}: the server applies the write and the + * client never hears about it. + */ + void dropResponses(); + + /** Stops dropping responses. */ + void deliverResponses(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoPrimaryController.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoPrimaryController.java new file mode 100644 index 00000000..80a0de8b --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoPrimaryController.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +/** + * Controls which node is primary (design §29, Task 45). + * + *

Failover is the condition under which the platform's hardest guarantees are tested — unknown + * commit results, change stream resumes, retryable writes. None of them can be exercised without + * being able to take the primary away on purpose. + */ +public interface MongoPrimaryController { + + /** The address of the current primary. */ + String primaryAddress(); + + /** Stops the current primary abruptly, as a crash would. */ + void stopPrimary(); + + /** Asks the current primary to step down, as a rolling restart would. */ + void stepDownPrimary(); + + /** Waits for a new primary and returns its address. */ + String awaitNewPrimary(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java new file mode 100644 index 00000000..f9df0c7c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +import java.io.IOException; +import java.time.Duration; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * One real MongoDB node reachable two ways: directly, and through a controllable network path + * (design §29, Task 45). + * + *

Both routes are the point. A fault injected in the proxied path has to be shown to be a + * path fault rather than a server fault, and the only way to show that is a second client + * that reaches the same server without the proxy and finds it healthy. Without the direct route, + * "the client could not write" and "the write did not happen" are indistinguishable — which is + * exactly the confusion the ambiguous-outcome contract exists to prevent. + */ +public final class MongoProxiedReplicaSetNode implements AutoCloseable { + + private static final int MONGO_PORT = 27017; + + private static final String ALIAS = "mongo-0"; + + private static final String REPLICA_SET_NAME = "rs0"; + + private final Network network = Network.newNetwork(); + + private final GenericContainer mongod; + + private ToxiproxyMongoNetworkFaultController faults; + + private MongoProxiedReplicaSetNode(String image) { + this.mongod = + new GenericContainer<>(DockerImageName.parse(image)) + .withNetwork(network) + .withNetworkAliases(ALIAS) + .withExposedPorts(MONGO_PORT) + .withCommand("--replSet", REPLICA_SET_NAME, "--bind_ip_all") + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(3))); + } + + /** A proxied node on the MongoDB 8.0 primary lane. */ + public static MongoProxiedReplicaSetNode startMongoEight() { + MongoProxiedReplicaSetNode node = + new MongoProxiedReplicaSetNode(System.getProperty("mongodb.primary.image", "mongo:8.0.16")); + node.start(); + return node; + } + + private void start() { + mongod.start(); + initiate(); + faults = ToxiproxyMongoNetworkFaultController.inFrontOf(network, ALIAS); + } + + /** + * The route that bypasses the proxy. + * + *

{@code directConnection=true} on both routes: replica set discovery would hand the driver + * the set's in-container member address, and the whole fixture depends on the client using the + * address it was given. + */ + public String directConnectionString() { + return "mongodb://" + + mongod.getHost() + + ':' + + mongod.getMappedPort(MONGO_PORT) + + "/?directConnection=true"; + } + + /** The route the faults are injected into. */ + public String proxiedConnectionString() { + return "mongodb://" + faults.proxiedAddress() + "/?directConnection=true"; + } + + /** The fault controller for the proxied path. */ + public MongoNetworkFaultController faults() { + return faults; + } + + private void initiate() { + String ok = + evaluate( + "rs.initiate({_id:'" + + REPLICA_SET_NAME + + "',members:[{_id:0,host:'localhost:" + + MONGO_PORT + + "'}]}).ok"); + if (!ok.contains("1")) { + throw new IllegalStateException("rs.initiate failed on the proxied node: " + ok); + } + awaitPrimary(); + } + + private void awaitPrimary() { + long deadline = System.nanoTime() + Duration.ofMinutes(2).toNanos(); + while (System.nanoTime() < deadline) { + if ("true".equals(evaluate("db.hello().isWritablePrimary"))) { + return; + } + sleepBriefly(); + } + throw new IllegalStateException("the proxied node did not reach a primary"); + } + + private String evaluate(String expression) { + try { + Container.ExecResult result = + mongod.execInContainer("mongosh", "--quiet", "--eval", expression); + return result.getStdout().trim(); + } catch (IOException unavailable) { + return ""; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while configuring the proxied node"); + } + } + + private static void sleepBriefly() { + try { + Thread.sleep(500); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the proxied node"); + } + } + + @Override + public void close() { + if (faults != null) { + faults.close(); + } + mongod.stop(); + network.close(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java new file mode 100644 index 00000000..f7f8f4b4 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java @@ -0,0 +1,278 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * Three real MongoDB nodes joined into one replica set (design §29, Task 45). + * + *

Three nodes rather than one, because a single-node replica set never holds an election: it has + * an oplog, so transactions work, but there is no second node to promote. Every guarantee that + * depends on a primary change — retryable writes, change stream resume, unknown commit results — is + * untested until a real election happens. + * + *

This deliberately does not use {@code MongoDBContainer}. That container initiates its own + * single-node set on start, so three of them are three separate clusters that never elect anything. + * The nodes here start with {@code --replSet} and no configuration, and one {@code rs.initiate} + * joins them, which is the only arrangement in which "stop the primary" means anything. + * + *

The primary is asked for, never guessed. {@code db.hello().primary} is the cluster's own + * answer; inferring it from which containers are still running produces a fixture that reports an + * election that never happened. + */ +public final class MongoThreeNodeReplicaSet implements AutoCloseable, MongoPrimaryController { + + private static final Duration ELECTION_TIMEOUT = Duration.ofSeconds(90); + + private static final String REPLICA_SET_NAME = "rs0"; + + private static final int MONGO_PORT = 27017; + + private final List> nodes = new ArrayList<>(); + + private final Network network = Network.newNetwork(); + + private String stoppedPrimaryAlias = ""; + + private MongoThreeNodeReplicaSet(String image) { + for (int index = 0; index < 3; index++) { + nodes.add( + new GenericContainer<>(DockerImageName.parse(image)) + .withNetwork(network) + .withNetworkAliases(alias(index)) + .withExposedPorts(MONGO_PORT) + .withCommand( + "--replSet", + REPLICA_SET_NAME, + "--bind_ip_all", + "--port", + String.valueOf(MONGO_PORT)) + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(3)))); + } + } + + /** A three-node set on the MongoDB 8.0 primary lane. */ + public static MongoThreeNodeReplicaSet startMongoEight() { + MongoThreeNodeReplicaSet replicaSet = + new MongoThreeNodeReplicaSet(System.getProperty("mongodb.primary.image", "mongo:8.0.16")); + replicaSet.start(); + return replicaSet; + } + + /** Starts every node, joins them into one set, and waits for a primary. */ + public void start() { + nodes.forEach(GenericContainer::start); + initiate(); + awaitStablePrimary(); + } + + /** + * The current primary's in-cluster address, as the cluster reports it. + * + * @throws IllegalStateException when no member currently answers with a primary + */ + @Override + public String primaryAddress() { + for (GenericContainer node : runningNodes()) { + String primary = evaluateQuietly(node, "db.hello().primary || ''"); + if (!primary.isBlank()) { + return primary; + } + } + throw new IllegalStateException("the replica set currently reports no primary"); + } + + /** Stops whichever node is currently primary. */ + @Override + public void stopPrimary() { + String primary = primaryAddress(); + stoppedPrimaryAlias = primary; + nodeForAddress(primary).stop(); + } + + /** + * Asks the current primary to hand over. + * + *

A step-down is the graceful case: the node stays in the set and asks for a new election, + * which exercises a different driver path from a node that simply disappears. + */ + @Override + public void stepDownPrimary() { + String primary = primaryAddress(); + stoppedPrimaryAlias = primary; + // stepDown closes the connection it was issued on, so a non-zero exit here is expected. + evaluateQuietly(nodeForAddress(primary), "try { rs.stepDown(60) } catch (e) { }"); + } + + /** + * Waits until the set reports a primary that is not the one most recently stopped. + * + * @throws IllegalStateException when no new primary appears within the election timeout + */ + @Override + public String awaitNewPrimary() { + String previous = stoppedPrimaryAlias; + long deadline = System.nanoTime() + ELECTION_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + String current = currentPrimaryOrEmpty(); + if (!current.isBlank() && !current.equals(previous)) { + return current; + } + sleepBriefly(); + } + throw new IllegalStateException( + "no new primary was elected within " + ELECTION_TIMEOUT + "; the set may have lost quorum"); + } + + /** + * The connection string covering every node, using each node's host-mapped port. + * + *

{@code directConnection} is deliberately absent: the point of this fixture is that the + * driver discovers the topology and follows the primary across an election. + */ + public String connectionString() { + StringBuilder uri = new StringBuilder("mongodb://"); + for (int index = 0; index < nodes.size(); index++) { + GenericContainer node = nodes.get(index); + if (index > 0) { + uri.append(','); + } + uri.append(node.getHost()).append(':').append(node.getMappedPort(MONGO_PORT)); + } + return uri.append("/?replicaSet=").append(REPLICA_SET_NAME).toString(); + } + + /** A single node's address, for a test that wants to bypass topology discovery. */ + public String directConnectionString(int index) { + GenericContainer node = nodes.get(index); + return "mongodb://" + + node.getHost() + + ':' + + node.getMappedPort(MONGO_PORT) + + "/?directConnection=true"; + } + + /** The nodes that are still running. */ + public List> runningNodes() { + return nodes.stream().filter(GenericContainer::isRunning).toList(); + } + + /** The nodes, for a fault controller that needs their addresses. */ + public List> nodes() { + return List.copyOf(nodes); + } + + /** The replica set name the members were configured with. */ + public String replicaSetName() { + return REPLICA_SET_NAME; + } + + private void initiate() { + String members = + "[{_id:0,host:'" + + alias(0) + + ":" + + MONGO_PORT + + "'},{_id:1,host:'" + + alias(1) + + ":" + + MONGO_PORT + + "'},{_id:2,host:'" + + alias(2) + + ":" + + MONGO_PORT + + "'}]"; + String result = + evaluateQuietly( + nodes.get(0), + "rs.initiate({_id:'" + REPLICA_SET_NAME + "',members:" + members + "}).ok"); + if (!result.contains("1")) { + throw new IllegalStateException( + "rs.initiate did not return ok=1 for the three-node set; got: " + result); + } + } + + private void awaitStablePrimary() { + long deadline = System.nanoTime() + ELECTION_TIMEOUT.toNanos(); + while (System.nanoTime() < deadline) { + if (!currentPrimaryOrEmpty().isBlank()) { + return; + } + sleepBriefly(); + } + throw new IllegalStateException("the replica set did not reach a stable primary"); + } + + private String currentPrimaryOrEmpty() { + for (GenericContainer node : runningNodes()) { + String primary = evaluateQuietly(node, "db.hello().primary || ''"); + if (!primary.isBlank()) { + return primary; + } + } + return ""; + } + + private GenericContainer nodeForAddress(String address) { + for (int index = 0; index < nodes.size(); index++) { + if (address.startsWith(alias(index) + ":")) { + return nodes.get(index); + } + } + throw new IllegalStateException("no container matches the reported primary address " + address); + } + + /** + * Runs one mongosh expression inside a node and returns its trimmed output. + * + *

Returns an empty string rather than throwing when the node cannot answer: during an election + * a member legitimately refuses commands, and a fixture that treats that as fatal fails on + * exactly the transition it exists to observe. + */ + private static String evaluateQuietly(GenericContainer node, String expression) { + try { + Container.ExecResult result = + node.execInContainer("mongosh", "--quiet", "--eval", expression); + return result.getStdout().trim(); + } catch (IOException | IllegalStateException unavailable) { + return ""; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while querying a MongoDB node"); + } + } + + private static String alias(int index) { + return "mongo-" + index; + } + + private static void sleepBriefly() { + try { + Thread.sleep(500); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for a MongoDB election"); + } + } + + @Override + public void close() { + nodes.forEach( + node -> { + try { + node.stop(); + } catch (RuntimeException ignored) { + // Cleanup must not mask the test's own failure; a leaked container is visible in + // Docker. + } + }); + network.close(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java new file mode 100644 index 00000000..510e40f0 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java @@ -0,0 +1,167 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.failover; + +import eu.rekawek.toxiproxy.Proxy; +import eu.rekawek.toxiproxy.ToxiproxyClient; +import eu.rekawek.toxiproxy.model.Toxic; +import eu.rekawek.toxiproxy.model.ToxicDirection; +import java.io.IOException; +import java.time.Duration; +import java.util.Objects; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.toxiproxy.ToxiproxyContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Network faults between the client and one MongoDB node (design §29). + * + *

Faults are injected in the path, not in the server, because the failure that matters cannot be + * produced any other way. Stopping a container tells the client the write did not happen. Dropping + * the response while still delivering the request is what produces {@code + * WRITE_RESULT_UNKNOWN}: the server applied the write and the client never found out. + * + *

The direction matters and is easy to get backwards. {@link ToxicDirection#DOWNSTREAM} is + * server → client, so a downstream-only toxic leaves the write happening and loses the + * acknowledgement. An upstream toxic would stop the write from arriving at all, which is the + * uninteresting case that a stopped container already covers. + */ +public final class ToxiproxyMongoNetworkFaultController + implements MongoNetworkFaultController, AutoCloseable { + + private static final String DEFAULT_TOXIPROXY_IMAGE = "ghcr.io/shopify/toxiproxy:2.12.0"; + + private static final int MONGO_PORT = 27017; + + private static final int LISTEN_PORT = 8666; + + private static final String PARTITION_TOXIC = "partition"; + + private static final String LATENCY_TOXIC = "latency"; + + private static final String RESPONSE_LOSS_TOXIC = "response-loss"; + + private final ToxiproxyContainer toxiproxy; + + private final Proxy proxy; + + private ToxiproxyMongoNetworkFaultController(ToxiproxyContainer toxiproxy, Proxy proxy) { + this.toxiproxy = toxiproxy; + this.proxy = proxy; + } + + /** + * Starts a proxy in front of one node on a shared network. + * + * @param network the network the MongoDB node is attached to + * @param upstreamAlias the node's network alias, e.g. {@code mongo-0} + */ + public static ToxiproxyMongoNetworkFaultController inFrontOf( + Network network, String upstreamAlias) { + Objects.requireNonNull(network, "network"); + Objects.requireNonNull(upstreamAlias, "upstreamAlias"); + ToxiproxyContainer container = + new ToxiproxyContainer( + DockerImageName.parse( + System.getProperty("mongodb.toxiproxy.image", DEFAULT_TOXIPROXY_IMAGE)) + .asCompatibleSubstituteFor("ghcr.io/shopify/toxiproxy")) + .withNetwork(network); + // addExposedPort appends; withExposedPorts would replace the list and drop the control port, + // leaving getControlPort() with nothing to map. + container.addExposedPort(LISTEN_PORT); + container.start(); + try { + ToxiproxyClient client = new ToxiproxyClient(container.getHost(), container.getControlPort()); + Proxy proxy = + client.createProxy( + "mongo-" + upstreamAlias, "0.0.0.0:" + LISTEN_PORT, upstreamAlias + ":" + MONGO_PORT); + return new ToxiproxyMongoNetworkFaultController(container, proxy); + } catch (IOException unreachable) { + container.stop(); + throw new IllegalStateException("could not create the MongoDB proxy", unreachable); + } + } + + /** The address a client on the host uses to reach the node through the proxy. */ + public String proxiedAddress() { + return toxiproxy.getHost() + ":" + toxiproxy.getMappedPort(LISTEN_PORT); + } + + /** The proxy's in-network address, for a client running in a container. */ + public String inNetworkAddress(String toxiproxyAlias) { + return Objects.requireNonNull(toxiproxyAlias, "toxiproxyAlias") + ":" + LISTEN_PORT; + } + + /** The Toxiproxy container, for a test that needs a network alias on it. */ + public GenericContainer container() { + return toxiproxy; + } + + @Override + public void partitionClientFromPrimary() { + // Disabling the proxy refuses the connection outright: the server stays up and healthy while + // this client cannot reach it, which is the partition case rather than the crash case. + run(() -> proxy.disable()); + } + + @Override + public void healPartition() { + run(() -> proxy.enable()); + removeIfPresent(PARTITION_TOXIC); + } + + @Override + public void delayResponses(Duration latency) { + Objects.requireNonNull(latency, "latency"); + removeIfPresent(LATENCY_TOXIC); + run( + () -> + proxy + .toxics() + .latency(LATENCY_TOXIC, ToxicDirection.DOWNSTREAM, latency.toMillis()) + .setJitter(0)); + } + + @Override + public void dropResponses() { + removeIfPresent(RESPONSE_LOSS_TOXIC); + // timeout(0) on the downstream direction stops data flowing back to the client without closing + // the connection, so requests keep arriving and being applied. + run(() -> proxy.toxics().timeout(RESPONSE_LOSS_TOXIC, ToxicDirection.DOWNSTREAM, 0)); + } + + @Override + public void deliverResponses() { + removeIfPresent(RESPONSE_LOSS_TOXIC); + removeIfPresent(LATENCY_TOXIC); + } + + private void removeIfPresent(String toxicName) { + try { + Toxic toxic = proxy.toxics().get(toxicName); + if (toxic != null) { + toxic.remove(); + } + } catch (IOException absent) { + // Removing a toxic that is not installed is the normal case when a test heals twice. + } + } + + private static void run(ToxiproxyCall call) { + try { + call.run(); + } catch (IOException failed) { + throw new IllegalStateException("the Toxiproxy control API rejected the call", failed); + } + } + + @Override + public void close() { + toxiproxy.stop(); + } + + /** One control-plane call that may fail on the network. */ + @FunctionalInterface + private interface ToxiproxyCall { + void run() throws IOException; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshot.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshot.java new file mode 100644 index 00000000..c964bd9a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshot.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.mapping; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.bson.BsonArray; +import org.bson.BsonDocument; +import org.bson.BsonValue; +import org.bson.Document; +import org.bson.UuidRepresentation; +import org.bson.codecs.BsonValueCodecProvider; +import org.bson.codecs.DocumentCodec; +import org.bson.codecs.DocumentCodecProvider; +import org.bson.codecs.IterableCodecProvider; +import org.bson.codecs.MapCodecProvider; +import org.bson.codecs.UuidCodec; +import org.bson.codecs.ValueCodecProvider; +import org.bson.codecs.configuration.CodecRegistries; +import org.bson.codecs.configuration.CodecRegistry; + +/** + * A canonical, comparable form of one stored document (design §10.1). + * + *

Canonicalisation sorts keys and keeps BSON types intact. Converting to JSON first would be + * easier and would destroy the distinctions the golden gate exists to protect: {@code Decimal128} + * and a string both render as text, a {@code Binary} UUID and an {@code ObjectId} both render as + * hex, and a missing field and a null field both disappear. + * + *

Missing and null are kept apart by rendering the key set as part of the canonical form, so a + * document with an explicit null is never equal to one without the field. + */ +public record MongoBsonSnapshot(BsonDocument canonical) { + + public MongoBsonSnapshot { + Objects.requireNonNull(canonical, "canonical"); + } + + /** The canonical form of a {@link Document}. */ + public static MongoBsonSnapshot of(Document value) { + Objects.requireNonNull(value, "value"); + return of(value.toBsonDocument(BsonDocument.class, defaultRegistry())); + } + + /** The canonical form of a {@link BsonDocument}. */ + public static MongoBsonSnapshot of(BsonDocument value) { + Objects.requireNonNull(value, "value"); + return new MongoBsonSnapshot(canonicalise(value)); + } + + /** + * The registry snapshots are taken through. + * + *

The UUID codec is pinned to {@code STANDARD} rather than left to the default, so a snapshot + * records the representation the platform's manifest fixes — the whole point of the golden gate + * is that this cannot drift. + */ + private static CodecRegistry defaultRegistry() { + return CodecRegistries.fromRegistries( + CodecRegistries.fromCodecs(new UuidCodec(UuidRepresentation.STANDARD)), + CodecRegistries.fromProviders( + new ValueCodecProvider(), + new BsonValueCodecProvider(), + new DocumentCodecProvider(), + new IterableCodecProvider(), + new MapCodecProvider())); + } + + private static BsonDocument canonicalise(BsonDocument source) { + BsonDocument canonical = new BsonDocument(); + List keys = new ArrayList<>(source.keySet()); + keys.sort(String::compareTo); + for (String key : keys) { + canonical.put(key, canonicalise(source.get(key))); + } + return canonical; + } + + private static BsonValue canonicalise(BsonValue value) { + if (value instanceof BsonDocument document) { + return canonicalise(document); + } + if (value instanceof BsonArray array) { + BsonArray canonical = new BsonArray(); + array.forEach(element -> canonical.add(canonicalise(element))); + return canonical; + } + return value; + } + + /** + * The BSON type names of every field, as a comparable signature. + * + *

This is what a representation assertion compares: the values in a fixture change, the types + * are the contract. + */ + public String typeSignature() { + StringBuilder signature = new StringBuilder(); + canonical.forEach( + (key, value) -> { + if (signature.length() > 0) { + signature.append(','); + } + signature.append(key).append(':').append(value.getBsonType()); + }); + return signature.toString(); + } + + /** The type of one field, or {@code null} when the field is absent. */ + public String bsonTypeOf(String field) { + BsonValue value = canonical.get(Objects.requireNonNull(field, "field")); + return value == null ? null : value.getBsonType().name(); + } + + /** A codec for writing a {@link Document} in tests. */ + public static DocumentCodec documentCodec() { + return new DocumentCodec(defaultRegistry()); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssert.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssert.java new file mode 100644 index 00000000..c258c5d6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoBsonSnapshotAssert.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.mapping; + +import java.util.Objects; +import org.assertj.core.api.AbstractAssert; + +/** + * AssertJ assertions for a canonical BSON snapshot (design §10.1). + * + *

The assertions are about types and presence, not values. A representation regression is + * invisible in a value comparison — {@code 12.30} looks the same whether it is stored as a {@code + * Decimal128} or a double — and it is the representation that has to stay fixed across deployments. + */ +public final class MongoBsonSnapshotAssert + extends AbstractAssert { + + private MongoBsonSnapshotAssert(MongoBsonSnapshot actual) { + super(actual, MongoBsonSnapshotAssert.class); + } + + /** Starts an assertion on a snapshot. */ + public static MongoBsonSnapshotAssert assertThat(MongoBsonSnapshot actual) { + return new MongoBsonSnapshotAssert(actual); + } + + /** Asserts that a field is stored as the given BSON type. */ + public MongoBsonSnapshotAssert hasBsonType(String field, String expectedType) { + isNotNull(); + String actualType = actual.bsonTypeOf(field); + if (!Objects.equals(actualType, expectedType)) { + failWithMessage( + "expected field <%s> to be stored as BSON <%s> but it was <%s>", + field, expectedType, actualType); + } + return this; + } + + /** Asserts that a field is absent, which is different from being present and null. */ + public MongoBsonSnapshotAssert hasNoField(String field) { + isNotNull(); + if (actual.canonical().containsKey(field)) { + failWithMessage( + "expected field <%s> to be absent, but it is present as <%s>", + field, actual.bsonTypeOf(field)); + } + return this; + } + + /** Asserts that a field is present and explicitly null. */ + public MongoBsonSnapshotAssert hasExplicitNull(String field) { + isNotNull(); + if (!actual.canonical().containsKey(field)) { + failWithMessage("expected field <%s> to be present and null, but it is absent", field); + } + return hasBsonType(field, "NULL"); + } + + /** Asserts that the whole type signature matches a recorded one. */ + public MongoBsonSnapshotAssert hasTypeSignature(String expectedSignature) { + isNotNull(); + String actualSignature = actual.typeSignature(); + if (!actualSignature.equals(expectedSignature)) { + failWithMessage( + "the BSON representation changed:%nexpected <%s>%nbut was <%s>%n" + + "A representation change needs an explicit migration and an updated snapshot.", + expectedSignature, actualSignature); + } + return this; + } + + /** Asserts that the document contains no Java class name. */ + public MongoBsonSnapshotAssert hasNoJavaClassName(String packagePrefix) { + isNotNull(); + String rendered = actual.canonical().toJson(); + if (rendered.contains(packagePrefix)) { + failWithMessage( + "the document contains a Java class name from <%s>; a long-lived collection stores a " + + "stable alias so moving the class stays a refactor rather than a data migration", + packagePrefix); + } + return this; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoRoundTripContract.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoRoundTripContract.java new file mode 100644 index 00000000..a363dbcd --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/mapping/MongoRoundTripContract.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.mapping; + +import dev.caskeleton.adapter.outbound.mongo.api.mapping.MongoTypeRepresentationManifest; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import org.bson.Document; + +/** + * The Java → BSON → server → raw BSON → Java round trip (design §10.1). + * + *

Half a round trip proves nothing. A converter that writes a decimal as a string and reads it + * back as a {@code BigDecimal} passes an object-to-object test and produces a collection no other + * reader can interpret; only the raw BSON in the middle shows it. + * + *

The recorded fingerprint pins the manifest and converter set, so a representation change fails + * here rather than in production data. + */ +public final class MongoRoundTripContract { + + private final MongoTypeRepresentationManifest manifest; + + private final Function writer; + + private final UnaryOperator storeAndReadBack; + + private final Function reader; + + public MongoRoundTripContract( + MongoTypeRepresentationManifest manifest, + Function writer, + UnaryOperator storeAndReadBack, + Function reader) { + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.writer = Objects.requireNonNull(writer, "writer"); + this.storeAndReadBack = Objects.requireNonNull(storeAndReadBack, "storeAndReadBack"); + this.reader = Objects.requireNonNull(reader, "reader"); + } + + /** + * Runs one value through the whole round trip. + * + * @return the snapshot of what was actually stored, and the object that came back + */ + public Result run(Object value) { + Objects.requireNonNull(value, "value"); + Document written = writer.apply(value); + Document readBack = storeAndReadBack.apply(written); + return new Result( + MongoBsonSnapshot.of(written), + MongoBsonSnapshot.of(readBack), + reader.apply(readBack), + manifest.fingerprint()); + } + + /** + * What one round trip produced. + * + * @param written what the converter serialized + * @param readBack what the server returned + * @param value the deserialized object + * @param manifestFingerprint the representation the round trip ran under + */ + public record Result( + MongoBsonSnapshot written, + MongoBsonSnapshot readBack, + Object value, + String manifestFingerprint) { + + /** True when the stored and returned representations are identical. */ + public boolean representationStable() { + return written.typeSignature().equals(readBack.typeSignature()); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoBackfillRestartFixture.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoBackfillRestartFixture.java new file mode 100644 index 00000000..31f7d619 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoBackfillRestartFixture.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.migration; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.function.IntPredicate; + +/** + * Kills a backfill at a batch boundary and resumes it (design §12.4, Task 47). + * + *

What this proves is that the checkpoint is honoured — the resumed run neither reprocesses a + * completed batch nor skips an incomplete one. Both failures are silent: reprocessing is invisible + * for an idempotent backfill and corrupting for anything else, and skipping leaves a subset of + * documents unmigrated with nothing to indicate which. + */ +public final class MongoBackfillRestartFixture { + + private final int killAfterBatch; + + private final List completedIds = new ArrayList<>(); + + private int batchesRun; + + private boolean killed; + + private MongoBackfillRestartFixture(int killAfterBatch) { + if (killAfterBatch <= 0) { + throw new IllegalArgumentException("the kill point must be a positive batch number"); + } + this.killAfterBatch = killAfterBatch; + } + + /** A fixture that kills the run after the given batch. */ + public static MongoBackfillRestartFixture killAfterBatch(int batch) { + return new MongoBackfillRestartFixture(batch); + } + + /** True when the run should stop before the given batch. */ + public IntPredicate killPoint() { + return batch -> batch > killAfterBatch && !killed; + } + + /** Records one batch of processed ids. */ + public void recordBatch(List ids) { + Objects.requireNonNull(ids, "ids"); + batchesRun++; + completedIds.addAll(ids); + if (batchesRun == killAfterBatch) { + killed = true; + } + } + + /** Resumes after the simulated kill. */ + public void restart() { + killed = false; + } + + /** Every id processed across both runs, in order. */ + public List completedIds() { + return List.copyOf(completedIds); + } + + /** True when no id was processed twice. */ + public boolean withoutDuplicates() { + return completedIds.size() == new HashSet<>(completedIds).size(); + } + + /** How many batches have run. */ + public int batchesRun() { + return batchesRun; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuite.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuite.java new file mode 100644 index 00000000..f45e2286 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationContractSuite.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.migration; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * The migration behaviours a release must demonstrate (design §12.4, Task 47). + * + *

Each check corresponds to a way migrations fail in production rather than in review: an + * upgrade path nobody ran, a change unit edited after it was applied, two runners racing during a + * rolling deployment, a backfill that restarts from zero, or an application credential quietly + * holding enough privilege to migrate. + */ +public interface MongoMigrationContractSuite { + + /** Runs every check for one starting snapshot. */ + MongoMigrationReport run(MongoMigrationSnapshotFixture snapshot); + + /** A suite that evaluates each check through the given predicate. */ + static MongoMigrationContractSuite of(Predicate checkRunner) { + Objects.requireNonNull(checkRunner, "checkRunner"); + return snapshot -> { + List failures = new ArrayList<>(); + for (Check check : Check.values()) { + if (!checkRunner.test(check)) { + failures.add(snapshot.snapshot().name() + '/' + check.name()); + } + } + return new MongoMigrationReport(snapshot, List.copyOf(failures)); + }; + } + + /** One migration behaviour under test. */ + enum Check { + + /** The snapshot upgrades cleanly to the current schema. */ + UPGRADES_TO_LATEST, + + /** An applied migration whose checksum changed is refused. */ + CHECKSUM_MUTATION_REFUSED, + + /** A migration missing from the ledger but present in the code is applied. */ + MISSING_APPLIED_MIGRATION_DETECTED, + + /** A killed backfill resumes from its checkpoint without reprocessing. */ + BACKFILL_RESUMES_WITHOUT_DUPLICATES, + + /** A second runner cannot acquire the lease while the first holds it. */ + DISTRIBUTED_LOCK_EXCLUDES_SECOND_RUNNER, + + /** The application credential cannot apply migrations. */ + APPLICATION_CREDENTIAL_CANNOT_MIGRATE + } + + /** + * What one snapshot's run produced. + * + * @param snapshot the starting state + * @param failures the checks that failed + */ + record MongoMigrationReport(MongoMigrationSnapshotFixture snapshot, List failures) { + + public MongoMigrationReport { + Objects.requireNonNull(snapshot, "snapshot"); + failures = List.copyOf(Objects.requireNonNull(failures, "failures")); + } + + /** True when every check passed. */ + public boolean passed() { + return failures.isEmpty(); + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationSnapshotFixture.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationSnapshotFixture.java new file mode 100644 index 00000000..d2325196 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/migration/MongoMigrationSnapshotFixture.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.migration; + +import java.util.Objects; + +/** + * The starting states a migration must be able to upgrade from (design §12.3, Task 47). + * + *

Three, because they fail differently. Empty is the only one most suites test and the only one + * that never happens in production. Previous-release is what a normal deployment upgrades. Oldest- + * supported is the instance that was left running for a year, and it is the one where a migration + * written against last month's schema quietly does nothing. + */ +public record MongoMigrationSnapshotFixture(Snapshot snapshot, String resourcePath) { + + public MongoMigrationSnapshotFixture { + Objects.requireNonNull(snapshot, "snapshot"); + Objects.requireNonNull(resourcePath, "resourcePath"); + } + + /** A fresh database with no collections. */ + public static MongoMigrationSnapshotFixture empty() { + return new MongoMigrationSnapshotFixture(Snapshot.EMPTY, "/snapshots/empty.json"); + } + + /** The schema as of the previous release. */ + public static MongoMigrationSnapshotFixture previousRelease() { + return new MongoMigrationSnapshotFixture( + Snapshot.PREVIOUS_RELEASE, "/snapshots/previous-release.json"); + } + + /** The oldest schema this release still claims to upgrade. */ + public static MongoMigrationSnapshotFixture oldestSupported() { + return new MongoMigrationSnapshotFixture( + Snapshot.OLDEST_SUPPORTED, "/snapshots/oldest-supported.json"); + } + + /** Every fixture the migration lane must run. */ + public static java.util.List all() { + return java.util.List.of(empty(), previousRelease(), oldestSupported()); + } + + /** Which starting state this fixture represents. */ + public enum Snapshot { + + /** No collections at all. */ + EMPTY, + + /** The schema shipped by the previous release. */ + PREVIOUS_RELEASE, + + /** The oldest schema this release claims to upgrade. */ + OLDEST_SUPPORTED + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoChaosGate.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoChaosGate.java new file mode 100644 index 00000000..86c78687 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoChaosGate.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.performance; + +import dev.caskeleton.adapter.outbound.mongo.testkit.failover.MongoFailoverScenario; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The chaos evidence a Stable release requires (design §29, Task 49). + * + *

The assertion that matters most is {@code businessBodyRetriesAfterUnknownCommit == 0}. Every + * other number here describes performance; that one describes correctness, and it is the difference + * between a failover that costs latency and one that creates a duplicate order. + */ +public final class MongoChaosGate { + + private final Map executed = new LinkedHashMap<>(); + + private long businessBodyRetriesAfterUnknownCommit; + + private long lostChangeEvents; + + private long duplicateProjections; + + /** Records that a scenario ran and whether it passed. */ + public MongoChaosGate record(MongoFailoverScenario scenario, boolean passed) { + executed.put(Objects.requireNonNull(scenario, "scenario"), passed); + return this; + } + + /** Records a business body that was replayed after an unknown commit. Must stay zero. */ + public MongoChaosGate recordBodyRetryAfterUnknownCommit() { + businessBodyRetriesAfterUnknownCommit++; + return this; + } + + /** Records a change event that was never projected. Must stay zero. */ + public MongoChaosGate recordLostChangeEvent() { + lostChangeEvents++; + return this; + } + + /** Records a duplicate projection, which is expected and bounded rather than forbidden. */ + public MongoChaosGate recordDuplicateProjection() { + duplicateProjections++; + return this; + } + + /** The report for this run. */ + public MongoChaosReport report() { + List failures = + executed.entrySet().stream() + .filter(entry -> !Boolean.TRUE.equals(entry.getValue())) + .map(entry -> entry.getKey().name()) + .toList(); + List missing = + MongoFailoverScenario.all().stream() + .filter(scenario -> !executed.containsKey(scenario)) + .map(scenario -> scenario.name() + " (not executed)") + .toList(); + return new MongoChaosReport( + java.util.stream.Stream.concat(failures.stream(), missing.stream()).toList(), + businessBodyRetriesAfterUnknownCommit, + lostChangeEvents, + duplicateProjections); + } + + /** + * What one chaos run produced. + * + * @param failures scenarios that failed or never ran + * @param businessBodyRetriesAfterUnknownCommit must be zero + * @param lostChangeEvents must be zero + * @param duplicateProjections expected; the deduplication store absorbs them + */ + public record MongoChaosReport( + List failures, + long businessBodyRetriesAfterUnknownCommit, + long lostChangeEvents, + long duplicateProjections) { + + public MongoChaosReport { + failures = List.copyOf(Objects.requireNonNull(failures, "failures")); + } + + /** True when every scenario ran, passed, and neither correctness counter moved. */ + public boolean passed() { + return failures.isEmpty() + && businessBodyRetriesAfterUnknownCommit == 0 + && lostChangeEvents == 0; + } + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoPerformanceGate.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoPerformanceGate.java new file mode 100644 index 00000000..bf51484f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoPerformanceGate.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.performance; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * The resource bounds a release must stay inside (design §29, Task 49). + * + *

Bounds rather than baselines. A baseline comparison on shared CI hardware produces failures + * that depend on what else was running, so the gate asserts what the platform promises — bounded + * results, bounded pool wait, no unplanned spill — rather than that this run was as fast as the + * last one. + */ +public record MongoPerformanceGate( + long maximumP99Millis, + long maximumPoolWaitMillis, + long maximumHeapBytes, + double maximumExaminedToReturnedRatio, + boolean diskSpillAllowed) { + + /** The platform's default bounds. */ + public static MongoPerformanceGate standard() { + return new MongoPerformanceGate(500, 250, 512L * 1024 * 1024, 10.0, false); + } + + public MongoPerformanceGate { + if (maximumP99Millis <= 0 || maximumPoolWaitMillis < 0 || maximumHeapBytes <= 0) { + throw new IllegalArgumentException("performance bounds must be positive"); + } + if (maximumExaminedToReturnedRatio < 1) { + throw new IllegalArgumentException( + "the examined-to-returned ratio cannot be below 1: a query examines at least what it returns"); + } + } + + /** The bounds a report violated, empty when it passed. */ + public Set violations(MongoResourceBudgetReport report) { + Objects.requireNonNull(report, "report"); + Set violations = new LinkedHashSet<>(); + if (report.p99Millis() > maximumP99Millis) { + violations.add("p99 " + report.p99Millis() + "ms above " + maximumP99Millis + "ms"); + } + if (report.maxPoolWaitMillis() > maximumPoolWaitMillis) { + violations.add( + "pool wait " + report.maxPoolWaitMillis() + "ms above " + maximumPoolWaitMillis + "ms"); + } + if (report.maxHeapBytes() > maximumHeapBytes) { + violations.add("heap " + report.maxHeapBytes() + " above " + maximumHeapBytes); + } + if (report.examinedToReturnedRatio() > maximumExaminedToReturnedRatio) { + violations.add( + "examined/returned " + + report.examinedToReturnedRatio() + + " above " + + maximumExaminedToReturnedRatio + + "; the query is scanning rather than seeking"); + } + if (report.aggregationSpilledToDisk() && !diskSpillAllowed) { + violations.add("the aggregation spilled to disk without an explicit resource profile"); + } + return Set.copyOf(violations); + } + + /** True when a report stays inside every bound. */ + public boolean passes(MongoResourceBudgetReport report) { + return violations(report).isEmpty(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoResourceBudgetReport.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoResourceBudgetReport.java new file mode 100644 index 00000000..954ee45c --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/performance/MongoResourceBudgetReport.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.performance; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * What one performance run measured (design §29, Task 49). + * + *

{@code documentsExamined} against {@code documentsReturned} is the ratio that matters most and + * the one a latency number hides: a query returning ten documents after examining a million is fast + * on an empty database and an outage on a full one. Latency alone would pass it. + */ +public record MongoResourceBudgetReport( + long p50Millis, + long p95Millis, + long p99Millis, + long maxPoolWaitMillis, + long maxHeapBytes, + long documentsExamined, + long documentsReturned, + long keysExamined, + boolean aggregationSpilledToDisk) { + + /** The examined-to-returned ratio; 1.0 means every examined document was returned. */ + public double examinedToReturnedRatio() { + return documentsReturned == 0 + ? Double.POSITIVE_INFINITY + : (double) documentsExamined / documentsReturned; + } + + /** A machine-readable rendering for a release artifact. */ + public Map asArtifact() { + Map artifact = new LinkedHashMap<>(); + artifact.put("p50Millis", p50Millis); + artifact.put("p95Millis", p95Millis); + artifact.put("p99Millis", p99Millis); + artifact.put("maxPoolWaitMillis", maxPoolWaitMillis); + artifact.put("maxHeapBytes", maxHeapBytes); + artifact.put("documentsExamined", documentsExamined); + artifact.put("documentsReturned", documentsReturned); + artifact.put("keysExamined", keysExamined); + artifact.put("examinedToReturnedRatio", examinedToReturnedRatio()); + artifact.put("aggregationSpilledToDisk", aggregationSpilledToDisk); + return Map.copyOf(artifact); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java new file mode 100644 index 00000000..7a158c82 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.rs; + +import java.io.IOException; +import java.time.Duration; +import java.util.Objects; +import org.testcontainers.containers.Container; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * A single-node replica set with authorization actually enforced (design §26). + * + *

{@code MongoDBContainer} starts mongod without {@code --auth}. Users can be created on it and + * every one of them can do everything, so a least-privilege test against it passes no matter how + * wrong the roles are. A security lane that cannot fail is not a security lane. + * + *

Enabling auth on a replica set also requires a keyfile for member-to-member authentication — + * mongod refuses to start with {@code --auth --replSet} and no internal credential. The keyfile is + * generated in the container at start, which is why this cannot be expressed as container options + * alone. + * + *

The first user is created through MongoDB's localhost exception: while auth is on and no user + * exists, a connection from inside the container may create one. After that, every connection + * authenticates. + */ +public final class MongoAuthenticatedReplicaSetContainer implements AutoCloseable { + + private static final int MONGO_PORT = 27017; + + private static final String REPLICA_SET_NAME = "rs0"; + + /** The bootstrap superuser this fixture creates. */ + public static final String ROOT_USER = "fixture-root"; + + /** The bootstrap superuser's password. */ + public static final String ROOT_PASSWORD = "fixture-root-secret"; + + private final GenericContainer container; + + private MongoAuthenticatedReplicaSetContainer(String image) { + this.container = + new GenericContainer<>(DockerImageName.parse(image)) + .withExposedPorts(MONGO_PORT) + .withCommand( + "bash", + "-c", + "openssl rand -base64 756 > /data/keyfile" + + " && chmod 400 /data/keyfile" + + " && chown mongodb:mongodb /data/keyfile" + + " && exec docker-entrypoint.sh mongod" + + " --replSet " + + REPLICA_SET_NAME + + " --bind_ip_all" + + " --auth" + + " --keyFile /data/keyfile") + .waitingFor(Wait.forListeningPort().withStartupTimeout(Duration.ofMinutes(3))); + } + + /** An authenticated single-node set on the MongoDB 8.0 primary lane. */ + public static MongoAuthenticatedReplicaSetContainer mongoEight() { + return new MongoAuthenticatedReplicaSetContainer( + System.getProperty(MongoSingleReplicaSetContainer.PRIMARY_IMAGE_PROPERTY, "mongo:8.0.16")); + } + + /** Starts the node, initiates the set, and creates the bootstrap superuser. */ + public void start() { + container.start(); + initiate(); + createRootUser(); + } + + /** + * The connection string for one user. + * + *

{@code directConnection=true} because the set's own member address is an in-container + * hostname the host cannot resolve; without it the driver discovers that address and stops using + * the mapped port it just connected on. + */ + public String connectionStringFor(String user, String password, String authDatabase) { + Objects.requireNonNull(user, "user"); + Objects.requireNonNull(password, "password"); + Objects.requireNonNull(authDatabase, "authDatabase"); + return "mongodb://" + + user + + ':' + + password + + '@' + + container.getHost() + + ':' + + container.getMappedPort(MONGO_PORT) + + '/' + + authDatabase + + "?authSource=" + + authDatabase + + "&directConnection=true"; + } + + /** The bootstrap superuser's connection string. */ + public String rootConnectionString() { + return connectionStringFor(ROOT_USER, ROOT_PASSWORD, "admin"); + } + + private void initiate() { + // Under the localhost exception, rs.initiate is permitted before any user exists. + String ok = + evaluate( + "rs.initiate({_id:'" + + REPLICA_SET_NAME + + "',members:[{_id:0,host:'localhost:" + + MONGO_PORT + + "'}]}).ok"); + if (!ok.contains("1")) { + throw new IllegalStateException("rs.initiate failed on the authenticated fixture: " + ok); + } + awaitPrimary(); + } + + private void awaitPrimary() { + long deadline = System.nanoTime() + Duration.ofMinutes(2).toNanos(); + while (System.nanoTime() < deadline) { + if ("true".equals(evaluate("db.hello().isWritablePrimary"))) { + return; + } + sleepBriefly(); + } + throw new IllegalStateException("the authenticated fixture did not reach a primary"); + } + + private void createRootUser() { + String created = + evaluate( + "db.getSiblingDB('admin').createUser({user:'" + + ROOT_USER + + "',pwd:'" + + ROOT_PASSWORD + + "',roles:[{role:'root',db:'admin'}]}).ok || 'created'"); + if (created.isBlank()) { + throw new IllegalStateException("the bootstrap superuser was not created"); + } + } + + private String evaluate(String expression) { + try { + Container.ExecResult result = + container.execInContainer("mongosh", "--quiet", "--eval", expression); + return result.getStdout().trim(); + } catch (IOException unavailable) { + return ""; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while configuring the authenticated fixture"); + } + } + + private static void sleepBriefly() { + try { + Thread.sleep(500); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for the authenticated fixture"); + } + } + + @Override + public void close() { + container.stop(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetContract.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetContract.java new file mode 100644 index 00000000..64dbc66f --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetContract.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.rs; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * The contracts that must pass on a real replica set (design §30). + * + *

Enumerated so the release gate can assert coverage rather than trust that the suite is + * complete. A missing contract is invisible in a green run; a missing name in this set is not. + */ +public enum MongoReplicaSetContract { + + /** Java → BSON → server → raw BSON → Java, with types preserved. */ + GOLDEN_BSON("mapping"), + + /** Concurrent atomic updates against one document. */ + ATOMIC_UPDATE_RACE("mapping"), + + /** Expected-revision predicates under concurrency. */ + OPTIMISTIC_CONFLICT("mapping"), + + /** Multi-document transaction commit and abort. */ + TRANSACTION("transaction"), + + /** Ordered and unordered bulk partial results. */ + BULK_PARTIAL_RESULT("mapping"), + + /** Keyset pagination across a page boundary with ties. */ + KEYSET_PAGINATION("mapping"), + + /** TTL index cleanup and query-time expiry. */ + TTL_CLEANUP("mapping"), + + /** GeoJSON proximity queries against a 2dsphere index. */ + GEOSPATIAL("mapping"), + + /** Change stream delivery, resume and checkpoint ordering. */ + CHANGE_STREAM("change-stream"), + + /** Validator behaviour under strict/error and moderate/warn. */ + SCHEMA_VALIDATION("mapping"); + + private final String evidenceCategory; + + MongoReplicaSetContract(String evidenceCategory) { + this.evidenceCategory = evidenceCategory; + } + + /** The release-gate evidence category this contract contributes to. */ + public String evidenceCategory() { + return evidenceCategory; + } + + /** Every contract the single-node replica set lane must run. */ + public static Set all() { + return new LinkedHashSet<>(java.util.List.of(values())); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetFixture.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetFixture.java new file mode 100644 index 00000000..12e0a6c6 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoReplicaSetFixture.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.rs; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A per-test-class database on a shared replica set (design §29). + * + *

Isolating by database rather than by container is what keeps the contract lane's runtime + * reasonable: starting a replica set costs seconds, and a suite that starts one per test class + * spends most of its time in Docker. + * + *

Names are generated per class so two classes running in parallel cannot see each other's + * documents. + */ +public final class MongoReplicaSetFixture { + + private static final AtomicInteger SEQUENCE = new AtomicInteger(); + + private final MongoSingleReplicaSetContainer container; + + private final String databaseName; + + private MongoReplicaSetFixture(MongoSingleReplicaSetContainer container, String databaseName) { + this.container = container; + this.databaseName = databaseName; + } + + /** A fixture with a database named after the test class. */ + public static MongoReplicaSetFixture forTest( + MongoSingleReplicaSetContainer container, Class testClass) { + Objects.requireNonNull(container, "container"); + Objects.requireNonNull(testClass, "testClass"); + String databaseName = + "t" + + Integer.toHexString(testClass.getSimpleName().hashCode()) + + '-' + + SEQUENCE.incrementAndGet(); + return new MongoReplicaSetFixture(container, databaseName); + } + + /** The isolated database name. */ + public String databaseName() { + return databaseName; + } + + /** The connection string for this fixture's database. */ + public String connectionString() { + String base = container.connectionString(); + int query = base.indexOf('?'); + return query < 0 + ? base + '/' + databaseName + : base.substring(0, query) + '/' + databaseName + base.substring(query); + } + + /** The container this fixture runs against. */ + public MongoSingleReplicaSetContainer container() { + return container; + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java new file mode 100644 index 00000000..2407c81a --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.rs; + +import java.util.Objects; +import org.testcontainers.mongodb.MongoDBContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * A single-node replica set for local and CI contract tests (design D-02, Task 44). + * + *

A replica set rather than a standalone, even for one node. Transactions, retryable writes and + * change streams all require an oplog, so a standalone local environment quietly cannot run the + * tests that matter most — and the difference surfaces in CI or in production instead. + * + *

The image tag is pinned rather than {@code latest}. A mutable tag makes a red run + * unattributable: the code did not change, and the image might have. + */ +public final class MongoSingleReplicaSetContainer implements AutoCloseable { + + /** System property carrying the pinned primary-lane image. */ + public static final String PRIMARY_IMAGE_PROPERTY = "mongodb.primary.image"; + + /** System property carrying the pinned compatibility-lane image. */ + public static final String COMPATIBILITY_IMAGE_PROPERTY = "mongodb.compatibility.image"; + + private final MongoDBContainer container; + + private MongoSingleReplicaSetContainer(String image) { + // withReplicaSet() is not the default: MongoDBContainer starts a standalone otherwise, and a + // standalone silently refuses transactions, retryable writes and change streams. A fixture + // named "replica set" that is actually a standalone passes every string assertion and fails + // every behaviour that matters. + this.container = new MongoDBContainer(DockerImageName.parse(image)).withReplicaSet(); + } + + /** The MongoDB 8.0 primary certification lane. */ + public static MongoSingleReplicaSetContainer mongoEight() { + return new MongoSingleReplicaSetContainer( + System.getProperty(PRIMARY_IMAGE_PROPERTY, "mongo:8.0.16")); + } + + /** The MongoDB 7.0 compatibility lane. */ + public static MongoSingleReplicaSetContainer mongoSeven() { + return new MongoSingleReplicaSetContainer( + System.getProperty(COMPATIBILITY_IMAGE_PROPERTY, "mongo:7.0.28")); + } + + /** An explicitly pinned image. */ + public static MongoSingleReplicaSetContainer image(String pinnedImage) { + return new MongoSingleReplicaSetContainer(Objects.requireNonNull(pinnedImage, "pinnedImage")); + } + + /** Starts the container and waits for the primary to be electable. */ + public void start() { + container.start(); + } + + /** + * The connection string for this deployment. + * + *

Taken from the container rather than assembled here. The replica set's own member address is + * an in-container hostname, so a hand-written URI that forces topology discovery resolves to an + * address the host cannot reach and fails server selection — which reads as a platform bug rather + * than a fixture one. + */ + public String connectionString() { + return container.getConnectionString(); + } + + /** The underlying container, for tests that need a raw handle. */ + public MongoDBContainer container() { + return container; + } + + /** + * A note for anyone tempted to treat this as failover evidence. + * + *

A single-node replica set never elects a new primary, so it proves nothing about failover. + * That lane is {@code mongoFailoverTest}, against three real nodes. + */ + public boolean providesFailoverEvidence() { + return false; + } + + @Override + public void close() { + container.stop(); + } +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoChunkMigrationController.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoChunkMigrationController.java new file mode 100644 index 00000000..8f88be92 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoChunkMigrationController.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.sharded; + +/** + * Moves chunks on purpose, while traffic runs (advanced plan Task 4). + * + *

A chunk migration is the sharded cluster's routine background event, and it is when routing + * metadata goes briefly stale. Testing only a quiet cluster means never exercising the retry paths + * that stale-config errors trigger — which is exactly what production hits during a rebalance. + */ +public interface MongoChunkMigrationController { + + /** Moves the chunk containing the given shard key value to another shard. */ + void moveChunkContaining(String collection, Object shardKeyValue); + + /** Stops the balancer so chunk movement is deterministic during a test. */ + void stopBalancer(); + + /** Starts the balancer. */ + void startBalancer(); + + /** Waits for any in-flight migration to finish. */ + void awaitMigrationComplete(); +} diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuite.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuite.java new file mode 100644 index 00000000..4f129fc2 --- /dev/null +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/sharded/MongoShardingContractSuite.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.outbound.mongo.testkit.sharded; + +import dev.caskeleton.adapter.outbound.mongo.advanced.sharding.MongoRoutingClassification; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * The sharding behaviours that only a real cluster can demonstrate (advanced plan Task 4). + * + *

None of these can be simulated. Routing is decided by mongos against live chunk metadata, + * chunk migration only happens when the balancer moves data, and cross-shard transaction latency is + * a property of the topology. A mocked sharded cluster proves that the mock is consistent. + */ +public final class MongoShardingContractSuite { + + private final Function runner; + + public MongoShardingContractSuite(Function runner) { + this.runner = Objects.requireNonNull(runner, "runner"); + } + + /** Runs one scenario. */ + public MongoShardingReport run(Scenario scenario) { + return runner.apply(Objects.requireNonNull(scenario, "scenario")); + } + + /** Runs every scenario and collects the failures. */ + public List runAll() { + List failures = new ArrayList<>(); + for (Scenario scenario : Scenario.values()) { + MongoShardingReport report = run(scenario); + if (!report.matches(scenario)) { + failures.add(scenario.name() + " observed " + report); + } + } + return List.copyOf(failures); + } + + /** A scenario and the routing it is expected to produce. */ + public enum Scenario { + + /** A query carrying the full shard key. */ + TARGETED_QUERY(MongoRoutingClassification.TARGETED, 1), + + /** A query carrying a prefix of the shard key. */ + PREFIX_TARGETED_QUERY(MongoRoutingClassification.PREFIX_TARGETED, 2), + + /** A query carrying no shard key predicate. */ + SCATTER_GATHER_QUERY(MongoRoutingClassification.SCATTER_GATHER, 2), + + /** A single-document update without the shard key. */ + UNROUTED_WRITE(MongoRoutingClassification.REJECTED, 0), + + /** Reads and writes continuing while a chunk moves. */ + CHUNK_MIGRATION_UNDER_LOAD(MongoRoutingClassification.TARGETED, 1), + + /** A transaction spanning two shards. */ + CROSS_SHARD_TRANSACTION(MongoRoutingClassification.SCATTER_GATHER, 2); + + private final MongoRoutingClassification expectedRouting; + + private final int expectedShardsExamined; + + Scenario(MongoRoutingClassification expectedRouting, int expectedShardsExamined) { + this.expectedRouting = expectedRouting; + this.expectedShardsExamined = expectedShardsExamined; + } + + /** What routing this scenario should produce. */ + public MongoRoutingClassification expectedRouting() { + return expectedRouting; + } + + /** How many shards this scenario should contact. */ + public int expectedShardsExamined() { + return expectedShardsExamined; + } + } + + /** + * What a scenario actually did. + * + * @param shardsExamined how many shards the explain plan reports + * @param routing the observed routing classification + */ + public record MongoShardingReport(int shardsExamined, MongoRoutingClassification routing) { + + public MongoShardingReport { + Objects.requireNonNull(routing, "routing"); + } + + /** True when the observation matches the scenario's expectation. */ + public boolean matches(Scenario scenario) { + return routing == scenario.expectedRouting() + && shardsExamined == scenario.expectedShardsExamined(); + } + } +}