From fa6d4c3d63cf84299167fdd89eb40d9cf8a64a0f Mon Sep 17 00:00:00 2001 From: Moti Cohen Date: Thu, 16 Apr 2026 13:16:52 +0300 Subject: [PATCH] Fix SIGABRT in HSETEX when a field appears twice in the FIELDS list (#14956) HSETEX crashed on assert() with a SIGABRT when the same field appeared more than once in the FIELDS list and an expiry time was given (EX/PX/EXAT/PXAT). Root cause: hfieldPersist() and the KEEP_TTL path in hashTypeSet() both asserted that dictExpireMeta->expireMeta.trash == 0, meaning the hash must be globally registered in the HFE DS. This is incorrect during HSETEX execution because hashTypeSetExDone(), which registers the hash globally and clears trash, called only at the end of flow. The private per-field ebuckets are fully valid regardless of the global registration state. Fix: Remove both incorrect assertions. The operations on the private ebuckets (ebRemove in hfieldPersist, ebAdd in the KEEP_TTL path) are correct and do not require the hash to be globally registered. Tests: Added two regression tests covering the crash scenarios: - HSETEX EX with a duplicate field (existing field, expiry given) - HSETEX FNX EX with a duplicate field (no prior field, FNX condition passes) --- src/t_hash.c | 4 ---- tests/unit/type/hash-field-expire.tcl | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/t_hash.c b/src/t_hash.c index e258eb71f..5ea456597 100644 --- a/src/t_hash.c +++ b/src/t_hash.c @@ -1022,7 +1022,6 @@ int hashTypeSet(redisDb *db, kvobj *o, sds field, sds value, int flags) { if (newExpireAt != EB_EXPIRE_TIME_INVALID) { dict *d = o->ptr; htMetadataEx *dictExpireMeta = htGetMetadataEx(d); - serverAssert(dictExpireMeta->expireMeta.trash == 0); ebAdd(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, newEntry, newExpireAt); } @@ -3478,9 +3477,6 @@ static void hfieldPersist(robj *hashObj, Entry *entry) { dict *d = hashObj->ptr; htMetadataEx *dictExpireMeta = htGetMetadataEx(d); - /* If field has valid expiry then dict must have valid metadata as well */ - serverAssert(dictExpireMeta->expireMeta.trash == 0); - /* Remove field from private HFE DS */ ebRemove(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, entry); diff --git a/tests/unit/type/hash-field-expire.tcl b/tests/unit/type/hash-field-expire.tcl index e1ba72019..402a9ad72 100644 --- a/tests/unit/type/hash-field-expire.tcl +++ b/tests/unit/type/hash-field-expire.tcl @@ -1277,6 +1277,24 @@ start_server {tags {"external:skip needs:debug"}} { assert_range [r hpttl myhash FIELDS 1 f3] 4500 5000 } + test "HSETEX EX - field appears twice in FIELDS list with EX is allowed ($type)" { + # The EX condition passes, so all fields must be set, and the last value wins. + r del myhash + r hset myhash f1 v1 + r hsetex myhash EX 100 FIELDS 2 f1 new1 f1 new2 + # Last value wins (same as plain HSET behavior with duplicate fields) + assert_equal "new2" [r hget myhash f1] + assert_range [r httl myhash FIELDS 1 f1] 80 100 + } + + test "HSETEX FNX - field appears twice in FIELDS list with EX is allowed ($type)" { + # The FNX condition passes, so all fields must be set, and the last value wins. + r del myhash + r hsetex myhash FNX EX 100 FIELDS 2 f1 new1 f1 new2 + assert_equal "new2" [r hget myhash f1] + assert_range [r httl myhash FIELDS 1 f1] 80 100 + } + test "HSETEX - Test 'EX' flag ($type)" { r del myhash r hset myhash f1 v1 f2 v2