DcsvIo.D2.Caching.Abstractions 0.1.1

dotnet add package DcsvIo.D2.Caching.Abstractions --version 0.1.1
                    
NuGet\Install-Package DcsvIo.D2.Caching.Abstractions -Version 0.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="DcsvIo.D2.Caching.Abstractions" Version="0.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="DcsvIo.D2.Caching.Abstractions" Version="0.1.1" />
                    
Directory.Packages.props
<PackageReference Include="DcsvIo.D2.Caching.Abstractions" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add DcsvIo.D2.Caching.Abstractions --version 0.1.1
                    
#r "nuget: DcsvIo.D2.Caching.Abstractions, 0.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package DcsvIo.D2.Caching.Abstractions@0.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=DcsvIo.D2.Caching.Abstractions&version=0.1.1
                    
Install as a Cake Addin
#tool nuget:?package=DcsvIo.D2.Caching.Abstractions&version=0.1.1
                    
Install as a Cake Tool

DcsvIo.D2.Caching.Abstractions

Shared abstractions for the whole D² cache stack — used by DcsvIo.D2.Caching.Local.Default (per-process), DcsvIo.D2.Caching.Distributed.Redis (cluster), DcsvIo.D2.Caching.Tiered (composed), and every consumer.

Install

dotnet add package DcsvIo.D2.Caching.Abstractions

Public surface

Building blocks (three)

The fine-grained interfaces. Implementations declare which they support; marker interfaces compose them.

  • ICacheBasicGet / GetMany / Exists / GetTtl / Set / SetMany / Remove / RemoveMany
  • ICacheAtomicSetNx / Increment / AcquireLock / ReleaseLock
  • ICacheBroadcastSetAndBroadcast / SetManyAndBroadcast / RemoveAndBroadcast / RemoveManyAndBroadcast
  • ICacheSetSetAdd / SetCardinality / SetRemove / SetContains (cluster-only — Redis SADD/SCARD/SREM/SISMEMBER)

All operations return D2Result<T> / D2Result.

Null or empty inputs (missing key, missing keys collection, missing entries dictionary) return D2Result.ValidationFailed with an InputError naming the offending parameter (built via the shared InputFailures.Required(...) helper). Implementations never throw ArgumentException for per-call caller mistakes — every per-call failure shape is observable on the result. (Construction-time / DI-registration errors are a different lifecycle concern and DO surface as exceptions — see the *AndBroadcast* carve-out below.)

Cache key convention

Cache keys follow the EntityName:{id} shape (Session:{userId}, Jwks:{kid}, WhoIs:{ip}, etc.) (EntityName:{id}). The LocalCacheOptions.KeyPrefix is a namespace prefix layered on top of that convention (handy when multiple caches share a process), not a substitute for it. Keep PII out of keys — keys leak into logs, traces, and Redis MONITOR / OBJECT inspection. Hash any user-supplied identifier first.

Marker interfaces (three)

What DI resolves. The marker name documents the cache scope at the dependency site so the reader sees the constructor parameter and immediately knows the behavioral profile, without checking registration.

ILocalCache — composes ICacheBasic + ICacheAtomic. Per-process scope. Atomic ops at process scope. No broadcast (nothing outside this process can see local cache state). Use for instance-scoped data: per-instance fingerprint cache, per-instance counters, hot in-process lookups.

IDistributedCache — composes ICacheBasic + ICacheAtomic + ICacheBroadcast

  • ICacheSet. Cluster scope. Atomic ops cluster-wide. Every read hits the remote store (no L1). Use when freshness matters more than read speed: rate-limit counters, distributed locks, ephemeral session lookups.

ITieredCache — composes ICacheBasic + ICacheAtomic + ICacheBroadcast. Composed L1+L2. Reads check L1 first / fall through to L2 / populate L1. Writes go L2-first (L1 only if L2 succeeded). Atomic ops route through L2 with L1 invalidation as side effect. Use for read-heavy entity data where freshness within a few seconds is acceptable. Does NOT compose ICacheSet — set primitives are cluster-only and tiered composition would silently hide their cluster-wide nature. Callers needing SADD/SCARD inject IDistributedCache directly.

Note on the structural identity of IDistributedCache and ITieredCache: they compose the same building blocks and are method-for-method identical. The marker distinction is purely behavioral — DI registration determines which concrete implementation a consumer gets, but the parameter type at the call site is what tells the reader whether they're consuming "single-tier remote" or "two-tier composed." Implementation behavior differs significantly; the contract surface does not.

Supporting types

LocalCacheOptionsMaxEntries (100_000), DefaultExpiration (1h), KeyPrefix (empty).

InputFailures — pre-built D2Result.ValidationFailed factory (Required<T>(paramName) / Required(paramName)) used by impls so the cache surface stays errors-as-values rather than ArgumentException-throws for per-call caller mistakes. Constructors / DI registration still throw — that's a registration-time concern, not per-call input.

ICacheSerializer — pluggable serialization for distributed caches. Default impl in distributed-redis is JSON; provider-specific impls may swap in MessagePack / Protobuf. Local caches store objects directly and don't need this.

ICacheInvalidationBackplane — optional pub/sub backplane for cross-instance L1 invalidation. Tiered consumers subscribe at construction; *AndBroadcast* writes publish on every send. Provider-agnostic — swappable for Redis pub/sub, Postgres LISTEN/NOTIFY, in-process for tests.

Provider-specific options (Redis connection string, Sentinel topology, channel prefixes for pub/sub, retry config, etc.) live on the implementation's own options class — there's no shared abstraction-level "DistributedCacheOptions" base because every distributed impl will need its own provider-specific surface anyway, and the few common fields (DefaultExpiration, KeyPrefix) are easier to redeclare per-impl than to inherit. Same for tiered: DefaultTieredCache will declare its own options when there's a real knob to expose.

Result mapping

  • GetAsync<T> — hit → Ok(value); miss → NotFound; backing-store error → failure.
  • GetManyAsync<T> — all hit → Ok(dict); some hit → SomeFound(partialDict); none → NotFound.
  • SetAsync<T> / RemoveAsyncOk (idempotent); failure on backing-store error.
  • ExistsAsyncOk(true) if present, Ok(false) if not.
  • GetTtlAsync — key + TTL → Ok(span); key but no TTL → Ok(null); absent → NotFound.
  • SetNxAsync<T> — wrote → Ok(true); already existed → Ok(false).
  • IncrementAsyncOk(newValue); type mismatch (Redis WRONGTYPE parity) → Conflict.
  • AcquireLockAsync — acquired → Ok(true); held by other → Ok(false).
  • ReleaseLockAsyncOk (idempotent — no-op if not held by this caller).
  • *AndBroadcast* — same as their plain counterparts. Throws InvalidOperationException if no ICacheInvalidationBackplane is registered. (Registration error, not a per-call failure — caught by the same lifecycle carve-out as construction-time exceptions.)
  • SetAddAsync — new member → Ok(true); already present → Ok(false).
  • SetCardinalityAsyncOk(count); absent set → Ok(0).
  • SetRemoveAsync — was present → Ok(true); was absent → Ok(false) (idempotent).
  • SetContainsAsyncOk(true|false).

Broadcast variants — when to use

The *AndBroadcast* methods write/remove AND publish an invalidation message. Other instances' tiered caches subscribe to the backplane and drop their L1 copies on receipt — keeping cluster L1 caches in sync without polling.

Use the broadcast variant when:

  • The data is shared across instances (user profile, org settings, contact info, JWKS).
  • A user-initiated remove must be visible cluster-wide quickly.
  • Coordinated state changes that depend on every instance seeing the new state.

Use the plain (non-broadcast) variant when:

  • Cache-warming / startup seed (no other instance has a stale copy of a key that didn't exist).
  • Single-writer-single-reader keys (derived from this instance's process ID, etc.).
  • Hot-path writes where the broadcast cost dominates (counter ticks, telemetry).
  • Refresh writes of effectively-the-same data.
  • Single-instance deployments (don't register a backplane and the question goes away).
  • Short-TTL data where staleness is naturally bounded.

The *AndBroadcast* naming is greppable — code review can see at a glance which writes propagate cross-cluster.

The invalidation backplane

ICacheInvalidationBackplane is the cross-instance pub/sub primitive that powers *AndBroadcast* writes and any other "tell every instance to drop K from its L1" flow.

Universal "everyone acts" rule

Every subscriber receives every message — including messages this instance itself published. There is no sender-ID filter. The cost of self-receive is bounded:

  • Tiered cache after SetAndBroadcastAsync — the publisher's L1 entry just got dropped; next read of K re-fetches from L2 (one extra round-trip, bounded by write rate)
  • Tiered cache after RemoveAndBroadcastAsync — L1 was already removed; receive is a no-op
  • External callers (SessionService.RevokeAsync etc.) — usually want their OWN instance's L1 to drop too, so this is correct behavior, not a cost

Universal rule = no flags, no per-call config, no caller bookkeeping. The session-revoke flow works automatically because the caller's L1 drops alongside everyone else's.

Subscribe pattern

public sealed class MySubscriber : IAsyncDisposable
{
    private readonly IAsyncDisposable _subscription;

    public MySubscriber(ICacheInvalidationBackplane backplane)
    {
        _subscription = backplane.Subscribe(async (key, ct) =>
        {
            // Handle the invalidation. Async-native; isolated from other
            // subscribers' handlers (an exception here doesn't affect them).
            await DoSomethingWithKey(key, ct);
        });
    }

    public ValueTask DisposeAsync() => _subscription.DisposeAsync();
}

Subscribe returns an IAsyncDisposable; the subscriber holds it as a field and disposes it when the subscriber itself is disposed. Lifetime tracking is explicit (no event += / -= mismatch potential).

The tiered cache subscribes automatically in its constructor — the act of registering services.AddD2TieredCache() is the subscription. External subscribers (the rare case where someone holds state outside the cache lib) explicitly call Subscribe(handler) in their own constructor.

Atomic on tiered — how it works

ITieredCache exposes the same atomic surface as IDistributedCache because the atomicity guarantee comes from L2 (the cluster source of truth) regardless of L1's role. Implementation pattern:

  • IncrementAsync → call L2's atomic increment. On success, invalidate L1 (force next read to repopulate from canonical L2 value) + broadcast invalidation. Counters are usually contested across instances; holding a stale local copy after-increment is worse than the tiny re-read cost.
  • SetNxAsync → call L2's SetNx. On success, write L1 + broadcast invalidation. On fail (key already existed in L2), invalidate L1 (in case L1 had a stale entry) + don't broadcast.
  • AcquireLockAsync / ReleaseLockAsync → pure delegation to L2. L1 isn't involved — lock state is a coordination primitive, not a cached value.

L1 is never authoritative for atomic state. L2 is. L1 just reflects (or invalidates).

Dependencies

  • DcsvIo.D2.Result — every op returns D2Result<T>
  • DcsvIo.D2.I18n.AbstractionsTKMessage for typed default error messages

No runtime deps beyond those (no DI, no logging, no provider libs). This abstraction stays domain-safe so any handler / repo / domain code can declare a cache dependency without dragging in implementation runtime.

References

  • DcsvIo.D2.Caching.Local.DefaultDefaultLocalCache impl
  • DcsvIo.D2.Caching.Distributed.RedisRedisDistributedCache + RedisCacheInvalidationBackplane impls
  • DcsvIo.D2.Caching.TieredDefaultTieredCache (L1+L2 composition)
Product Compatible and additional computed target framework versions.
.NET 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (4)

Showing the top 4 NuGet packages that depend on DcsvIo.D2.Caching.Abstractions:

Package Downloads
DcsvIo.D2.Caching.Tiered

Two-tier cache for D2 composing an L1 ILocalCache and an L2 IDistributedCache into the ITieredCache interface.

DcsvIo.D2.Caching.Local.Default

Default per-process ILocalCache implementation for D2, wrapping Microsoft.Extensions.Caching.Memory with in-process lock state.

DcsvIo.D2.Messaging.RabbitMq

Default RabbitMQ implementation of the D2 messaging abstractions — publishing, subscribing, encryption frames, and dead-letter handling.

DcsvIo.D2.Caching.Distributed.Redis

Redis-backed IDistributedCache and cache-invalidation backplane implementation for D2, wrapping StackExchange.Redis.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.1 152 7/17/2026
0.1.0 141 7/17/2026