Trace the complete transaction lifecycle through a logical session, including visibility, abort, commit, callback retries, and commit ambiguity.
Logical Sessions, Transaction Start/Commit/Abort, and Driver Transaction APIs
Follow one logical session through start, statement visibility, commit, abort, and driver-managed retry behavior without duplicating external side effects.
Learning objectives
Define logical sessions and explain how transactions are associated with a session and transaction number.
Trace startTransaction(), statement execution,
commitTransaction(), and
abortTransaction().
Observe transaction invisibility outside the session before commit and committed state afterward.
Distinguish driver callback APIs from lower-level explicit transaction APIs.
Explain why callback retries make non-database side effects dangerous unless they are idempotent or deduplicated.
This lesson pins MongoDB Community Server
8.3.8 with
mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim, mongosh 2.10.0, and PyMongo
4.17.0 where a driver example is used. Transactions
require a replica set or sharded cluster, so the mandatory local
lab runs a disposable replica set published only on loopback
beginning at 127.0.0.1:27078. Authentication and
TLS are disabled only for this isolated learning topology.
Feature Compatibility Version (FCV) is observed and never
changed. Unless a section explicitly overrides them, read/write
concern and read preference use the transaction/client defaults
described beside the example. Atlas, Search, Vector Search, KMS,
and Enterprise Advanced are not mandatory. The one-member
replica set can prove transaction atomicity and visibility, but
it cannot prove multi-node majority durability or failover
behavior. The product commands were not executed in this
generation environment because Docker, mongod, mongosh, and
PyMongo are unavailable here; measured output must be recorded
on the learner's machine.
1. AtlasMart problem: transfer value between independently owned wallets
A logical session groups related client
operations and provides the server-side framework for causal
consistency, retryable writes, and transactions. A
multi-document transaction is identified by session information
such as lsid plus a transaction number
(txnNumber) in server observability.
ACID abbreviates atomicity, consistency,
isolation, and durability; MongoDB transactions provide the
database transaction boundary, while application correctness
still depends on valid business predicates and retry design.
Wallet A and Wallet B have independent lifecycle ownership. Embedding every customer's wallet into one shared document would create an unreasonable hot aggregate, so a real cross-document transaction is justified for an atomic transfer.
docker rm -f atlasmart-mongo-ch13-l2 2>/dev/null || truedocker volume rm atlasmart-mongo-ch13-l2-data 2>/dev/null || truedocker run -d --name atlasmart-mongo-ch13-l2 \ -p 127.0.0.1:27078:27017 \ -v atlasmart-mongo-ch13-l2-data:/data/db \ mongodb/mongodb-community-server:8.3.8-ubuntu2204-slim --replSet atlasmart-rs13-l2 --bind_ip_alluntil mongosh "mongodb://127.0.0.1:27078/admin?directConnection=true" --quiet --eval \'quit(db.runCommand({ping:1}).ok === 1 ? 0 : 1)'; do sleep 1; donemongosh "mongodb://127.0.0.1:27078/admin?directConnection=true" --quiet --eval \'rs.initiate({_id:"atlasmart-rs13-l2",members:[{_id:0,host:"localhost:27017"}]})'until mongosh "mongodb://127.0.0.1:27078/admin?directConnection=true&replicaSet=atlasmart-rs13-l2" --quiet --eval \'quit(db.hello().isWritablePrimary ? 0 : 1)'; do sleep 1; donemongosh "mongodb://127.0.0.1:27078/admin?directConnection=true&replicaSet=atlasmart-rs13-l2" --quiet --eval \'printjson({server:db.version(),setName:db.hello().setName,primary:db.hello().isWritablePrimary}); printjson(db.runCommand({getParameter:1,featureCompatibilityVersion:1,transactionLifetimeLimitSeconds:1,maxTransactionLockRequestTimeoutMillis:1}))'
2. Create existing collections before transaction work
The fixture is created before the transaction. This keeps the lesson focused on transaction mechanics rather than Data Definition Language (DDL) rules. DDL means operations that create or change collections, indexes, or database metadata; Lesson 4 treats those restrictions separately.
const w=db.wallets_ch13_l2;w.drop();w.insertMany([ {_id:"wallet-a",tenantId:"tenant-a",balanceCents:10000}, {_id:"wallet-b",tenantId:"tenant-a",balanceCents:2500}]);printjson(w.find().sort({_id:1}).toArray());
3. Start, observe, and commit
Session.startTransaction() configures the
transaction locally; the server-side transaction actually starts
on the first command sent with that session. Operations must use
the session-bound database/collection. Until commit, writes are
not visible outside the transaction. The
$currentOp pipeline can expose an inactive
transaction with its lsid, txnNumber,
read concern, timestamps, and elapsed time when that session is
reportable.
const session=db.getMongo().startSession({causalConsistency:true});const sdb=session.getDatabase("atlasmart");const wallets=sdb.wallets_ch13_l2;session.startTransaction({readConcern:{level:"snapshot"},writeConcern:{w:"majority"}});const debit=wallets.updateOne({_id:"wallet-a",balanceCents:{$gte:2000}},{$inc:{balanceCents:-2000}});if (debit.modifiedCount !== 1) throw new Error("insufficient funds");wallets.updateOne({_id:"wallet-b"},{$inc:{balanceCents:2000}});print("outside session before commit:");printjson(db.wallets_ch13_l2.find().sort({_id:1}).toArray());print("transaction observability where the idle session is exposed:");printjson(db.getSiblingDB("admin").aggregate([ {$currentOp:{allUsers:true,idleSessions:true}}, {$match:{transaction:{$exists:true}}}, {$project:{_id:0,type:1,lsid:1,transaction:1}}]).toArray());session.commitTransaction();print("outside session after commit:");printjson(db.wallets_ch13_l2.find().sort({_id:1}).toArray());session.endSession();
Before commit, the ordinary
db.wallets_ch13_l2 read should still show 10000
and 2500. After commit, it should show 8000 and 4500. The
$currentOp shape is version/runtime dependent; an
empty result is not evidence that no transaction exists,
because only reportable active/idle transaction state appears.
4. Abort on a business precondition failure
An application error is also a reason to abort. The impossible debit is detected inside the transaction; the catch block aborts and the outside collection remains unchanged. The transaction does not commit a prefix of its writes.
const session=db.getMongo().startSession();const sdb=session.getDatabase("atlasmart");const wallets=sdb.wallets_ch13_l2;session.startTransaction({readConcern:{level:"snapshot"},writeConcern:{w:"majority"}});try { const r=wallets.updateOne({_id:"wallet-a",balanceCents:{$gte:999999}},{$inc:{balanceCents:-999999}}); if (r.modifiedCount !== 1) throw new Error("insufficient funds: abort business transaction"); wallets.updateOne({_id:"wallet-b"},{$inc:{balanceCents:999999}}); session.commitTransaction();} catch (e) { try { session.abortTransaction(); } catch (_) {} print("aborted:",e.message);}printjson(db.wallets_ch13_l2.find().sort({_id:1}).toArray());session.endSession();
5. Driver-managed callback API and retry danger
PyMongo with_transaction() manages
start/commit/abort and incorporates transaction retry behavior.
The callback may be invoked more than once if the driver retries
the whole transaction. Therefore sending email, charging a
non-transactional external payment API, incrementing an external
counter, or publishing a message directly inside the callback
can duplicate side effects. Keep the callback database-focused,
or protect external effects with an idempotency/deduplication
protocol.
from pymongo import MongoClientfrom pymongo.errors import ConnectionFailure, OperationFailurefrom pymongo.read_concern import ReadConcernfrom pymongo.write_concern import WriteConcernfrom pymongo.read_preferences import ReadPreferenceuri = "mongodb://127.0.0.1:27078/?directConnection=true&replicaSet=atlasmart-rs13-l2"client = MongoClient(uri)wallets = client.atlasmart.wallets_ch13_l2def body(session): debit = wallets.update_one( {"_id": "wallet-a", "balanceCents": {"$gte": 100}}, {"$inc": {"balanceCents": -100}}, session=session, ) if debit.modified_count != 1: raise RuntimeError("insufficient funds") wallets.update_one( {"_id": "wallet-b"}, {"$inc": {"balanceCents": 100}}, session=session, ) return "db-work-complete"with client.start_session() as session: result = session.with_transaction( body, read_concern=ReadConcern("snapshot"), write_concern=WriteConcern("majority"), read_preference=ReadPreference.PRIMARY, ) print(result)client.close()
If the server commits but the network fails before the client
receives the answer, the client cannot infer “not committed”
from the missing response. Drivers handle an
UnknownTransactionCommitResult by retrying the
commit operation. A TransientTransactionError can
require retrying the whole transaction. Retrying the entire
business action from scratch is not the same thing.
Production judgment. Reuse the application's
long-lived MongoClient; create bounded client
sessions from it. A session must not be used concurrently. Every
operation in an explicit transaction must be associated with
that session. Monitor abort/retry rates and latency rather than
hiding retries. Keep transaction bodies short, deterministic,
and free from irreversible external side effects.
Bridge. Lesson 3 separates snapshot/read visibility from durability acknowledgement and proves snapshot behavior with two client connections.
docker rm -f atlasmart-mongo-ch13-l2docker volume rm atlasmart-mongo-ch13-l2-data
Check your understanding
- What identifies a server transaction in observability?
-
When does
startTransaction()actually start work on the server? - Are uncommitted writes visible to ordinary outside reads?
-
Why can
with_transaction()duplicate an email sent inside its callback? -
What should be retried for
UnknownTransactionCommitResult?
Review the answers
1. The logical session identifier
(lsid) together with the transaction number
identifies a transaction.
2. The method configures the transaction; the server transaction begins when the first command is sent on the session.
3. No. Transaction changes remain invisible outside the transaction until commit.
4. The driver may invoke the callback more than once when retrying the transaction.
5. The commit decision should be retried with the same transaction context; it does not mean the transaction definitely failed.
Authoritative references
- MongoDB 8.3 release notes — Current 8.3 release line and patch-sensitive server behavior.
- Atomicity and transactions — Single-document atomicity and guidance to minimize unnecessary distributed transactions.
- Transactions — Sessions, transaction read/write concern, read preference, and transaction semantics.
- Drivers API for transactions — Callback versus core APIs and retry labels for transient transactions and ambiguous commits.
- Transaction production considerations — Runtime, locking, cache, DDL, conflicts, and operational constraints.
- Sharded transaction considerations — Cross-shard snapshot semantics, commit coordination, migrations, and outside reads.
- Transactions and operations — Operations permitted and prohibited inside transactions.
- $currentOp — Session/transaction observability including lsid, txnNumber, timing, and sharded coordinators.
- PyMongo transactions — PyMongo session, with_transaction, retry, and callback behavior.
- PyMongo release notes — Current PyMongo 4.17 behavior and session APIs.
- mongosh release notes — Current mongosh 2.10.0 release baseline.