Concerns belong to the transaction, not to its operations. Pass readConcern and writeConcern to startTransaction or withTransaction; commands inside inherit them and cannot override them. readConcern: { level: "snapshot" } is the level you want: every read sees one majority-committed snapshot, so two reads of a document agree even if another client changes it between them. writeConcern: { w: "majority" } makes the commit durable on a majority of members before it returns.
const s = a.startSession(); // ca and cb are two clients
s.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } });
const inTxn = async () => (await ca.findOne({ _id: 'A' }, { session: s })).bal;
console.log('in txn ->', await inTxn());
await ca.updateOne({ _id: 'A' }, { $inc: { bal: 5 } }, { session: s });
console.log('in txn ->', await inTxn(), 'outside ->', (await cb.findOne({ _id: 'A' })).bal);
try { await cb.updateOne({ _id: 'A' }, { $inc: { bal: 1 } }, { maxTimeMS: 2000 }); }
catch (e) { console.log('outside write ->', e.codeName); }
await s.commitTransaction();
console.log('committed, bal =', (await cb.findOne({ _id: 'A' })).bal);in txn -> 100 in txn -> 105 outside -> 100 outside write -> MaxTimeMSExpired committed, bal = 105
Three things are visible. The transaction reads its own uncommitted write, 105. The other client still reads 100, because uncommitted data is invisible everywhere else. And that client's write to the document does not fail outright — it waits for the lock the transaction holds, here until its own maxTimeMS ran out. Set a maxTimeMS on writes that may collide with a long transaction, or they block for its full lifetime.
A snapshot is consistent, not current, so reading, deciding and then writing can still be wrong. Encode the invariant in the filter — updateOne({ _id: 'A', bal: { $gte: 25 } }, ...) — and check modifiedCount, letting the write enforce the rule instead of your if.