Prevent race conditions in chat storage with semaphores

This commit is contained in:
Thorsten Sommer 2026-01-18 19:10:44 +01:00
parent ff01ed6a3c
commit aabbb5c2c7
Signed by: tsommer
GPG Key ID: 371BBA77A02C0108
2 changed files with 69 additions and 24 deletions

View File

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
@ -13,6 +14,19 @@ public static class WorkspaceBehaviour
{ {
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour)); private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(WorkspaceBehaviour).Namespace, nameof(WorkspaceBehaviour));
/// <summary>
/// Semaphores for synchronizing chat storage operations per chat.
/// This prevents race conditions when multiple threads try to write
/// the same chat file simultaneously.
/// </summary>
private static readonly ConcurrentDictionary<string, SemaphoreSlim> CHAT_STORAGE_SEMAPHORES = new();
private static SemaphoreSlim GetChatSemaphore(Guid workspaceId, Guid chatId)
{
var key = $"{workspaceId}_{chatId}";
return CHAT_STORAGE_SEMAPHORES.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
}
public static readonly JsonSerializerOptions JSON_OPTIONS = new() public static readonly JsonSerializerOptions JSON_OPTIONS = new()
{ {
WriteIndented = true, WriteIndented = true,
@ -37,35 +51,50 @@ public static class WorkspaceBehaviour
public static async Task StoreChat(ChatThread chat) public static async Task StoreChat(ChatThread chat)
{ {
string chatDirectory; // Acquire the semaphore for this specific chat to prevent concurrent writes to the same file:
if (chat.WorkspaceId == Guid.Empty) var semaphore = GetChatSemaphore(chat.WorkspaceId, chat.ChatId);
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString()); await semaphore.WaitAsync();
else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
// Ensure the directory exists: try
Directory.CreateDirectory(chatDirectory); {
string chatDirectory;
if (chat.WorkspaceId == Guid.Empty)
chatDirectory = Path.Join(SettingsManager.DataDirectory, "tempChats", chat.ChatId.ToString());
else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
// Save the chat name: // Ensure the directory exists:
var chatNamePath = Path.Join(chatDirectory, "name"); Directory.CreateDirectory(chatDirectory);
await File.WriteAllTextAsync(chatNamePath, chat.Name);
// Save the thread as thread.json: // Save the chat name:
var chatPath = Path.Join(chatDirectory, "thread.json"); var chatNamePath = Path.Join(chatDirectory, "name");
await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8); await File.WriteAllTextAsync(chatNamePath, chat.Name);
// Save the thread as thread.json:
var chatPath = Path.Join(chatDirectory, "thread.json");
await File.WriteAllTextAsync(chatPath, JsonSerializer.Serialize(chat, JSON_OPTIONS), Encoding.UTF8);
}
finally
{
semaphore.Release();
}
} }
public static async Task<ChatThread?> LoadChat(LoadChat loadChat) public static async Task<ChatThread?> LoadChat(LoadChat loadChat)
{ {
var chatPath = loadChat.WorkspaceId == Guid.Empty // Acquire the semaphore for this specific chat to prevent concurrent read/writes to the same file:
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString()) var semaphore = GetChatSemaphore(loadChat.WorkspaceId, loadChat.ChatId);
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString()); await semaphore.WaitAsync();
if(!Directory.Exists(chatPath))
return null;
try try
{ {
var chatPath = loadChat.WorkspaceId == Guid.Empty
? Path.Join(SettingsManager.DataDirectory, "tempChats", loadChat.ChatId.ToString())
: Path.Join(SettingsManager.DataDirectory, "workspaces", loadChat.WorkspaceId.ToString(), loadChat.ChatId.ToString());
if(!Directory.Exists(chatPath))
return null;
var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8); var chatData = await File.ReadAllTextAsync(Path.Join(chatPath, "thread.json"), Encoding.UTF8);
var chat = JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS); var chat = JsonSerializer.Deserialize<ChatThread>(chatData, JSON_OPTIONS);
return chat; return chat;
@ -74,6 +103,10 @@ public static class WorkspaceBehaviour
{ {
return null; return null;
} }
finally
{
semaphore.Release();
}
} }
public static async Task<string> LoadWorkspaceName(Guid workspaceId) public static async Task<string> LoadWorkspaceName(Guid workspaceId)
@ -144,7 +177,18 @@ public static class WorkspaceBehaviour
else else
chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString()); chatDirectory = Path.Join(SettingsManager.DataDirectory, "workspaces", chat.WorkspaceId.ToString(), chat.ChatId.ToString());
Directory.Delete(chatDirectory, true); // Acquire the semaphore to prevent deleting while another thread is writing:
var semaphore = GetChatSemaphore(workspaceId, chatId);
await semaphore.WaitAsync();
try
{
Directory.Delete(chatDirectory, true);
}
finally
{
semaphore.Release();
}
} }
private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName) private static async Task EnsureWorkspace(Guid workspaceId, string workspaceName)

View File

@ -8,3 +8,4 @@
- Fixed a bug with local transcription providers by handling errors correctly when the local provider is unavailable. - Fixed a bug with local transcription providers by handling errors correctly when the local provider is unavailable.
- Fixed a bug with local transcription providers by correctly handling empty model IDs. - Fixed a bug with local transcription providers by correctly handling empty model IDs.
- Fixed a bug affecting the transcription preview: previously, when you stopped music or other media, recorded or dictated text, and then tried to resume playback, the media wouldnt resume as expected. This behavior is now fixed. - Fixed a bug affecting the transcription preview: previously, when you stopped music or other media, recorded or dictated text, and then tried to resume playback, the media wouldnt resume as expected. This behavior is now fixed.
- Fixed a rare bug that occurred when multiple threads tried to manage the same chat thread.