Files
clean-architecture-backend-…/docs/mongodb/consistency-transaction-guide.md
T
DongHyeonkaandClaude Opus 5 d57d2f62a0 feat(mongodb): implement the MongoDB document persistence platform
Implements the mongodb-superpowers-package design: Stable Tasks 1-50 and
Advanced Tasks 1-15.

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

Contract highlights, all enforced by tests rather than convention:

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 13:41:00 +09:00

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.