Transactions: how to make reads conflict with writes but not with other reads

I have a project in which I sometime have to use transactions to read and/or write multiple documents. There are two reasons why I need transactions: 1) if two operations try to update the same document, I want one of them to fail (and possibly retry) 2) I have operations for which I need to be sure that all the data read during the operation are consistent, and that I will not read the result of an update performed by someone else during the operation. So I want my transaction to commit only if no document I accessed during the transaction (read or writes) was modified. This comes naturally for writes, but not for reads because of the way transactions work in mondodb (the document lock is only acquired on writes).

Example: I have two documents with a property value. I have two transactions, both reading the two documents, and if the value from both documents is identical, they should increment the value of one of the document, different depending on the transaction:

session1.withTransaction(async () => {
  const docA = await collection.findOne(collection, { _id: 'A' }, { session: session1 });
  const docB = await collection.findOne(collection, { _id: 'B' }, { session: session1 });
  if (docA.value === docB.value) {
    await collection.findOneAndUpdate({ _id: 'B' }, { $inc: { value: 1 } }, { session: session1 });
  }
});
session2.withTransaction(async () => {
  const docA = await collection.findOne(collection, { _id: 'A' }, { session: session2 });
  const docB = await collection.findOne(collection, { _id: 'B' }, { session: session2 });
  if (docA.value === docB.value) {
    await collection.findOneAndUpdate({ _id: 'A' }, { $inc: { value: 1 } }, { session: session2 });
  }
});

After this, I would expect the value from only one document to have been incremented, but in practice both “A” and “B” can be incremented because there is no conflict.

One way to make this work is to have all reads that are part of the transaction acquire the write lock by updating the document, for example by using a findOneAndUpdate and incrementing a _lock property (as is suggested here in-progress-transactions-and-stale-reads (*)). This does solve the above problem (with only one document being modified), but the problem with that solution is that two transactions reading the same document without doing any update will also conflict.

According to this presentation: How and When to Use Multi-Document Distributed Transactions - YouTube (from 16:19) (**), there is an optimization which cause writes that do not actually change a document to be “skipped” and not acquire the lock. This means that by having writes increment the special field, and reads set it to a fixed value, I should have almost what I want: reads will conflict with writes, but not with other reads (except for the first read after a write, but that’s ok):

// Read
collection.findOneAndUpdate(query, { $set: { _lock: 1 } }, options);

// Write (merge is a recursive merge like lodash.merge)
collection.findOneAndUpdate(query, merge(update, { $inc: { _lock: 1 } }), options);

I tried this and it appears to work as intended. My concern is that I couldn’t find this documented anywhere except in the video linked above, so I am not sure I should base my implementation on this. So my main question(s) is this: did I miss the documentation about this? is this a stable behavior, and is it safe to use it? is there a risk that this would change in the future?

And alternatively, is there a better way to do what I am trying to do? Keeping in mind that I need transaction for two things: I don’t want intermediary states to be visible, and I want some of my operations (even read only ones) to read consistent data and commit only if no accessed document was modified during the operation start and end.

I searched in the forum and the documentation but didn’t find discussions related to this specific problem, so I hope I’ll have better luck here :slight_smile: Thanks in advance!

(*) IIUC the solution suggested in in-progress-transactions-and-stale-reads appears incorrect because of (**), since the { $set: { employee: 1 } } should be considered a no-op