Cache stores

edit

HttpCachingDelegateHandler keeps its entries in the IHttpCacheStore you pass to its constructor. Choose the store from where the entries must live:

StorePackageUse it when
InMemoryHttpCacheStoreMeziantou.Framework.Http.Caching.InMemoryThe cache lives as long as the process, optionally saved to a file between runs
SqliteHttpCacheStoreMeziantou.Framework.Http.Caching.SqliteThe cache must survive restarts without an explicit save
RedisHttpCacheStoreMeziantou.Framework.Http.Caching.RedisThe cache lives in a Redis server
Your own IHttpCacheStoreMeziantou.Framework.Http.CachingThe entries go elsewhere

No store limits its size or evicts entries on its own: an entry stays until a request replaces or invalidates it, or until you prune it. See Removing obsolete entries.

In memory

InMemoryHttpCacheStore keeps the entries in memory. SaveToFileAsync writes them to a JSON file, and LoadFromFileAsync replaces the entries of the store with the ones of a file, so the cache can survive a restart:

var cacheStore = new InMemoryHttpCacheStore();

// Restore the entries saved by a previous run. A missing file leaves the store empty.
await cacheStore.LoadFromFileAsync(cacheFilePath);

using var cachingHandler = new HttpCachingDelegateHandler(originHandler, cacheStore);
using var httpClient = new HttpClient(cachingHandler);
using var response = await httpClient.GetAsync("https://example.com/data");

// Drop the entries that can no longer be used, then save the others for the next run
await cacheStore.PruneObsoleteEntriesAsync();
await cacheStore.SaveToFileAsync(cacheFilePath);
  • LoadFromFileAsync empties the store when the file does not exist or is not a valid cache file.
  • SaveToFileAsync creates the directory if needed. It writes a temporary file next to the destination, then moves it into place, so an interrupted save does not destroy the previous file.
  • Nothing is saved automatically: call SaveToFileAsync when it suits your application, for instance when it stops.

SQLite

SqliteHttpCacheStore keeps the entries in a SQLite database. Install Meziantou.Framework.Http.Caching.Sqlite; the store is in the Meziantou.Framework.Http.Caching.Sqlite namespace and SqliteConnectionStringBuilder in Microsoft.Data.Sqlite:

var connectionString = new SqliteConnectionStringBuilder
{
    DataSource = databasePath,
    Mode = SqliteOpenMode.ReadWriteCreate,
    Pooling = false,
}.ToString();

using var cacheStore = new SqliteHttpCacheStore(connectionString);

// Remove the entries that can no longer be used, for instance when the application starts
await cacheStore.PruneObsoleteEntriesAsync();

using var cachingHandler = new HttpCachingDelegateHandler(originHandler, cacheStore);
using var httpClient = new HttpClient(cachingHandler);
using var response = await httpClient.GetAsync("https://example.com/data");
  • The constructor takes a SQLite connection string. The store creates its table, HttpCacheEntries, on first use, and every operation is written to the database as it happens.
  • The store keeps one connection open for its lifetime, and Dispose closes it. With Pooling = false, the other connections are closed after each operation, so the database file is released when the store is disposed.
  • A shared in-memory database (Mode = SqliteOpenMode.Memory and Cache = SqliteCacheMode.Shared) works too: the connection the store keeps open keeps the database alive until the store is disposed.

Redis

RedisHttpCacheStore keeps the entries in Redis. Install Meziantou.Framework.Http.Caching.Redis; the store is in the Meziantou.Framework.Http.Caching.Redis namespace and takes an IConnectionMultiplexer from StackExchange.Redis:

await using var connection = await ConnectionMultiplexer.ConnectAsync("localhost:6379");

// The key prefix isolates the entries of this cache from the other data of the Redis database
var cacheStore = new RedisHttpCacheStore(connection, keyPrefix: "MyApp:HttpCache");

using var cachingHandler = new HttpCachingDelegateHandler(new SocketsHttpHandler(), cacheStore);
using var httpClient = new HttpClient(cachingHandler);
using var response = await httpClient.GetAsync("https://example.com/data");

// Redis keeps the entries until they are removed: remove the obsolete ones from time to time
await cacheStore.PruneObsoleteEntriesAsync();
  • Every key the store writes starts with keyPrefix, Meziantou:HttpCache by default. Use a different prefix for each cache that shares a Redis database.
  • The store does not set an expiration on the keys it writes: Redis keeps the entries until a request replaces or invalidates them, or until PruneObsoleteEntriesAsync removes them.

Removing obsolete entries

A stale entry is still useful when the cache can revalidate it (it has an ETag or a Last-Modified date) or serve it on errors (stale-if-error, see Serving stale responses on errors). An entry with none of them is obsolete once it is stale: the cache can never use it again.

The three stores have a PruneObsoleteEntriesAsync method that removes the obsolete entries, and keeps the fresh ones and the ones that can still be used. It compares the entries with the current time, DateTimeOffset.UtcNow; an overload takes the time to use instead, for instance the time of the TimeProvider of the options. Call it from time to time, or before saving an in-memory store.

Write your own store

IHttpCacheStore has four methods:

MethodWhat it does
GetEntriesAsync(primaryKey, cancellationToken)Returns the entries stored under a key, or an empty collection
SetEntryAsync(primaryKey, entry, cancellationToken)Adds an entry, or replaces the entry stored for the same variant
RemoveEntriesAsync(primaryKey, cancellationToken)Removes every entry stored under a key
PruneObsoleteEntriesAsync(cancellationToken)Removes the obsolete entries. Optional: the default implementation does nothing

A key identifies a request method and URL. Several entries can share a key: one per variant of the response, for instance one per Accept-Language when the response has Vary: Accept-Language (see Vary and No-Vary-Search). This store keeps them in a dictionary:

private sealed class DictionaryHttpCacheStore : IHttpCacheStore
{
    private readonly ConcurrentDictionary<string, ImmutableArray<HttpCachePersistenceEntry>> _entries = new(StringComparer.Ordinal);

    public ValueTask<IReadOnlyCollection<HttpCachePersistenceEntry>> GetEntriesAsync(string primaryKey, CancellationToken cancellationToken)
    {
        IReadOnlyCollection<HttpCachePersistenceEntry> entries = _entries.TryGetValue(primaryKey, out var value) ? value : [];
        return ValueTask.FromResult(entries);
    }

    public ValueTask SetEntryAsync(string primaryKey, HttpCachePersistenceEntry entry, CancellationToken cancellationToken)
    {
        // An entry replaces the one stored for the same variant of the response (same Vary headers and query)
        _entries.AddOrUpdate(
            primaryKey,
            _ => [entry],
            (_, existing) => [.. existing.Where(item => !HttpCachePersistenceEntry.HasSameSecondaryKey(item, entry)), entry]);
        return ValueTask.CompletedTask;
    }

    public ValueTask RemoveEntriesAsync(string primaryKey, CancellationToken cancellationToken)
    {
        _entries.TryRemove(primaryKey, out _);
        return ValueTask.CompletedTask;
    }
}

HttpCachePersistenceEntry holds what the cache needs to use an entry without reading the response: the variant it answers, its dates, its directives and validators, and the serialized response. It also has helpers for a store:

  • HasSameSecondaryKey(left, right) tells whether two entries are the same variant, so SetEntryAsync knows which entry to replace. ComputeSecondaryKeyHash() gives a stable hash of the variant, to use as a storage key.
  • IsObsolete(now) tells whether the entry can be removed, and GetStaleTime() when it becomes stale, which a store can index to prune without reading every entry. IsUnusableWhenStale tells whether it can be removed as soon as it is stale.
  • Clone() copies the entry, for a store that keeps the instances it receives and must not share them.

A store can fail: the cache treats a failed lookup as a miss and skips a failed write, so the request still succeeds. See Options.