Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and Advanced Tasks 1-15. The design assumes 19 Stable + 12 Advanced Gradle projects under modules/mongodb*. This repository's fail-closed registry declares exactly 19 leaf identities, so those modules become package boundaries inside the registered leaf :adapter:outbound:persistence-mongo, with the design's module dependency table enforced by ten ArchUnit rules. The mapping and every deviation are recorded in docs/mongodb/repository-adaptation.md. Contract highlights, all enforced by tests rather than convention: - Transaction body retry and commit retry are separate loops. A new session per body attempt; commit-only retry on an unknown commit. The body is never replayed after a commit ambiguity, so a failover cannot become a duplicate. - MongoExecutionOutcome keeps both ambiguous outcomes distinct from success and failure, and MongoFailureContext records only the design-permitted fields. - Failure classification reads server error labels before numeric codes. - BSON representations come from a pinned manifest, never a library default, and a golden type-signature gate fails on any drift. - Index and validator changes go through the manifest and the admin plane; metadata ownership gates every drop. - Every Advanced capability refuses construction unless its flag is enabled. Verified against real servers, not only unit tests. Running the lanes for the first time exposed four defects that a green `check` had hidden: - Four release lanes passed while executing zero tests; the gate now counts executed tests per lane and fails on zero. - The "single replica set" fixture was a standalone, because Testcontainers 2.x needs withReplicaSet(); its test only asserted a connection string. - The three-node fixture was three independent clusters, so no election could occur, and awaitNewPrimary() compared against the post-stop primary. - The migration lease checked modifiedCount, so a same-millisecond refresh read as a lost lease. scripts/verify-mongodb-platform.sh now reports: 9 lanes, 0 skipped, 0 failed, every evidence category produced. scripts/verify-mongodb-advanced.sh reports NOT PROMOTABLE: actual-topology evidence (real sharded cluster, real KMS, real target deployment) is unobtainable here, so it is named rather than assumed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
6.4 KiB
Markdown
127 lines
6.4 KiB
Markdown
# 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.
|