Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

In-Memory Caching with auto-regeneration on ASP.Net Core

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).

like image 369
Jean Avatar asked Jun 23 '17 13:06

Jean


1 Answers

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);
like image 152
Jean Avatar answered Oct 22 '22 09:10

Jean