Redis 8.4 introduces atomic Compare-And-Swap (CAS) and Compare-And-Delete (CAD) operations, allowing you to conditionally modify
or delete values based on their current state. SE.Redis exposes these features through the ValueCondition abstraction.
Prerequisites
- Redis 8.4.0 or later
Overview
Traditional Redis operations like SET NX (set if not exists) and SET XX (set if exists) only check for key existence.
CAS/CAD operations go further by allowing you to verify the actual value before making changes, enabling true atomic
compare-and-swap semantics, without requiring Lua scripts or complex MULTI/WATCH/EXEC usage.
The ValueCondition struct supports several condition types:
- Existence checks:
Always,Exists,NotExists(equivalent to the traditionalWhenenum) - Value equality:
Equal(value),NotEqual(value)- compare the full value (usesIFEQ/IFNE) - Digest equality:
DigestEqual(value),DigestNotEqual(value)- compare XXH3 64-bit hash (usesIFDEQ/IFDNE)
Basic Value Equality Checks
Use value equality when you need to verify the exact current value before updating or deleting:
var db = connection.GetDatabase();
var key = "user:session:12345";
// Set a value only if it currently equals a specific value
var currentToken = "old-token-abc";
var newToken = "new-token-xyz";
var wasSet = await db.StringSetAsync(
key,
newToken,
when: ValueCondition.Equal(currentToken)
);
if (wasSet)
{
Console.WriteLine("Token successfully rotated");
}
else
{
Console.WriteLine("Token mismatch - someone else updated it");
}
Conditional Delete
Delete a key only if it contains a specific value:
var lockToken = "my-unique-lock-token";
// Only delete if the lock still has our token
var wasDeleted = await db.StringDeleteAsync(
"resource:lock",
when: ValueCondition.Equal(lockToken)
);
if (wasDeleted)
{
Console.WriteLine("Lock released successfully");
}
else
{
Console.WriteLine("Lock was already released or taken by someone else");
}
(see also the Lock Operations section below)
Digest-Based Checks
For large values, comparing the full value can be inefficient. Digest-based checks use XXH3 64-bit hashing to compare values efficiently:
var key = "document:content";
var largeDocument = GetLargeDocumentBytes(); // e.g., 10MB
// Calculate digest locally
var expectedDigest = ValueCondition.CalculateDigest(largeDocument);
// Update only if the document hasn't changed
var newDocument = GetUpdatedDocumentBytes();
var wasSet = await db.StringSetAsync(
key,
newDocument,
when: expectedDigest
);
Retrieving Server-Side Digests
You can retrieve the digest of a value stored in Redis without fetching the entire value:
// Get the digest of the current value
var digest = await db.StringDigestAsync(key);
if (digest.HasValue)
{
Console.WriteLine($"Current digest: {digest.Value}");
// Later, use this digest for conditional operations
var wasDeleted = await db.StringDeleteAsync(key, when: digest.Value);
}
else
{
Console.WriteLine("Key does not exist");
}
Negating Conditions
Use the ! operator to negate any condition:
var expectedValue = "old-value";
// Set only if the value is NOT equal to expectedValue
var wasSet = await db.StringSetAsync(
key,
"new-value",
when: !ValueCondition.Equal(expectedValue)
);
// Equivalent to:
var wasSet2 = await db.StringSetAsync(
key,
"new-value",
when: ValueCondition.NotEqual(expectedValue)
);
Converting Between Value and Digest Conditions
Convert a value condition to a digest condition for efficiency:
var valueCondition = ValueCondition.Equal("some-value");
// Convert to digest-based check
var digestCondition = valueCondition.AsDigest();
// Now uses IFDEQ instead of IFEQ
var wasSet = await db.StringSetAsync(key, "new-value", when: digestCondition);
Parsing Digests
If you receive a XXH3 digest as a hex string (e.g., from external systems), you can parse it:
// Parse from hex string
var digestCondition = ValueCondition.ParseDigest("e34615aade2e6333");
// Use in conditional operations
var wasSet = await db.StringSetAsync(key, newValue, when: digestCondition);
Lock Operations
StackExchange.Redis automatically uses CAS/CAD for lock operations when Redis 8.4+ is available, providing better performance and atomicity:
var lockKey = "resource:lock";
var lockToken = Guid.NewGuid().ToString();
var lockExpiry = TimeSpan.FromSeconds(30);
// Take a lock (uses NX internally)
if (await db.LockTakeAsync(lockKey, lockToken, lockExpiry))
{
try
{
// Do work while holding the lock
// Extend the lock (uses CAS internally on Redis 8.4+)
if (!(await db.LockExtendAsync(lockKey, lockToken, lockExpiry)))
{
// Failed to extend the lock - it expired, or was forcibly taken against our will
throw new InvalidOperationException("Lock extension failed - check expiry duration is appropriate.");
}
// Do more work...
}
finally
{
// Release the lock (uses CAD internally on Redis 8.4+)
await db.LockReleaseAsync(lockKey, lockToken);
}
}
On Redis 8.4+, LockExtend uses SET with IFEQ and LockRelease uses DELEX with IFEQ, eliminating
the need for transactions.
Common Patterns
Optimistic Locking
Implement optimistic concurrency control for updating data:
async Task<bool> UpdateUserProfileAsync(string userId, Func<UserProfile, UserProfile> updateFunc)
{
var key = $"user:profile:{userId}";
// Read current value
var currentJson = await db.StringGetAsync(key);
if (currentJson.IsNull)
{
return false; // User doesn't exist
}
var currentProfile = JsonSerializer.Deserialize<UserProfile>(currentJson!);
var updatedProfile = updateFunc(currentProfile);
var updatedJson = JsonSerializer.Serialize(updatedProfile);
// Attempt to update only if value hasn't changed
var wasSet = await db.StringSetAsync(
key,
updatedJson,
when: ValueCondition.Equal(currentJson)
);
return wasSet; // Returns false if someone else modified it
}
// Usage with retry logic
int maxRetries = 10;
for (int i = 0; i < maxRetries; i++)
{
if (await UpdateUserProfileAsync(userId, profile =>
{
profile.LastLogin = DateTime.UtcNow;
return profile;
}))
{
break; // Success
}
// Retry with exponential backoff
await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, i) * 10));
}
Session Token Rotation
Safely rotate session tokens with atomic verification:
async Task<bool> RotateSessionTokenAsync(string sessionId, string expectedToken)
{
var key = $"session:{sessionId}";
var newToken = GenerateSecureToken();
// Only rotate if the current token matches
var wasRotated = await db.StringSetAsync(
key,
newToken,
expiry: TimeSpan.FromHours(24),
when: ValueCondition.Equal(expectedToken)
);
return wasRotated;
}
Large Document Updates with Digest
For large documents, use digests to avoid transferring the full value:
async Task<bool> UpdateLargeDocumentAsync(string docId, byte[] newContent)
{
var key = $"document:{docId}";
// Get just the digest, not the full document
var currentDigest = await db.StringDigestAsync(key);
if (!currentDigest.HasValue)
{
return false; // Document doesn't exist
}
// Update only if digest matches (document unchanged)
var wasSet = await db.StringSetAsync(
key,
newContent,
when: currentDigest.Value
);
return wasSet;
}
Performance Considerations
Value vs. Digest Checks
- Value equality (
IFEQ/IFNE): Best for small values (< 1KB). Sends the full value to Redis for comparison. - Digest equality (
IFDEQ/IFDNE): Best for large values. Only sends a 16-character hex digest (8 bytes).
// For small values (session tokens, IDs, etc.)
var condition = ValueCondition.Equal(smallValue);
// For large values (documents, images, etc.)
var condition = ValueCondition.DigestEqual(largeValue);
// or
var condition = ValueCondition.CalculateDigest(largeValueBytes);
See Also
- Transactions - For multi-key atomic operations
- Keys and Values - Understanding Redis data types
- Redis CAS/CAD Documentation - Redis 8.4 SET command with IFEQ/IFNE/IFDEQ/IFDNE modifiers