Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,4 @@ See the [Usage Guide](./docs/usage.md) for installation, connecting, and a walkt
| [DEL](./docs/del.md) | Delete one or more keys |
| [EXPIRE](./docs/expire.md) | Set a TTL on an existing key (in seconds) |
| [KEYS](./docs/keys.md) | Find keys matching a pattern |
| [CONFIG](./docs/config.md) | Read and modify server configuration |
61 changes: 61 additions & 0 deletions cmd/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cmd

import (
"HelixDB/common"
"HelixDB/db"
"errors"
)

var ErrUnknownSubcommand = errors.New("unknown subcommand")
var ErrUnknownConfigParam = errors.New("unknown config parameter")
var ErrInvalidConfigValue = errors.New("invalid config value")

// ConfigCmd handles the CONFIG command.
// Supported subcommands: GET, SET.
func ConfigCmd(command common.Cmd) ([]byte, error) {
if len(command.Args) < 1 {
err := common.WrongNumberOfArgsError(command.Name)
return common.RespError(err.Error()), err
}

subcommand := command.Args[0]

switch subcommand {
case "GET":
return configGet(command)
case "SET":
return configSet(command)
default:
return common.RespError("unknown subcommand '" + subcommand + "' for 'config' command"), ErrUnknownSubcommand
}
}

// configGet returns the current value of the requested config parameter.
// Usage: CONFIG GET <parameter>
func configGet(command common.Cmd) ([]byte, error) {
if len(command.Args) != 2 {
err := common.WrongNumberOfArgsError(command.Name)
return common.RespError(err.Error()), err
}
param := command.Args[1]
value, ok := db.ServerConfig.Get(param)
if !ok {
return common.RespError("unknown config parameter '"+param+"'"), ErrUnknownConfigParam
}
// Return as a two-element array: [parameter, value]
return common.RespBulkStringArray([]string{param, value}), nil
}

// configSet updates the value of the named config parameter.
// Usage: CONFIG SET <parameter> <value>
func configSet(command common.Cmd) ([]byte, error) {
if len(command.Args) != 3 {
err := common.WrongNumberOfArgsError(command.Name)
return common.RespError(err.Error()), err
}
param, value := command.Args[1], command.Args[2]
if ok := db.ServerConfig.Set(param, value); !ok {
return common.RespError("invalid value for config parameter '"+param+"'"), ErrInvalidConfigValue
}
return []byte("+OK" + common.Terminator), nil
}
81 changes: 81 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package cmd

import (
"HelixDB/common"
"HelixDB/db"
"errors"
"reflect"
"testing"
)

func TestConfigGet(t *testing.T) {
// Reset config to defaults before testing
db.ServerConfig.Set("hz", "1")
db.ServerConfig.Set("active-expire-enabled", "yes")
db.ServerConfig.Set("maxmemory", "0")

tests := []struct {
command common.Cmd
want []byte
wantErr error
}{
// Valid GET
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "hz"}}, common.RespBulkStringArray([]string{"hz", "1"}), nil},
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "active-expire-enabled"}}, common.RespBulkStringArray([]string{"active-expire-enabled", "yes"}), nil},
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "maxmemory"}}, common.RespBulkStringArray([]string{"maxmemory", "0"}), nil},
// Unknown parameter
{common.Cmd{Name: "CONFIG", Args: []string{"GET", "unknown"}}, common.RespError("unknown config parameter 'unknown'"), ErrUnknownConfigParam},
// Wrong number of arguments
{common.Cmd{Name: "CONFIG", Args: []string{"GET"}}, common.RespError("wrong number of arguments for 'config' command"), common.ErrWrongNumberOfArgs},
}
for _, test := range tests {
got, gotErr := ConfigCmd(test.command)
if !reflect.DeepEqual(got, test.want) || !errors.Is(gotErr, test.wantErr) {
t.Errorf("ConfigCmd(%v) = %v, %v; want %v, %v", test.command, got, gotErr, test.want, test.wantErr)
}
}
}

func TestConfigSet(t *testing.T) {
tests := []struct {
command common.Cmd
want []byte
wantErr error
}{
// Valid SET
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "10"}}, []byte("+OK\r\n"), nil},
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "active-expire-enabled", "no"}}, []byte("+OK\r\n"), nil},
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "maxmemory", "1073741824"}}, []byte("+OK\r\n"), nil},
// Invalid value
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "abc"}}, common.RespError("invalid value for config parameter 'hz'"), ErrInvalidConfigValue},
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "0"}}, common.RespError("invalid value for config parameter 'hz'"), ErrInvalidConfigValue},
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "active-expire-enabled", "maybe"}}, common.RespError("invalid value for config parameter 'active-expire-enabled'"), ErrInvalidConfigValue},
// Unknown parameter
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "unknown", "value"}}, common.RespError("invalid value for config parameter 'unknown'"), ErrInvalidConfigValue},
// Wrong number of arguments
{common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz"}}, common.RespError("wrong number of arguments for 'config' command"), common.ErrWrongNumberOfArgs},
}
for _, test := range tests {
got, gotErr := ConfigCmd(test.command)
if !reflect.DeepEqual(got, test.want) || !errors.Is(gotErr, test.wantErr) {
t.Errorf("ConfigCmd(%v) = %v, %v; want %v, %v", test.command, got, gotErr, test.want, test.wantErr)
}
}
}

func TestConfigUnknownSubcommand(t *testing.T) {
got, gotErr := ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"REWRITE"}})
if !reflect.DeepEqual(got, common.RespError("unknown subcommand 'REWRITE' for 'config' command")) || !errors.Is(gotErr, ErrUnknownSubcommand) {
t.Errorf("ConfigCmd REWRITE = %v, %v; want error response, ErrUnknownSubcommand", got, gotErr)
}
}

func TestConfigSetThenGet(t *testing.T) {
// Set hz to 5 and verify GET reflects it
ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"SET", "hz", "5"}})
got, _ := ConfigCmd(common.Cmd{Name: "CONFIG", Args: []string{"GET", "hz"}})
want := common.RespBulkStringArray([]string{"hz", "5"})
if !reflect.DeepEqual(got, want) {
t.Errorf("CONFIG SET then GET: got %v, want %v", got, want)
}
}
1 change: 1 addition & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const Set = "SET"
const Expire = "EXPIRE"
const Del = "DEL"
const Keys = "KEYS"
const Config = "CONFIG"

// Command Args
// Expiration Args
Expand Down
86 changes: 86 additions & 0 deletions db/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package db

import (
"sync"
"time"
)

// Config holds runtime configuration for HelixDB.
// All fields are safe for concurrent access via the embedded mutex.
type Config struct {
mu sync.RWMutex

// Hz controls how many times per second the active expiry job runs.
// Maps to the cleanup interval: interval = 1s / Hz.
Hz int

// ActiveExpireEnabled controls whether the active expiry background job runs.
ActiveExpireEnabled bool

// MaxMemory is the maximum memory limit in bytes. 0 means unlimited.
MaxMemory int64
}

// ServerConfig is the package-level config instance used across the server.
var ServerConfig = &Config{
Hz: 1,
ActiveExpireEnabled: true,
MaxMemory: 0,
}

// CleanupInterval returns the active expiry ticker interval derived from Hz.
func (c *Config) CleanupInterval() time.Duration {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Hz <= 0 {
return time.Second
}
return time.Duration(float64(time.Second) / float64(c.Hz))
}

// Get returns the string value of the named config parameter.
// Returns ("", false) if the parameter name is unknown.
func (c *Config) Get(param string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
switch param {
case "hz":
return itoa(c.Hz), true
case "active-expire-enabled":
return boolToYesNo(c.ActiveExpireEnabled), true
case "maxmemory":
return itoa64(c.MaxMemory), true
}
return "", false
}

// Set updates the named config parameter from a string value.
// Returns false if the parameter name is unknown or the value is invalid.
func (c *Config) Set(param, value string) bool {
c.mu.Lock()
defer c.mu.Unlock()
switch param {
case "hz":
n, ok := parseInt(value)
if !ok || n <= 0 {
return false
}
c.Hz = n
return true
case "active-expire-enabled":
b, ok := yesNoToBool(value)
if !ok {
return false
}
c.ActiveExpireEnabled = b
return true
case "maxmemory":
n, ok := parseInt64(value)
if !ok || n < 0 {
return false
}
c.MaxMemory = n
return true
}
return false
}
40 changes: 40 additions & 0 deletions db/config_helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package db

import (
"strconv"
)

func itoa(n int) string {
return strconv.Itoa(n)
}

func itoa64(n int64) string {
return strconv.FormatInt(n, 10)
}

func parseInt(s string) (int, bool) {
n, err := strconv.Atoi(s)
return n, err == nil
}

func parseInt64(s string) (int64, bool) {
n, err := strconv.ParseInt(s, 10, 64)
return n, err == nil
}

func boolToYesNo(b bool) string {
if b {
return "yes"
}
return "no"
}

func yesNoToBool(s string) (bool, bool) {
switch s {
case "yes":
return true, true
case "no":
return false, true
}
return false, false
}
18 changes: 14 additions & 4 deletions db/expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
)

const (
cleanupInterval = time.Second
chunkSize = 20
expiredRatioThreshold = 0.25
)
Expand All @@ -16,11 +15,22 @@ const (
// If the expired ratio within a chunk exceeds expiredRatioThreshold, it
// immediately runs another round rather than waiting for the next tick —
// this handles bursts of expiring keys efficiently.
// The cleanup interval is derived from ServerConfig.Hz and is re-read on
// each tick, so CONFIG SET hz takes effect without a restart.
func StartActiveExpiry() {
go func() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for range ticker.C {
for {
// A fresh ticker is created on every iteration so that changes to hz
// via CONFIG SET take effect on the next cycle without a restart.
// Reusing a single ticker would lock in the interval set at startup.
interval := ServerConfig.CleanupInterval()
ticker := time.NewTicker(interval)
<-ticker.C
ticker.Stop()

if !ServerConfig.ActiveExpireEnabled {
continue
}
for {
total, expired := cleanExpiredKeys()
if total < chunkSize || float64(expired)/float64(total) < expiredRatioThreshold {
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@
| [DEL](./del.md) | Delete one or more keys |
| [EXPIRE](./expire.md) | Set a TTL on an existing key (in seconds) |
| [KEYS](./keys.md) | Find keys matching a pattern |
| [CONFIG](./config.md) | Read and modify server configuration |
Loading
Loading