Skip to content

Commit c996e93

Browse files
committed
feat(cmd): implement CONFIG GET and CONFIG SET
Signed-off-by: Ankush Chavan <cankush625@gmail.com>
1 parent f337232 commit c996e93

10 files changed

Lines changed: 361 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ See the [Usage Guide](./docs/usage.md) for installation, connecting, and a walkt
2424
| [DEL](./docs/del.md) | Delete one or more keys |
2525
| [EXPIRE](./docs/expire.md) | Set a TTL on an existing key (in seconds) |
2626
| [KEYS](./docs/keys.md) | Find keys matching a pattern |
27+
| [CONFIG](./docs/config.md) | Read and modify server configuration |

cmd/config.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package cmd
2+
3+
import (
4+
"HelixDB/common"
5+
"HelixDB/db"
6+
"errors"
7+
)
8+
9+
var ErrUnknownSubcommand = errors.New("unknown subcommand")
10+
var ErrUnknownConfigParam = errors.New("unknown config parameter")
11+
var ErrInvalidConfigValue = errors.New("invalid config value")
12+
13+
// ConfigCmd handles the CONFIG command.
14+
// Supported subcommands: GET, SET.
15+
func ConfigCmd(command common.Cmd) ([]byte, error) {
16+
if len(command.Args) < 1 {
17+
err := common.WrongNumberOfArgsError(command.Name)
18+
return common.RespError(err.Error()), err
19+
}
20+
21+
subcommand := command.Args[0]
22+
23+
switch subcommand {
24+
case "GET":
25+
return configGet(command)
26+
case "SET":
27+
return configSet(command)
28+
default:
29+
return common.RespError("unknown subcommand '" + subcommand + "' for 'config' command"), ErrUnknownSubcommand
30+
}
31+
}
32+
33+
// configGet returns the current value of the requested config parameter.
34+
// Usage: CONFIG GET <parameter>
35+
func configGet(command common.Cmd) ([]byte, error) {
36+
if len(command.Args) != 2 {
37+
err := common.WrongNumberOfArgsError(command.Name)
38+
return common.RespError(err.Error()), err
39+
}
40+
param := command.Args[1]
41+
value, ok := db.ServerConfig.Get(param)
42+
if !ok {
43+
return common.RespError("unknown config parameter '"+param+"'"), ErrUnknownConfigParam
44+
}
45+
// Return as a two-element array: [parameter, value]
46+
return common.RespBulkStringArray([]string{param, value}), nil
47+
}
48+
49+
// configSet updates the value of the named config parameter.
50+
// Usage: CONFIG SET <parameter> <value>
51+
func configSet(command common.Cmd) ([]byte, error) {
52+
if len(command.Args) != 3 {
53+
err := common.WrongNumberOfArgsError(command.Name)
54+
return common.RespError(err.Error()), err
55+
}
56+
param, value := command.Args[1], command.Args[2]
57+
if ok := db.ServerConfig.Set(param, value); !ok {
58+
return common.RespError("invalid value for config parameter '"+param+"'"), ErrInvalidConfigValue
59+
}
60+
return []byte("+OK" + common.Terminator), nil
61+
}

cmd/config_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package cmd
2+
3+
import (
4+
"HelixDB/common"
5+
"HelixDB/db"
6+
"errors"
7+
"reflect"
8+
"testing"
9+
)
10+
11+
func TestConfigGet(t *testing.T) {
12+
// Reset config to defaults before testing
13+
db.ServerConfig.Set("hz", "1")
14+
db.ServerConfig.Set("active-expire-enabled", "yes")
15+
db.ServerConfig.Set("maxmemory", "0")
16+
17+
tests := []struct {
18+
command common.Cmd
19+
want []byte
20+
wantErr error
21+
}{
22+
// Valid GET
23+
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "hz"}}, common.RespBulkStringArray([]string{"hz", "1"}), nil},
24+
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "active-expire-enabled"}}, common.RespBulkStringArray([]string{"active-expire-enabled", "yes"}), nil},
25+
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "maxmemory"}}, common.RespBulkStringArray([]string{"maxmemory", "0"}), nil},
26+
// Unknown parameter
27+
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "unknown"}}, common.RespError("unknown config parameter 'unknown'"), ErrUnknownConfigParam},
28+
// Wrong number of arguments
29+
{common.Cmd{Name: "CONFIG", Args: []string{"GET"}}, common.RespError("wrong number of arguments for 'config' command"), common.ErrWrongNumberOfArgs},
30+
}
31+
for _, test := range tests {
32+
got, gotErr := ConfigCmd(test.command)
33+
if !reflect.DeepEqual(got, test.want) || !errors.Is(gotErr, test.wantErr) {
34+
t.Errorf("ConfigCmd(%v) = %v, %v; want %v, %v", test.command, got, gotErr, test.want, test.wantErr)
35+
}
36+
}
37+
}
38+
39+
func TestConfigSet(t *testing.T) {
40+
tests := []struct {
41+
command common.Cmd
42+
want []byte
43+
wantErr error
44+
}{
45+
// Valid SET
46+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "10"}}, []byte("+OK\r\n"), nil},
47+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "active-expire-enabled", "no"}}, []byte("+OK\r\n"), nil},
48+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "maxmemory", "1073741824"}}, []byte("+OK\r\n"), nil},
49+
// Invalid value
50+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "abc"}}, common.RespError("invalid value for config parameter 'hz'"), ErrInvalidConfigValue},
51+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "0"}}, common.RespError("invalid value for config parameter 'hz'"), ErrInvalidConfigValue},
52+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "active-expire-enabled", "maybe"}}, common.RespError("invalid value for config parameter 'active-expire-enabled'"), ErrInvalidConfigValue},
53+
// Unknown parameter
54+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "unknown", "value"}}, common.RespError("invalid value for config parameter 'unknown'"), ErrInvalidConfigValue},
55+
// Wrong number of arguments
56+
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz"}}, common.RespError("wrong number of arguments for 'config' command"), common.ErrWrongNumberOfArgs},
57+
}
58+
for _, test := range tests {
59+
got, gotErr := ConfigCmd(test.command)
60+
if !reflect.DeepEqual(got, test.want) || !errors.Is(gotErr, test.wantErr) {
61+
t.Errorf("ConfigCmd(%v) = %v, %v; want %v, %v", test.command, got, gotErr, test.want, test.wantErr)
62+
}
63+
}
64+
}
65+
66+
func TestConfigUnknownSubcommand(t *testing.T) {
67+
got, gotErr := ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"REWRITE"}})
68+
if !reflect.DeepEqual(got, common.RespError("unknown subcommand 'REWRITE' for 'config' command")) || !errors.Is(gotErr, ErrUnknownSubcommand) {
69+
t.Errorf("ConfigCmd REWRITE = %v, %v; want error response, ErrUnknownSubcommand", got, gotErr)
70+
}
71+
}
72+
73+
func TestConfigSetThenGet(t *testing.T) {
74+
// Set hz to 5 and verify GET reflects it
75+
ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "5"}})
76+
got, _ := ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"GET", "hz"}})
77+
want := common.RespBulkStringArray([]string{"hz", "5"})
78+
if !reflect.DeepEqual(got, want) {
79+
t.Errorf("CONFIG SET then GET: got %v, want %v", got, want)
80+
}
81+
}

common/constants.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const Set = "SET"
1212
const Expire = "EXPIRE"
1313
const Del = "DEL"
1414
const Keys = "KEYS"
15+
const ConfigCmd = "CONFIG"
1516

1617
// Command Args
1718
// Expiration Args

db/config.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package db
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// Config holds runtime configuration for HelixDB.
9+
// All fields are safe for concurrent access via the embedded mutex.
10+
type Config struct {
11+
mu sync.RWMutex
12+
13+
// Hz controls how many times per second the active expiry job runs.
14+
// Maps to the cleanup interval: interval = 1s / Hz.
15+
Hz int
16+
17+
// ActiveExpireEnabled controls whether the active expiry background job runs.
18+
ActiveExpireEnabled bool
19+
20+
// MaxMemory is the maximum memory limit in bytes. 0 means unlimited.
21+
MaxMemory int64
22+
}
23+
24+
// ServerConfig is the package-level config instance used across the server.
25+
var ServerConfig = &Config{
26+
Hz: 1,
27+
ActiveExpireEnabled: true,
28+
MaxMemory: 0,
29+
}
30+
31+
// CleanupInterval returns the active expiry ticker interval derived from Hz.
32+
func (c *Config) CleanupInterval() time.Duration {
33+
c.mu.RLock()
34+
defer c.mu.RUnlock()
35+
if c.Hz <= 0 {
36+
return time.Second
37+
}
38+
return time.Duration(float64(time.Second) / float64(c.Hz))
39+
}
40+
41+
// Get returns the string value of the named config parameter.
42+
// Returns ("", false) if the parameter name is unknown.
43+
func (c *Config) Get(param string) (string, bool) {
44+
c.mu.RLock()
45+
defer c.mu.RUnlock()
46+
switch param {
47+
case "hz":
48+
return itoa(c.Hz), true
49+
case "active-expire-enabled":
50+
return boolToYesNo(c.ActiveExpireEnabled), true
51+
case "maxmemory":
52+
return itoa64(c.MaxMemory), true
53+
}
54+
return "", false
55+
}
56+
57+
// Set updates the named config parameter from a string value.
58+
// Returns false if the parameter name is unknown or the value is invalid.
59+
func (c *Config) Set(param, value string) bool {
60+
c.mu.Lock()
61+
defer c.mu.Unlock()
62+
switch param {
63+
case "hz":
64+
n, ok := parseInt(value)
65+
if !ok || n <= 0 {
66+
return false
67+
}
68+
c.Hz = n
69+
return true
70+
case "active-expire-enabled":
71+
b, ok := yesNoToBool(value)
72+
if !ok {
73+
return false
74+
}
75+
c.ActiveExpireEnabled = b
76+
return true
77+
case "maxmemory":
78+
n, ok := parseInt64(value)
79+
if !ok || n < 0 {
80+
return false
81+
}
82+
c.MaxMemory = n
83+
return true
84+
}
85+
return false
86+
}

db/config_helpers.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package db
2+
3+
import (
4+
"strconv"
5+
)
6+
7+
func itoa(n int) string {
8+
return strconv.Itoa(n)
9+
}
10+
11+
func itoa64(n int64) string {
12+
return strconv.FormatInt(n, 10)
13+
}
14+
15+
func parseInt(s string) (int, bool) {
16+
n, err := strconv.Atoi(s)
17+
return n, err == nil
18+
}
19+
20+
func parseInt64(s string) (int64, bool) {
21+
n, err := strconv.ParseInt(s, 10, 64)
22+
return n, err == nil
23+
}
24+
25+
func boolToYesNo(b bool) string {
26+
if b {
27+
return "yes"
28+
}
29+
return "no"
30+
}
31+
32+
func yesNoToBool(s string) (bool, bool) {
33+
switch s {
34+
case "yes":
35+
return true, true
36+
case "no":
37+
return false, true
38+
}
39+
return false, false
40+
}

db/expiry.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
)
66

77
const (
8-
cleanupInterval = time.Second
98
chunkSize = 20
109
expiredRatioThreshold = 0.25
1110
)
@@ -16,11 +15,19 @@ const (
1615
// If the expired ratio within a chunk exceeds expiredRatioThreshold, it
1716
// immediately runs another round rather than waiting for the next tick —
1817
// this handles bursts of expiring keys efficiently.
18+
// The cleanup interval is derived from ServerConfig.Hz and is re-read on
19+
// each tick, so CONFIG SET hz takes effect without a restart.
1920
func StartActiveExpiry() {
2021
go func() {
21-
ticker := time.NewTicker(cleanupInterval)
22-
defer ticker.Stop()
23-
for range ticker.C {
22+
for {
23+
interval := ServerConfig.CleanupInterval()
24+
ticker := time.NewTicker(interval)
25+
<-ticker.C
26+
ticker.Stop()
27+
28+
if !ServerConfig.ActiveExpireEnabled {
29+
continue
30+
}
2431
for {
2532
total, expired := cleanExpiredKeys()
2633
if total < chunkSize || float64(expired)/float64(total) < expiredRatioThreshold {

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@
1515
| [DEL](./del.md) | Delete one or more keys |
1616
| [EXPIRE](./expire.md) | Set a TTL on an existing key (in seconds) |
1717
| [KEYS](./keys.md) | Find keys matching a pattern |
18+
| [CONFIG](./config.md) | Read and modify server configuration |

docs/config.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# CONFIG
2+
3+
Read and modify server configuration at runtime without restarting.
4+
5+
## Syntax
6+
7+
```
8+
CONFIG GET parameter
9+
CONFIG SET parameter value
10+
```
11+
12+
## Subcommands
13+
14+
### CONFIG GET
15+
16+
Returns the current value of a configuration parameter.
17+
18+
```
19+
CONFIG GET parameter
20+
```
21+
22+
### CONFIG SET
23+
24+
Updates a configuration parameter at runtime.
25+
26+
```
27+
CONFIG SET parameter value
28+
```
29+
30+
## Supported parameters
31+
32+
| Parameter | Type | Default | Description |
33+
|-------------------------|---------|---------|----------------------------------------------------------|
34+
| `hz` | integer | `1` | Number of active expiry cycles per second. Must be > 0. |
35+
| `active-expire-enabled` | yes/no | `yes` | Enable or disable the active expiry background job. |
36+
| `maxmemory` | integer | `0` | Max memory in bytes. `0` means unlimited. |
37+
38+
## Return value
39+
40+
- `CONFIG GET` — a two-element list: `[parameter, value]`.
41+
- `CONFIG SET``OK` on success.
42+
43+
## Errors
44+
45+
- `wrong number of arguments for 'config' command` — incorrect number of arguments.
46+
- `unknown subcommand '<name>' for 'config' command` — unrecognised subcommand.
47+
- `unknown config parameter '<name>'` — unrecognised parameter name on GET.
48+
- `invalid value for config parameter '<name>'` — value is out of range or wrong type on SET.
49+
50+
## Examples
51+
52+
```
53+
> CONFIG GET hz
54+
hz
55+
1
56+
57+
> CONFIG SET hz 10
58+
OK
59+
60+
> CONFIG GET hz
61+
hz
62+
10
63+
64+
> CONFIG GET active-expire-enabled
65+
active-expire-enabled
66+
yes
67+
68+
> CONFIG SET active-expire-enabled no
69+
OK
70+
71+
> CONFIG GET maxmemory
72+
maxmemory
73+
0
74+
75+
> CONFIG SET maxmemory 1073741824
76+
OK
77+
```

0 commit comments

Comments
 (0)