Skip to main content

Distributed locks

Respire's lock helpers use Redis leases: each lock has an owner token and an expiry. Acquisition is SET ... NX PX; extension and release use atomic owner comparisons, so an expired handle cannot extend or delete a later owner's lock.

For leases carrying an ordered token that a protected resource can check, use the optional fencing-token locks in Respire.Coordination. Its persistent counter has explicit failover and rollback limitations; ordinary lock ownership alone does not enforce fencing at an external resource.

Native commands and compatibility​

Respire chooses the operation supported by each physical server connection:

ServerExtensionRelease
Redis 8.4+SET key token IFEQ token PX millisecondsDELEX key IFEQ token
Valkey 8.1–8.xSET key token IFEQ token PX millisecondsLua compare-and-delete
Valkey 9.0+SET key token IFEQ token PX millisecondsDELIFEQ key token
Older Redis/ValkeyLua compare-and-PEXPIRELua compare-and-delete

The first operation tries the native command. Release tries DELEX, then DELIFEQ, then Lua only when the preceding command returns its standard unknown-command error. Extension falls back only when the validated SET ... IFEQ ... PX command returns ERR syntax error. These replies confirm that the attempted operation did not execute. Missing scripts use the usual EVALSHA followed by EVAL after NOSCRIPT.

Unsupported capabilities are remembered per physical connection. Each new connection discovers capabilities independently, including reconnects, failover destinations, and Cluster redirects. A mixed-version Cluster can therefore use native commands on some nodes and Lua on others. No INFO or COMMAND permission is needed for discovery. Managed renewal retains its existing CLIENT ID / CLIENT KILL permissions and cancellation fence on the connection that executed it.

ACLs must permit the commands selected for the server: SET for acquisition and native renewal, DELEX for native release on Redis 8.4+, or DELIFEQ for native release on Valkey 9.0+. Lua fallback needs EVALSHA/EVAL and the script's GET, DEL, and PEXPIRE permissions. When upgrading Respire or the server, update any command allowlist that previously permitted only the Lua release/renewal path. NOPERM is returned to the caller; permission denial does not select a different implementation. There is no native-command opt-out setting.

An ownership mismatch returns false without fallback. Timeouts, cancellation, connection loss, ACL denial, and other server errors do not trigger a Lua retry. A write with an uncertain outcome is never replayed for capability discovery. Token equality uses the original bytes; renewing a missing, expired, or replaced lock cannot recreate it or change another owner's TTL. As before, use a fresh token for each acquisition, and treat expiry estimates as local estimates.

Acquire a lock​

AcquireAsync generates the owner token. Check Acquired before accessing Lock; disposing the attempt releases the lock when acquisition succeeded and does nothing otherwise.

await using var attempt = await redis.Locks.AcquireAsync(
"locks:report",
expiry: TimeSpan.FromSeconds(30),
cancellationToken);

if (!attempt.Acquired)
{
return; // another owner holds the lock
}

RespireLock mutex = attempt.Lock;
await RunReportAsync(cancellationToken);

When contention is exceptional, use AcquireOrThrowAsync. It returns the handle directly and throws RespireLockNotAcquiredException when the lock is unavailable.

await using var mutex = await redis.Locks.AcquireOrThrowAsync(
"locks:report",
expiry: TimeSpan.FromSeconds(30),
cancellationToken);

For long-running work, acquisition can start a keep-alive owned by the returned lock. Disposing the attempt stops renewal before releasing the lock; its acquisition cancellation token does not control the keep-alive after acquisition succeeds.

await using var attempt = await redis.Locks.AcquireAsync(
"locks:report",
expiry: TimeSpan.FromSeconds(30),
keepAlive: true,
cancellationToken);

if (!attempt.Acquired)
{
return;
}

await RunReportAsync(attempt.Lock.KeepAliveCancellationToken);

Wait for contention​

Both acquisition styles have an overload that polls until a wait budget expires:

await using var mutex = await redis.Locks.AcquireOrThrowAsync(
"locks:report",
expiry: TimeSpan.FromSeconds(30),
wait: TimeSpan.FromSeconds(5),
retryEvery: TimeSpan.FromMilliseconds(250),
cancellationToken);

AcquireAsync returns an unsuccessful attempt after the budget. AcquireOrThrowAsync throws. Cancellation interrupts the response wait, polling delay, or contention wait. It cannot recall a command already written to Redis. If cancellation races with acquisition, Redis may acquire the lock without returning its handle or generated owner token; the lock then remains until its expiry.

Treat the lock as a lease​

The lock disappears when Duration elapses, even if protected work is still running. Keep work shorter than the lease or extend it before expiry:

RemainingEstimate and ExpiresAtEstimate are approximate timing values, not ownership checks. Use VerifyStillHeldAsync when current ownership must be checked against Redis.

if (!await mutex.ResetExpiryAsync(TimeSpan.FromSeconds(30), cancellationToken))
{
return; // ownership was lost; stop protected writes
}

ResetExpiryAsync applies the supplied duration from now; it does not add time to the current expiry. It returns false after expiry, release, or ownership loss. Do not retry protected writes after that result: another process may now own the lock.

For longer work, use a keep-alive and pass its cancellation token to the protected operation:

await using var keepAlive = await mutex.KeepAliveAsync(cancellationToken);
await RunReportAsync(keepAlive.CancellationToken);

The keep-alive token is cancelled when renewal fails or ownership is lost. Protected operations must honor it and stop protected writes after cancellation. Disposing keepAlive stops renewal; it does not release mutex, which remains held until ReleaseAsync, mutex disposal, or expiry.

Managed RespireLock.ResetExpiryAsync and KeepAliveAsync fence cancelled or timed-out renewals with Redis CLIENT ID and CLIENT KILL. The authenticated Redis user must permit both commands. The raw-token APIs remain available for restricted users, but do not provide this managed fencing.

Disposing mutex is the normal release path. Call ReleaseAsync explicitly when release success must be observed; it returns LockReleaseOutcome.Released, AlreadyReleased, or NotOwned. Disposal suppresses connection, timeout, cancellation, and disposed-client cleanup failures because expiry remains the final safety net.

If ReleaseAsync is cancelled or times out before the delete is sent, the handle still owns the lock and you can call ReleaseAsync again. If the delete may already have reached Redis, the handle treats ownership as lost: it cancels the keep-alive token and fences the connection with CLIENT KILL. Fencing is used only when a release can be cancelled or time out. If the user may not run CLIENT ID or CLIENT KILL, Respire falls back to a plain compare-and-delete. Concurrent ReleaseAsync calls share one release. A caller that joined another caller's release stops waiting when its own token is cancelled, and the shared release keeps running.

Manage owner tokens directly​

Use TryTakeAsync, ResetExpiryAsync, ReleaseAsync, and GetOwnerTokenAsync when the token must be shared with another process or outlive the acquiring process:

RespireLockToken token = Guid.NewGuid().ToString("N");

if (await redis.Locks.TryTakeAsync("locks:report", token, TimeSpan.FromSeconds(30), cancellationToken))
{
try
{
await RunReportAsync(cancellationToken);
}
finally
{
await redis.Locks.ReleaseAsync("locks:report", token);
}
}

Keep tokens unique and secret to the owners. Release and extension succeed only when the stored token matches. Client key prefixes apply to lock keys exactly as they do to other Respire commands.

TryTakeAsync, ResetExpiryAsync, and ReleaseAsync accept RespireLockToken. GetOwnerTokenAsync returns RespireLockToken? (null means the key is missing), and RespireLock.Token uses the same type. Strings convert implicitly as UTF-8; unpaired UTF-16 surrogates throw EncoderFallbackException instead of silently changing the token. For binary tokens, construct new RespireLockToken(bytes) or use an explicit cast from a byte array or memory slice. These operations copy the bytes without text decoding, so later changes to the original buffer cannot change ownership checks. Reuse the constructed token to avoid repeated copying.

Compare tokens with == or Equals; equality and hash codes use the exact bytes. Use Bytes for binary transport. ToString() and the debugger show lossless uppercase hexadecimal. Use ToUtf8String() for text tokens; it throws DecoderFallbackException for invalid UTF-8 rather than replacing bytes. Compare tokens directly for ownership checks. Default and empty tokens are rejected by take, release, and renewal commands.

RespireLockToken token = "owner-42";
RespireLockToken? owner = await redis.Locks.GetOwnerTokenAsync("locks:report", cancellationToken);
bool sameOwner = owner == token;

Migration: replace explicitly typed RespireValue lock-token variables and custom interface parameters with RespireLockToken. Owner queries now return RespireLockToken? instead of byte[]?; access owner.Value.Bytes after checking for null. Managed handles expose bytes as mutex.Token.Bytes instead of mutex.Token. String command arguments still convert implicitly; wrap byte-array or memory arguments in new RespireLockToken(...). Reuse a constructed token across operations to avoid repeated encoding or copying.