Skip to content

Commit db525b0

Browse files
committed
feat: enhance cache management with async methods and improve disposal logic
1 parent 163cb95 commit db525b0

8 files changed

Lines changed: 249 additions & 22 deletions

File tree

src/EasyAppDev.Blazor.Store/AsyncActions/AsyncActionExecutor.cs

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public sealed class AsyncActionExecutor<TState> : IAsyncActionExecutor<TState>,
2020
private readonly Dictionary<string, CachedOperation> _inFlightOperations = new();
2121
private readonly Dictionary<string, CachedResult> _cachedResults = new();
2222
private readonly SemaphoreSlim _lock = new(1, 1);
23-
private bool _disposed;
23+
private int _disposed; // 0 = not disposed, 1 = disposed (use int for Interlocked)
2424

2525
/// <summary>
2626
/// Tracks an in-flight operation for deduplication.
@@ -300,7 +300,29 @@ public void InvalidateCache(string cacheKey)
300300
ThrowIfDisposed();
301301
ArgumentNullException.ThrowIfNull(cacheKey);
302302

303-
_lock.Wait();
303+
// Use timeout to prevent indefinite blocking in edge cases
304+
if (!_lock.Wait(TimeSpan.FromSeconds(5)))
305+
{
306+
_logger?.LogWarning("InvalidateCache timed out waiting for lock. Consider using InvalidateCacheAsync instead.");
307+
return;
308+
}
309+
try
310+
{
311+
_cachedResults.Remove(cacheKey);
312+
}
313+
finally
314+
{
315+
_lock.Release();
316+
}
317+
}
318+
319+
/// <inheritdoc />
320+
public async Task InvalidateCacheAsync(string cacheKey)
321+
{
322+
ThrowIfDisposed();
323+
ArgumentNullException.ThrowIfNull(cacheKey);
324+
325+
await _lock.WaitAsync().ConfigureAwait(false);
304326
try
305327
{
306328
_cachedResults.Remove(cacheKey);
@@ -317,7 +339,36 @@ public void InvalidateCacheByPrefix(string prefix)
317339
ThrowIfDisposed();
318340
ArgumentNullException.ThrowIfNull(prefix);
319341

320-
_lock.Wait();
342+
// Use timeout to prevent indefinite blocking in edge cases
343+
if (!_lock.Wait(TimeSpan.FromSeconds(5)))
344+
{
345+
_logger?.LogWarning("InvalidateCacheByPrefix timed out waiting for lock. Consider using InvalidateCacheByPrefixAsync instead.");
346+
return;
347+
}
348+
try
349+
{
350+
var keysToRemove = _cachedResults.Keys
351+
.Where(k => k.StartsWith(prefix, StringComparison.Ordinal))
352+
.ToList();
353+
354+
foreach (var key in keysToRemove)
355+
{
356+
_cachedResults.Remove(key);
357+
}
358+
}
359+
finally
360+
{
361+
_lock.Release();
362+
}
363+
}
364+
365+
/// <inheritdoc />
366+
public async Task InvalidateCacheByPrefixAsync(string prefix)
367+
{
368+
ThrowIfDisposed();
369+
ArgumentNullException.ThrowIfNull(prefix);
370+
371+
await _lock.WaitAsync().ConfigureAwait(false);
321372
try
322373
{
323374
var keysToRemove = _cachedResults.Keys
@@ -340,7 +391,28 @@ public void ClearCache()
340391
{
341392
ThrowIfDisposed();
342393

343-
_lock.Wait();
394+
// Use timeout to prevent indefinite blocking in edge cases
395+
if (!_lock.Wait(TimeSpan.FromSeconds(5)))
396+
{
397+
_logger?.LogWarning("ClearCache timed out waiting for lock. Consider using ClearCacheAsync instead.");
398+
return;
399+
}
400+
try
401+
{
402+
_cachedResults.Clear();
403+
}
404+
finally
405+
{
406+
_lock.Release();
407+
}
408+
}
409+
410+
/// <inheritdoc />
411+
public async Task ClearCacheAsync()
412+
{
413+
ThrowIfDisposed();
414+
415+
await _lock.WaitAsync().ConfigureAwait(false);
344416
try
345417
{
346418
_cachedResults.Clear();
@@ -394,15 +466,18 @@ private void PerformCacheCleanup()
394466
/// </summary>
395467
public void Dispose()
396468
{
397-
if (_disposed) return;
398-
_disposed = true;
399-
_lock.Dispose();
469+
// Use Interlocked.Exchange for atomic check-and-set to prevent race conditions
470+
if (Interlocked.Exchange(ref _disposed, 1) != 0)
471+
return;
472+
400473
_inFlightOperations.Clear();
401474
_cachedResults.Clear();
475+
_lock.Dispose();
402476
}
403477

404478
private void ThrowIfDisposed()
405479
{
406-
ObjectDisposedException.ThrowIf(_disposed, this);
480+
if (Volatile.Read(ref _disposed) != 0)
481+
throw new ObjectDisposedException(nameof(AsyncActionExecutor<TState>));
407482
}
408483
}

src/EasyAppDev.Blazor.Store/AsyncActions/IAsyncActionExecutor.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,23 +206,58 @@ Task ExecuteCachedAsync<TResult>(
206206
/// <param name="cacheKey">The cache key to invalidate.</param>
207207
/// <remarks>
208208
/// This does not affect in-flight operations. Only cached results are removed.
209+
/// Prefer <see cref="InvalidateCacheAsync"/> in async contexts to avoid potential deadlocks.
209210
/// </remarks>
210211
void InvalidateCache(string cacheKey);
211212

213+
/// <summary>
214+
/// Removes a specific cached result by key asynchronously.
215+
/// </summary>
216+
/// <param name="cacheKey">The cache key to invalidate.</param>
217+
/// <returns>A task representing the async operation.</returns>
218+
/// <remarks>
219+
/// This does not affect in-flight operations. Only cached results are removed.
220+
/// Preferred over <see cref="InvalidateCache"/> in async contexts.
221+
/// </remarks>
222+
Task InvalidateCacheAsync(string cacheKey);
223+
212224
/// <summary>
213225
/// Removes all cached results with keys starting with the specified prefix.
214226
/// </summary>
215227
/// <param name="prefix">The prefix to match cache keys against.</param>
216228
/// <remarks>
217229
/// Useful for invalidating related cache entries (e.g., "product-" invalidates "product-1", "product-2", etc.).
230+
/// Prefer <see cref="InvalidateCacheByPrefixAsync"/> in async contexts to avoid potential deadlocks.
218231
/// </remarks>
219232
void InvalidateCacheByPrefix(string prefix);
220233

234+
/// <summary>
235+
/// Removes all cached results with keys starting with the specified prefix asynchronously.
236+
/// </summary>
237+
/// <param name="prefix">The prefix to match cache keys against.</param>
238+
/// <returns>A task representing the async operation.</returns>
239+
/// <remarks>
240+
/// Useful for invalidating related cache entries (e.g., "product-" invalidates "product-1", "product-2", etc.).
241+
/// Preferred over <see cref="InvalidateCacheByPrefix"/> in async contexts.
242+
/// </remarks>
243+
Task InvalidateCacheByPrefixAsync(string prefix);
244+
221245
/// <summary>
222246
/// Clears all cached results.
223247
/// </summary>
224248
/// <remarks>
225249
/// This does not affect in-flight operations. Only cached results are removed.
250+
/// Prefer <see cref="ClearCacheAsync"/> in async contexts to avoid potential deadlocks.
226251
/// </remarks>
227252
void ClearCache();
253+
254+
/// <summary>
255+
/// Clears all cached results asynchronously.
256+
/// </summary>
257+
/// <returns>A task representing the async operation.</returns>
258+
/// <remarks>
259+
/// This does not affect in-flight operations. Only cached results are removed.
260+
/// Preferred over <see cref="ClearCache"/> in async contexts.
261+
/// </remarks>
262+
Task ClearCacheAsync();
228263
}

src/EasyAppDev.Blazor.Store/Query/QueryClient.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public sealed class QueryClient : IQueryClient, IDisposable
1818
private readonly QueryClientOptions _options;
1919
private readonly ILogger<QueryClient>? _logger;
2020
private readonly Timer _cleanupTimer;
21-
private bool _disposed;
21+
private int _disposed; // 0 = not disposed, 1 = disposed (use int for Interlocked)
2222

2323
/// <summary>
2424
/// Creates a new query client with the specified options.
@@ -249,7 +249,7 @@ private async Task TriggerRefetchAsync(string key)
249249

250250
private void CleanupExpiredEntries(object? state)
251251
{
252-
if (_disposed) return;
252+
if (Volatile.Read(ref _disposed) != 0) return;
253253

254254
var now = DateTime.UtcNow;
255255
var expiredKeys = new List<string>();
@@ -288,8 +288,9 @@ private void CleanupExpiredEntries(object? state)
288288
/// </summary>
289289
public void Dispose()
290290
{
291-
if (_disposed) return;
292-
_disposed = true;
291+
// Use Interlocked.Exchange for atomic check-and-set to prevent race conditions
292+
if (Interlocked.Exchange(ref _disposed, 1) != 0)
293+
return;
293294

294295
_cleanupTimer.Dispose();
295296
_cache.Clear();

src/EasyAppDev.Blazor.Store/ServerSync/ServerSyncMiddleware.cs

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -487,11 +487,29 @@ private async void SendPendingUpdate()
487487
catch (Exception ex)
488488
{
489489
_logger?.LogWarning(ex, "Failed to send state update");
490-
if (_options.EnableOfflineQueue)
490+
491+
// Guard QueueOfflineUpdate to prevent exceptions escaping async void
492+
try
491493
{
492-
QueueOfflineUpdate(update);
494+
if (_options.EnableOfflineQueue)
495+
{
496+
QueueOfflineUpdate(update);
497+
}
498+
}
499+
catch (Exception queueEx)
500+
{
501+
_logger?.LogError(queueEx, "Failed to queue offline update");
502+
}
503+
504+
// Guard OnError callback to prevent user code exceptions escaping async void
505+
try
506+
{
507+
_options.OnError?.Invoke(ex);
508+
}
509+
catch (Exception callbackEx)
510+
{
511+
_logger?.LogError(callbackEx, "OnError callback threw an exception");
493512
}
494-
_options.OnError?.Invoke(ex);
495513
}
496514
}
497515

@@ -540,7 +558,15 @@ private async void SendPendingCursor()
540558
}
541559
catch (Exception ex)
542560
{
543-
_logger?.LogDebug(ex, "Failed to send cursor update");
561+
// Guard logger call to prevent exceptions escaping async void
562+
try
563+
{
564+
_logger?.LogDebug(ex, "Failed to send cursor update");
565+
}
566+
catch
567+
{
568+
// Swallow logging exceptions to prevent async void crash
569+
}
544570
}
545571
}
546572

src/EasyAppDev.Blazor.Store/Utilities/DebounceManager.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,18 @@ public void Dispose()
200200
{
201201
if (_disposed) return;
202202

203-
_lock.Wait();
203+
// Use timeout to prevent indefinite blocking during dispose
204+
if (!_lock.Wait(TimeSpan.FromSeconds(5)))
205+
{
206+
// Force dispose even if lock timeout - cancel all CTS without lock
207+
_disposed = true;
208+
foreach (var cts in _pendingActions.Values)
209+
{
210+
try { cts.Cancel(); cts.Dispose(); } catch { }
211+
}
212+
_lock.Dispose();
213+
return;
214+
}
204215
try
205216
{
206217
foreach (var cts in _pendingActions.Values)

src/EasyAppDev.Blazor.Store/Utilities/ILazyCache.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ namespace EasyAppDev.Blazor.Store.Utilities;
2222
/// }
2323
/// </code>
2424
/// </example>
25-
public interface ILazyCache : IDisposable
25+
public interface ILazyCache : IDisposable, IAsyncDisposable
2626
{
2727
/// <summary>
2828
/// Gets data from cache or loads it using the provided loader function if not cached or expired.
@@ -76,5 +76,17 @@ Task<T> GetOrLoadAsync<T>(
7676
/// Gets the current number of entries in the cache, including expired entries.
7777
/// </summary>
7878
/// <value>The total count of entries in the cache (includes expired entries).</value>
79+
/// <remarks>
80+
/// Prefer <see cref="GetCountAsync"/> in async contexts to avoid potential deadlocks.
81+
/// </remarks>
7982
int Count { get; }
83+
84+
/// <summary>
85+
/// Gets the current number of entries in the cache asynchronously.
86+
/// </summary>
87+
/// <returns>The total count of entries in the cache (includes expired entries).</returns>
88+
/// <remarks>
89+
/// Preferred over <see cref="Count"/> property in async contexts.
90+
/// </remarks>
91+
Task<int> GetCountAsync();
8092
}

0 commit comments

Comments
 (0)