Two browser tabs send messages to the same conversation. Both load revision 12 and call the model. Turn A finishes first and saves its updated history. Turn B finishes later and overwrites the conversation with a history that does not include A.
Both model calls succeed. One turn is lost from storage.
Load the history and its revision together, then save only if the revision still matches. This applies the concurrency-token pattern to the conversation history used for generation.
Create an independent message snapshot for each turn before calling IChatClient. Concurrent requests should not share mutable message collections or message contents. Copying a list reference is not a snapshot. Keep the original revision with the snapshot and run generation outside any database transaction.
For a non-streaming chat endpoint, store the completed user message and assistant reply together. The example below assumes a relational database with one conversation row that contains HistoryJson and a numeric Revision.
In this Minimal API example, expectedRevision is taken from the history loaded before generation. updatedHistoryJson includes that history along with the new user message and assistant reply:
var affected = await db.Conversations
.Where(c => c.Id == conversationId
&& c.Revision == expectedRevision)
.ExecuteUpdateAsync(update => update
.SetProperty(c => c.HistoryJson, updatedHistoryJson)
.SetProperty(c => c.Revision, c => c.Revision + 1),
cancellationToken);
if (affected == 0)
{
return Results.Conflict(new
{
code = "conversation_changed",
message = "Reload the conversation before sending again."
});
}
The database checks the revision and updates the history in a single operation. If A saves revision 13, B’s attempt with expected revision 12 affects zero rows. Zero rows can also mean the conversation was deleted. This endpoint treats both situations as a conflict. An API that distinguishes deletion can check whether the conversation still exists.
ExecuteUpdateAsync runs immediately and does not automatically use configured concurrency tokens. The explicit revision condition and affected-row check provide concurrency protection in this example. A revision mismatch does not raise DbUpdateConcurrencyException, and the update does not require a later SaveChangesAsync.
Return the answer as successfully saved only after the update succeeds. On a conflict, this endpoint tells the caller to reload and resubmit. The rejected generation still consumes time and tokens, even though its answer was not saved.
Do not update expectedRevision to the latest value and save the same answer again. The model generated that answer without the intervening turn. If you choose automatic regeneration, reload the latest history and generate a new answer. Appending the old answer requires its own ordering policy.
Test this scenario with two snapshots of revision 12: save A, then attempt B. A should update one row, B should update zero rows, and storage should still contain A’s turn.
Every writer that modifies conversation history must perform the revision check and advance the revision. If messages are stored in separate rows, perform the conditional revision update and message inserts in a single short transaction after generation. Commit only if the revision update succeeds. The revision check cannot reverse external tool actions that have already run.