Skip to content

Commit c0c3843

Browse files
Propose CAS/CAD docs (#3012)
* Propose CAS/CAD docs * internal link * clarify usage of LockExtendAsync * reuse local * Update docs/CompareAndSwap.md Co-authored-by: Philo <[email protected]> --------- Co-authored-by: Philo <[email protected]>
1 parent a2ac956 commit c0c3843

File tree

2 files changed

+322
-0
lines changed

2 files changed

+322
-0
lines changed

docs/CompareAndSwap.md

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
# Compare-And-Swap / Compare-And-Delete (CAS/CAD)
2+
3+
Redis 8.4 introduces atomic Compare-And-Swap (CAS) and Compare-And-Delete (CAD) operations, allowing you to conditionally modify
4+
or delete values based on their current state. SE.Redis exposes these features through the `ValueCondition` abstraction.
5+
6+
## Prerequisites
7+
8+
- Redis 8.4.0 or later
9+
10+
## Overview
11+
12+
Traditional Redis operations like `SET NX` (set if not exists) and `SET XX` (set if exists) only check for key existence.
13+
CAS/CAD operations go further by allowing you to verify the **actual value** before making changes, enabling true atomic
14+
compare-and-swap semantics, without requiring Lua scripts or complex `MULTI`/`WATCH`/`EXEC` usage.
15+
16+
The `ValueCondition` struct supports several condition types:
17+
18+
- **Existence checks**: `Always`, `Exists`, `NotExists` (equivalent to the traditional `When` enum)
19+
- **Value equality**: `Equal(value)`, `NotEqual(value)` - compare the full value (uses `IFEQ`/`IFNE`)
20+
- **Digest equality**: `DigestEqual(value)`, `DigestNotEqual(value)` - compare XXH3 64-bit hash (uses `IFDEQ`/`IFDNE`)
21+
22+
## Basic Value Equality Checks
23+
24+
Use value equality when you need to verify the exact current value before updating or deleting:
25+
26+
```csharp
27+
var db = connection.GetDatabase();
28+
var key = "user:session:12345";
29+
30+
// Set a value only if it currently equals a specific value
31+
var currentToken = "old-token-abc";
32+
var newToken = "new-token-xyz";
33+
34+
var wasSet = await db.StringSetAsync(
35+
key,
36+
newToken,
37+
when: ValueCondition.Equal(currentToken)
38+
);
39+
40+
if (wasSet)
41+
{
42+
Console.WriteLine("Token successfully rotated");
43+
}
44+
else
45+
{
46+
Console.WriteLine("Token mismatch - someone else updated it");
47+
}
48+
```
49+
50+
### Conditional Delete
51+
52+
Delete a key only if it contains a specific value:
53+
54+
```csharp
55+
var lockToken = "my-unique-lock-token";
56+
57+
// Only delete if the lock still has our token
58+
var wasDeleted = await db.StringDeleteAsync(
59+
"resource:lock",
60+
when: ValueCondition.Equal(lockToken)
61+
);
62+
63+
if (wasDeleted)
64+
{
65+
Console.WriteLine("Lock released successfully");
66+
}
67+
else
68+
{
69+
Console.WriteLine("Lock was already released or taken by someone else");
70+
}
71+
```
72+
73+
(see also the [Lock Operations section](#lock-operations) below)
74+
75+
## Digest-Based Checks
76+
77+
For large values, comparing the full value can be inefficient. Digest-based checks use XXH3 64-bit hashing to compare values efficiently:
78+
79+
```csharp
80+
var key = "document:content";
81+
var largeDocument = GetLargeDocumentBytes(); // e.g., 10MB
82+
83+
// Calculate digest locally
84+
var expectedDigest = ValueCondition.CalculateDigest(largeDocument);
85+
86+
// Update only if the document hasn't changed
87+
var newDocument = GetUpdatedDocumentBytes();
88+
var wasSet = await db.StringSetAsync(
89+
key,
90+
newDocument,
91+
when: expectedDigest
92+
);
93+
```
94+
95+
### Retrieving Server-Side Digests
96+
97+
You can retrieve the digest of a value stored in Redis without fetching the entire value:
98+
99+
```csharp
100+
// Get the digest of the current value
101+
var digest = await db.StringDigestAsync(key);
102+
103+
if (digest.HasValue)
104+
{
105+
Console.WriteLine($"Current digest: {digest.Value}");
106+
107+
// Later, use this digest for conditional operations
108+
var wasDeleted = await db.StringDeleteAsync(key, when: digest.Value);
109+
}
110+
else
111+
{
112+
Console.WriteLine("Key does not exist");
113+
}
114+
```
115+
116+
## Negating Conditions
117+
118+
Use the `!` operator to negate any condition:
119+
120+
```csharp
121+
var expectedValue = "old-value";
122+
123+
// Set only if the value is NOT equal to expectedValue
124+
var wasSet = await db.StringSetAsync(
125+
key,
126+
"new-value",
127+
when: !ValueCondition.Equal(expectedValue)
128+
);
129+
130+
// Equivalent to:
131+
var wasSet2 = await db.StringSetAsync(
132+
key,
133+
"new-value",
134+
when: ValueCondition.NotEqual(expectedValue)
135+
);
136+
```
137+
138+
## Converting Between Value and Digest Conditions
139+
140+
Convert a value condition to a digest condition for efficiency:
141+
142+
```csharp
143+
var valueCondition = ValueCondition.Equal("some-value");
144+
145+
// Convert to digest-based check
146+
var digestCondition = valueCondition.AsDigest();
147+
148+
// Now uses IFDEQ instead of IFEQ
149+
var wasSet = await db.StringSetAsync(key, "new-value", when: digestCondition);
150+
```
151+
152+
## Parsing Digests
153+
154+
If you receive a XXH3 digest as a hex string (e.g., from external systems), you can parse it:
155+
156+
```csharp
157+
// Parse from hex string
158+
var digestCondition = ValueCondition.ParseDigest("e34615aade2e6333");
159+
160+
// Use in conditional operations
161+
var wasSet = await db.StringSetAsync(key, newValue, when: digestCondition);
162+
```
163+
164+
## Lock Operations
165+
166+
StackExchange.Redis automatically uses CAS/CAD for lock operations when Redis 8.4+ is available, providing better performance and atomicity:
167+
168+
```csharp
169+
var lockKey = "resource:lock";
170+
var lockToken = Guid.NewGuid().ToString();
171+
var lockExpiry = TimeSpan.FromSeconds(30);
172+
173+
// Take a lock (uses NX internally)
174+
if (await db.LockTakeAsync(lockKey, lockToken, lockExpiry))
175+
{
176+
try
177+
{
178+
// Do work while holding the lock
179+
180+
// Extend the lock (uses CAS internally on Redis 8.4+)
181+
if (!(await db.LockExtendAsync(lockKey, lockToken, lockExpiry)))
182+
{
183+
// Failed to extend the lock - it expired, or was forcibly taken against our will
184+
throw new InvalidOperationException("Lock extension failed - check expiry duration is appropriate.");
185+
}
186+
187+
// Do more work...
188+
}
189+
finally
190+
{
191+
// Release the lock (uses CAD internally on Redis 8.4+)
192+
await db.LockReleaseAsync(lockKey, lockToken);
193+
}
194+
}
195+
```
196+
197+
On Redis 8.4+, `LockExtend` uses `SET` with `IFEQ` and `LockRelease` uses `DELEX` with `IFEQ`, eliminating
198+
the need for transactions.
199+
200+
## Common Patterns
201+
202+
### Optimistic Locking
203+
204+
Implement optimistic concurrency control for updating data:
205+
206+
```csharp
207+
async Task<bool> UpdateUserProfileAsync(string userId, Func<UserProfile, UserProfile> updateFunc)
208+
{
209+
var key = $"user:profile:{userId}";
210+
211+
// Read current value
212+
var currentJson = await db.StringGetAsync(key);
213+
if (currentJson.IsNull)
214+
{
215+
return false; // User doesn't exist
216+
}
217+
218+
var currentProfile = JsonSerializer.Deserialize<UserProfile>(currentJson!);
219+
var updatedProfile = updateFunc(currentProfile);
220+
var updatedJson = JsonSerializer.Serialize(updatedProfile);
221+
222+
// Attempt to update only if value hasn't changed
223+
var wasSet = await db.StringSetAsync(
224+
key,
225+
updatedJson,
226+
when: ValueCondition.Equal(currentJson)
227+
);
228+
229+
return wasSet; // Returns false if someone else modified it
230+
}
231+
232+
// Usage with retry logic
233+
int maxRetries = 10;
234+
for (int i = 0; i < maxRetries; i++)
235+
{
236+
if (await UpdateUserProfileAsync(userId, profile =>
237+
{
238+
profile.LastLogin = DateTime.UtcNow;
239+
return profile;
240+
}))
241+
{
242+
break; // Success
243+
}
244+
245+
// Retry with exponential backoff
246+
await Task.Delay(TimeSpan.FromMilliseconds(Math.Pow(2, i) * 10));
247+
}
248+
```
249+
250+
### Session Token Rotation
251+
252+
Safely rotate session tokens with atomic verification:
253+
254+
```csharp
255+
async Task<bool> RotateSessionTokenAsync(string sessionId, string expectedToken)
256+
{
257+
var key = $"session:{sessionId}";
258+
var newToken = GenerateSecureToken();
259+
260+
// Only rotate if the current token matches
261+
var wasRotated = await db.StringSetAsync(
262+
key,
263+
newToken,
264+
expiry: TimeSpan.FromHours(24),
265+
when: ValueCondition.Equal(expectedToken)
266+
);
267+
268+
return wasRotated;
269+
}
270+
```
271+
272+
### Large Document Updates with Digest
273+
274+
For large documents, use digests to avoid transferring the full value:
275+
276+
```csharp
277+
async Task<bool> UpdateLargeDocumentAsync(string docId, byte[] newContent)
278+
{
279+
var key = $"document:{docId}";
280+
281+
// Get just the digest, not the full document
282+
var currentDigest = await db.StringDigestAsync(key);
283+
284+
if (!currentDigest.HasValue)
285+
{
286+
return false; // Document doesn't exist
287+
}
288+
289+
// Update only if digest matches (document unchanged)
290+
var wasSet = await db.StringSetAsync(
291+
key,
292+
newContent,
293+
when: currentDigest.Value
294+
);
295+
296+
return wasSet;
297+
}
298+
```
299+
300+
## Performance Considerations
301+
302+
### Value vs. Digest Checks
303+
304+
- **Value equality** (`IFEQ`/`IFNE`): Best for small values (< 1KB). Sends the full value to Redis for comparison.
305+
- **Digest equality** (`IFDEQ`/`IFDNE`): Best for large values. Only sends a 16-character hex digest (8 bytes).
306+
307+
```csharp
308+
// For small values (session tokens, IDs, etc.)
309+
var condition = ValueCondition.Equal(smallValue);
310+
311+
// For large values (documents, images, etc.)
312+
var condition = ValueCondition.DigestEqual(largeValue);
313+
// or
314+
var condition = ValueCondition.CalculateDigest(largeValueBytes);
315+
```
316+
317+
## See Also
318+
319+
- [Transactions](Transactions.md) - For multi-key atomic operations
320+
- [Keys and Values](KeysValues.md) - Understanding Redis data types
321+
- [Redis CAS/CAD Documentation](https://redis.io/docs/latest/commands/set/) - Redis 8.4 SET command with IFEQ/IFNE/IFDEQ/IFDNE modifiers

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Documentation
3838
- [Pipelines and Multiplexers](PipelinesMultiplexers) - what is a multiplexer?
3939
- [Keys, Values and Channels](KeysValues) - discusses the data-types used on the API
4040
- [Transactions](Transactions) - how atomic transactions work in redis
41+
- [Compare-And-Swap / Compare-And-Delete (CAS/CAD)](CompareAndSwap) - atomic conditional operations using value comparison
4142
- [Events](Events) - the events available for logging / information purposes
4243
- [Pub/Sub Message Order](PubSubOrder) - advice on sequential and concurrent processing
4344
- [Pub/Sub Key Notifications](KeyspaceNotifications) - how to use keyspace and keyevent notifications

0 commit comments

Comments
 (0)