How to Cache Whole Datasets and Sessions in C#

A cache that fetches one value per key does not fit every source. Some data arrives only as a complete set, such as a configuration table or a list of reference codes, and asking for it one key at a time would repeat the same query. Other entries have nothing to fetch at all. A session exists because a caller created it, and it should disappear when nobody has touched it for a while.

Sphere10 Framework has a cache for each of these shapes, built on the same engine as its per-key cache. This article covers BulkFetchActionCache, PreloadedCache and SessionCache, and then shows how PooledCacheReaper puts several caches under one capacity budget.

Whole-snapshot caches and an idle-timeout session cache drawing on one shared capacity pool.
Whole-snapshot caches and an idle-timeout session cache drawing on one shared capacity pool.
💡 In short. Cache whole snapshots with BulkFetchActionCache and PreloadedCache, expire idle sessions with SessionCache, and place several caches under one capacity budget with PooledCacheReaper.

1. Load a whole snapshot with one fetch

BulkFetchActionCache<TKey, TValue> takes a delegate that returns the complete key and value set. The first read calls it once, and every key is then served from that snapshot.


var loads = 0;
using var cache = new BulkFetchActionCache<string, int>(
	() => {
		loads++;
		return new Dictionary<string, int> { ["a"] = 1, ["b"] = 2, ["c"] = 3 };
	},
	expirationStrategy: ExpirationPolicy.SinceFetchedTime,
	expirationDuration: TimeSpan.FromMilliseconds(150));

The snapshot is replaced as a unit. A read reloads it when the requested key is missing, expired or invalidated, and ForceRefresh() reloads it immediately.


Thread.Sleep(300);
Require(cache["a"] == 1 && loads == 2, "An expired snapshot was not reloaded.");

cache.ForceRefresh();
Require(loads == 3, "ForceRefresh did not reload the snapshot.");

Two constructor arguments change when a reload happens.

  • expirationStrategy and expirationDuration give the snapshot a lifetime, as they do for a per-key cache.
  • fetchOnceOnly: true loads once and then serves that snapshot until ForceRefresh() is called, ignoring expiry and invalidation.

A bulk cache holds zero-sized entries, never reaps and rejects Remove, because removing one entry would leave a partial snapshot.

2. How the cache types relate

All of the caches derive from CacheBase<TKey, TValue>, which supplies synchronized storage, expiration, events and the ICache<TKey, TValue> contract. The subclasses differ only in where values come from.

PreloadedCache<TKey, TValue> is a second subclass of BulkFetchCacheBase. Because every type implements the same contract, code that reads through ICache<TKey, TValue> does not need to know which one it was given.

Diagram source
classDiagram
    direction TB
    class CacheBase~TKey, TValue~ {
      +Get(key)
      +Set(key, value)
      +Invalidate(key)
      +Purge()
    }
    class BulkFetchCacheBase {
      +ForceRefresh()
      #BulkFetch()
    }
    class BulkFetchActionCache
    class SessionCache {
      +KeepAlive(id)
      +Cleanup()
    }
    CacheBase <|-- BulkFetchCacheBase
    CacheBase <|-- SessionCache
    BulkFetchCacheBase <|-- BulkFetchActionCache

3. Serve a fixed dictionary

PreloadedCache<TKey, TValue> is the simplest case. The dictionary passed to the constructor is the whole source, and an optional comparer controls key matching.


var values = new Dictionary<string, int> { ["alpha"] = 1, ["beta"] = 2 };
using var cache = new PreloadedCache<string, int>(values, StringComparer.OrdinalIgnoreCase);

Require(cache["ALPHA"] == 1 && cache["Beta"] == 2, "The comparer was not applied.");

It is useful when a component expects an ICache<TKey, TValue> and the data is already in memory, for example in tests or for built-in defaults.

4. Keep sessions alive until they go idle

SessionCache<K, T> has no fetch. Callers add entries with Set, and each entry expires when it has not been accessed for the timeout passed to the constructor. KeepAlive counts as an access.


using var sessions = new SessionCache<string, string>(TimeSpan.FromMilliseconds(400));
sessions.Set("kept", "first user");
sessions.Set("idle", "second user");

for (var i = 0; i < 4; i++) {
	Thread.Sleep(200);
	sessions.KeepAlive("kept");
}

After this loop the first session is still readable, although twice the timeout has passed. Reading the second one throws, because an expired session has nothing to fetch.


var idleExpired = false;
try {
	_ = sessions["idle"];
} catch (Exception) {
	idleExpired = true;
}

The cache also runs a background job, on the Framework scheduler, that removes expired sessions at each timeout interval, and Cleanup() does the same on demand. Dispose the cache to stop that job.

5. Share one capacity budget between caches

A bounded cache normally owns its budget. When several caches hold different kinds of values but should share one memory budget, give them the same PooledCacheReaper.


var reaper = new PooledCacheReaper(8);
using var names = new ActionCache<int, string>(
	key => $"name-{key}", value => 1,
	CacheReapPolicy.LongestIdle, maxCapacity: 8, reaper: reaper);
using var codes = new ActionCache<string, int>(
	key => key.Length, value => 1,
	CacheReapPolicy.Oldest, maxCapacity: 8, reaper: reaper);

Each member registers with the reaper as it is constructed and declares the reaper's capacity in the same estimator units. When a member needs room, the reaper looks at the combined size of all members.

The sample admits six entries to each cache against a pool of eight units. The second cache is the requester once the pool is full, so it gives up its own entries first and the two finish with six and two units.

Diagram source
---
config:
  sequence:
    actorMargin: 8
    width: 96
    mirrorActors: false
    messageMargin: 28
    noteMargin: 8
---
sequenceDiagram
    participant R as Requester
    participant P as Reaper
    participant O as Others
    R->>P: needs capacity
    Note over P: over budget
    P->>R: Reap first
    P->>O: Reap next
    P-->>R: capacity released

6. Ownership and limits

⚠️ Keep these limits in mind.
  • Values are borrowed. No cache disposes its values when an entry is removed or the cache is disposed.
  • The reaper borrows its members. It does not dispose them. Dispose each member cache, and keep the reaper alive for as long as any member is in use.
  • Members share units, not types. A pooled budget is meaningful only when every member's size estimator uses the same unit.
  • Session timing is approximate. Expiry is evaluated when a session is read or when the cleanup job runs, so an expired session can stay in memory until the next cleanup.

7. Get the package and run the sample

The caches are in the Sphere10.Framework namespace of the Sphere10.Framework NuGet package. The sample targets .NET 10.


dotnet new console --framework net10.0 --name ArticleSample
cd ArticleSample
dotnet add package Sphere10.Framework --version 3.1.3

The complete MIT-licensed sample contains the program shown in this article. Extract the download over the generated project and run it.


dotnet run -c Release

Each line is printed after the program has checked the behaviour it describes.


Bulk fetch: 3 keys from 1 load, 3 loads after expiry and ForceRefresh
Preloaded: 2 entries, keys matched without regard to case
Sessions: the session kept alive survived 800 ms, the idle one expired after 400 ms
Shared budget of 8: 6 + 2 units held after 12 admissions

The cache guide covers the per-key ActionCache, the policies and the reaper contract in full.


sample.zip
Download the complete worked example and build instructions (ZIP).

The Sphere10 Framework overview describes the rest of the toolkit.

Author: Herman Schoenfeld