Search code examples
c#mongodbmongodb-.net-driverupsert

Creating a sequence -- SetOnInsert appears to do nothing


I'm having a problem trying, what boils down to, incrementing a field in a document or inserting an entire document. The context is "trying to insert an initial document for a sequence or incrementing the sequence number for an existing sequence".

This code:

private async Task<int> GetSequenceNumber(string sequenceName)
{
    var filter = new ExpressionFilterDefinition<Sequence>(x => x.Id == sequenceName);
    var builder = Builders<Sequence>.Update;
    var update = builder
        .SetOnInsert(x => x.CurrentValue, 1000)
        .Inc(x => x.CurrentValue, 1);

    var sequence = await _context.SequenceNumbers.FindOneAndUpdateAsync(
        filter, 
        update, 
        new FindOneAndUpdateOptions<Sequence>
        {
            IsUpsert = true, 
            ReturnDocument = ReturnDocument.After,
        });

    return sequence.CurrentValue;
}

results in the exception

MongoDB.Driver.MongoCommandException: Command findAndModify failed: Updating the path 'currentvalue' would create a conflict at 'currentvalue'. at MongoDB.Driver.Core.WireProtocol.CommandUsingCommandMessageWireProtocol`1.ProcessResponse(ConnectionId connectionId, CommandMessage responseMessage)

Removing the SetOnInsert results in no errors, but inserts a document with the currentValue equal to 1 instead of the expected 1000.

It almost appears if SetOnInsert is not being honored, and that what's happening is a default document is inserted and then currentValue is incremented via Inc atomically as the new document is created.

How do I overcome these issues? A non-C# solution would also be welcome, as I could translate that...


Solution

  • Ok thanks to @dododo in the comments, I now realize that both an Inc and a SetOnInsert can't be applied at the same time. It's unintuitive because you'd think the former would apply on update only and the latter on insert only.

    I went with the solution below, which suffers more than one round-trip, but at least works, and appears to work with my concurrency based tests.

    public async Task<int> GetSequenceNumber(string sequenceName, int tryCount)
    {
        if (tryCount > 5) throw new InvalidOperationException();
    
        var filter = new ExpressionFilterDefinition<Sequence>(x => x.Id == sequenceName);
        var builder = Builders<Sequence>.Update;
    
        // optimistically assume value was already initialized
        var update = builder.Inc(x => x.CurrentValue, 1);
    
        var sequence = await _context.SequenceNumbers.FindOneAndUpdateAsync(
            filter, 
            update, 
            new FindOneAndUpdateOptions<Sequence>
            {
                IsUpsert = true, 
                ReturnDocument = ReturnDocument.After,
            });
    
        if (sequence == null)
            try
            {
                // we have to try to save a new sequence...
                sequence = new Sequence { Id = sequenceName, CurrentValue = 1001 };
                await _context.SequenceNumbers.InsertOneAsync(sequence);
            }
            // ...but something else could beat us to it
            catch (MongoWriteException e) when (e.WriteError.Code == DuplicateKeyCode)
            {
                // ...so we have to retry an update
                return await GetSequenceNumber(sequenceName, tryCount + 1);
            }
    
        return sequence.CurrentValue;
    }
    

    I'm sure there are other options. It may be possible to use an aggregation pipeline, for example.