9

I guess there is not built-in way to achieve that:

I have some cached data, that need to be always up to date (interval of few 10s of minutes). Its generation takes around 1-2 minutes, therefore it leads sometimes to timeout requests.

For performances optimisation, I put it into memory cache, using Cache.GetOrCreateAsync, so I am sure to have fast access to the data during 40 minutes. However it still takes time when the cache expires.

I would like to have a mechanism that auto-refreshes the data before its expiration, so the users are not impacted from this refresh and can still access the "old data" during the refresh.

It would actually be adding a "pre-expiration" process, that would avoid data expiration to arrive at its term.

I feel that is not the functioning of the default IMemoryCache cache, but I might be wrong? Does it exist? If not, how would you develop this feature?

I am thinking of using PostEvictionCallbacks, with an entry set to be removed after 35 minutes and that would trigger the update method (it involves a DbContext).

Jean
  • 4,911
  • 3
  • 29
  • 50

1 Answers1

14

This is how I solve it:

The part called by the web request (the "Create" method should be called only the first time).

var allPlaces = await Cache.GetOrCreateAsync(CACHE_KEY_PLACES
    , (k) =>
    {
       k.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(40);
       UpdateReset();
       return GetAllPlacesFromDb();
    });

And then the magic (This could have been implemented through a timer, but didn't want to handle timers there)

// This method adds a trigger to refresh the data from background
private void UpdateReset()
{
    var mo = new MemoryCacheEntryOptions();
    mo.RegisterPostEvictionCallback(RefreshAllPlacessCache_PostEvictionCallback);
    mo.AddExpirationToken(new CancellationChangeToken(new CancellationTokenSource(TimeSpan.FromMinutes(35)).Token));
    Cache.Set(CACHE_KEY_PLACES_RESET, DateTime.Now, mo);
}

// Method triggered by the cancellation token that triggers the PostEvictionCallBack
private async void RefreshAllPlacesCache_PostEvictionCallback(object key, object value, EvictionReason reason, object state)
{
    // Regenerate a set of updated data
    var places = await GetLongGeneratingData();
    Cache.Set(CACHE_KEY_PLACES, places, TimeSpan.FromMinutes(40));

    // Re-set the cache to be reloaded in 35min
    UpdateReset();
}

So the cache gets two entries, the first one with the data, expiring after 40 minutes, the second one expiring after 35min via a cancellation token that triggers the post eviction method. This callback refreshes the data before it expires.

Keep in mind that this will keep the website awake and using memory even if not used.

** * UPDATE USING TIMERS * **

The following class is registered as a singleton. DbContextOptions is passed instead of DbContext to create a DbContext with the right scope.

public class SearchService
{
    const string CACHE_KEY_ALLPLACES = "ALL_PLACES";
    protected readonly IMemoryCache Cache;
    private readonly DbContextOptions<AppDbContext> AppDbOptions;
    public SearchService(
            DbContextOptions<AppDbContext> appDbOptions,
            IMemoryCache cache)
    {
        this.AppDbOptions = appDbOptions;
        this.Cache = cache;
        InitTimer();
    }
    private void InitTimer()
    {
        Cache.Set<AllEventsResult>(CACHE_KEY_ALLPLACESS, new AllPlacesResult() { Result = new List<SearchPlacesResultItem>(), IsBusy = true });

        Timer = new Timer(TimerTickAsync, null, 1000, RefreshIntervalMinutes * 60 * 1000);
    }
    public Task LoadingTask = Task.CompletedTask;
    public Timer Timer { get; set; }
    public long RefreshIntervalMinutes = 10;
    public bool LoadingBusy = false;

    private async void TimerTickAsync(object state)
    {
        if (LoadingBusy) return;
        try
        {
            LoadingBusy = true;
            LoadingTask = LoadCaches();
            await LoadingTask;
        }
        catch
        {
            // do not crash the app
        }
        finally
        {
            LoadingBusy = false;
        }
    }
    private async Task LoadCaches()
    {
       try
       {
           var places = await GetAllPlacesFromDb();
           Cache.Set<AllPlacesResult>(CACHE_KEY_ALLPLACES, new AllPlacesResult() { Result = places, IsBusy = false });
       }
       catch{}
     }
     private async Task<List<SearchPlacesResultItem>> GetAllPlacesFromDb() 
     {
         // blablabla
     }

 }

Note: DbContext options require to be registered as singleton, default options are now Scoped (I believe to allow simpler multi-tenancy configurations)

services.AddDbContext<AppDbContext>(o =>
    {
        o.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
        o.UseSqlServer(connectionString);
    }, 
    contextLifetime: ServiceLifetime.Scoped, 
    optionsLifetime: ServiceLifetime.Singleton);
Jean
  • 4,911
  • 3
  • 29
  • 50
  • Hi. Which `Cache` object are you using? I am using `IMemoryCache` which is injected. So, in the static callback, I do not have access to the cache object. You probably want to send that object as state in `RegisterPostEvictionCallback` and use it in the callback. Am I missing something here? – Abhi Feb 28 '18 at 12:47
  • @Abhi I have updated the solution with a new one using timers, that I now use. Probably simpler to understand. However to answer: the callback doesn't need to be static. – Jean Feb 28 '18 at 18:03
  • Thanks for the answer. Why did you move to Timer way of doing this? I think, the callback method was much better. Is it not true? – Abhi Mar 01 '18 at 10:20
  • 1
    If the update fails for any reason, the callback method is not called again, and the data disappears. The timer ensure an init, and then periodical updates without interruption. If the update fails, the previous data is still available. I guess this is the main reason. (it is also much easier to understand for other people who would read the code, the callback method is exactly the same as a single-tick timer) – Jean Mar 01 '18 at 14:45
  • Oh yes... I see that now... thanks for the info, @Jean! – Abhi Mar 05 '18 at 11:18
  • @Jean Where are you calling the service so it can start? Because if you just register it, it's not instantiated. I tried to inject it on my Controller just to test, but I'm getting the error: `System.InvalidOperationException: Cannot consume scoped service Microsoft.EntityFrameworkCore.DbContextOptions[DatabaseContext] from singleton 'SearchService'.` – rbasniak Feb 21 '19 at 02:28
  • @RBasniak Indeed, I always register my `DbContext` with options as singleton as it used to be in previous versions of ASP.NET Core, so that it can be consumed by singleton services. services.AddDbContext(o => { o.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); o.UseSqlServer(connectionString); }, contextLifetime: ServiceLifetime.Scoped, optionsLifetime: ServiceLifetime.Singleton); – Jean Feb 21 '19 at 10:24
  • @RBasniak you may check the update at the end of the post – Jean Feb 21 '19 at 10:30
  • Based on your Timers solution I've created RefreshebleCache class [LazyCache: Regularly refresh cached items](//stackoverflow.com/a/56502821) – Michael Freidgeim Jun 09 '19 at 07:02
  • This was a very helpful solution. The docs don't go into many practical examples like this. – joshmcode Jun 05 '20 at 22:43