Document-Level Pessimistic Concurrency in MongoDB
In the last post, I got quite a few comments on some other ways to approach OCC. One pointed out that I wanted to explore was using a "SELECT...FOR UPDATE", which basically will grant an intent lock on the document in question. With an intent lock, we transition from optimistic concurrency, where we read a document and assume that others won't modify it, but check anyway, to pessimistic concurrency, where we know that our document will be modified by others.
Document-level locks in MongoDB can be granted in single operations or as part of a transaction. Intent locks can be granted as part of a write operation, or at the start of a transaction. Both are slightly different in their approach, one requiring an explicit write, and the other for the entire transaction, so let's look at each approach.
SELECT ... FOR UPDATE
In this approach, we want to trigger an intent exclusive lock (IX) on our first read of the document when we pull it out. The link above describes an approach with the bare API, so we can translate this into the C# version.
The general idea here is that MongoDb itself doesn't support this sort of "intentional" locking, but we can trigger it by updating some field to some new value. If another process has already locked that document, we'll immediately error out. In my case, we can just invent some field, I called it "ETag" to mirror Cosmos DB:
public class Counter
{
public Guid Id { get; set; }
public int Version { get; set; }
public Guid ETag { get; set; }
public int Value { get; set; }
}
When we first load up the document, we don't just load - we find-and-update that ETag
property to some new value, BUT, inside a transaction:
using var session = await client.StartSessionAsync().ConfigureAwait(false);
var transactionOptions = new TransactionOptions(readConcern: ReadConcern.Local, writeConcern: WriteConcern.W1);
session.StartTransaction(transactionOptions);
try
{
var update = Builders<Counter>.Update.Set(c => c.ETag, Guid.NewGuid());
var loaded = await collection.FindOneAndUpdateAsync(session, c => c.Id == id, update);
loaded.Value++;
result = await collection.ReplaceOneAsync(session, c => c.Id == id, loaded, new UpdateOptions { IsUpsert = false });
await session.CommitTransactionAsync();
}
catch
{
await session.AbortTransactionAsync();
throw;
}
return result;
In that first write operation, or trivial change locks the document for the duration of the transaction. After we perform whatever operation we need to on the document, we write back as normal - no version checking.
When we do this, our throughput drops fairly drastically - since we're locking ahead of time, we disallow any other concurrent write operations. Another concurrent write at the beginning of the operation will simply error out.
In practice, we'd likely want to have some sort of transparent retry mechanism around our operation - especially in scenarios where we're likely to see write collisions. We'd also likely want to introduce some jitter or randomness in our delays, since two operations retrying at the same time will likely collide again.
It can get fairly complicated, which is why you'd only want to introduce this in scenarios where optimistic locking isn't appropriate or viable. In fact, you can see an example of this in action in the NServiceBus MongoDB storage library - it's designed for concurrent operations, and uses pessimistic locking to do so.
Wrapping it up
With optimistic and pessimistic locking solutions possible, we see the flexibility of the MongoDB API. However, I do wish that it were easier and more explicit to call out locking strategies. This might be possible with other APIs on top of the MongoDB client, but as others have pointed out, you're far less likely to "foot-gun" yourself with a client API that encapsulates all of this for us.