OmniDataAccess.NoSqlDatabases
1.2.0
dotnet add package OmniDataAccess.NoSqlDatabases --version 1.2.0
NuGet\Install-Package OmniDataAccess.NoSqlDatabases -Version 1.2.0
<PackageReference Include="OmniDataAccess.NoSqlDatabases" Version="1.2.0" />
<PackageVersion Include="OmniDataAccess.NoSqlDatabases" Version="1.2.0" />
<PackageReference Include="OmniDataAccess.NoSqlDatabases" />
paket add OmniDataAccess.NoSqlDatabases --version 1.2.0
#r "nuget: OmniDataAccess.NoSqlDatabases, 1.2.0"
#:package OmniDataAccess.NoSqlDatabases@1.2.0
#addin nuget:?package=OmniDataAccess.NoSqlDatabases&version=1.2.0
#tool nuget:?package=OmniDataAccess.NoSqlDatabases&version=1.2.0
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 —
GetOrSetAsyncandGetOrSetListAsyncwith 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, orIncrement
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 ofStreamMaxLength. Messages older than the TTL are trimmed on everyProduceAsynccall, 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 theMINIDtrim 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:EXPIREon 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 | 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 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. |
-
net8.0
- Microsoft.Extensions.Caching.Memory (>= 8.0.1)
- Microsoft.Extensions.Logging (>= 8.0.1)
- Microsoft.Extensions.Logging.Console (>= 8.0.1)
- Microsoft.Extensions.Options (>= 8.0.2)
- OmniDataAccess.Core (>= 1.2.0)
- StackExchange.Redis (>= 3.1.0)
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 |