Rask.Cache — a developer-facing cache on your database
In practice: Tutorial Ch 6 · recipe cache an expensive query · cheat sheet.
Rask.Cache caches computed values in the app's own database — no message broker, no Redis. It implements
the standard IDistributedCache (so it drops straight into ASP.NET session state, output caching, and
anything else built on the abstraction) and adds a typed cache you reach the way you say it —
remember the rates for ten minutes:
var rates = await Cache.Remember("rates", LoadRates).For(10.Minutes);
Entries carry absolute and sliding expirations; a background worker sweeps expired rows.
Included in
Rask.Server— nothing to install. It is on; an app that does without it says so:app.Configure(c => c.Cache.Off());
Why cache on the database
A cache is usually the first thing that pushes a solo app onto a second piece of infrastructure — a Redis instance to run, secure, and back up. But most apps don't need a separate cache server: they need to avoid recomputing an expensive value or re-calling a slow upstream on every request. SQLite, already on the box with WAL enabled, is fast enough for that — and keeping the cache in the same database means one thing to operate, one thing to back up.
Rask.Cache persists each entry to a table and a hosted worker purges expired rows, so there's nothing else
to run. Because it implements IDistributedCache, framework features that expect that abstraction work
unchanged.
Use
// Program.cs
builder.Services.AddRaskCache<AppDbContext>();
builder.Services.AddDbContextFactory<AppDbContext>(o => o.UseSqlite("Data Source=app.db"));
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
modelBuilder.AddRaskCache(); // maps the CacheEntry table
}
Add a migration for the new table before running — rask db add AddCache && rask db update
(or dotnet ef migrations add AddCache directly). Then cache from anywhere — a handler, a render, a
request, a job — with nothing injected:
// remember: a hit returns what was stored; a miss runs the loader, stores it, and returns it.
var rates = await Cache.Remember($"rates:{date:yyyyMMdd}", () => exchange.FetchRates(date)).Sliding(10.Minutes);
await Cache.Set("greeting", "hello").For(1.Hour);
var greeting = await Cache.Get<string>("greeting");
await Cache.Forget("greeting");
How long an entry lives is a step at the end: .For(10.Minutes) from now, .Sliding(20.Minutes)
while it keeps being read, .Until(midnight) at a fixed moment. With no step it follows
CacheOptions.DefaultSlidingExpiration, and never expires when that is unset. Nothing runs until the line
is awaited.
No call takes a cancellation token: each is cancelled with the work it runs in. Where there is no such work
— a hosted service, a timer started at boot — inject ICache, which reads the same:
public sealed class RatesWarmer(ICache cache) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken) =>
await cache.Remember("rates", LoadRates, stoppingToken).For(10.Minutes);
}
Or use the standard IDistributedCache directly (bytes in, bytes out) — for ASP.NET session state, register
it with builder.Services.AddSession() and AddRaskCache provides the store.
How it works
RaskDistributedCache<TContext>— theIDistributedCache. Reads and writes go through yourIDbContextFactory<TContext>(a Rask Server session is long-lived, so each operation gets a fresh short-lived context). It stores oneCacheEntryrow per key: the bytes, an optional absolute deadline, an optional sliding window, and the effectiveExpiresAt. A read pastExpiresAtis a miss and the row is evicted lazily; a read of a sliding entry renewsExpiresAt(capped by any absolute deadline).Cache/ICache— the typed layer:Remember,Set,Get,Forget, serializingTwithSystem.Text.Json.Rememberruns the loader once on a miss, stores the result, and returns it; a concurrent second caller may also run the loader (the cache is not a lock), so keep loaders idempotent.CachePurger<TContext>— a hostedBackgroundServicethat bulk-deletes rows pastExpiresAtonSweepInterval(default 5 minutes). Reads already evict lazily; the sweep is the backstop for entries that are simply never read again.
CacheEntry is a Rask.Data entity with a read face, so the table can be looked at like any other —
CacheEntry.OrderBy(e => e.Key) — without a context of your own.
One cache, isolated per tenant
In an app with multi-tenancy, each tenant gets its own value under the same key. With a
tenant in flight — the signed-in user's, or a Tenant.Use scope — the stored key is prefixed with that tenant, so
one tenant can neither read nor remove another's entry, whatever name it asks for. With no tenant in flight
the key is stored as given, which is what keeps anonymous requests working: ASP.NET's session and output
caching write through IDistributedCache with nobody signed in.
It is isolated by the key rather than by a query filter, the way the rest of tenancy works, for two reasons. The purger has to sweep every tenant's expired rows, and a filter would hide them from it, so the table would only grow. And a tenant-partitioned table refuses a row with no tenant, so an anonymous page with output caching would fail the moment tenancy was switched on. Scoping the key also makes the isolation exact: a tenant cannot form another tenant's key, so there is nothing to get wrong in code written by hand.
The scoping lives in the database-backed store. An ICache over Redis or another
IDistributedCache stores the key as given, so put the tenant into it yourself there.
Trim / AOT
Values are stored as JSON. An untrimmed app serializes them with reflection and needs nothing. A trimmed or
AOT app registers its source-generated JsonSerializerContext once, and every call site stays exactly as
it is:
builder.Services.AddRaskCache<AppDbContext>(o => o.Json = AppJson.Default);
[JsonSerializable(typeof(List<Rate>))]
internal sealed partial class AppJson : JsonSerializerContext;
A type the context does not list fails on first use with the attribute to add —
[JsonSerializable(typeof(Rate))] — rather than with a trim warning at every call site.
The IDistributedCache (byte[]) surface is fully trim-safe.
If you already run Redis
The argument above is about not standing Redis up for a cache. It is not an argument against using one you
already operate — and if you already have it, or you are running several app instances and would rather the
database didn't carry the cache traffic, ICache works over any IDistributedCache:
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddRaskCache(); // no <AppDbContext> — the store is Redis
That is the whole change. Cache.Remember and the rest behave identically, because the typed layer only
ever talks to IDistributedCache; nothing about your calling code moves.
There is no Rask.Cache.Redis package, and there shouldn't be:
Microsoft.Extensions.Caching.StackExchangeRedis
is the standard .NET API for this and wrapping it would only add a layer to keep in step.
Of CacheOptions, only Json applies to this overload. The other two — SweepInterval and
DefaultSlidingExpiration — are implemented by the database-backed store, which it does not register. Expiry
is Redis's own business: configure it there, or say it per call with .For(…) and .Sliding(…). Using the <AppDbContext> overload with a Redis store registered
would still work, but it also registers the purge worker and so keeps needing the CacheEntry table and a
migration for it — which is exactly what this overload removes.
Notes
- Server-side. The store is your EF Core database and the purger is a hosted service — this is not a
browser/WASM concern. (For client-side browser storage, see
LocalStoragein Web APIs from MDN.) - No
ShutdownGracePeriod, on purpose. Jobs, the outbox and mail each take one, because they run your code and cancelling it halfway is destructive. The purger's only in-flight work is a single bulk delete of expired rows: cancel it and either the statement rolls back (the next sweep redoes it) or it committed (the work is done). There is no user code, no external side effect and no per-row state to lose, so the purge is abort-safe by construction and a knob would be pure surface area. See Shutdown and redeploy. - SQLite is single-writer, so writes serialize. Use
UseRaskSqlite(WAL + abusy_timeout) on your context so a concurrent write waits for the lock instead of failing withSQLITE_BUSY. Run one purger per app. - What to cache. This is a value cache for expensive computations and slow upstream calls — not a hot per-request key/value store handling tens of thousands of writes a second. For that scale, reach for a dedicated cache; for the one-server app it's designed for, the database is enough.