Skip to content

[BUG] RemoveByTag is lost if it races a background factory (eager refresh) #628

Description

@KristianJakubik

Describe the bug

A cache entry created by a factory running in the background — e.g. during an eager refresh — is not correctly invalidated by tags.

There is a race condition when a tag invalidation happens while the factory is already executing and may have loaded its data from the database. The invalidation is silently lost and the entry keeps being served.

I believe the cause is that the entry's timestamp is assigned after the factory finishes,

var lateEntry = FusionCacheMemoryEntry<TValue>.CreateFromOptions(result, GetSerializedValueFromValue(operationId, key, result, options), null, ctx.Tags, options, false, ctx.LastModified?.UtcTicks, ctx.ETag);

An entry created by a standard foreground factory has its timestamp captured before the factory runs, so the same race resolves correctly there.

To Reproduce

using ZiggyCreatures.Caching.Fusion;

var cache = new FusionCache(new FusionCacheOptions
{
    DefaultEntryOptions = new FusionCacheEntryOptions
    {
        Duration = TimeSpan.FromSeconds(10),
        EagerRefreshThreshold = 0.1f,
    },
});

// We want to run tag invalidation when factory is already running
_ = RunTagInvalidationAfter(500);

// Prime the entry, then move into its eager-refresh window (10% of 10s = 1s).
await cache.GetOrSetAsync<string>("key", async (ctx, ct) =>
{
    Console.WriteLine("RUN: Factory is started for v1 value.");
    //Simulate loading data from database
    await Task.Delay(1000);
    Console.WriteLine("RUN: Factory is finnished. Return v1 value.");
    return "v1";
}, tags: ["tag"]);

// The cache item should be invalidated by tag
var cacheItem = await cache.TryGetAsync<string>("key");
Console.WriteLine("RUN: TryGet value: {0}.", cacheItem.HasValue ? cacheItem.Value : "empty");


Console.WriteLine("EXPECTED: The cache item is invalidated. {0}", cacheItem.HasValue ? "BUG: still cached" : "OK: invalidated");


await Task.Delay(1000);


Console.WriteLine("--------------------------------------------------------------------------------------------------------------------");

// Let's add the cache item again for eager loading
await cache.GetOrSetAsync<string>("key", (ctx, ct) =>
{
    Console.WriteLine("RUN: Factory is started and finnished. Return v1 value.");
    return Task.FromResult("v1");
}, tags: ["tag"]);

// Wait to trigger eager loading
await Task.Delay(2000);

var loadedValue = await cache.GetOrSetAsync<string>("key", async (ctx, ct) =>
{
    Console.WriteLine("RUN: Factory is started for v2 value.");
    await Task.Delay(1000);
    Console.WriteLine("RUN: Factory is finnished. Return v2 value.");
    return "v2";
}, tags: ["tag"]);

Console.WriteLine($"RUN: Loaded value from factory: {loadedValue}.");

_ = RunTagInvalidationAfter(500);

await Task.Delay(1500);

var eagerLoadedValue = await cache.TryGetAsync<string>("key");
Console.WriteLine($"RUN: TryGet eager loaded value: {eagerLoadedValue.Value}.");

Console.WriteLine("EXPECTED: The cache item with value: v2 is invalidated. {0}", eagerLoadedValue.HasValue ? "BUG: still cached" : "OK: invalidated");

Task RunTagInvalidationAfter(int delay)
{
    return Task.Run(async () =>
    {
        await Task.Delay(delay);
        Console.WriteLine("RUN: Remove by Tag");
        await cache.RemoveByTagAsync("tag");
    });
}

Expected behavior

An invalidation that overlaps a running factory should win, on the background path as well as the foreground one — so the entry should be timestamped when the factory starts, not when it completes.

Versions

I've encountered this issue on:

  • FusionCache version : 2.6.0
  • .NET 10

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions