Skip to content

Optimistic Locking

Optimistic locking prevents concurrent updates from overwriting each other by using version numbers. When two processes try to update the same entity simultaneously, only one succeeds while the other receives a version mismatch error.

Overview

sequenceDiagram
    participant P1 as Process 1
    participant P2 as Process 2
    participant DB as DynamoDB

    P1->>DB: Read User (version=1)
    P2->>DB: Read User (version=1)
    P1->>DB: Update User (version=1→2)
    DB-->>P1: Success
    P2->>DB: Update User (version=1→2)
    DB-->>P2: ConditionalCheckFailed
    P2->>P2: Handle version mismatch

Without optimistic locking, the second update would silently overwrite the first, causing a lost update. With optimistic locking, the second update fails, allowing the application to handle the conflict appropriately.

Quick Start

Using the Versioned Trait

The simplest way to enable optimistic locking is to embed dynorm.Versioned in your entity:

type User struct {
    dynorm.Entity
    dynorm.Versioned  // Adds Version field

    Email string
    Name  string
}

type UserRepository struct {
    dynorm.Repository[User]
}

var UserRepo = &UserRepository{}

Now all Save() operations automatically:

  1. Check that the version in the database matches the entity's version
  2. Increment the version on successful save
  3. Return ErrVersionMismatch if versions don't match

Basic Usage

// Create a new user (version starts at 0)
user := &User{
    Email: "john@example.com",
    Name:  "John Doe",
}
err := UserRepo.Save(user)
// user.Version is now 1

// Later, another process reads the same user
user2, _ := UserRepo.Find(user.ID)
// user2.Version is 1

// First process updates
user.Name = "John Smith"
err = UserRepo.Save(user)
// user.Version is now 2

// Second process tries to update (will fail!)
user2.Name = "Johnny Doe"
err = UserRepo.Save(user2)
if errors.Is(err, dynorm.ErrVersionMismatch) {
    // Handle the conflict
    fmt.Println("Someone else updated this user!")
}

Handling Version Conflicts

Check for Version Mismatch

import (
    "errors"
    "github.com/go-gamma/dynorm"
    dynormerrors "github.com/go-gamma/dynorm/pkg/errors"
)

err := UserRepo.Save(user)
if err != nil {
    if dynormerrors.IsVersionMismatch(err) {
        // Version conflict - handle appropriately
        handleConflict(user)
        return
    }
    // Other error
    return err
}

Retry Pattern

A common pattern is to retry the operation after refreshing the entity:

func UpdateUserWithRetry(id string, updateFn func(*User)) error {
    maxRetries := 3

    for i := 0; i < maxRetries; i++ {
        // Fetch fresh copy
        user, err := UserRepo.Find(id)
        if err != nil {
            return err
        }

        // Apply updates
        updateFn(user)

        // Try to save
        err = UserRepo.Save(user)
        if err == nil {
            return nil // Success!
        }

        if !dynormerrors.IsVersionMismatch(err) {
            return err // Different error, don't retry
        }

        // Version mismatch - retry with fresh data
        log.Printf("Version conflict on attempt %d, retrying...", i+1)
    }

    return fmt.Errorf("failed after %d retries", maxRetries)
}

// Usage
err := UpdateUserWithRetry(userID, func(u *User) {
    u.LoginCount++
    u.LastLoginAt = time.Now()
})

Merge Conflicts

For more complex scenarios, you may want to merge changes:

func UpdateUserWithMerge(id string, changes map[string]any) error {
    for {
        user, err := UserRepo.Find(id)
        if err != nil {
            return err
        }

        // Apply changes
        if email, ok := changes["email"].(string); ok {
            user.Email = email
        }
        if name, ok := changes["name"].(string); ok {
            user.Name = name
        }

        err = UserRepo.Save(user)
        if err == nil {
            return nil
        }

        if !dynormerrors.IsVersionMismatch(err) {
            return err
        }

        // Conflict - loop will retry with fresh data
    }
}

Custom Version Field

Instead of using the Versioned trait, you can define your own version field:

type Document struct {
    dynorm.Entity

    Title    string
    Content  string
    Revision int64 `dynorm:"version"`  // Custom version field
}

The dynorm:"version" tag tells Dynorm to use this field for optimistic locking.

Force Save (Bypass Version Check)

Sometimes you need to force an update regardless of the version. Use ForceSave():

// Normal save - checks version
err := UserRepo.Save(user)

// Force save - bypasses version check
err := UserRepo.ForceSave(user)

Use with Caution

ForceSave() bypasses optimistic locking protections. Only use it when you intentionally want to overwrite any concurrent changes, such as:

  • Administrative overrides
  • Data migrations
  • Recovery operations

With Context

Both save methods have context-aware variants:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// With version check
err := UserRepo.SaveWithContext(ctx, user)

// Without version check
err := UserRepo.ForceSaveWithContext(ctx, user)

Version Field Behavior

New Entities

When saving a new entity (no existing version in database):

  1. Version starts at 0 in the struct
  2. After successful save, version becomes 1
  3. The condition allows the save if either:
  4. The item doesn't exist (new entity)
  5. The version matches (update)

Existing Entities

When updating an existing entity:

  1. Current version is checked against database
  2. If versions match, save succeeds and version increments
  3. If versions don't match, ErrVersionMismatch is returned

Version Increment

The version is always incremented on successful save:

user := &User{Name: "John"}  // Version: 0
UserRepo.Save(user)          // Version: 1
UserRepo.Save(user)          // Version: 2
UserRepo.Save(user)          // Version: 3

Accessing Version

The Versioned trait provides helper methods:

type User struct {
    dynorm.Entity
    dynorm.Versioned
    // ...
}

user := &User{}
UserRepo.Save(user)

// Get current version
v := user.GetVersion()  // 1

// Set version (rarely needed)
user.SetVersion(5)

// Increment version (rarely needed)
user.IncrementVersion()  // Now 6

Error Details

The version mismatch error includes helpful details:

err := UserRepo.Save(user)
if err != nil {
    var dynErr *dynormerrors.Error
    if errors.As(err, &dynErr) && dynErr.Kind == dynormerrors.KindVersionMismatch {
        fmt.Printf("Operation: %s\n", dynErr.Operation)
        fmt.Printf("Entity: %s\n", dynErr.Entity)
        fmt.Printf("Key: %s\n", dynErr.Key)
        fmt.Printf("Message: %s\n", dynErr.Message)
    }
}

Use Cases

Counter Updates

type Counter struct {
    dynorm.Entity
    dynorm.Versioned
    Value int64
}

func IncrementCounter(id string) error {
    return UpdateWithRetry(id, func(c *Counter) {
        c.Value++
    })
}

Inventory Management

type Product struct {
    dynorm.Entity
    dynorm.Versioned
    Name     string
    Quantity int
}

func DecrementStock(productID string, amount int) error {
    for {
        product, err := ProductRepo.Find(productID)
        if err != nil {
            return err
        }

        if product.Quantity < amount {
            return errors.New("insufficient stock")
        }

        product.Quantity -= amount

        err = ProductRepo.Save(product)
        if err == nil {
            return nil
        }

        if !dynormerrors.IsVersionMismatch(err) {
            return err
        }
        // Retry with fresh stock count
    }
}

Financial Transactions

type Account struct {
    dynorm.Entity
    dynorm.Versioned
    Balance float64
}

func Transfer(fromID, toID string, amount float64) error {
    // This is simplified - real transfers need transactions
    for {
        from, _ := AccountRepo.Find(fromID)
        to, _ := AccountRepo.Find(toID)

        if from.Balance < amount {
            return errors.New("insufficient funds")
        }

        from.Balance -= amount
        to.Balance += amount

        // Save both - if either fails, retry
        if err := AccountRepo.Save(from); err != nil {
            if dynormerrors.IsVersionMismatch(err) {
                continue
            }
            return err
        }

        if err := AccountRepo.Save(to); err != nil {
            if dynormerrors.IsVersionMismatch(err) {
                // Need to handle partial update!
                continue
            }
            return err
        }

        return nil
    }
}

Use Transactions

For operations that modify multiple entities, consider using DynamoDB Transactions instead of or in addition to optimistic locking.

Best Practices

Do

  • Use optimistic locking for entities that may be updated concurrently
  • Implement retry logic with reasonable limits
  • Log version conflicts for monitoring
  • Use ForceSave() sparingly and intentionally

Don't

  • Ignore version mismatch errors
  • Retry indefinitely without backoff
  • Use ForceSave() as a default
  • Assume saves always succeed

Monitoring

Track version mismatch errors in your metrics. A high rate of conflicts may indicate:

  • Hot spots in your data
  • Need for request coalescing
  • Opportunity to redesign data access patterns

Performance Considerations

Optimistic locking adds a condition expression to each PutItem operation:

ConditionExpression: attribute_not_exists(version) OR version = :v

This has minimal overhead but:

  • Increases the request size slightly
  • May cause retries under high contention
  • Works best when conflicts are rare

If you experience frequent conflicts, consider:

  1. Request coalescing: Batch similar updates together
  2. Atomic counters: Use UpdateItem with ADD for counters
  3. Sharding: Distribute hot items across multiple records

API Reference

Versioned Trait

Method Description
GetVersion() Returns the current version
SetVersion(v int64) Sets the version
IncrementVersion() Increments the version by 1

Repository Methods

Method Description
Save(entity) Save with version check
SaveWithContext(ctx, entity) Save with context and version check
ForceSave(entity) Save without version check
ForceSaveWithContext(ctx, entity) Save with context, without version check

Error Functions

Function Description
errors.IsVersionMismatch(err) Check if error is version mismatch
errors.VersionMismatch(op, entity, key, expected, actual) Create version mismatch error

Next Steps