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
124 changes: 124 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Changelog

All notable changes to this project are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). While the
version stays below 1.0, breaking changes may land in a minor release — they are always
listed first in the entry.

## [0.1.0] — 2026-08-29

The first release of ActionCache as a set of packages rather than one, and the first since
`0.0.9` (February 2025). Everything below is relative to `0.0.9`.

### Breaking changes

- **The backends ship as separate packages.** `ActionCache` now contains the attributes,
filters, key building, DI and the in-memory backend only. Redis, SQL Server and Azure
Cosmos DB each moved to their own package. A consumer who caches in memory no longer
inherits StackExchange.Redis, `Microsoft.Data.SqlClient`, the Cosmos SDK and
Newtonsoft.Json — which `0.0.9` pulled in unconditionally. See *Upgrading* below; the
registration code itself does not change.
- **`net9.0` is no longer targeted.** `0.0.9` shipped `net8.0` and `net9.0`; `0.1.0` ships
`net8.0` and `net10.0`.
- **Cached entries from `0.0.9` are not readable.** Both the key format (now hashed) and the
stored payload (now a rendered response envelope rather than a serialized result graph)
changed. Entries left in a distributed backend by an older version are ignored and
overwritten as they are re-cached — a cold cache after upgrade, not an error.
- **Stored values are no longer polymorphic.** Entries serialize through a source-generated
`System.Text.Json` context, and nothing in a payload names a type to construct.
- **Responses vary by the authenticated user by default.** `VaryByUserMode.Auto` means two
users hitting one `[Authorize]` endpoint no longer share a cache entry. Set
`VaryByUser = VaryByUserMode.Never` to restore the previous behavior.
- **An endpoint may cache or have cache side effects, never both.** Combinations such as
`[ActionCache]` with `[ActionCacheEviction]` on one endpoint now throw
`ConflictingCacheAttributesException` at startup, listing every offending route.

### Added

- **Azure Cosmos DB backend** as `ActionCache.AzureCosmos`, with TTL-based expiry and lazy
container initialization.
- **`ActionCache.Abstractions`**, for implementing a cache backend without depending on an
implementation.
- **Minimal API support end to end** — `WithActionCache`, `WithActionCacheEviction` and
`WithActionCacheRefresh`, including refresh, which previously worked for controller
actions only.
- **Per-endpoint options for `WithActionCache`**, matching what `[ActionCache]` offers:
expiration, vary-by and single-flight, stated as `TimeSpan` rather than milliseconds.
- **Stampede protection.** `IActionCacheSingleFlight` coalesces concurrent misses for one
key so the origin action runs once. On by default; opt out with `SingleFlight = false`.
`options.UseDistributedSingleFlight()` coalesces across processes over the Redis or SQL
Server lock.
- **Vary-by keys** — `VaryByUser`, `VaryByHeader`, `VaryByQuery` and `VaryByClaim`, plus
`IActionCacheKeyContributor` for anything else.
- **Layered backends.** Registering more than one chains them: a deeper-layer hit is
promoted into the first layer, and key enumeration unions every layer.
- **Graceful degradation.** A backend outage degrades to a cache miss and logs a warning
rather than failing the request. Configurable through `ActionCacheResilienceOptions`,
including fail-closed and an operation timeout.
- **Cancellation throughout.** Every `IActionCache` method takes a `CancellationToken`, and
the filters pass `HttpContext.RequestAborted`.
- **Observability** — structured logging and a documented telemetry contract across cache
hits, misses, evictions, refreshes and degradation.
- **A documentation site** at <https://jzills.github.io/action-cache/>, and XML
documentation on every public API.

### Changed

- **Refresh replays the recorded request** against the matching endpoint in its own DI
scope, rather than reflecting over the action. Replays are marked so a refresh cannot
recurse into itself or trip the eviction filter, and a refreshed entry keeps the
expiration its endpoint declared instead of silently inheriting the global options.
- **Refresh skips entries that vary by the request** — replaying another caller's request
would mean impersonating them.
- **Cache keys are hashed**, bounding key length regardless of argument size.
- **Distributed locking is production-grade** — `sp_getapplock` on SQL Server, and Lua
scripts on Redis so its operations are atomic without a lock at all.
- **Backends connect lazily.** Redis and Cosmos initialize on first use, so an application
no longer fails to start because a cache backend is unreachable.
- **Inter-package dependencies are pinned to an exact version.** These assemblies share
internals and release in lockstep, so a mismatched pair could fail at runtime rather than
at build time.
- Microsoft.Azure.Cosmos updated to 3.62.1.

### Fixed

- Only successful (2xx) results are cached. A `NotFound()` or `BadRequest()` body was
previously cached and replayed for the whole lifetime of the entry.
- A recorded request body is replayed with the content type it arrived as, so an endpoint
with `[Consumes]` no longer answers 415 on every refresh pass.
- A request body that cannot be faithfully replayed (XML, form data) no longer produces a
broken replay: the entry is still cached, and refresh skips it and logs why.
- The in-memory namespace index is guarded by a singleton lock — caches are created per
request, so a per-instance lock guarded nothing, and the read-modify-write it protects is
not atomic in `IMemoryCache`.
- Namespace eviction in the memory backend disposed a `CancellationTokenSource` that
in-flight requests still held, so a concurrent write threw `ObjectDisposedException` — a
500 when fail-closed, a silently dropped cache write when fail-open. Entries written
afterwards also carried a token no later eviction would cancel.
- The Redis expiry listener targets the database named in the connection string rather than
database 0.
- Namespace injection through route-parameter templates.
- All build warnings; the build now treats warnings as errors.

### Upgrading from 0.0.9

Add the package for each backend you register. The registration API is unchanged — the
`Use…Cache` extensions still live in `ActionCache.Common.Extensions`, the namespace you
already import for `AddActionCache` — so no `using` and no call site needs to change:

```bash
dotnet add package ActionCache.Redis # if you call UseRedisCache
dotnet add package ActionCache.SqlServer # if you call UseSqlServerCache
dotnet add package ActionCache.AzureCosmos # if you call UseAzureCosmosCache
```

`ActionCache` on its own still covers `UseMemoryCache`. Each backend package references
`ActionCache`, so you do not need to list both.

Then review the breaking changes above — in particular, expect a cold cache on first
deploy, and check whether any endpoint carries a combination of cache attributes that
startup validation now rejects.

[0.1.0]: https://github.com/jzills/action-cache/releases/tag/v0.1.0
20 changes: 20 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<PackageIcon>Icon.jpg</PackageIcon>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/jzills/action-cache</PackageProjectUrl>
<!-- Points at the changelog rather than restating it: the notes are baked into the
nuspec at pack time, so anything written here is frozen at the version it shipped
with, while the link keeps working. -->
<PackageReleaseNotes>See https://github.com/jzills/action-cache/blob/main/CHANGELOG.md</PackageReleaseNotes>
<RepositoryUrl>https://github.com/jzills/action-cache.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
Expand Down Expand Up @@ -68,4 +72,20 @@
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
</ItemGroup>

<!--
A package with no readme renders an empty tab on nuget.org, which is the first thing a
prospective consumer sees. Each backend keeps its own README.md next to its csproj and
it is packed from here, so a new backend gets a landing page by adding the file.

ActionCache is the exception: it packs the repository root README instead, declared in
its own csproj. It has no README.md of its own, so this condition passes it by.
-->
<PropertyGroup Condition="Exists('$(MSBuildProjectDirectory)/README.md')">
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)/README.md')">
<None Include="README.md" Pack="true" PackagePath="" />
</ItemGroup>

</Project>
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,5 @@ observability.
## Resources

- [Documentation](https://jzills.github.io/action-cache/)
- [Changelog](./CHANGELOG.md)
- [Samples](./samples/)
30 changes: 30 additions & 0 deletions site/content/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,36 @@ a `using`.
`AddActionCache` detects whether the application uses MVC or Minimal APIs and registers the
matching filter and descriptor providers, so nothing further is needed for either style.

## Upgrading from 0.0.9

`0.0.9` shipped as a single package containing every backend. `0.1.0` is the first release
split across several, so an existing application needs the package for each backend it
registers:

```bash
dotnet add package ActionCache.Redis # if you call UseRedisCache
dotnet add package ActionCache.SqlServer # if you call UseSqlServerCache
dotnet add package ActionCache.AzureCosmos # if you call UseAzureCosmosCache
```

**No code changes.** The `Use…Cache` extensions still live in
`ActionCache.Common.Extensions`, so no call site and no `using` moves. `ActionCache` on its
own still covers `UseMemoryCache`.

Three things to know before deploying:

- **`net9.0` is no longer targeted.** `0.0.9` shipped `net8.0` and `net9.0`; this release
ships `net8.0` and `net10.0`.
- **Expect a cold cache.** Both the key format and the stored payload changed, so entries
left in a distributed backend by `0.0.9` are ignored and rewritten as they are re-cached.
A drop in hit rate on first deploy is expected; nothing errors.
- **Responses now vary by the authenticated user by default.** Two users on one
`[Authorize]` endpoint no longer share an entry — see [Vary-by](../../caching/vary-by).
Set `VaryByUser = VaryByUserMode.Never` to keep one shared entry.

The [changelog](https://github.com/jzills/action-cache/blob/main/CHANGELOG.md) lists every
breaking change.

## Next

{{< cards >}}
Expand Down
24 changes: 24 additions & 0 deletions site/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ See [Resilience](../../reliability/resilience).

`[ActionCacheEviction]` and `[ActionCacheRefresh]` take `Namespace` only.

## ActionCacheEndpointOptions

The Minimal API equivalent, configured through `WithActionCache(ns, options => ...)`.

| Property | Type | Default |
|---|---|---|
| `AbsoluteExpiration` | `TimeSpan?` | `null` — none |
| `SlidingExpiration` | `TimeSpan?` | `null` — none |
| `VaryByUser` | `VaryByUserMode` | `Auto` |
| `VaryByHeader` | `string?` | `null` |
| `VaryByQuery` | `string?` | `null` |
| `VaryByClaim` | `string?` | `null` |
| `SingleFlight` | `bool` | `true` |

The expirations are `TimeSpan?` where the attribute takes `long` milliseconds: an attribute
argument must be a compile-time constant, and a builder argument need not be.

The delegate runs **once at registration**, not per request — the options describe the
endpoint, so re-running caller code on every request would only make an expensive lambda an
expensive endpoint.

`WithActionCacheEviction` and `WithActionCacheRefresh` take a namespace only, matching their
attributes. See [Attributes](../../caching/attributes#minimal-apis).

## Key contributors

```csharp
Expand Down
37 changes: 37 additions & 0 deletions src/ActionCache.Abstractions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# ActionCache.Abstractions

The core contracts for [ActionCache](https://www.nuget.org/packages/ActionCache/) —
namespaced response caching for ASP.NET Core.

```bash
dotnet add package ActionCache.Abstractions
```

**Most applications do not need this package.** Install
[`ActionCache`](https://www.nuget.org/packages/ActionCache/) for the attributes, filters and
the in-memory backend, or one of the backend packages
([`ActionCache.Redis`](https://www.nuget.org/packages/ActionCache.Redis/),
[`ActionCache.SqlServer`](https://www.nuget.org/packages/ActionCache.SqlServer/),
[`ActionCache.AzureCosmos`](https://www.nuget.org/packages/ActionCache.AzureCosmos/)) —
each of them brings this one with it.

Reference it directly when you are **writing a cache backend of your own**, or when a
library needs to depend on the abstractions without pulling in an implementation.

## What is here

| Type | Purpose |
|---|---|
| `IActionCache` | The core contract: `GetAsync`, `SetAsync`, `RemoveAsync`, `RefreshAsync`, `GetKeysAsync` |
| `IActionCacheFactory` | Creates an `IActionCache` per namespace |
| `ActionCacheBase<TLock>` | Base class carrying the locking strategy a backend opts into |
| `Namespace` | The namespace primitive, including route-parameter templates |
| `ActionCacheEntryOptions` | Absolute and sliding expiration for a single entry |
| `CachedResponse` | The stored value: status code, content type, rendered body, and the request refresh replays |

This package takes no third-party dependencies — no Redis client, no SqlClient, no Cosmos
SDK, no Newtonsoft.

## Documentation

Full documentation: <https://jzills.github.io/action-cache/>
2 changes: 1 addition & 1 deletion src/ActionCache.AzureCosmos/ActionCache.AzureCosmos.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.62.1" />
<!--
Required by Microsoft.Azure.Cosmos, which hard-fails the build without an explicit
reference and serializes its documents with it. No ActionCache code uses Newtonsoft.
Expand Down
50 changes: 50 additions & 0 deletions src/ActionCache.AzureCosmos/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ActionCache.AzureCosmos

The Azure Cosmos DB backend for [ActionCache](https://www.nuget.org/packages/ActionCache/) —
namespaced response caching for ASP.NET Core.

```bash
dotnet add package ActionCache.AzureCosmos
```

This package references `ActionCache`, so installing it is enough — you do not need the
core package as well.

## Registration

```csharp
using ActionCache.Common.Extensions;

builder.Services.AddActionCache(options =>
{
options.UseAzureCosmosCache(cosmos =>
{
cosmos.DatabaseId = "MyDatabase";
cosmos.ConnectionString = configuration.GetValue<string>("CosmosDb:ConnectionString");
});
});
```

Both `DatabaseId` and `ConnectionString` are required.

## Provisioning

The only thing to create in Azure is the Cosmos DB account. The database and container are
created on first use if they do not already exist, so there is no setup script to run.

Initialization is **lazy** — it happens on the first cache operation rather than at startup,
so an application does not fail to start because Cosmos is unreachable.

Each entry is a document holding the key, the namespace, the serialized value, and its
expiration as both an absolute timestamp and a Cosmos `ttl`. Expiry is enforced by the
container's TTL rather than by a background sweep.

## Distributed single-flight

Cosmos supplies no distributed lock, so it cannot back `options.UseDistributedSingleFlight()`.
Register `ActionCache.Redis` or `ActionCache.SqlServer` alongside it if you want distributed
stampede protection; otherwise the in-process default applies.

## Documentation

Full documentation: <https://jzills.github.io/action-cache/docs/backends/cosmos/>
52 changes: 52 additions & 0 deletions src/ActionCache.Redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# ActionCache.Redis

The Redis backend for [ActionCache](https://www.nuget.org/packages/ActionCache/) —
namespaced response caching for ASP.NET Core.

```bash
dotnet add package ActionCache.Redis
```

This package references `ActionCache`, so installing it is enough — you do not need the
core package as well.

## Registration

```csharp
using ActionCache.Common.Extensions;

builder.Services.AddActionCache(options =>
{
options.UseRedisCache(redis => redis.Configuration = "localhost:6379");
});
```

The delegate configures `RedisCacheOptions`, so a `ConfigurationOptions` instance,
credentials or TLS settings all go here. A shorthand overload takes the configuration
string directly:

```csharp
options.UseRedisCache("localhost:6379");
```

## Keyspace notifications

The backend keeps a sorted-set index per namespace, which is what makes namespace eviction
and refresh possible without scanning. Entries that expire on their own are removed from
that index in response to Redis key-expired events, so enable the `Ex` flags:

```bash
redis-cli config set notify-keyspace-events Ex
```

Without them nothing breaks — the index self-heals lazily when it is next read. Enabling
the flags keeps it tight instead.

## Distributed single-flight

This backend supplies a distributed lock, so it can back `options.UseDistributedSingleFlight()`
to coalesce concurrent cache misses across processes rather than only within one.

## Documentation

Full documentation: <https://jzills.github.io/action-cache/docs/backends/redis/>
Loading
Loading