From 045376e7e64c44884474acc56360149b648fd4ea Mon Sep 17 00:00:00 2001 From: Surya Teja Date: Sat, 19 Oct 2024 16:39:33 +0530 Subject: [PATCH] #1128: Added support for ZPOPMIN (#1143) --- docs/src/content/docs/commands/ZPOPMIN.md | 148 +++++++++++++++++ integration_tests/commands/async/zset_test.go | 70 +++++++- integration_tests/commands/http/zset_test.go | 107 ++++++++++++ integration_tests/commands/resp/zset_test.go | 75 +++++++++ .../commands/websocket/zset_test.go | 78 +++++++++ internal/eval/commands.go | 13 ++ internal/eval/eval_test.go | 156 ++++++++++++++++++ internal/eval/sortedset/sorted_set.go | 29 ++++ internal/eval/store_eval.go | 56 +++++++ internal/server/cmd_meta.go | 5 + internal/worker/cmd_meta.go | 4 + 11 files changed, 740 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/commands/ZPOPMIN.md create mode 100644 integration_tests/commands/http/zset_test.go create mode 100644 integration_tests/commands/resp/zset_test.go create mode 100644 integration_tests/commands/websocket/zset_test.go diff --git a/docs/src/content/docs/commands/ZPOPMIN.md b/docs/src/content/docs/commands/ZPOPMIN.md new file mode 100644 index 000000000..731460607 --- /dev/null +++ b/docs/src/content/docs/commands/ZPOPMIN.md @@ -0,0 +1,148 @@ +--- +title: ZPOPMIN +description: The `ZPOPMIN` command in DiceDB is used to remove and return the members with the lowest scores from the sorted set data structure at the specified key. If a count is provided, it returns up to that number of members with the lowest scores, removing them from the set. +--- + +The `ZPOPMIN` command in DiceDB is used to remove and return the members with the lowest scores from the sorted set data structure at the specified key. If a count is provided, it returns up to that number of members with the lowest scores, removing them from the set. + +## Syntax + +``` +ZPOPMIN key [count] +``` + +## Parameters + +| Parameter | Description | Type | Required | +|------------|----------------------------------------------------------------------------------------------|---------|----------| +| `key` | The name of the sorted set data structure. If it does not exist, an empty array is returned. | String | Yes | +| `count` | The count argument specifies the maximum number of members to return with the lowest scores. | Integer | No | + +## Return values + +| Condition | Return Value | +|----------------------------------------------------------|------------------------------------------| +| If the key is of valid type and records are present | List of members including their scores | +| If the key does not exist or if the sorted set is empty | `(empty list or set)` | + +## Behaviour + +- The command first checks if the specified key exists. +- If the key does not exist, an empty array is returned. +- If the key exists but is not a sorted set, an error is returned. +- If the `count` argument is specified, up to that number of members with the lowest scores are returned and removed. +- The returned array contains the members and their corresponding scores in the order of lowest to highest. + +## Errors +1. `Wrong type error`: + - Error Message: `(error) WRONGTYPE Operation against a key holding the wrong kind of value` + - Occurs when trying to use the command on a key that is not a sorted set. + +2. `Syntax error`: + - Error Message: `(error) ERROR wrong number of arguments for 'zpopmin' command` + - Occurs when the command syntax is incorrect or missing required parameters. + +3. `Invalid argument type error`: + - Error Message : `(error) ERR value is not an integer or out of range` + - Occurs when the count argument passed to the command is not an integer. + +## Examples + +### Non-Existing Key (without count argument) + +Attempting to pop the member with the lowest score from a non-existent sorted set: + +```bash +127.0.0.1:7379> ZPOPMIN NON_EXISTENT_KEY +(empty array) +``` + +### Existing Key (without count argument) + +Popping the member with the lowest score from an existing sorted set: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 2 member2 3 member3 +(integer) 3 +127.0.0.1:7379> ZPOPMIN myzset +1) 1 "member1" +``` + +### With Count Argument + +Popping multiple members with the lowest scores using the count argument: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 2 member2 3 member3 +(integer) 3 +127.0.0.1:7379> ZPOPMIN myzset 2 +1) 1 "member1" +2) 2 "member2" +``` + +### Count Argument but Multiple Members Have the Same Score + +Popping members when multiple members share the same score: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 1 member2 1 member3 +(integer) 3 +127.0.0.1:7379> ZPOPMIN myzset 2 +1) 1 "member1" +2) 1 "member2" +``` + +### Negative Count Argument + +Attempting to pop members using a negative count argument: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 2 member2 3 member3 +(integer) 3 +127.0.0.1:7379> ZPOPMIN myzset -1 +(empty array) +``` + +### Floating-Point Scores + +Popping members with floating-point scores: + +```bash +127.0.0.1:7379> ZADD myzset 1.5 member1 2.7 member2 3.8 member3 +(integer) 3 +127.0.0.1:7379> ZPOPMIN myzset +1) 1.5 "member1" +``` + +### Wrong number of arguments + +Attempting to pop from a key that is not a sorted set: + +```bash +127.0.0.1:7379> SET stringkey "string_value" +OK +127.0.0.1:7379> ZPOPMIN stringkey +(error) WRONGTYPE Operation against a key holding the wrong kind of value +``` + +### Invalid Count Argument + +Using an invalid (non-integer) count argument: + +```bash +127.0.0.1:7379> ZADD myzset 1 member1 +(integer) 1 +127.0.0.1:7379> ZPOPMIN myzset INCORRECT_COUNT_ARGUMENT +(error) ERR value is not an integer or out of range +``` + +### Wrong Type of Key (without count argument) + +Attempting to pop from a key that is not a sorted set: + +```bash +127.0.0.1:7379> SET stringkey "string_value" +OK +127.0.0.1:7379> ZPOPMIN stringkey +(error) WRONGTYPE Operation against a key holding the wrong kind of value +``` \ No newline at end of file diff --git a/integration_tests/commands/async/zset_test.go b/integration_tests/commands/async/zset_test.go index 9eecd1463..0eaa1dba6 100644 --- a/integration_tests/commands/async/zset_test.go +++ b/integration_tests/commands/async/zset_test.go @@ -1,8 +1,9 @@ package async import ( - "gotest.tools/v3/assert" "testing" + + "gotest.tools/v3/assert" ) func TestZADD(t *testing.T) { @@ -117,3 +118,70 @@ func TestZRANGE(t *testing.T) { }) } } + +func TestZPOPMIN(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + testCases := []TestCase{ + { + name: "ZPOPMIN on non-existing key with/without count argument", + commands: []string{"ZPOPMIN NON_EXISTENT_KEY"}, + expected: []interface{}{[]interface{}{}}, + }, + { + name: "ZPOPMIN with wrong type of key with/without count argument", + commands: []string{"SET stringkey string_value", "ZPOPMIN stringkey", "DEL stringkey"}, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value", int64(1)}, + }, + { + name: "ZPOPMIN on existing key (without count argument)", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1"}, int64(1)}, + }, + { + name: "ZPOPMIN with normal count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 2", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2"}, int64(1)}, + }, + { + name: "ZPOPMIN with count argument but multiple members have the same score", + commands: []string{"ZADD myzset 1 member1 1 member2 1 member3", "ZPOPMIN myzset 2", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "1"}, int64(1)}, + }, + { + name: "ZPOPMIN with negative count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset -1", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{}, int64(1)}, + }, + { + name: "ZPOPMIN with invalid count argument", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset INCORRECT_COUNT_ARGUMENT", "DEL myzset"}, + expected: []interface{}{int64(1), "ERR value is not an integer or out of range", int64(1)}, + }, + { + name: "ZPOPMIN with count argument greater than length of sorted set", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 10", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2", "member3", "3"}, int64(1)}, + }, + { + name: "ZPOPMIN on empty sorted set", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset 1", "ZPOPMIN myzset", "DEL myzset"}, + expected: []interface{}{int64(1), []interface{}{"member1", "1"}, []interface{}{}, int64(1)}, + }, + { + name: "ZPOPMIN with floating-point scores", + commands: []string{"ZADD myzset 1.5 member1 2.7 member2 3.8 member3", "ZPOPMIN myzset", "DEL myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1.5"}, int64(1)}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + result := FireCommand(conn, cmd) + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/http/zset_test.go b/integration_tests/commands/http/zset_test.go new file mode 100644 index 000000000..9e0bba116 --- /dev/null +++ b/integration_tests/commands/http/zset_test.go @@ -0,0 +1,107 @@ +package http + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestZPOPMIN(t *testing.T) { + exec := NewHTTPCommandExecutor() + testCases := []TestCase{ + { + name: "ZPOPMIN on non-existing key with/without count argument", + commands: []HTTPCommand{ + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "NON_EXISTENT_KEY"}}, + }, + expected: []interface{}{[]interface{}{}}, + }, + { + name: "ZPOPMIN with wrong type of key with/without count argument", + commands: []HTTPCommand{ + {Command: "SET", Body: map[string]interface{}{"key": "stringkey", "value": "string_value"}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "stringkey"}}, + }, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value", float64(1)}, + }, + { + name: "ZPOPMIN on existing key (without count argument)", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1", "2", "member2", "3", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset"}}, + }, + expected: []interface{}{float64(3), []interface{}{"member1", "1"}, float64(1)}, + }, + { + name: "ZPOPMIN with normal count argument", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1", "2", "member2", "3", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": int64(2)}}, + }, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "2"}, float64(1)}, + }, + { + name: "ZPOPMIN with count argument but multiple members have the same score", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1", "1", "member2", "1", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": int64(2)}}, + }, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "1"}, float64(1)}, + }, + { + name: "ZPOPMIN with negative count argument", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1", "2", "member2", "3", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": int64(-1)}}, + }, + expected: []interface{}{float64(3), []interface{}{}, float64(1)}, + }, + { + name: "ZPOPMIN with invalid count argument", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": "INCORRECT_COUNT_ARGUMENT"}}, + }, + expected: []interface{}{float64(1), "ERR value is not an integer or out of range", float64(1)}, + }, + { + name: "ZPOPMIN with count argument greater than length of sorted set", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1", "2", "member2", "3", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": int64(10)}}, + }, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "2", "member3", "3"}, float64(1)}, + }, + { + name: "ZPOPMIN on empty sorted set", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1", "member1"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset", "value": int64(1)}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset"}}, + }, + expected: []interface{}{float64(1), []interface{}{"member1", "1"}, []interface{}{}, float64(1)}, + }, + { + name: "ZPOPMIN with floating-point scores", + commands: []HTTPCommand{ + {Command: "ZADD", Body: map[string]interface{}{"key": "myzset", "values": [...]string{"1.5", "member1", "2.7", "member2", "3.8", "member3"}}}, + {Command: "ZPOPMIN", Body: map[string]interface{}{"key": "myzset"}}, + }, + expected: []interface{}{float64(3), []interface{}{"member1", "1.5"}, float64(1)}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exec.FireCommand(HTTPCommand{ + Command: "DEL", + Body: map[string]interface{}{"key": "myzset"}, + }) + for i, cmd := range tc.commands { + result, _ := exec.FireCommand(cmd) + + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/resp/zset_test.go b/integration_tests/commands/resp/zset_test.go new file mode 100644 index 000000000..d4ab38bf2 --- /dev/null +++ b/integration_tests/commands/resp/zset_test.go @@ -0,0 +1,75 @@ +package resp + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestZPOPMIN(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + testCases := []TestCase{ + { + name: "ZPOPMIN on non-existing key with/without count argument", + commands: []string{"ZPOPMIN NON_EXISTENT_KEY"}, + expected: []interface{}{[]interface{}{}}, + }, + { + name: "ZPOPMIN with wrong type of key with/without count argument", + commands: []string{"SET stringkey string_value", "ZPOPMIN stringkey", "DEL stringkey"}, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value", int64(1)}, + }, + { + name: "ZPOPMIN on existing key (without count argument)", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1"}}, + }, + { + name: "ZPOPMIN with normal count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 2"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2"}}, + }, + { + name: "ZPOPMIN with count argument but multiple members have the same score", + commands: []string{"ZADD myzset 1 member1 1 member2 1 member3", "ZPOPMIN myzset 2"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "1"}}, + }, + { + name: "ZPOPMIN with negative count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset -1"}, + expected: []interface{}{int64(3), []interface{}{}}, + }, + { + name: "ZPOPMIN with invalid count argument", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset INCORRECT_COUNT_ARGUMENT"}, + expected: []interface{}{int64(1), "ERR value is not an integer or out of range"}, + }, + { + name: "ZPOPMIN with count argument greater than length of sorted set", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 10"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1", "member2", "2", "member3", "3"}}, + }, + { + name: "ZPOPMIN on empty sorted set", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset 1", "ZPOPMIN myzset"}, + expected: []interface{}{int64(1), []interface{}{"member1", "1"}, []interface{}{}}, + }, + { + name: "ZPOPMIN with floating-point scores", + commands: []string{"ZADD myzset 1.5 member1 2.7 member2 3.8 member3", "ZPOPMIN myzset"}, + expected: []interface{}{int64(3), []interface{}{"member1", "1.5"}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + FireCommand(conn, "DEL myzset") + for i, cmd := range tc.commands { + result := FireCommand(conn, cmd) + assert.DeepEqual(t, tc.expected[i], result) + } + }) + } +} diff --git a/integration_tests/commands/websocket/zset_test.go b/integration_tests/commands/websocket/zset_test.go new file mode 100644 index 000000000..a36133c4c --- /dev/null +++ b/integration_tests/commands/websocket/zset_test.go @@ -0,0 +1,78 @@ +package websocket + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZPOPMIN(t *testing.T) { + exec := NewWebsocketCommandExecutor() + + testCases := []TestCase{ + { + name: "ZPOPMIN on non-existing key with/without count argument", + commands: []string{"ZPOPMIN NON_EXISTENT_KEY"}, + expected: []interface{}{[]interface{}{}}, + }, + { + name: "ZPOPMIN with wrong type of key with/without count argument", + commands: []string{"SET stringkey string_value", "ZPOPMIN stringkey", "DEL stringkey"}, + expected: []interface{}{"OK", "WRONGTYPE Operation against a key holding the wrong kind of value", float64(1)}, + }, + { + name: "ZPOPMIN on existing key (without count argument)", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset"}, + expected: []interface{}{float64(3), []interface{}{"member1", "1"}}, + }, + { + name: "ZPOPMIN with normal count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 2"}, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "2"}}, + }, + { + name: "ZPOPMIN with count argument but multiple members have the same score", + commands: []string{"ZADD myzset 1 member1 1 member2 1 member3", "ZPOPMIN myzset 2"}, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "1"}}, + }, + { + name: "ZPOPMIN with negative count argument", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset -1"}, + expected: []interface{}{float64(3), []interface{}{}}, + }, + { + name: "ZPOPMIN with invalid count argument", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset INCORRECT_COUNT_ARGUMENT"}, + expected: []interface{}{float64(1), "ERR value is not an integer or out of range"}, + }, + { + name: "ZPOPMIN with count argument greater than length of sorted set", + commands: []string{"ZADD myzset 1 member1 2 member2 3 member3", "ZPOPMIN myzset 10"}, + expected: []interface{}{float64(3), []interface{}{"member1", "1", "member2", "2", "member3", "3"}}, + }, + { + name: "ZPOPMIN on empty sorted set", + commands: []string{"ZADD myzset 1 member1", "ZPOPMIN myzset 1", "ZPOPMIN myzset"}, + expected: []interface{}{float64(1), []interface{}{"member1", "1"}, []interface{}{}}, + }, + { + name: "ZPOPMIN with floating-point scores", + commands: []string{"ZADD myzset 1.5 member1 2.7 member2 3.8 member3", "ZPOPMIN myzset"}, + expected: []interface{}{float64(3), []interface{}{"member1", "1.5"}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + conn := exec.ConnectToServer() + + DeleteKey(t, conn, exec, "myzset") + + for i, cmd := range tc.commands { + result, err := exec.FireCommandAndReadResponse(conn, cmd) + assert.Nil(t, err) + assert.Equal(t, tc.expected[i], result) + } + }) + } +} diff --git a/internal/eval/commands.go b/internal/eval/commands.go index c6414ca06..8c913cdff 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -1041,6 +1041,18 @@ var ( IsMigrated: true, NewEval: evalZRANGE, } + zpopminCmdMeta = DiceCmdMeta{ + Name: "ZPOPMIN", + Info: `ZPOPMIN key [count] + Removes and returns the member with the lowest score from the sorted set at the specified key. + If multiple members have the same score, the one that comes first alphabetically is returned. + You can also specify a count to remove and return multiple members at once. + If the set is empty, it returns an empty result.`, + Arity: 2, + KeySpecs: KeySpecs{BeginIndex: 1}, + IsMigrated: true, + NewEval: evalZPOPMIN, + } bitfieldCmdMeta = DiceCmdMeta{ Name: "BITFIELD", Info: `The command treats a string as an array of bits as well as bytearray data structure, @@ -1230,6 +1242,7 @@ func init() { DiceCmds["TYPE"] = typeCmdMeta DiceCmds["ZADD"] = zaddCmdMeta DiceCmds["ZRANGE"] = zrangeCmdMeta + DiceCmds["ZPOPMIN"] = zpopminCmdMeta DiceCmds["JSON.STRAPPEND"] = jsonstrappendCmdMeta } diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 472fce0cb..86f733fb3 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/dicedb/dice/internal/eval/sortedset" "github.com/dicedb/dice/internal/server/utils" "github.com/bytedance/sonic" @@ -108,6 +109,7 @@ func TestEval(t *testing.T) { testEvalHRANDFIELD(t, store) testEvalZADD(t, store) testEvalZRANGE(t, store) + testEvalZPOPMIN(t, store) testEvalHVALS(t, store) testEvalBitField(t, store) testEvalHINCRBYFLOAT(t, store) @@ -5779,6 +5781,160 @@ func testEvalZRANGE(t *testing.T, store *dstore.Store) { runMigratedEvalTests(t, tests, evalZRANGE, store) } +func testEvalZPOPMIN(t *testing.T, store *dstore.Store) { + tests := map[string]evalTestCase{ + "ZPOPMIN on non-existing key with/without count argument": { + input: []string{"NON_EXISTING_KEY"}, + migratedOutput: EvalResponse{ + Result: []string{}, + Error: nil, + }, + }, + "ZPOPMIN with wrong type of key with/without count argument": { + setup: func() { + store.Put("mystring", store.NewObj("string_value", -1, object.ObjTypeString, object.ObjEncodingRaw)) + }, + input: []string{"mystring", "1"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrWrongTypeOperation, + }, + }, + "ZPOPMIN on existing key (without count argument)": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2"}, store) + }, + input: []string{"myzset"}, + migratedOutput: EvalResponse{ + Result: []string{"1", "member1"}, + Error: nil, + }, + }, + "ZPOPMIN with normal count argument": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "2"}, + migratedOutput: EvalResponse{ + Result: []string{"1", "member1", "2", "member2"}, + Error: nil, + }, + }, + "ZPOPMIN with count argument but multiple members have the same score": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "1", "member2", "1", "member3"}, store) + }, + input: []string{"myzset", "2"}, + migratedOutput: EvalResponse{ + Result: []string{"1", "member1", "1", "member2"}, + Error: nil, + }, + }, + "ZPOPMIN with negative count argument": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3"}, store) + }, + input: []string{"myzset", "-1"}, + migratedOutput: EvalResponse{ + Result: []string{}, + Error: nil, + }, + }, + "ZPOPMIN with invalid count argument": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1"}, store) + }, + input: []string{"myzset", "INCORRECT_COUNT_ARGUMENT"}, + migratedOutput: EvalResponse{ + Result: nil, + Error: diceerrors.ErrIntegerOutOfRange, + }, + }, + "ZPOPMIN with count argument greater than length of sorted set": { + setup: func() { + evalZADD([]string{"myzset", "1", "member1", "2", "member2"}, store) + }, + input: []string{"myzset", "10"}, + migratedOutput: EvalResponse{ + Result: []string{"1", "member1", "2", "member2"}, + Error: nil, + }, + }, + "ZPOPMIN on empty sorted set": { + setup: func() { + store.Put("myzset", store.NewObj(sortedset.New(), -1, object.ObjTypeSortedSet, object.ObjEncodingBTree)) // Ensure the set exists but is empty + }, + input: []string{"myzset"}, + migratedOutput: EvalResponse{ + Result: []string{}, + Error: nil, + }, + }, + "ZPOPMIN with floating-point scores": { + setup: func() { + evalZADD([]string{"myzset", "1.5", "member1", "2.7", "member2"}, store) + }, + input: []string{"myzset"}, + migratedOutput: EvalResponse{ + Result: []string{"1.5", "member1"}, + Error: nil, + }, + }, + } + + runMigratedEvalTests(t, tests, evalZPOPMIN, store) +} + +func BenchmarkEvalZPOPMIN(b *testing.B) { + // Define benchmark cases with varying sizes of sorted sets + benchmarks := []struct { + name string + setup func(store *dstore.Store) + input []string + }{ + { + name: "ZPOPMIN on small sorted set (10 members)", + setup: func(store *dstore.Store) { + evalZADD([]string{"myzset", "1", "member1", "2", "member2", "3", "member3", "4", "member4", "5", "member5", "6", "member6", "7", "member7", "8", "member8", "9", "member9", "10", "member10"}, store) + }, + input: []string{"myzset", "3"}, + }, + { + name: "ZPOPMIN on large sorted set (10000 members)", + setup: func(store *dstore.Store) { + args := []string{"myzset"} + for i := 1; i <= 10000; i++ { + args = append(args, fmt.Sprintf("%d", i), fmt.Sprintf("member%d", i)) + } + evalZADD(args, store) + }, + input: []string{"myzset", "10"}, + }, + { + name: "ZPOPMIN with duplicate scores", + setup: func(store *dstore.Store) { + evalZADD([]string{"myzset", "1", "member1", "1", "member2", "1", "member3"}, store) + }, + input: []string{"myzset", "2"}, + }, + } + + store := dstore.NewStore(nil, nil) + + for _, bm := range benchmarks { + b.Run(bm.name, func(b *testing.B) { + bm.setup(store) + + for i := 0; i < b.N; i++ { + // Reset the store before each run to avoid contamination + dstore.ResetStore(store) + bm.setup(store) + evalZPOPMIN(bm.input, store) + } + }) + } +} + func testEvalBitField(t *testing.T, store *dstore.Store) { testCases := map[string]evalTestCase{ "BITFIELD signed SET": { diff --git a/internal/eval/sortedset/sorted_set.go b/internal/eval/sortedset/sorted_set.go index dac62be9d..ee38dc72b 100644 --- a/internal/eval/sortedset/sorted_set.go +++ b/internal/eval/sortedset/sorted_set.go @@ -142,6 +142,35 @@ func (ss *Set) GetRange( return result } +// GetMin returns the first 'count' key-value pairs (member and score) with the minimum scores +// and removes those items from the sorted set. +func (ss *Set) GetMin(count int) []string { + // Initialize the result slice to hold the key-value pairs (member and score). + result := []string{} + + // Iterate 'count' times to get the minimum items. + for i := 0; i < count; i++ { + // Delete the minimum item from the tree and get the item. If the tree is empty, this returns nil. + minItem := ss.tree.DeleteMin() + if minItem == nil { + break // Exit if the tree is empty before reaching the desired count. + } + + // Cast the btree.Item to *Item. + ssi := minItem.(*Item) + + // Add member and score to the result. + result = append(result, ssi.Member) + scoreStr := strings.ToLower(strconv.FormatFloat(ssi.Score, 'g', -1, 64)) + result = append(result, scoreStr) + + // Remove the item from the member map. + delete(ss.memberMap, ssi.Member) + } + + return result +} + func (ss *Set) Get(member string) (float64, bool) { score, exists := ss.memberMap[member] return score, exists diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 661534205..11df4b2b6 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -910,3 +910,59 @@ func evalPFMERGE(args []string, store *dstore.Store) *EvalResponse { Error: nil, } } + +// ZPOPMIN Removes and returns the member with the lowest score from the sorted set at the specified key. +// If multiple members have the same score, the one that comes first alphabetically is returned. +// You can also specify a count to remove and return multiple members at once. +// If the set is empty, it returns an empty result. +func evalZPOPMIN(args []string, store *dstore.Store) *EvalResponse { + // Incorrect number of arguments should return error + if len(args) < 1 || len(args) > 2 { + return &EvalResponse{ + Result: clientio.NIL, + Error: diceerrors.ErrWrongArgumentCount("ZPOPMIN"), + } + } + + key := args[0] // Key argument + obj := store.Get(key) // Getting sortedSet object from store + + // If the sortedSet is nil, return an empty list + if obj == nil { + return &EvalResponse{ + Result: []string{}, + Error: nil, + } + } + + sortedSet, err := sortedset.FromObject(obj) + if err != nil { + return &EvalResponse{ + Result: clientio.NIL, + Error: diceerrors.ErrWrongTypeOperation, + } + } + + count := 1 + // Check if the count argument is provided. + if len(args) == 2 { + countArg, err := strconv.Atoi(args[1]) + if err != nil { + // Return an error if the argument is not a valid integer + return &EvalResponse{ + Result: clientio.NIL, + Error: diceerrors.ErrIntegerOutOfRange, + } + } + count = countArg + } + + // If the count argument is present, return all the members with lowest score sorted in ascending order. + // If there are multiple lowest scores with same score value, it sorts the members in lexographical order of member name + results := sortedSet.GetMin(count) + + return &EvalResponse{ + Result: results, + Error: nil, + } +} diff --git a/internal/server/cmd_meta.go b/internal/server/cmd_meta.go index 9d745dd37..81be12ce7 100644 --- a/internal/server/cmd_meta.go +++ b/internal/server/cmd_meta.go @@ -70,6 +70,10 @@ var ( Cmd: "ZRANGE", CmdType: SingleShard, } + zpopminCmdMeta = CmdsMeta{ + Cmd: "ZPOPMIN", + CmdType: SingleShard, + } pfaddCmdMeta = CmdsMeta{ Cmd: "PFADD", CmdType: SingleShard, @@ -120,6 +124,7 @@ func init() { WorkerCmdsMeta["ZADD"] = zaddCmdMeta WorkerCmdsMeta["ZRANGE"] = zrangeCmdMeta WorkerCmdsMeta["PFADD"] = pfaddCmdMeta + WorkerCmdsMeta["ZPOPMIN"] = zpopminCmdMeta WorkerCmdsMeta["PFCOUNT"] = pfcountCmdMeta WorkerCmdsMeta["PFMERGE"] = pfmergeCmdMeta // Additional commands (multishard, custom) can be added here as needed. diff --git a/internal/worker/cmd_meta.go b/internal/worker/cmd_meta.go index 7f405b739..535c3fc00 100644 --- a/internal/worker/cmd_meta.go +++ b/internal/worker/cmd_meta.go @@ -64,6 +64,7 @@ const ( const ( CmdGetWatch = "GET.WATCH" CmdZRangeWatch = "ZRANGE.WATCH" + CmdZPopMin = "ZPOPMIN" CmdJSONClear = "JSON.CLEAR" CmdJSONStrlen = "JSON.STRLEN" CmdJSONObjlen = "JSON.OBJLEN" @@ -190,6 +191,9 @@ var CommandsMeta = map[string]CmdMeta{ CmdZRange: { CmdType: SingleShard, }, + CmdZPopMin: { + CmdType: SingleShard, + }, } func init() {