StackExchange.Redis

Compare-And-Swap / Compare-And-Delete (CAS/CAD)

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

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:

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

// 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