diff --git a/docs/src/content/docs/commands/RANDOMKEY.md b/docs/src/content/docs/commands/RANDOMKEY.md new file mode 100644 index 000000000..fa12a32fa --- /dev/null +++ b/docs/src/content/docs/commands/RANDOMKEY.md @@ -0,0 +1,59 @@ +--- +title: RANDOMKEY +description: The `RANDOMKEY` command in DiceDB return a random key from the currently selected database. +--- + +The `RANDOMKEY` command in DiceDB is used to return a random key from the currently selected database. + +## Syntax + +``` +RANDOMKEY +``` + +## Parameters + +The `RANDOMKEY` command does not take any parameters. + +## Return values + +| Condition | Return Value | +|-----------------------------------------------|-----------------------------------------------------| +| Command is successful | A random key from the keyspace of selected database | +| Failure to scan keyspace or pick a random key | Error | + +## Behaviour + +- When executed, `RANDOMKEY` fetches the keyspace from currently selected database and picks a random key from it. +- The operation is slow and runs in O(N) time complexity where N is the number of keys in the database. +- The command does not modify the database in any way; it is purely informational. + +## Errors +The `RANDOMKEY` command is straightforward and does not typically result in errors under normal usage. However, since it internally depends on KEYS command, it can fail for the same cases as KEYS. + +## Example Usage + +### Basic Usage + +Getting a random key from the currently selected database: + +```shell +127.0.0.1:7379> RANDOMKEY +"key_6" +``` + +### Using with Multiple Databases + +If you are working with multiple databases, you can switch between them using the `SELECT` command and then use `RANDOMKEY` to get a random key from selected database: + +```shell +127.0.0.1:7379> SELECT 0 +OK +127.0.0.1:7379> RANDOMKEY +"db0_key_54" + +127.0.0.1:7379> SELECT 1 +OK +127.0.0.1:7379> RANDOMKEY +"db1_key_435" +``` diff --git a/integration_tests/commands/http/randomkey_test.go b/integration_tests/commands/http/randomkey_test.go new file mode 100644 index 000000000..5920f08f4 --- /dev/null +++ b/integration_tests/commands/http/randomkey_test.go @@ -0,0 +1,44 @@ +package http + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRandomKey(t *testing.T) { + exec := NewHTTPCommandExecutor() + + testCases := []struct { + name string + commands []HTTPCommand + expected []interface{} + delays []time.Duration + }{ + + { + name: "Random Key", + commands: []HTTPCommand{ + {Command: "FLUSHDB"}, + {Command: "SET", Body: map[string]interface{}{"key": "k1", "value": "v1"}}, + {Command: "RANDOMKEY"}, + }, + expected: []interface{}{"OK", "OK", "k1"}, + delays: []time.Duration{0, 0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.commands { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommand(cmd) + assert.Nil(t, err) + assert.Equal(t, tc.expected[i], result, "Value mismatch for cmd %s", cmd) + } + }) + } +} diff --git a/integration_tests/commands/resp/randomkey_test.go b/integration_tests/commands/resp/randomkey_test.go new file mode 100644 index 000000000..6b57fd552 --- /dev/null +++ b/integration_tests/commands/resp/randomkey_test.go @@ -0,0 +1,43 @@ +package resp + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRandomKey(t *testing.T) { + conn := getLocalConnection() + defer conn.Close() + + testCases := []struct { + name string + cmds []string + expect []interface{} + delays []time.Duration + }{ + { + name: "Random Key", + cmds: []string{ + "FLUSHDB", + "SET k1 v1", + "RANDOMKEY", + }, + expect: []interface{}{"OK", "OK", "k1"}, + delays: []time.Duration{0, 0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.cmds { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result := FireCommand(conn, cmd) + assert.Equal(t, tc.expect[i], result, "Value mismatch for cmd %s", cmd) + } + }) + } +} diff --git a/integration_tests/commands/websocket/randomkey_test.go b/integration_tests/commands/websocket/randomkey_test.go new file mode 100644 index 000000000..1b99cce83 --- /dev/null +++ b/integration_tests/commands/websocket/randomkey_test.go @@ -0,0 +1,44 @@ +package websocket + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestRandomKey(t *testing.T) { + exec := NewWebsocketCommandExecutor() + conn := exec.ConnectToServer() + + testCases := []struct { + name string + cmds []string + expect []interface{} + delays []time.Duration + }{ + { + name: "Random Key", + cmds: []string{ + "FLUSHDB", + "SET k1 v1", + "RANDOMKEY", + }, + expect: []interface{}{"OK", "OK", "k1"}, + delays: []time.Duration{0, 0, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + for i, cmd := range tc.cmds { + if tc.delays[i] > 0 { + time.Sleep(tc.delays[i]) + } + result, err := exec.FireCommandAndReadResponse(conn, cmd) + assert.Nil(t, err) + assert.Equal(t, tc.expect[i], result, "Value mismatch for cmd %s", cmd) + } + }) + } +} diff --git a/internal/eval/commands.go b/internal/eval/commands.go index d3238bdd0..447e1de8b 100644 --- a/internal/eval/commands.go +++ b/internal/eval/commands.go @@ -1355,6 +1355,13 @@ var ( Arity: 4, KeySpecs: KeySpecs{BeginIndex: 1}, } + randomKeyCmdMeta = DiceCmdMeta{ + Name: "RANDOMKEY", + Info: `RANDOMKEY returns a random key from the currently selected database.`, + NewEval: evalRandomKey, + Arity: 1, + IsMigrated: true, + } ) func init() { @@ -1485,6 +1492,7 @@ func init() { DiceCmds["LINSERT"] = linsertCmdMeta DiceCmds["LRANGE"] = lrangeCmdMeta DiceCmds["JSON.ARRINDEX"] = jsonArrIndexCmdMeta + DiceCmds["RANDOMKEY"] = randomKeyCmdMeta DiceCmds["SINGLETOUCH"] = singleTouchCmdMeta DiceCmds["SINGLEDBSIZE"] = singleDBSizeCmdMeta diff --git a/internal/eval/eval_test.go b/internal/eval/eval_test.go index 646a87d10..80d5b366e 100644 --- a/internal/eval/eval_test.go +++ b/internal/eval/eval_test.go @@ -165,6 +165,7 @@ func TestEval(t *testing.T) { testEvalBFEXISTS(t, store) testEvalBFADD(t, store) testEvalJSONARRINDEX(t, store) + testEvalRANDOMKEY(t, store) } func testEvalPING(t *testing.T, store *dstore.Store) { @@ -1606,6 +1607,67 @@ func BenchmarkEvalJSONOBJLEN(b *testing.B) { } } +func testEvalRANDOMKEY(t *testing.T, store *dstore.Store) { + t.Run("invalid no of args", func(t *testing.T) { + response := evalRandomKey([]string{"INVALID_ARG"}, store) + expectedErr := errors.New("ERR wrong number of arguments for 'randomkey' command") + assert.Equal(t, response.Error, expectedErr) + }) + + t.Run("some keys present in db", func(t *testing.T) { + data := map[string]string{ + "EXISTING_KEY": "MOCK_VALUE", + "EXISTING_KEY_2": "MOCK_VALUE_2", + "EXISTING_KEY_3": "MOCK_VALUE_3", + } + + for key, value := range data { + obj := &object.Obj{ + Value: value, + LastAccessedAt: uint32(time.Now().Unix()), + } + store.Put(key, obj) + } + + results := make(map[string]int) + for i := 0; i < 10000; i++ { + result := evalRandomKey([]string{}, store) + + str, ok := result.Result.(string) + assert.True(t, ok) + results[str]++ + } + + for key := range data { + if results[key] == 0 { + t.Errorf("key %s was never returned", key) + } + } + }) +} + +func BenchmarkEvalRANDOMKEY(b *testing.B) { + storeSize := 1000000 + store := dstore.NewStore(nil, nil) + + b.Run(fmt.Sprintf("benchmark_randomkey_with_%d_keys", storeSize), func(b *testing.B) { + for i := 0; i < storeSize; i++ { + obj := &object.Obj{ + Value: i, + } + store.Put(fmt.Sprintf("key%d", i), obj) + } + + b.ResetTimer() + b.ReportAllocs() + + // Benchmark the evalRandomKey function + for i := 0; i < b.N; i++ { + _ = evalRandomKey([]string{}, store) + } + }) +} + func testEvalJSONDEL(t *testing.T, store *dstore.Store) { tests := map[string]evalTestCase{ "JSON.DEL : nil value": { diff --git a/internal/eval/store_eval.go b/internal/eval/store_eval.go index 7d6f2c6db..e031aa5b8 100644 --- a/internal/eval/store_eval.go +++ b/internal/eval/store_eval.go @@ -17,10 +17,12 @@ package eval import ( + "crypto/rand" "encoding/base64" "errors" "fmt" "math" + "math/big" "math/bits" "reflect" "regexp" @@ -7004,6 +7006,37 @@ func evalJSONARRINDEX(args []string, store *dstore.Store) *EvalResponse { return makeEvalResult(arrIndexList) } +// evalRANDOMKEY returns a random key from the currently selected database. +func evalRandomKey(args []string, store *dstore.Store) *EvalResponse { + if len(args) > 0 { + return makeEvalError(diceerrors.ErrWrongArgumentCount("RANDOMKEY")) + } + + availKeys, err := store.Keys("*") + if err != nil { + return makeEvalError(diceerrors.ErrGeneral("could not get keys")) + } + + if len(availKeys) > 0 { + for range len(availKeys) { + randKeyIdx, err := rand.Int(rand.Reader, big.NewInt(int64(len(availKeys)))) + if err != nil { + continue + } + + randKey := availKeys[randKeyIdx.Uint64()] + keyObj := store.Get(randKey) + if keyObj == nil { + continue + } + + return makeEvalResult(randKey) + } + } + + return makeEvalResult(nil) +} + // adjustIndices adjusts the start and stop indices for array traversal. // It handles negative indices and ensures they are within the array bounds. func adjustIndices(start, stop, length int) (adjustedStart, adjustedStop int) { diff --git a/internal/iothread/cmd_compose.go b/internal/iothread/cmd_compose.go index 0836eda15..a6f3e1420 100644 --- a/internal/iothread/cmd_compose.go +++ b/internal/iothread/cmd_compose.go @@ -17,7 +17,10 @@ package iothread import ( + "crypto/rand" + diceerrors "github.com/dicedb/dice/internal/errors" "math" + "math/big" "sort" "github.com/dicedb/dice/internal/clientio" @@ -276,3 +279,21 @@ func composePFMerge(responses ...ops.StoreResponse) interface{} { return clientio.OK } + +func composeRandomKey(responses ...ops.StoreResponse) interface{} { + results := make([]interface{}, 0, len(responses)) + for idx := range responses { + if responses[idx].EvalResponse.Error != nil { + return responses[idx].EvalResponse.Error + } + + results = append(results, responses[idx].EvalResponse.Result) + } + + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(results)))) + if err != nil { + return diceerrors.ErrGeneral("cannot extract random key") + } + + return results[idx.Int64()] +} diff --git a/internal/iothread/cmd_decompose.go b/internal/iothread/cmd_decompose.go index ebf7182ca..6d009d9e2 100644 --- a/internal/iothread/cmd_decompose.go +++ b/internal/iothread/cmd_decompose.go @@ -324,3 +324,21 @@ func decomposeFlushDB(_ context.Context, thread *BaseIOThread, cd *cmd.DiceDBCmd } return decomposedCmds, nil } + +func decomposeRandomKey(_ context.Context, thread *BaseIOThread, cd *cmd.DiceDBCmd) ([]*cmd.DiceDBCmd, error) { + if len(cd.Args) > 0 { + return nil, diceerrors.ErrWrongArgumentCount("RANDOMKEY") + } + + decomposedCmds := make([]*cmd.DiceDBCmd, 0, len(cd.Args)) + for i := uint8(0); i < uint8(thread.shardManager.GetShardCount()); i++ { + decomposedCmds = append(decomposedCmds, + &cmd.DiceDBCmd{ + Cmd: store.RandomKey, + Args: []string{}, + }, + ) + } + + return decomposedCmds, nil +} diff --git a/internal/iothread/cmd_meta.go b/internal/iothread/cmd_meta.go index 9c450fd03..44ddb3466 100644 --- a/internal/iothread/cmd_meta.go +++ b/internal/iothread/cmd_meta.go @@ -185,15 +185,16 @@ const ( // Multi-shard commands. const ( - CmdMset = "MSET" - CmdMget = "MGET" - CmdSInter = "SINTER" - CmdSDiff = "SDIFF" - CmdJSONMget = "JSON.MGET" - CmdKeys = "KEYS" - CmdTouch = "TOUCH" - CmdDBSize = "DBSIZE" - CmdFlushDB = "FLUSHDB" + CmdMset = "MSET" + CmdMget = "MGET" + CmdSInter = "SINTER" + CmdSDiff = "SDIFF" + CmdJSONMget = "JSON.MGET" + CmdKeys = "KEYS" + CmdTouch = "TOUCH" + CmdDBSize = "DBSIZE" + CmdFlushDB = "FLUSHDB" + CmdRandomKey = "RANDOMKEY" ) // Multi-Step-Multi-Shard commands @@ -650,6 +651,11 @@ var CommandsMeta = map[string]CmdMeta{ decomposeCommand: decomposeFlushDB, composeResponse: composeFlushDB, }, + CmdRandomKey: { + CmdType: AllShard, + decomposeCommand: decomposeRandomKey, + composeResponse: composeRandomKey, + }, // Custom commands. CmdAbort: { diff --git a/internal/store/constants.go b/internal/store/constants.go index 1c2e8d95a..99227e0d8 100644 --- a/internal/store/constants.go +++ b/internal/store/constants.go @@ -35,4 +35,5 @@ const ( SingleShardTouch string = "SINGLETOUCH" SingleShardKeys string = "SINGLEKEYS" FlushDB string = "FLUSHDB" + RandomKey string = "RANDOMKEY" )