Files
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

6.4 KiB

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.

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.