Skip to content

Commit a250b7c

Browse files
committed
feat: Implement HSETEX command with Redis 8.0 compatibility
- Adds `HSETEX` command parsing and handling, including `EX`, `PX`, `EXAT`, `PXAT`, `FNX`, and `FXX` options. - Modifies `DEL` command to accept multiple keys and return the count of deleted keys, aligning with Redis behavior. - Introduces `ExpireOption` enum to represent various TTL/expiration settings for commands. - Implements `handle_hsetex` function in `server.rs` to manage the logic for `HSETEX`, including conditional writes (`FNX`, `FXX`) and expiration. - Integrates `HSETEX` into the shard manager for both synchronous and asynchronous write operations, handling `expires_at` logic for hash fields. - Updates `README.md` to document the new `HSETEX` command and the updated `DEL` command usage.
1 parent 5a297d7 commit a250b7c

File tree

6 files changed

+806
-94
lines changed

6 files changed

+806
-94
lines changed

README.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Blobasaur is a high-performance, sharded blob storage server written in Rust. It
5252

5353
- **🚀 High Performance Sharding**: Distributes data across multiple SQLite databases using multi-probe consistent hashing for optimal concurrency and scalability
5454
- **🔄 Shard Migration**: Built-in support for migrating data between different shard configurations with data integrity verification
55-
- **🔌 Redis Protocol Compatible**: Full compatibility with Redis clients using standard commands (`GET`, `SET`, `DEL`, `EXISTS`, `HGET`, `HSET`, `HDEL`, `HEXISTS`)
55+
- **🔌 Redis Protocol Compatible**: Full compatibility with Redis clients using standard commands (`GET`, `SET`, `DEL`, `EXISTS`, `HGET`, `HSET`, `HSETEX`, `HDEL`, `HEXISTS`)
5656
- **⚡ Asynchronous Operations**: Built on Tokio for non-blocking I/O and efficient concurrent request handling
5757
- **💾 SQLite Backend**: Each shard uses its own SQLite database for simple deployment and reliable storage
5858
- **🗜️ Storage Compression**: Configurable compression with multiple algorithms (Gzip, Zstd, Lz4, Brotli)
@@ -284,9 +284,13 @@ Blobasaur implements core Redis commands for blob operations:
284284
redis-cli GET mykey
285285
```
286286
287-
- **`DEL key`**: Delete a blob
287+
- **`DEL key [key ...]`**: Delete one or more blobs (returns count of deleted keys)
288288
```bash
289+
# Delete single key
289290
redis-cli DEL mykey
291+
292+
# Delete multiple keys
293+
redis-cli DEL key1 key2 key3
290294
```
291295
292296
- **`EXISTS key`**: Check if a blob exists (excludes expired keys)
@@ -321,6 +325,24 @@ Use namespaces to organize data into logical groups:
321325
redis-cli HSET users:123 email "[email protected]"
322326
```
323327
328+
- **`HSETEX key [options] FIELDS numfields field value [field value ...]`**: Store fields with TTL
329+
```bash
330+
# Set single field with 60 second expiration
331+
redis-cli HSETEX sessions EX 60 FIELDS 1 user123 "session_data"
332+
333+
# Set multiple fields with expiration
334+
redis-cli HSETEX cache EX 300 FIELDS 2 key1 "value1" key2 "value2"
335+
336+
# Set with millisecond precision
337+
redis-cli HSETEX temp PX 5000 FIELDS 1 data "temporary"
338+
339+
# Only set if field doesn't exist (FNX option)
340+
redis-cli HSETEX users FNX EX 3600 FIELDS 1 newuser "data"
341+
342+
# Only set if field exists (FXX option)
343+
redis-cli HSETEX users FXX EX 3600 FIELDS 1 existinguser "updated"
344+
```
345+
324346
- **`HGET namespace key`**: Retrieve from namespace
325347
```bash
326348
redis-cli HGET users:123 name
@@ -350,6 +372,13 @@ value = r.get('mykey')
350372
# Namespaced operations
351373
r.hset('users:123', 'name', 'John Doe')
352374
name = r.hget('users:123', 'name')
375+
376+
# Namespaced operations with TTL (Redis 8.0 compatible)
377+
# HSETEX syntax: key [options] FIELDS numfields field value [field value ...]
378+
r.execute_command('HSETEX', 'sessions', 'EX', '3600', 'FIELDS', '1', 'user456', 'session_data')
379+
380+
# Set multiple fields with expiration
381+
r.execute_command('HSETEX', 'cache', 'EX', '300', 'FIELDS', '2', 'key1', 'val1', 'key2', 'val2')
353382
```
354383

355384
## Shard Migration

scripts/test_hsetex.sh

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/bin/bash
2+
3+
# Test script for HSETEX command (Redis 8.0 syntax)
4+
# This script tests the HSETEX implementation in Blobasaur
5+
6+
set -e # Exit on error
7+
8+
# Colors for output
9+
RED='\033[0;31m'
10+
GREEN='\033[0;32m'
11+
YELLOW='\033[1;33m'
12+
NC='\033[0m' # No Color
13+
14+
# Configuration
15+
REDIS_PORT=${REDIS_PORT:-6379}
16+
REDIS_CLI="redis-cli -p $REDIS_PORT"
17+
18+
# Helper functions
19+
print_test() {
20+
echo -e "${YELLOW}[TEST]${NC} $1"
21+
}
22+
23+
print_success() {
24+
echo -e "${GREEN}[PASS]${NC} $1"
25+
}
26+
27+
print_error() {
28+
echo -e "${RED}[FAIL]${NC} $1"
29+
exit 1
30+
}
31+
32+
assert_equals() {
33+
local actual="$1"
34+
local expected="$2"
35+
local test_name="$3"
36+
37+
if [ "$actual" = "$expected" ]; then
38+
print_success "$test_name: Got expected value '$expected'"
39+
else
40+
print_error "$test_name: Expected '$expected', but got '$actual'"
41+
fi
42+
}
43+
44+
assert_contains() {
45+
local haystack="$1"
46+
local needle="$2"
47+
local test_name="$3"
48+
49+
if [[ "$haystack" == *"$needle"* ]]; then
50+
print_success "$test_name: Output contains '$needle'"
51+
else
52+
print_error "$test_name: Output doesn't contain '$needle'. Got: '$haystack'"
53+
fi
54+
}
55+
56+
# Start tests
57+
echo "========================================="
58+
echo "Testing HSETEX command (Redis 8.0 syntax)"
59+
echo "========================================="
60+
echo ""
61+
62+
# Test 1: Basic HSETEX with EX option
63+
print_test "Test 1: Setting single field with 3 second TTL using EX"
64+
result=$($REDIS_CLI HSETEX test_sessions EX 3 FIELDS 1 session1 "data1")
65+
assert_equals "$result" "1" "Single field HSETEX"
66+
67+
print_test "Verifying field exists immediately after setting"
68+
result=$($REDIS_CLI HGET test_sessions session1)
69+
assert_equals "$result" "data1" "HGET after HSETEX"
70+
71+
result=$($REDIS_CLI HEXISTS test_sessions session1)
72+
assert_equals "$result" "1" "HEXISTS after HSETEX"
73+
74+
print_test "Waiting for expiration (4 seconds)..."
75+
sleep 4
76+
77+
result=$($REDIS_CLI HGET test_sessions session1)
78+
assert_equals "$result" "" "HGET after expiration"
79+
80+
result=$($REDIS_CLI HEXISTS test_sessions session1)
81+
assert_equals "$result" "0" "HEXISTS after expiration"
82+
83+
# Test 2: Multiple fields with expiration
84+
print_test "Test 2: Setting multiple fields with expiration"
85+
result=$($REDIS_CLI HSETEX test_users EX 10 FIELDS 3 alice "Alice Data" bob "Bob Data" charlie "Charlie Data")
86+
assert_equals "$result" "1" "Multiple fields HSETEX"
87+
88+
result=$($REDIS_CLI HGET test_users alice)
89+
assert_equals "$result" "Alice Data" "HGET alice"
90+
91+
result=$($REDIS_CLI HGET test_users bob)
92+
assert_equals "$result" "Bob Data" "HGET bob"
93+
94+
result=$($REDIS_CLI HGET test_users charlie)
95+
assert_equals "$result" "Charlie Data" "HGET charlie"
96+
97+
# Test 3: PX option (milliseconds)
98+
print_test "Test 3: Setting field with 2000ms (2 second) TTL using PX"
99+
result=$($REDIS_CLI HSETEX test_temp PX 2000 FIELDS 1 tempdata "temporary")
100+
assert_equals "$result" "1" "HSETEX with PX option"
101+
102+
result=$($REDIS_CLI HGET test_temp tempdata)
103+
assert_equals "$result" "temporary" "HGET immediately after PX"
104+
105+
sleep 1
106+
print_test "After 1 second, field should still exist"
107+
result=$($REDIS_CLI HGET test_temp tempdata)
108+
assert_equals "$result" "temporary" "HGET after 1 second"
109+
110+
sleep 1.5
111+
print_test "After 2.5 seconds total, field should be expired"
112+
result=$($REDIS_CLI HGET test_temp tempdata)
113+
assert_equals "$result" "" "HGET after expiration"
114+
115+
# Test 4: FNX option (only set if field doesn't exist)
116+
print_test "Test 4: Testing FNX option"
117+
118+
# First set a field
119+
$REDIS_CLI HSET test_fnx field1 "original" > /dev/null
120+
121+
# Try to overwrite with FNX (should fail)
122+
result=$($REDIS_CLI HSETEX test_fnx FNX EX 10 FIELDS 1 field1 "new_value")
123+
assert_equals "$result" "0" "HSETEX FNX on existing field returns 0"
124+
125+
# Verify original value is unchanged
126+
result=$($REDIS_CLI HGET test_fnx field1)
127+
assert_equals "$result" "original" "Field value unchanged after FNX"
128+
129+
# Try to set a new field with FNX (should succeed)
130+
result=$($REDIS_CLI HSETEX test_fnx FNX EX 10 FIELDS 1 field2 "new_field")
131+
assert_equals "$result" "1" "HSETEX FNX on new field returns 1"
132+
133+
result=$($REDIS_CLI HGET test_fnx field2)
134+
assert_equals "$result" "new_field" "New field set with FNX"
135+
136+
# Test 5: FXX option (only set if field exists)
137+
print_test "Test 5: Testing FXX option"
138+
139+
# Try to set a non-existent field with FXX (should fail)
140+
result=$($REDIS_CLI HSETEX test_fxx FXX EX 10 FIELDS 1 nonexistent "value")
141+
assert_equals "$result" "0" "HSETEX FXX on non-existent field returns 0"
142+
143+
# Set a field first
144+
$REDIS_CLI HSET test_fxx existing "initial" > /dev/null
145+
146+
# Update with FXX (should succeed)
147+
result=$($REDIS_CLI HSETEX test_fxx FXX EX 10 FIELDS 1 existing "updated")
148+
assert_equals "$result" "1" "HSETEX FXX on existing field returns 1"
149+
150+
result=$($REDIS_CLI HGET test_fxx existing)
151+
assert_equals "$result" "updated" "Field updated with FXX"
152+
153+
# Test 6: EXAT option (absolute timestamp)
154+
print_test "Test 6: Testing EXAT option (absolute timestamp)"
155+
# Set expiration to 3 seconds from now
156+
expire_time=$(($(date +%s) + 3))
157+
result=$($REDIS_CLI HSETEX test_exat EXAT $expire_time FIELDS 1 field1 "exat_data")
158+
assert_equals "$result" "1" "HSETEX with EXAT"
159+
160+
result=$($REDIS_CLI HGET test_exat field1)
161+
assert_equals "$result" "exat_data" "HGET after EXAT"
162+
163+
print_test "Waiting 4 seconds for EXAT expiration..."
164+
sleep 4
165+
166+
result=$($REDIS_CLI HGET test_exat field1)
167+
assert_equals "$result" "" "HGET after EXAT expiration"
168+
169+
# Test 7: Mixed field conditions
170+
print_test "Test 7: Testing multiple fields with mixed results"
171+
172+
# Setup: Create one existing field
173+
$REDIS_CLI HSET test_mixed existing_field "exists" > /dev/null
174+
175+
# Try to set both existing and new fields with FNX
176+
result=$($REDIS_CLI HSETEX test_mixed FNX EX 10 FIELDS 2 existing_field "should_fail" new_field "should_succeed")
177+
# In our implementation, if any field fails the condition, the whole operation returns 0
178+
assert_equals "$result" "0" "HSETEX FNX with mixed fields"
179+
180+
# Verify existing field is unchanged
181+
result=$($REDIS_CLI HGET test_mixed existing_field)
182+
assert_equals "$result" "exists" "Existing field unchanged"
183+
184+
# The new field might or might not be set depending on implementation
185+
# (Redis 8.0 would set fields that pass the condition)
186+
187+
# Test 8: No expiration option (fields should persist)
188+
print_test "Test 8: HSETEX without expiration option"
189+
result=$($REDIS_CLI HSETEX test_persist FIELDS 1 permanent "forever")
190+
assert_equals "$result" "1" "HSETEX without TTL"
191+
192+
result=$($REDIS_CLI HGET test_persist permanent)
193+
assert_equals "$result" "forever" "Field set without expiration"
194+
195+
sleep 2
196+
result=$($REDIS_CLI HGET test_persist permanent)
197+
assert_equals "$result" "forever" "Field still exists after time"
198+
199+
# Cleanup
200+
print_test "Cleaning up test data..."
201+
$REDIS_CLI DEL test_sessions test_users test_temp test_fnx test_fxx test_exat test_mixed test_persist > /dev/null
202+
203+
echo ""
204+
echo "========================================="
205+
echo -e "${GREEN}All HSETEX tests passed successfully!${NC}"
206+
echo "========================================="

src/redis/integration_tests.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ mod integration_tests {
104104
assert_eq!(
105105
parsed_command,
106106
RedisCommand::Del {
107-
key: "mykey".to_string()
107+
keys: vec!["mykey".to_string()]
108108
}
109109
);
110110
}
@@ -221,12 +221,16 @@ mod integration_tests {
221221

222222
match parsed_command {
223223
RedisCommand::Get { key } => assert_eq!(key, "key1"),
224-
RedisCommand::Set { key, value, ttl_seconds } => {
224+
RedisCommand::Set {
225+
key,
226+
value,
227+
ttl_seconds,
228+
} => {
225229
assert_eq!(key, "key2");
226230
assert_eq!(value, Bytes::from_static(b"value"));
227231
assert_eq!(ttl_seconds, None);
228232
}
229-
RedisCommand::Del { key } => assert_eq!(key, "key3"),
233+
RedisCommand::Del { keys } => assert_eq!(keys, vec!["key3"]),
230234
_ => panic!("Unexpected command"),
231235
}
232236
}
@@ -243,7 +247,11 @@ mod integration_tests {
243247
let parsed_command = parse_command(parsed_resp).unwrap();
244248

245249
match parsed_command {
246-
RedisCommand::Set { key, value, ttl_seconds } => {
250+
RedisCommand::Set {
251+
key,
252+
value,
253+
ttl_seconds,
254+
} => {
247255
assert_eq!(key, "binary");
248256
assert_eq!(value, Bytes::copy_from_slice(binary_data));
249257
assert_eq!(ttl_seconds, None);
@@ -270,7 +278,11 @@ mod integration_tests {
270278
let parsed_command = parse_command(parsed_resp).unwrap();
271279

272280
match parsed_command {
273-
RedisCommand::Set { key, value, ttl_seconds } => {
281+
RedisCommand::Set {
282+
key,
283+
value,
284+
ttl_seconds,
285+
} => {
274286
assert_eq!(key, large_key);
275287
assert_eq!(value, Bytes::from(large_value.into_bytes()));
276288
assert_eq!(ttl_seconds, None);
@@ -287,7 +299,11 @@ mod integration_tests {
287299
let parsed_command = parse_command(parsed_resp).unwrap();
288300

289301
match parsed_command {
290-
RedisCommand::Set { key, value, ttl_seconds } => {
302+
RedisCommand::Set {
303+
key,
304+
value,
305+
ttl_seconds,
306+
} => {
291307
assert_eq!(key, "empty");
292308
assert_eq!(value, Bytes::new());
293309
assert_eq!(ttl_seconds, None);
@@ -423,11 +439,16 @@ mod integration_tests {
423439
#[tokio::test]
424440
async fn test_set_with_ttl_integration() {
425441
// Test SET with EX option
426-
let set_ex_command = b"*5\r\n$3\r\nSET\r\n$8\r\nttl_test\r\n$5\r\nvalue\r\n$2\r\nEX\r\n$2\r\n60\r\n";
442+
let set_ex_command =
443+
b"*5\r\n$3\r\nSET\r\n$8\r\nttl_test\r\n$5\r\nvalue\r\n$2\r\nEX\r\n$2\r\n60\r\n";
427444
let (parsed_resp, _) = parse_resp_with_remaining(set_ex_command).unwrap();
428445
let parsed_command = parse_command(parsed_resp).unwrap();
429446
match parsed_command {
430-
RedisCommand::Set { key, value, ttl_seconds } => {
447+
RedisCommand::Set {
448+
key,
449+
value,
450+
ttl_seconds,
451+
} => {
431452
assert_eq!(key, "ttl_test");
432453
assert_eq!(value, Bytes::from_static(b"value"));
433454
assert_eq!(ttl_seconds, Some(60));
@@ -436,11 +457,16 @@ mod integration_tests {
436457
}
437458

438459
// Test SET with PX option
439-
let set_px_command = b"*5\r\n$3\r\nSET\r\n$8\r\nttl_test\r\n$5\r\nvalue\r\n$2\r\nPX\r\n$5\r\n60000\r\n";
460+
let set_px_command =
461+
b"*5\r\n$3\r\nSET\r\n$8\r\nttl_test\r\n$5\r\nvalue\r\n$2\r\nPX\r\n$5\r\n60000\r\n";
440462
let (parsed_resp, _) = parse_resp_with_remaining(set_px_command).unwrap();
441463
let parsed_command = parse_command(parsed_resp).unwrap();
442464
match parsed_command {
443-
RedisCommand::Set { key, value, ttl_seconds } => {
465+
RedisCommand::Set {
466+
key,
467+
value,
468+
ttl_seconds,
469+
} => {
444470
assert_eq!(key, "ttl_test");
445471
assert_eq!(value, Bytes::from_static(b"value"));
446472
assert_eq!(ttl_seconds, Some(60)); // 60000ms = 60s

0 commit comments

Comments
 (0)