Skip to content

Commit 4d78d36

Browse files
hanxizh9910ranshid
andauthored
HSETEX: Support NX/XX Flags (#2668)
### Summary Addresses #2619. This PR extends the `HSETEX` command to support optional key-level `NX` and `XX` flags, allowing operations conditional on the existence of the hash key. ### Changes - Updated `hsetex.json` and regenerated `commands.def`. - Extended argument parsing for NX/XX. - Added key-level `NX`/`XX` support in `HSETEX`. - Added tests covering all four NX/XX scenarios. --------- Signed-off-by: Hanxi Zhang <[email protected]> Co-authored-by: Ran Shidlansik <[email protected]>
1 parent 6cbc1a3 commit 4d78d36

File tree

5 files changed

+168
-17
lines changed

5 files changed

+168
-17
lines changed

src/commands.def

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4312,6 +4312,12 @@ keySpec HSETEX_Keyspecs[1] = {
43124312
};
43134313
#endif
43144314

4315+
/* HSETEX key_condition argument table */
4316+
struct COMMAND_ARG HSETEX_key_condition_Subargs[] = {
4317+
{MAKE_ARG("nx",ARG_TYPE_PURE_TOKEN,-1,"NX",NULL,NULL,CMD_ARG_NONE,0,NULL)},
4318+
{MAKE_ARG("xx",ARG_TYPE_PURE_TOKEN,-1,"XX",NULL,NULL,CMD_ARG_NONE,0,NULL)},
4319+
};
4320+
43154321
/* HSETEX fields_condition argument table */
43164322
struct COMMAND_ARG HSETEX_fields_condition_Subargs[] = {
43174323
{MAKE_ARG("fnx",ARG_TYPE_PURE_TOKEN,-1,"FNX",NULL,NULL,CMD_ARG_NONE,0,NULL)},
@@ -4342,6 +4348,7 @@ struct COMMAND_ARG HSETEX_fields_Subargs[] = {
43424348
/* HSETEX argument table */
43434349
struct COMMAND_ARG HSETEX_Args[] = {
43444350
{MAKE_ARG("key",ARG_TYPE_KEY,0,NULL,NULL,NULL,CMD_ARG_NONE,0,NULL)},
4351+
{MAKE_ARG("key-condition",ARG_TYPE_ONEOF,-1,NULL,NULL,NULL,CMD_ARG_OPTIONAL,2,NULL),.subargs=HSETEX_key_condition_Subargs},
43454352
{MAKE_ARG("fields-condition",ARG_TYPE_ONEOF,-1,NULL,NULL,NULL,CMD_ARG_OPTIONAL,2,NULL),.subargs=HSETEX_fields_condition_Subargs},
43464353
{MAKE_ARG("expiration",ARG_TYPE_ONEOF,-1,NULL,NULL,NULL,CMD_ARG_OPTIONAL,5,NULL),.subargs=HSETEX_expiration_Subargs},
43474354
{MAKE_ARG("fields",ARG_TYPE_BLOCK,-1,"FIELDS",NULL,NULL,CMD_ARG_NONE,2,NULL),.subargs=HSETEX_fields_Subargs},
@@ -11808,7 +11815,7 @@ struct COMMAND_STRUCT serverCommandTable[] = {
1180811815
{MAKE_CMD("hrandfield","Returns one or more random fields from a hash.","O(N) where N is the number of fields returned","6.2.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HRANDFIELD_History,0,HRANDFIELD_Tips,1,hrandfieldCommand,-2,CMD_READONLY,ACL_CATEGORY_HASH,HRANDFIELD_Keyspecs,1,NULL,2),.args=HRANDFIELD_Args},
1180911816
{MAKE_CMD("hscan","Iterates over fields and values of a hash.","O(1) for every call. O(N) for a complete iteration, including enough command calls for the cursor to return back to 0. N is the number of elements inside the collection.","2.8.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSCAN_History,0,HSCAN_Tips,1,hscanCommand,-3,CMD_READONLY,ACL_CATEGORY_HASH,HSCAN_Keyspecs,1,NULL,5),.args=HSCAN_Args},
1181011817
{MAKE_CMD("hset","Creates or modifies the value of a field in a hash.","O(1) for each field/value pair added, so O(N) to add N field/value pairs when the command is called with multiple field/value pairs.","2.0.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSET_History,1,HSET_Tips,0,hsetCommand,-4,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_HASH,HSET_Keyspecs,1,NULL,2),.args=HSET_Args},
11811-
{MAKE_CMD("hsetex","Set the value of one or more fields of a given hash key, and optionally set their expiration time.","O(1)","9.0.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSETEX_History,0,HSETEX_Tips,0,hsetexCommand,-6,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_HASH,HSETEX_Keyspecs,1,NULL,4),.args=HSETEX_Args},
11818+
{MAKE_CMD("hsetex","Set the value of one or more fields of a given hash key, and optionally set their expiration time.","O(1)","9.0.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSETEX_History,0,HSETEX_Tips,0,hsetexCommand,-6,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_HASH,HSETEX_Keyspecs,1,NULL,5),.args=HSETEX_Args},
1181211819
{MAKE_CMD("hsetnx","Sets the value of a field in a hash only when the field doesn't exist.","O(1)","2.0.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSETNX_History,0,HSETNX_Tips,0,hsetnxCommand,4,CMD_WRITE|CMD_DENYOOM|CMD_FAST,ACL_CATEGORY_HASH,HSETNX_Keyspecs,1,NULL,3),.args=HSETNX_Args},
1181311820
{MAKE_CMD("hstrlen","Returns the length of the value of a field.","O(1)","3.2.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HSTRLEN_History,0,HSTRLEN_Tips,0,hstrlenCommand,3,CMD_READONLY|CMD_FAST,ACL_CATEGORY_HASH,HSTRLEN_Keyspecs,1,NULL,2),.args=HSTRLEN_Args},
1181411821
{MAKE_CMD("httl","Returns the remaining time to live (in seconds) of a hash key's field(s) that have an associated expiration.","O(1) for each field, so O(N) for N items when the command is called with multiple fields.","9.0.0",CMD_DOC_NONE,NULL,NULL,"hash",COMMAND_GROUP_HASH,HTTL_History,0,HTTL_Tips,0,httlCommand,-5,CMD_READONLY|CMD_FAST,ACL_CATEGORY_HASH,HTTL_Keyspecs,1,NULL,2),.args=HTTL_Args},

src/commands/hsetex.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@
5252
"type": "key",
5353
"key_spec_index": 0
5454
},
55+
{
56+
"name": "key-condition",
57+
"type": "oneof",
58+
"optional": true,
59+
"arguments": [
60+
{
61+
"name": "nx",
62+
"type": "pure-token",
63+
"token": "NX"
64+
},
65+
{
66+
"name": "xx",
67+
"type": "pure-token",
68+
"token": "XX"
69+
}
70+
]
71+
},
5572
{
5673
"name": "fields-condition",
5774
"type": "oneof",

src/server.c

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7524,12 +7524,12 @@ int parseExtendedCommandArgumentsOrReply(client *c, int *flags, int *unit, robj
75247524
/* clang-format off */
75257525
if ((opt[0] == 'n' || opt[0] == 'N') &&
75267526
(opt[1] == 'x' || opt[1] == 'X') && opt[2] == '\0' &&
7527-
!(*flags & ARGS_SET_XX || *flags & ARGS_SET_IFEQ) && (command_type == COMMAND_SET))
7527+
!(*flags & ARGS_SET_XX || *flags & ARGS_SET_IFEQ) && (command_type == COMMAND_SET || command_type == COMMAND_HSET))
75287528
{
75297529
*flags |= ARGS_SET_NX;
75307530
} else if ((opt[0] == 'x' || opt[0] == 'X') &&
75317531
(opt[1] == 'x' || opt[1] == 'X') && opt[2] == '\0' &&
7532-
!(*flags & ARGS_SET_NX || *flags & ARGS_SET_IFEQ) && (command_type == COMMAND_SET))
7532+
!(*flags & ARGS_SET_NX || *flags & ARGS_SET_IFEQ) && (command_type == COMMAND_SET || command_type == COMMAND_HSET))
75337533
{
75347534
*flags |= ARGS_SET_XX;
75357535
} else if ((opt[0] == 'f' || opt[0] == 'F') &&

src/t_hash.c

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,7 @@ void hsetexCommand(client *c) {
11901190
int changes = 0;
11911191
robj **new_argv = NULL;
11921192
int new_argc = 0;
1193+
int need_rewrite_argv = 0;
11931194

11941195
for (; fields_index < c->argc - 1; fields_index++) {
11951196
if (!strcasecmp(c->argv[fields_index]->ptr, "fields")) {
@@ -1209,6 +1210,17 @@ void hsetexCommand(client *c) {
12091210
if (checkType(c, o, OBJ_HASH))
12101211
return;
12111212

1213+
if (flags & (ARGS_SET_NX | ARGS_SET_XX | ARGS_SET_FNX | ARGS_SET_FXX | ARGS_EX | ARGS_PX | ARGS_EXAT)) {
1214+
need_rewrite_argv = 1;
1215+
}
1216+
1217+
/* Check NX/XX key-level conditions before creating a new object */
1218+
if (((flags & ARGS_SET_NX) && o != NULL) ||
1219+
((flags & ARGS_SET_XX) && o == NULL)) {
1220+
addReply(c, shared.czero);
1221+
return;
1222+
}
1223+
12121224
/* Handle parsing and calculating the expiration time. */
12131225
if (flags & ARGS_KEEPTTL)
12141226
set_flags |= HASH_SET_KEEP_EXPIRY;
@@ -1252,13 +1264,37 @@ void hsetexCommand(client *c) {
12521264

12531265
bool has_volatile_fields = hashTypeHasVolatileFields(o);
12541266

1255-
/* In case we are expiring all the elements prepare a new argv since we are going to delete all the expired fields. */
1267+
/* Prepare a new argv when rewriting the command. If set_expired is true,
1268+
* all expired fields will be deleted. Otherwise, if rewriting is needed due to NX/XX/FNX/FXX flags,
1269+
* copy the command, key, and optional arguments, skipping the NX/XX/FNX/FXX flags. */
12561270
if (set_expired) {
12571271
new_argv = zmalloc(sizeof(robj *) * (num_fields + 2));
12581272
new_argv[new_argc++] = shared.hdel;
12591273
incrRefCount(shared.hdel);
12601274
new_argv[new_argc++] = c->argv[1];
12611275
incrRefCount(c->argv[1]);
1276+
} else if (need_rewrite_argv) {
1277+
/* We use new_argv for rewrite */
1278+
new_argv = zmalloc(sizeof(robj *) * c->argc);
1279+
// Copy optional args (skip NX/XX/FNX/FXX)
1280+
for (int i = 0; i < fields_index; i++) {
1281+
if (strcmp(c->argv[i]->ptr, "NX") &&
1282+
strcmp(c->argv[i]->ptr, "XX") &&
1283+
strcmp(c->argv[i]->ptr, "FNX") &&
1284+
strcmp(c->argv[i]->ptr, "FXX")) {
1285+
/* Propagate as HSETEX Key Value PXAT millisecond-timestamp if there is
1286+
* EX/PX/EXAT flag. */
1287+
if (expire && !(flags & ARGS_PXAT) && c->argv[i + 1] == expire) {
1288+
robj *milliseconds_obj = createStringObjectFromLongLong(when);
1289+
new_argv[new_argc++] = shared.pxat;
1290+
new_argv[new_argc++] = milliseconds_obj;
1291+
i++; // skip the original expire argument
1292+
} else {
1293+
new_argv[new_argc++] = c->argv[i];
1294+
incrRefCount(c->argv[i]);
1295+
}
1296+
}
1297+
}
12621298
}
12631299

12641300
for (i = fields_index; i < c->argc; i += 2) {
@@ -1273,6 +1309,12 @@ void hsetexCommand(client *c) {
12731309
} else {
12741310
hashTypeSet(o, c->argv[i]->ptr, c->argv[i + 1]->ptr, when, set_flags);
12751311
changes++;
1312+
if (need_rewrite_argv) {
1313+
new_argv[new_argc++] = c->argv[i];
1314+
incrRefCount(c->argv[i]);
1315+
new_argv[new_argc++] = c->argv[i + 1];
1316+
incrRefCount(c->argv[i + 1]);
1317+
}
12761318
}
12771319
}
12781320

@@ -1287,20 +1329,10 @@ void hsetexCommand(client *c) {
12871329
notifyKeyspaceEvent(NOTIFY_HASH, "hexpired", c->argv[1], c->db->id);
12881330
} else {
12891331
notifyKeyspaceEvent(NOTIFY_HASH, "hset", c->argv[1], c->db->id);
1332+
if (need_rewrite_argv) {
1333+
replaceClientCommandVector(c, new_argc, new_argv);
1334+
}
12901335
if (expire) {
1291-
/* Propagate as HSETEX Key Value PXAT millisecond-timestamp if there is
1292-
* EX/PX/EXAT flag. */
1293-
if (!(flags & ARGS_PXAT)) {
1294-
for (int i = 2; i < fields_index; i++) {
1295-
if (c->argv[i + 1] == expire) {
1296-
robj *milliseconds_obj = createStringObjectFromLongLong(when);
1297-
rewriteClientCommandArgument(c, i, shared.pxat);
1298-
rewriteClientCommandArgument(c, i + 1, milliseconds_obj);
1299-
decrRefCount(milliseconds_obj);
1300-
break;
1301-
}
1302-
}
1303-
}
13041336
notifyKeyspaceEvent(NOTIFY_HASH, "hexpire", c->argv[1], c->db->id);
13051337
}
13061338
}

tests/unit/hashexpire.tcl

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,7 +627,43 @@ start_server {tags {"hashexpire"}} {
627627
assert_error {ERR numfields should be greater than 0 and match the provided number of fields} {r HSETEX myhash PX 100 FIELDS 1 field1 val1 extra}
628628
}
629629

630+
## NX/XX key-level tests
630631

632+
test {HSETEX NX - non-existing key creates the key} {
633+
r FLUSHALL
634+
set res [r HSETEX myhash NX FIELDS 2 f1 v1 f2 v2]
635+
assert_equal 1 $res
636+
assert_equal v1 [r HGET myhash f1]
637+
assert_equal v2 [r HGET myhash f2]
638+
}
639+
640+
test {HSETEX NX - existing key blocked} {
641+
r FLUSHALL
642+
r HSET myhash f1 v1
643+
set res [r HSETEX myhash NX FIELDS 2 f1 new1 f2 new2]
644+
assert_equal 0 $res
645+
assert_equal v1 [r HGET myhash f1]
646+
assert_equal 0 [r HEXISTS myhash f2]
647+
}
648+
649+
test {HSETEX XX - existing key updates fields} {
650+
r FLUSHALL
651+
r HSET myhash f1 v1 f2 v2
652+
set res [r HSETEX myhash XX FIELDS 2 f1 new1 f2 new2]
653+
assert_equal 1 $res
654+
assert_equal new1 [r HGET myhash f1]
655+
assert_equal new2 [r HGET myhash f2]
656+
}
657+
658+
test {HSETEX XX - non-existing key blocked} {
659+
r FLUSHALL
660+
set res [r HSETEX myhash XX FIELDS 2 f1 v1 f2 v2]
661+
assert_equal 0 $res
662+
assert_equal 0 [r EXISTS myhash]
663+
assert_equal 0 [r HEXISTS myhash f1]
664+
assert_equal 0 [r HEXISTS myhash f2]
665+
}
666+
631667
## FNX/FXX
632668

633669
# hsetex throws ERR *, it shouldn't
@@ -690,6 +726,65 @@ start_server {tags {"hashexpire"}} {
690726
assert_equal 0 [r EXISTS myhash]
691727
}
692728

729+
## NX/XX + FNX/FXX combinations
730+
731+
# NX + FNX — only set if key does not exist AND fields do not exist
732+
test {HSETEX EX NX FNX - set only if key missing and fields missing} {
733+
r FLUSHALL
734+
set res [r HSETEX myhash EX 10 NX FNX FIELDS 2 f1 v1 f2 v2]
735+
assert_equal 1 $res
736+
assert_equal v1 [r HGET myhash f1]
737+
assert_equal v2 [r HGET myhash f2]
738+
739+
# Try again — key exists now, should block
740+
set res [r HSETEX myhash EX 10 NX FNX FIELDS 2 f3 v3 f4 v4]
741+
assert_equal 0 $res
742+
assert_equal 0 [r HEXISTS myhash f3]
743+
assert_equal 0 [r HEXISTS myhash f4]
744+
}
745+
746+
# NX + FXX — only set if key does not exist AND all fields exist (key missing → blocked)
747+
test {HSETEX EX NX FXX - key missing blocks all} {
748+
r FLUSHALL
749+
set res [r HSETEX myhash EX 10 NX FXX FIELDS 2 f1 v1 f2 v2]
750+
assert_equal 0 $res
751+
assert_equal 0 [r EXISTS myhash]
752+
assert_equal 0 [r HEXISTS myhash f1]
753+
assert_equal 0 [r HEXISTS myhash f2]
754+
}
755+
756+
# XX + FNX — only set if key exists AND none of the fields exist
757+
test {HSETEX EX XX FNX - set only if key exists and fields missing} {
758+
r FLUSHALL
759+
r HSET myhash f1 old1
760+
set res [r HSETEX myhash EX 10 XX FNX FIELDS 2 f2 v2 f3 v3]
761+
assert_equal 1 $res
762+
assert_equal v2 [r HGET myhash f2]
763+
assert_equal v3 [r HGET myhash f3]
764+
765+
# Try again — fields already exist → should block
766+
set res [r HSETEX myhash EX 10 XX FNX FIELDS 2 f2 x f4 y]
767+
assert_equal 0 $res
768+
assert_equal v2 [r HGET myhash f2]
769+
assert_equal 0 [r HEXISTS myhash f4]
770+
}
771+
772+
# XX + FXX — only set if key exists AND all fields exist
773+
test {HSETEX EX XX FXX - set only if key exists and all fields exist} {
774+
r FLUSHALL
775+
r HSET myhash f1 old1 f2 old2
776+
set res [r HSETEX myhash EX 10 XX FXX FIELDS 2 f1 new1 f2 new2]
777+
assert_equal 1 $res
778+
assert_equal new1 [r HGET myhash f1]
779+
assert_equal new2 [r HGET myhash f2]
780+
781+
# Try when one field is missing → should block
782+
set res [r HSETEX myhash EX 10 XX FXX FIELDS 2 f1 x f3 y]
783+
assert_equal 0 $res
784+
assert_equal new1 [r HGET myhash f1]
785+
assert_equal 0 [r HEXISTS myhash f3]
786+
}
787+
693788
###### Test EXPIRE #############
694789

695790

0 commit comments

Comments
 (0)