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>
69 lines
3.5 KiB
Markdown
69 lines
3.5 KiB
Markdown
# 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.
|