Skip to content

Commit 1b89b5b

Browse files
fix: replace unbounded ConcurrentDictionary with MemoryCache (sliding 30min TTL, 500 entry limit)
- Add private MemoryCache with SizeLimit=500 and 30-minute sliding expiration - Implement IDisposable with GC.SuppressFinalize for proper DI singleton cleanup - Update Set call to use MemoryCacheEntryOptions - Update TryGetValue to use generic overload with null guard
1 parent c6f7ea6 commit 1b89b5b

1 file changed

Lines changed: 17 additions & 5 deletions

File tree

EssentialCSharp.Chat.Shared/Services/LocalChatService.cs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1-
using System.Collections.Concurrent;
21
using System.Linq;
32
using System.Net.Http.Headers;
43
using System.Text;
54
using System.Text.Json;
5+
using Microsoft.Extensions.Caching.Memory;
66
using Microsoft.Extensions.Logging;
77
using Microsoft.Extensions.Options;
88
using ModelContextProtocol.Client;
99
using OpenAI.Responses;
1010

1111
namespace EssentialCSharp.Chat.Common.Services;
1212

13-
public partial class LocalChatService : IChatCompletionService
13+
public partial class LocalChatService : IChatCompletionService, IDisposable
1414
{
1515
private const int MaxConversationMessages = 20;
16+
private const int MaxConversationEntries = 500;
17+
private static readonly TimeSpan _HistoryTtl = TimeSpan.FromMinutes(30);
18+
1619
private readonly AIOptions _Options;
1720
private readonly IHttpClientFactory _HttpClientFactory;
1821
private readonly ILogger<LocalChatService> _Logger;
19-
private readonly ConcurrentDictionary<string, List<LocalChatMessage>> _ConversationHistory = new();
22+
private readonly MemoryCache _ConversationHistory = new(new MemoryCacheOptions { SizeLimit = MaxConversationEntries });
23+
private static readonly MemoryCacheEntryOptions _HistoryEntryOptions = new MemoryCacheEntryOptions()
24+
.SetSlidingExpiration(_HistoryTtl)
25+
.SetSize(1);
2026

2127
public bool IsAvailable => true;
2228

@@ -27,6 +33,12 @@ public LocalChatService(IOptions<AIOptions> options, IHttpClientFactory httpClie
2733
_Logger = logger;
2834
}
2935

36+
public void Dispose()
37+
{
38+
_ConversationHistory.Dispose();
39+
GC.SuppressFinalize(this);
40+
}
41+
3042
public async Task<(string response, string responseId)> GetChatCompletion(
3143
string prompt,
3244
string? systemPrompt = null,
@@ -77,7 +89,7 @@ public LocalChatService(IOptions<AIOptions> options, IHttpClientFactory httpClie
7789
var (text, responseId) = ParseResponse(body);
7890
history.Add(new LocalChatMessage("user", prompt));
7991
history.Add(new LocalChatMessage("assistant", text));
80-
_ConversationHistory[responseId] = history.TakeLast(MaxConversationMessages).ToList();
92+
_ConversationHistory.Set(responseId, history.TakeLast(MaxConversationMessages).ToList(), _HistoryEntryOptions);
8193
return (text, responseId);
8294
}
8395
catch (Exception ex) when (ex is JsonException || ex is InvalidOperationException || ex is NotSupportedException)
@@ -147,7 +159,7 @@ private List<LocalChatMessage> ResolveHistory(string? previousResponseId)
147159
if (string.IsNullOrWhiteSpace(previousResponseId))
148160
return [];
149161

150-
return _ConversationHistory.TryGetValue(previousResponseId, out var history)
162+
return _ConversationHistory.TryGetValue(previousResponseId, out List<LocalChatMessage>? history) && history is not null
151163
? [.. history]
152164
: [];
153165
}

0 commit comments

Comments
 (0)