CodeCargo.Nats.DistributedCache
1.0.0-preview.1
dotnet add package CodeCargo.Nats.DistributedCache --version 1.0.0-preview.1
NuGet\Install-Package CodeCargo.Nats.DistributedCache -Version 1.0.0-preview.1
<PackageReference Include="CodeCargo.Nats.DistributedCache" Version="1.0.0-preview.1" />
<PackageVersion Include="CodeCargo.Nats.DistributedCache" Version="1.0.0-preview.1" />
<PackageReference Include="CodeCargo.Nats.DistributedCache" />
paket add CodeCargo.Nats.DistributedCache --version 1.0.0-preview.1
#r "nuget: CodeCargo.Nats.DistributedCache, 1.0.0-preview.1"
#:package CodeCargo.Nats.DistributedCache@1.0.0-preview.1
#addin nuget:?package=CodeCargo.Nats.DistributedCache&version=1.0.0-preview.1&prerelease
#tool nuget:?package=CodeCargo.Nats.DistributedCache&version=1.0.0-preview.1&prerelease
NATS Distributed Cache
Overview
A .NET 8+ library (tested on .NET 8 and .NET 10) for using NATS with HybridCache or as an IDistributedCache directly.
Requirements
- NATS 2.11 or later
- A NATS KV bucket with
LimitMarkerTTLset for per-key TTL support. Either enable automatic bucket creation (options.CreateBucketIfNotExists = true), or pre-create the bucket yourself:using NATS.Client.KeyValueStore; using NATS.Net; // assuming an INatsConnection natsConnection var kvContext = natsConnection.CreateKeyValueStoreContext(); await kvContext.CreateOrUpdateStoreAsync( new NatsKVConfig("cache") { LimitMarkerTTL = TimeSpan.FromSeconds(1), History = 1 });
Use with HybridCache
The CodeCargo.Nats.HybridCacheExtensions package provides an extension method that:
- Adds the NATS
IDistributedCache - Adds
HybridCache - Configures
HybridCacheto use the NATS Connection's serializer registry
Install
dotnet add package CodeCargo.Nats.HybridCacheExtensions
dotnet add package NATS.Extensions.Microsoft.DependencyInjection
Example
using CodeCargo.Nats.HybridCacheExtensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NATS.Extensions.Microsoft.DependencyInjection;
using NATS.Net;
// Set the NATS URL, this normally comes from configuration
const string natsUrl = "nats://localhost:4222";
// Create a host builder for a Console application
// For a Web Application you can use WebApplication.CreateBuilder(args)
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
services.AddNatsClient(natsBuilder =>
natsBuilder.ConfigureOptions(optsBuilder => optsBuilder.Configure(opts =>
opts.Opts = opts.Opts with { Url = natsUrl })));
services.AddNatsHybridCache(options =>
{
options.BucketName = "cache";
// Create the KV bucket on first use if it doesn't already exist.
// Omit this if you pre-create the bucket yourself (see Requirements).
options.CreateBucketIfNotExists = true;
});
});
var host = builder.Build();
// Start the host
await host.RunAsync();
Use IDistributedCache Directly
Install
dotnet add package CodeCargo.Nats.DistributedCache
dotnet add package NATS.Extensions.Microsoft.DependencyInjection
Example
using CodeCargo.Nats.DistributedCache;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using NATS.Extensions.Microsoft.DependencyInjection;
using NATS.Net;
// Set the NATS URL, this normally comes from configuration
const string natsUrl = "nats://localhost:4222";
// Create a host builder for a Console application
// For a Web Application you can use WebApplication.CreateBuilder(args)
var builder = Host.CreateDefaultBuilder(args);
builder.ConfigureServices(services =>
{
services.AddNatsClient(natsBuilder =>
natsBuilder.ConfigureOptions(optsBuilder => optsBuilder.Configure(opts =>
opts.Opts = opts.Opts with { Url = natsUrl })));
services.AddNatsDistributedCache(options =>
{
options.BucketName = "cache";
// Create the KV bucket on first use if it doesn't already exist.
// Omit this if you pre-create the bucket yourself (see Requirements).
options.CreateBucketIfNotExists = true;
});
});
var host = builder.Build();
// Start the host
await host.RunAsync();
Automatic bucket creation
By default the KV bucket must already exist. Set CreateBucketIfNotExists = true to have the cache
create it on first use if it is missing, with the settings per-key TTL requires —
History = 1 and a non-zero LimitMarkerTTL:
services.AddNatsDistributedCache(options =>
{
options.BucketName = "cache";
options.CreateBucketIfNotExists = true;
});
To customize storage, replication, or size limits, use ConfigureBucketOnCreate. NatsKVConfig is an
immutable record, so return a modified copy with a with expression:
using NATS.Client.KeyValueStore;
services.AddNatsDistributedCache(options =>
{
options.BucketName = "cache";
options.CreateBucketIfNotExists = true;
options.ConfigureBucketOnCreate = config => config with
{
Storage = NatsKVStorageType.File,
NumberOfReplicas = 3,
};
});
Notes:
- Only a missing bucket is created; an existing bucket is used as-is and never modified, so
operator-managed settings are preserved.
ConfigureBucketOnCreatetherefore only applies when the bucket is first created. - Creating a bucket requires JetStream stream-management permissions.
- Overriding
History(away from1) or clearingLimitMarkerTTLinConfigureBucketOnCreatedisables reliable per-key TTL.
Multi-tenant Key Prefixes and Bulk Purge
Set CacheKeyPrefix to partition a single bucket across apps, services, or tenants. Every key is then
stored as <CacheKeyPrefix>.<key>, so several consumers can safely share one KV bucket:
services.AddNatsDistributedCache(options =>
{
options.BucketName = "cache";
options.CacheKeyPrefix = "tenant-42";
});
To evict every entry beneath a sub-prefix — for example, all keys for one tenant — resolve
INatsCacheMaintenance and call PurgeByPrefixAsync. It is implemented by the same singleton that backs
IDistributedCache, so it shares the bucket, key prefix, and key encoding:
using CodeCargo.Nats.DistributedCache;
var maintenance = serviceProvider.GetRequiredService<INatsCacheMaintenance>();
// Purges every key stored as "orders.<...>" beneath the configured CacheKeyPrefix.
// Returns the number of stream messages purged (see the count note below).
long purged = await maintenance.PurgeByPrefixAsync("orders");
The supplied prefix is relative to CacheKeyPrefix and matches keys hierarchically: "orders" purges
orders.a and orders.a.b, but not orders-archive.a.
Notes:
- This is a bulk, irreversible maintenance operation, not a per-request cache call. It removes every
matching entry in a single JetStream stream purge (a subject-filtered purge of the bucket's backing
KV_<bucket>stream), so the messages are deleted outright rather than left as purge-marker tombstones. - This requires JetStream stream-purge permission on the
KV_<bucket>stream, in addition to the ordinary KV access the cache uses. - The prefix must be non-empty and not consist solely of whitespace or
.characters; otherwisePurgeByPrefixAsyncthrowsArgumentException. This guards against accidentally purging the entire prefix space (or, with noCacheKeyPrefix, the whole bucket). - Purging is scoped to children of the prefix (
<prefix>.<...>); the cache never stores a bare key equal to the prefix itself. - The returned count is the number of stream messages purged. For the cache's single-revision
(
History = 1) buckets this equals the number of live entries removed, but it can also include not-yet-compacted delete markers left by earlier evictions, so treat it as an approximate count.
Controlling Expiration Timing
Expiration is computed from a TimeProvider,
defaulting to TimeProvider.System. Register a TimeProvider in DI to override the clock the cache uses —
for example, to drive expiration deterministically in tests with
FakeTimeProvider:
services.AddSingleton<TimeProvider>(new FakeTimeProvider());
services.AddNatsDistributedCache(options => options.BucketName = "cache");
Cache Entry Format and Upgrades
Cache entries are stored in a compact binary envelope. When an entry cannot be deserialized — because
it was written by an incompatible release (for example a pre-binary version that used a JSON envelope)
or is otherwise corrupt — the read is treated as a cache miss rather than an error, and logged at
Debug. Because a cache's source of truth lives elsewhere, no manual migration is required:
- Entries with a TTL are reaped automatically by NATS once they expire.
- Entries without a TTL are left in place and re-populated the next time the key is written (a
Setoverwrites the stored bytes unconditionally), which happens naturally under cache-aside usage.
Upgrading is therefore seamless in a rolling deployment: a node never deletes an entry it cannot read, so it cannot discard entries written by a newer node still being rolled out.
Telemetry
Metrics and traces are emitted through System.Diagnostics.Metrics and System.Diagnostics.ActivitySource.
This package takes no dependency on OpenTelemetry. No measurement is recorded, no duration is timed,
and no per-operation allocation occurs until a listener subscribes, so registering the names below is the
opt-in. (Each cache instance does build its meter, two instruments, and four span-name strings once on
first use, whether or not anything is listening — a fixed setup cost, not a per-operation one.)
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter(NatsCacheTelemetryNames.MeterName))
.WithTracing(tracing => tracing.AddSource(NatsCacheTelemetryNames.ActivitySourceName));
Both resolve to CodeCargo.Nats.DistributedCache. They are compile-time constants, so referencing them
does not initialize the meter.
Instruments
| Name | Type | Unit | Description |
|---|---|---|---|
nats.cache.operation.duration |
Histogram<double> | s |
Duration of cache operations |
nats.cache.misses |
Counter<long> | {miss} |
Read misses, by reason |
There is no separate hits/operations counter: the histogram's count already gives operation rate, hit
ratio, and error rate via the nats.cache.result tag.
| Tag | Applies to | Values |
|---|---|---|
nats.cache.operation |
both | get, set, refresh, remove |
nats.cache.bucket |
both | The configured BucketName |
nats.cache.result |
duration | hit, miss, ok, error, cancelled |
error.type |
duration | Exception type name; present only when result=error |
nats.cache.miss.reason |
misses | see below |
nats.cache.bucket is one value per cache instance, so it adds no meaningful cardinality while keeping
two caches in the same process distinguishable. Spans carry the same tags, plus the optional
nats.cache.key.
| Miss reason | Meaning |
|---|---|
not_found |
Key absent, or already reaped by the NATS TTL. The ordinary miss. |
expired |
Absolute expiration reached; the entry was evicted by this read. |
undeserializable |
Legacy or corrupt envelope (see Cache Entry Format and Upgrades). A sustained rate means a format migration has not drained. |
revision_conflict |
Lost an optimistic-concurrency race while refreshing a sliding expiration. A sustained rate means key contention. |
Example queries
The series names below assume the default Prometheus exporter, which appends the unit and a _total
suffix (add_metric_suffixes = true): the histogram's unit s makes it ..._seconds_bucket /
..._seconds_count, and the misses counter becomes nats_cache_misses_total. If you set
add_metric_suffixes = false, drop the _seconds infix and the _total suffix.
# Hit ratio. Both selectors must filter on the same operation — leaving it off the numerator would
# fold refresh hits into a denominator that counts only gets, producing a ratio above 1.
sum(rate(nats_cache_operation_duration_seconds_count{nats_cache_operation="get",nats_cache_result="hit"}[5m]))
/ sum(rate(nats_cache_operation_duration_seconds_count{nats_cache_operation="get"}[5m]))
# p99 latency by operation
histogram_quantile(0.99, sum by (le, nats_cache_operation)
(rate(nats_cache_operation_duration_seconds_bucket[5m])))
# Miss rate by reason
sum by (nats_cache_miss_reason) (rate(nats_cache_misses_total[5m]))
Notes
- Cache keys are never recorded on metrics. Set
options.Telemetry.RecordCacheKeys = trueto add the key to spans asnats.cache.key; it defaults tofalsebecause keys commonly embed user or tenant identifiers. TryGetfailures reportresult=error, notresult=miss. The read failed rather than finding nothing, and conflating the two would make an outage look like a cold cache. The observed miss rate a caller experiences ismiss + error.TryGetreports asoperation=get. TheIBufferWriteroverload is a zero-copy detail, not a different cache operation, so hit ratio covers all read paths.- Cancellation of the caller's own token is
result=cancelled, not an error, so shutdown-time cancellation does not trigger error alerts. AnOperationCanceledExceptionraised for any other reason — a NATS request timeout, for example — is reported asresult=error, matching how the same event is logged, so genuine failures are never hidden behind the cancellation filter. - An
ArgumentOutOfRangeExceptionfrom an invalid expiration passed toSetis not counted — that operation never reaches NATS. - A miss leaves the span status
Unset. Only genuine failures setError. - Naming: there is no stable OpenTelemetry semantic convention for caches, so
nats.cache.*is a library-scoped prefix.db.client.*was rejected because NATS KV has no registereddb.system.namevalue and cache traffic would pollute database dashboards. If OTel later stabilizes a cache convention it can be adopted alongside these names without breaking existing dashboards.
Interaction with HybridCache
nats.cache.* measures only the L2 (NATS) layer. An L1 in-process hit produces no measurement at all,
so the hit ratio above is the hit ratio of reads that reached NATS, not of application cache lookups.
Microsoft.Extensions.Caching.Hybrid (10.7.0) reports its own combined L1+L2 telemetry through
EventCounters on HybridCacheEventSource, not through a Meter — so AddMeter("Microsoft.Extensions.Caching.Hybrid")
yields nothing, and an EventListener is required for the L1 view.
NATS.Client.Core publishes its own ActivitySource for messaging spans; those nest beneath the cache
spans when both sources are subscribed.
Tuning
Omit AddMeter/AddSource to disable metrics or tracing independently. To keep hit-ratio data while
dropping the more expensive histogram:
metrics.AddView(NatsCacheTelemetryNames.OperationDurationInstrumentName, MetricStreamConfiguration.Drop);
The nats.cache.misses counter continues to record when the histogram is dropped.
Additional Resources
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- NATS.Client.KeyValueStore (>= 3.0.1)
-
net8.0
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- NATS.Client.KeyValueStore (>= 3.0.1)
- System.Diagnostics.DiagnosticSource (>= 10.0.9)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on CodeCargo.Nats.DistributedCache:
| Package | Downloads |
|---|---|
|
CodeCargo.Nats.HybridCacheExtensions
Extensions for using HybridCache with NATS. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.1 | 139 | 7/30/2026 |
| 0.3.0 | 1,010 | 6/30/2026 |
| 0.3.0-preview.4 | 72 | 6/30/2026 |
| 0.3.0-preview.3 | 1,976 | 12/24/2025 |
| 0.3.0-preview.2 | 1,594 | 5/26/2025 |
| 0.3.0-preview.1 | 191 | 5/25/2025 |
| 0.2.0 | 1,101 | 5/19/2025 |
| 0.2.0-preview.1 | 204 | 5/19/2025 |
| 0.1.2 | 252 | 5/16/2025 |
| 0.1.1 | 280 | 5/16/2025 |
| 0.1.0 | 290 | 5/12/2025 |