- Remove unsed handleClientKill, handleClientKill - Fix misleading error message on rate limit - Eliminated unused channel-to-pattern index that was causing unnecessary work - Fixed O(n²) duplicate check in PublishToPattern - Limit total pattern subscriptions to 10,000 - Limit channel subscriptions per client to 10,000 - Added INFO command - Added graceful shutdown timeout - Validate that log directory is writable during config load - Make SELECT properly handle whitespace around DB index like Redis - Return proper error instead of empty array for unsupported commands on sharded pubsub stubs - More accurate error messages throughout - Updated README.md
207 lines
5.9 KiB
Go
207 lines
5.9 KiB
Go
// @tag config
|
|
// @tag core
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/eleksir/pekoe/internal/logger"
|
|
)
|
|
|
|
// Constants for default values - chosen to be production-ready defaults
|
|
const (
|
|
DefaultBind = "0.0.0.0"
|
|
DefaultPort = 6379
|
|
DefaultMaxClients = 10000
|
|
DefaultReadTimeout = 0 // 0 means no timeout (blocking I/O)
|
|
DefaultWriteTimeout = 0 // 0 means no timeout
|
|
DefaultMaxMsgBytes = 512 * 1024 * 1024 // 512 MB - allows large messages
|
|
DefaultLogLevel = "info"
|
|
DefaultLogFile = "" // empty = stdout only
|
|
DefaultPidFile = "" // empty = no PID file created
|
|
DefaultShutdownTimeout = 30 // 30 seconds grace period for shutdown
|
|
)
|
|
|
|
type Config struct {
|
|
Bind string `json:"bind"`
|
|
Port int `json:"port"`
|
|
MaxClients int `json:"max_clients"`
|
|
ReadTimeout int `json:"read_timeout"` // in seconds, 0 = no timeout
|
|
WriteTimeout int `json:"write_timeout"` // in seconds, 0 = no timeout
|
|
MaxMsgBytes int `json:"max_message_bytes"`
|
|
LogLevel string `json:"log_level"`
|
|
LogFile string `json:"log_file"` // empty = stdout only
|
|
PidFile string `json:"pid_file"` // empty = no PID file
|
|
ShutdownTimeout int `json:"shutdown_timeout"` // in seconds, 0 = wait forever (not recommended)
|
|
}
|
|
|
|
// SetDefaults applies sensible defaults when config values are missing or invalid.
|
|
// This ensures the server can start even with incomplete configuration.
|
|
// nolint: cyclop
|
|
func (c *Config) SetDefaults() {
|
|
if c.Bind == "" {
|
|
c.Bind = DefaultBind
|
|
}
|
|
if c.Port <= 0 || c.Port > 65535 {
|
|
c.Port = DefaultPort
|
|
}
|
|
if c.MaxClients <= 0 {
|
|
c.MaxClients = DefaultMaxClients
|
|
}
|
|
if c.ReadTimeout < 0 {
|
|
c.ReadTimeout = 0
|
|
}
|
|
if c.WriteTimeout < 0 {
|
|
c.WriteTimeout = 0
|
|
}
|
|
if c.MaxMsgBytes <= 0 {
|
|
c.MaxMsgBytes = DefaultMaxMsgBytes
|
|
}
|
|
if c.LogLevel == "" {
|
|
c.LogLevel = DefaultLogLevel
|
|
}
|
|
if c.LogFile == "" {
|
|
c.LogFile = DefaultLogFile
|
|
}
|
|
if c.PidFile == "" {
|
|
c.PidFile = DefaultPidFile
|
|
}
|
|
if c.ShutdownTimeout <= 0 {
|
|
c.ShutdownTimeout = DefaultShutdownTimeout
|
|
}
|
|
}
|
|
|
|
// Validate checks that all configuration values are within acceptable bounds.
|
|
// Returns an error if validation fails, which should prevent server startup.
|
|
func (c *Config) Validate() error {
|
|
if c.Bind == "" {
|
|
return fmt.Errorf("bind address cannot be empty")
|
|
}
|
|
if net.ParseIP(c.Bind) == nil {
|
|
return fmt.Errorf("bind must be a valid IP address (e.g. 0.0.0.0, ::, ::1, 192.168.1.1)")
|
|
}
|
|
if c.Port <= 0 || c.Port > 65535 {
|
|
return fmt.Errorf("port must be between 1 and 65535")
|
|
}
|
|
if c.MaxClients <= 0 {
|
|
return fmt.Errorf("max_clients must be positive")
|
|
}
|
|
if c.MaxMsgBytes <= 0 {
|
|
return fmt.Errorf("max_message_bytes must be positive")
|
|
}
|
|
// Validate log level against allowed values
|
|
validLevels := map[string]bool{
|
|
"debug": true,
|
|
"info": true,
|
|
"warn": true,
|
|
"error": true,
|
|
}
|
|
if !validLevels[c.LogLevel] {
|
|
return fmt.Errorf("invalid log_level: %s (must be debug, info, warn, or error)", c.LogLevel)
|
|
}
|
|
// Validate log file directory is writable
|
|
if c.LogFile != "" {
|
|
dir := filepath.Dir(c.LogFile)
|
|
if err := testWriteDir(dir); err != nil {
|
|
return fmt.Errorf("log_file: %w", err)
|
|
}
|
|
}
|
|
// Validate shutdown timeout is non-negative
|
|
if c.ShutdownTimeout < 0 {
|
|
return fmt.Errorf("shutdown_timeout must be non-negative")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// testWriteDir checks if a directory is writable by trying to create a temp file
|
|
func testWriteDir(dir string) error {
|
|
// If directory doesn't exist, check parent
|
|
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return fmt.Errorf("directory does not exist: %s", dir)
|
|
}
|
|
return testWriteDir(parent)
|
|
}
|
|
// Directory exists - try to create a temp file
|
|
tmp, err := os.CreateTemp(dir, ".pekoe_test_")
|
|
if err != nil {
|
|
return fmt.Errorf("directory not writable: %s", dir)
|
|
}
|
|
_ = tmp.Close()
|
|
_ = os.Remove(tmp.Name())
|
|
return nil
|
|
}
|
|
|
|
// ReadTimeoutDuration converts the config's read timeout (in seconds) to a Duration.
|
|
// Zero means no timeout, allowing the server to block on reads indefinitely.
|
|
func (c *Config) ReadTimeoutDuration() time.Duration {
|
|
if c.ReadTimeout == 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(c.ReadTimeout) * time.Second
|
|
}
|
|
|
|
// WriteTimeoutDuration converts the config's write timeout (in seconds) to a Duration.
|
|
func (c *Config) WriteTimeoutDuration() time.Duration {
|
|
if c.WriteTimeout == 0 {
|
|
return 0
|
|
}
|
|
return time.Duration(c.WriteTimeout) * time.Second
|
|
}
|
|
|
|
// Load reads and parses the configuration from a JSON file.
|
|
// It applies defaults for missing/invalid values and validates the final config.
|
|
func Load(path string) (*Config, error) {
|
|
//nolint: gosec
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
var cfg Config
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
|
}
|
|
|
|
// Apply defaults before validation to ensure minimum viable config
|
|
cfg.SetDefaults()
|
|
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("config validation failed: %w", err)
|
|
}
|
|
|
|
logger.SetLevel(cfg.LogLevel)
|
|
|
|
if cfg.LogFile != "" {
|
|
if err := logger.SetOutput(cfg.LogFile); err != nil {
|
|
return nil, fmt.Errorf("failed to set log file: %w", err)
|
|
}
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// Addr returns the bind address in host:port format for use in net.Listen.
|
|
// IPv6 addresses are wrapped in brackets: [::]:6379.
|
|
func (c *Config) Addr() string {
|
|
ip := net.ParseIP(c.Bind)
|
|
if ip != nil && ip.To4() == nil {
|
|
// IPv6 address — wrap in brackets
|
|
return "[" + c.Bind + "]:" + strconv.Itoa(c.Port)
|
|
}
|
|
return c.Bind + ":" + strconv.Itoa(c.Port)
|
|
}
|
|
|
|
// String returns a JSON representation of the config for debugging.
|
|
func (c *Config) String() string {
|
|
b, _ := json.MarshalIndent(c, "", " ")
|
|
return string(b)
|
|
}
|