How to do Document-level locking on MongoDB and .NET Core – part 2
Hi All! Welcome back to the second article of the Series. Today we’re going to discuss a simple implementation of a locking technique on MongoDB.
Last time we saw what optimistic and pessimistic locking mean and we talked about a possible implementation using two extra fields.
Today instead we’ll dig into the code! As usual, I’ve published all the code on GitHub, feel free to look and come back.
Let’s start by defining a simple Entity:
public record Dummy(Guid Id, string Value, Guid? LockId = null, DateTime? LockTime = null);
Note the two nullable properties, LockId and LockTime, they’re going to be very useful in a moment.
I like to keep my classes immutable and C# 9 records are definitely a nice way to do that.
Let’s take a look at the Repository now. This class exposes just two methods: LockAsync and ReleaseLockAsync. Pretty self-explanatory, aren’t they 😀
public async Task<Dummy> LockAsync(Guid id, Dummy newEntity, CancellationToken cancellationToken = default) { var filter = Builders<Dummy>.Filter.And( Builders<Dummy>.Filter.Eq(e => e.Id, id), Builders<Dummy>.Filter.Or( Builders<Dummy>.Filter.Eq(e => e.LockId, null), Builders<Dummy>.Filter.Lt(e => e.LockTime, DateTime.UtcNow - _lockMaxDuration) ) ); var update = Builders<Dummy>.Update .Set(e => e.LockId, Guid.NewGuid()) .Set(e => e.LockTime, DateTime.UtcNow); if (newEntity is not null) { update = update.SetOnInsert(e => e.Id, newEntity.Id) .SetOnInsert(e => e.Value, newEntity.Value); } var options = new FindOneAndUpdateOptions<Dummy>() { IsUpsert = true, ReturnDocument = ReturnDocument.After }; try { return await _collection.FindOneAndUpdateAsync(filter, update, options, cancellationToken); } catch (MongoCommandException e) when(e.Code == 11000 && e.CodeName == "DuplicateKey") { throw new LockException($"item '{id}' is already locked"); } }
In order to lock a document, the first thing to do is fetching it by ID. But with a twist: we also make sure that LockId is null. Additionally, we’re also checking if the lock has expired by querying on LockTime.
We can have 3 situations:
- the document is available. We fetch it, set LockId and LockTime and return to the caller.
- the document is locked. The MongoDB driver will throw a specific MongoCommandException. We’re catching that and converting it into a custom LockException.
- the document does not exist at all. We’ll create it by setting IsUpsert to true and using the Dummy instance we passed as input to initialize the data.
The trick here is that we’re leveraging the FindOneAndUpdateAsync MongoDB method, which executes both the operations atomically, in one go.
This will cover locking. Let’s see how we can release the document now. The main idea is that we’re locking, doing some operations and then release and update the data in the DB with the new state.
public async Task ReleaseLockAsync(Dummy item, CancellationToken cancellationToken = default) { var filter = Builders<Dummy>.Filter.And( Builders<Dummy>.Filter.Eq(e => e.Id, item.Id), Builders<Dummy>.Filter.Eq(e => e.LockId, item.LockId) ); var update = Builders<Dummy>.Update .Set(e => e.Value, item.Value) .Set(e => e.LockId, null) .Set(e => e.LockTime, null); var options = new UpdateOptions(){ IsUpsert = false }; var result = await _collection.UpdateOneAsync(filter, update, options, cancellationToken); if (result is null || result.ModifiedCount != 1) throw new LockException($"unable to release lock on item '{item.Id}'"); }
Here we’re filtering by Id and LockId. In this case, we’ll be using UpdateOneAsync since we don’t need to return anything to the caller.
We make sure to reset to null both LockId and LockTime, de-facto freeing up the document.
We’ll also set IsUpsert to false: we are already sure the document exists after the call to LockAsync.
This is the same technique I’m using in the MongoDB driver for OpenSleigh, the Saga management library for .NET Core I’m working on these days.
That’s all folks!