Skip to content

Chapter 13: Concurrency Control in Multi-User Chat

Theoretical Foundations

In the realm of multi-user chat applications, data integrity is not a luxury; it is the bedrock of trust. When two users edit a message simultaneously, or when a system processes a high volume of chat history for RAG (Retrieval-Augmented Generation) operations, the risk of race conditions—where the final state of data depends on the unpredictable timing of events—becomes acute. This subsection delves into the theoretical underpinnings of managing these concurrent accesses using Entity Framework Core, specifically focusing on Optimistic Concurrency Control.

The Illusion of Linearity in a Concurrent World

At its core, a chat application is a stream of state changes. User A sends a message, User B edits a message, and the backend ingests this data into a vector database for semantic search. In a single-threaded environment, these operations are linear: one follows the other. However, modern web applications are inherently asynchronous and parallel. A single HTTP request might trigger database reads, complex calculations (like vector embedding generation), and writes. If multiple requests attempt to modify the same resource simultaneously, chaos ensues.

Consider the Lost Update problem. User A loads a chat message to correct a typo. Simultaneously, User B loads the same message to add a comment. User A saves the correction. User B, unaware of A's change, saves the comment, overwriting A's correction entirely. In a traditional application, this is annoying. In an AI-driven application where the chat history is used as context for a Large Language Model (LLM), this is catastrophic. If the RAG pipeline retrieves a message that has been silently overwritten, the LLM might generate responses based on stale or incorrect context, breaking the illusion of a coherent conversation.

To prevent this, we employ Concurrency Control. There are two primary strategies: Pessimistic and Optimistic.

Pessimistic Concurrency: The "One Key at a Time" Approach

Pessimistic concurrency assumes that conflicts are inevitable and prevents them by locking resources. If User A wants to edit a message, the database places an exclusive lock on that row. No one else can read or write to it until User A commits or rolls back.

While this guarantees safety, it is disastrous for chat applications. It introduces blocking. If User A is slow to type, User B is stuck waiting. In a high-throughput system, this leads to deadlocks and severely degraded performance. More importantly, it scales poorly. With thousands of concurrent users, the database becomes a bottleneck of waiting locks. Therefore, for responsive, real-time AI applications, we turn to Optimistic Concurrency.

Optimistic Concurrency: Trust, but Verify

Optimistic concurrency operates on the philosophy of "optimism": it assumes that conflicts are rare. It allows multiple users to read data simultaneously without locking. However, at the moment of writing, it verifies that the data has not been modified by another user since it was read.

If the data matches what was expected, the update proceeds. If not, the update is rejected, and the system must resolve the conflict.

The Core Mechanism: The Concurrency Token

In EF Core, this verification is achieved using a concurrency token. This is a property in the entity that acts as a sentinel, changing its value every time the row is modified. The most common implementation is a rowversion (or timestamp) in SQL Server, or a byte[] property mapped to a database-specific versioning column.

When EF Core executes an UPDATE statement, it includes a WHERE clause that checks the current value of this token.

  1. Read: The application fetches the entity, including the current concurrency token.
  2. Modify: The application changes the entity's data (e.g., the text of a message).
  3. Write: The application calls SaveChanges().
  4. Verify: EF Core generates SQL similar to:

    UPDATE Messages SET Text = @p0, Version = @p1
    WHERE Id = @p2 AND Version = @p3
    
    Here, @p3 is the original version value read in step 1.

  5. Result:

    • If 1 row is affected, the update succeeded. The database has incremented the version.
    • If 0 rows are affected, it means another user modified the row (changing the version) before this update occurred. EF Core throws a DbUpdateConcurrencyException.

Analogy: The Shared Whiteboard with Dynamic Stickers

Imagine a team collaborating on a physical whiteboard.

  • Pessimistic: You put a padlock on the whiteboard. Only you can write on it. Others must wait. (Bad for collaboration).
  • Optimistic: You take a photo of the whiteboard (Reading the state). You write your note on a piece of paper (Modifying the state).
    • To stick your note on the board, you look at the board. If the board looks exactly like your photo (no one else added anything), you paste your note over the old spot.
    • If someone else added a note in the meantime (the board looks different), you cannot paste your note blindly. You must look at the new board, reconcile your changes, and try again.

In EF Core, the "photo" is the concurrency token (the version number). The "pasting" is the SQL WHERE clause.

Architectural Implications for AI and RAG

In the context of Book 6, where we integrate vector databases and RAG, concurrency control takes on a new dimension. It is not just about preventing data loss; it is about contextual consistency.

1. Vector Synchronization

When a chat message is updated, it often triggers a background process to update its vector embedding in a vector database (e.g., Pinecone, Milvus, or a local provider). This process is asynchronous and eventually consistent.

The Conflict Scenario:

  1. User A edits Message M (Concurrency control saves the text successfully).
  2. The system queues a job to re-embed Message M.
  3. User B edits Message M (Concurrency control saves the text successfully).
  4. The system queues a second job to re-embed Message M.
  5. The Risk: If the jobs execute out of order or overlap, the vector database might end up with an embedding that corresponds to an older version of the text, or worse, a version that was never persisted.

The Solution: We must treat the vector update as part of the transactional boundary or use Idempotency Keys derived from the concurrency token. If the concurrency token of the message in the relational database does not match the token associated with the vector embedding, the RAG retrieval system must discard that vector or trigger a re-index. This ensures the semantic search results align perfectly with the source of truth.

2. RAG Context Window Integrity

When an LLM is invoked with RAG, it receives a "context window" populated with chat history. If concurrency is not managed, this context window becomes a schizophrenic mix of states.

  • Scenario: User A views the chat history (reads version 1). User B updates a message (version becomes 2). The LLM generates a response based on User A's view (version 1). User A then sees the response, but the underlying message has changed (version 2).
  • Result: The AI's response references content that no longer exists or contradicts the current state.

Optimistic concurrency ensures that any read operation intended for AI processing is "fresh" or explicitly versioned. By enforcing concurrency checks on the retrieval of chat history used for RAG, we ensure the LLM always operates on a coherent, linear timeline of the conversation.

Handling the DbUpdateConcurrencyException

When the exception is thrown, we are in a conflict state. We must resolve it. There is no single "correct" way; the strategy depends on the business logic of the chat application.

Strategy 1: Client Wins (Overwrite)

The current user's changes are accepted, and the database values are overwritten. This is dangerous for chat messages but acceptable for metadata (e.g., "Is Read" status).

  • Logic: Catch the exception, reload the entity (getting the new values), apply the current user's changes again, and save.

Strategy 2: Database Wins (Discard)

The current user's changes are discarded, and the user is shown the latest state from the database. This is safe but frustrating for users who feel their work is lost.

Strategy 3: Merge / Prompt User (The "Git Conflict" Approach)

This is the gold standard for chat applications. When a conflict is detected, the system presents the user with the current database state and their attempted changes, allowing them to merge.

  • Analogy: Like a Git merge conflict.
  • Implementation: The exception handler captures the DbUpdateConcurrencyException. From the exception's Entries property, we can access:
    • CurrentValue: What the user tried to save.
    • OriginalValue: What the user thought they were editing.
    • DatabaseValues: What is actually in the database now.
  • We can construct a UI showing these three states and ask the user to choose or combine them.

Transactional Consistency in Chat History

While Optimistic Concurrency handles row-level conflicts, Transactions handle atomicity across multiple operations. In a chat application, sending a message often involves multiple writes:

  1. Insert the message into the Messages table.
  2. Update the Conversation table (e.g., LastMessageAt timestamp).
  3. Update the User table (e.g., LastActive).
  4. Queue a vector embedding job.

If step 1 succeeds but step 2 fails, the chat history is inconsistent. We use EF Core's DbContext.Database.BeginTransactionAsync() to wrap these operations in an atomic unit.

// Conceptual Transaction Flow
using var transaction = await context.Database.BeginTransactionAsync();
try
{
    context.Messages.Add(newMessage);
    await context.SaveChangesAsync(); // Implicit check for concurrency on existing rows if updated

    conversation.LastMessageAt = DateTime.UtcNow;
    await context.SaveChangesAsync();

    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

In the context of AI, if the vector embedding generation is synchronous (which is rare and discouraged due to latency), it must happen inside the transaction to ensure the vector is available immediately. However, in modern architectures, we use Outbox Patterns or Transactional Outboxes. We save the message and a "pending embedding" record in the same transaction. A separate worker process polls this table and performs the vectorization. This ensures that even if the app crashes after the commit, the vectorization job is guaranteed to be picked up.

Visualizing the Concurrency Flow

The following diagram illustrates the optimistic concurrency workflow in a multi-user chat scenario, highlighting the decision points where conflicts are detected and resolved.

This diagram illustrates the optimistic concurrency workflow in a multi-user chat scenario, highlighting the decision points where conflicts are detected and resolved.
Hold "Ctrl" to enable pan & zoom

This diagram illustrates the optimistic concurrency workflow in a multi-user chat scenario, highlighting the decision points where conflicts are detected and resolved.

Deep Dive: The ConcurrencyCheck Attribute

While [Timestamp] is the most common way to define a concurrency token (automatically configuring it as a rowversion), EF Core allows granular control via the [ConcurrencyCheck] attribute. This can be applied to any property, such as a Version integer or a LastModified datetime.

In AI applications, where we might track the "sentiment" or "topic" of a message, we might want to prevent concurrent changes to these derived properties. By applying [ConcurrencyCheck], we ensure that if User A updates the sentiment analysis while User B edits the text, the system detects the conflict.

However, using mutable properties (like integers or dates) as tokens is riskier than using database-managed rowversion columns, as they can be manipulated by application logic. rowversion is strictly managed by the database engine, incrementing automatically on any update, making it the robust choice for chat systems.

Edge Cases and Nuances

  1. Deleted Resources: What happens if User A edits a message that User B deletes?

    • The UPDATE statement will affect 0 rows because the row no longer exists (or is soft-deleted).
    • EF Core throws DbUpdateConcurrencyException.
    • The application must distinguish between "deleted" and "conflicted." Checking the database state after the exception reveals if the row is gone.
  2. Batch Updates: When updating multiple messages in a batch (e.g., marking a whole thread as read), concurrency checking applies to every row in the batch. If even one row has changed, the entire batch operation fails.

    • Strategy: For batch operations, it is often better to use a "last-writer-wins" strategy or perform the batch update on a specific subset of data that is less likely to be modified concurrently (e.g., updating a ReadStatus flag rather than the message content).
  3. Scalability and Vector Databases: Vector databases often have eventual consistency models. If we rely on the relational database for concurrency control and the vector database for search, there is a window of inconsistency.

    • Mitigation: Use a Saga Pattern or Process Manager. The state of the message (pending, synced, conflict) should be tracked. The RAG retrieval logic should query the state. If the state is "conflict" or "pending sync," the RAG system should exclude that message from the context window to prevent hallucinations based on stale data.

Theoretical Foundations

Optimistic concurrency in EF Core is not merely a technical checkbox; it is a design philosophy that prioritizes responsiveness and scalability while safeguarding data integrity. By leveraging concurrency tokens, we transform the database from a passive store into an active guardian against race conditions.

In the specific context of AI-powered chat applications, this mechanism is vital. It ensures that the data feeding into RAG pipelines is consistent, preventing the "poisoning" of context windows with conflicting or overwritten information. It allows the application to handle high concurrency without the bottlenecks of locking, ensuring that the AI assistant remains responsive even under heavy load. The resolution strategies (Client Wins, Database Wins, Merge) provide the flexibility to tailor the user experience to the specific needs of the chat domain, balancing user intent with system consistency.

Basic Code Example

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

// The Problem Context:
// In a multi-user chat application, imagine two users editing the same message simultaneously.
// User A clicks "Edit" on a message, and User B clicks "Edit" on the same message at the exact same time.
// Without concurrency control, User B's changes might overwrite User A's changes (the "Lost Update" problem).
// This example demonstrates how to use EF Core's optimistic concurrency to prevent this data corruption.

namespace ConcurrencyChatExample
{
    // 1. Define the Message Entity
    // We use a 'record' for immutability and concise syntax (modern C# feature).
    // The [ConcurrencyCheck] attribute or the 'rowversion' property tells EF Core to track changes.
    public class ChatMessage
    {
        public int Id { get; set; }
        public string User { get; set; } = string.Empty;
        public string Content { get; set; } = string.Empty;

        // CRITICAL: This is the concurrency token.
        // In SQL Server, this maps to a 'rowversion' (timestamp) column that automatically updates on any write.
        // EF Core compares this value during SaveChanges(). If it differs, a concurrency exception is thrown.
        public byte[] RowVersion { get; set; } = Array.Empty<byte>();
    }

    // 2. Define the DbContext
    public class ChatContext : DbContext
    {
        public DbSet<ChatMessage> Messages { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            // Using SQLite for a self-contained, portable example.
            // In a real app, this would be a connection string to SQL Server or PostgreSQL.
            optionsBuilder.UseSqlite("Data Source=chat.db");
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Explicitly configure the RowVersion as a concurrency token.
            // This ensures EF Core checks this value against the database during updates.
            modelBuilder.Entity<ChatMessage>()
                .Property(m => m.RowVersion)
                .IsRowVersion(); 
        }
    }

    class Program
    {
        static async Task Main(string[] args)
        {
            // Ensure a clean database for the demo
            using var context = new ChatContext();
            await context.Database.EnsureDeletedAsync();
            await context.Database.EnsureCreatedAsync();

            Console.WriteLine("=== Concurrency Control Demo ===\n");

            // SCENARIO: Two users (or threads) try to update the same message.

            // 1. User A fetches the message to edit.
            int messageId = await CreateInitialMessageAsync();

            // We simulate a "Race Condition" by creating two separate contexts (User A and User B).
            // In a real web app, this represents two separate HTTP requests.

            // --- User A's Session ---
            using (var userAContext = new ChatContext())
            {
                var messageForA = await userAContext.Messages.FindAsync(messageId);
                Console.WriteLine($"User A read: '{messageForA.Content}' (RowVersion: {BitConverter.ToString(messageForA.RowVersion)})");

                // User A modifies the content locally (in memory).
                messageForA.Content = "User A's corrected version";

                // Delay to simulate network latency or user thinking time.
                // During this delay, User B acts...
                await Task.Delay(100); 
            }

            // --- User B's Session (Interleaved) ---
            using (var userBContext = new ChatContext())
            {
                // User B fetches the ORIGINAL message (because User A hasn't saved yet).
                var messageForB = await userBContext.Messages.FindAsync(messageId);
                Console.WriteLine($"User B read: '{messageForB.Content}' (RowVersion: {BitConverter.ToString(messageForB.RowVersion)})");

                // User B modifies the content.
                messageForB.Content = "User B's conflicting version";

                // User B saves FIRST.
                // EF Core sends the UPDATE command including the original RowVersion in the WHERE clause.
                // Since the DB hasn't changed yet, this succeeds.
                await userBContext.SaveChangesAsync();
                Console.WriteLine("User B saved successfully. Database now contains 'User B's conflicting version'.");
            }

            // --- User A Tries to Save (The Conflict) ---
            using (var userAContext = new ChatContext())
            {
                // We need to re-attach the entity User A was working on.
                // In a real app, User A's context might still be alive, or we re-fetch.
                // Here, we simulate re-attaching the disconnected entity.
                var messageForA = new ChatMessage 
                { 
                    Id = messageId, 
                    Content = "User A's corrected version",
                    // IMPORTANT: We must preserve the ORIGINAL RowVersion User A saw.
                    // If we fetch fresh now, we'd see User B's changes. 
                    // To trigger the conflict, we use the version User A originally loaded.
                    RowVersion = context.Messages.Find(messageId).RowVersion 
                };

                userAContext.Messages.Attach(messageForA);
                userAContext.Entry(messageForA).Property(m => m.Content).IsModified = true;

                try
                {
                    // EF Core tries to update: UPDATE Messages SET Content = ... WHERE Id = ... AND RowVersion = [Original A's Version]
                    // But the DB now has User B's NEWER RowVersion.
                    // The WHERE clause fails (0 rows affected).
                    await userAContext.SaveChangesAsync();
                    Console.WriteLine("User A saved successfully (Unexpected).");
                }
                catch (DbUpdateConcurrencyException ex)
                {
                    Console.WriteLine("\n!!! CONCURRENCY EXCEPTION CAUGHT !!!");
                    Console.WriteLine($"Error: {ex.Message}");

                    // RESOLUTION STRATEGY: Fetch current values from DB to resolve the conflict.
                    var entry = ex.Entries.Single();
                    var currentValues = await entry.GetDatabaseValuesAsync();

                    Console.WriteLine("\n--- Conflict Resolution ---");
                    Console.WriteLine($"Database Current Content: {currentValues.GetValue<string>("Content")}");
                    Console.WriteLine($"User A Attempted Content: {entry.Property("Content").CurrentValue}");

                    // Example Resolution: Merge or Notify User.
                    // Here, we simply accept the database value and append a note.
                    var resolvedMessage = (ChatMessage)currentValues.ToObject();
                    resolvedMessage.Content += " [Merged with User A's input]";

                    // Update the context with the resolved values and retry.
                    entry.CurrentValues.SetValues(resolvedMessage);

                    // Optional: Force save if business logic dictates.
                    // await userAContext.SaveChangesAsync(); 
                    Console.WriteLine($"Resolved Content: {resolvedMessage.Content}");
                }
            }
        }

        static async Task<int> CreateInitialMessageAsync()
        {
            using var context = new ChatContext();
            var msg = new ChatMessage { User = "System", Content = "Original Message" };
            context.Messages.Add(msg);
            await context.SaveChangesAsync();
            return msg.Id;
        }
    }
}

Detailed Line-by-Line Explanation

1. Entity Definition (ChatMessage)

public class ChatMessage
{
    public int Id { get; set; }
    public string User { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}
  • Id: The primary key. Unique identifier for the chat message.
  • Content: The text of the message. This is the property we expect multiple users to edit concurrently.
  • RowVersion: This is the heart of optimistic concurrency.
    • Type: byte[] (byte array).
    • Behavior: In SQL Server/SQLite, this maps to a special column type (rowversion or timestamp). The database automatically updates this value to a unique binary number every time the row is modified.
    • Optimistic Concurrency: Unlike pessimistic locking (which locks the row so no one else can read it), optimistic concurrency assumes conflicts are rare. It allows everyone to read and edit, but checks at the moment of saving if the data was changed by someone else.

2. DbContext Configuration (ChatContext)

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<ChatMessage>()
        .Property(m => m.RowVersion)
        .IsRowVersion();
}
  • IsRowVersion(): This fluent API configuration is crucial. It tells Entity Framework:
    1. Treat this property as a database-generated value.
    2. Include this property in the WHERE clause of UPDATE and DELETE statements.
    3. If the database returns 0 rows affected (because the RowVersion in the DB doesn't match the one loaded into the application), throw a DbUpdateConcurrencyException.

3. The Simulation (Main Method)

The code simulates a race condition without needing two actual human users running the app simultaneously. We use two distinct DbContext instances (userAContext and userBContext) to represent two separate HTTP requests or threads.

Step A: User A Reads

var messageForA = await userAContext.Messages.FindAsync(messageId);
// ... modify content ...
// ... delay ...

  • EF Core executes SELECT * FROM Messages WHERE Id = @p0.
  • The RowVersion (e.g., 0x00000000000007D3) is loaded into memory.
  • User A modifies Content in memory. The RowVersion in memory remains unchanged.

Step B: User B Reads and Writes (The Interleaving)

var messageForB = await userBContext.Messages.FindAsync(messageId);
messageForB.Content = "User B's conflicting version";
await userBContext.SaveChangesAsync();

  • User B reads the current state of the DB (which is still the original state).
  • User B modifies the content.
  • SaveChangesAsync:
    • EF Core generates SQL: UPDATE Messages SET Content = 'User B...' WHERE Id = 1 AND RowVersion = 0x00000000000007D3.
    • The database executes this. Since the RowVersion matches, the update succeeds.
    • Database Side: The database automatically increments the RowVersion to a new value (e.g., 0x00000000000007D4).
    • User B's context updates its local RowVersion to match the DB.

Step C: User A Tries to Save (The Conflict)

try
{
    await userAContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
    // Handle the conflict
}

  • User A's context attempts to save.
  • EF Core generates SQL: UPDATE Messages SET Content = 'User A...' WHERE Id = 1 AND RowVersion = 0x00000000000007D3.
  • The Failure: The database looks for a row with RowVersion = 0x00000000000007D3. It finds 0x00000000000007D4 (User B's update).
  • The WHERE clause matches 0 rows.
  • The database reports "0 rows affected".
  • EF Core detects this mismatch and throws DbUpdateConcurrencyException.

4. Conflict Resolution Strategy

The catch block demonstrates how to handle the exception gracefully.

var entry = ex.Entries.Single();
var currentValues = await entry.GetDatabaseValuesAsync();

  • ex.Entries: Contains the entity that caused the failure (User A's version).
  • GetDatabaseValuesAsync(): Fetches the actual current state from the database (User B's version).
  • Resolution Logic:
    • Client Wins: Overwrite the DB with User A's data (risky, deletes User B's work).
    • Database Wins: Discard User A's changes and accept the DB state (safe, but User A loses work).
    • Merge: (Implemented in the example) Combine the data. Here, we appended a note to User A's content using the current DB values.

Common Pitfalls

  1. Disconnected Entities in Web Apps:

    • The Mistake: In a standard stateless Web API, the DbContext is usually disposed after every request. When a user submits an edit, you often have to reconstruct the entity from the incoming DTO (Data Transfer Object). If you don't include the original RowVersion in the DTO, EF Core cannot perform the concurrency check.
    • The Fix: Always include the RowVersion in your API payloads (e.g., as a hidden field in a form or a property in a JSON request). When updating, attach the entity with the original RowVersion and mark only the changed fields as modified.
  2. Missing IsRowVersion() Configuration:

    • The Mistake: Adding a byte[] property to the entity but forgetting to configure it in OnModelCreating (or via attributes).
    • The Result: EF Core will treat it as a regular byte array, not a concurrency token. No exception will be thrown during conflicts, leading to silent data overwrites.
  3. Using [ConcurrencyCheck] on Non-Timestamp Properties:

    • The Mistake: Applying the [ConcurrencyCheck] attribute to a standard property (like Content or LastModified).
    • The Result: EF Core will include that specific column in the WHERE clause. While this works, it is less efficient than using a database-managed rowversion (which is a tiny binary footprint and automatically handles the versioning logic). It also requires you to manually update the timestamp property on every change.
  4. Handling DbUpdateConcurrencyException Incorrectly:

    • The Mistake: Catching the exception and simply calling SaveChanges() again without resolving the underlying data mismatch.
    • The Result: The second SaveChanges() call will likely fail immediately with the same exception because the database state hasn't changed to match the application state.
    • The Fix: Always inspect ex.Entries, retrieve the current database values (GetDatabaseValuesAsync), and decide on a merge strategy before attempting to save again.

Visualizing the Race Condition

A diagram would show two concurrent user threads attempting to update the same database record, where one thread's overwrite causes the other's changes to be lost, illustrating the need for conflict resolution strategies like inspecting ex.Entries and merging data.
Hold "Ctrl" to enable pan & zoom

A diagram would show two concurrent user threads attempting to update the same database record, where one thread's overwrite causes the other's changes to be lost, illustrating the need for conflict resolution strategies like inspecting `ex.Entries` and merging data.

The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon


Loading knowledge check...



Code License: All code examples are released under the MIT License. Github repo.

Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.

All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.