Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Cache keys on non-relational providers (InMemory, Cosmos): every query produced the same key because
`ToQueryString()` returns a constant message there. Keys now also include a rendering of the expression tree with
captured variable values. The fallback renderer also no longer ignores captured values, method call targets
(`a.Name.ToUpper()` vs `a.Email.ToUpper()`) or generic arguments (`OfType<A>()` vs `OfType<B>()`).
- `MemoryCacheProvider` could lose tag tracking when an entry was overwritten (the replaced entry's eviction callback
removed the new entry's tags), leaving entries that `SaveChanges` could not invalidate. Tags are now cancellation
tokens attached to entries.
- Query dependencies missed `DbSet`s used in joins and subqueries (EF Core query roots were never visited).
- `SaveChanges` invalidation used the runtime CLR type: lazy-loading proxies never matched, changing a derived entity
did not invalidate queries on its base type, owned entities did not invalidate their owner, and many-to-many link
changes invalidated nothing. Types now come from EF metadata.
- Stampede protection removed a key's semaphore while callers were still waiting on it, allowing concurrent loads;
`FirstOrDefaultAsync`/`SingleOrDefaultAsync` had no stampede protection at all.
- A failed `SaveChanges` inside a transaction discarded pending invalidations of earlier successful `SaveChanges`
calls in the same transaction. A failed commit/savepoint no longer discards them either.
- Pending invalidations leaked when a transaction was disposed without commit or rollback.
- `TransactionScope`: invalidation ran before the scope committed (and also on rollback). It now runs on commit.
- Redis: tag invalidation failed silently for tags with more than ~8000 entries (Lua `unpack` limit).
- Redis: a short-lived entry shortened the TTL of a shared tag set, so longer-lived entries could no longer be
invalidated once the set expired. Tag set TTLs are now only extended.
- Redis: sliding expiration was ignored in multiplexer mode; cache hits now refresh the entry and its tag sets.
- Redis: `ClearAsync` also scanned replicas.
- `CachedQueriesConfiguration.DefaultOptions` and `AutoInvalidation` had no effect.
- Library code now uses `ConfigureAwait(false)`, avoiding deadlocks of synchronous `SaveChanges`/`Commit` under a
`SynchronizationContext`.

### Added
- `CacheTags.ForEntity<T>()` — the tag under which queries depending on an entity type are tracked. Use it with
`WithTags(...)` to keep automatic invalidation, or with `Cache.InvalidateByTagAsync(...)`.

### Changed
- Results served through the cache are loaded with `AsNoTracking()` (unless the query uses
`AsNoTrackingWithIdentityResolution()`): cached instances are shared between callers and must not be tracked by,
or modified through, one request's `DbContext`.
- `AddCacheInvalidation<TContext>()` throws when called before the DbContext is registered (it used to silently do
nothing, leaving automatic invalidation off).
- `AddCachedQueriesWithRedis(connectionString)` shares one `IConnectionMultiplexer` with `IDistributedCache`.
- XML documentation is now complete for all public APIs, including the provider contract and known limitations.

### Deprecated
- `CachedQueriesConfiguration.EnableLogging` (never had an effect; configure log levels instead).
- `ICacheInvalidator.RegisterCacheEntry` overloads (no-ops since tracking moved to provider tags).

## [3.0.0] - 2026-03-18

### Added
Expand All @@ -20,7 +64,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ClearAllAsync()` to clear all cache entries across all providers
- Manual invalidation: `Cache.InvalidateAsync<T>()`, `Cache.InvalidateByTagAsync()`, `Cache.InvalidateByTagsAsync()`
- Redis provider with atomic tag operations via `StackExchange.Redis`
- Circular reference handling (`ReferenceHandler.Preserve`) in Redis serialization
- Circular reference handling (`ReferenceHandler.IgnoreCycles`) in Redis serialization
- Multi-provider support (different providers for single items, collections, scalars)
- `ICacheProviderFactory` for multi-provider scenarios
- Demo project with Docker Compose, PostgreSQL, and integration tests
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<PropertyGroup>
<!-- Build settings -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- CS1591: Missing XML comment - suppress until all public API is documented -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- CS1591: Missing XML comment - enforced for the published libraries, suppressed elsewhere -->
<NoWarn Condition="'$(MSBuildProjectName)' != 'CachedQueries' and '$(MSBuildProjectName)' != 'CachedQueries.Redis'">$(NoWarn);CS1591</NoWarn>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
Expand Down
131 changes: 110 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));

builder.Services.AddCachedQueries();
builder.Services.AddCacheInvalidation<AppDbContext>();
builder.Services.AddCacheInvalidation<AppDbContext>(); // must come after AddDbContext

var app = builder.Build();
app.Services.UseCachedQueries();
app.Run();
```

> Until `UseCachedQueries()` runs, `.Cacheable()` queries execute without caching.

### 2. Cache Queries

```csharp
Expand All @@ -54,9 +56,7 @@ var orders = await db.Orders
// Cache with custom options
var goods = await db.Goods
.Where(g => g.Category == "Electronics")
.Cacheable(o => o
.Expire(TimeSpan.FromMinutes(10))
.WithTags("catalog", "electronics"))
.Cacheable(o => o.Expire(TimeSpan.FromMinutes(10)))
.ToListAsync();

// Cache single item
Expand Down Expand Up @@ -88,14 +88,28 @@ Configure per-query behavior with the fluent builder:

```csharp
.Cacheable(o => o
.Expire(TimeSpan.FromMinutes(5)) // Absolute expiration
.Expire(TimeSpan.FromMinutes(5)) // Absolute expiration
.SlidingExpiration(TimeSpan.FromMinutes(5)) // Or sliding
.WithKey("my-custom-key") // Custom cache key
.WithTags("orders", "reports") // Tags for grouped invalidation
.SkipIf(bypassCache) // Conditional skip
.UseTarget(CacheTarget.Collection)) // Override provider selection
.WithKey("my-custom-key") // Custom cache key (manual invalidation only)
.WithTags("orders", "reports") // Tags for grouped invalidation (see below)
.IgnoreContext() // Share the entry across contexts (e.g. tenants)
.SkipIf(bypassCache) // Conditional skip
.UseTarget(CacheTarget.Collection)) // Override provider selection
```

### How results are cached

- **Results are read-only.** Queries served through the cache run with `AsNoTracking()` (unless the query already uses
`AsNoTrackingWithIdentityResolution()`), because the same instances can be returned to many callers — with the
in-memory provider, literally the same objects. To modify an entity, load it without `.Cacheable()`.
- **Stampede protection.** Concurrent cache misses for the same key are coalesced: one caller queries the database, the
others wait for its result.
- **`null` is not cached.** `FirstOrDefaultAsync`/`SingleOrDefaultAsync` returning `null` hit the database every time.
- **Cache keys** are a SHA-256 hash of the generated SQL (with parameter values) plus a rendering of the expression tree
with captured variable values, so `Where(o => o.Id == id)` gets a different key for every `id` on every provider,
including non-relational ones (InMemory, Cosmos).
- `SkipIf(true)` or an unconfigured library executes the query normally, with tracking.

## Cache Invalidation

### Automatic (default)
Expand All @@ -108,6 +122,14 @@ db.Orders.Add(newOrder);
await db.SaveChangesAsync(); // All cached Order queries are invalidated
```

A query depends on every entity type it touches: the root set, `Include`d navigations, navigations used in filters or
projections, and other sets used in joins or subqueries. On the write side, changed types are read from EF metadata, so:

- changing a derived entity (`Dog`) also invalidates queries on its base type (`Animal`);
- changing an owned entity invalidates its owner;
- adding/removing a many-to-many link invalidates both sides;
- lazy-loading proxies are handled.

### Transaction-Aware

Inside a transaction, invalidation is deferred until commit. Rollback discards pending invalidations.
Expand All @@ -122,6 +144,8 @@ await tx.CommitAsync(); // Invalidation fires NOW
// Or: await tx.RollbackAsync(); // No invalidation
```

Ambient `System.Transactions.TransactionScope`s are supported too: invalidation runs when the scope commits.

### Manual

```csharp
Expand All @@ -132,7 +156,6 @@ await Cache.InvalidateByKeysAsync(["key1", "key2"]); // By multiple keys
await Cache.InvalidateAsync<Order>(); // By entity type
await Cache.InvalidateByTagAsync("reports"); // By single tag
await Cache.InvalidateByTagsAsync(["orders", "reports"]); // By multiple tags
await Cache.ClearContextAsync(); // Current context only (e.g. for app user or tenant)
await Cache.ClearAllAsync(); // Everything
```

Expand All @@ -149,26 +172,53 @@ var order = await db.Orders
await Cache.InvalidateByKeyAsync($"order:{id}");
```

### Tags vs. automatic invalidation

`WithKey()` and `WithTags()` **replace** automatic entity tracking: such entries are only invalidated manually (or by
expiration). To keep `SaveChanges`-driven invalidation for an entity type while also tagging an entry, add the entity's
tag explicitly with `CacheTags.ForEntity<T>()`:

```csharp
var report = await db.Orders
.Cacheable(o => o.WithTags("reports", CacheTags.ForEntity<Order>()))
.ToListAsync();

await Cache.InvalidateByTagAsync("reports"); // manual
// ...and SaveChanges on Order invalidates it automatically, too.

await Cache.InvalidateByTagAsync(CacheTags.ForEntity<Order>()); // same as Cache.InvalidateAsync<Order>()
```

## Redis Support

```csharp
// Automatic setup (recommended)
// Automatic setup (recommended): one shared connection, atomic tag operations
builder.Services.AddCachedQueriesWithRedis("localhost:6379");

// Or with existing IDistributedCache
// Or with existing IDistributedCache — also register IConnectionMultiplexer for atomic tag operations
builder.Services.AddSingleton<IConnectionMultiplexer>(ConnectionMultiplexer.Connect("localhost:6379"));
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
builder.Services.AddCachedQueriesWithRedis();
```

- Values are serialized with System.Text.Json (camelCase, `ReferenceHandler.IgnoreCycles`): cyclic back-references
(e.g. `Order.Items[i].Order`) come back as `null`. Types must be deserializable by System.Text.Json; failures are
logged and treated as cache misses.
- Sliding expiration is honoured: a hit refreshes the entry and its tag sets.
- **Redis Cluster is not supported** — the Lua scripts touch keys in different hash slots. Standalone,
primary/replica and Sentinel deployments work.
- Without an `IConnectionMultiplexer` the provider runs in a non-atomic fallback mode intended for development.

## Multi-Context Caching

For example, implement `ICacheContextProvider` to isolate cache per tenant:

```csharp
public class TenantCacheContextProvider(IHttpContextAccessor http) : ICacheContextProvider
{
// Use an authenticated source (e.g. a claim), not raw client input
public string? GetContextKey()
=> http.HttpContext?.Request.Headers["X-Tenant-Id"].FirstOrDefault();
=> http.HttpContext?.User.FindFirst("tenant_id")?.Value;
}
```

Expand All @@ -180,19 +230,36 @@ builder.Services.AddCachedQueries(config =>
config.UseContextProvider<TenantCacheContextProvider>());
```

Cache keys are automatically prefixed: `tenant-a:CQ:abc123`.
Cache keys are automatically prefixed: `cq:tenant-a:abc123`. Invalidation removes the current context's entries plus
global (`IgnoreContext()`) entries; other contexts are untouched.

Things to know:

- The provider is resolved from a **new DI scope** on each cache operation, not from the request scope. Read ambient
state (`IHttpContextAccessor`, claims, an `AsyncLocal`), not values that middleware stored in another scoped service.
- Code without an ambient context (background services, message handlers) has a `null` context key, so its
`SaveChanges` only invalidates **global** entries. Invalidate the tenant explicitly there:

```csharp
var invalidator = serviceProvider.GetRequiredService<ICacheInvalidator>();
await invalidator.InvalidateAsync([typeof(Order)], tenantId);
```

## Configuration

```csharp
builder.Services.AddCachedQueries(config =>
{
config.DefaultOptions = new CachingOptions(TimeSpan.FromHours(1));
config.AutoInvalidation = true; // default
config.EnableLogging = true; // default
config.DefaultOptions = new CachingOptions(TimeSpan.FromHours(1)); // default expiration for .Cacheable()
config.AutoInvalidation = true; // default; false = no SaveChanges interceptors, manual invalidation only
config.CachePrefix = "cq"; // default; namespace for all keys and tags
});
```

`DefaultOptions` supplies the expiration (absolute or sliding) for `.Cacheable()` and `.Cacheable(o => ...)`.
Options passed explicitly with `.Cacheable(new CachingOptions(...))` are used as-is.
Logging uses the standard `ILogger` categories under `CachedQueries`; configure their levels as usual.

## Multi-Provider Setup

Use different cache backends for different query types:
Expand All @@ -213,7 +280,7 @@ builder.Services.AddCachedQueries(config => config
| Target | Auto-selected for | Description |
|--------------|-------------------------------------|------------------------------------|
| `Single` | `FirstOrDefault`, `SingleOrDefault` | Individual entities |
| `Collection` | `ToList` | Lists and arrays |
| `Collection` | `ToList` | Lists |
| `Scalar` | `Count`, `Any` | Aggregation results |
| `Auto` | - | Automatically determined (default) |

Expand All @@ -232,6 +299,27 @@ public class MyCacheProvider : ICacheProvider
builder.Services.AddCachedQueries<MyCacheProvider>();
```

A provider must be thread-safe, return `default` on a miss, and associate each entry with every tag in
`options.Tags` so that `InvalidateByTagsAsync` removes it. See the XML docs on `ICacheProvider` for the full contract.

## Consistency & Limitations

CachedQueries is a look-aside cache with invalidation. It shortens, but does not eliminate, windows in which stale data
can be served:

- **Concurrent read during a write.** If a query misses the cache, reads the database, and a concurrent `SaveChanges`
invalidates before that query stores its result, the (now stale) result is stored and served until it expires or the
next invalidation. Keep expirations reasonable for data that must be fresh, or bypass the cache (`SkipIf`) where
read-your-writes matters.
- **Only `SaveChanges` is observed.** `ExecuteUpdate`/`ExecuteDelete`, raw SQL, stored procedures, and other
applications writing to the database do not invalidate anything — call `Cache.InvalidateAsync<T>()` yourself.
- **External transactions.** If a context joins a transaction via `Database.UseTransaction(...)` and the transaction is
committed outside EF (or through another context), no commit is observed for it — invalidate manually after commit.
- **String includes** (`Include("Items")`) are not detected as dependencies; use lambda includes or tags.
- **Process-wide configuration.** `UseCachedQueries()` configures a static accessor: one configuration per process.
- **Memory provider** is per process. Use Redis (or another distributed provider) when running several instances.
- Cache invalidation failures are logged and never fail `SaveChanges`.

## Legacy API

The older extension methods are still supported:
Expand All @@ -245,8 +333,9 @@ await db.Orders.AnyCachedAsync(o => o.Status == OrderStatus.Pending, ct);

## Demo Project

See [`examples/`](examples/) for a full working demo with Docker Compose, PostgreSQL, Redis, multi-context isolation,
and 73 integration tests.
See [`examples/`](examples/) for a full working demo covering every feature (with a feature map): Docker Compose,
PostgreSQL, Redis, multi-context isolation, transactions, bulk updates, background services,
and integration tests (they start PostgreSQL and Redis with Testcontainers, so Docker is required).

```bash
cd examples
Expand All @@ -256,7 +345,7 @@ dotnet test Demo.Api.Tests # Run integration tests

## Requirements

- .NET 8.0, 9.0, or 10.0
- .NET 8.0 or 9.0
- Entity Framework Core 8.0+

## Contributing
Expand Down
Loading
Loading