Handle Concurrency Conflicts
Deal with multiple users modifying the same data simultaneously
Event sourcing shines when multiple users try to modify the same data. Here’s how to handle it properly.
The problem
Section titled “The problem”Two users load the same todo. Both try to update it. Without proper handling, one update overwrites the other.
The solution: Expected stream version
Section titled “The solution: Expected stream version”DeltaBase uses optimistic concurrency control. When appending events, specify the expected version:
try { await eventStore.appendToStream('todo-123', [updateEvent], { expectedStreamVersion: 5 // Fail if stream has moved past version 5 });} catch (error) { if (error.status === 409) { // Conflict! Handle it console.log('Someone else modified this todo'); }}
Conflict resolution strategies
Section titled “Conflict resolution strategies”Strategy 1: Last writer wins
Section titled “Strategy 1: Last writer wins”Simple but data loss-prone:
async function updateTodoLastWriterWins(id: string, updates: Partial<Todo>) { const event = { type: 'todo.updated', data: { id, ...updates } };
// No expected version - always wins await eventStore.appendToStream(id, [event]);}
Strategy 2: Fail fast
Section titled “Strategy 2: Fail fast”Let the user know there’s a conflict:
async function updateTodoFailFast(id: string, updates: Partial<Todo>, expectedVersion: number) { const event = { type: 'todo.updated', data: { id, ...updates } };
try { await eventStore.appendToStream(id, [event], { expectedStreamVersion }); return { success: true }; } catch (error) { if (error.status === 409) { return { success: false, error: 'Todo was modified by someone else. Please refresh and try again.' }; } throw error; }}
Strategy 3: Auto-merge
Section titled “Strategy 3: Auto-merge”Detect conflicts and merge automatically:
async function updateTodoAutoMerge(id: string, updates: Partial<Todo>, expectedVersion: number) { const event = { type: 'todo.updated', data: { id, ...updates } };
try { await eventStore.appendToStream(id, [event], { expectedStreamVersion }); return { success: true }; } catch (error) { if (error.status === 409) { // Get current state const events = await eventStore.readStream(id); const currentTodo = buildTodoFromEvents(events);
// Check if our changes conflict const hasConflict = checkForConflicts(currentTodo, updates);
if (!hasConflict) { // Safe to retry with latest version const latestVersion = events.length - 1; await eventStore.appendToStream(id, [event], { expectedStreamVersion: latestVersion }); return { success: true, merged: true }; }
return { success: false, error: 'Conflicting changes detected', currentState: currentTodo }; } throw error; }}
function checkForConflicts(currentTodo: Todo, updates: Partial<Todo>): boolean { // Example: Title changes conflict, but description changes don't if (updates.title && currentTodo.title !== updates.title) { return true; } return false;}
Strategy 4: Three-way merge
Section titled “Strategy 4: Three-way merge”Most sophisticated - merge based on original state:
async function updateTodoThreeWayMerge( id: string, updates: Partial<Todo>, originalState: Todo) { // Get current state const events = await eventStore.readStream(id); const currentState = buildTodoFromEvents(events);
// Perform three-way merge const mergedUpdates = threeWayMerge(originalState, currentState, updates);
if (mergedUpdates.conflicts.length > 0) { return { success: false, conflicts: mergedUpdates.conflicts, currentState }; }
// Apply merged changes const event = { type: 'todo.updated', data: { id, ...mergedUpdates.changes } };
const latestVersion = events.length - 1; await eventStore.appendToStream(id, [event], { expectedStreamVersion: latestVersion });
return { success: true };}
function threeWayMerge(original: Todo, current: Todo, updates: Partial<Todo>) { const changes: Partial<Todo> = {}; const conflicts: string[] = [];
// Check each field for (const [key, value] of Object.entries(updates)) { const originalValue = original[key]; const currentValue = current[key];
if (originalValue === currentValue) { // No conflict - current hasn't changed changes[key] = value; } else if (currentValue === value) { // No conflict - both changed to same value changes[key] = value; } else { // Conflict - both changed to different values conflicts.push(key); } }
return { changes, conflicts };}
Frontend handling
Section titled “Frontend handling”In your web app, track versions:
// When loading dataconst todo = await api.getTodo('todo-123');const version = todo._version; // Include version in your API response
// When updatingconst result = await api.updateTodo('todo-123', { title: 'New title'}, version);
if (!result.success) { // Show conflict resolution UI showConflictDialog(result.error, result.currentState);}
Best practices
Section titled “Best practices”- Use expected versions for all mutations - Don’t let silent overwrites happen
- Show versions to users - Let them know when data is stale
- Implement refresh workflows - Easy way for users to get latest data
- Choose appropriate strategy - Consider your domain and user expectations
- Test conflict scenarios - Simulate concurrent updates in your tests
Remember: In event sourcing, conflicts are features, not bugs. They help you maintain data integrity.