Skip to content

Repository files navigation

NCache.OSS.Caching.Hybrid

An implementation of Microsoft's HybridCache — backed by NCache

Overview

HybridCache is a caching API introduced by Microsoft in .NET 9 that unifies in-process (L1) and distributed (L2) caching behind a single abstraction, along with a default implementation.

NCache.OSS.Caching.Hybrid is an implementation of the HybridCache abstraction that uses NCache as the distributed (L2) cache layer. It is intended as a direct replacement for the default implementation in applications that already use, or want to use, an NCache cluster.

In addition to satisfying the standard HybridCache contract, this implementation uses NCache's built-in Pub/Sub messaging to keep the in-process (L1) cache synchronized across every application instance connected to the cluster. Under the default HybridCache implementation, L1 caches on different nodes are independent and can diverge until entries expire; this implementation propagates updates, removals, and tag invalidations to every node in real time.

Abstraction Microsoft.Extensions.Caching.Hybrid.HybridCache
L1 layer NCache In-Proc cache
L2 layer NCache distributed cluster
Cross-node sync Automatic, via NCache Pub/Sub
Minimum .NET .NET 8

Feature Comparison

The default Microsoft implementation of HybridCache gives you:

  • an L1 (in-process, memory) cache
  • an L2 (distributed) cache, via IDistributedCache
  • cache stampede protection, scoped to a single node
  • tag-based invalidation

This package gives you all of that, plus:

  • real-time L1 ⇄ L2 synchronization across every node — see below
  • cross-node REMOVE and wildcard (*) invalidation
  • bulk key / tag operations, batched into a single round-trip and a single sync message
  • fine-grained HybridCacheEntryFlags for per-call control over which layer is read/written
  • structured logging via ILogger, with a dedicated diagnostic category

L1 ⇄ L2 Synchronization

This is the main thing this package adds on top of HybridCache: every node's L1 cache stays in sync, in real time, without you doing anything extra.

How it works

NCache.OSS.Caching.Hybrid provides its own backplane for L1 synchronization, built on top of NCache's Pub/Sub API — that backplane keeps every node's L1 cache in sync automatically, with no separate broker to stand up or wire in yourself.

flowchart LR
    subgraph App["🖥️ Application Tier"]
        direction TB
        N1["🧠 Node 1 — L1"]
        N2["🧠 Node 2 — L1"]
        N3["🧠 Node 3 — L1"]
    end

    PS(("📡 Pub/Sub<br/>UPDATE · REMOVE · TAG"))

    subgraph Cluster["🗄️ NCache Cluster (L2)"]
        direction TB
        S1[("Server 1")]
        S2[("Server 2")]
    end

    N1 <--> PS
    N2 <--> PS
    N3 <--> PS
    PS <--> Cluster

    classDef app fill:#eef2f7,stroke:#4a5568,color:#1a202c,stroke-width:1.5px
    classDef bus fill:#fff8e6,stroke:#b7791f,color:#5c3d00,stroke-width:1.5px
    classDef cluster fill:#f0f5f0,stroke:#4a5568,color:#1a202c,stroke-width:1.5px
    class N1,N2,N3 app
    class PS bus
    class S1,S2 cluster
Loading

Concretely, here's what triggers a sync message and what every other node does with it:

Operation What's published What other nodes do
SetAsync UPDATE for the key Refresh or invalidate their local L1 entry for that key
RemoveAsync (single or bulk) REMOVE for the key(s) Evict the key(s) from their local L1
RemoveByTagAsync TAG invalidation, with a timestamp Treat any L1/L2 entry created before that timestamp as stale
RemoveByTagAsync("*") WILDCARD invalidation Treat every entry as stale, cluster-wide

Tag invalidations don't physically delete anything — a timestamp is persisted in L2 (as a sentinel key) and broadcast to every node. On the next read, any entry created before that timestamp is treated as invalid and re-fetched, regardless of which node originally cached it. This means tag invalidation is both instant across the cluster and durable, since the sentinel lives in L2, not just in memory on one node.

The net effect: a write, remove, or invalidation on any one node is reflected on every other node within the cluster in real time — you don't write any additional code for this, and there's no separate component to configure. It falls out of registering the package against your NCache cluster.


On Top of HybridCache

A few other things this implementation adds beyond the base HybridCache contract:

  • Bulk operations — passing multiple keys or tags to RemoveAsync / RemoveByTagAsync batches them into a single L1/L2 operation and a single Pub/Sub message, instead of one round-trip per item.
  • Fine-grained cache flags — HybridCacheEntryFlags lets you disable L1 or L2 reads/writes independently, per call, so hot-but-volatile data can skip L1 while long-lived data can skip L2.
  • Independent expirations — Expiration (L2) and LocalCacheExpiration (L1) are set separately, so your distributed copy can safely outlive your local copy (or vice versa).
  • Structured, diagnosable logging — every layer logs through ILogger, with a dedicated category for debug-level tracing of cache/sync behavior when you need to troubleshoot.

Features

Synchronization

  • 📡 Pub/Sub L1 Sync — every node's local cache stays consistent automatically
  • 🔁 Cross-Node Invalidation — RemoveAsync, RemoveByTagAsync, and wildcard * flushes propagate instantly
  • 🧭 Sentinel-Based Tag Tracking — tag invalidation timestamps persisted in L2 for durability

Performance

  • 🧠 L1/L2 Hybrid Caching — blazing-fast local reads backed by distributed durability
  • 🛡️ Cache Stampede Prevention — semaphore-based locking with TRY/FINALLY safety
  • 📦 Bulk Operations — RemoveBulk / GetBulk under the hood for multi-key/tag ops

Flexibility

  • 🏷️ Tag-Based Invalidation — logical deletion, no physical scan required
  • 🎚️ Configurable Cache Flags — fine-grained control over L1/L2 read/write behavior
  • Ⓜ️ Microsoft HybridCache Compatible — true drop-in for HybridCache

Observability

  • 📜 Structured Logging — full ILogger integration
  • 🩺 Diagnostic Logging — dedicated debug category for troubleshooting
  • ⏱️ Independent L1/L2 Expiration — tune freshness vs. durability separately

Package Versions

Package Version
NCache.OSS.Caching.Hybrid NuGet 5.3.6.1
Alachisoft.NCache.Opensource.SDK >= 5.3.6.2
Microsoft.Extensions.Caching.Hybrid >= 10.4.0

Installation

.NET CLI

dotnet add package NCache.OSS.Caching.Hybrid

NuGet Package Manager Console

Install-Package NCache.OSS.Caching.Hybrid

Prerequisites

# Requirement
1 🖧 A running NCache Server cluster
2 💾 An In-Proc cache configured for L1
3 🗄️ A Replicated cache configured for L2

Quick Start

1. Configure appsettings.json

{
  "NCacheHybridCacheConfiguration": {
    "LocalCacheName": "myLocalCache",
    "DistributedCacheName": "myDistributedCache",
    "ServerList": [
      { "Ip": "192.168.1.100", "Port": 9800 },
      { "Ip": "192.168.1.101", "Port": 9800 }
    ],
    "EnableLogs": true
  }
}

2. Register services in Program.cs

Using IConfiguration
var builder = WebApplication.CreateBuilder(args);

// Option 1: Bind from configuration section
var config = builder.Configuration.GetSection("NCacheHybridCacheConfiguration");
builder.Services.AddNCacheHybridCache(config);

// Option 2: Bind directly (auto-detects section name)
builder.Services.AddNCacheHybridCache(builder.Configuration);
Using an action delegate
builder.Services.AddNCacheHybridCache(options =>
{
    options.LocalCacheName = "myLocalCache";
    options.DistributedCacheName = "myDistributedCache";
    options.ServerList = new List<ServerConfig>
    {
        new ServerConfig { Ip = "192.168.1.100", Port = 9800 }
    };
    options.EnableLogs = true;
});

3. Inject and use HybridCache

public class ProductService
{
    private readonly HybridCache _cache;

    public ProductService(HybridCache cache) => _cache = cache;

    public async Task<Product> GetProductAsync(int productId)
    {
        return await _cache.GetOrCreateAsync(
            key: $"product:{productId}",
            state: productId,
            factory: async (id, ct) => await _database.GetProductAsync(id, ct),
            tags: new[] { "products", $"category:{product.CategoryId}" }
        );
    }
}

API Reference

GetOrCreateAsync — retrieve or create with L1/L2 fallback

ValueTask<T> GetOrCreateAsync<TState, T>(
    string key,
    TState state,
    Func<TState, CancellationToken, ValueTask<T>> factory,
    HybridCacheEntryOptions? options = null,
    IEnumerable<string>? tags = null,
    CancellationToken cancellationToken = default
);
Parameter Description
key Unique cache key (null/empty handled gracefully)
state State passed to the factory
factory Async function invoked on cache miss
options Expiration, flags
tags Tags for grouping/invalidation
cancellationToken Passed to factory and semaphore

Checks L1, then L2, and falls back to the factory on a full miss — with built-in stampede protection so concurrent callers for the same key don't all hit the factory at once.

var user = await _cache.GetOrCreateAsync(
    key: $"user:{userId}",
    state: userId,
    factory: async (id, ct) => await _userRepository.GetByIdAsync(id, ct),
    options: new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),         // L2 expiration
        LocalCacheExpiration = TimeSpan.FromMinutes(5)  // L1 expiration (shorter)
    },
    tags: new[] { "users", $"tenant:{tenantId}" },
    cancellationToken: cancellationToken
);

SetAsync — write to L1 + L2, then sync the cluster

ValueTask SetAsync<T>(
    string key,
    T value,
    HybridCacheEntryOptions? options = null,
    IEnumerable<string>? tags = null,
    CancellationToken cancellationToken = default
);

Writes to L2, publishes a Pub/Sub UPDATE so every other node syncs its L1, then writes L1 locally.

await _cache.SetAsync(
    key: $"config:{configKey}",
    value: configValue,
    options: new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromHours(1),
        LocalCacheExpiration = TimeSpan.FromMinutes(10)
    },
    tags: new[] { "configuration" }
);

RemoveAsync — single key & bulk key removal

Single key

ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);

Removes the key from L1 and L2, then publishes a REMOVE message so other nodes sync.

await _cache.RemoveAsync($"product:{productId}");

Bulk keys

ValueTask RemoveAsync(IEnumerable<string> keys, CancellationToken cancellationToken = default);

Removes multiple keys in one bulk L1/L2 operation, with a single Pub/Sub notification for the batch.

var keysToRemove = new[] { "product:1", "product:2", "product:3" };
await _cache.RemoveAsync(keysToRemove);

RemoveByTagAsync — logical, timestamp-based invalidation

ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);
ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default);

This does not physically delete entries — it records an invalidation timestamp per tag (persisted in L2 as a sentinel key and broadcast via Pub/Sub), so future lookups across every node treat matching entries as stale. Using "*" as the tag performs a global, cluster-wide invalidation.

// Invalidate all products in a category
await _cache.RemoveByTagAsync($"category:{categoryId}");

// Bulk — single Pub/Sub notification for all tags
await _cache.RemoveByTagAsync(new[] { "products", "users", "orders" });

// Wildcard — invalidate everything
await _cache.RemoveByTagAsync("*");

💡 Bulk tag invalidation updates multiple tag timestamps in a single L2 operation and publishes a single Pub/Sub notification — always prefer it over looping single-tag calls.


Configuration Options

NCacheHybridCacheConfiguration
Property Type Required Default Description
LocalCacheName string ✅ – Name of the NCache In-Proc cache (L1)
DistributedCacheName string ✅ – Name of the NCache distributed cache (L2)
ServerList IList<ServerConfig> ✅ – NCache L2 server nodes
EnableLogs bool ❌ false Enable detailed logging
ServerConfig
Property Type Default Description
Ip string – NCache server IP
Port int 9800 NCache server port
HybridCacheEntryOptions
Property Type Description
Expiration TimeSpan? L2 entry expiration; falls back to DefaultEntryOptions.Expiration
LocalCacheExpiration TimeSpan? L1 entry expiration; falls back to LocalCacheExpiration config
Flags HybridCacheEntryFlags Fine-grained cache behavior
HybridCacheEntryFlags
Flag Description
None Default — read/write both layers
DisableLocalCacheRead Skip L1 reads
DisableLocalCacheWrite Skip L1 writes
DisableLocalCache Skip L1 entirely
DisableDistributedCacheRead Skip L2 reads
DisableDistributedCacheWrite Skip L2 writes
DisableDistributedCache Skip L2 entirely
DisableUnderlyingData Return default(T) instead of invoking factory for null/empty keys

Best Practices

⏱️ Expiration tiers

var options = new HybridCacheEntryOptions
{
    // L2 = source of truth, longer
    Expiration =
        TimeSpan.FromHours(1),

    // L1 = quick refresh, shorter
    LocalCacheExpiration =
        TimeSpan.FromMinutes(5)
};

🏷️ Hierarchical tagging

var tags = new[]
{
    "entity:product",
    $"category:{categoryId}",
    $"tenant:{tenantId}",
    $"product:{productId}"
};

🎯 Selective layers

// Hot, rarely-changing data
var o1 = new HybridCacheEntryOptions
{
    LocalCacheExpiration =
        TimeSpan.FromMinutes(30)
};

// Frequently-changing, skip L1
var o2 = new HybridCacheEntryOptions
{
    Flags = HybridCacheEntryFlags
        .DisableLocalCache
};

Bulk over loop — always:

// ✅ One L1/L2 bulk op + one Pub/Sub message
await _cache.RemoveAsync(products.Select(p => $"product:{p.Id}"));

// ❌ N round-trips + N Pub/Sub messages
foreach (var product in products)
    await _cache.RemoveAsync($"product:{product.Id}");

Troubleshooting

Issue Cause Solution
💥 InvalidOperationException on startup Invalid configuration Verify LocalCacheName, DistributedCacheName, ServerList
⏳ Connection timeout Server unreachable Check NCache server status & firewall rules
🔀 Data inconsistency across nodes Pub/Sub not working Verify the messaging topic exists and is reachable
❓ Unexpected null-key behavior DisableUnderlyingData flag If set → default(T); otherwise factory is invoked
🕸️ Stale data after tag invalidation Sentinel missing Confirm "$$sentinel$$:{tag}" exists in L2

Enable diagnostic logging:

{
  "Logging": {
    "LogLevel": {
      "NCache.Microsoft.Extensions.Caching.Hybrid.Opensource": "Debug"
    }
  },
  "NCacheHybridCacheConfiguration": {
    "EnableLogs": true
  }
}
Internal optimizations worth knowing about
  • isItemInvalid flag: an L1 hit with invalid tags skips L2 entirely and goes straight to the factory.
  • TRY/FINALLY cleanup: semaphore locks always release, even on exceptions.
  • Expiration precedence: options.Expiration > config default (L2); options.LocalCacheExpiration > config default (L1).
  • Bulk operations: RemoveBulk / GetBulk used automatically for multi-key/tag calls.
  • Sentinel keys: tag timestamps persist in L2 as "$$sentinel$$:{tag}" / "$$sentinel$$:*".

License

Copyright © 2005–2026 Alachisoft. All rights reserved.

Resources

About

The NCache-HybridCache integration provides a high-performance, two-tier caching strategy that combines the speed of local memory with the scale of a distributed cluster.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages