Strings and keys
Multi-key commands accept one key and a cancellation token without a collection
expression, for example redis.DeleteAsync("key", cancellationToken),
redis.Keys.UnlinkAsync("key", cancellationToken), and
redis.Strings.GetManyAsync("key", cancellationToken). The single-key overload
keeps the multi-key result shape: GetManyAsync still returns an array.
Collection facets also accept a single member, field, or other item followed by
a token, such as redis.Lists.LeftPushAsync("jobs", "job", cancellationToken).
Common string and key operations are on IRespireClient directly. The full surfaces are redis.Strings and redis.Keys.
Read and write
await redis.SetAsync("greeting", "hello");
string? greeting = await redis.GetStringAsync("greeting");
await redis.SetAsync("visits", 1);
long visits = await redis.IncrementAsync("visits");
Conditional writes use RespireSetWhen:
bool created = await redis.SetAsync(
"lock:invoice:42",
requestId,
expiry: TimeSpan.FromSeconds(20),
when: RespireSetWhen.NotExists);
Large binary values
On plain socket connections, large array-backed binary values are sent straight from your memory. Keep the input unchanged until SetAsync completes, including after cancellation, timeout, or failure. Cancellation and command deadlines can wait for an accepted socket write to finish. TLS, custom streams, and other command shapes copy the value instead.
GatheredWriteStallTimeout bounds a stalled borrowed write:
| Value | Behavior |
|---|---|
null (default) | Inherits ConnectionIdleReadTimeout. |
| 1 ms or more | Overrides ConnectionIdleReadTimeout, even when receive checks are disabled. |
Timeout.InfiniteTimeSpan | Disables only this bound. A configured receive timeout still applies. |
When no socket send completes for that period, the watchdog closes the socket, waits for the kernel to release your memory, and then completes the operation.
- The connection is discarded. Other pending commands on it can fail.
- Redis may have received part or all of the SET.
- The timer starts when the borrowed memory is queued and runs until the whole owning buffer is sent. Completed sends reset it; new enqueues do not.
- A paused
GetStreamAsyncconsumer does not suspend it. Cancellation alone does not abort an accepted frame.
With no timeout set (both options default to null), a peer that never resumes reading can hold the operation until the socket fails or the client is disposed, even after cancellation or a deadline. In every case, reuse the input only after the operation completes. See independent liveness bounds.
Stream a value
Stream large values without building a payload-sized command buffer:
await using var file = File.OpenRead("archive.bin");
await redis.Strings.SetAsync("archive", file, file.Length);
- Pass the exact, nonnegative byte length. Respire reads at most that many bytes, does not seek, and leaves the stream open.
- A seekable stream with fewer remaining bytes throws
ArgumentOutOfRangeExceptionbefore sending. Any other source that ends early throwsEndOfStreamException. - Respire reads the first chunk (up to 32 KiB) before sending. A failure, end, cancellation, or timeout within that chunk throws without sending anything. The stream has still been read, so retry with a fresh or rewound source.
- In Cluster, if the slot moves while the first chunk is read, Respire sends the chunk to the new owner without rereading it. A read that was canceled or faulted with an unknown byte count fails instead.
- The
ReadOnlySequence<byte>overload writes its segments in 32 KiB chunks without combining them. Keep its memory unchanged until the task completes. - Both overloads always stream and allocate a little per call. Use the ordinary
SetAsyncoverloads for small values. - Prefer seekable or in-memory sources.
Connections:
- Each upload uses a dedicated connection, so a slow source does not block other commands.
- Concurrent uploads have no connection limit: Respire opens a new connection when none is idle. Bound concurrent uploads yourself to limit Redis connections. At most four idle connections are kept, shared with blocking commands.
- After a
MOVINGhandoff (standalone, Cluster, or Sentinel), new uploads use the new endpoint; uploads in progress finish on the old one. - During Sentinel failover, an upload in progress finishes on its original connection. An interrupted upload is not replayed on the new primary, because the old primary may have accepted it.
Failures:
CommandTimeoutcovers the whole upload, including every source read and socket write. Raise it, or set it tonull, for slow uploads.- Cancellation, a read failure, or a timeout before the full frame is written closes the upload connection. If that connection closes while Respire reads the source, the call throws
RespireConnectionException. - After the full frame is written, cancellation only stops the wait for the reply. Redis may still apply the write.
- After a transport failure once the header is sent, Respire does not replay the write. Redis may or may not have applied it.
MOVEDandASKare followed for seekable streams (replayed from their original position) and unchangedReadOnlySequence<byte>sources. Non-seekable streams return the redirect to you.
Expiry
RespireExpiry is the single expiry argument: none, a relative TTL, an absolute instant, or "keep the current TTL". TimeSpan and DateTimeOffset convert implicitly.
string token = "session-token";
await redis.SetAsync("session:42", token); // no TTL (clears any existing one)
await redis.SetAsync("session:42", token, TimeSpan.FromMinutes(30)); // PX
await redis.SetAsync("session:42", token, RespireExpiry.At(midnight)); // PXAT
await redis.SetAsync("session:42", token, RespireExpiry.Keep); // KEEPTTL
Expiry inputs and results have different meanings:
| Meaning | Type and parameter | Examples |
|---|---|---|
| Expiry policy sent to Redis | RespireExpiry expiry | SetAsync, ExpireAsync, GetOrSetAsync |
| Remaining lifetime reported by Redis | RespireTtlResult | Keys.ExpiryAsync (PTTL) |
| Absolute expiry instant reported by Redis | RespireExpiryTimeResult | Keys.ExpiryTimeAsync (PEXPIRETIME/EXPIRETIME) |
| Relative lock lease | TimeSpan leaseDuration | Locks.AcquireAsync, Locks.TryTakeAsync |
Both result types distinguish a missing key or field from a persistent one using Exists and
HasExpiry. RespireTtlResult.TimeToLive is a duration; RespireExpiryTimeResult.UnixTimeMilliseconds is the
server's deadline. They remain separate because adding a TTL to the client's clock cannot recover
the server's exact deadline. Expiry result types are never command inputs.
Individual commands accept only meaningful expiry policies. Cache-aside writes require a positive
relative expiry or a positive Unix-millisecond absolute instant. Lock leases are always relative
durations so renewals can reset the lease from now. Operation timeout parameters, such as MIGRATE's
I/O timeout, bound an operation rather than the lifetime of a key.
Compare values before writing or deleting
Strings.SetConditionalAsync and Strings.DeleteConditionalAsync compare the stored bytes or digest atomically before acting.
var expected = RespireValueCondition.EqualTo("version-1");
bool changed = await redis.Strings.SetConditionalAsync(
"document", (RespireValue)"version-2", expected, RespireExpiry.Keep);
// Redis 8.4+: capture a digest, then condition a later write on that digest.
string? digest = await redis.Strings.DigestAsync("document");
if (digest is not null)
{
bool deleted = await redis.Strings.DeleteConditionalAsync(
"document", RespireValueCondition.DigestEqualTo(digest));
}
| API or condition | Command | Minimum supported server |
|---|---|---|
EqualTo(value) with conditional SET | SET ... IFEQ value | Redis 8.4 or Valkey 8.1 |
NotEqualTo(value) with conditional SET | SET ... IFNE value | Redis 8.4 |
DigestEqualTo(digest) / DigestNotEqualTo(digest) with conditional SET | SET ... IFDEQ / IFDNE | Redis 8.4 |
DeleteConditionalAsync with any of these four conditions | DELEX | Redis 8.4 |
DigestAsync | DIGEST | Redis 8.4 |
DeleteIfEqualAsync(key, value) | DELIFEQ | Valkey 9.0 |
- Commands are sent as is: no version negotiation, script fallback, or substitution between Redis
DELEXand ValkeyDELIFEQ. Unsupported commands, malformed digests, and wrong-type keys return server errors. - Neither conditional delete works on all Redis and Valkey versions. Check the server flavor and version first. Use
Keys.DeleteAsyncfor unconditional deletes. - Conditional SET returns
truewhen it writes andfalsewhen the comparison fails. On a missing key, equality conditions fail and Redis inequality conditions create the key. - Conditional delete returns
falsefor a missing key or a mismatch. - A failed SET keeps the value and expiry. A successful SET accepts a relative or absolute expiry or
RespireExpiry.Keep; the default clears the expiry.RespireExpiry.Persistis not allowed. - Relative expiry truncates to whole milliseconds. A positive duration under 1 ms becomes
PX 0, which the server rejects without changing anything.
GetAndSetConditionalAsync (SET ... GET) returns the old string even when the comparison fails; null means the key was missing. The result is not a write-success flag. The generic form deserializes the old value. Generic SET methods serialize the new value, but comparison operands are always raw bytes: serialize an operand yourself to compare a serialized object.
byte[] expectedBytes = [0xff, 0x00];
var condition = RespireValueCondition.EqualTo(expectedBytes);
byte[] replacement = [0x01, 0xfe];
byte[]? previous = await redis.Strings.GetAndSetConditionalAsync<byte[]>(
"binary", replacement, condition, RespireExpiry.Keep);
// Valkey 9.0+: delete only if the stored bytes still match.
bool removed = await redis.Strings.DeleteIfEqualAsync("binary", replacement);
- Conditions copy binary operands, so you can reuse them. New-value buffers and
DeleteIfEqualAsyncoperands are borrowed: keep them unchanged until completion. - Keys get the view's prefix and determine the Cluster slot. Operands are never treated as keys.
DIGESTreturns a hexadecimal string or null. Digests can collide; use byte equality when exactness matters.
Batches and transactions expose SetConditional, GetAndSetConditional, DeleteConditional, DeleteIfEqual, and Digest (with the generic SET forms) on Strings. Results stay valid after the queue is disposed. Cancellation does not prove the server did not run a command.
using var batch = redis.CreateBatch();
var pending = batch.Strings.SetConditional(
"document", (RespireValue)"version-2", RespireValueCondition.EqualTo("version-1"));
await batch.ExecuteAsync();
bool changed = pending.Result;
The client-side cache invalidates the key around immediate comparisons, even failed ones. DIGEST does not invalidate.
References: Redis SET, DELEX, DIGEST; Valkey SET, DELIFEQ.
Bulk operations
await redis.Strings.SetManyAsync(
("feature:a", "on"),
("feature:b", "off"));
// MSETNX writes both keys only when neither exists.
bool allCreated = await redis.Strings.SetManyIfNotExistsAsync(
("reservation:42:owner", "alice"),
("reservation:42:state", "pending"));
// One shared expiry (and optional NX/XX) for every pair — Redis MSETEX.
await redis.Strings.SetManyExpireAsync(
TimeSpan.FromMinutes(5),
RespireSetWhen.NotExists,
("feature:a", "on"),
("feature:b", "off"));
string?[] values = await redis.Strings.GetManyAsync("feature:a", "feature:b");
long removed = await redis.DeleteAsync("feature:a", "feature:b");
SetManyIfNotExistsAsync uses atomic MSETNX (also in Valkey):
- Returns
falsewithout changing anything when any key exists, whatever its type. New values have no expiry. - Needs at least one pair. Empty keys and values are valid. With duplicate keys, the last value wins.
- In Cluster, prefixed keys must share a slot, or it throws
RespireCrossSlotExceptionbefore sending. - Values are raw
RespireValues. Keep binary buffers unchanged until completion. - Batches and transactions expose
Strings.SetManyIfNotExists(pairs).
Variadic APIs take params ReadOnlySpan<T>. Because params must come last, each has an overload that takes the items plus a required CancellationToken:
string?[] values = await redis.Strings.GetManyAsync(keys, cancellationToken);
long removed = await redis.DeleteAsync(keys, cancellationToken);
Key lifetime
await redis.ExpireAsync("session:42", TimeSpan.FromMinutes(30));
RespireTtlResult ttl = await redis.Keys.ExpiryAsync("session:42");
await redis.Keys.ExpireAsync("session:42", RespireExpiry.Persist);
await redis.Keys.ExpireAsync("report", RespireExpiry.At(DateTimeOffset.UtcNow.AddDays(1)));
await redis.Keys.ExpireAsync("lease", TimeSpan.FromMinutes(10), RespireExpireWhen.GreaterThan);
var value = await redis.Strings.GetAndExpireAsync("session:42", TimeSpan.FromMinutes(30));
CachedJob? job = await redis.Strings.GetAndDeleteAsync<CachedJob>("jobs:next");
Keys.ExpireAsyncand queuedKeys.ExpireacceptRespireExpireWhen.NotExists(NX),Exists(XX),GreaterThan(GT), andLessThan(LT). Relative inputs sendPEXPIREand absolute inputsPEXPIREAT.RespireExpiry.PersistsendsPERSIST, which rejects conditions.- Generic get-and forms deserialize with the client serializer.
- Hash fields use
GetAndRemoveAsyncandGetAndExpireAsync. Removing the last field deletes the hash.
TypeAsync returns RespireKeyType. Rename and copy have conditional forms:
RespireKeyType type = await redis.Keys.TypeAsync("session:42");
bool renamed = await redis.Keys.TryRenameAsync("draft", "published");
bool copied = await redis.Keys.CopyAsync("template", "working-copy", replace: true);
To copy into another database, pass destinationDatabase (Redis 6.2+ COPY DB):
bool copied = await redis.Keys.CopyAsync("template", "working-copy",
destinationDatabase: 1, replace: true);
Copy returns false when the source is missing, or the destination exists without replace: true. A negative database fails locally. In Cluster, both keys must share a hash slot. batch.Keys.Copy and transaction.Keys.Copy take the same options. See Copy to another database for the full rules.
Scan safely
ScanAsync manages Redis cursors and returns an async stream:
await foreach (string key in redis.Keys.ScanAsync(
match: "session:*",
type: RespireKeyType.Hash,
countHint: 500,
cancellationToken: stoppingToken))
{
await InspectAsync(key);
}
countHintmaps toCOUNT. It guides work per iteration; it does not set the page size.- Use
SCANinstead ofKEYSin production. Each page yields, so the server is never blocked by one sweep. - In Cluster, see resumable Cluster scans for checkpoints and Valkey 9.1 server cursors for
CLUSTERSCAN.
Key-prefixed views
Create a lightweight view when a service or tenant needs a namespace:
IRespireClient tenant = redis.WithKeyPrefix("tenant:42:");
await tenant.SetAsync("settings", json); // tenant:42:settings
Use the RespireKey overload for arbitrary prefix bytes, including NUL and invalid UTF-8:
RespireKey prefix = new byte[] { 0xff, 0x00, (byte)':' };
IRespireClient binaryTenant = redis.WithKeyPrefix(prefix);
await binaryTenant.SetAsync("settings", "enabled");
- Empty prefixes are rejected. Binary prefixes are copied when the view is created.
- Nested views append prefixes in order. Text-only composition behaves like string concatenation, including surrogate pairs split across a boundary. Once a binary part is present, its bytes are kept exactly and later text parts are encoded as UTF-8 separately.
- Views share the root's connections and client-side cache, and keep its read-routing and cache-bypass policies. Disposing a view does not dispose the root.
- Keys are prefixed before Cluster slot selection, so hash tags can span the prefix boundary. Cache invalidation, keyspace notification filters, and
ResolveKeyuse the physical (prefixed) key. - Pub/sub channels are not prefixed. Use WithPubSubPrefix for those; composing either view keeps the other namespace.
- A prefix is not a security boundary.
What gets prefixed:
- Typed key arguments, script keys, streams, batches, transactions, supported deferred raw layouts, and explicitly registered immediate module layouts.
- Not prefixed: immediate caller-supplied core commands and interpolated commands. Pass physical keys to them.
- Rejected: immediate catalog commands without a prefixable layout, and unsupported deferred layouts.
SORT BY/GETexternal patterns reject prefixes that contain*,->, or NUL, because Redis would read them as pattern syntax.
Scans escape the prefix in MATCH and strip it from returned keys. Binary prefixes are matched as bytes, so different invalid UTF-8 prefixes stay distinct. ScanAsync and ScanClusterPageAsync return strings, so invalid UTF-8 suffixes are decoded with replacement characters and cannot round-trip. Resume a Cluster checkpoint with the same prefix and filter; see resumable Cluster scans.
Default root namespace
Set RespireOptions.KeyPrefix to apply a namespace to the root client:
await using var namespaced = RespireClient.Create(new RespireOptions
{
Endpoints = { new RespireEndpoint("localhost", 6379) },
KeyPrefix = "tenant:42:", // Also accepts RespireKey, byte[] or ReadOnlyMemory<byte>.
});
await namespaced.SetAsync("settings", "enabled"); // tenant:42:settings
IRespireClient section = namespaced.WithKeyPrefix("section:");
await section.GetStringAsync("settings"); // tenant:42:section:settings
CreateandConnectAsynccapture the prefix before connecting. EachConnectAnyAsynccandidate uses its own prefix.- An empty or default prefix disables root prefixing. (
WithKeyPrefixstill rejects empty prefixes.) - Binary values keep their exact bytes and are copied. Text values follow the same UTF-16 rules as text views.
- Derived views append to the root namespace and keep its routing and cache policies.
- Every view rule above applies. Cache tracking prefixes and invalidation observers use physical keys, including the root prefix.
KeyPrefixdoes not change pub/sub channels. SetRespireOptions.PubSubPrefixor use WithPubSubPrefix.
Connection strings accept keyPrefix in both forms:
redis://localhost:6379/0?keyPrefix=tenant%3A42%3A
localhost:6379,keyPrefix=tenant%3A42%3A
- Values are percent-decoded once and encoded as UTF-8. Build them with
Uri.EscapeDataString(prefixText), especially for%, commas,&,=, NUL, or leading or trailing spaces. +stays+; use%20for a space. Comma-form values are trimmed before decoding.- Malformed escapes and undecodable bytes stay literal (
%FFstays%FF). For prefixes that are not UTF-8 text, useRespireOptions. - Option names are case-insensitive. The last
keyPrefixwins, andkeyPrefix=disables prefixing. Unknown options throw.
Absolute expiration and object metadata
RespireExpiryTimeResult expiration = await redis.Keys.ExpiryTimeAsync("session:42");
string? encoding = await redis.Keys.EncodingAsync("session:42");
TimeSpan? idle = await redis.Keys.IdleTimeAsync("session:42");
long? references = await redis.Keys.ReferenceCountAsync("session:42");
Keys.ExpiryTimeAsync(key) sends Redis 7 PEXPIRETIME; pass RespireExpiryTimePrecision.Seconds for EXPIRETIME. Both return RespireExpiryTimeResult, also used for hash-field expiration:
Existstells a missing key from a persistent one.HasExpiryand nullableUnixTimeMillisecondsgive the absolute expiration, not the remaining TTL. The seconds variant keeps the server's whole seconds, expressed in milliseconds.- Use
TryGetExpiresAt(out var instant)when a timestamp might exceed theDateTimeOffsetrange.
EncodingAsync, IdleTimeAsync, FrequencyAsync, and ReferenceCountAsync send OBJECT ENCODING, IDLETIME, FREQ, and REFCOUNT:
- Missing keys return null. Idle time has one-second resolution.
- Frequency is a logarithmic counter, not an exact count. It requires an LFU eviction policy, under which
IDLETIMEis unavailable. Other combinations return the server error. - Encodings and reference counts are Redis internals and can change with value size or server version.
- Batch and transaction
Keysfacets exposeExpiryTime,Encoding,IdleTime,Frequency, andReferenceCount. None of the results needs disposal.
References: EXPIRETIME, OBJECT IDLETIME, OBJECT FREQ.
Longest common subsequence
Redis 7.0+ LCS compares the values at two keys. Strings.LcsAsync returns the subsequence text, Strings.LcsLengthAsync its total byte length, and Strings.LcsIndexAsync typed match ranges:
RespireLcsIndexResult result = await redis.Strings.LcsIndexAsync(
"before", "after", new RespireLcsOptions
{
MinimumMatchLength = 4,
IncludeMatchLength = true,
}, cancellationToken);
foreach (RespireLcsMatch match in result.Matches)
{
Console.WriteLine($"{match.FirstRange.Start}-{match.FirstRange.End}: " +
$"{match.SecondRange.Start}-{match.SecondRange.End} ({match.Length} bytes)");
}
- Ranges are zero-based, inclusive byte offsets, also for UTF-8 values.
- Matches keep Redis order: the last match comes first.
MinimumMatchLengthsendsMINMATCHLENand must be nonnegative; null omits it.IncludeMatchLengthsendsWITHMATCHLEN; without it, each match'sLengthis null.- The result's
Lengthis the total length before filtering, even if every match is filtered out. - Missing keys or no common subsequence return no matches and zero.
- Batches and transactions expose
Strings.LcsIndex. Both keys get the view's prefix. In Cluster, both keys must share a slot, checked before sending.
For example, ohmytext and mynewtext have total length 6, with ranges 4-7 / 5-8 and 2-3 / 0-1. Filtering at 4 bytes returns only the first pair, still with total length 6. See the Redis LCS reference.
Bounded counters with expiry
Redis 8.8+ INCREX atomically increments, applies optional inclusive bounds, and updates the expiry. Strings.IncrementExtendedAsync has long and double overloads, both returning Value and AppliedIncrement:
RespireIncrementResult<long> counter = await redis.Strings.IncrementExtendedAsync(
"requests", by: 1, options: new RespireIntegerIncrementOptions
{
UpperBound = 100,
Expiry = TimeSpan.FromMinutes(1),
ExpireOnlyWhenPersistent = true,
});
RespireIncrementResult<double> balance = await redis.Strings.IncrementExtendedAsync(
"balance", by: -0.5, options: new RespireFloatIncrementOptions
{
LowerBound = 0,
Saturate = true,
});
using var batch = redis.CreateBatch();
var queued = batch.Strings.IncrementExtended("requests", options: new RespireIntegerIncrementOptions
{
UpperBound = 100,
});
await batch.ExecuteAsync();
long applied = queued.Result.AppliedIncrement;
- A missing key starts at zero.
- Without
Saturate, a result outside the bounds or the server's numeric range leaves the value and TTL unchanged and reportsAppliedIncrement0. - With
Saturate, Redis clamps the result and reports the actual delta. An unrepresentable delta still errors. AppliedIncrement0 does not mean nothing changed: the expiry can still update.Expirytakes a positive relative TTL (PX), a positive absolute instant (PXAT), orRespireExpiry.Persist.NoneandKeepkeep the TTL. An expiry in the past deletes the key after computing the result.ExpireOnlyWhenPersistentsendsENXand needs a relative or absolute expiry. It limits only the expiry update; the increment still runs on keys with a TTL.- Integer replies keep all 64 bits. Float increments must be finite; bounds may be infinite but not NaN. Redis computes in
long doubleand replies convert to .NETdouble, so precision and range can differ. - Batches and transactions expose
Strings.IncrementExtended. Writes invalidate only that key in the client-side cache. - Servers without
INCREX(older Redis, Valkey) return an unsupported-command error. There is no fallback. The coordination fixed-window limiter has its own Lua fallback.
Resumable Cluster scans
In Cluster mode, Keys.ScanAsync and Keys.ScanClusterPageAsync share a slot-aware page engine. Use the page API when you need a durable checkpoint:
await using var cluster = await RespireClient.ConnectAsync(new RespireOptions
{
UseCluster = true,
Endpoints = { new("redis-1", 6379), new("redis-2", 6379) },
});
var cursor = RespireClusterScanCursor.Start;
do
{
var page = await cluster.Keys.ScanClusterPageAsync(cursor, match: "user:*", countHint: 250);
foreach (var key in page.Keys)
Console.WriteLine(key);
// Persist this string after processing every key in the page.
string checkpoint = page.Cursor.ToString();
// The same string can be parsed by another process connected to this cluster.
cursor = RespireClusterScanCursor.Parse(checkpoint);
if (page.WaitingOnMigration) await Task.Delay(250);
}
while (!cursor.IsComplete);
Using cursors:
- Cursors are immutable. A failed or cancelled page call leaves its input cursor usable; retry it after a transient failure.
- Save the returned cursor only after processing its keys. Make processing idempotent: pages can repeat keys.
- Empty pages can have an incomplete cursor.
COUNTis a server work hint, not a maximum. CompletedSlotCountreports progress across 16,384 slots and can decrease after resharding.- A
MATCHthat pins one slot (a fixed hash tag such as{tenant}:*, or an exact key) scans only that slot. Wildcards or escapes before the hash tag, and text prefixes that end in a split surrogate pair, use the general path. - Resume with the same database,
match,type, and key-prefix view. They are bound into the cursor, and a mismatch fails before network access.countHintmay change. - When
WaitingOnMigrationis set, the page has no keys and sends no scan; delay before the next call.ScanAsyncwaits 50 ms, doubling to 250 ms, and observes its cancellation token. - Continuous topology changes can prevent completion. Use cancellation or a deadline to bound a scan.
Protocol:
- Each new pass checks
COMMAND INFO CLUSTERSCANon the primary. It uses ValkeyCLUSTERSCANwhen available andSCANotherwise, including on mixed-version clusters. - If the metadata check, or
CLUSTERSCANat the initial unredirected request, is denied, the pass usesSCAN. Denied continuations and denied redirected requests fail and keep the checkpoint. - A downgrade to
SCANrestarts the unfinished pass from zero. Completed slots stay complete, but keys can repeat.
Coverage:
- Each page refreshes topology and checks the node's process (
INFO server) and migration state (CLUSTER NODES). Moved slots are rescanned on their new owner. A changed process or configuration epoch restarts that node's pass. Migrating slots complete only after the move settles. - Keys present for the whole scan are covered across observed resharding and failover. Keys added or deleted during the scan have
SCAN's usual weak guarantees. - An A-to-B-to-A slot move between two page reads, with matching identities and epochs, can go unseen.
- Incomplete ownership or an unavailable primary fails the page instead of skipping keys. The scan is not a snapshot.
- Required permissions:
SCAN,CLUSTER SLOTS,CLUSTER NODES, andINFO, plusCOMMAND INFOandCLUSTERSCANfor Valkey. Validation adds round trips per page; a larger count hint amortizes them. - Standalone scans use a plain cursor loop.
Checkpoint tokens:
Parserejects malformed, oversized, or unsupported-version tokens;TryParsereturns false.- Tokens are versioned Base64, not encrypted or authenticated. They contain filters and node identities, but no passwords.
- This client writes RSC3 and reads RSC1, RSC2, and RSC3. Older clients reject RSC3.
- RSC1/RSC2 tokens have no database: resume them against their original database. Legacy UTF-8 text tokens cannot recover characters already replaced during serialization.
- Tokens do not identify the cluster. Reconnect to the original cluster to resume.
- Treat tokens as trusted state. Authenticate them if they cross a trust boundary, and enforce size limits: tokens can be several kilobytes, up to 6 MiB encoded.
- After completion, start a new scan with
Start.
Valkey 9.1 server cursors
Keys.ScanValkeyClusterPageAsync uses Valkey 9.1+ CLUSTERSCAN in Cluster mode. It returns a RespireValkeyClusterScanPage with an opaque string cursor and binary-safe RespireKey values. Start with "0" and pass each returned cursor back unchanged:
await using var cluster = await RespireClient.ConnectAsync(new RespireOptions
{
UseCluster = true,
Endpoints = [new("localhost", 6379)],
}, cancellationToken);
string cursor = "0";
do
{
var page = await cluster.Keys.ScanValkeyClusterPageAsync(
cursor, match: "user:*", type: RespireKeyType.String, countHint: 250,
cancellationToken: cancellationToken);
foreach (RespireKey key in page.Keys)
Console.WriteLine(await cluster.GetStringAsync(key, cancellationToken));
cursor = page.Cursor; // Persist only after processing this page.
}
while (cursor != "0");
- Only
"0"means completion. Empty pages and duplicate keys are valid; make processing idempotent.COUNTcontrols server work, not page size. - Resume with the same cluster, database, prefix,
match,type, and optionalslot, even from another client. The cursor does not bind these for you. - A failed or cancelled call leaves the input cursor available for retry. Use cancellation or a deadline to bound scans during topology changes.
- Valkey can restart a scan range after server or topology changes, so keys can repeat. This is not a snapshot.
- Requests go to primaries regardless of
ReadFrom, with normalMOVED/ASKhandling. The cursor is never key-prefixed. - Optional
slotlimits the scan to one slot, 0 through 16383. - A prefixed view escapes its prefix in
MATCH, filters returned keys, and strips the prefix. Pass returned keys straight back to the same view; converting binary keys to strings can lose data. - Page keys and cursor stay valid after the reply or client is disposed.
- If a string prefix ends with a high UTF-16 surrogate, Respire skips the server
MATCHand filters locally, so pages can be empty before completion. Returned keys keep the boundary information and round-trip through a view with the same prefix. Their equality includes it, so they do not equal ordinary replacement-byte suffixes.
Queue one page with batch.Keys.ScanValkeyClusterPage(...) or transaction.Keys.ScanValkeyClusterPage(...), and read the result after execution. A whole walk cannot be queued, because each page needs the previous cursor. Normal Cluster slot rules apply. In a non-primary read view, a read-only batch routes the scan to a primary without flushing the client-side cache.
Older servers return their unsupported-command or permission errors. This raw cursor is not a RespireClusterScanCursor; do not pass one format to the other API. See the Valkey CLUSTERSCAN reference.
Database ACLs
Valkey 9.1 adds database ACL rules. This rule limits an existing user to databases 0 and 1 and keeps its other rules:
ACL SETUSER app-user resetdbs db=0,1
- Apply matching ACLs on every cluster node.
- Nonzero cluster databases need a server
cluster-databasessetting large enough for the selected database. - Set
RespireOptions.Database = 1and the user'sUsername/Password.CLUSTERSCANscans the selected database. - Database access does not grant command or key permissions.
- A denied database selection can surface as a connection exception containing
NOPERM. Respire does not switch databases or broaden permissions.
See the Valkey 9.1 announcement.