OmniDataAccess.NoSqlDatabases 1.2.0

dotnet add package OmniDataAccess.NoSqlDatabases --version 1.2.0
                    
NuGet\Install-Package OmniDataAccess.NoSqlDatabases -Version 1.2.0
                    
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="OmniDataAccess.NoSqlDatabases" Version="1.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="OmniDataAccess.NoSqlDatabases" Version="1.2.0" />
                    
Directory.Packages.props
<PackageReference Include="OmniDataAccess.NoSqlDatabases" />
                    
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 OmniDataAccess.NoSqlDatabases --version 1.2.0
                    
#r "nuget: OmniDataAccess.NoSqlDatabases, 1.2.0"
                    
#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 OmniDataAccess.NoSqlDatabases@1.2.0
                    
#: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=OmniDataAccess.NoSqlDatabases&version=1.2.0
                    
Install as a Cake Addin
#tool nuget:?package=OmniDataAccess.NoSqlDatabases&version=1.2.0
                    
Install as a Cake Tool

OmniDataAccess.NoSqlDatabases

Cache provider layer for OmniDataAccess. Exposes a consistent ICacheManager API for strings, lists, sets, hash maps and counters — backed by Redis (RedisCacheManager) or the .NET in-memory cache (MemCacheManager).

📦 Installation

dotnet add package OmniDataAccess.NoSqlDatabases

Supported Providers

Provider Class Notes
Redis RedisCacheManager Backed by StackExchange.Redis; distributed, persistent
In-memory MemCacheManager Process-local; backed by IMemoryCache; no sharing across instances

DI Registration

// Redis
builder.Services.AddRedisCacheManager(options =>
{
    options.ConnectionString = "localhost:6379";
    options.DatabaseIndex    = 0;
    options.CacheExpiry      = 3600; // seconds; -1 = no expiry
    options.ShowLogs         = true; // enables connection/reconnect diagnostic messages; timing logs always emit
});

// In-memory
builder.Services.AddMemCacheManager(options =>
{
    options.ShowLogs = true;
});

Multiple instances of the same provider (keyed DI)

The cacheManagerName overload registers the manager as a .NET 8 keyed service in addition to the usual flat ICacheManager singleton, so you can run more than one cache (e.g. two Redis instances, or Redis + an in-memory fallback) and resolve each one unambiguously:

builder.Services.AddRedisCacheManager("redis-sessions", connectionString: "session-redis:6379");
builder.Services.AddRedisCacheManager("redis-ratelimits", connectionString: "ratelimit-redis:6379");
builder.Services.AddMemCacheManager("mem-local");
public class SessionService(
    [FromKeyedServices("redis-sessions")] ICacheManager sessions,
    [FromKeyedServices("redis-ratelimits")] ICacheManager rateLimits)
{
    public Task<CacheResult<string>> GetSessionAsync(string id, CancellationToken ct = default)
        => sessions.GetStringAsync(id, token: ct);
}

ICacheManager.Provider (CacheProvider.Redis / .MemCache) tells you what kind of manager you have, same as ISqlDatabaseManager.Provider — but it can't tell two Redis instances (or two MemCache instances) apart at runtime. The keyed service name is what disambiguates them.

The flat ICacheManager registration keeps working for every keyed manager too (e.g. IEnumerable<ICacheManager>, used internally by AddOmniWarmUp) — it resolves to the same instance as its keyed registration, not a duplicate connection. The unnamed overload (no cacheManagerName) has nothing to key on and stays a plain single-instance registration.

Standalone (no DI)

var redis = new RedisCacheManager(new CacheManagerOptions("localhost:6379"));
var mem   = new MemCacheManager(new CacheManagerOptions());

Cancellation

Every ICacheManager method accepts an optional trailing CancellationToken, with one exception: the inline-value GetOrSetAsync<T>(key, value, ...) and GetOrSetListAsync<T>(key, values, ...) overloads take no token (there's no I/O to cancel — the value is already in hand). Their factory-based overloads do accept one, since the factory delegate itself is async.

await cache.SaveAsStringAsync("user:1", user, token: cancellationToken);
CacheResult<User> result = await cache.GetStringAsync<User>("user:1", token: cancellationToken);

StackExchange.Redis's IDatabase async methods don't accept a token themselves, so RedisCacheManager honors cancellation via an early ThrowIfCancellationRequested() check before each operation rather than mid-flight. MemCacheManager's in-process path honors it the same way.


String (single value)

// Save — complex objects are serialised to JSON automatically
await cache.SaveAsStringAsync("user:1", user,   expiry: TimeSpan.FromHours(1));
await cache.SaveAsStringAsync("counter", 42,    expiry: TimeSpan.FromMinutes(10));
await cache.SaveAsStringAsync("flag",    true);

// Overwrite before writing (useful when you want a hard reset)
await cache.SaveAsStringAsync("user:1", updated, deleteBeforeInsert: true);

// Retrieve — returns CacheResult<T>
CacheResult<User>   result = await cache.GetStringAsync<User>("user:1");
CacheResult<string> raw    = await cache.GetStringAsync("user:1");

if (result.IsSuccess)
    Console.WriteLine(result.Data?.Name);
else
    Console.WriteLine(result.FailureReason); // CacheMiss | FailedConnection | InternalError

List

Backed by a Redis LIST (ordered, duplicates allowed) or an in-memory List<T>.

// Append to the list (creates it if it doesn't exist)
await cache.SaveAsListAsync("posts:recent", posts, expiry: TimeSpan.FromMinutes(10));

// Retrieve a range
CacheResult<IEnumerable<Post>> all    = await cache.GetListAsync<Post>("posts:recent");
CacheResult<IEnumerable<Post>> first5 = await cache.GetListAsync<Post>("posts:recent", startIndex: 0, endIndex: 4);

if (all.IsSuccess)
    foreach (var p in all.Data!) Console.WriteLine(p.Title);

Set

Backed by a Redis SET (unordered, unique) or an in-memory deduplicated list.

// Add members — duplicates are discarded
await cache.SaveAsSetAsync("online:users", userIds, expiry: TimeSpan.FromMinutes(5));

// Retrieve all members
CacheResult<IEnumerable<int>> members = await cache.GetSetMembersAsync<int>("online:users");

if (members.IsSuccess)
    foreach (var id in members.Data!) Console.WriteLine(id);

Hash

Backed by a Redis HASH or an in-memory Dictionary<TKey, TValue>.

// Store a dictionary
await cache.SaveAsHashSetAsync("user:1:meta", new Dictionary<string, string>
{
    ["name"]  = "Alice",
    ["email"] = "alice@example.com"
}, expiry: TimeSpan.FromHours(1));

// Retrieve all fields
CacheResult<IDictionary<string, string>> all = await cache.GetHashSetAsync<string>("user:1:meta");

// Retrieve one field
CacheResult<IDictionary<string, string>> one = await cache.GetHashSetAsync<string>("user:1:meta", hashKey: "email");

if (all.IsSuccess)
    foreach (var kv in all.Data!) Console.WriteLine($"{kv.Key} = {kv.Value}");

Counter

// Atomic increment (Redis INCR; non-atomic read-modify-write for MemCache)
int visits = await cache.IncrementAsync("page:home:views", expiry: TimeSpan.FromHours(24));

Cache-aside (GetOrSetAsync / GetOrSetListAsync)

ICacheManager ships two cache-aside helpers for both single values and collections. Each has a value overload (inline data) and a factory overload (async delegate invoked only on a miss).

// Single value — inline data
User? user = await cache.GetOrSetAsync("user:42", someUserObject, expiry: TimeSpan.FromHours(1));

// Single value — factory (fetches from DB on miss)
User? user = await cache.GetOrSetAsync<User>(
    "user:42",
    factory: ct => db.FindSingleAsync<User>("SELECT * FROM users WHERE id = 42", token: ct),
    expiry: TimeSpan.FromMinutes(15),
    token: cancellationToken);

// Collection — inline data
IEnumerable<Post> posts = await cache.GetOrSetListAsync("posts:recent", somePostList, expiry: TimeSpan.FromMinutes(5));

// Collection — factory
IEnumerable<Post> posts = await cache.GetOrSetListAsync<Post>(
    "posts:recent",
    factory: ct => db.FindAsync<Post>("SELECT * FROM posts ORDER BY created_at DESC LIMIT 50", token: ct),
    expiry: TimeSpan.FromMinutes(5),
    token: cancellationToken);

Delete

bool removed = await cache.DeleteAsync("user:1");

Liveness

bool alive = await cache.PingAsync();

Startup Warmup

RedisCacheManager connects to Redis lazily on the first cache operation. Pre-warming establishes the ConnectionMultiplexer during startup so the first real request does not pay the handshake cost. MemCacheManager is in-memory so its WarmUpAsync is a no-op.

Option A — explicit, after builder.Build():

var app = builder.Build();
await app.Services.GetRequiredService<ICacheManager>().WarmUpAsync();
app.Run();

Option B — use AddOmniWarmUp() from the OmniDataAccess meta-package to warm up SQL and cache managers together in one hosted service.


Provider-specific client access

// Redis — access the underlying IConnectionMultiplexer
IConnectionMultiplexer mux = redis.GetCacheInstance<IConnectionMultiplexer>();

// MemCache — access the underlying IMemoryCache
IMemoryCache mc = mem.GetCacheInstance<IMemoryCache>();

// Redis connection status
string status = await redis.GetStatusAsync();

Execution Timing Logs

Every cache operation emits an EF Core-style structured log entry unconditionally — ShowLogs does not suppress these:

info: OmniDataAccess.NoSqlDatabases.Redis.RedisCacheManager[0]
      Executed CacheCommand (1ms) [Operation='SaveAsString', Key='user:42']

info: OmniDataAccess.NoSqlDatabases.Redis.RedisCacheManager[0]
      Executed CacheCommand (0ms) [Operation='GetString', Key='user:42']

info: OmniDataAccess.NoSqlDatabases.ORM.OmniCache[0]
      Executed OmniCache (2ms) [Operation='Hit', Key='user:42']

info: OmniDataAccess.NoSqlDatabases.ORM.OmniCache[0]
      Executed OmniCache (3ms) [Operation='Miss+Set', Key='user:42']

OmniCache reports Hit, Miss, Miss+Set, Set, Delete, Exists, and Increment. The underlying RedisCacheManager / MemCacheManager logs map directly to the ICacheManager method called.

Control verbosity through the host's LogLevel configuration:

"Logging": {
  "LogLevel": {
    "OmniDataAccess": "Warning"
  }
}

Tracing

Every RedisCacheManager / MemCacheManager operation also starts an Activity under the OmniDataAccess.NoSqlDatabases ActivitySource, tagged with cache.system (redis / memcache), cache.operation, cache.key, and cache.database. Point any OpenTelemetry-compatible exporter at that source name to see cache spans alongside SQL spans from OmniDataAccess.SqlDatabases.


CacheResult<T> properties

Property Type Description
IsSuccess bool true on a cache hit
Data T? The cached value
FailureReason CacheReason CacheMiss, NoCacheData, FailedConnection, InternalError

Error handling

Read failures (GetStringAsync, GetListAsync, etc.) never throw for ordinary miss/connection scenarios — they return a CacheResult<T> with IsSuccess = false and FailureReason set, as shown above.

Write/delete/increment failures (SaveAsStringAsync, DeleteAsync, IncrementAsync, etc.) throw CacheManagerException, which speaks the same CacheReason vocabulary via its Reason property, and always preserves the original exception as InnerException:

try
{
    await cache.SaveAsStringAsync("user:1", user);
}
catch (CacheManagerException ex)
{
    Console.WriteLine(ex.Reason);          // CacheReason.FailedConnection | InternalError
    Console.WriteLine(ex.InnerException);  // original StackExchange.Redis / serialization exception
}

OmniCache — higher-level wrapper

OmniCache (in ORM/OmniCache.cs) sits on top of ICacheManager and adds:

  • Entity-level key derivation — keys are built from [CacheKey] prefix + PK value; TTL comes from [CacheExpiry]
  • Cache-aside helpers — GetOrSetAsync and GetOrSetListAsync with both inline-value and async-factory overloads
  • Per-operation timing logs — every call emits a structured log line reporting Hit, Miss, Miss+Set, Set, Delete, or Increment
var cache = OmniCache.UseRedis("localhost:6379");
// or inject via DI: builder.Services.AddOmniCache();

// Key derived from [CacheKey("user")] + PK → "user:42"
await cache.SetAsync(user);
User? hit = await cache.GetByIdAsync<User>(42);

// Cache-aside
User? user = await cache.GetOrSetAsync(
    $"user:{id}",
    fetch: () => db.FindByIdAsync<User>(id),
    expiry: TimeSpan.FromMinutes(15));

// Cache-aside for a list result
IEnumerable<User> activeUsers = await cache.GetOrSetListAsync(
    "users:active",
    fetch: () => db.Find<User>().Where(u => u.Active).ToListAsync(),
    expiry: TimeSpan.FromMinutes(5));

bool hit = await cache.ExistsAsync<User>("user:42");
int  visits = await cache.IncrementAsync("page:home:views", expiry: TimeSpan.FromHours(24));
await cache.DeleteAsync("user:42");
await cache.DeleteAsync(user); // keyed by [CacheKey] + PK, same as SetAsync(entity)

// Access the underlying ICacheManager for advanced operations
ICacheManager raw = cache.Manager;

See the root README for the full OmniCache API reference.


Message Queue & Pub/Sub

Two additional abstractions, each with a Redis-backed and an in-memory (MemCache) implementation:

Interface Backing (Redis) Backing (MemCache) Use for
IMessageQueueManager Redis Streams (XADD/XREADGROUP/XACK/XAUTOCLAIM/XPENDING/XRANGE) In-process System.Threading.Channels, keyed by queue + consumer group Durable work queues — each message is claimed by exactly one consumer within a group
IPubSubManager Redis Pub/Sub In-process System.Threading.Channels, keyed by channel Fire-and-forget broadcast — every live subscriber gets every message, no persistence, no ack

The MemCache variants are explicitly single-process: state lives only in that process's memory, is lost on restart, and never crosses process boundaries. Use the Redis-backed managers for anything distributed. Concretely: examples/OmniDataAccess.Examples.QueuePublisher and examples/OmniDataAccess.Examples.QueueSubscriber run as two separate processes and therefore need Redis to see each other's messages — swapping either one to AddMemQueueManager breaks that pairing, since its queue state never leaves that process. To exercise the MemCache provider directly, see examples/OmniDataAccess.Examples.QueueInProcess, which produces and consumes through a single shared IMessageQueueManager instance in one process.

DI Registration

// Redis-backed queue
builder.Services.AddRedisQueueManager(options =>
{
    options.ConnectionString    = "localhost:6379";
    options.ConsumerGroup       = "order-processors";
    options.MaxDeliveryAttempts = 5;
    options.ClaimMinIdleTime    = TimeSpan.FromSeconds(30);
    options.StreamMaxLength     = 10_000;             // XADD MAXLEN ~ trim; null = unbounded
    options.MessageTtl          = TimeSpan.FromHours(24); // age-based trim (exact); null = no age-based trim
    options.QueueTtl            = TimeSpan.FromHours(1);  // idle-queue expiration; null = never expires
});

// Redis-backed pub/sub
builder.Services.AddRedisPubSubManager(connectionString: "localhost:6379");

// In-memory (single-process) equivalents — same interfaces, no Redis required
builder.Services.AddMemQueueManager();
builder.Services.AddMemPubSubManager();

Standalone (no DI)

var queue  = new RedisMessageQueueManager(new MessageQueueOptions("localhost:6379"));
var pubsub = new RedisPubSubManager(new MessageQueueOptions("localhost:6379"));

IMessageQueueManager — durable, competing-consumer queue

// Producer
QueueResult<string> produced = await queue.ProduceAsync("orders", new OrderCreated(orderId),
    headers: new Dictionary<string, string> { ["source"] = "checkout-api" });
// produced.Data => stream entry id, e.g. "1720440000000-0"

// Idempotent — creates the stream and/or consumer group if either is missing
await queue.EnsureConsumerGroupAsync("orders", consumerGroup: "order-processors");

// Consumer loop — competing consumers within a group
while (!token.IsCancellationRequested)
{
    QueueResult<IEnumerable<QueueMessage<OrderCreated>>> result = await queue.ConsumeAsync<OrderCreated>(
        "orders", consumerGroup: "order-processors", count: 10, blockFor: TimeSpan.FromSeconds(5), token: token);

    if (result.IsSuccess)
    {
        foreach (var msg in result.Data!)
        {
            await ProcessOrderAsync(msg.Data!);
            await queue.AcknowledgeAsync("orders", msg.Id, "order-processors");
        }
    }
}

consumerName defaults to a name generated once from the machine name + a short GUID and cached for the lifetime of the manager instance, when neither the call nor MessageQueueOptions.ConsumerName supplies one — every subsequent call from that instance reuses the same identity rather than minting a new consumer each time (important for Redis, which never garbage-collects abandoned consumer entries in a group). count on ConsumeAsync, ClaimPendingAsync, GetPendingAsync, and ReadAsync similarly falls back to MessageQueueOptions.DefaultReadCount (10) when omitted.

Recovering stuck/crashed-consumer messages
QueueResult<IEnumerable<PendingMessageInfo>> pending = await queue.GetPendingAsync("orders", "order-processors");
// Each PendingMessageInfo carries IdleTime and DeliveryCount. The library does not auto-dead-letter —
// MaxDeliveryAttempts is informational only; callers decide policy from DeliveryCount themselves.

var claimed = await queue.ClaimPendingAsync<OrderCreated>(
    "orders", consumerGroup: "order-processors", minIdleTime: TimeSpan.FromSeconds(30));

foreach (var msg in claimed.Data!)
{
    // msg.DeliveryCount is incremented — reprocess or dead-letter based on your own threshold
}
Building a dead-letter queue

There's no dead-letter primitive in the library — MaxDeliveryAttempts is informational, and policy is left to the caller. A DLQ is just another queue key: produce onto it and ack the original off, once DeliveryCount crosses your threshold.

foreach (var msg in claimed.Data!)
{
    if (msg.DeliveryCount > options.MaxDeliveryAttempts)
    {
        await queue.ProduceAsync("orders:dead-letter", msg.Data!, headers: msg.Headers);
        await queue.AcknowledgeAsync("orders", msg.Id, "order-processors"); // drop it from the live queue
        continue;
    }

    await ProcessOrderAsync(msg.Data!);
    await queue.AcknowledgeAsync("orders", msg.Id, "order-processors");
}

See examples/OmniDataAccess.Examples.QueueSubscriber's OrderConsumerService for a full worked example, including wiring this into both the crash-recovery path and the live consume loop. That example also demonstrates two related concerns for running more than one consumer instance: it omits minIdleTime on its crash-recovery ClaimPendingAsync call (falling back to MessageQueueOptions.ClaimMinIdleTime, 30s by default) so a newly started replica doesn't steal work a live sibling picked up moments ago, and it processes each fetched batch with bounded in-process concurrency (a configurable Queue:MaxConcurrency, gated by a SemaphoreSlim) instead of strictly sequentially.

Message and queue TTL

Two independent, orthogonal TTL knobs — both null by default (no expiration), both configurable at the MessageQueueOptions level and overridable per call on ProduceAsync:

await queue.ProduceAsync("orders", new OrderCreated(orderId),
    messageTtl: TimeSpan.FromHours(24), // per-call override of MessageQueueOptions.MessageTtl
    queueTtl:   TimeSpan.FromHours(1)); // per-call override of MessageQueueOptions.QueueTtl
  • messageTtl — age-based message retention, independent of StreamMaxLength. Messages older than the TTL are trimmed on every ProduceAsync call, in addition to any count-based trim. Redis uses an exact (not approximate) XTRIM MINID — the approximate ~ form only ever removes whole internal stream nodes, so it silently no-ops on small or mixed-age streams, which isn't acceptable for a feature whose whole point is "gone within this window." Requires Redis 6.2+ for the MINID trim strategy.
  • queueTtl — idle-queue expiration: the entire queue (every consumer group, every pending/unread message) is dropped once nothing has touched it for this long. Redis: EXPIRE on the stream key, which also destroys consumer-group state stored on that same key. MemCache: a lightweight background sweep owned by the manager instance.

Queue TTL refresh semantics: queueTtl is only armed/re-armed by ProduceAsync and EnsureConsumerGroupAsync — not by ConsumeAsync, AcknowledgeAsync, or ClaimPendingAsync. This is deliberate: refreshing on consume/ack using the config default would silently clobber a shorter per-call override a producer just set. The trade-off is that a queue idle on the producer side for longer than queueTtl gets deleted even while consumers are still actively draining it. If you rely on queueTtl, either set it comfortably above your expected max consumer lag, or have a long-running consumer periodically call EnsureConsumerGroupAsync (already idempotent) as a cheap heartbeat. An expired/gone queue surfaces to callers the same way a never-created one does — Redis via QueueReason.ConsumerGroupNotFound (NOGROUP), MemCache via empty results — no separate QueueReason exists for it.

Replay / audit (Redis only)
// Raw log read — no consumer group, no ack, no pending-entries tracking
var history = await queue.ReadAsync<OrderCreated>("orders", startId: "-", endId: "+", count: 100);

// Typed convenience over the same raw log, using timestamps instead of raw stream ids
using OmniDataAccess.NoSqlDatabases.MessageQueue; // ReadFromAsync extension method

var lastHour = await queue.ReadFromAsync<OrderCreated>("orders", from: DateTimeOffset.UtcNow.AddHours(-1));

// Bound both ends
var window = await queue.ReadFromAsync<OrderCreated>(
    "orders", from: DateTimeOffset.UtcNow.AddHours(-2), to: DateTimeOffset.UtcNow.AddHours(-1));

ReadAsync/ReadFromAsync throw NotSupportedException on the MemCache provider — unlike Redis Streams it keeps no persisted log to replay.

Kafka-style timestamp seek (Redis only)

ConsumeAsync normally only ever delivers new messages to a consumer group. SeekConsumerGroupAsync repositions an existing group's delivery cursor to a point in time (Redis: XGROUP SETID), the same idea as Kafka's consumer.seek(timestamp) — useful for reprocessing a time window after fixing a bug in a consumer, without replaying the entire stream:

await queue.EnsureConsumerGroupAsync("orders", "billing-service");

bool seeked = await queue.SeekConsumerGroupAsync(
    "orders", timestamp: DateTimeOffset.UtcNow.AddMinutes(-30), consumerGroup: "billing-service");
// false if the group/queue doesn't exist

// Subsequent ConsumeAsync calls (from any consumer in the group) only see messages produced
// at/after that timestamp — ack/claim/pending semantics are unaffected
var replay = await queue.ConsumeAsync<OrderCreated>("orders", consumerGroup: "billing-service");

Millisecond-granular, like all Redis stream ids — it can't distinguish between multiple messages produced within the same millisecond. Throws NotSupportedException on the MemCache provider — no persisted log to seek into.

Queue depth
long depth = await queue.GetQueueLengthAsync("orders");

On Redis this is the stream's total entry count (XLEN) — stream-wide, not scoped to any one consumer group. The MemCache provider has no shared stream, so it reports the unread backlog for a specific group instead — pass consumerGroup to target one other than the default:

long backlog = await queue.GetQueueLengthAsync("orders", consumerGroup: "order-processors");
Removing a stale consumer
// Mirrors Redis's XGROUP DELCONSUMER — also drops that consumer's still-pending (unacked) entries from
// tracking, so ack or claim away anything you still care about first. Call on graceful worker shutdown;
// Redis never expires abandoned consumers on its own. Returns the dropped entries themselves (not just a
// count) so the caller can log or reprocess anything that was still in flight.
IEnumerable<PendingMessageInfo> droppedPending = await queue.RemoveConsumerAsync("orders", consumerName, "order-processors");
Removing a stale consumer group
// Mirrors Redis's XGROUP DESTROY — drops the whole group (every consumer identity in it, and its entire
// pending-entries list), not just one consumer. Anything still unacked in the group is lost, not reassigned
// to another group — claim or acknowledge anything you still care about first. Doesn't touch the queue
// itself or any other consumer group on it. Returns false if the group (or the queue) didn't exist.
bool removed = await queue.RemoveConsumerGroupAsync("orders", consumerGroup: "order-processors");

Use this instead of RemoveConsumerAsync when the group itself is being retired — e.g. a feature flag that decommissions a whole class of worker, not just one replica of it. For the common per-replica shutdown case, RemoveConsumerAsync is still what you want, since it leaves the group (and any live siblings still reading from it) intact.

Cleanup
bool deleted = await queue.DeleteQueueAsync("orders");

IPubSubManager — fire-and-forget broadcast

await using IQueueSubscription subscription = await pubsub.SubscribeAsync<PriceChanged>("prices",
    async (msg, ct) => await HandlePriceChangeAsync(msg));

long subscriberCount = await pubsub.PublishAsync("prices", new PriceChanged(sku, newPrice));

// Disposing/unsubscribing the handle only removes that one handler
await subscription.UnsubscribeAsync();

// Remove every subscriber on a channel
await pubsub.UnsubscribeAsync("prices");

Each MemCache subscriber has its own bounded, drop-oldest buffer (MessageQueueOptions.PubSubChannelCapacity, default 100) so one slow subscriber can't stall others or grow memory unbounded; disposing the manager itself tears down every subscription still outstanding, so nothing leaks even if callers forget to unsubscribe.

Error handling

ProduceAsync, ConsumeAsync, ClaimPendingAsync, GetPendingAsync, and ReadAsync never throw for ordinary failures — they return a QueueResult<T> with IsSuccess = false and FailureReason set from QueueReason (QueueEmpty, QueueNotFound, FailedConnection, InternalError, ConsumerGroupNotFound, MessageNotFound).

EnsureConsumerGroupAsync, SeekConsumerGroupAsync, AcknowledgeAsync, DeleteQueueAsync, PublishAsync, and SubscribeAsync throw QueueManagerException on failure — it carries the same QueueReason vocabulary via its Reason property and always preserves the original exception as InnerException.

try
{
    await queue.AcknowledgeAsync("orders", msg.Id, "order-processors");
}
catch (QueueManagerException ex)
{
    Console.WriteLine(ex.Reason);          // e.g. QueueReason.FailedConnection
    Console.WriteLine(ex.InnerException);  // original StackExchange.Redis exception
}

Startup warmup

await queue.WarmUpAsync();   // no-op for MemCache
await pubsub.WarmUpAsync();  // no-op for MemCache

Provider-specific client access

IConnectionMultiplexer mux = queue.GetQueueInstance<IConnectionMultiplexer>();
ISubscriber sub            = pubsub.GetPubSubInstance<ISubscriber>();

MessageQueueOptions (extends CacheManagerOptions)

Field Default Description
ConsumerGroup "default-group" Default consumer group name when none is passed per-call
ConsumerName null Fixed consumer name; null generates one at runtime (machine name + short GUID)
MaxDeliveryAttempts 5 Informational threshold only — the library does not auto-dead-letter; callers decide policy using PendingMessageInfo.DeliveryCount
ClaimMinIdleTime 30s Minimum idle time before ClaimPendingAsync will reassign a message
StreamMaxLength null Redis: maps to XADD MAXLEN ~ (approximate trim). MemCache: caps each group's channel to roughly this many undelivered messages, trimming the oldest once exceeded — without it, a group whose consumer never drains it grows unbounded. null = unbounded on both providers
MessageTtl null Age-based message retention, independent of StreamMaxLength. Redis: exact (not approximate) XTRIM MINID on every ProduceAsync (requires Redis 6.2+). MemCache: discards channel-front entries older than the cutoff on every produce. Overridable per call via ProduceAsync's messageTtl. null = no age-based trim on either provider
QueueTtl null Whole-queue idle expiration — Redis: EXPIRE on the stream key (also destroys consumer-group state); MemCache: background sweep. Refreshed only by ProduceAsync/EnsureConsumerGroupAsync, not by consume/ack/claim — see "Message and queue TTL" above. Overridable per call via ProduceAsync's queueTtl. null = never expires on either provider
DefaultReadCount 10 Default page size for ConsumeAsync, ClaimPendingAsync, GetPendingAsync, and ReadAsync when their count parameter is omitted; each still accepts an explicit count per call
PollInterval 200ms Ceiling on the client-side poll backoff for the Redis provider's blocking ConsumeAsync/blockFor between non-blocking XREADGROUP attempts (StackExchange.Redis has no cancellable server-side BLOCK) — starts at 20ms and doubles on each empty attempt up to this value. Ignored by the MemCache provider, which blocks natively
PubSubChannelCapacity 100 Bounded capacity of each in-process pub/sub subscriber channel on the MemCache provider (drop-oldest on overflow). Ignored by the Redis provider

Where to look in the codebase

Path Contents
Redis/RedisCacheManager.cs Redis provider with uniform LogCommand timing on every operation
MemCache/MemCacheManager.cs In-memory provider with uniform LogCommand timing on every operation
Interfaces/ICacheManager.cs Shared contract including GetLogger()
NoSqlDatabaseManagerBase.cs Shared LogCommand, PrintLog, and GetLogger helpers
CacheExceptionHelpers.cs Builds CacheManagerException with Reason classification and a preserved inner exception
Extensions/CacheExtension.cs ToHashEntries, AsRedisValueEnumerable helpers
ORM/OmniCache.cs Higher-level OmniCache wrapper (entity-level get/set) with per-operation timing
MessageQueue/Redis/RedisMessageQueueManager.cs Redis Streams-backed IMessageQueueManager
MessageQueue/Redis/RedisPubSubManager.cs Redis Pub/Sub-backed IPubSubManager
MessageQueue/MemCache/MemMessageQueueManager.cs In-process, single-instance IMessageQueueManager (System.Threading.Channels)
MessageQueue/MemCache/MemPubSubManager.cs In-process, single-instance IPubSubManager (System.Threading.Channels)
MessageQueue/Interfaces/IMessageQueueManager.cs Durable, competing-consumer queue contract
MessageQueue/Interfaces/IPubSubManager.cs Fire-and-forget broadcast contract
MessageQueue/MessageQueueManagerBase.cs Shared LogCommand, consumer-name resolution, and OTel activity helpers
MessageQueue/MessageQueueExceptionHelpers.cs Builds QueueManagerException with QueueReason classification
MessageQueue/Extensions/*.cs DI registration (AddRedisQueueManager, AddRedisPubSubManager, AddMemQueueManager, AddMemPubSubManager)
Product 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 was computed.  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 (1)

Showing the top 1 NuGet packages that depend on OmniDataAccess.NoSqlDatabases:

Package Downloads
OmniDataAccess

Package Description

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.2.0 179 8/2/2026
1.2.0-preview.260730.1 111 7/30/2026
1.2.0-preview.260729.1 78 7/29/2026
1.2.0-preview.260715.1 121 7/15/2026
1.2.0-preview.260706.2 91 7/6/2026
1.2.0-preview.260706.1 72 7/6/2026
1.2.0-preview.260705.1 73 7/5/2026
1.2.0-preview.260703.1 83 7/3/2026
1.2.0-preview.260620.1 91 6/20/2026
1.1.1 573 1/22/2026
1.1.1-rc.1.251124 239 11/24/2025
1.1.0 579 10/19/2025
1.1.0-rc.1.251006 448 10/6/2025
1.0.7-rc.1.250921 330 9/21/2025
1.0.6 394 7/29/2025
1.0.5 638 7/22/2025
Loading failed