Collections and streams
Each Redis data type has a facet: Hashes, Lists, Sets, SortedSets, Streams, Bitmaps, HyperLogLog, and Geo. Method names omit the Redis prefix. Use ExistsAsync for a key or hash field, and ContainsAsync for a member value.
bool keyExists = await redis.Keys.ExistsAsync("user:42");
bool fieldExists = await redis.Hashes.ExistsAsync("user:42", "name");
bool containsMember = await redis.Sets.ContainsAsync("team:red", "ada");
Rules that apply across this page unless a section says otherwise:
- Batch and transaction facets have the same methods without the
Asyncsuffix. - Queued commands copy key spans when queued. Caller-owned byte buffers must stay unchanged until execution completes.
- Results, including deferred ones, are owned and stay valid after response disposal.
- Key prefixes apply to keys only, not to fields, members, scan match patterns, or group and consumer names.
- In Cluster, all keys in one command must share a hash slot after prefixing.
- Unsupported servers return their normal Redis errors; Respire does not emulate missing commands.
Binary key serialization
Keys.DumpAsync(key) returns Redis's serialized bytes as an owned byte[], or null for a missing key. These are not Respire serializer payloads. DUMP omits expiry; read Keys.ExpiryAsync separately.
byte[]? payload = await redis.Keys.DumpAsync("source");
if (payload is not null)
{
await redis.Keys.RestoreAsync("copy", payload,
expiry: TimeSpan.FromMinutes(5),
options: new RespireRestoreOptions { Replace = true });
}
RestoreAsync returns true on OK. Expiry rules:
- The default and
RespireExpiry.Persistcreate a persistent key, even when replacing an expiring key. - A relative expiry must stay positive after millisecond truncation.
- An absolute
DateTimeOffsetmust be after the Unix epoch and sendsABSTTL. Past timestamps expire immediately. RespireExpiry.Keepand nonpositive values are rejected before sending, so Redis's zero TTL cannot silently make the key persistent.
RespireRestoreOptions | Meaning | Redis |
|---|---|---|
Replace | Overwrite an existing key. | 3.0+ |
IdleTimeSeconds | Nonnegative LRU idle time. | 5.0+ |
Frequency | LFU frequency, 0–255. | 5.0+ |
IdleTimeSeconds and Frequency are mutually exclusive. RESTORE requires Redis 2.6+; ABSTTL requires 5.0+. Bad payloads and existing-key conflicts return Redis server errors.
Queued forms are Keys.Dump and Keys.Restore. RESTORE borrows its input bytes until execution completes. DUMP does not invalidate cached data; RESTORE invalidates its target key.
Hashes
await redis.Hashes.SetAsync("user:42", "name", "Ada");
await redis.Hashes.SetAsync(
"user:42",
("role", "admin"),
("region", "eu-west"));
string? name = await redis.Hashes.GetStringAsync("user:42", "name");
Dictionary<string, string> profile = await redis.Hashes.GetAllAsync("user:42");
- Methods that remove fields or stream entries are
RemoveAsyncandHashes.GetAndRemoveAsync.Deletenames mean whole-key deletion or stream group/consumer lifecycle. LengthAsync(key, field)returns the value's byte length (HSTRLEN), or zero for an empty or missing field.RandomFieldAsyncreturns one field, or null for a missing hash.RandomFieldsAsyncandRandomFieldsWithValuesAsynctake a count. Positive returns up to that many distinct fields, negative allows repeats, zero returns an empty array. Field/value results are an array of pairs, so repeats are kept.- Bound
count: a negative count can return more entries than the hash holds. - The random methods require Redis 6.2+.
long nameBytes = await redis.Hashes.LengthAsync("user:42", "name");
string? randomField = await redis.Hashes.RandomFieldAsync("user:42");
string[] randomFields = await redis.Hashes.RandomFieldsAsync("user:42", count: 2);
KeyValuePair<string, string>[] samples =
await redis.Hashes.RandomFieldsWithValuesAsync("user:42", count: -5);
RespireExpiryTimeResult[] expiryTimes = await redis.Hashes.ExpiryTimeAsync("user:42", "name", "role");
RespireExpiryTimeResult[] secondResolution = await redis.Hashes.ExpiryTimeAsync(
"user:42", RespireExpiryTimePrecision.Seconds, "name", "role");
ExpiryTimeAsync (Redis 7.4+) uses HPEXPIRETIME. Pass RespireExpiryTimePrecision.Seconds for HEXPIRETIME, which rounds fractional seconds up. Results follow input order, including repeated fields.
For a single field's remaining lifetime, Hashes.ExpiryAsync(key, field, cancellationToken) returns one RespireTtlResult; a string field and a RespireKey field both select the scalar result. Pass a string[] or ReadOnlySpan<string> for the array result, even when the collection contains one field. Other collection commands such as GetManyAsync retain their array result when called with a single item and a cancellation token.
RespireExpiryTimeResult member | Meaning |
|---|---|
Exists | False for a missing field. |
HasExpiry | True when an expiration is set. |
UnixTimeMilliseconds | Always milliseconds, even with Seconds precision. Null when missing or persistent. Holds values beyond DateTimeOffset range. |
GetExpiresAt() | Nullable UTC DateTimeOffset. Throws when the timestamp is out of range. |
TryGetExpiresAt(out DateTimeOffset expiresAt) | Returns false for missing or persistent fields and out-of-range timestamps. |
Scanning fields
ScanFieldsAsync enumerates field names without values, using HSCAN NOVALUES (Redis 7.4+). ScanAsync returns field/value pairs.
await foreach (var field in redis.Hashes.ScanFieldsAsync("user:42", match: "profile:*", countHint: 100))
{
Console.WriteLine(field);
}
For manual pages, ScanFieldsPageAsync(key, cursor, match, countHint) returns a RespireHashScanPage with Fields, an unsigned 64-bit Cursor, and IsComplete:
- Start with cursor zero.
- Pass each returned cursor to the next call.
- Stop when
IsCompleteis true. An empty page with a nonzero cursor is not the end.
A null count omits COUNT; enumeration defaults to 250. Counts are hints, not page-size limits. Queued Hashes.ScanFieldsPage(...) returns the page as a pending result: execute and read it before queuing the next page. Queued pages use the queue's cache policy.
Scans are not snapshots. Duplicates are possible, and concurrent changes may or may not appear. Across pages:
- Keep the same key, match, execution form, and read policy.
- Restart from zero after topology changes.
- Use a primary read view if you move pages between execution forms.
- A continuation fails, rather than moving servers, if its replica is lost.
- A replica continuation cannot share a Cluster batch group with writes, which would route the group to the primary. Standalone batches and transactions run on the primary.
Lists
await redis.Lists.RightPushAsync("jobs", "invoice:42", "invoice:43");
string? next = await redis.Lists.LeftPopAsync("jobs");
string[] nextBatch = await redis.Lists.LeftPopManyAsync("jobs", count: 128);
string[] pending = await redis.Lists.RangeAsync("jobs", 0, 99);
PositionAsync returns one index or null. PositionsAsync returns an array, empty when nothing matches (Redis 6.0.6+). Indexes always count from the head.
- Negative
ranksearches from the tail and returns matches in that order.rankcannot be zero orlong.MinValue. count: 0returns all matches;maxLength: 0removes the scan limit. Negative values are rejected.
long? position = await redis.Lists.PositionAsync("jobs", "invoice:42", rank: -1);
long[] positions = await redis.Lists.PositionsAsync("jobs", "invoice:42", count: 10, maxLength: 1000);
long inserted = await redis.Lists.InsertBeforeAsync("jobs", "invoice:43", "invoice:42a");
await redis.Lists.InsertAfterAsync("jobs", "invoice:43", "invoice:43a");
await redis.Lists.SetAsync("jobs", -1, "invoice:44");
long existingLength = await redis.Lists.RightPushIfExistsAsync("jobs", "invoice:45", "invoice:46");
InsertBeforeAsyncandInsertAfterAsynctarget the first matching pivot. They return the new length,0for a missing key, or-1for a missing pivot.SetAsyncaccepts negative indexes. Missing keys and out-of-range indexes return Redis errors.LeftPushIfExistsAsyncandRightPushIfExistsAsyncneed at least one value. They return0without creating a missing list.- Values and pivots accept binary
RespireValueinputs.
Multi-key pops
Multi-key pops return the selected key with its values, or null when no list has data before the timeout.
PopManyAsyncuses LMPOP (Redis 7.0+). It pops up tocount(positive) from thesideof the first nonempty list, in input order. SupplyingwaitForuses BLMPOP.PopAsyncalways waits, using BLPOP or BRPOP, and returns one value.- Explicit
waitFor: nullwaits indefinitely; omitwaitForfor an immediate call. A zero wait uses Redis's minimum one-millisecond timeout.
RespireListPopManyResult? jobs = await redis.Lists.PopManyAsync(
["{jobs}:urgent", "{jobs}:normal"], count: 10, side: RespireListSide.Left);
RespireListPopResult? job = await redis.Lists.PopAsync(
["{jobs}:urgent", "{jobs}:normal"], waitFor: TimeSpan.FromSeconds(5));
if (job is { } popped)
{
long remaining = await redis.Lists.CountAsync(popped.Key);
}
Returned keys keep their binary bytes. A prefixed view strips its prefix, so you can pass the key back through that view. Values decode as UTF-8. Empty Redis keys are valid. Blocking calls use dedicated connections; cancellation discards the blocked connection by default. Opt in to connection reuse with CLIENT UNBLOCK without stalling other traffic.
Queued Lists.PopMany(keys, count, side) sends nonblocking LMPOP. Blocking pops have no queued form.
Moving several elements (Redis 8.10+)
MoveManyAsync uses LMOVEM, or BLMOVEM when waitFor is set. It returns null for a missing source, an unmet exact count, or a timeout.
countMode | Without waitFor | With waitFor |
|---|---|---|
UpTo | Moves up to count available elements. | Waits for at least one. |
Exactly | Moves nothing unless count are available. | Waits for count. |
string[]? moved = await redis.Lists.MoveManyAsync(
"{jobs}:ready", "{jobs}:processing", count: 10,
from: RespireListSide.Left, to: RespireListSide.Right,
countMode: RespireListMoveCountMode.Exactly, order: RespireListMoveOrder.Bulk,
waitFor: TimeSpan.FromSeconds(5));
- The result holds UTF-8 strings in destination order.
Bulkkeeps source order.OneByOneuses pop order, reversed when inserting at the head.- When source and destination are the same key, Redis removes the block before reinserting it. A same-end
OneByOnemove reverses the block; an opposite-end move can rotate the list. - Waits behave as for multi-key pops.
- Queued
Lists.MoveManysends only LMOVEM and never waits.
See the LMOVEM and BLMOVEM references. For waitFor and dedicated connections, see blocking queues.
Sets
await redis.Sets.AddAsync("team:red", "ada", "grace");
await redis.Sets.AddAsync("on-call", "ada");
string[] both = await redis.Sets.IntersectAsync("team:red", "on-call");
bool member = await redis.Sets.ContainsAsync("team:red", "ada");
bool[] membership = await redis.Sets.ContainsManyAsync("team:red", "ada", "missing", "ada");
long sharedCount = await redis.Sets.IntersectCountAsync("team:red", "on-call");
long atMostOne = await redis.Sets.IntersectCountAsync(limit: 1, "team:red", "on-call");
bool moved = await redis.Sets.MoveAsync("team:red", "team:blue", "grace");
string? random = await redis.Sets.PopAsync("available");
string[] randomBatch = await redis.Sets.PopManyAsync("available", count: 2);
ContainsManyAsync(SMISMEMBER, Redis 6.2+) keeps input order and duplicates. Missing members arefalse. It needs at least one member.MoveAsyncmoves one member atomically. It returnsfalseif the member was not in the source, including a missing source. A member already in the destination still counts as moved.IntersectCountAsync(SINTERCARD, Redis 7+) counts without fetching members. The limit overload putslimitbefore the keys; zero means unlimited, and negative is rejected. It needs at least one key.- For cancellation, pass a key span and a trailing token:
IntersectCountAsync(1, ["team:red", "on-call"], cancellationToken). PopManyAsyncreturns an empty array for a missing key;PopAsyncreturns null. Queued forms arePopManyandPop.
DifferenceCountAsync (SDIFFCARD) and UnionCountAsync (SUNIONCARD) count without returning members (Redis 8.10+). Missing keys are empty sets. A difference reduces the first set by every later set. At least one key is required. Limits must be nonnegative; zero means unlimited.
long exclusive = await redis.Sets.DifferenceCountAsync("team:red", "on-call");
long limited = await redis.Sets.DifferenceCountAsync(10, "team:red", "on-call");
long total = await redis.Sets.UnionCountAsync("team:red", "on-call");
long estimate = await redis.Sets.UnionCountAsync(
new RespireSetUnionCountOptions(Limit: 100, Approximate: true), "team:red", "on-call");
Approximate returns Redis's union cardinality estimate; leave it false for an exact count. The fake server always counts exactly. Immediate overloads also accept a key span and cancellation token.
Sorted sets
Conditional adds and increments
Pass RespireSortedSetAddOptions right after the key to use ZADD conditions:
bool added = await redis.SortedSets.AddAsync("scores", RespireSortedSetAddOptions.Nx, "ada", 98.5);
bool improved = await redis.SortedSets.AddAsync("scores",
RespireSortedSetAddOptions.Xx | RespireSortedSetAddOptions.Gt | RespireSortedSetAddOptions.Ch,
"ada", 100);
long changed = await redis.SortedSets.AddAsync("scores", RespireSortedSetAddOptions.Ch,
("ada", 101), ("grace", 99));
double? increased = await redis.SortedSets.IncrementAsync("scores",
RespireSortedSetAddOptions.Xx | RespireSortedSetAddOptions.Gt, "ada", 2);
| Flag | Effect |
|---|---|
Nx | Only insert missing members. |
Xx | Only update existing members. |
Gt / Lt | Only update when the new score is greater / less. New members are still added unless combined with Xx. Redis 6.2+. |
Ch | Count changed scores as well as new members. An unchanged score never counts. |
- Invalid combinations (
NxwithXx,Gt, orLt;GtwithLt) are rejected before sending. - Bulk adds need at least one entry.
- The options overload of
IncrementAsyncusesZADD INCRfor one member. It returns the new score, or null when a condition rejects the update.Gt/Ltcompare the resulting score;Chdoes not affect the result. - The options-free
IncrementAsyncusesZINCRBYand returnsdouble. - Queued
SortedSets.AddandSortedSets.IncrementreturnRespirePending<bool>,RespirePending<long>, orRespirePending<double?>. - Generic single-member adds use typed serialization, including
1/0for booleans.
Reads and other operations
RandomMemberAsync(key)returns one member ornull.RandomMembersAsync(key, count)andRandomMembersWithScoresAsync(key, count)return arrays (Redis 6.2+). Positive counts give distinct members up to the set size. Negative counts allow duplicates and return exactly the absolute count for a nonempty set. Zero returns empty.long.MinValueis rejected.- Sampling never removes members. Generic variants deserialize members, including
byte[]. A missing scalar result isdefault(T); a missing counted result is an empty array. RankWithScoreAsync(key, member, descending: false)(Redis 7.2+,ZRANK/ZREVRANK WITHSCORE) returns a nullableRespireSortedSetRankResultwith zero-basedRankandScore. It returnsnullfor a missing key or member.RankAsyncreturns only the rank.
RespireSortedSetRankResult? position = await redis.SortedSets.RankWithScoreAsync("scores", "ada", descending: true);
CountByLexAsyncandRemoveRangeByLexAsynctake aRespireLexRangewith inclusive, exclusive, or infinite bounds. Lex operations need all members to share one score.IntersectCountAsync(Redis 7+) returns only the cardinality. A nonnegativelimitcaps the count; zero means unlimited. It needs at least one key.
RespireSortedSetEntry[] sample = await redis.SortedSets.RandomMembersWithScoresAsync("scores", 3);
long common = await redis.SortedSets.IntersectCountAsync(100, "{scores}:today", "{scores}:yesterday");
var range = new RespireLexRange(RespireLexBound.Inclusive("a"), RespireLexBound.Exclusive("m"));
long matches = await redis.SortedSets.CountByLexAsync("names", range);
long removed = await redis.SortedSets.RemoveRangeByLexAsync("names", range);
Ranges, pops, and stores
await redis.SortedSets.AddAsync("scores", "ada", 98.5);
await redis.SortedSets.AddAsync("scores", ("grace", 97.5), ("linus", 96.0));
await redis.SortedSets.IncrementAsync("scores", "ada", 1.5);
double?[] scores = await redis.SortedSets.ScoresManyAsync("scores", "ada", "missing");
RespireSortedSetEntry? next = await redis.SortedSets.PopAsync("ready:scores");
RespireSortedSetEntry[] nextBatch = await redis.SortedSets.PopManyAsync("ready:scores", count: 2);
RespireSortedSetEntry<int>[] nextPlayers = await redis.SortedSets.PopManyAsync<int>("player:scores", count: 2);
RespireSortedSetEntry<int>? highest = await redis.SortedSets.PopAsync<int>("player:scores", descending: true);
RespireSortedSetEntry[] top = await redis.SortedSets.RangeWithScoresAsync(
"scores",
start: 0,
stop: 9,
descending: true);
var finalists = new RespireScoreRange(
RespireScoreBound.Exclusive(90),
RespireScoreBound.Max);
RespireSortedSetEntry[] page = await redis.SortedSets.RangeByScoreWithScoresAsync(
"scores",
finalists,
offset: 0,
count: 10,
descending: true);
var names = new RespireLexRange("a", RespireLexBound.Exclusive("m"));
string[] alphabetical = await redis.SortedSets.RangeByLexAsync("names", names);
long stored = await redis.SortedSets.StoreRangeByScoreAsync(
"finalists",
"scores",
finalists,
count: 100,
descending: true);
string[] combined = await redis.SortedSets.UnionAsync("regional:uk", "regional:eu");
long combinedCount = await redis.SortedSets.UnionStoreAsync(
"regional:all", "regional:uk", "regional:eu");
RespireSortedSetEntry<int>[] players =
await redis.SortedSets.RangeWithScoresAsync<int>("player:scores", descending: true);
- Score and lex bounds are inclusive by default. Use
Exclusive(...)for an open bound, andMin/Maxfor infinity. offsetrequirescount, because Redis sends them together asLIMIT offset count.- Intersection, union, and difference each have read and
Storeforms. - Typed rank ranges, score ranges, and pops return
RespireSortedSetEntry<T>, keeping scores.
Binary members
Bulk adds take (RespireValue Member, double Score) tuples. Members can be text, numbers, or bytes. RespireSortedSetEntry is a read result, not an input.
Byte members borrow your memory. In batches and transactions, keep them unchanged until ExecuteAsync or CommitAsync completes, or copy first, for example (buffer.ToArray(), score).
byte[] member = [0xff, 0x00, 0x80];
await redis.SortedSets.AddAsync("binary:scores", (member, 1.5), ("text", 2.5));
// Use an explicit tuple array when retaining inputs for reuse or passing cancellation.
(RespireValue Member, double Score)[] entries = [(member, 3.5), ("text", 4.5)];
await redis.SortedSets.AddAsync("binary:scores", entries, cancellationToken);
Streams
Idempotent production (Redis 8.6+)
Set RespireStreamAddOptions.Idempotency to deduplicate appends:
RespireStreamIdempotency.Manual(producerId, messageId)sends IDMP.RespireStreamIdempotency.Automatic(producerId)sends IDMPAUTO and derives the identity from the field/value payload.
Null keeps ordinary XADD. Both modes need an automatic entry ID (Id = null or "*") and nonempty identifiers. Factories copy binary identifiers, which are never key-prefixed. Equality compares the mode and identifier bytes.
var options = new RespireStreamAddOptions
{
Idempotency = RespireStreamIdempotency.Manual("orders-producer", "event-42"),
};
RespireStreamId? first = await redis.Streams.AddAsync("orders", options, ("status", "paid"));
RespireStreamId? duplicate = await redis.Streams.AddAsync("orders", options, ("status", "paid"));
Within Redis's retained history, a repeated producer/message identity returns the original entry ID, even when a manual-mode payload differs. Each producer has its own history.
This is not unlimited exactly-once delivery. Duration and size limits can evict identities, and deleting the stream removes its history. See XADD and Redis's implementation.
- Queued
Streams.Addaccepts the same options. Trimming, reference policies, and NOMKSTREAM still combine with it. - An idempotent XADD is audited as
WriteChecked, because a resend returns the original entry instead of appending again. - Cancellation after dispatch cannot prove that the write did not run.
- When a retry policy can resend a possibly executed checked write, typed
Streams.AddAsyncattaches an identity automatically. SetRespireOptions.StreamProducerIdto choose the producer.
Stream configuration (Redis 8.6+)
Streams.ConfigureAsync changes an existing stream's idempotency settings with XCFGSET. Supply at least one setting; null keeps the current value.
| Option | Range | Redis default |
|---|---|---|
IdempotencyDurationSeconds | 1–86400 whole seconds | 100 |
IdempotencyMaxSize | 1–10000 identities per producer | 100 |
Capacity eviction can remove identities before their duration ends.
await redis.Streams.AddAsync("orders", ("status", "created"));
bool configured = await redis.Streams.ConfigureAsync("orders", new RespireStreamConfigurationOptions
{
IdempotencyDurationSeconds = 300,
IdempotencyMaxSize = 1000,
});
- Changing either value clears all producer deduplication records for the stream. Reapplying unchanged values keeps them. Entries are untouched.
- Redis does not invalidate
WATCHfor these changes. - Queued
Streams.ConfigurereturnsRespirePending<bool>. Invalid options fail locally before queuing. - Cancellation cannot undo a change already sent.
Negative acknowledgements (Redis 8.8+)
NegativeAcknowledgeAsync runs XNACK. It releases pending entries from their consumers without acknowledging or deleting them, so they are claimable at once.
RespireStreamNackMode | Delivery count |
|---|---|
Silent | Decremented, stopping at zero. |
Fail | Unchanged. |
Fatal | Set to long.MaxValue. |
long released = await redis.Streams.NegativeAcknowledgeAsync(
"orders", "workers", RespireStreamNackMode.Fail, "1710000000000-0");
- The result is Redis's total count, not per-ID outcomes. Duplicate IDs count repeatedly.
PendingAsyncshows released entries with an empty consumer and a-1 msidle time.- Absent pending entries are skipped unless
RespireStreamNackOptions.Forcecreates unowned references for existing stream entries. New forced entries start with zero deliveries, except inFatalmode. RetryCountoverrides any mode with a nonnegative count. Ordinary consumers rarely needForceorRetryCount.- Cancellation overloads take an ID span (for example
["1-0"]) followed by the token. In variadic calls, writenew RespireStreamNackOptions { ... }, not target-typednew(). - IDs must be numeric. Group names accept raw binary and are copied.
- Queued form:
Streams.NegativeAcknowledge. A pre-cancelled call sends nothing; later cancellation cannot undo the write. Missing groups return server errors.
Trimming
RespireStreamAddOptions.MinId or MaxLength trims while appending. Streams.TrimAsync with RespireStreamTrimOptions trims an existing stream and returns the number of entries removed.
MinIdremoves entries with lower IDs.MaxLengthkeeps the newest entries up to that length.- The two thresholds are mutually exclusive.
TrimAsyncrequires one;AddAsyncdoes not trim without one.
await redis.Streams.AddAsync("events", new RespireStreamAddOptions
{
MinId = "1700000000000-0", Limit = 1000,
}, ("type", "updated"));
long removed = await redis.Streams.TrimAsync("events", new RespireStreamTrimOptions
{
MinId = "1700000000000-0", Approximate = true, Limit = 0,
});
| Option | Default | Meaning |
|---|---|---|
RespireStreamAddOptions.ApproximateTrim | true | Approximate trimming removes whole internal nodes and can keep entries past the threshold. |
RespireStreamTrimOptions.Approximate | false | Same, for TrimAsync. TrimByMaxLengthAsync also defaults to exact. |
Limit | null (server default) | Caps trimming work, not the removed count. Only valid with approximate trimming and a threshold. 0 disables the cap. |
- Lengths and limits must be nonnegative.
MinIdtakes numeric IDs, including milliseconds-only IDs, not range or consumer-read sentinels.- Invalid combinations fail before I/O.
- MINID and LIMIT require Redis 6.2+; MAXLEN requires Redis 5.0+. See XADD and XTRIM.
- Queued
Streams.AddandStreams.Trimvalidate and copy keys and values when queued. Immediate calls borrow binary keys and values until the call completes.
Reference-aware removal (Redis 8.2+)
RespireStreamReferencePolicy controls pending-entry references for XDELEX, XACKDEL, and trimming (RespireStreamAddOptions.ReferencePolicy, RespireStreamTrimOptions.ReferencePolicy). Null omits the token, which works on older servers. Any explicit policy, including KeepReferences, requires Redis 8.2+.
| Policy | Wire token | Effect |
|---|---|---|
KeepReferences | KEEPREF | Deletes stream entries but retains other pending references. |
DeleteReferences | DELREF | Deletes entries and removes their pending references from all groups. |
Acknowledged | ACKED | Deletes only when every group has read and acknowledged the entry. |
Streams.RemoveAsync(key, policy, ids)runs XDELEX.RemoveAsync(key, ids)runs XDEL.AcknowledgeAndRemoveAsyncruns XACKDEL: it acknowledges entries in the named group and applies the policy atomically.
RespireStreamDeletionResult[] outcomes = await redis.Streams.AcknowledgeAndRemoveAsync(
"events", "processors", RespireStreamReferencePolicy.Acknowledged, "1700000000000-0");
await redis.Streams.RemoveAsync("events", RespireStreamReferencePolicy.DeleteReferences,
"1700000000000-0", "1700000000001-0");
await redis.Streams.TrimAsync("events", new RespireStreamTrimOptions
{
MaxLength = 1000, ReferencePolicy = RespireStreamReferencePolicy.Acknowledged,
});
Results keep the requested ID order, including duplicates:
| Result | Meaning |
|---|---|
Deleted (1) | Removed. XACKDEL can also return it when clearing a pending reference to an already-deleted entry. |
NotFound (-1) | Not found. For XACKDEL, it also means the ID was not pending in the group or the group is absent, so the entry may still exist. DELREF XDELEX can return it while removing dangling references. |
Retained (2) | The ACKED condition was not met, including for unread entries. XACKDEL still acknowledged the group's pending entry. |
KEEPREF can leave pending references to deleted data. ACKED is not a hard retention bound: unread or unacknowledged entries can keep a stream above its trim threshold. With no consumer groups, Redis 8.2 allows ACKED deletion and trimming.
Both commands need at least one numeric ID. Invalid policies and sentinel or malformed IDs fail before I/O. Queued forms are Remove(key, policy, ids) and AcknowledgeAndRemove. Respire never falls back to older write commands. See XDELEX, XACKDEL, and XTRIM.
Reading without consumer groups
RespireStreamEntry[] entries = await redis.Streams.ReadAsync("events", after: "0", count: 100);
RespireStreamReadResult[] streams = await redis.Streams.ReadAsync(
[("{jobs}:events", "12-0"), ("{jobs}:audit", "8-0")],
count: 100, waitFor: TimeSpan.FromSeconds(5), cancellationToken: stoppingToken);
await foreach (var item in redis.Streams.ReadAllAsync(
[("{jobs}:events", "12-0"), ("{jobs}:audit", "8-0")], cancellationToken: stoppingToken))
{
await HandleAsync(item.Entry.GetString("type"));
// Persist item.Key and item.Entry.Id if this application needs a durable checkpoint.
}
ReadAsync runs XREAD (Redis 5.0+).
- The default start ID is
0. Only entries newer than each ID are returned.countlimits entries per stream. - Missing or empty streams are left out of multi-stream results. An empty or timed-out read returns an empty array.
- Returned keys have the client prefix removed.
- Omit
waitForfor a nonblocking read. A finite wait rounds up to milliseconds (zero becomes one millisecond). ExplicitwaitFor: nullsendsBLOCK 0;Timeout.InfiniteTimeSpanremains a compatibility alias. - Blocking reads use dedicated connections, so other commands stay responsive. Pass cancellation to interrupt a wait.
- Queued
Streams.Readis nonblocking only.
Reply limits (Redis 8.10+)
Pass RespireStreamReadOptions to the options-first ReadAsync overload or queued Read overload:
var page = await redis.Streams.ReadAsync(
new RespireStreamReadOptions { Count = 50, MaxCount = 80, MaxSize = 65_536 },
[("{events}:one", "0"), ("{events}:two", "0")]);
| Option | Meaning |
|---|---|
Count | Entries per stream. |
MaxCount | Total entries across streams. Positive, and at least Count when both are set. |
MaxSize | Positive byte budget for the whole reply. Redis still returns the first entry even if it is larger. |
- The first limit reached wins. These are server-side limits, not client-side truncation.
- Nonblocking reads visit streams in request order, so earlier streams can use the whole budget.
- Leave
RespireStreamReadOptions.WaitForunset for immediate reads. AssigningWaitFor = nullwaits indefinitely;IsBlockingreports whether the option was assigned. Queued reads reject any assignedWaitFor, including null, locally. See blocking-wait migration. - Without
MaxCountandMaxSize, the server requirements are unchanged. XREADGROUPaccepts the same limits; see Consumer groups.
Following streams with ReadAllAsync
- Tracks a last-delivered ID per stream, in memory. It advances when an entry is delivered, not when your handler succeeds. Persist checkpoints yourself to survive restarts.
- Drains each batch, then polls with one-second blocking reads. Polls are exempt from
CommandTimeout; your cancellation token bounds a wait. - Retries transient connection and server failures indefinitely, with delays from 100 ms to 3.2 seconds, until cancellation or disposal. Configure the client logger to see retries.
- Stops on authentication, ACL, configuration, and other non-transient errors, and when Cluster MOVED/ASK redirects exceed their limit.
- Disposing the enumerator between entries releases its buffered batch.
RespireStreamId.New($) in a one-shot read means the current tail. In enumeration, each$is resolved once withXREVRANGE ... COUNT 1(requiresXREVRANGEpermission); an empty stream starts at0. A failed initial lookup throws without retry, so a reconnect cannot silently skip ahead. Multi-stream lookups are not atomic. Numeric IDs avoid the lookup.
ReadAllAsync is not lossless. Trimming, deleting, or recreating a stream during an outage can remove unread entries, and reconnecting does not detect every gap. These reads create no pending entries and support no acknowledgement.
Consumer groups
await redis.Streams.CreateGroupAsync("events", "processors", createStream: true);
await foreach (var entry in redis.Streams.ReadGroupAsync(
"events",
group: "processors",
consumer: Environment.MachineName,
cancellationToken: stoppingToken))
{
await HandleAsync(entry.GetString("type"));
await entry.AckAsync();
}
RespireStreamClaimResult recovered = await redis.Streams.ClaimPendingAsync(
"events",
group: "processors",
consumer: Environment.MachineName,
minIdle: TimeSpan.FromMinutes(1),
count: 100,
cancellationToken: stoppingToken);
foreach (var entry in recovered.Entries)
{
await HandleAsync(entry.GetString("type"));
await entry.AckAsync(stoppingToken);
}
- The single-stream
ReadGroupAsyncloop blocks for five seconds per poll whenWaitForis omitted. Its options-first overload accepts the reply limits. - Pass an explicit
startAtto replay this consumer's pending entries. Replay does not block and ends when the pending entries run out. ReadGroupOnceAsync(key, group, consumer, options)reads one page. The multi-streamReadGroupAsync(streams, group, consumer, options)also reads one page: use>for new entries or numeric cursors for this consumer's pending entries.- Entries support
AckAsync. QueuedStreams.ReadGroupprovides the nonblocking forms. - Reply limits also apply to pending history and after a blocking read wakes. Entries excluded by the byte budget are not added to the pending list.
- Use
ClaimAsyncwhen you know the pending IDs.ClaimPendingAsyncreturns theXAUTOCLAIMnext position and deleted IDs for iterative recovery.
See XREADGROUP.
Consumer recovery options
RespireStreamReadOptions | Effect |
|---|---|
NoAck | New group entries skip the pending list. Existing pending entries are not acknowledged. |
ClaimMinIdle | Uses Redis 8.4 XREADGROUP CLAIM. Eligible pending entries arrive before new entries, longest idle first. |
Both work with single- and multi-stream pages, the options-first consumer loop, and queued reads. Plain XREAD methods reject them before sending.
var page = await redis.Streams.ReadGroupAsync(
[("{events}:one", ">"), ("{events}:two", ">")], "processors", "worker-2",
new RespireStreamReadOptions
{
Count = 100,
ClaimMinIdle = TimeSpan.FromMinutes(1),
WaitFor = TimeSpan.FromSeconds(5),
}, stoppingToken);
PreviousIdleTimeandPreviousDeliveryCounton a reclaimed entry describe it before this delivery. They are null when the server omits them. Redis 8.4 sends zero for both on new entries delivered withCLAIM.- Reclaimed entries support
AckAsync. WithCLAIMandNOACKtogether, only new entries skip the pending list; reclaimed entries still need acknowledgement. - Redis ignores
CLAIM,NOACK, andBLOCKfor numeric pending-history cursors.
Use options-first PendingAsync to filter by idle time (Redis 6.2+), consumer, and ID bounds. The options-based PendingAsync, ClaimAsync, and ClaimIdsAsync overloads take the options before the key.
var pending = await redis.Streams.PendingAsync(
new RespireStreamPendingOptions { MinIdle = TimeSpan.FromMinutes(1), Count = 100 },
"events", "processors", stoppingToken);
if (pending.Length > 0)
{
var ids = await redis.Streams.ClaimIdsAsync(
new RespireStreamClaimOptions { IdleTime = TimeSpan.Zero, RetryCount = 1 },
"events", "processors", "worker-2", TimeSpan.FromMinutes(1),
pending.Select(entry => entry.Id).ToArray(), cancellationToken: stoppingToken);
}
var recoveredIds = await redis.Streams.ClaimPendingIdsAsync(
"events", "processors", "worker-2", TimeSpan.FromMinutes(1),
count: 100, cancellationToken: stoppingToken);
RespireStreamClaimOptions works with ClaimAsync and ClaimIdsAsync:
| Option | Meaning |
|---|---|
IdleTime | Sends IDLE. Truncated to whole milliseconds. |
DeliveryTime | Sends an absolute Unix-millisecond TIME. Use either this or IdleTime. |
RetryCount | Sets the delivery counter. |
Force | Creates a pending record, only if the stream entry exists. |
LastId | Advances the group's last-delivered ID when greater. |
- Durations and counters must be nonnegative.
- The
minIdlethreshold onClaimAsync,ClaimIdsAsync,ClaimPendingAsync, andClaimPendingIdsAsyncrounds fractional milliseconds up, including atTimeSpan.MaxValue. - For cancellable
ClaimIdsAsync, pass the IDs as a span or array followed bycancellationToken:, as above. Theparamsoverload has no cancellation. - The IDs-only methods send
JUSTID: no field payloads and no automatic delivery-counter increment. An explicitRetryCountstill sets the counter. ClaimPendingIdsAsync(Redis 6.2+) returnsNextStart,Ids, andDeletedIds. Continue untilNextStartis0-0, even when a page has no IDs.DeletedIdsis filled on Redis 7+ and empty on older servers.- Recovery commands have no queued form. The in-memory fake does not implement these options; test them against Redis containers.
See XCLAIM, XAUTOCLAIM, and XPENDING.
Stream metadata
Streams.CreateConsumerAsync(key, group, consumer) creates a consumer in an existing group (Redis 6.2+, XGROUP CREATECONSUMER). It returns true for a new consumer and false for an existing one. It does not read entries or create a missing stream or group; those cases and wrong key types are server errors.
Streams.SetLastIdAsync(key, lastId, entriesAdded: ..., maxDeletedId: ...) runs XSETID (Redis 5.0+). Each optional argument requires Redis 7.0+ and can be set alone; omitted values stay unchanged.
XSETID is an advanced restoration command that Redis documents as internal to replication. It changes only the stream's last-generated ID. It does not add or trim entries or move consumer groups; use SetGroupPositionAsync for a group's last-delivered ID.
- Negative
entriesAddedfails locally. - Redis rejects invalid IDs, a last ID below the highest entry, an
entriesAddedbelow the stream length, and amaxDeletedIdabove the last ID. - Redis 7.0 treats a
maxDeletedIdof0-0as unchanged.
Both methods accept cancellation and have no queued form. See CREATECONSUMER and XSETID.
Bitmaps, HyperLogLogs, and geo indexes
See the dedicated bitmap, HyperLogLog, and geospatial guides for commands, result shapes, and server requirements.
bool wasActive = await redis.Bitmaps.SetAsync("active:2026-08-09", userId, true);
long active = await redis.Bitmaps.SetBitCountAsync("active:2026-08-09");
long? firstActive = await redis.Bitmaps.PositionAsync("active:2026-08-09", true);
long?[] bytes = await redis.Bitmaps.FieldReadOnlyAsync(
"packed:counters",
RespireBitFieldOperation.Get(RespireBitFieldEncoding.Unsigned(8), RespireBitFieldOffset.Fields(2)));
long?[] updated = await redis.Bitmaps.FieldAsync(
"packed:counters",
RespireBitFieldOperation.Set(RespireBitFieldEncoding.Unsigned(8), RespireBitFieldOffset.Bits(16), 10),
RespireBitFieldOperation.Increment(RespireBitFieldEncoding.Unsigned(8), RespireBitFieldOffset.Fields(2), 1));
await redis.HyperLogLog.AddAsync("visitors", sessionId);
long estimate = await redis.HyperLogLog.CountAsync("visitors");
await redis.Geo.AddAsync("cafes", new RespireGeoEntry(-0.1276, 51.5072, "london"));
RespireGeoSearchResult[] nearby = await redis.Geo.SearchAsync(
"cafes",
RespireGeoSearchOrigin.FromCoordinates(-0.1, 51.5),
RespireGeoSearchShape.Circle(10, RespireGeoUnit.Kilometers));
BITFIELD types:
RespireBitFieldEncoding.Signed(width)takes 1–64 bits;Unsigned(width)takes 1–63.RespireBitFieldOffset.Bits(n)is an absolute bit offset.Fields(n)multiplies the index by the encoding width (Redis#n). Both reject negative values. The default offset is bit zero.Get,Set, andIncrementaccept these types.FieldReadOnlyAsyncaccepts onlyGet.
For vector similarity, use the VectorSets facet. For uncommon operations and modules, use the complete command catalog.