valkey/tests/unit/hashexpire.tcl

4352 lines
166 KiB
Tcl

proc info_field {info field} {
foreach line [split $info "\n"] {
if {[string match "$field:*" $line]} {
return [string trim [lindex [split $line ":"] 1]]
}
}
return [s field_name]
}
proc get_keys_with_volatile_items {r} {
set line [$r info keyspace]
set match [regexp -inline {keys_with_volatile_items=([\d]+)} $line]
if {[llength $match] == 2} {
return [lindex $match 1]
} else {
return 0
}
}
proc get_keys {r} {
set line [$r info keyspace]
set match [regexp -inline {keys=([\d]+)} $line]
if {[llength $match] == 2} {
return [lindex $match 1]
} else {
return 0
}
}
proc check_myhash_and_expired_subkeys {r myhash expected_len initial_expired expected_increment} {
expr {
[$r HLEN $myhash] == $expected_len &&
[info_field [$r info stats] expired_fields] == ($initial_expired + $expected_increment)
}
}
proc get_short_expire_value {command} {
expr {
($command eq "HEXPIRE" || $command eq "EX") ? 1 :
($command eq "HPEXPIRE" || $command eq "PX") ? 1000 :
($command eq "HEXPIREAT" || $command eq "EXAT") ? [clock seconds] + 1 :
[clock milliseconds] + 1000
}
}
proc get_long_expire_value {command} {
expr {
($command eq "HEXPIRE" || $command eq "EX") ? 60000000 :
($command eq "HPEXPIRE" || $command eq "PX") ? 60000000 :
($command eq "HEXPIREAT" || $command eq "EXAT") ? [clock seconds] + 60000000 :
[clock milliseconds] + 60000000
}
}
proc get_longer_then_long_expire_value {command} {
expr {
($command eq "HEXPIRE" || $command eq "EX") ? 1200000000 :
($command eq "HPEXPIRE" || $command eq "PX") ? 1200000000 :
($command eq "HEXPIREAT" || $command eq "EXAT") ? [clock seconds] + 1200000000 :
[clock milliseconds] + 1200000000
}
}
proc get_past_zero_expire_value {command} {
expr {
($command eq "HEXPIRE" || $command eq "EX") ? 0 :
($command eq "HPEXPIRE" || $command eq "PX") ? 0 :
($command eq "HEXPIREAT" || $command eq "EXAT") ? [clock seconds] - 200000 :
[clock milliseconds] - 200000
}
}
proc get_check_ttl_command {command} {
if {$command eq "EX"} {
return "HTTL"
} elseif {$command eq "PX"} {
return "HPTTL"
} elseif {$command eq "EXAT"} {
return "HEXPIRETIME"
} else {
return "HPEXPIRETIME"
}
}
proc assert_keyevent_patterns {rd key args} {
foreach event_type $args {
set event [$rd read]
assert_match "pmessage __keyevent@* __keyevent@*:$event_type $key" $event
}
}
proc setup_replication_test {primary replica primary_host primary_port} {
$primary FLUSHALL
$replica replicaof $primary_host $primary_port
wait_for_condition 50 100 {
[lindex [$replica role] 0] eq {slave} &&
[string match {*master_link_status:up*} [$replica info replication]]
} else {
fail "Can't turn the instance into a replica"
}
set primary_initial_expired [info_field [$primary info stats] expired_fields]
set replica_initial_expired [info_field [$replica info stats] expired_fields]
return [list $primary_initial_expired $replica_initial_expired]
}
proc setup_single_keyspace_notification {r} {
$r config set notify-keyspace-events KEA
set rd [valkey_deferring_client]
assert_equal {1} [psubscribe $rd __keyevent@*]
return $rd
}
proc wait_for_active_expiry {r key expected_len initial_expired expected_increment {timeout 100} {interval 100}} {
wait_for_condition $timeout $interval {
[check_myhash_and_expired_subkeys $r $key $expected_len $initial_expired $expected_increment]
} else {
fail "Active expiry did not occur as expected"
}
}
start_server {tags {"hashexpire"}} {
####### Valid scenarios tests #######
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command expiry" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
r HSET myhash f1 v1
set ttl_cmd [get_check_ttl_command $command]
set expire_time [get_long_expire_value $command]
# Verify HGETEX command
assert_equal "v1" [r HGETEX myhash $command $expire_time FIELDS 1 f1]
set expire_result [r $ttl_cmd myhash FIELDS 1 f1]
# Verify expiry
if {[regexp "AT$" $command]} {
assert_equal $expire_result $expire_time
} else {
assert_morethan $expire_result 0
}
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test "HGETEX $command with mix of existing and non-existing fields" {
r FLUSHALL
r HSET myhash f1 v1 f3 v3
# HGETEX on exist/non-exist fields
assert_equal "v1 {} v3" [r HGETEX myhash $command [get_long_expire_value $command] FIELDS 3 f1 f2 f3]
# Verification checks (f2 should not be created)
assert_equal "" [r HGET myhash f2]
assert_equal -2 [r HTTL myhash FIELDS 1 f2]
assert_morethan [r HTTL myhash FIELDS 1 f1] 0
assert_morethan [r HTTL myhash FIELDS 1 f3] 0
}
test "HGETEX $command on more then 1 field" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
r HSET myhash f1 v1 f2 v2
set ttl_cmd [get_check_ttl_command $command]
set expire_time [get_long_expire_value $command]
assert_equal "v1 v2" [r HGETEX myhash $command $expire_time FIELDS 2 f1 f2]
# Verify expiration
if {[regexp "AT$" $command]} {
assert_equal $expire_time [r $ttl_cmd myhash FIELDS 1 f1]
assert_equal $expire_time [r $ttl_cmd myhash FIELDS 1 f2]
} else {
assert_morethan [r $ttl_cmd myhash FIELDS 1 f1] 0
assert_morethan [r $ttl_cmd myhash FIELDS 1 f2] 0
}
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test "HGETEX $command -> PERSIST" {
r FLUSHALL
r HSET myhash f1 v1
r HSETEX myhash EX 10000 FIELDS 1 f2 v2
set ttl_cmd [get_check_ttl_command $command]
set expire_time [get_long_expire_value $command]
assert_equal "v1" [r HGETEX myhash $command $expire_time FIELDS 1 f1]
if {[regexp "AT$" $command]} {
assert_equal $expire_time [r $ttl_cmd myhash FIELDS 1 f1]
} else {
assert_morethan [r $ttl_cmd myhash FIELDS 1 f1] 0
}
assert_equal "v1" [r HGETEX myhash PERSIST FIELDS 1 f1]
assert_equal -1 [r HTTL myhash FIELDS 1 f1]
# Verify f2 still has ttl
assert_morethan [r HTTL myhash FIELDS 1 f2] 100
}
test "HGETEX $command on non-exist field" {
r FLUSHALL
r HSET myhash f1 v1
assert_equal {{}} [r HGETEX myhash $command [get_short_expire_value $command] FIELDS 1 f2]
}
test "HGETEX $command on non-exist key" {
r FLUSHALL
assert_equal {{} {} {}} [r HGETEX myhash $command [get_long_expire_value $command] FIELDS 3 f1 f2 f3]
}
test "HGETEX $command with duplicate field names" {
r FLUSHALL
r HSET myhash f1 v1
assert_equal "v1 v1" [r HGETEX myhash $command [get_long_expire_value $command] FIELDS 2 f1 f1]
}
test "HGETEX $command overwrites existing field TTL with bigger value" {
r FLUSHALL
r HSETEX myhash $command [get_long_expire_value $command] FIELDS 1 f1 v1
set old_ttl [r HTTL myhash FIELDS 1 f1]
r HGETEX myhash $command [get_longer_then_long_expire_value $command] FIELDS 1 f1
set new_ttl [r HTTL myhash FIELDS 1 f1]
assert {$new_ttl > $old_ttl}
}
test "HGETEX $command overwrites existing field TTL with smaller value" {
r FLUSHALL
r HSETEX myhash $command [get_long_expire_value $command] FIELDS 1 f1 v1
set old_ttl [r HTTL myhash FIELDS 1 f1]
r HGETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1
set new_ttl [r HTTL myhash FIELDS 1 f1]
assert {$new_ttl <= $old_ttl}
}
}
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command overwrites existing field TTL with bigger value" {
r FLUSHALL
set config [dict create \
EX [list setup_cmd EX setup_val 100000 bigger_val 200000] \
PX [list setup_cmd PX setup_val 100000000 bigger_val 200000000] \
EXAT [list setup_cmd EX setup_val 100000 bigger_val [expr {[clock seconds] + 200000}]] \
PXAT [list setup_cmd PX setup_val 100000000 bigger_val [expr {[clock milliseconds] + 200000000}]] \
]
set params [dict get $config $command]
set setup_cmd [dict get $params setup_cmd]
set setup_val [dict get $params setup_val]
set bigger_val [dict get $params bigger_val]
r HSETEX myhash $setup_cmd $setup_val FIELDS 1 f1 v1
set old_ttl [r HTTL myhash FIELDS 1 f1]
r HGETEX myhash $command $bigger_val FIELDS 1 f1
set new_ttl [r HTTL myhash FIELDS 1 f1]
assert {$new_ttl > $old_ttl}
}
test "HGETEX $command overwrites existing field TTL with smaller value" {
r FLUSHALL
set config [dict create \
EX [list setup_cmd EX setup_val 100000 smaller_val 50000] \
PX [list setup_cmd PX setup_val 100000000 smaller_val 50000000] \
EXAT [list setup_cmd EX setup_val 100000 smaller_val [expr {[clock seconds] + 50000}]] \
PXAT [list setup_cmd PX setup_val 100000000 smaller_val [expr {[clock milliseconds] + 50000000}]] \
]
set params [dict get $config $command]
set setup_cmd [dict get $params setup_cmd]
set setup_val [dict get $params setup_val]
set smaller_val [dict get $params smaller_val]
r HSETEX myhash $setup_cmd $setup_val FIELDS 1 f1 v1
set old_ttl [r HTTL myhash FIELDS 1 f1]
r HGETEX myhash $command $smaller_val FIELDS 1 f1
set new_ttl [r HTTL myhash FIELDS 1 f1]
assert {$new_ttl <= $old_ttl}
}
}
test {HGETEX - verify no change when field does not exist} {
r FLUSHALL
r HSET myhash f1 v1
set mem_before [r MEMORY USAGE myhash]
assert_equal {{}} [r HGETEX myhash EX 1 FIELDS 1 f2]
set memory_after [r MEMORY USAGE myhash]
assert_equal $mem_before $memory_after
}
####### Invalid scenarios tests #######
test {HGETEX EX- multiple options used (EX + PX)} {
r FLUSHALL
r HSET myhash f1 v1
assert_error "ERR*" {r HGETEX myhash EX 60 PX 1000 FIELDS 1 f1}
}
test {HGETEX EXAT- multiple options used (EXAT + PXAT)} {
r FLUSHALL
r HSET myhash f1 v1
assert_error "ERR*" {r HGETEX myhash EXAT [expr {[clock seconds] + 100}] PXAT [expr {[clock milliseconds] + 100000}] 1000 FIELDS 1 f1}
}
# Common error scenarios for all commands
foreach cmd {EX PX EXAT PXAT} {
test "HGETEX $cmd- missing TTL value" {
r FLUSHALL
r HSET myhash f1 v1
catch {r HGETEX myhash $cmd FIELDS 1 f1} e
set e
} {ERR *}
test "HGETEX $cmd- negative TTL" {
r FLUSHALL
r HSET myhash f1 v1
catch {r HGETEX myhash $cmd -10 FIELDS 1 f1} e
set e
} {ERR invalid expire time in 'hgetex' command}
test "HGETEX $cmd- non-integer TTL" {
r FLUSHALL
r HSET myhash f1 v1
catch {r HGETEX myhash $cmd abc FIELDS 1 f1} e
set e
} {ERR value is not an integer or out of range}
test "HGETEX $cmd- missing FIELDS keyword" {
r FLUSHALL
r HSET myhash f1 v1
catch {r HGETEX myhash $cmd [get_short_expire_value $cmd] 1 f1} e
set e
} {ERR *}
test "HGETEX $cmd- wrong numfields count (too few fields)" {
r FLUSHALL
r HSET myhash f1 v1 f2 v2
catch {r HGETEX myhash $cmd [get_short_expire_value $cmd] FIELDS 2 f1} e
set e
} {ERR *}
test "HGETEX $cmd- wrong numfields count (too many fields)" {
r FLUSHALL
r HSET myhash f1 v1
catch {r HGETEX myhash $cmd [get_short_expire_value $cmd] FIELDS 1 f1 f2} e
set e
} {ERR *}
test "HGETEX $cmd- key is wrong type (string instead of hash)" {
r FLUSHALL
r SET mystring "v1"
catch {r HGETEX mystring $cmd [get_short_expire_value $cmd] FIELDS 1 f1} e
set e
} {WRONGTYPE Operation against a key holding the wrong kind of value}
test "HGETEX $cmd with FIELDS 0" {
r FLUSHALL
catch {r HGETEX myhash $cmd [get_short_expire_value $cmd] FIELDS 0} e
set e
} {ERR *}
test "HGETEX $cmd with negative numfields" {
r FLUSHALL
catch {r HGETEX myhash $cmd [get_short_expire_value $cmd] FIELDS -10} e
set e
} {ERR *}
test "HGETEX $cmd with missing key" {
r FLUSHALL
catch {r HGETEX $cmd [get_short_expire_value $cmd] FIELDS 1 f1} e
set e
} {ERR *}
}
}
start_server {tags {"hashexpire"}} {
if {$::singledb} {
set db 0
} else {
set db 9
}
set all_h_pattern "h*"
set hexpire_pattern "hexpire"
set hpersist_pattern "hpersist"
r config set notify-keyspace-events KEA
## HGETEX -> Keyspace notification tests ####
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command generates hexpire keyspace notification" {
r FLUSHALL
r HSET myhash f1 v1
assert_equal 0 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
r HGETEX myhash $command [get_long_expire_value $command] FIELDS 1 f1
assert_keyevent_patterns $rd myhash hexpire
assert_equal 1 [get_keys_with_volatile_items r]
$rd close
}
test "HGETEX $command with multiple fields generates single notification" {
r FLUSHALL
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 0 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
r HGETEX myhash $command [get_long_expire_value $command] FIELDS 3 f1 f2 f3
assert_keyevent_patterns $rd myhash hexpire
# Verify no notification (getting hset and not hexpire)
r HSET dummy dummy dummy
assert_keyevent_patterns $rd dummy hset
assert_equal 1 [get_keys_with_volatile_items r]
$rd close
}
test "HGETEX $command on non-existent field generates no notification" {
r FLUSHALL
r HSET myhash f1 v1
assert_equal 0 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
# This HGETEX targets a non-existent field, so no notification about hexpire should be emitted
r HGETEX myhash $command [get_long_expire_value $command] FIELDS 1 f2
# Verify no notification (getting hset and not hexpire)
r HSET dummy dummy dummy
assert_keyevent_patterns $rd dummy hset
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
}
}
test {HGETEX PERSIST generates hpersist keyspace notification} {
r FLUSHALL
r HSET myhash f1 v1
assert_equal 0 [get_keys_with_volatile_items r]
r HEXPIRE myhash [get_long_expire_value HEXPIRE] FIELDS 1 f1
assert_equal 1 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
r HGETEX myhash PERSIST FIELDS 1 f1
assert_keyevent_patterns $rd myhash hpersist
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
}
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command 0/past time works correctly with 1 field" {
r FLUSHALL
r config resetstat
# Create hash with field
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [get_keys r]
set rd [setup_single_keyspace_notification r]
# Set field to expire immediately
r HGETEX myhash $command [get_past_zero_expire_value $command] FIELDS 1 f1
# Verify field and keys are deleted
assert_keyevent_patterns $rd myhash hexpired del
assert_equal -2 [r HTTL myhash FIELDS 1 f1]
assert_equal 0 [r HLEN myhash]
assert_equal 0 [r EXISTS myhash]
assert_equal 0 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [info_field [r info stats] expired_fields]
$rd close
}
}
# HSETEX ####
test {HSETEX KEEPTTL - preserves existing TTL of field} {
r FLUSHALL
# Set a field with a known TTL
r HSETEX myhash PX 1000 FIELDS 1 field1 val1
set original_pttl [r HPTTL myhash FIELDS 1 field1]
set original_expiretime [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal 1 [get_keys_with_volatile_items r]
# Validate TTL is active and expiretime is in the future
assert {$original_pttl > 0}
assert {$original_expiretime > [clock seconds]}
# Overwrite the field with KEEPTTL
r HSETEX myhash KEEPTTL FIELDS 1 field1 newval
# Ensure TTL is preserved
set updated_pttl [r HPTTL myhash FIELDS 1 field1]
set updated_expiretime [r HEXPIRETIME myhash FIELDS 1 field1]
assert {$updated_pttl > 0}
assert {$updated_pttl <= $original_pttl}
assert_equal $original_expiretime $updated_expiretime
# Ensure value was updated
assert_equal newval [r HGET myhash field1]
}
test {HSETEX EX - FIELDS 0 returns error} {
r FLUSHALL
catch {r HSETEX myhash EX 10 FIELDS 0} e
set e
} {ERR *}
test {HSETEX EX - test negative ttl} {
set ttl -10
catch {r HSETEX myhash EX $ttl FIELDS 1 field1 val1} e
set e
} {ERR invalid expire time in 'hsetex' command}
test {HSETEX EX - test non-numeric ttl} {
set ttl abc
catch {r HSETEX myhash EX $ttl FIELDS 1 field1 val1} e
set e
} {ERR value is not an integer or out of range}
test {HSETEX EX - overwrite field resets TTL} {
r FLUSHALL
r HSETEX myhash EX 100 FIELDS 1 field1 val1
r HSETEX myhash EX 200 FIELDS 1 field1 newval
assert_equal 200 [r HTTL myhash FIELDS 1 field1]
assert_equal newval [r HGET myhash field1]
}
test {HSETEX EX - test mix of expiring and persistent fields} {
r FLUSHALL
r HSET myhash field2 "persistent"
r HSETEX myhash EX 1 FIELDS 1 field1 "temp"
assert_equal 1 [get_keys_with_volatile_items r]
after 1100
assert_equal 0 [r HEXISTS myhash field1]
assert_equal 1 [r HEXISTS myhash field2]
}
test {HSETEX EX - test missing TTL} {
catch {r HSETEX myhash EX FIELDS 1 field1 val1} e
set e
} {ERR *}
test {HSETEX EX - mismatched field/value count} {
catch {r HSETEX myhash EX 10 FIELDS 2 field1 val1} e
set e
} {ERR *}
foreach command {EX PX EXAT PXAT} {
test "HSETEX $command 0/past time works correctly with 1 field" {
r FLUSHALL
r config resetstat
# Create hash with field
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [get_keys r]
set rd [setup_single_keyspace_notification r]
# Set field to expire immediately
assert_equal {1} [r HSETEX myhash $command [get_past_zero_expire_value $command] FIELDS 1 f1 v1]
# Verify field and keys are deleted
assert_keyevent_patterns $rd myhash hexpired del
assert_equal -2 [r HTTL myhash FIELDS 1 f1]
assert_equal 0 [r HLEN myhash]
assert_equal 0 [r EXISTS myhash]
assert_equal 0 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [info_field [r info stats] expired_fields]
$rd close
}
}
###### PX #######
test {HSETEX PX - test negative ttl} {
set ttl -50
catch {r HSETEX myhash PX $ttl FIELDS 1 field1 val1} e
set e
} {ERR invalid expire time in 'hsetex' command}
test {HSETEX PX - test non-numeric ttl} {
set ttl xyz
catch {r HSETEX myhash PX $ttl FIELDS 1 field1 val1} e
set e
} {ERR value is not an integer or out of range}
test {HSETEX PX - overwrite field resets TTL} {
r FLUSHALL
r HSETEX myhash PX 10000 FIELDS 1 field1 val1
r HSETEX myhash PX 20000 FIELDS 1 field1 newval
set ttl [r HPTTL myhash FIELDS 1 field1]
assert {$ttl >= 19000 && $ttl <= 20000}
assert_equal newval [r HGET myhash field1]
assert_equal 1 [get_keys_with_volatile_items r]
}
test {HSETEX PX - test zero ttl expires immediately} {
r FLUSHALL
r HSETEX myhash PX 0 FIELDS 1 field1 val1
after 10
assert_equal 0 [r HEXISTS myhash field1]
}
test {HSETEX PX - test mix of expiring and persistent fields} {
r FLUSHALL
r HSET myhash field2 "persistent"
r HSETEX myhash PX 10 FIELDS 1 field1 "temp"
after 20
assert_equal 0 [r HEXISTS myhash field1]
assert_equal 1 [r HEXISTS myhash field2]
}
test {HSETEX PX - test missing TTL} {
catch {r HSETEX myhash PX FIELDS 1 field1 val1} e
set e
} {ERR *}
# test {HSETEX PX - mismatched field/value count} {
# catch {r HSETEX myhash PX 100 FIELDS 2 field1 val1} e
# set e
# } {ERR wrong number of arguments for 'hsetex' command}
## FNX/FXX
# hsetex throws ERR *, it shouldn't
test {HSETEX EX FNX - set only if none of the fields exist} {
r FLUSHALL
r HSET myhash field1 val1
set res [r HSETEX myhash EX 10 FNX FIELDS 1 field1 val2]
assert_equal 0 $res
assert_equal val1 [r HGET myhash field1]
# Now try with all-new fields
set res [r HSETEX myhash EX 10 FNX FIELDS 2 f2 v2 f3 v3]
assert_equal 1 $res
assert_equal v2 [r HGET myhash f2]
assert_equal v3 [r HGET myhash f3]
}
test {HSETEX EX FXX - set only if all fields exist} {
r FLUSHALL
r HSET myhash field1 val1 field2 val2
set res [r HSETEX myhash EX 10 FXX FIELDS 2 field1 new1 field2 new2]
assert_equal 1 $res
assert_equal new1 [r HGET myhash field1]
assert_equal new2 [r HGET myhash field2]
# Now try when one field doesn't exist
set res [r HSETEX myhash EX 10 FXX FIELDS 2 field1 x fieldX y]
assert_equal 0 $res
assert_equal new1 [r HGET myhash field1]
assert_equal 0 [r HEXISTS myhash fieldX]
}
# Syntax error: HSETEX myhash PX 100 FNX FIELDS 2 x 2 y 3
test {HSETEX PX FNX - partial conflict returns 0} {
r FLUSHALL
r HSET myhash x 1
set res [r HSETEX myhash PX 100 FNX FIELDS 2 x 2 y 3]
assert_equal 0 $res
assert_equal 1 [r HEXISTS myhash x]
assert_equal 0 [r HEXISTS myhash y]
}
test {HSETEX PX FXX - one field missing returns 0} {
r FLUSHALL
r HSET myhash a 1
set res [r HSETEX myhash PX 100 FXX FIELDS 2 a 2 b 3]
assert_equal 0 $res
assert_equal 1 [r HGET myhash a]
assert_equal 0 [r HEXISTS myhash b]
}
test {HSETEX EX - FNX and FXX conflict error} {
catch {r HSETEX myhash EX 10 FNX FXX FIELDS 1 x y} e
set e
} {ERR *}
###### Test EXPIRE #############
# Basic Expiry Functionality
test {HEXPIRE - set TTL on existing field} {
r FLUSHALL
r HSET myhash field1 hello
r HEXPIRE myhash 10 FIELDS 1 field1
set ttl [r HTTL myhash FIELDS 1 field1]
assert {$ttl > 0}
}
test {HEXPIRE - TTL 0 deletes field} {
r FLUSHALL
r HSET myhash field1 goodbye
set res [r HEXPIRE myhash 0 FIELDS 1 field1]
assert_equal {2} $res
assert_equal 0 [r HEXISTS myhash field1]
}
test {HEXPIRE - negative TTL returns error} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIRE myhash -5 FIELDS 1 field1} e
set e
} {ERR invalid expire time in 'hexpire' command}
test {HEXPIRE - wrong type key returns error} {
r FLUSHALL
r SET myhash notahash
catch {r HEXPIRE myhash 10 FIELDS 1 field1} e
set e
} {WRONGTYPE Operation against a key holding the wrong kind of value}
# Conditionals: NX
test {HEXPIRE NX - only set when field has no TTL} {
r FLUSHALL
r HSETEX myhash PX 100 FIELDS 1 field1 val
set res [r HEXPIRE myhash 10 NX FIELDS 1 field1]
assert_equal {0} $res
r HSET myhash field2 val2
set res2 [r HEXPIRE myhash 10 NX FIELDS 1 field2]
assert_equal {1} $res2
}
# Conditionals: XX
test {HEXPIRE XX - only set when field has TTL} {
r FLUSHALL
r HSET myhash field1 val1 field2 val2
r HEXPIRE myhash 20 FIELDS 1 field1
set res [r HEXPIRE myhash 30 XX FIELDS 2 field1 field2]
assert_equal {1 0} $res
}
# Conditionals: GT
test {HEXPIRE GT - only set if new TTL > existing TTL} {
r FLUSHALL
r HSETEX myhash EX 300 FIELDS 1 field1 val1
after 10
set res [r HEXPIRE myhash 600 GT FIELDS 1 field1] ;# 600s > 300s remaining
assert_equal {1} $res
# GT should fail if field is persistent
r HSET myhash field2 val2
set res2 [r HEXPIRE myhash 1 GT FIELDS 1 field2]
assert_equal {0} $res2
}
# Conditionals: LT
test {HEXPIRE LT - only set if new TTL < existing TTL} {
r FLUSHALL
r HSETEX myhash EX 600 FIELDS 1 field1 val1
set res [r HEXPIRE myhash 1 LT FIELDS 1 field1]
assert_equal {1} $res
## TODO this is an expected behavior really? what does non existintg ttl mean?
r HSET myhash field2 val2
set res2 [r HEXPIRE myhash 1 LT FIELDS 1 field2]
assert_equal {1} $res2
}
# TTL Refresh
test {HEXPIRE - refresh TTL with new value} {
r FLUSHALL
r HSET myhash field1 val1
r HEXPIRE myhash 1 FIELDS 1 field1
after 500
r HEXPIRE myhash 3 FIELDS 1 field1
set ttl [r HTTL myhash FIELDS 1 field1]
assert {$ttl >= 2}
}
# HEXPIRE on a non-existent field
test {HEXPIRE on a non-existent field (should not create field)} {
r FLUSHALL
r HSET myhash f1 v1
r HEXPIRE myhash 1000 FIELDS 1 f2
assert_equal 0 [r HEXISTS myhash f2]
assert_equal -2 [r HTTL myhash FIELDS 1 f2]
}
# Error Cases
test {HEXPIRE - conflicting conditions error} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIRE myhash 10 NX XX FIELDS 1 field1} e
set e
} {ERR NX and XX, GT or LT options at the same time are not compatible}
test {HEXPIRE - missing FIELDS error} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIRE myhash 10} e
set e
} {ERR wrong number of arguments for 'hexpire' command}
test {HEXPIRE - no fields after FIELDS keyword} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIRE myhash 10 FIELDS 0} e
set e
} {ERR wrong number of arguments for 'hexpire' command}
test {HEXPIRE - non-integer TTL error} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIRE myhash abc FIELDS 1 field1} e
set e
} {ERR value is not an integer or out of range}
test {HEXPIRE - non-existing key returns -2} {
r FLUSHALL
set res [r HEXPIRE nokey 10 FIELDS 1 field1]
assert_equal {-2} $res
}
test {HEXPIRE EX - set TTL on multiple fields} {
r FLUSHALL
r HSET myhash fieldA valA fieldB valB
set ttl 100
r HEXPIRE myhash $ttl FIELDS 2 fieldA fieldB
set ttlA [r HTTL myhash FIELDS 1 fieldA]
set ttlB [r HTTL myhash FIELDS 1 fieldB]
assert { $ttlA > 0 && $ttlA <= $ttl }
assert { $ttlB > 0 && $ttlB <= $ttl }
} {}
test {HEXPIRE returns -2 on non-existing key} {
r FLUSHALL
assert_equal {-2 -2} [r HEXPIRE nokey 10 FIELDS 2 field1 field2]
} {}
test {HEXPIRE - GT condition fails when field has no TTL} {
r FLUSHALL
r HSET myhash f1 v1
assert_equal 0 [r HEXPIRE myhash 10 GT fields 1 f1]
}
test {HEXPIRE - LT condition succeeds when field has no TTL} {
r FLUSHALL
r HSET myhash f1 v1
assert_equal 1 [r HEXPIRE myhash 10 LT fields 1 f1]
}
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "HSETEX $command 0/past time works correctly with 1 field" {
r FLUSHALL
r config resetstat
# Create hash with field
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [get_keys r]
set rd [setup_single_keyspace_notification r]
# Set field to expire immediately
assert_equal {2} [r $command myhash [get_past_zero_expire_value $command] FIELDS 1 f1]
# Verify field and keys are deleted
assert_keyevent_patterns $rd myhash hexpired del
assert_equal -2 [r HTTL myhash FIELDS 1 f1]
assert_equal 0 [r HLEN myhash]
assert_equal 0 [r EXISTS myhash]
assert_equal 0 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 1 [info_field [r info stats] expired_fields]
$rd close
}
}
##### HTTL #####
test {HTTL - persistent field returns -1} {
r FLUSHALL
r HSET myhash field1 val1
assert_equal -1 [r HTTL myhash FIELDS 1 field1]
} {}
test {HTTL - non-existent field returns -2} {
r FLUSHALL
r HSET myhash field1 val1
assert_equal -2 [r HTTL myhash FIELDS 1 nofield]
} {}
test {HTTL - non-existent key returns -2} {
r FLUSHALL
assert_equal -2 [r HTTL nokey FIELDS 1 field1]
} {}
##### EXPIRETIME ######
# Basic Expiry Functionality
test {HEXPIREAT - set absolute expiry on field} {
r FLUSHALL
r HSET myhash field1 hello
set now [clock seconds]
set exp [expr {$now + 30}]
r HEXPIREAT myhash $exp FIELDS 1 field1
set etime [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal $exp $etime
}
test {HEXPIREAT - timestamp in past deletes field immediately} {
r FLUSHALL
r HSET myhash field1 gone
set past [expr {[clock seconds] - 1000}]
set res [r HEXPIREAT myhash $past FIELDS 1 field1]
assert_equal {2} $res
assert_equal 0 [r HEXISTS myhash field1]
}
test {HEXPIREAT - set TTL on multiple fields (existing + non-existing)} {
r FLUSHALL
r HSET myhash field1 hello field2 world
set exp [expr {[clock seconds] + 10}]
set res [r HEXPIREAT myhash $exp FIELDS 3 field1 field2 fieldX]
assert_equal {1 1 -2} $res
}
# Conditionals: NX
test {HEXPIREAT NX - only set when field has no TTL} {
r FLUSHALL
r HSETEX myhash EX 100 FIELDS 1 field1 val
set exp [expr {[clock seconds] + 100}]
set res [r HEXPIREAT myhash $exp NX FIELDS 1 field1]
assert_equal {0} $res
r HSET myhash field2 val2
set res2 [r HEXPIREAT myhash $exp NX FIELDS 1 field2]
assert_equal {1} $res2
}
# Conditionals: XX
test {HEXPIREAT XX - only set when field has TTL} {
r FLUSHALL
r HSET myhash field1 val1 field2 val2
set exp1 [expr {[clock seconds] + 20}]
r HEXPIREAT myhash $exp1 FIELDS 1 field1
set exp2 [expr {[clock seconds] + 30}]
set res [r HEXPIREAT myhash $exp2 XX FIELDS 2 field1 field2]
assert_equal {1 0} $res
}
# Conditionals: GT
test {HEXPIREAT GT - only set if new expiry > existing} {
r FLUSHALL
r HSETEX myhash PX 5000 FIELDS 1 field1 val1
after 10
set now [clock seconds]
set future [expr {$now + 10}]
set res [r HEXPIREAT myhash $future GT FIELDS 1 field1]
assert_equal {1} $res
r HSET myhash field2 val2
set res2 [r HEXPIREAT myhash $future GT FIELDS 1 field2]
assert_equal {0} $res2
}
# Conditionals: LT
test {HEXPIREAT LT - only set if new expiry < existing} {
r FLUSHALL
set now [clock seconds]
# now + 20K seconds
set long_future_expiration [expr {$now + 20000}]
# now + 1K seconds
set short_future_expiration [expr {$now + 1000}]
r HSETEX myhash EX $long_future_expiration FIELDS 1 field1 val1
assert_equal {1} [r HEXPIREAT myhash $short_future_expiration LT FIELDS 1 field1]
r HSET myhash field2 val2
assert_equal {1} [r HEXPIREAT myhash $short_future_expiration LT FIELDS 1 field2]
# TODO is this the expected behavior? if no TTL exist, it should be treated as minimum ttl possible?
}
test {HEXPIREAT - refresh TTL with new future timestamp} {
r FLUSHALL
r HSET myhash field1 val1
# Set initial expiry to very near future
set ts1 [expr {[clock seconds] + 10}]
r HEXPIREAT myhash $ts1 FIELDS 1 field1
# Immediately refresh to a further expiry (no sleep needed)
set ts2 [expr {$ts1 + 5}]
r HEXPIREAT myhash $ts2 FIELDS 1 field1
# Confirm that expiry was updated
set actual [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal $ts2 $actual
}
# TTL Validations
test {HEXPIREAT - TTL is accurate via HEXPIRETIME} {
r FLUSHALL
r HSET myhash field1 val1
set ts [expr {[clock seconds] + 50}]
r HEXPIREAT myhash $ts FIELDS 1 field1
set returned [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal $ts $returned
}
# Error Cases
test {HEXPIREAT - conflicting options error} {
r FLUSHALL
r HSET myhash field1 val
set ts [expr {[clock seconds] + 5}]
catch {r HEXPIREAT myhash $ts NX XX FIELDS 1 field1} e
set e
} {ERR NX and XX, GT or LT options at the same time are not compatible}
test {HEXPIREAT - missing FIELDS keyword} {
r FLUSHALL
r HSET myhash field1 val
set ts [expr {[clock seconds] + 5}]
catch {r HEXPIREAT myhash $ts} e
set e
} {ERR wrong number of arguments for 'hexpireat' command}
test {HEXPIREAT - no fields after FIELDS} {
r FLUSHALL
r HSET myhash field1 val
set ts [expr {[clock seconds] + 5}]
catch {r HEXPIREAT myhash $ts FIELDS 0} e
set e
} {ERR wrong number of arguments for 'hexpireat' command}
test {HEXPIREAT - non-integer timestamp} {
r FLUSHALL
r HSET myhash field1 val
catch {r HEXPIREAT myhash tomorrow FIELDS 1 field1} e
set e
} {ERR value is not an integer or out of range}
test {HEXPIREAT - non-existing key returns -2} {
r FLUSHALL
set ts [expr {[clock seconds] + 5}]
set res [r HEXPIREAT nokey $ts FIELDS 1 field1]
assert_equal {-2} $res
}
#################### HEXPIRETIME ##################
# Basic TTL retrieval
test {HEXPIRETIME - returns expiry timestamp for single field with TTL} {
r FLUSHALL
r HSET myhash field1 val
set ts [expr {[clock seconds] + 3}]
r HEXPIREAT myhash $ts FIELDS 1 field1
set out [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal $ts $out
}
# No expiration set
test {HEXPIRETIME - field has no TTL returns -1} {
r FLUSHALL
r HSET myhash field1 val
set out [r HEXPIRETIME myhash FIELDS 1 field1]
assert_equal -1 $out
}
# Non-existent field
test {HEXPIRETIME - field does not exist returns -2} {
r FLUSHALL
r HSET myhash field1 val
set out [r HEXPIRETIME myhash FIELDS 1 fieldX]
assert_equal -2 $out
}
# Non-existent key
test {HEXPIRETIME - key does not exist returns -2} {
r FLUSHALL
set out [r HEXPIRETIME missingkey FIELDS 1 field1]
assert_equal -2 $out
}
# Multiple fields: mix of TTL, no TTL, and missing
test {HEXPIRETIME - multiple fields mixed cases} {
r FLUSHALL
r HSET myhash f1 a f2 b
set now [clock seconds]
r HEXPIREAT myhash [expr {$now + 100}] FIELDS 1 f1
set out [r HEXPIRETIME myhash FIELDS 3 f1 f2 f3]
# Should return: expiry for f1, -1 for f2 (no TTL), -2 for f3 (not found)
assert_equal [list [expr {$now + 100}] -1 -2] $out
}
# Invalid usages
test {HEXPIRETIME - no FIELDS keyword} {
r FLUSHALL
r HSET myhash f1 a
catch {r HEXPIRETIME myhash} e
set e
} {ERR wrong number of arguments for 'hexpiretime' command}
test {HEXPIRETIME - FIELDS 0} {
r FLUSHALL
r HSET myhash f1 a
catch {r HEXPIRETIME myhash FIELDS 0} e
set e
} {ERR wrong number of arguments for 'hexpiretime' command}
test {HEXPIRETIME - wrong FIELDS count} {
r FLUSHALL
r HSET myhash f1 a
catch {r HEXPIRETIME myhash FIELDS 1} e
set e
} {ERR wrong number of arguments for 'hexpiretime' command}
test {HEXPIRETIME - wrong type key} {
r FLUSHALL
r SET myhash "not a hash"
catch {r HEXPIRETIME myhash FIELDS 1 f1} e
set e
} {WRONGTYPE Operation against a key holding the wrong kind of value}
# Basic expiration in milliseconds
test {HPEXPIREAT - set absolute expiry with ms precision} {
r FLUSHALL
r HSET myhash field1 val
set now [clock milliseconds]
set future [expr {$now + 123456789}]
r HPEXPIREAT myhash $future FIELDS 1 field1
set t [r HPEXPIRETIME myhash FIELDS 1 field1]
assert_equal $future $t
}
test {HPEXPIREAT - past timestamp deletes field immediately} {
r FLUSHALL
r HSET myhash field1 val
set past [expr {[clock milliseconds] - 10000}]
set res [r HPEXPIREAT myhash $past FIELDS 1 field1]
assert_equal {2} $res
assert_equal 0 [r HEXISTS myhash field1]
}
test {HPEXPIREAT - non-existent key returns -2} {
r FLUSHALL
set ts [expr {[clock milliseconds] + 1000}]
set res [r HPEXPIREAT nokey $ts FIELDS 1 field1]
assert_equal {-2} $res
}
test {HPEXPIREAT - mixed fields} {
r FLUSHALL
r HSET myhash f1 a f2 b
set ts [expr {[clock milliseconds] + 200000}]
set res [r HPEXPIREAT myhash $ts FIELDS 3 f1 f2 fX]
assert_equal {1 1 -2} $res
}
test {HPEXPIREAT - GT and LT options with success and failure cases} {
r FLUSHALL
r HSET myhash f1 a
# Setup: assign a baseline expiry time
set now [clock milliseconds]
set ts1 [expr {$now + 10000}]
set ts2 [expr {$now + 20000}]
r HPEXPIREAT myhash $ts1 FIELDS 1 f1
# --- GT Case ---
# ts2 > ts1 → should succeed
set res_gt_pass [r HPEXPIREAT myhash $ts2 GT FIELDS 1 f1]
assert_equal {1} $res_gt_pass
# ts1 < ts2 → now try GT with ts1 again (should fail because ts2 is already set)
set res_gt_fail [r HPEXPIREAT myhash $ts1 GT FIELDS 1 f1]
assert_equal {0} $res_gt_fail
# --- LT Case ---
# ts1 < ts2 → LT should fail
set res_lt_fail [r HPEXPIREAT myhash $ts2 LT FIELDS 1 f1]
assert_equal {0} $res_lt_fail
# ts1 < ts2 → try LT with earlier timestamp, should succeed
set ts0 [expr {$now + 5000}]
set res_lt_pass [r HPEXPIREAT myhash $ts0 LT FIELDS 1 f1]
assert_equal {1} $res_lt_pass
}
test {HPEXPIREAT - invalid inputs} {
r FLUSHALL
r HSET myhash f1 a
catch {r HPEXPIREAT myhash abc FIELDS 1 f1} e
assert_match {*not an integer*} $e
catch {r HPEXPIREAT myhash 12345 NX XX FIELDS 1 f1} e2
assert_match {ERR NX and XX, GT or LT options at the same time are not compatible} $e2
}
test {HPEXPIRETIME - check with multiple fields} {
r FLUSHALL
# Setup: one expiring field, one persistent, one missing
r HSET myhash f1 v1 f2 v2
set ts [expr {[clock milliseconds] + 1000}]
r HPEXPIREAT myhash $ts FIELDS 1 f1
# Query all 3 fields
set result [r HPEXPIRETIME myhash FIELDS 3 f1 f2 f3]
# Expect: [timestamp] for f1, -1 for f2, -2 for f3
assert {[llength $result] == 3}
# f1: has TTL → returns exact timestamp
assert_equal $ts [lindex $result 0]
# f2: exists, no TTL → returns -1
assert_equal -1 [lindex $result 1]
# f3: doesn't exist → returns -2
assert_equal -2 [lindex $result 2]
}
#################### HPERSIST ##################
test "HPERSIST - field does not exist" {
r FLUSHALL
r hset myhash field1 value1
assert_equal {-2} [r hpersist myhash FIELDS 1 field2]
}
test "HPERSIST - key does not exist" {
r FLUSHALL
assert_equal {-2} [r hpersist nonexistent FIELDS 1 field1]
}
test "HPERSIST - field exists but no expiration" {
r del myhash
r hset myhash field1 value1
assert_equal {-1} [r hpersist myhash FIELDS 1 field1]
}
test "HPERSIST - field exists with expiration" {
r FLUSHALL
r hset myhash field1 value1
r hexpire myhash 600 FIELDS 1 field1
assert_morethan [r httl myhash FIELDS 1 field1] 0
assert_equal {1} [r hpersist myhash FIELDS 1 field1]
assert_equal {-1} [r httl myhash FIELDS 1 field1]
}
test "HPERSIST - multiple fields with mixed state" {
r FLUSHALL
r hset myhash f1 v1
r hset myhash f2 v2
r hset myhash f3 v3
r hexpire myhash 600 FIELDS 1 f1
# f2 will have no expiration
# f4 does not exist
assert_equal {1 -1 -2} [r hpersist myhash FIELDS 3 f1 f2 f4]
}
test {HPERSIST, then HEXPIRE, check new TTL is set} {
r FLUSHALL
r HSET myhash f1 v1
r HEXPIRE myhash 1000 FIELDS 1 f1
assert_equal 1 [r HPERSIST myhash FIELDS 1 f1]
r HEXPIRE myhash 2000 FIELDS 1 f1
assert_morethan [r HTTL myhash FIELDS 1 f1] 1000
}
#################### HRANDFIELD ##################
test "HRANDFIELD - CASE 1: negative count" {
r FLUSHALL
assert_equal {1} [r HSETEX myhash PX 1 fields 5 f1 v1 f2 v2 f3 v3 f4 v4 f5 v5]
wait_for_condition 100 100 {
[r HGETALL myhash] eq {}
} else {
fail "Hash is showing expired elements"
}
# check that we get an empty response even though there are expired fields
assert_match {} [r hrandfield myhash 1]
# Now write a persistent element
assert_equal {1} [r HSET myhash f5 v5]
# make sure this is the element we will get all the time
for {set i 1} {$i <= 50} {incr i} {
assert_equal {f5 f5 f5 f5 f5} [r hrandfield myhash -5]
}
}
test "HRANDFIELD - CASE 2: The number of requested elements is greater than the number of elements inside the hash" {
r FLUSHALL
assert_equal {1} [r HSETEX myhash PX 1 fields 5 f1 v1 f2 v2 f3 v3 f4 v4 f5 v5]
wait_for_condition 100 100 {
[r HGETALL myhash] eq {}
} else {
fail "Hash is showing expired elements"
}
# check that we get an empty response even though there are expired fields
assert_match {} [r hrandfield myhash 10]
# Now write a persistent element
assert_equal {3} [r HSET myhash f5 v5 f6 v6 f7 v7]
# make sure this is the element we will get all the time
for {set i 1} {$i <= 50} {incr i} {
set result [r hrandfield myhash 10]
assert_equal 3 [llength [split $result]]
assert_match {*f5*} $result
assert_match {*f6*} $result
assert_match {*f7*} $result
}
}
test "HRANDFIELD - CASE 3: The number of elements inside the hash is not greater than 3 times the number of requested elements" {
r FLUSHALL
assert_equal {1} [r HSETEX myhash PX 1 fields 5 f1 v1 f2 v2 f3 v3 f4 v4 f5 v5]
wait_for_condition 100 100 {
[r HGETALL myhash] eq {}
} else {
fail "Hash is showing expired elements"
}
# check that we get an empty response even though there are expired fields
assert_match {} [r hrandfield myhash 4]
# Now write a persistent elements
assert_equal {4} [r HSET myhash f5 v5 f6 v6 f7 v7 f8 v8]
# make sure this is the elements we will get all the time
for {set i 1} {$i <= 50} {incr i} {
set result [r hrandfield myhash 4]
assert_equal 4 [llength [split $result]]
assert_match {*f5*} $result
assert_match {*f6*} $result
assert_match {*f7*} $result
assert_match {*f8*} $result
}
}
test "HRANDFIELD - CASE 4: The number of elements inside the hash is greater than 3 times the number of requested elements" {
r FLUSHALL
assert_equal {1} [r HSETEX myhash PX 1 fields 8 f1 v1 f2 v2 f3 v3 f4 v4 f5 v5 f6 v6 f7 v7 f8 v8]
wait_for_condition 100 100 {
[r HGETALL myhash] eq {}
} else {
fail "Hash is showing expired elements"
}
# check that we get an empty response even though there are expired fields
assert_match {} [r hrandfield myhash 2]
# Now write a persistent elements
assert_equal {3} [r HSET myhash f8 v8 f9 v9 f10 v10]
# make sure this is the elements we will get all the time
for {set i 1} {$i <= 50} {incr i} {
set result [r hrandfield myhash 3]
assert_equal 3 [llength [split $result]]
assert_match {*f8*} $result
assert_match {*f9*} $result
assert_match {*f10*} $result
}
}
foreach cmd {RENAME RESTORE} {
test "$cmd Preserves Field TTLs" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
r HSET myhash{t} f1 v1 f2 v2
r HEXPIRE myhash{t} 200 FIELDS 1 f1
# Verify initial TTL state
set mem_before [r MEMORY USAGE myhash{t}]
assert_equal "v1 v2" [r HMGET myhash{t} f1 f2]
assert_morethan [r HTTL myhash{t} FIELDS 1 f1] 100
assert_equal -1 [r HTTL myhash{t} FIELDS 1 f2]
assert_equal 2 [r HLEN myhash{t}]
assert_equal 1 [get_keys r]
assert_equal 1 [get_keys_with_volatile_items r]
# Run the command
if {$cmd eq "RENAME"} {
r rename myhash{t} nwhash{t}
set newhash nwhash{t}
} elseif {$cmd eq "RESTORE"} {
set serialized [r DUMP myhash{t}]
r RESTORE rstrhs{t} 0 $serialized
set newhash rstrhs{t}
}
# Verify field values and TTLs are preserved
set memory_after [r MEMORY USAGE $newhash]
assert_equal "v1 v2" [r HMGET $newhash f1 f2]
assert_morethan [r HTTL $newhash FIELDS 1 f1] 100
assert_equal -1 [r HTTL $newhash FIELDS 1 f2]
assert_equal 2 [r HLEN $newhash]
if {$cmd eq "RESTORE"} {
assert_equal 2 [get_keys r]
assert_equal 2 [get_keys_with_volatile_items r]
} else {
assert_equal 1 [get_keys r]
assert_equal 1 [get_keys_with_volatile_items r]
}
assert_equal $mem_before $memory_after
} {} {needs:debug}
}
test {COPY Preserves TTLs} {
r flushall
# Create hash with fields
r HSET myhash{t} f1 v1 f3 v3 f4 v4
# Set TTL on f1 only
r HEXPIRE myhash{t} 2000 FIELDS 1 f1
r HEXPIRE myhash{t} 5000 FIELDS 1 f3
# Copy hash to new key
r copy myhash{t} nwhash{t}
# Verify initial TTL state
assert_equal [r MEMORY USAGE myhash{t}] [r MEMORY USAGE nwhash{t}]
assert_equal "v1 v3 v4" [r HMGET myhash{t} f1 f3 f4]
assert_equal "v1 v3 v4" [r HMGET nwhash{t} f1 f3 f4]
assert_equal [r HEXPIRETIME myhash{t} FIELDS 1 f1] [r HEXPIRETIME nwhash{t} FIELDS 1 f1]
assert_equal [r HEXPIRETIME myhash{t} FIELDS 1 f2] [r HEXPIRETIME nwhash{t} FIELDS 1 f2]
assert_equal [r HEXPIRETIME myhash{t} FIELDS 1 f3] [r HEXPIRETIME nwhash{t} FIELDS 1 f3]
assert_equal [r HEXPIRETIME myhash{t} FIELDS 1 f4] [r HEXPIRETIME nwhash{t} FIELDS 1 f4]
}
test {Hash Encoding Transitions with TTL - Add TTL to Existing Fields} {
r flushall
r DEBUG SET-ACTIVE-EXPIRE 0
# Create small hash with listpack encoding
r HSET myhash f1 v1 f2 v2
# Verify initial encoding
set "listpack" [r OBJECT ENCODING myhash]
# Add TTL to existing field
r HEXPIRE myhash 300 FIELDS 1 f1
# Verify encoding changed to hashtable
set "hashtable" [r OBJECT ENCODING myhash]
# Verify field values are preserved
assert_equal "v1 v2" [r HMGET myhash f1 f2]
# Veridy expiry
assert_morethan [r HTTL myhash FIELDS 1 f1] 100
assert_equal -1 [r HTTL myhash FIELDS 1 f2]
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {Hash Encoding Transitions with TTL - Create New Fields with TTL} {
r flushall
r DEBUG SET-ACTIVE-EXPIRE 0
# Create small hash with listpack encoding
r HSET myhash f1 v1 f2 v2
# Verify initial encoding
set "listpack" [r OBJECT ENCODING myhash]
# Add many fields to force encoding transition
for {set i 3} {$i <= 600} {incr i} {
lappend pairs "f$i" "v$i"
}
r HSET myhash {*}$pairs
r HEXPIRE myhash 3 FIELDS 5 f1 f10 f100 f200 f300
# Verify encoding changed to hashtable
set "hashtable" [r OBJECT ENCODING myhash]
# Verify all field values and TTLs are correct
for {set i 1} {$i <= 600} {incr i} {
assert_equal "v$i" [r HGET myhash "f$i"]
if {$i == 1 || $i == 10 || $i == 100 || $i == 200 || $i == 300} {
assert_equal 3 [r HTTL myhash FIELDS 1 "f$i"]
} else {
assert_equal -1 [r HTTL myhash FIELDS 1 "f$i"]
}
}
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HGETALL skips expired fields} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# Set two fields: one persistent, one with short TTL
r HSET myhash persistent "val1"
r HSETEX myhash PX 5 FIELDS 1 expiring "val2"
# Wait for expiry to pass
after 10
# HGETALL should skip expired field
set result [r HGETALL myhash]
assert_equal {persistent val1} $result
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HSCAN skips expired fields} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# Set multiple fields, one with expiry
r HSET myhash persistent1 "a" persistent2 "b"
r HSETEX myhash PX 5 FIELDS 1 expiring "c"
# Wait for expiration
after 10
# HSCAN must not return the expired field
set cursor 0
set allfields {}
while {1} {
set res [r HSCAN myhash $cursor]
set cursor [lindex $res 0]
set kvs [lindex $res 1]
lappend allfields {*}$kvs
if {$cursor eq "0"} break
}
# Extract just the field names
set fieldnames [lmap {k v} $allfields { set k }]
set fieldnames_sorted [lsort $fieldnames]
# Should only include persistent1 and persistent2
assert_equal {persistent1 persistent2} $fieldnames_sorted
# Re-enable active expiry for future tests
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {MOVE preserves field TTLs} {
r FLUSHALL
r SELECT 0
r HSETEX myhash PX 50000 FIELDS 1 field1 val1
# Capture original TTL
set original_ttl [r HPTTL myhash FIELDS 1 field1]
assert {$original_ttl > 0}
# Move to DB 1
assert_equal 1 [r MOVE myhash 1]
# Switch to target DB
r SELECT 1
# Field must exist and TTL must be preserved
set moved_ttl [r HPTTL myhash FIELDS 1 field1]
assert {$moved_ttl > 0 && $moved_ttl <= $original_ttl}
} {} {needs:debug}
test {HSET - overwrite expired field without TTL clears expiration} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# This test verifies that if a field has expired (but not yet lazily deleted),
# and it is overwritten using a plain HSET (i.e., no TTL),
# Valkey treats the field as non existing and updates it,
# effectively clearing the old TTL and making the field persistent.
r HSETEX myhash PX 10 FIELDS 1 field1 oldval
wait_for_condition 100 100 {
[r HTTL myhash FIELDS 1 field1] eq "-2"
} else {
fail "hash value was not expired after timeout"
}
# Field should still be present in memory due to lazy expiry
assert_equal 1 [r HLEN myhash]
# Overwrite with HSET (no TTL) before accessing
r HSET myhash field1 newval
# TTL should now be gone; field becomes persistent
set ttl [r HPTTL myhash FIELDS 1 field1]
assert_equal -1 $ttl
assert_equal newval [r HGET myhash field1]
assert_equal 1 [r HLEN myhash]
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HINCRBY - on expired field} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# This test verifies that if a field has expired,
# and it is overwritten using a plain HINCRBY (i.e., no TTL),
# Valkey treats the field as still existing and updates it,
# effectively clearing the old TTL and starting the value from 0.
r HSETEX myhash PX 10 FIELDS 1 field1 1
wait_for_condition 100 100 {
[r HTTL myhash FIELDS 1 field1] eq "-2"
} else {
fail "hash value was not expired after timeout"
}
# Field should still be present in memory
assert_equal 1 [r HLEN myhash]
# Overwrite with HINCRBY (no TTL) before accessing
r HINCRBY myhash field1 1
# Sanity check: check we only have one field in the hash
assert_equal 1 [r HLEN myhash]
# TTL should now be gone; field becomes persistent
set ttl [r HPTTL myhash FIELDS 1 field1]
assert_equal -1 $ttl
assert_equal 1 [r HGET myhash field1]
assert_equal 1 [r HLEN myhash]
# set expiration on the field
assert_equal 1 [r HEXPIRE myhash 100000000 FIELDS 1 field1]
# verify the field has TTL
assert_morethan [r HPTTL myhash FIELDS 1 field1] 0
# now incr the field again
assert_equal 2 [r HINCRBY myhash field1 1]
# verify the field has TTL
assert_morethan [r HPTTL myhash FIELDS 1 field1] 0
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HINCRBYFLOAT - on expired field} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# This test verifies that if a field has expired,
# and it is overwritten using a plain HINCRBYFLOAT (i.e., no TTL),
# Valkey treats the field as still existing and updates it,
# effectively clearing the old TTL and starting the value from 0.
r HSETEX myhash PX 10 FIELDS 1 field1 1
wait_for_condition 100 100 {
[r HTTL myhash FIELDS 1 field1] eq "-2"
} else {
fail "hash value was not expired after timeout"
}
# Field should still be present in memory
assert_equal 1 [r HLEN myhash]
# Overwrite with HINCRBYFLOAT (no TTL) before accessing
r HINCRBYFLOAT myhash field1 1
# Sanity check: check we only have one field in the hash
assert_equal 1 [r HLEN myhash]
# TTL should now be gone; field becomes persistent
set ttl [r HPTTL myhash FIELDS 1 field1]
assert_equal -1 $ttl
assert_equal 1 [r HGET myhash field1]
assert_equal 1 [r HLEN myhash]
# set expiration on the field
assert_equal 1 [r HEXPIRE myhash 100000000 FIELDS 1 field1]
# verify the field has TTL
assert_morethan [r HPTTL myhash FIELDS 1 field1] 0
# now incr the field again
assert_equal 2 [r HINCRBYFLOAT myhash field1 1]
# verify the field has TTL
assert_morethan [r HPTTL myhash FIELDS 1 field1] 0
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HSET - overwrite unexpired field removes TTL} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# This test verifies that overwriting a field with HSET,
# even while its TTL is still valid (not expired),
# clears the TTL and makes the field persistent.
# This behavior is consistent with how HSET works for normal keys.
# Set field with long TTL
r HSETEX myhash PX 1000 FIELDS 1 field1 val1
# Confirm TTL is active
set before [r HPTTL myhash FIELDS 1 field1]
assert {$before > 0}
# Overwrite with HSET before TTL expires
r HSET myhash field1 newval
# TTL should now be gone
set after [r HPTTL myhash FIELDS 1 field1]
assert_equal -1 $after
assert_equal newval [r HGET myhash field1]
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HDEL - expired field is removed without triggering expiry logic} {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
# This test proves that deleting an expired field with HDEL
# does NOT trigger Valkey's expiration mechanism.
#
# The key observation is that Valkey tracks how many fields were
# expired via TTL using the `expired_fields` counter in INFO stats.
# If HDEL caused expiration to be processed internally,
# this counter would increment. We assert that it remains unchanged.
# Capture expired_fields before
set before_info [r INFO stats]
set before [info_field $before_info expired_fields]
# Create field with short TTL
r HSETEX myhash PX 10 FIELDS 1 field1 val1
after 20
# Field is technically expired, but still in-memory due to lazy expiry
assert_equal 1 [r HLEN myhash]
# Delete the expired field directly
r HDEL myhash field1
# Field should be gone
assert_equal 0 [r HEXISTS myhash field1]
# Capture expired_fields again
set after_info [r INFO stats]
set after [info_field $after_info expired_fields]
# Verify that no expiry occurred internally
assert_equal $before $after
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {HDEL on field with TTL, then re-add and check TTL is gone} {
r FLUSHALL
r HSET myhash f1 v1
r HEXPIRE myhash 10000 FIELDS 1 f1
assert_morethan [r HTTL myhash FIELDS 1 f1] 0
r HDEL myhash f1
r HSET myhash f1 v2
assert_equal -1 [r HTTL myhash FIELDS 1 f1]
}
## expired_fields Tests ####
test {expired_fields metric increments by one when single hash field expires} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
# Create hash with fields and ttl
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
# Force expiration by setting very short TTL
r HPEXPIRE myhash 1 FIELDS 1 f1
# Wait for expiration
wait_for_active_expiry r myhash 2 $initial_expired 1
# Verify expired field returns empty string and non-expired return values
assert_equal "{} v2 v3" [r HMGET myhash f1 f2 f3]
}
test {expired_fields metric tracks multiple field expirations with keyspace notifications} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
set rd [setup_single_keyspace_notification r]
# Create hash with expiring fields
r HSET myhash f1 v1 f2 v2 f3 v3 f4 v4 f5 v5
r HEXPIRE myhash 1000 FIELDS 1 f1
r HEXPIRE myhash 2000 FIELDS 1 f2
# Force expiration with short ttl
r HPEXPIRE myhash 1 FIELDS 1 f1
# Wait for expiration
wait_for_active_expiry r myhash 4 $initial_expired 1
# Verify expired_fields incremented
assert_equal 1 [expr {[info_field [r info stats] expired_fields] - $initial_expired}]
# Verify expired field returns empty string and non-expired return values
assert_equal "{} v2 v3 v4 v5" [r HMGET myhash f1 f2 f3 f4 f5]
# Test HPERSIST remove TTL from f2
r HPERSIST myhash FIELDS 1 f2
# Verify f2 no longer has TTL
assert_equal -1 [r HTTL myhash FIELDS 1 f2]
assert_equal 1 [expr {[info_field [r info stats] expired_fields] - $initial_expired}]
# Expire 2 fields at once
r HPEXPIRE myhash 1 FIELDS 2 f4 f5
wait_for_active_expiry r myhash 2 $initial_expired 3
assert_equal 3 [expr {[info_field [r info stats] expired_fields] - $initial_expired}]
# Verify expired fields return empty string and non-expired return values
assert_equal "{} v2 v3 {} {}" [r HMGET myhash f1 f2 f3 f4 f5]
# Wait for hset and hexpire events
assert_keyevent_patterns $rd myhash hset hexpire hexpire hexpire hexpired hpersist hexpire hexpired
$rd close
}
foreach time_unit {s, ms} {
test "Key TTL expires before field TTL: entire hash should be deleted timeunit: $time_unit" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
r config set notify-keyspace-events KEA
set rd [valkey_deferring_client]
assert_equal {1} [psubscribe $rd __keyevent@*]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 3 [r HLEN myhash]
if {$time_unit eq "s"} {
r HEXPIRE hash1 10 FIELDS 1 f1
r EXPIRE hash1 1
} else {
r HPEXPIRE myhash 10000 FIELDS 1 f1
r PEXPIRE myhash 1000
}
wait_for_condition 100 100 {
[r EXISTS myhash] eq "0"
} else {
fail "myhash still exists"
}
assert_equal 0 [r HLEN myhash]
assert_equal 0 [get_keys r]
assert_keyevent_patterns $rd myhash hset hexpire expire
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test "Field TTL expires before key TTL: only the specific field should expire: $time_unit" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
set rd [valkey_deferring_client]
assert_equal {1} [psubscribe $rd __keyevent@*]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 3 [r HLEN myhash]
if {$time_unit eq "s"} {
r HEXPIRE myhash 1 FIELDS 1 f1
r EXPIRE myhash 10
} else {
r HPEXPIRE myhash 1000 FIELDS 1 f1
r PEXPIRE myhash 10000
}
wait_for_condition 100 100 {
[r HGET myhash f1] eq ""
} else {
fail "f1 not expired"
}
assert_equal 1 [get_keys r]
assert_equal 1 [r EXISTS myhash]
assert_equal "{} v2 v3" [r HMGET myhash f1 f2 f3]
assert_keyevent_patterns $rd myhash hset hexpire
# When active expire is disabled, expired key is
# not deleted and get_keys_with_volatile_items is the same
assert_equal 1 [get_keys_with_volatile_items r]
$rd close
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test "Key and field TTL expire simultaneously: entire hash should be deleted: $time_unit" {
r FLUSHALL
r DEBUG SET-ACTIVE-EXPIRE 0
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 1 [get_keys r]
assert_equal 3 [r HLEN myhash]
if {$time_unit eq "s"} {
set expire [expr {[clock seconds] + 1}]
r HEXPIREAT myhash $expire FIELDS 1 f1
r EXPIREAT myhash $expire
} else {
set expire [expr {[clock milliseconds] + 1000}]
r HPEXPIREAT myhash $expire FIELDS 1 f1
r PEXPIREAT myhash $expire
}
wait_for_condition 100 100 {
[r EXISTS myhash] eq 0
} else {
fail "myhash still exist"
}
assert_equal "{} {} {}" [r HMGET myhash f1 f2 f3]
assert_equal 0 [get_keys r]
assert_equal 0 [r HLEN myhash]
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {Millisecond/Seconds precision} {
r flushall
r DEBUG SET-ACTIVE-EXPIRE 0
r HSET myhash f1 v1 f2 v2
if {$time_unit eq "s"} {
r HEXPIRE myhash 3 FIELDS 1 f1
r EXPIRE myhash 1
} else {
r HPEXPIRE myhash 3000 FIELDS 1 f1
r PEXPIRE myhash 1000
}
after 1500
assert_equal 0 [r EXISTS myhash]
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
}
test {Ensure that key-level PERSIST on the key don't affect field TTL} {
r FLUSHALL
r HSET myhash f1 v1 f2 v2
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 2 [r HLEN myhash]
r HEXPIRE myhash 100000 FIELDS 1 f1
r PERSIST myhash
assert_equal -1 [r TTL myhash]
assert_morethan [r HTTL myhash FIELDS 1 f1] 0
assert_equal 1 [get_keys_with_volatile_items r]
}
test {Verify error when hash expire commands num fields is not provided} {
r FLUSHALL
catch {r hsetex myhash KEEPTTL KEEPTTL KEEPTTL FIELDS} e
assert_match $e {ERR numfields should be greater than 0 and match the provided number of fields}
catch {r hexpire myhash 10 NX NX FIELDS} e
assert_match $e {ERR numfields should be greater than 0 and match the provided number of fields}
catch {r hgetex myhash PERSIST PERSIST FIELDS} e
assert_match $e {ERR numfields should be greater than 0 and match the provided number of fields}
}
}
####### Test info
start_server {tags {"hash-ttl-info external:skip"}} {
test {Hash ttl - check command stats} {
r FLUSHALL
# Run all relevant hash TTL commands
r HSET myhash f1 v1 f2 v2
r HEXPIRE myhash 10 FIELDS 1 f1
r HEXPIREAT myhash [expr {[clock seconds] + 10}] FIELDS 1 f2
r HEXPIRETIME myhash FIELDS 2 f1 f2
r HPEXPIRE myhash 1000 FIELDS 1 f1
r HPEXPIREAT myhash [expr {[clock milliseconds] + 2000}] FIELDS 1 f2
r HPEXPIRETIME myhash FIELDS 2 f1 f2
r HGETEX myhash EX 120 FIELDS 1 f1
r HTTL myhash FIELDS 1 f2
r HPTTL myhash FIELDS 1 f1
# Fetch commandstats
set info [r INFO commandstats]
# Extract call counts
proc get_calls {info cmd} {
foreach line [split $info "\n"] {
if {[string match "cmdstat_$cmd:*" $line]} {
regexp {calls=(\d+)} $line -> count
return $count
}
}
return -1
}
# Assert each command appears with correct call count (1 call each)
assert_equal 1 [get_calls $info hexpire]
assert_equal 1 [get_calls $info hexpireat]
assert_equal 1 [get_calls $info hexpiretime]
assert_equal 1 [get_calls $info hpexpire]
assert_equal 1 [get_calls $info hpexpireat]
assert_equal 1 [get_calls $info hpexpiretime]
assert_equal 1 [get_calls $info hgetex]
assert_equal 1 [get_calls $info httl]
assert_equal 1 [get_calls $info hpttl]
}
}
#### Replication ####
start_server {tags {"hashexpire external:skip"}} {
# Start another server to test replication of TTLs
start_server {tags {needs:repl external:skip}} {
# Set the outer layer server as primary
set primary [srv -1 client]
set primary_host [srv -1 host]
set primary_port [srv -1 port]
# Set this inner layer server as replica
set replica [srv 0 client]
test {Setup replica and check field expiry after full sync} {
$primary flushall
# Set up some TTLs on primary BEFORE replica connects
set now [clock milliseconds]
set f1_exp [expr {$now + 50000}]
set f2_exp [expr {$now + 70000}]
$primary HSET myhash f1 v1 f2 v2
$primary HPEXPIREAT myhash $f1_exp FIELDS 1 f1
$primary HPEXPIREAT myhash $f2_exp FIELDS 1 f2
# Now connect replica
$replica replicaof $primary_host $primary_port
wait_for_condition 100 100 {
[info_field [$replica info replication] master_link_status] eq "up"
} else {
fail "Master <-> Replica didn't finish sync"
}
# Wait for full sync
wait_for_ofs_sync $primary $replica
# Validate TTLs replicated correctly
set r1 [$replica HPEXPIRETIME myhash FIELDS 1 f1]
set r2 [$replica HPEXPIRETIME myhash FIELDS 1 f2]
assert_equal $f1_exp $r1
assert_equal $f2_exp $r2
}
test {HASH TTL - replicated TTL is absolute and consistent on replica} {
$primary flushall
set now [clock milliseconds]
set future [expr {$now + 5000}]
set future_sec [expr {$future / 1000}]
# HPEXPIREAT
$primary HSET myhash f1 v1
$primary HPEXPIREAT myhash $future FIELDS 1 f1
# HSETEX EX
$primary HSETEX myhash EX 5 FIELDS 1 f2 v2
# HEXPIRE
$primary HSET myhash f3 v3
$primary HEXPIRE myhash 5 FIELDS 1 f3
wait_for_ofs_sync $primary $replica
set t1 [$primary HPEXPIRETIME myhash FIELDS 1 f1]
set t1r [$replica HPEXPIRETIME myhash FIELDS 1 f1]
assert_equal $t1 $t1r
set t2 [$primary HEXPIRETIME myhash FIELDS 1 f2]
set t2r [$replica HEXPIRETIME myhash FIELDS 1 f2]
assert_equal $t2 $t2r
set t3 [$primary HEXPIRETIME myhash FIELDS 1 f3]
set t3r [$replica HEXPIRETIME myhash FIELDS 1 f3]
assert_equal $t3 $t3r
}
test {HASH TTL - field expired on master gets deleted on replica} {
$primary flushall
$primary HSETEX myhash PX 10 FIELDS 1 f1 val1
after 20
wait_for_ofs_sync $primary $replica
# Trigger lazy expiry
catch {$primary HGET myhash f1}
wait_for_ofs_sync $primary $replica
assert_equal 0 [$replica HEXISTS myhash f1]
}
test {HASH TTL - replica retains TTL and field before expiration} {
$primary flushall
$primary HSETEX myhash PX 1000 FIELDS 1 f1 val1
wait_for_ofs_sync $primary $replica
set master_ttl [$primary HPTTL myhash FIELDS 1 f1]
set replica_ttl [$replica HPTTL myhash FIELDS 1 f1]
assert {$replica_ttl > 0}
assert {$replica_ttl <= $master_ttl}
}
test {HSETEX with expired time is propagated to the replica} {
$primary flushall
assert_equal [$primary HSET myhash f1 val1] "1"
wait_for_condition 100 100 {
[$replica HGET myhash f1] eq {val1}
} else {
fail "hash field was not set on replica after timeout"
}
assert_equal [$primary HSETEX myhash EXAT 0 FIELDS 1 f1 val1] {1}
wait_for_condition 100 100 {
[$primary EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on primary after timeout"
}
wait_for_ofs_sync $primary $replica
wait_for_condition 100 100 {
[$replica EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on replica after timeout"
}
}
test {HGETEX with expired time is propagated to the replica} {
$primary flushall
assert_equal [$primary HSET myhash f1 val1] "1"
wait_for_condition 100 100 {
[$replica HGET myhash f1] eq {val1}
} else {
fail "hash field was not set on replica after timeout"
}
assert_equal [$primary HGETEX myhash EXAT 0 FIELDS 1 f1] {val1}
wait_for_condition 100 100 {
[$primary EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on primary after timeout"
}
wait_for_ofs_sync $primary $replica
wait_for_condition 100 100 {
[$replica EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on replica after timeout"
}
}
test {HEXPIREAT with expired time is propagated to the replica} {
$primary flushall
assert_equal [$primary HSET myhash f1 val1] "1"
wait_for_condition 100 100 {
[$replica HGET myhash f1] eq {val1}
} else {
fail "hash field was not set on replica after timeout"
}
assert_equal [$primary HEXPIREAT myhash 0 FIELDS 1 f1] {2}
wait_for_condition 100 100 {
[$primary EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on primary after timeout"
}
wait_for_ofs_sync $primary $replica
wait_for_condition 100 100 {
[$replica EXISTS myhash] eq "0"
} else {
fail "hash object was not deleted on replica after timeout"
}
}
}
}
start_server {tags {"hashexpire external:skip"}} {
set primary [srv 0 client]
set primary_host [srv 0 host]
set primary_port [srv 0 port]
start_server {tags {needs:repl external:skip}} {
set replica_1 [srv 0 client]
set replica_1_host [srv 0 host]
set replica_1_port [srv 0 port]
test {Replication Primary -> R1} {
lassign [setup_replication_test $primary $replica_1 $primary_host $primary_port] primary_initial_expired replica_1_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica_1] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -1]
set rd_replica_1 [valkey_deferring_client $replica_1_host $replica_1_port]
foreach rd [list $rd_primary $rd_replica_1] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Setup hash, set expire and set expire 0
$primary HSET myhash f1 v1 f2 v2 ;# Should trigger 3 hset
# Create hash and timing - f1 < f2 expiry times
set f1_exp [expr {[clock seconds] + 10000}]
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1 ;# Should trigger 3 hexpire
wait_for_ofs_sync $primary $replica_1
$primary HEXPIRE myhash 0 FIELDS 1 f1 ;# Should trigger 1 hexpired (for primary) and 1 hdel (for replica)
wait_for_ofs_sync $primary $replica_1
# Wait for f1 expiration
wait_for_condition 50 100 {
[$primary HTTL myhash FIELDS 1 f1] eq -2 && \
[$replica_1 HTTL myhash FIELDS 1 f1] eq -2
} else {
fail "f1 still exists"
}
# Verify keyspace notification
foreach rd [list $rd_primary $rd_replica_1] {
assert_keyevent_patterns $rd myhash hset hexpire
}
# primary gets hexpired and replica gets hdel
assert_keyevent_patterns $rd_primary myhash hexpired
assert_keyevent_patterns $rd_replica_1 myhash hdel
$rd_primary close
$rd_replica_1 close
}
start_server {tags {needs:repl external:skip}} {
$primary FLUSHALL
set replica_2 [srv 0 client]
set replica_2_host [srv 0 host]
set replica_2_port [srv 0 port]
test {Chain Replication (Primary -> R1 -> R2) preserves TTL} {
$replica_1 replicaof $primary_host $primary_port
# Wait for R2 to connect to R1
wait_for_condition 100 100 {
[info_field [$replica_1 info replication] master_link_status] eq "up"
} else {
fail "R1 <-> PRIMARY didn't establish connection"
}
$replica_2 replicaof $replica_1_host $replica_1_port
# Wait for R2 to connect to R1
wait_for_condition 100 100 {
[info_field [$replica_1 info replication] master_link_status] eq "up"
} else {
fail "R2 <-> R1 didn't establish connection"
}
# Initialize deferred clients and subscribe to keyspace notifications
set rd_primary [valkey_deferring_client -2]
set rd_replica_1 [valkey_deferring_client -1]
set rd_replica_2 [valkey_deferring_client $replica_2_host $replica_2_port]
assert_equal {1} [psubscribe $rd_primary __keyevent@*]
assert_equal {1} [psubscribe $rd_replica_1 __keyevent@*]
assert_equal {1} [psubscribe $rd_replica_2 __keyevent@*]
# Create hash and timing - f1 < f2 < f3 expiry times
set f1_exp [expr {[clock seconds] + 1000000}]
wait_for_ofs_sync $primary $replica_1
wait_for_ofs_sync $replica_1 $replica_2
############################################# STEUP HASH #############################################
$primary HSETEX myhash FIELDS 2 f1 v1 f2 v2 ;# Should trigger 3 hset
wait_for_ofs_sync $primary $replica_1
wait_for_ofs_sync $replica_1 $replica_2
# Verify hset event was generated on all 3 nodes
foreach rd [list $rd_primary $rd_replica_1 $rd_replica_2] {
assert_keyevent_patterns $rd myhash hset
}
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1 ;# Should trigger 3 hexpire
wait_for_ofs_sync $primary $replica_1
wait_for_ofs_sync $replica_1 $replica_2
# Verify hexpire event was generated on all 3 nodes
foreach rd [list $rd_primary $rd_replica_1 $rd_replica_2] {
assert_keyevent_patterns $rd myhash hexpire
}
$primary HPEXPIRE myhash 0 FIELDS 1 f1 ;# Should trigger 1 hexpired (for primary) and 2 hdel (for replicas)
wait_for_ofs_sync $primary $replica_1
wait_for_ofs_sync $replica_1 $replica_2
# Wait for f1 expiration
wait_for_condition 50 100 {
[$primary HTTL myhash FIELDS 1 f1] eq -2 && \
[$replica_1 HTTL myhash FIELDS 1 f1] eq -2 && \
[$replica_2 HTTL myhash FIELDS 1 f1] eq -2
} else {
fail "f1 still exists"
}
# primary gets hexpired and replicas get hdel
assert_keyevent_patterns $rd_primary myhash hexpired
assert_keyevent_patterns $rd_replica_1 myhash hdel
assert_keyevent_patterns $rd_replica_2 myhash hdel
$rd_primary close
$rd_replica_1 close
$rd_replica_2 close
}
}
test {Replica Failover} {
$primary FLUSHALL
$primary DEBUG SET-ACTIVE-EXPIRE 0
$replica_1 DEBUG SET-ACTIVE-EXPIRE 0
####### Replication setup #######
$replica_1 replicaof $primary_host $primary_port
wait_for_condition 50 100 {
[lindex [$replica_1 role] 0] eq {slave} &&
[string match {*master_link_status:up*} [$replica_1 info replication]]
} else {
fail "Can't turn the instance into a replica"
}
# Create hash fields with TTL on primary
set f1_exp [expr {[clock seconds] + 200}]
set f2_exp [expr {[clock seconds] + 300000}]
$primary HSET myhash f1 v1 f2 v2 f3 v3
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1
$primary HEXPIREAT myhash $f2_exp FIELDS 1 f2
# f3 remains persistent
# Wait for full sync
wait_for_ofs_sync $primary $replica_1
# Verify primary and replica are the same
foreach instance [list $primary $replica_1] {
assert_equal $f1_exp [$instance HEXPIRETIME myhash FIELDS 1 f1]
assert_equal $f2_exp [$instance HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -1 [$instance HTTL myhash FIELDS 1 f3]
assert_equal 1 [get_keys $instance]
assert_equal 1 [get_keys_with_volatile_items $instance]
assert_equal "v1 v2 v3" [$instance HMGET myhash f1 f2 f3]
assert_equal 3 [$instance HLEN myhash]
}
# Perform failover
$replica_1 replicaof no one
# Wait for replica to become primary
wait_for_condition 100 100 {
[info_field [$replica_1 info replication] role] eq "master"
} else {
fail "Replica didn't become master"
}
# Setup keyspace notifications for the promoted replica
$replica_1 config set notify-keyspace-events KEA
set rd_replica [valkey_deferring_client $replica_1_host $replica_1_port]
assert_equal {1} [psubscribe $rd_replica __keyevent@*]
# Check all values that checked before are the same
assert_equal 3 [$replica_1 HLEN myhash]
assert_equal $f1_exp [$replica_1 HEXPIRETIME myhash FIELDS 1 f1]
assert_equal $f2_exp [$replica_1 HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -1 [$replica_1 HTTL myhash FIELDS 1 f3]
assert_equal "v1 v2 v3" [$replica_1 HGETEX myhash FIELDS 3 f1 f2 f3]
assert_equal 3 [$replica_1 HLEN myhash]
# Set f1 to expire in 1 second and wait for expiration
$replica_1 HEXPIRE myhash 1 FIELDS 1 f1 ;# will trigger hexpire
wait_for_condition 50 100 {
[$replica_1 HTTL myhash FIELDS 1 f1] eq -2
} else {
fail "f1 not expired"
}
# Verify expiry in replica
assert_equal "" [$replica_1 HGET myhash f1]
assert_equal 3 [$replica_1 HLEN myhash]
# Verify no expiry in primary
assert_equal "v1" [$primary HGET myhash f1]
# Change TTL of f2
$replica_1 HEXPIRE myhash 1000000 FIELDS 1 f2 ;# will trigger hexpire
assert_morethan [$replica_1 HTTL myhash FIELDS 1 f2] 9000
assert_equal $f2_exp [$primary HEXPIRETIME myhash FIELDS 1 f2]
# Change TTL of f2 to 0 (immediate expiry)
$replica_1 HGETEX myhash EX 0 FIELDS 1 f2 ;# will trigger hexpired
# Verify final state
assert_equal 2 [$replica_1 HLEN myhash]
assert_equal "{} {} v3" [$replica_1 HGETEX myhash FIELDS 3 f1 f2 f3]
assert_equal "v1 v2 v3" [$primary HGETEX myhash FIELDS 3 f1 f2 f3] ;# No change for primary
assert_keyevent_patterns $rd_replica myhash hexpire hexpire hexpired
$rd_replica close
# Re-enable active expiry
$primary DEBUG SET-ACTIVE-EXPIRE 1
$replica_1 DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
test {Promotion to primary} {
lassign [setup_replication_test $primary $replica_1 $primary_host $primary_port] primary_initial_expired replica_1_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica_1] {
$instance config set notify-keyspace-events KEA
$instance DEBUG SET-ACTIVE-EXPIRE 0
}
####### Replication setup #######
$replica_1 replicaof $primary_host $primary_port
wait_for_condition 50 100 {
[lindex [$replica_1 role] 0] eq {slave} &&
[string match {*master_link_status:up*} [$replica_1 info replication]]
} else {
fail "Can't turn the instance into a replica"
}
# Create hash fields with TTL on primary
set f1_exp [expr {[clock seconds] + 200}]
set f2_exp [expr {[clock seconds] + 300000}]
$primary HSET myhash f1 v1 f2 v2 f3 v3
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1
$primary HEXPIREAT myhash $f2_exp FIELDS 1 f2
# f3 remains persistent
# Wait for full sync
wait_for_ofs_sync $primary $replica_1
# Verify primary and replica are the same
foreach instance [list $primary $replica_1] {
assert_equal $f1_exp [$instance HEXPIRETIME myhash FIELDS 1 f1]
assert_equal $f2_exp [$instance HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -1 [$instance HTTL myhash FIELDS 1 f3]
assert_equal 1 [get_keys $instance]
assert_equal 1 [get_keys_with_volatile_items $instance]
assert_equal "v1 v2 v3" [$instance HMGET myhash f1 f2 f3]
assert_equal 3 [$instance HLEN myhash]
}
# Perform promotion to primary
$primary FAILOVER TO $replica_1_host $replica_1_port
# Wait for replica to become primary
wait_for_condition 100 100 {
[info_field [$replica_1 info replication] role] eq "master"
} else {
fail "Replica didn't become master"
}
# Setup keyspace notifications
$primary config set notify-keyspace-events KEA
$replica_1 config set notify-keyspace-events KEA
set rd_primary [valkey_deferring_client -1]
set rd_replica_1 [valkey_deferring_client $replica_1_host $replica_1_port]
assert_equal {1} [psubscribe $rd_primary __keyevent@*]
assert_equal {1} [psubscribe $rd_replica_1 __keyevent@*]
# Check all values that checked before are the same after the failover
foreach instance [list $primary $replica_1] {
assert_equal $f1_exp [$instance HEXPIRETIME myhash FIELDS 1 f1]
assert_equal $f2_exp [$instance HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -1 [$instance HTTL myhash FIELDS 1 f3]
assert_equal 1 [get_keys $instance]
assert_equal 1 [get_keys_with_volatile_items $instance]
assert_equal "v1 v2 v3" [$instance HMGET myhash f1 f2 f3]
assert_equal 3 [$instance HLEN myhash]
}
# Set f1 to expire in 1 second and wait for expiration
$replica_1 HEXPIRE myhash 1 FIELDS 1 f1 ;# will trigger hexpire
wait_for_ofs_sync $replica_1 $primary
wait_for_condition 50 100 {
[$replica_1 HTTL myhash FIELDS 1 f1] eq -2
} else {
fail "f1 not expired"
}
# Verify replica and primary are sync
foreach instance [list $primary $replica_1] {
assert_equal $f2_exp [$instance HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -2 [$instance HTTL myhash FIELDS 1 f1]
assert_equal 1 [get_keys $instance]
assert_equal 1 [get_keys_with_volatile_items $instance]
assert_equal "{} v2 v3" [$instance HMGET myhash f1 f2 f3]
assert_equal 3 [$instance HLEN myhash]
}
# Change TTL of f2
$replica_1 HEXPIRE myhash 1000000 FIELDS 1 f2 ;# will trigger hexpire
wait_for_ofs_sync $replica_1 $primary
foreach instance [list $primary $replica_1] {
assert_morethan [$instance HTTL myhash FIELDS 1 f2] 9000
}
# Change TTL of f2 to 0 (immediate expiry)
$replica_1 HGETEX myhash EX 0 FIELDS 1 f2 ;# will trigger hexpired for replica_1 and hdel for primary
# Verify final state
wait_for_ofs_sync $replica_1 $primary
foreach instance [list $primary $replica_1] {
assert_equal 2 [$instance HLEN myhash]
assert_equal "{} {} v3" [r HMGET myhash f1 f2 f3]
}
foreach rd [list $rd_replica_1 $rd_primary] {
assert_keyevent_patterns $rd myhash hexpire hexpire
}
assert_keyevent_patterns $rd_replica_1 myhash hexpired
assert_keyevent_patterns $rd_primary myhash hdel
$rd_replica_1 close
$rd_primary close
# Re-enable active expiry
$primary DEBUG SET-ACTIVE-EXPIRE 1
$replica_1 DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
}
}
### Slot Migration ####
start_cluster 3 0 {tags {"cluster mytest external:skip"} overrides {cluster-node-timeout 1000}} {
# Flush all data on all cluster nodes before starting
for {set i 0} {$i < 3} {incr i} {
R $i FLUSHALL
}
if {$::singledb} {
set db 0
} else {
set db 9
}
set R0_id [R 0 CLUSTER MYID]
set R1_id [R 1 CLUSTER MYID]
# Use a fixed hash tag to ensure key is in one slot
set key "{mymigrate}myhash"
test {Hash with TTL fields migrates correctly between nodes} {
R 0 DEBUG SET-ACTIVE-EXPIRE 0
R 1 DEBUG SET-ACTIVE-EXPIRE 0
# Create hash fields
R 0 HSET $key f1 v1 f2 v2 f3 v3
# Set TTL on fields f1 and f2
R 0 HEXPIRE $key 300 FIELDS 2 f1 f2
# Verify before slot migration
assert_equal 3 [R 0 HLEN $key]
assert_morethan [R 0 HTTL $key FIELDS 1 f1] 290
assert_match {1} [scan [regexp -inline {keys\=([\d]*)} [R 0 info keyspace]] keys=%d]
assert_equal 1 [scan [lindex [regexp -inline {keys_with_volatile_items=([\d]+)} [R 0 info keyspace]] 1] "%d"]
# Prepare slot migration
set slot [R 0 CLUSTER KEYSLOT $key]
assert_equal OK [R 1 CLUSTER SETSLOT $slot IMPORTING $R0_id]
assert_equal OK [R 0 CLUSTER SETSLOT $slot MIGRATING $R1_id]
# Migrate key to destination node
R 0 MIGRATE [srv -1 host] [srv -1 port] $key 0 5000
# Complete slot migration
R 0 CLUSTER SETSLOT $slot NODE $R1_id
R 1 CLUSTER SETSLOT $slot NODE $R1_id
# Verify after slot migration
assert_equal 3 [R 1 HLEN $key]
assert_morethan [R 1 HTTL $key FIELDS 1 f1] 280
assert_match {1} [scan [regexp -inline {keys\=([\d]*)} [R 1 info keyspace]] keys=%d]
assert_equal 1 [scan [lindex [regexp -inline {keys_with_volatile_items=([\d]+)} [R 1 info keyspace]] 1] "%d"]
# Setup keyspace notifications
R 1 config set notify-keyspace-events KEA
set rd [valkey_deferring_client -1]
assert_equal {1} [psubscribe $rd __keyevent@0__:hexpired]
# Set expiration to 0
R 1 HGETEX $key EX 0 FIELDS 1 f1
# Veridy expiration
assert_keyevent_patterns $rd "{$key}" hexpired
assert_equal 2 [R 1 HLEN $key]
assert_equal "" [R 1 HGET $key f1]
assert_equal -2 [R 1 HTTL $key FIELDS 1 f1]
$rd close
# Re-enable active expiry
R 0 DEBUG SET-ACTIVE-EXPIRE 1
R 1 DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
}
#### AOF Test #####
proc validate_aof_content {aof_file pxat_count hdel_count} {
wait_for_condition 100 100 {
[file exists $aof_file] eq 1
} else {
fail "hash value was not expired after timeout"
}
set aof_content [exec cat $aof_file]
# Verify amount of PXAT and HDEL
# Count PXAT commands
set got_pxat_count [regexp -all {PXAT} $aof_content]
assert_equal $got_pxat_count $pxat_count
# Count HDEL commands
set got_hdel_count [regexp -all {HDEL} $aof_content]
assert_equal $got_hdel_count $hdel_count
}
tags {"aof external:skip"} {
foreach rdb_preamble {"yes" "no"} {
set defaults {appendonly {yes} appendfilename {appendonly.aof} appenddirname {appendonlydir} auto-aof-rewrite-percentage {0}}
set server_path [tmpdir server.multi.aof]
start_server_aof [list dir $server_path aof-use-rdb-preamble $rdb_preamble] {
set rdb_preamble [lindex [r config get aof-use-rdb-preamble] 1]
test "TTL Persistence in AOF aof-use-rdb-preamble $rdb_preamble" {
r flushall
r DEBUG SET-ACTIVE-EXPIRE 0
r config set appendonly yes
r config set appendfsync always
assert_equal 0 [get_keys_with_volatile_items r]
# Create hash with 1 short, long and no expired fields
set long_expire [expr {[clock seconds] + 1000000}]
# Create 10 fields with long expiry
for {set i 1} {$i <= 10} {incr i} {
r HSETEX myhash EXAT $long_expire FIELDS 1 f$i v$i ;# 10 PXAT to aof
}
# Create 10 fields with short expiry
for {set i 11} {$i <= 20} {incr i} {
r HSETEX myhash PXAT [expr {[clock milliseconds] + 10}] FIELDS 1 f$i v$i ;# 10 PXAT to aof
}
# Create 10 fields with expire 0
for {set i 21} {$i <= 30} {incr i} {
r HSET myhash f$i v$i
r HEXPIRE myhash 0 FIELDS 1 f$i ;# 10 HDEL to aof
}
# Create 10 fields with no expiry
for {set i 31} {$i <= 40} {incr i} {
r HSET myhash f$i v$i
}
# Now wait for expire of the short expiry
for {set i 11} {$i <= 20} {incr i} {
wait_for_condition 100 100 {
[r HTTL myhash FIELDS 1 f$i] eq "-2"
} else {
fail "hash value was not expired after timeout"
}
}
# Verify initial HLEN
assert_equal 30 [r HLEN myhash]
# Verify values
for {set i 1} {$i <= 40} {incr i} {
if {$i >= 11 && $i <= 30} {
assert_equal "" [r HGET myhash f$i]
} else {
assert_equal v$i [r HGET myhash f$i]
}
}
assert_equal 1 [get_keys_with_volatile_items r]
# Ensure the initial rewrite finishes
waitForBgrewriteaof r
# Get the last incremental AOF file path and validate its content
# Count PXAT commands (should be 20: 10 long + 10 short)
# Count HDEL commands (should be 10: from expire 0)
validate_aof_content [get_last_incr_aof_path r] 20 10
# Restart the server and load the AOF
restart_server 0 true false
r DEBUG SET-ACTIVE-EXPIRE 0
r debug loadaof
set hlen [r HLEN myhash]
set expired_fields [info_field [r info stats] expired_fields]
assert_equal 1 [get_keys_with_volatile_items r]
# Verify that HLEN is between 20 and 30 (inclusive), and
# when combined with expired_fields, the total should be 30
if {$hlen < 20 || $hlen > 30} {
fail "Expected HLEN to be between 20 and 30, but got $hlen"
}
assert_equal 30 [expr ($expired_fields + $hlen)]
# Verify the TTLs are preserved
for {set i 1} {$i <= 10} {incr i} {
assert_equal $long_expire [r HEXPIRETIME myhash FIELDS 1 f$i]
assert_equal v$i [r HGET myhash f$i]
}
# Verify expired fields
for {set i 11} {$i <= 30} {incr i} {
assert_equal -2 [r HTTL myhash FIELDS 1 f$i]
assert_equal "" [r HGET myhash f$i]
}
# Verify fields with no TTL
for {set i 31} {$i <= 40} {incr i} {
assert_equal -1 [r HTTL myhash FIELDS 1 f$i]
assert_equal v$i [r HGET myhash f$i]
}
# Trigger and wait for a second rewrite
r BGREWRITEAOF
waitForBgrewriteaof r
if {"$rdb_preamble" eq "no"} {
# Get the last base AOF file path and validate its content
# Count PXAT commands (should be 10: just the long expiry fields)
# Count HDEL commands (should be rewritten out)
validate_aof_content [get_base_aof_path r] 10 0
}
# Restart the server and load the AOF
restart_server 0 true false
r DEBUG SET-ACTIVE-EXPIRE 0
r debug loadaof
set hlen [r HLEN myhash]
set expired_fields [info_field [r info stats] expired_fields]
assert_equal 1 [get_keys_with_volatile_items r]
# Verify that HLEN is 20, and we should now have no expired fields
if {$hlen != 20} {
fail "Expected HLEN to be 20, but got $hlen"
}
assert_equal 20 [expr ($expired_fields + $hlen)]
# Verify the TTLs are preserved
for {set i 1} {$i <= 10} {incr i} {
assert_equal $long_expire [r HEXPIRETIME myhash FIELDS 1 f$i]
assert_equal v$i [r HGET myhash f$i]
}
# Verify expired fields
for {set i 11} {$i <= 30} {incr i} {
assert_equal -2 [r HTTL myhash FIELDS 1 f$i]
assert_equal "" [r HGET myhash f$i]
}
# Verify fields with no TTL
for {set i 31} {$i <= 40} {incr i} {
assert_equal -1 [r HTTL myhash FIELDS 1 f$i]
assert_equal v$i [r HGET myhash f$i]
}
# Re-enable active expiry
r DEBUG SET-ACTIVE-EXPIRE 1
} {OK} {needs:debug}
}
}
}
### ACTIVE EXPIRY TESTS ####
##### HGETEX Active Expiry Tests #####
start_server {tags {"hashexpire external:skip"}} {
r config set notify-keyspace-events KEA
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command active expiry with single field" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Use HGETEX to set expiry
assert_equal "v1" [r HGETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1]
wait_for_active_expiry r myhash 1 $initial_expired 1
assert_equal "{} v2" [r HGETEX myhash FIELDS 2 f1 f2]
assert_equal 0 [get_keys_with_volatile_items r]
}
test "HGETEX $command active expiry with multiple fields" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set expiry on multiple fields with HGETEX
assert_equal "v1 v3" [r HGETEX myhash $command [get_short_expire_value $command] FIELDS 2 f1 f3]
wait_for_active_expiry r myhash 1 $initial_expired 2
# Verify only non-expired field remains
assert_equal "{} v2 {}" [r HGETEX myhash FIELDS 3 f1 f2 f3]
assert_equal 0 [get_keys_with_volatile_items r]
}
test "HGETEX $command active expiry removes entire key when last field expires" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal "v1" [r HGETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1]
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
assert_equal 0 [get_keys_with_volatile_items r]
}
test "HGETEX $command and HPEXPIRE" {
r FLUSHALL
r HSET myhash f1 v1 f2 v2 f3 v3 f4 v4
r HEXPIRE myhash 3000 FIELDS 1 f1
r HSETEX myhash EX 5000 FIELDS 1 f2 v2
r HEXPIRE myhash 60000 FIELDS 1 f3
assert_equal "v1 v2 v3 v4" [r HGETEX myhash FIELDS 4 f1 f2 f3 f4]
assert_equal "v3" [r HGETEX myhash PERSIST FIELDS 1 f3]
r HPEXPIRE myhash 1 FIELDS 1 f1
}
}
test "HGETEX PERSIST removes expiry and prevents active expiry" {
r FLUSHALL
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set short expiry
assert_equal "v1" [r HGETEX myhash PX 1000 FIELDS 1 f1]
# Immediately persist to prevent expiry
assert_equal "v1" [r HGETEX myhash PERSIST FIELDS 1 f1]
assert_equal -1 [r HTTL myhash FIELDS 1 f1]
# Wait longer than original expiry time
after 200
# Field should still exist due to PERSIST
assert_equal "v1" [r HGET myhash f1]
assert_equal 2 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
}
test "HGETEX overwrite existing expiry with active expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set initial long expiry
r HEXPIRE myhash [get_long_expire_value HEXPIRE] FIELDS 1 f1
assert_morethan [r HTTL myhash FIELDS 1 f1] 5000
# Use HGETEX to set shorter expiry
assert_equal "v1" [r HGETEX myhash PX 100 FIELDS 1 f1]
# Wait for active expiry with new shorter time
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
assert_equal 0 [get_keys_with_volatile_items r]
}
##### HGETEX Active Expiry Keyspace Notifications #####
foreach command {EX PX EXAT PXAT} {
test "HGETEX $command keyspace notifications for active expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 2 [r HLEN myhash]
set rd [setup_single_keyspace_notification r]
# Set expiry with HGETEX
r HGETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1
wait_for_active_expiry r myhash 1 $initial_expired 1
assert_keyevent_patterns $rd myhash hexpire hexpired
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
}
}
test "HGETEX keyspace notification when key deleted with active expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
# Set expiry on only field
r HGETEX myhash PX [get_short_expire_value PX] FIELDS 1 f1
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
# Should get both hexpired and del notifications
assert_keyevent_patterns $rd myhash hexpire hexpired del
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
}
##### HSETEX Active Expiry Tests #####
foreach command {EX PX EXAT PXAT} {
test "HSETEX $command single field expires leaving other fields intact" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f2 v2
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Use HSETEX to set expiry
r HSETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1 v1
wait_for_active_expiry r myhash 1 $initial_expired 1
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal "{} v2" [r HGETEX myhash FIELDS 2 f1 f2]
}
test "HSETEX $command multiple fields expire leaving non-expired fields intact" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f2 v2
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set expiry on multiple fields with HSETEX
r HSETEX myhash $command [get_short_expire_value $command] FIELDS 2 f1 v1 f3 v3
wait_for_active_expiry r myhash 1 $initial_expired 2
assert_equal 0 [get_keys_with_volatile_items r]
# Verify only non-expired field remains
assert_equal "{} v2 {}" [r HGETEX myhash FIELDS 3 f1 f2 f3]
}
test "HSETEX $command hash key deleted when all fields expire" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1 v1
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
}
test "HSETEX $command after HSETEX $command" {
r FLUSHALL
r HSETEX myhash EX 1000000000 FIELDS 1 f1 v1
r HSETEX myhash PX 10 FIELDS 1 f2 v2
}
}
test "HPERSIST cancels HSETEX expiry preventing field deletion" {
r FLUSHALL
r HSET myhash f2 v2
assert_equal 1 [r HLEN myhash]
# Set short expiry
r HSETEX myhash PX [get_short_expire_value PX] FIELDS 1 f1 v1
# Immediately persist to prevent expiry
r HPERSIST myhash FIELDS 1 f1
assert_equal -1 [r HTTL myhash FIELDS 1 f1]
# Wait longer than original expiry time
after 200
# Field should still exist due to PERSIST
assert_equal "v1" [r HGET myhash f1]
assert_equal 2 [r HLEN myhash]
}
test "HSETEX overwrites existing field expiry with new shorter expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set initial long expiry
r HEXPIRE myhash [get_long_expire_value HEXPIRE] FIELDS 1 f1
assert_equal 1 [get_keys_with_volatile_items r]
assert_morethan [r HTTL myhash FIELDS 1 f1] 5000
# Use HSETEX to set shorter expiry
r HSETEX myhash PX 100 FIELDS 1 f1 v1
# Wait for active expiry with new shorter time
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal 0 [r EXISTS myhash]
}
##### HSETEX Active Expiry Keyspace Notifications #####
foreach command {EX PX EXAT PXAT} {
test "HSETEX $command - keyspace notifications fired on field expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f2 v2
assert_equal 1 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
set rd [setup_single_keyspace_notification r]
r HSETEX myhash $command [get_short_expire_value $command] FIELDS 1 f1 v1
wait_for_active_expiry r myhash 1 $initial_expired 1
assert_keyevent_patterns $rd myhash hset hexpire hexpired
assert_equal 0 [get_keys_with_volatile_items r]
$rd close
}
}
test "HSETEX - keyspace notifications include del event when hash key removed" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
set rd [setup_single_keyspace_notification r]
r HSETEX myhash PX 100 FIELDS 1 f1 v1
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
assert_keyevent_patterns $rd myhash hset hexpire hexpired del
$rd close
}
##### Active expiry test with 1 node #####
set rd [valkey_deferring_client]
assert_equal {1} [psubscribe $rd __keyevent@*]
test {Active expiry deletes entire key when only field expires} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
r HPEXPIRE myhash 100 FIELDS 1 f1
wait_for_active_expiry r myhash 0 $initial_expired 1
# Key is deleted after its only field got expired
assert_equal 0 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal "" [r HGET myhash f1]
assert_equal 0 [r EXISTS myhash]
# Verify keyspace notifications
assert_keyevent_patterns $rd myhash hset hexpire hexpired del
}
test {Active expiry removes only expired field while preserving others} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
r HPEXPIRE myhash 100 FIELDS 1 f1
set mem_before [r MEMORY USAGE myhash]
wait_for_active_expiry r myhash 2 $initial_expired 1
# Key still exists because it has 2 fields remaining
assert_equal 1 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal "{} v2 v3" [r HGETEX myhash FIELDS 3 f1 f2 f3]
# Verify memory decreased after field expiry
set mem_after [r MEMORY USAGE myhash]
assert_morethan $mem_before $mem_after
# Verify keyspace notifications
assert_keyevent_patterns $rd myhash hset hexpire hexpired
assert_equal 0 [get_keys_with_volatile_items r]
}
test {Active expiry reclaims memory correctly with large hash containing many fields} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
set value [string repeat x 1024]
set num_fields 10000
# Set multiple fields
for {set i 1} {$i <= $num_fields} {incr i} {
lappend pairs "f$i" $value$i
}
r HSET myhash {*}$pairs
assert_equal 0 [get_keys_with_volatile_items r]
assert_equal $num_fields [r HLEN myhash]
set mem_before_expire [r MEMORY USAGE myhash]
if {$mem_before_expire eq ""} {set mem_before_expire 0}
assert_morethan $mem_before_expire 10000000
assert_equal 1 [get_keys r]
assert_equal $num_fields [r HLEN myhash]
r HPEXPIRE myhash 100 FIELDS 1 f1
wait_for_active_expiry r myhash [expr {$num_fields - 1}] $initial_expired 1
# Key still exists because it has num_fields 1 fields remaining
assert_equal 1 [get_keys r]
assert_equal "" [r HGET myhash f1]
for {set i 2} {$i <= $num_fields} {incr i} {
assert_equal $value$i [r HGET myhash "f$i"]
}
assert_equal 0 [get_keys_with_volatile_items r]
# Expire all remaining fields
set all_field_names {}
for {set i 2} {$i <= $num_fields} {incr i} {
lappend all_field_names "f$i"
}
r HPEXPIRE myhash 100 FIELDS [expr {$num_fields - 1}] {*}$all_field_names
wait_for_active_expiry r myhash 0 $initial_expired $num_fields 350 100
# Verify memory decreased by at least 15MB (size of hash key)
set mem_after_expire [r MEMORY USAGE myhash]
if {$mem_after_expire eq ""} {set mem_after_expire 0}
assert_morethan [expr {$mem_before_expire - $mem_after_expire}] 10000000
# Verify keyspace notifications
assert_keyevent_patterns $rd myhash hset hexpire hexpired hexpire hexpired
# Wait for del, maximum num_fields reads
for {set i 2} {$i <= $num_fields} {incr i} {
if {[string match "pmessage __keyevent@* __keyevent@*:del myhash" [$rd read]]} {
break
}
}
assert_equal 0 [get_keys_with_volatile_items r]
}
test {Active expiry handles fields with different TTL values correctly} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
# Set very short expiry and longer expiry
r HPEXPIRE myhash [get_short_expire_value HPEXPIRE] FIELDS 1 f1
# Wait for f1 to expire
wait_for_active_expiry r myhash 2 $initial_expired 1
r HEXPIRE myhash [get_long_expire_value HEXPIRE] FIELDS 1 f2
# f3 has no expiry
# Verify f2 and f3 still exist
assert_equal 2 [r HLEN myhash]
assert_equal "{} v2 v3" [r HGETEX myhash FIELDS 3 f1 f2 f3]
assert_keyevent_patterns $rd myhash hset hexpire hexpired hexpire
}
test {Active expiry removes only specified fields leaving others intact} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3 f4 v4 f5 v5
assert_equal 5 [r HLEN myhash]
# Set expiry on alternating fields
r HPEXPIRE myhash 100 FIELDS 2 f1 f3
# f2, f4, f5 have no expiry
wait_for_active_expiry r myhash 3 $initial_expired 2
# Verify expired fields are gone and non-expired exists
assert_equal "{} v2 {} v4 v5" [r HGETEX myhash FIELDS 5 f1 f2 f3 f4 f5]
# Key should still exist
assert_equal 1 [get_keys r]
}
$rd close
test {Field TTL is removed when field value is overwritten with HSET} {
r FLUSHALL
r HSET myhash f1 v1
r HEXPIRE myhash 100000 FIELDS 1 f1
r HSET myhash f1 v2
# TTL should be removed after overwrite
assert_equal -1 [r HPTTL myhash FIELDS 1 f1]
# Field should still exist
assert_equal "v2" [r HGET myhash f1]
}
# Active expiry with field deletion and recreation
test {Field TTL is cleared when field is deleted and recreated} {
r FLUSHALL
r HSET myhash f1 v1
r HPEXPIRE myhash 100 FIELDS 1 f1
r HDEL myhash f1
r HSET myhash f1 v2
assert_equal -1 [r HPTTL myhash FIELDS 1 f1]
after 200
assert_equal v2 [r HGET myhash f1]
}
##### Test Active Expiry Tests with all hash expire commands #####
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "$command active expiry on single field" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
# Set expiry based on command type
r $command myhash [get_short_expire_value $command] FIELDS 1 f1
# Wait for active expiry
wait_for_active_expiry r myhash 1 $initial_expired 1
# Verify only expired field is gone
assert_equal "{} v2" [r HGETEX myhash FIELDS 2 f1 f2]
}
test "$command active expiry with multiple fields" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3 f4 v4
assert_equal 4 [r HLEN myhash]
# Set expiry on multiple fields
r $command myhash [get_short_expire_value $command] FIELDS 3 f1 f2 f4
# Wait for active expiry
wait_for_active_expiry r myhash 1 $initial_expired 3
# Only f3 should remain
assert_equal "{} {} v3 {}" [r HGETEX myhash FIELDS 4 f1 f2 f3 f4]
}
test "$command active expiry removes entire key when last field expires" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
assert_equal 1 [r HLEN myhash]
# Set expiry on only field
r $command myhash [get_short_expire_value $command] FIELDS 1 f1
# Wait for active expiry to remove key
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
}
test "$command active expiry with non-existing fields" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
# Try to expire non-existing fields
r $command myhash [get_short_expire_value $command] FIELDS 2 f3 f4
# Wait to ensure no active expiry occurs
after 1500
assert [check_myhash_and_expired_subkeys r myhash 2 $initial_expired 0]
}
test "$command active expiry with mixed existing and non-existing fields" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
# Mix of existing and non-existing fields
r $command myhash [get_short_expire_value $command] FIELDS 4 f1 f4 f3 f5
# Wait for active expiry of existing fields only
wait_for_active_expiry r myhash 1 $initial_expired 2
# Only f2 should remain
assert_equal "{} v2 {}" [r HGETEX myhash FIELDS 3 f1 f2 f3]
}
test "$command active expiry with already expired fields" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3
assert_equal 3 [r HLEN myhash]
# Set very short expiry on f1
r $command myhash [get_short_expire_value $command] FIELDS 1 f1
# Wait for active expiry
wait_for_active_expiry r myhash 2 $initial_expired 1
# Now try to expire f1 again (already expired) and f2 (existing)
r $command myhash [get_short_expire_value $command] FIELDS 2 f1 f2
# Wait for f2 to expire
wait_for_active_expiry r myhash 1 $initial_expired 2
# Only f3 should remain
assert_equal "{} {} v3" [r HGETEX myhash FIELDS 3 f1 f2 f3]
}
}
test "CLIENT PAUSE WRITE blocks hash field active expiry until pause ends" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
assert_equal 2 [r HLEN myhash]
# To avoid flakiness - run commands in transaction
r multi
r HPEXPIRE myhash 500 FIELDS 1 f1
r CLIENT PAUSE 1200 WRITE
r exec
# Verify no expiry happened immediately after transaction
assert_equal 2 [r HLEN myhash]
assert_equal 0 [expr {[info_field [r info stats] expired_fields] - $initial_expired}]
# Wait longer than expiry time while paused
after 600
# Field should still exist because active expiry is paused
assert_equal 2 [r HLEN myhash]
assert_equal 0 [expr {[info_field [r info stats] expired_fields] - $initial_expired}]
# Wait for pause to end
after 600
# Now active expiry should work
wait_for_active_expiry r myhash 1 $initial_expired 1 50 20
assert_equal "{} v2" [r HMGET myhash f1 f2]
}
##### Active Expiry Tests After RENAME/COPY/RESTORE Operations #####
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
foreach op {RENAME COPY RESTORE MOVE} {
test "$command active expiry works correctly after $op operation" {
r FLUSHALL
r SELECT 0
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2 f3 v3 f4 v4
assert_equal 4 [r HLEN myhash]
assert_equal 0 [get_keys_with_volatile_items r]
# Set expiry on fields
r $command myhash [get_short_expire_value $command] FIELDS 1 f1
wait_for_active_expiry r myhash 3 $initial_expired 1
r $command myhash [get_long_expire_value $command] FIELDS 1 f4
assert_equal 1 [get_keys_with_volatile_items r]
# Run op command
if {$op eq "RENAME"} {
r RENAME myhash newhash
set target_key newhash
} elseif {$op eq "COPY"} {
r COPY myhash copyhash
set target_key copyhash
} elseif {$op eq "RESTORE"} {
# RESTORE
set serialized [r DUMP myhash]
r DEL myhash
r RESTORE restorehash 0 $serialized
set target_key restorehash
} else {
r MOVE myhash 1
# Switch to target DB
r SELECT 1
set target_key myhash
}
if {$op eq "COPY"} {
assert_equal 2 [get_keys_with_volatile_items r]
} else {
assert_equal 1 [get_keys_with_volatile_items r]
}
# Set expiry on fields after op command
r $command $target_key [get_short_expire_value $command] FIELDS 1 f3
# Wait for active expiry on "new" key
wait_for_active_expiry r $target_key 2 $initial_expired 2
assert_equal "{} v2 {}" [r HMGET $target_key f1 f2 f3]
# In copy verify original hash hasnt changed
if {$op eq "COPY"} {
assert_equal "{} v2 v3" [r HMGET myhash f1 f2 f3]
}
}
}
}
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "$command active expiry processes multiple hash keys with different field counts" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
# Create multiple hash keys
for {set i 1} {$i <= 5} {incr i} {
r HSET hash$i f1 v1_$i f2 v2_$i f3 v3_$i
}
assert_equal 5 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
r $command hash1 [get_short_expire_value $command] FIELDS 1 f1
r $command hash2 [get_short_expire_value $command] FIELDS 2 f1 f2
r $command hash3 [get_short_expire_value $command] FIELDS 3 f1 f2 f3
r $command hash4 [get_short_expire_value $command] FIELDS 1 f2
wait_for_condition 100 100 {
[r HLEN hash1] eq 2 && [r HLEN hash2] eq 1 &&
[r HLEN hash3] eq 0 && [r HLEN hash4] eq 2 && [r HLEN hash5] eq 3 &&
[expr {[info_field [r info stats] expired_fields] - $initial_expired}] eq 7
} else {
fail "Fields should expire across multiple keys"
}
assert_equal "{} v2_1 v3_1" [r HMGET hash1 f1 f2 f3]
assert_equal "{} {} v3_2" [r HMGET hash2 f1 f2 f3]
assert_equal 0 [r EXISTS hash3]
assert_equal "v1_4 {} v3_4" [r HMGET hash4 f1 f2 f3]
assert_equal "v1_5 v2_5 v3_5" [r HMGET hash5 f1 f2 f3]
assert_equal 4 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
# Set long expire
r $command hash1 [get_long_expire_value $command] FIELDS 1 f2
assert_equal 1 [get_keys_with_volatile_items r]
r $command hash2 [get_long_expire_value $command] FIELDS 1 f3
assert_equal 2 [get_keys_with_volatile_items r]
}
}
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "$command handles mixed short and long expiry times across multiple keys" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET key1 f1 v1 f2 v2 f3 v3
r HSET key2 f1 v1 f2 v2 f3 v3
r HSET key3 f1 v1 f2 v2 f3 v3
r HSET key4 f1 v1 f2 v2 f3 v3
assert_equal 4 [get_keys r]
assert_equal 0 [get_keys_with_volatile_items r]
r $command key2 [get_long_expire_value $command] FIELDS 1 f1
assert_equal 1 [get_keys_with_volatile_items r]
set short_expire [get_short_expire_value $command]
r $command key1 $short_expire FIELDS 1 f1
r $command key3 $short_expire FIELDS 2 f1 f2
r $command key4 $short_expire FIELDS 3 f1 f2 f3
wait_for_condition 100 100 {
[r HLEN key1] eq 2 && [r HLEN key3] eq 1 &&
[r HLEN key4] eq 0 && [expr {[info_field [r info stats] expired_fields] - $initial_expired}] eq 6
} else {
fail "Short expiry fields should expire"
}
assert_equal "{} v2 v3" [r HMGET key1 f1 f2 f3]
assert_equal "v1 v2 v3" [r HMGET key2 f1 f2 f3]
assert_equal "{} {} v3" [r HMGET key3 f1 f2 f3]
assert_equal 0 [r EXISTS key4]
assert_equal 3 [get_keys r]
assert_equal 1 [get_keys_with_volatile_items r]
assert_morethan [r HTTL key2 FIELDS 1 f1] 3000
}
}
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "$command deletes entire keys when all fields expire while preserving partial keys" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
# Create keys where some will be completely deleted
for {set i 1} {$i <= 4} {incr i} {
r HSET delkey$i f1 v1
}
r HSET keepkey f1 v1 f2 v2
# Set expiry on f1 field in delkey1-4 (which is all the fields there)
for {set i 1} {$i <= 4} {incr i} {
r $command delkey$i [get_short_expire_value $command] FIELDS 1 f1
}
r $command keepkey [get_short_expire_value $command] FIELDS 1 f1
# Wait for active expiry - 4 keys deleted, 1 key reduced
wait_for_condition 100 100 {
[r EXISTS delkey1] eq 0 && [r EXISTS delkey2] eq 0 &&
[r EXISTS delkey3] eq 0 && [r EXISTS delkey4] eq 0 &&
[r HLEN keepkey] eq 1 &&
[info_field [r info stats] expired_fields] eq [expr {$initial_expired + 5}]
} else {
fail "Keys should be deleted when last field expires"
}
assert_equal "{} v2" [r HMGET keepkey f1 f2]
}
}
foreach command {HEXPIRE HPEXPIRE HEXPIREAT HPEXPIREAT} {
test "$command active expiry reclaims memory efficiently across multiple large hash keys" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
# Create keys with large values
set large_value [string repeat "x" 1024]
# 5 keys, 10 "large" fields in each
for {set i 1} {$i <= 5} {incr i} {
for {set j 1} {$j <= 10} {incr j} {
r HSET myhash$i f$j $large_value$i$j
}
}
# Save initial memory
set total_mem_before 0
for {set i 1} {$i <= 5} {incr i} {
set mem [r MEMORY USAGE myhash$i]
if {$mem eq ""} {set mem 0}
incr total_mem_before $mem
}
# For each key, set expire for 5 fields
for {set i 1} {$i <= 5} {incr i} {
assert_equal {1 1 1 1 1} [r $command myhash$i [get_short_expire_value $command] FIELDS 5 f1 f2 f3 f4 f5]
}
# Wait for expiry
wait_for_condition 100 100 {
[r HLEN myhash1] eq 5 && [r HLEN myhash2] eq 5 &&
[r HLEN myhash3] eq 5 && [r HLEN myhash4] eq 5 &&
[r HLEN myhash5] eq 5 &&
[info_field [r info stats] expired_fields] eq [expr {$initial_expired + 25}]
} else {
fail "25 fields should expire across 5 keys"
}
# Verify memory reduction
set total_mem_after 0
for {set i 1} {$i <= 5} {incr i} {
set mem [r MEMORY USAGE myhash$i]
if {$mem eq ""} {set mem 0}
incr total_mem_after $mem
}
# Memory should be reduced
if {$total_mem_before > 0} {
assert_morethan [expr {$total_mem_before - $total_mem_after}] 10000
}
}
}
##### HINCRBY/HINCRBYFLOAT Active Expiry Tests #####
foreach cmd {HINCRBY HINCRBYFLOAT} {
# Set increment values
if {$cmd eq "HINCRBY"} {
set inc1 2
set inc2 3
set inc3 4
} else {
set inc1 2.5
set inc2 3.5
set inc3 4.5
}
# 1 key, 1 field
test "$cmd recreates field with correct value after active expiry deletion" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 1
assert_equal 1 [r HLEN myhash]
# Set expiry on f1
r HPEXPIRE myhash 100 FIELDS 1 f1
# Wait for active expiry
wait_for_active_expiry r myhash 0 $initial_expired 1
# Try increment after expiry (should recreate field)
r $cmd myhash f1 $inc1
assert_equal $inc1 [r HGET myhash f1]
}
# 1 key, 1 field, increment before expiry
test "$cmd preserves existing TTL when incrementing field value" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 1
assert_equal 1 [r HLEN myhash]
# Set expiry after increment
r HEXPIRE myhash 100000 FIELDS 1 f1
# Increment after expiry set
r $cmd myhash f1 $inc1
# Check value and expiry is still set
assert_equal [expr {$inc1 + 1}] [r HGET myhash f1]
assert_morethan [r HTTL myhash FIELDS 1 f1] 90000
}
# 1 key, 3 fields, increment multiple fields, expiry on multiple fields
test "$cmd handles mix of expired and existing fields during increment operations" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 1 f2 2 f3 3
assert_equal 3 [r HLEN myhash]
# Set expiry on f1 and f3
r HPEXPIRE myhash 100 FIELDS 2 f1 f3
# Wait for active expiry
wait_for_active_expiry r myhash 1 $initial_expired 2
# Increment all fields (f1 and f3 should be recreated, f2 should increment)
r $cmd myhash f1 $inc1
r $cmd myhash f2 $inc2
r $cmd myhash f3 $inc3
# Check values
assert_equal "$inc1 [expr {$inc2+2}] $inc3" [r HMGET myhash f1 f2 f3]
}
# 1 key, 3 fields, increment before expiry, then expire
test "$cmd maintains TTL values when incrementing fields with existing expiry" {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 1 f2 2 f3 3
assert_equal 3 [r HLEN myhash]
# Set expiry on f1 and f3
r HEXPIRE myhash 100000 FIELDS 2 f1 f3
# Increment/ Decrement all fields
r $cmd myhash f1 $inc1
r $cmd myhash f3 -$inc3
# Only f2 should remain
assert_equal "[expr {$inc1+1}] 2 [expr {-$inc3+3}]" [r HMGET myhash f1 f2 f3]
}
}
### HDEL WITH ACTIVE EXPIRE #####
test {HDEL removes both expired and non-expired fields deleting key when empty} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1 f2 v2
r HEXPIRE myhash 1 FIELDS 1 f1
wait_for_active_expiry r myhash 1 $initial_expired 1
# f1 is expired, f2 is not, f3 does not exist
r HDEL myhash f1 f2 f3
# f1 and f2 should be gone, f3 never existed
assert_equal 0 [r HEXISTS myhash f1]
assert_equal 0 [r HEXISTS myhash f2]
assert_equal 0 [r HEXISTS myhash f3]
# The key should be deleted since all fields are gone
assert_equal 0 [r EXISTS myhash]
}
##### HPERSIST TEST WITH ACTIVE EXPIRY #####
test {HPERSIST returns -2 when attempting to persist already expired field} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
r HPEXPIRE myhash 50 FIELDS 1 f1
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal -2 [r HPERSIST myhash FIELDS 1 f1]
assert_equal -2 [r HTTL myhash FIELDS 1 f1]
assert_equal "" [r HGET myhash f1]
}
test {HPEXPIRE works correctly on field after HPERSIST removes its TTL} {
r FLUSHALL
set initial_expired [info_field [r info stats] expired_fields]
r HSET myhash f1 v1
r HEXPIRE myhash 10000 FIELDS 1 f1
r HPERSIST myhash FIELDS 1 f1
r HPEXPIRE myhash 150 FIELDS 1 f1
wait_for_active_expiry r myhash 0 $initial_expired 1
assert_equal 0 [r EXISTS myhash]
}
}
##### Active expiry test slot migration #####
start_cluster 3 0 {tags {"cluster mytest external:skip"} overrides {cluster-node-timeout 1000}} {
# Flush all data on all cluster nodes before starting
for {set i 0} {$i < 3} {incr i} {
R $i FLUSHALL
}
set R0_id [R 0 CLUSTER MYID]
set R1_id [R 1 CLUSTER MYID]
# Use a fixed hash tag to ensure key is in one slot
set key "{mymigrate}myhash"
test {Hash field TTL values and active expiry state preserved during cluster slot migration} {
set initial_expired [info_field [R 0 info stats] expired_fields]
R 0 HSET $key f1 v1 f2 v2 f3 v3
assert_equal 3 [R 0 HLEN $key]
set far_exp [expr {[clock seconds] + 30000}]
R 0 HEXPIREAT $key $far_exp FIELDS 1 f1 ; # f1 with far expire
R 0 HPEXPIRE $key 100 FIELDS 1 f2 ; # f2 with short expire
assert_equal 1 [scan [lindex [regexp -inline {keys_with_volatile_items=([\d]+)} [R 0 info keyspace]] 1] "%d"]
# Wait for short expire field (f2) to be expired by active expire
wait_for_condition 100 100 {
[R 0 HLEN $key] eq 2 &&
[info_field [R 0 info stats] expired_fields] eq [expr {$initial_expired + 1}]
} else {
fail "Fields should have expired"
}
# Verify expired field returns empty string and non-expired returns value
assert_equal "v1 {} v3" [R 0 HMGET $key f1 f2 f3]
# Prepare slot migration
set slot [R 0 CLUSTER KEYSLOT $key]
assert_equal OK [R 1 CLUSTER SETSLOT $slot IMPORTING $R0_id]
assert_equal OK [R 0 CLUSTER SETSLOT $slot MIGRATING $R1_id]
# Migrate key to destination node
R 0 MIGRATE [srv -1 host] [srv -1 port] $key 0 5000
# Complete slot migration
R 0 CLUSTER SETSLOT $slot NODE $R1_id
R 1 CLUSTER SETSLOT $slot NODE $R1_id
set initial_expired [info_field [R 1 info stats] expired_fields]
# Verify after slot migration all fields are present and ttl is kept
assert_match {1} [scan [regexp -inline {keys=([\d]*)} [R 1 info keyspace]] keys=%d]
assert_equal 1 [scan [lindex [regexp -inline {keys_with_volatile_items=([\d]+)} [R 1 info keyspace]] 1] "%d"]
assert_equal 2 [R 1 HLEN $key]
assert_equal "v1 {} v3" [R 1 HMGET $key f1 f2 f3]
assert_equal -1 [R 1 HTTL $key FIELDS 1 f3]
assert_equal $far_exp [R 1 HEXPIRETIME $key FIELDS 1 f1]
assert_equal -2 [R 1 HTTL $key FIELDS 1 f2]
# Set short expiration on all fields (some do not exist)
R 1 HPEXPIRE $key 100 FIELDS 3 f1 f2 f3
# Verify active expiry
wait_for_condition 200 50 {
[R 1 HLEN $key] eq 0 &&
[info_field [R 1 info stats] expired_fields] eq [expr {$initial_expired + 2}]
} else {
fail "All fields should have expired"
}
assert_match "" [scan [regexp -inline {keys=([\d]*)} [R 1 info keyspace]] keys=%d]
# TODO handle empty #Keyspace properly
# assert_equal 0 [scan [lindex [regexp -inline {keys_with_volatile_items=([\d]+)} [R 1 info keyspace]] 1] "%d"]
}
}
##### Active expiry test slot migration with multiple fields #####
start_cluster 3 0 {tags {"cluster mytest external:skip"} overrides {cluster-node-timeout 1000}} {
# Flush all data on all cluster nodes before starting
for {set i 0} {$i < 3} {incr i} {
R $i FLUSHALL
}
set R0_id [R 0 CLUSTER MYID]
set R1_id [R 1 CLUSTER MYID]
# Use a fixed hash tag to ensure key is in one slot
set key "{mymigrate}myhash"
test {Large hash with mixed TTL fields maintains expiry state after cluster slot migration} {
set initial_expired [info_field [R 0 info stats] expired_fields]
set num_fields 100
# Create hash fields
for {set i 1} {$i <= $num_fields} {incr i} {
lappend pairs "f$i" "v$i"
}
R 0 HSET $key {*}$pairs
assert_equal $num_fields [R 0 HLEN $key]
set far_exp [expr {[clock seconds] + 30000}]
# Set large TTL on 25 fields
for {set i 1} {$i <= 25} {incr i} {
R 0 HEXPIREAT $key $far_exp FIELDS 1 "f$i"
}
# Set short TTL on 25 fields
for {set i 26} {$i <= 50} {incr i} {
R 0 HPEXPIRE $key 100 FIELDS 1 "f$i"
}
# wait for short expire field to be expired by active expire
wait_for_condition 100 100 {
[R 0 HLEN $key] eq 75 &&
[info_field [R 0 info stats] expired_fields] eq [expr {$initial_expired + 25}]
} else {
fail "Fields should have expired"
}
# Verify expired fields return empty string and non-expired return values
for {set i 26} {$i <= 50} {incr i} {
assert_equal "" [R 0 HGET $key "f$i"]
}
for {set i 1} {$i <= 25} {incr i} {
assert_equal "v$i" [R 0 HGET $key "f$i"]
}
for {set i 51} {$i <= $num_fields} {incr i} {
assert_equal "v$i" [R 0 HGET $key "f$i"]
}
# Prepare slot migration
set slot [R 0 CLUSTER KEYSLOT $key]
assert_equal OK [R 1 CLUSTER SETSLOT $slot IMPORTING $R0_id]
assert_equal OK [R 0 CLUSTER SETSLOT $slot MIGRATING $R1_id]
# Migrate key to destination node
R 0 MIGRATE [srv -1 host] [srv -1 port] $key 0 5000
# Complete slot migration
R 0 CLUSTER SETSLOT $slot NODE $R1_id
R 1 CLUSTER SETSLOT $slot NODE $R1_id
set initial_expired [info_field [R 1 info stats] expired_fields]
# Verify after slot migration all fields are present and ttl is kept
assert_equal 75 [R 1 HLEN $key]
for {set i 1} {$i <= $num_fields} {incr i} {
if {$i > 50} {
assert_equal -1 [R 1 HTTL $key FIELDS 1 "f$i"]
assert_equal "v$i" [R 1 HGET $key "f$i"]
} else {
if {$i <= 25} {
assert_equal $far_exp [R 1 HEXPIRETIME $key FIELDS 1 f$i]
assert_equal "v$i" [R 1 HGET $key "f$i"]
} else {
assert_equal -2 [R 1 HTTL $key FIELDS 1 "f$i"]
assert_equal "" [R 1 HGET $key "f$i"]
}
}
}
# Set short expiration on all fields (some do not exist)
set fields {}
for {set i 1} {$i <= 100} {incr i} {
lappend fields "f$i"
}
R 1 HPEXPIRE $key 100 FIELDS 100 {*}$fields
# Verify active expiry
wait_for_condition 100 100 {
[R 1 HLEN $key] eq 0 &&
[info_field [R 1 info stats] expired_fields] eq [expr {$initial_expired + 75}]
} else {
fail "All fields should have expired"
}
}
}
##### Active expiry test replication #####
start_server {tags {"hashexpire external:skip"}} {
set primary [srv 0 client]
set primary_host [srv 0 host]
set primary_port [srv 0 port]
start_server {tags {needs:repl external:skip}} {
set replica [srv 0 client]
set replica_host [srv 0 host]
set replica_port [srv 0 port]
# Set this inner layer server as replica
set replica [srv 0 client]
test {Hash field active expiry on primary triggers HDEL replication to replica} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -1]
set rd_replica [valkey_deferring_client $replica_host $replica_port]
foreach rd [list $rd_primary $rd_replica] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Create hash and timing f1 < f2 expiry times
set f1_exp [expr {[clock seconds] + 10000}]
# Setup hash, set expire and set expire 0
$primary HSET myhash f1 v1 f2 v2 ;# Should trigger hset
wait_for_ofs_sync $primary $replica
$primary HPEXPIRE myhash 500 FIELDS 1 f1 ;# Should trigger 1 hexpire and then hexpired (for primary) and 1 hdel (for replica)
wait_for_ofs_sync $primary $replica
# Wait for active expiry
wait_for_active_expiry $primary myhash 1 $primary_initial_expired 1
# Ensure the replica does not increment expired_fields
assert_equal $replica_initial_expired [info_field [$replica info stats] expired_fields]
# Verify expired field returns empty string and non-expired returns value
foreach instance [list $primary $replica] {
assert_equal "{} v2" [$instance HMGET myhash f1 f2]
assert_equal 0 [get_keys_with_volatile_items $instance]
}
# Verify keyspace notification
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hset
assert_keyevent_patterns $rd myhash hexpire
}
# primary gets hexpired and replica gets hdel
assert_keyevent_patterns $rd_primary myhash hexpired
assert_keyevent_patterns $rd_replica myhash hdel
$rd_primary close
$rd_replica close
}
start_server {tags {needs:repl external:skip}} {
$primary FLUSHALL
set replica_2 [srv 0 client]
set replica_2_host [srv 0 host]
set replica_2_port [srv 0 port]
test {Hash field TTL and active expiry propagates correctly through chain replication} {
$replica replicaof $primary_host $primary_port
# Wait for R2 to connect to R1
wait_for_condition 100 100 {
[info_field [$replica info replication] master_link_status] eq "up"
} else {
fail "Replica <-> Primary connection not established"
}
$replica_2 replicaof $replica_host $replica_port
# Wait for R2 to connect to R1
wait_for_condition 100 100 {
[info_field [$replica info replication] master_link_status] eq "up"
} else {
fail "Second replica <-> First replica connection not established"
}
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica $replica_2] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -2]
set rd_replica [valkey_deferring_client -1]
set rd_replica_2 [valkey_deferring_client $replica_2_host $replica_2_port]
foreach rd [list $rd_primary $rd_replica $rd_replica_2] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Create hash and timing f1 < f2 expiry times
set f1_exp [expr {[clock seconds] + 10000}]
############################################# STEUP HASH #############################################
$primary HSET myhash f1 v1 f2 v2 ;# Should trigger 3 hset
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1 ;# Should trigger 3 hexpire
wait_for_ofs_sync $primary $replica
wait_for_ofs_sync $replica $replica_2
set primary_initial_expired [info_field [$primary info stats] expired_fields]
set replica_initial_expired [info_field [$replica info stats] expired_fields]
set replica_2_initial_expired [info_field [$replica_2 info stats] expired_fields]
$primary HPEXPIRE myhash 100 FIELDS 1 f1 ;# Should trigger 1 hexpired (for primary) and 2 hdel (for replicas)
wait_for_ofs_sync $primary $replica
wait_for_ofs_sync $replica $replica_2
# Wait for active expire
wait_for_active_expiry $primary myhash 1 $primary_initial_expired 1
# Ensure the replica does not increment expired_fields
assert_equal $replica_initial_expired [info_field [$replica info stats] expired_fields]
assert_equal $replica_2_initial_expired [info_field [$replica_2 info stats] expired_fields]
# Verify expired field returns empty string and non-expired returns value
foreach instance [list $primary $replica $replica_2] {
assert_equal "{} v2" [$instance HMGET myhash f1 f2]
assert_equal 0 [get_keys_with_volatile_items $instance]
}
# primary gets hexpired and replicas get hdel
foreach rd [list $rd_primary $rd_replica $rd_replica_2] {
assert_keyevent_patterns $rd myhash hset hexpire hexpire
}
assert_keyevent_patterns $rd_primary myhash hexpired
assert_keyevent_patterns $rd_replica myhash hdel
assert_keyevent_patterns $rd_replica_2 myhash hdel
$rd_primary close
$rd_replica close
$rd_replica_2 close
}
}
proc verify_values {instance f1_exp f2_exp} {
assert_equal $f1_exp [$instance HEXPIRETIME myhash FIELDS 1 f1]
assert_equal $f2_exp [$instance HEXPIRETIME myhash FIELDS 1 f2]
assert_equal -1 [$instance HTTL myhash FIELDS 1 f3]
assert_match {1} [scan [regexp -inline {keys=([\d]*)} [$instance info keyspace]] keys=%d]
assert_equal "v1" [$instance HGET myhash f1]
assert_equal "v2" [$instance HGET myhash f2]
assert_equal "v3" [$instance HGET myhash f3]
assert_equal 3 [$instance HLEN myhash]
}
test {Hash field TTL values remain intact after replica promotion to primary} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -1]
set rd_replica [valkey_deferring_client $replica_host $replica_port]
foreach rd [list $rd_primary $rd_replica] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Create hash fields with TTL on primary
set f1_exp [expr {[clock seconds] + 2000}]
set f2_exp [expr {[clock seconds] + 300000}]
$primary HSET myhash f1 v1 f2 v2 f3 v3
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1
$primary HEXPIREAT myhash $f2_exp FIELDS 1 f2
# f3 remains persistent
# Wait for full sync
wait_for_ofs_sync $primary $replica
# Verify primary and replica are the same
foreach instance [list $primary $replica] {
verify_values $instance $f1_exp $f2_exp
assert_equal 1 [get_keys_with_volatile_items $instance]
}
# Perform failover
$replica replicaof no one
# Wait for replica to become primary
wait_for_condition 100 100 {
[info_field [$replica info replication] role] eq "master"
} else {
fail "Replica didn't become master"
}
# Check all values that checked before are the same
verify_values $replica $f1_exp $f2_exp
# Set f1 to expire in 1 second and wait for active expiration
set replica_initial_expired [info_field [$replica info stats] expired_fields]
$replica HEXPIRE myhash 1 FIELDS 1 f1
wait_for_active_expiry $replica myhash 2 $replica_initial_expired 1
assert_equal "{} v2 v3" [$replica HMGET myhash f1 f2 f3]
# Not affected primary
assert_equal 3 [$primary HLEN myhash]
assert_equal "v1 v2 v3" [$primary HMGET myhash f1 f2 f3]
set primary_initial_expired [info_field [$primary info stats] expired_fields]
assert_equal 0 [expr {[info_field [$primary info stats] expired_fields] - $primary_initial_expired}]
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hset hexpire hexpire
}
assert_keyevent_patterns $rd_replica myhash hexpire
assert_keyevent_patterns $rd_replica myhash hexpired
$rd_primary close
$rd_replica close
}
test {Hash field TTL values persist correctly during FAILOVER command execution} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -1]
set rd_replica [valkey_deferring_client $replica_host $replica_port]
foreach rd [list $rd_primary $rd_replica] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Create hash fields with TTL on primary
set f1_exp [expr {[clock seconds] + 2000}]
set f2_exp [expr {[clock seconds] + 300000}]
$primary HSET myhash f1 v1 f2 v2 f3 v3
$primary HEXPIREAT myhash $f1_exp FIELDS 1 f1
$primary HEXPIREAT myhash $f2_exp FIELDS 1 f2
# f3 remains persistent
# Wait for full sync
wait_for_ofs_sync $primary $replica
# Verify primary and replica are the same
foreach instance [list $primary $replica] {
verify_values $instance $f1_exp $f2_exp
assert_equal 1 [get_keys_with_volatile_items $instance]
}
# Perform failover swap roles
$primary FAILOVER TO $replica_host $replica_port
# Wait for role swap
wait_for_condition 100 100 {
[info_field [$replica info replication] role] eq "master" &&
[info_field [$primary info replication] role] eq "slave"
} else {
fail "Failover didn't complete"
}
# Verify primary and replica are still the same
foreach instance [list $primary $replica] {
verify_values $instance $f1_exp $f2_exp
assert_equal 1 [get_keys_with_volatile_items $instance]
}
# Set f1 to expire in 1 second and wait for active expiration
$replica HEXPIRE myhash 1 FIELDS 1 f1 ;# will trigger hexpire
wait_for_ofs_sync $replica $primary
set replica_initial_expired [info_field [$replica info stats] expired_fields]
wait_for_active_expiry $replica myhash 2 $replica_initial_expired 1
# Verify prev primary, which is now replica of new primary (prev primary) is sync
assert_equal 2 [$primary HLEN myhash]
# Verify expiry
assert_equal "{} v2 v3" [$replica HMGET myhash f1 f2 f3]
assert_equal "" [$primary HGET myhash f1]
assert_equal "v2" [$primary HGET myhash f2]
assert_equal "v3" [$primary HGET myhash f3]
# Primary is now replica, so no expected change in expired_fields
assert_equal [info_field [$primary info stats] expired_fields] $primary_initial_expired
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hset hexpire hexpire hexpire
}
assert_keyevent_patterns $rd_replica myhash hexpired
assert_keyevent_patterns $rd_primary myhash hdel
$rd_primary close
$rd_replica close
}
}
}
## Check monitor tests ###
start_server {tags {"hashexpire external:skip"}} {
set primary [srv 0 client]
set primary_host [srv 0 host]
set primary_port [srv 0 port]
start_server {tags {needs:repl external:skip}} {
set replica [srv 0 client]
set replica_host [srv 0 host]
set replica_port [srv 0 port]
# Set this inner layer server as replica
set replica [srv 0 client]
proc setup_replica_monitor_test {primary replica primary_host primary_port replica_host replica_port} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
set rd_replica [valkey_deferring_client $replica_host $replica_port]
$rd_replica monitor
assert_match {*OK*} [$rd_replica read]
return [list $primary_initial_expired $rd_replica]
}
proc read_monitor_output {rd_replica read_amount} {
set res {}
set i 0
while {$i < $read_amount} {
set curr_read [$rd_replica read]
# Skip lines with INFO commands
if {[regexp {\"info\"} $curr_read] || [regexp {\"SELECT\"} $curr_read]} {
continue
}
lappend res $curr_read
incr i
}
$rd_replica close
return [join $res " "]
}
# These tests are flaky, probably monitor output should be filtered
test {Multiple expired hash fields are replicated as single HDEL command to replica} {
lassign [setup_replica_monitor_test $primary $replica $primary_host $primary_port $replica_host $replica_port] primary_initial_expired rd_replica
$primary HSET myhash f1 v1 f2 v2 f3 v3
wait_for_ofs_sync $primary $replica
$primary HPEXPIRE myhash 50 FIELDS 1 f2
wait_for_ofs_sync $primary $replica
wait_for_active_expiry $primary myhash 2 $primary_initial_expired 1
set _ [read_monitor_output $rd_replica 3]
} {*HSET*myhash*f1*f2*f3*HDEL*myhash*f2*}
test {HDEL replication includes only actually expired fields not non-existent ones} {
lassign [setup_replica_monitor_test $primary $replica $primary_host $primary_port $replica_host $replica_port] primary_initial_expired rd_replica
$primary HSET myhash f1 v1 f2 v2 f3 v3
wait_for_ofs_sync $primary $replica
$primary HPEXPIRE myhash 50 FIELDS 2 f1 f5
wait_for_ofs_sync $primary $replica
wait_for_active_expiry $primary myhash 2 $primary_initial_expired 1
set _ [read_monitor_output $rd_replica 3]
} {*HSET*myhash*f1*f2*f3*HDEL*myhash*f1*}
}
}
start_server {tags {"hashexpire external:skip"}} {
set primary [srv 0 client]
set primary_host [srv 0 host]
set primary_port [srv 0 port]
start_server {tags {needs:repl external:skip}} {
set replica [srv 0 client]
set replica_host [srv 0 host]
set replica_port [srv 0 port]
test {expired_fields metric increments only on primary not replica during field expiry} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
# Create hash fields with different TTLs
$primary HSET myhash f1 v1 f2 v2 f3 v3 f4 v4
$primary HEXPIRE myhash 3000 FIELDS 1 f1
$primary HSETEX myhash EX 5000 FIELDS 1 f2 v2
$primary HEXPIRE myhash 60000 FIELDS 1 f3
wait_for_ofs_sync $primary $replica
# Verify PERSIST
assert_equal "v3" [$primary HGETEX myhash PERSIST FIELDS 1 f3]
wait_for_ofs_sync $primary $replica
assert_equal -1 [$primary HTTL myhash FIELDS 1 f3]
assert_equal -1 [$replica HTTL myhash FIELDS 1 f3]
$primary HPEXPIRE myhash 1 FIELDS 1 f1
wait_for_ofs_sync $primary $replica
# Wait for active expiry
wait_for_active_expiry $primary myhash 3 $primary_initial_expired 1
assert_equal 0 [info_field [$replica info stats] expired_fields]
}
test {expired_fields metric correctly tracks sequential field expirations in replication} {
lassign [setup_replication_test $primary $replica $primary_host $primary_port] primary_initial_expired replica_initial_expired
# Initialize deferred clients and subscribe to keyspace notifications
foreach instance [list $primary $replica] {
$instance config set notify-keyspace-events KEA
}
set rd_primary [valkey_deferring_client -1]
set rd_replica [valkey_deferring_client $replica_host $replica_port]
foreach rd [list $rd_primary $rd_replica] {
assert_equal {1} [psubscribe $rd __keyevent@*]
}
# Create hash fields with different TTLs
$primary HSET myhash f1 v1 f2 v2 f3 v3 f4 v4
$primary HEXPIRE myhash 3000 FIELDS 1 f1
$primary HSETEX myhash EX 5000 FIELDS 1 f2 v2
$primary HEXPIRE myhash 60000 FIELDS 1 f3
wait_for_ofs_sync $primary $replica
# Verify TTLs are set correctly
assert_morethan [$primary HTTL myhash FIELDS 1 f1] 0
assert_morethan [$primary HTTL myhash FIELDS 1 f2] 0
assert_morethan [$primary HTTL myhash FIELDS 1 f3] 0
assert_equal -1 [$primary HTTL myhash FIELDS 1 f4]
assert_equal 4 [$primary HLEN myhash]
assert_equal 4 [$replica HLEN myhash]
# Verify values
assert_equal "v1 v2 v3 v4" [$primary HMGET myhash f1 f2 f3 f4]
assert_equal "v1 v2 v3 v4" [$replica HMGET myhash f1 f2 f3 f4]
# Verify PERSIST
assert_equal "v3" [$primary HGETEX myhash PERSIST FIELDS 1 f3]
wait_for_ofs_sync $primary $replica
assert_equal -1 [$primary HTTL myhash FIELDS 1 f3]
assert_equal -1 [$replica HTTL myhash FIELDS 1 f3]
assert_equal 1 [get_keys_with_volatile_items $primary]
assert_equal 1 [get_keys_with_volatile_items $replica]
# Expire fields one by one
for {set i 1} {$i <= 4} {incr i} {
assert_equal 1 [get_keys $primary]
assert_equal 1 [get_keys $replica]
# Set field to expire immediately
$primary HPEXPIRE myhash 1 FIELDS 1 f$i
wait_for_ofs_sync $primary $replica
# Wait for active expiry
wait_for_active_expiry $primary myhash [expr {4 - $i}] $primary_initial_expired $i
# Replica should NOT increment expired_fields
assert_equal 0 [info_field [$replica info stats] expired_fields]
# Replica should also have the field removed with replication
assert_equal [expr {4 - $i}] [$replica HLEN myhash]
}
assert_equal 0 [get_keys_with_volatile_items $primary]
assert_equal 0 [get_keys_with_volatile_items $replica]
# Hash should be deleted when all fields expire
assert_equal 0 [$primary EXISTS myhash]
assert_equal 0 [$replica EXISTS myhash]
assert_equal 0 [get_keys $primary]
assert_equal 0 [get_keys $replica]
assert_equal 0 [get_keys_with_volatile_items $primary]
assert_equal 0 [get_keys_with_volatile_items $replica]
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hset hexpire hset hexpire hexpire hpersist hexpire
}
assert_keyevent_patterns $rd_primary myhash hexpired ; # f1
assert_keyevent_patterns $rd_replica myhash hdel
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hexpire
}
assert_keyevent_patterns $rd_primary myhash hexpired ; # f2
assert_keyevent_patterns $rd_replica myhash hdel
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hexpire
}
assert_keyevent_patterns $rd_primary myhash hexpired ; # f3
assert_keyevent_patterns $rd_replica myhash hdel
foreach rd [list $rd_primary $rd_replica] {
assert_keyevent_patterns $rd myhash hexpire
}
assert_keyevent_patterns $rd_primary myhash hexpired del ; # f4
assert_keyevent_patterns $rd_replica myhash hdel del
$rd_primary close
$rd_replica close
}
}
}