Repository navigation
GetValue never completes when the handler throws after RemoveValue #151
Description
In 2.2.0 (and current main), if RemoveValue is called while a load is running and the handler then throws anything other than OperationCanceledException, the pending GetValue never completes. Later GetValue calls get the same pending task, and calling RemoveValue again does not recover it.
RemoveValue cancels the load's token but keeps the uncompleted _responseSource. The general catch in RunHandler then skips SetException because the token is cancelled, and returns:
catch (Exception ex)
{
lock (_lock)
{
if (!tokenSource.IsCancellationRequested)
_responseSource?.SetException(ex);
return;
}
}Nothing completes _responseSource after that, and the load is not restarted. A successful result that arrives after cancellation falls through to the retry block below, so only the exception path is affected. GetHandlerTask keeps returning the pending task because it is neither faulted nor canceled, and RemoveValue keeps it because it is not completed.
We hit this when an EF Core split query threw DbQueryConcurrencyException: the save that called RemoveValue had also changed the query's rows between its statements.
Repro
Console app on .NET 10 with Immediate.Cache 2.2.0. The project must be named CacheRepro, because the generated registration methods are in that namespace.
CacheRepro.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Immediate.Cache" Version="2.2.0" />
<PackageReference Include="Immediate.Handlers" Version="4.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
</ItemGroup>
</Project>Program.cs:
using CacheRepro;
using Immediate.Cache.Shared;
using Immediate.Handlers.Shared;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddCacheReproCaches();
services.AddCacheReproHandlers();
services.AddMemoryCache();
var cache = services.BuildServiceProvider().GetRequiredService<FailingLoadCache>();
await Run("Load fails", removeDuringLoad: false);
await Run("Load fails after RemoveValue", removeDuringLoad: true);
async Task Run(string scenario, bool removeDuringLoad)
{
var query = new FailingLoad.Query { Key = scenario };
var waiting = cache.GetValue(query).AsTask();
await query.Started.Task;
if (removeDuringLoad)
cache.RemoveValue(query);
query.Release.SetResult();
Console.WriteLine($"{scenario}:");
await Report("caller waiting during the load", waiting);
await Report("caller arriving afterwards", cache.GetValue(query).AsTask());
cache.RemoveValue(query);
await Report("caller after another RemoveValue", cache.GetValue(query).AsTask());
}
static async Task Report(string caller, Task<string> task)
{
try
{
Console.WriteLine($" {caller}: returned {await task.WaitAsync(TimeSpan.FromSeconds(3))}");
}
catch (TimeoutException)
{
Console.WriteLine($" {caller}: still pending after 3 s");
}
catch (Exception exception)
{
Console.WriteLine($" {caller}: threw {exception.GetType().Name}");
}
}
[Handler]
public sealed partial class FailingLoad
{
public sealed class Query
{
public required string Key { get; init; }
public TaskCompletionSource Started { get; } = new();
public TaskCompletionSource Release { get; } = new();
}
private async ValueTask<string> HandleAsync(Query query, CancellationToken token)
{
query.Started.TrySetResult();
// Stands in for work that fails without observing the token, such as a database query whose rows changed underneath it.
await query.Release.Task;
throw new InvalidOperationException("load failed");
}
}
[CacheFor<FailingLoad>]
public sealed partial class FailingLoadCache
{
protected override string TransformKey(FailingLoad.Query request) => request.Key;
}Output:
Load fails:
caller waiting during the load: threw InvalidOperationException
caller arriving afterwards: threw InvalidOperationException
caller after another RemoveValue: threw InvalidOperationException
Load fails after RemoveValue:
caller waiting during the load: still pending after 3 s
caller arriving afterwards: still pending after 3 s
caller after another RemoveValue: still pending after 3 s
Possible fix
Return only after SetException, so a cancelled load falls through to the retry the way a successful one does:
@@ -225,8 +225,10 @@ public abstract class ApplicationCache<TRequest, TResponse>(
lock (_lock)
{
if (!tokenSource.IsCancellationRequested)
+ {
_responseSource?.SetException(ex);
- return;
+ return;
+ }
}
}
With this change, all three callers in the second scenario throw InvalidOperationException. The existing tests pass on net10.0 and net11.0; I couldn't run net8.0 or net9.0 locally.
Activity