Skip to content

Versioning ​

Foundatio.Repositories provides optimistic concurrency control through document versioning. This guide covers how versioning works and how to handle conflicts.

Overview ​

Versioning prevents lost updates when multiple processes modify the same document simultaneously. When enabled, the repository checks that the document version matches before saving.

Enabling Versioning ​

Implement IVersioned on your entity:

csharp
using Foundatio.Repositories.Models;

public class Employee : IIdentity, IHaveDates, IVersioned
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public DateTime CreatedUtc { get; set; }
    public DateTime UpdatedUtc { get; set; }
    public string Version { get; set; } = string.Empty;  // Required by IVersioned
}

How Versioning Works ​

Version Format ​

The version is stored as "primaryTerm:sequenceNumber" using Elasticsearch's sequence numbers:

csharp
// Example version: "1:42"
// - primaryTerm: 1
// - sequenceNumber: 42

Read-Modify-Write Cycle ​

csharp
// 1. Read document (version is populated)
var employee = await repository.GetByIdAsync(id);
Console.WriteLine($"Version: {employee.Version}");  // e.g., "1:42"

// 2. Modify document
employee.Name = "John Smith";

// 3. Save document (version is checked)
await repository.SaveAsync(employee);
// If version matches: Save succeeds, version updated to "1:43"
// If version doesn't match: VersionConflictDocumentException thrown

Conflict Detection ​

Handling Version Conflicts ​

Basic Error Handling ​

csharp
try
{
    await repository.SaveAsync(employee);
}
catch (VersionConflictDocumentException ex)
{
    Console.WriteLine($"Version conflict: {ex.Message}");
    // Handle conflict...
}

Retry Pattern ​

csharp
public async Task UpdateEmployeeWithRetry(string id, Action<Employee> update, int maxRetries = 3)
{
    int retries = maxRetries;
    
    while (retries > 0)
    {
        try
        {
            var employee = await repository.GetByIdAsync(id);
            if (employee == null)
                throw new DocumentNotFoundException(id);
            
            update(employee);
            await repository.SaveAsync(employee);
            return;  // Success
        }
        catch (VersionConflictDocumentException)
        {
            retries--;
            if (retries == 0)
                throw;
            
            // Wait before retry (exponential backoff)
            await Task.Delay(TimeSpan.FromMilliseconds(100 * (maxRetries - retries)));
        }
    }
}

// Usage
await UpdateEmployeeWithRetry(id, emp => emp.Name = "John Smith");

Merge Strategy ​

csharp
public async Task<Employee> UpdateEmployeeWithMerge(string id, Action<Employee> update)
{
    while (true)
    {
        try
        {
            var employee = await repository.GetByIdAsync(id);
            if (employee == null)
                throw new DocumentNotFoundException(id);
            
            update(employee);
            return await repository.SaveAsync(employee);
        }
        catch (VersionConflictDocumentException)
        {
            // Refresh and retry - the update action will be applied to fresh data
            continue;
        }
    }
}

Last-Write-Wins ​

Skip version checking when you want the last write to win:

csharp
await repository.SaveAsync(employee, o => o.SkipVersionCheck());

Skipping Version Check ​

Per-Operation ​

csharp
// Skip version check for this operation
await repository.SaveAsync(employee, o => o.SkipVersionCheck());

// Explicitly control version checking
await repository.SaveAsync(employee, o => o.VersionCheck(false));

Use Cases for Skipping ​

  • Bulk imports - Trusted data from verified sources
  • System updates - Internal updates that should always succeed
  • Migrations - Data transformations during schema changes
  • Last-write-wins scenarios - When conflicts don't matter

WARNING

Only skip version checking when you're certain that lost updates are acceptable.

Version in Patch Operations ​

Patch operations also support version checking:

csharp
// Patch with version check (default)
await repository.PatchAsync(id, patch);

// Patch without version check
await repository.PatchAsync(id, patch, o => o.SkipVersionCheck());

Atomic Operations ​

For truly atomic operations, use ScriptPatch:

csharp
// Atomic increment - no version conflict possible
await repository.PatchAsync(id, new ScriptPatch("ctx._source.counter++"));

// Atomic conditional update
await repository.PatchAsync(id, new ScriptPatch(@"
    if (ctx._source.status == 'pending') {
        ctx._source.status = 'approved';
    }
"));

Version in Find Results ​

When querying, versions are available in the hits:

csharp
var results = await repository.FindAsync(query);

foreach (var hit in results.Hits)
{
    Console.WriteLine($"ID: {hit.Id}, Version: {hit.Version}");
    Console.WriteLine($"Document Version: {hit.Document.Version}");
}

Common Patterns ​

Optimistic Locking Service ​

csharp
public class OptimisticLockingService<T> where T : class, IIdentity, IVersioned, new()
{
    private readonly IRepository<T> _repository;
    private readonly int _maxRetries;

    public OptimisticLockingService(IRepository<T> repository, int maxRetries = 3)
    {
        _repository = repository;
        _maxRetries = maxRetries;
    }

    public async Task<T> UpdateAsync(string id, Func<T, Task> updateAction)
    {
        int retries = _maxRetries;
        
        while (true)
        {
            var document = await _repository.GetByIdAsync(id);
            if (document == null)
                throw new DocumentNotFoundException(id);
            
            await updateAction(document);
            
            try
            {
                return await _repository.SaveAsync(document);
            }
            catch (VersionConflictDocumentException)
            {
                retries--;
                if (retries == 0)
                    throw;
                
                await Task.Delay(TimeSpan.FromMilliseconds(50 * (_maxRetries - retries)));
            }
        }
    }
}

// Usage
var service = new OptimisticLockingService<Employee>(repository);
var updated = await service.UpdateAsync(id, async emp =>
{
    emp.Name = "John Smith";
    emp.UpdatedBy = currentUserId;
});

Compare-and-Swap ​

csharp
public async Task<bool> CompareAndSwapAsync(string id, string expectedValue, string newValue)
{
    try
    {
        var employee = await repository.GetByIdAsync(id);
        if (employee == null || employee.Status != expectedValue)
            return false;
        
        employee.Status = newValue;
        await repository.SaveAsync(employee);
        return true;
    }
    catch (VersionConflictDocumentException)
    {
        return false;
    }
}

// Usage
bool success = await CompareAndSwapAsync(id, "pending", "approved");

Conditional Update ​

csharp
public async Task<(bool Success, T Document)> ConditionalUpdateAsync<T>(
    string id,
    Func<T, bool> condition,
    Action<T> update) where T : class, IIdentity, IVersioned, new()
{
    var document = await repository.GetByIdAsync(id);
    if (document == null)
        return (false, null);
    
    if (!condition(document))
        return (false, document);
    
    update(document);
    
    try
    {
        var saved = await repository.SaveAsync(document);
        return (true, saved);
    }
    catch (VersionConflictDocumentException)
    {
        return (false, document);
    }
}

// Usage
var (success, employee) = await ConditionalUpdateAsync<Employee>(
    id,
    emp => emp.Status == "pending",
    emp => emp.Status = "approved");

Bulk Operations and Partial Failures ​

When saving multiple versioned documents in a single call, some may succeed while others hit version conflicts. The repository handles this as a partial failure:

  1. Successful documents are fully processed — versions updated, events fired, cache populated.
  2. Conflicting documents have their cache entries left unchanged — the concurrent writer that caused the conflict handles its own cache update.
  3. A VersionConflictDocumentException is thrown after processing all successes.
csharp
try
{
    await repository.SaveAsync(employees);
}
catch (VersionConflictDocumentException ex)
{
    // Successful documents were saved and notified.
    // Conflicting documents need to be re-fetched and retried.
    _logger.LogWarning(ex, "Some documents had version conflicts");
}

WARNING

Version conflicts (HTTP 409) on AddAsync/SaveAsync are not automatically retried by the resilience policy. Transient errors (HTTP 429/503) are retried with exponential backoff, but conflict resolution is the caller's responsibility.

Best Practices ​

1. Always Handle Version Conflicts ​

csharp
try
{
    await repository.SaveAsync(employee);
}
catch (VersionConflictDocumentException)
{
    _logger.LogWarning("Version conflict for {Id}, retrying...", employee.Id);
    // Implement retry logic
}

2. Use Atomic Operations When Possible ​

csharp
// Prefer atomic script over read-modify-write
await repository.PatchAsync(id, new ScriptPatch("ctx._source.counter++"));

3. Keep Retry Counts Reasonable ​

csharp
// 3-5 retries is usually sufficient
const int MaxRetries = 3;

4. Use Exponential Backoff ​

csharp
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, attempt)));

5. Log Conflicts for Monitoring ​

csharp
catch (VersionConflictDocumentException)
{
    _metrics.IncrementCounter("version_conflicts", new { entity = typeof(T).Name });
    _logger.LogWarning("Version conflict for {Type}", typeof(T).Name);
}

Next Steps ​

Released under the Apache 2.0 License.