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:
- Check that the version in the database matches the entity's version
- Increment the version on successful save
- Return
ErrVersionMismatchif 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):
- Version starts at 0 in the struct
- After successful save, version becomes 1
- The condition allows the save if either:
- The item doesn't exist (new entity)
- The version matches (update)
Existing Entities¶
When updating an existing entity:
- Current version is checked against database
- If versions match, save succeeds and version increments
- If versions don't match,
ErrVersionMismatchis 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:
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:
- Request coalescing: Batch similar updates together
- Atomic counters: Use
UpdateItemwith ADD for counters - 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¶
- Query Caching - Reduce read costs with caching
- Transactions - Atomic multi-item operations
- Batch Operations - Efficient bulk operations