Skip to content

Commit

Permalink
#653: Added HSCAN command (#907)
Browse files Browse the repository at this point in the history
  • Loading branch information
manishrw authored Oct 6, 2024
1 parent f0018a2 commit eca9c7f
Show file tree
Hide file tree
Showing 7 changed files with 346 additions and 1 deletion.
73 changes: 73 additions & 0 deletions docs/src/content/docs/commands/HSCAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: HSCAN
description: Documentation for the DiceDB command HSCAN
---

The `HSCAN` command is used to incrementally iterate over the fields of a hash stored at a given key. It returns both the next cursor and the matching fields.

## Syntax

```
HSCAN key cursor [MATCH pattern] [COUNT count]
```

## Parameters

- `key`: The key of the hash to scan.
- `cursor`: The cursor indicating the starting position of the scan.
- `MATCH pattern` (optional): Specifies a pattern to match against the fields. Only the fields that match the pattern will be returned.
- `COUNT count` (optional): Specifies the maximum number of fields to return.

## Return Value

The `HSCAN` command returns an array containing the next cursor and the matching fields. The format of the returned array is `[nextCursor, [field1, value1, field2, value2, ...]]`.

## Behaviour
When the `HSCAN` command is executed:

1. DiceDB checks if the specified key exists.
2. If the key exists and is associated with a hash, DiceDB scans the fields of the hash and returns the next cursor and the matching fields.
3. If the key does not exist, DiceDB returns an empty array.
4. If the key exists but is not associated with a hash, an error is returned.
5. If the key exists and all keys have been scanned, cursor is reset to 0.

## Error handling
The `HSCAN` command can raise the following errors:

- `WRONGTYPE Operation against a key holding the wrong kind of value`: This error occurs if the specified key exists but is not associated with a hash. For example, if the key is associated with a string, list, set, or any other data type, this error will be raised.
- `Invalid integer value for COUNT`: This error occurs if the value provided for the `COUNT` option is not a valid integer or is out of range.



## Examples

```DiceDB
> HSET myhash field1 "value1" field2 "value2"
1) (integer) 2
> HSCAN myhash 0
1) "2"
2) 1) "field1"
2) "value1"
3) "field2"
4) "value2"
> HSCAN myhash 0 MATCH field* COUNT 1
1) "1"
2) 1) "field1"
2) "value1"
> HSCAN myhash 1 MATCH field* COUNT 1
1) "0"
2) 1) "field2"
2) "value2"
```


## Additional Notes

- The `HSCAN` command has a time complexity of O(N), where N is the number of keys in the hash. This is in contrast to Redis, which implements `HSCAN` in O(1) time complexity by maintaining a cursor.
- The `HSCAN` command is particularly useful for iterating over the fields of a hash in a cursor-based manner, allowing for efficient processing of large hashes.
- The `MATCH` pattern allows for flexible filtering of fields based on their names, making it easy to target specific fields or groups of fields.
- The `COUNT` option enables limiting the number of fields returned, which can be beneficial for performance and memory usage considerations.
- The cursor returned by `HSCAN` can be used to resume the scan from the last position, making it suitable for use cases where the scan needs to be interrupted and resumed later.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/dgryski/go-metro v0.0.0-20211217172704-adc40b04c140 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.8 // indirect
github.com/magiconair/properties v1.8.7 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
Expand Down
91 changes: 91 additions & 0 deletions integration_tests/commands/async/hscan_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package async

import (
"testing"

"gotest.tools/v3/assert"
)

var THREE int64 = 3
var FOUR int64 = 4

func TestHSCAN(t *testing.T) {
conn := getLocalConnection()
defer conn.Close()

testCases := []TestCase{
{
commands: []string{"HSCAN empty_hash 0"},
expected: []interface{}{[]interface{}{"0", []interface{}{}}},
},
{
commands: []string{
"HSET test_hash field1 value1 field2 value2 field3 value3",
"HSCAN test_hash 0",
},
expected: []interface{}{
THREE,
[]interface{}{
"0",
[]interface{}{"field1", "value1", "field2", "value2", "field3", "value3"},
},
},
},
{
commands: []string{
"HSET pattern_hash foo1 bar1 foo2 bar2 baz1 qux1 baz2 qux2",
"HSCAN pattern_hash 0 MATCH foo*",
},
expected: []interface{}{
FOUR,
[]interface{}{
"0",
[]interface{}{"foo1", "bar1", "foo2", "bar2"},
},
},
},
{
commands: []string{
"HSET large_hash field1 value1 field2 value2",
"HSCAN large_hash 0 COUNT 2",
},
expected: []interface{}{
TWO,
[]interface{}{"0", []interface{}{"field1", "value1", "field2", "value2"}},
},
},
{
commands: []string{
"SET wrong_type_key string_value",
"HSCAN wrong_type_key 0",
},
expected: []interface{}{
"OK",
"WRONGTYPE Operation against a key holding the wrong kind of value",
},
},
{
commands: []string{"HSCAN"},
expected: []interface{}{"ERR wrong number of arguments for 'hscan' command"},
},
{
commands: []string{
"HSET test_hash1 field1 value1 field2 value2 field3 value3 field4 value4",
"HSCAN test_hash1 0 COUNT 2",
"HSCAN test_hash1 2 COUNT 2",
},
expected: []interface{}{
FOUR,
[]interface{}{"2", []interface{}{"field1", "value1", "field2", "value2"}},
[]interface{}{"0", []interface{}{"field3", "value3", "field4", "value4"}},
},
},
}

for _, tc := range testCases {
for i, cmd := range tc.commands {
result := FireCommand(conn, cmd)
assert.DeepEqual(t, tc.expected[i], result)
}
}
}
11 changes: 11 additions & 0 deletions internal/eval/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,16 @@ var (
Arity: -3,
KeySpecs: KeySpecs{BeginIndex: 1},
}
hscanCmdMeta = DiceCmdMeta{
Name: "HSCAN",
Info: `HSCAN is used to iterate over fields and values of a hash.
It returns a cursor and a list of key-value pairs.
The cursor is used to paginate through the hash.
The command returns a cursor value of 0 when all the elements are iterated.`,
Eval: evalHSCAN,
Arity: -3,
KeySpecs: KeySpecs{BeginIndex: 1},
}
hexistsCmdMeta = DiceCmdMeta{
Name: "HEXISTS",
Info: `Returns if field is an existing field in the hash stored at key.`,
Expand Down Expand Up @@ -1170,6 +1180,7 @@ func init() {
DiceCmds["SETEX"] = setexCmdMeta
DiceCmds["HRANDFIELD"] = hrandfieldCmdMeta
DiceCmds["HDEL"] = hdelCmdMeta
DiceCmds["HSCAN"] = hscanCmdMeta
DiceCmds["HVALS"] = hValsCmdMeta
DiceCmds["APPEND"] = appendCmdMeta
DiceCmds["ZADD"] = zaddCmdMeta
Expand Down
83 changes: 82 additions & 1 deletion internal/eval/eval.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/dicedb/dice/internal/querymanager"
"github.com/dicedb/dice/internal/server/utils"
dstore "github.com/dicedb/dice/internal/store"
"github.com/gobwas/glob"
"github.com/ohler55/ojg/jp"
)

Expand Down Expand Up @@ -67,6 +68,7 @@ const (

const defaultRootPath = "$"
const maxExDuration = 9223372036854775
const CountConst = "COUNT"

func init() {
diceCommandsCount = len(DiceCmds)
Expand Down Expand Up @@ -2598,7 +2600,7 @@ func evalCommandHelp() []byte {
format := "COMMAND <subcommand> [<arg> [value] [opt] ...]. Subcommands are:"
noTitle := "(no subcommand)"
noMessage := " Return details about all Dice commands."
countTitle := "COUNT"
countTitle := CountConst
countMessage := " Return the total number of commands in this Dice server."
listTitle := "LIST"
listMessage := " Return a list of all commands in this Dice server."
Expand Down Expand Up @@ -3242,6 +3244,85 @@ func evalHDEL(args []string, store *dstore.Store) []byte {
return clientio.Encode(count, false)
}

func evalHSCAN(args []string, store *dstore.Store) []byte {
if len(args) < 2 {
return diceerrors.NewErrArity("HSCAN")
}

key := args[0]
cursor, err := strconv.ParseInt(args[1], 10, 64)
if err != nil {
return diceerrors.NewErrWithMessage(diceerrors.InvalidIntErr)
}

obj := store.Get(key)
if obj == nil {
return clientio.Encode([]interface{}{"0", []string{}}, false)
}

if err := object.AssertTypeAndEncoding(obj.TypeEncoding, object.ObjTypeHashMap, object.ObjEncodingHashMap); err != nil {
return diceerrors.NewErrWithMessage(diceerrors.WrongTypeErr)
}

hashMap := obj.Value.(HashMap)
pattern := "*"
count := 10

// Parse optional arguments
for i := 2; i < len(args); i += 2 {
switch strings.ToUpper(args[i]) {
case "MATCH":
if i+1 < len(args) {
pattern = args[i+1]
}
case CountConst:
if i+1 < len(args) {
parsedCount, err := strconv.Atoi(args[i+1])
if err != nil || parsedCount < 1 {
return diceerrors.NewErrWithMessage("value is not an integer or out of range")
}
count = parsedCount
}
}
}

// Note that this implementation has a time complexity of O(N), where N is the number of keys in 'hashMap'.
// This is in contrast to Redis, which implements HSCAN in O(1) time complexity by maintaining a cursor.
keys := make([]string, 0, len(hashMap))
for k := range hashMap {
keys = append(keys, k)
}
sort.Strings(keys)

matched := 0
results := make([]string, 0, count*2)
newCursor := 0

g, err := glob.Compile(pattern)
if err != nil {
return diceerrors.NewErrWithMessage(fmt.Sprintf("Invalid glob pattern: %s", err))
}

// Scan the keys and add them to the results if they match the pattern
for i := int(cursor); i < len(keys); i++ {
if g.Match(keys[i]) {
results = append(results, keys[i], hashMap[keys[i]])
matched++
if matched >= count {
newCursor = i + 1
break
}
}
}

// If we've scanned all keys, reset cursor to 0
if newCursor >= len(keys) {
newCursor = 0
}

return clientio.Encode([]interface{}{strconv.Itoa(newCursor), results}, false)
}

// evalHKEYS returns all the values in the hash stored at key.
func evalHVALS(args []string, store *dstore.Store) []byte {
if len(args) != 1 {
Expand Down
Loading

0 comments on commit eca9c7f

Please sign in to comment.