Bugfixes, add INFO command, graceful shutdown. elimitate dead code

- 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
This commit is contained in:
eleksir
2026-04-03 10:01:29 +03:00
parent 92cd652b0d
commit 488ce1e4d1
9 changed files with 321 additions and 115 deletions

View File

@@ -1,3 +1,5 @@
// @tag server
// @tag core
package server
import (

View File

@@ -4,6 +4,8 @@ package server
import (
"fmt"
"os"
"strconv"
"strings"
"time"
@@ -144,13 +146,13 @@ func (s *Server) handlePubsub(args [][]byte, writer *ConnWriter) error {
case "shardchannels":
// PUBSUB SHARDCHANNELS [pattern] - List active sharded channels
// Pekoe doesn't support sharded pubsub, return empty list
writer.WriteArray(nil)
// Pekoe doesn't support sharded pubsub
writer.WriteError("ERR command not supported in current context")
case "shardnumsub":
// PUBSUB SHARDNUMSUB [shardchannel ...] - Get subscriber count for sharded channels
// Pekoe doesn't support sharded pubsub, return empty array
writer.WriteArray(nil)
// Pekoe doesn't support sharded pubsub
writer.WriteError("ERR command not supported in current context")
case "numpat":
// PUBSUB NUMPAT - Count of unique pattern subscriptions
@@ -365,7 +367,6 @@ func (s *Server) buildClientList(args [][]byte) string {
func (s *Server) handleClientKill(currentClient *pubsub.Client, args [][]byte) (int, error) {
clients := s.hub.GetAllClients()
killed := 0
var notFoundErr error
// Parse arguments
var killID uint64
@@ -456,10 +457,10 @@ func (s *Server) handleClientKill(currentClient *pubsub.Client, args [][]byte) (
shouldKill = c.RemoteAddr() == killAddr
}
// Check MAXAGE
// Check MAXAGE - kill clients that have been connected longer than maxAge
if shouldKill && maxAgeMs > 0 {
age := now.Sub(c.CreatedAt()).Milliseconds()
shouldKill = age >= maxAgeMs
shouldKill = age <= maxAgeMs
}
if shouldKill {
@@ -476,7 +477,7 @@ func (s *Server) handleClientKill(currentClient *pubsub.Client, args [][]byte) (
return 0, fmt.Errorf("No such client")
}
return killed, notFoundErr
return killed, nil
}
// handleSelect processes SELECT command.
@@ -489,9 +490,9 @@ func (s *Server) handleSelect(client *pubsub.Client, args [][]byte, writer *Conn
return nil
}
dbIndexStr := string(args[0])
var dbIndex int
if _, err := fmt.Sscanf(dbIndexStr, "%d", &dbIndex); err != nil {
dbIndexStr := strings.TrimSpace(string(args[0]))
dbIndex, err := strconv.Atoi(dbIndexStr)
if err != nil {
writer.WriteError("ERR invalid DB index")
return nil
}
@@ -506,6 +507,102 @@ func (s *Server) handleSelect(client *pubsub.Client, args [][]byte, writer *Conn
return nil
}
// handleInfo processes INFO command - returns server information.
// Provides Redis-compatible info about the Pekoe server for client compatibility.
func (s *Server) handleInfo(writer *ConnWriter) error {
// Calculate uptime
uptime := int(time.Since(s.startTime).Seconds())
info := "# Server\r\n" +
"redis_version:" + s.version + "\r\n" +
"redis_mode:standalone\r\n" +
"os:linux 5.15.0\r\n" + // Generic Linux version - in reality we'd get this from uname
"arch_bits:64\r\n" +
"multiplexing_api:epoll\r\n" + // Linux default
"atomicvar_api:stdatomic-gcc\r\n" +
"gcc_version:11.2.0\r\n" +
"process_id:" + strconv.Itoa(os.Getpid()) + "\r\n" +
"process_supervised:no\r\n" +
"run_id:" + s.runID + "\r\n" +
"tcp_port:" + strconv.Itoa(s.cfg.Port) + "\r\n" +
"uptime_in_seconds:" + strconv.Itoa(uptime) + "\r\n" +
"uptime_in_days:" + strconv.Itoa(uptime/(3600*24)) + "\r\n" +
"hz:10\r\n" + // Default Redis hz
"configured_hz:10\r\n" +
"lru_clock:0\r\n" + // Not applicable for pubsub-only
"\r\n" +
"# Clients\r\n" +
"connected_clients:" + strconv.Itoa(s.hub.ClientCount()) + "\r\n" +
"client_recent_max_input_buffer:0\r\n" +
"client_recent_max_output_buffer:0\r\n" +
"blocked_clients:0\r\n" +
"tracking_clients:0\r\n" +
"pubsub_clients:" + strconv.Itoa(s.hub.ClientCount()) + "\r\n" + // Approximation: all clients are pubsub
"watching_clients:0\r\n" +
"\r\n" +
"# Memory\r\n" +
"used_memory:0\r\n" +
"used_memory_human:0B\r\n" +
"used_memory_rss:0\r\n" +
"used_memory_peak:0\r\n" +
"used_memory_lua:0\r\n" + // Not applicable
"\r\n" +
"# Persistence\r\n" +
"loading:0\r\n" +
"rdb_changes_since_last_save:0\r\n" +
"rdb_bgsave_in_progress:0\r\n" +
"rdb_last_save_time:0\r\n" +
"rdb_last_bgsave_status:ok\r\n" +
"rdb_last_bgsave_time_sec:0\r\n" +
"rdb_current_bgsave_time_sec:-1\r\n" +
"aof_enabled:0\r\n" +
"aof_rewrite_in_progress:0\r\n" +
"aof_rewrite_scheduled:0\r\n" +
"aof_last_rewrite_time_sec:0\r\n" +
"aof_last_bgrewrite_status:ok\r\n" +
"aof_last_write_status:ok\r\n" +
"\r\n" +
"# Stats\r\n" +
"total_connections_received:0\r\n" + // Would need to track this
"total_commands_processed:0\r\n" + // Would need to track this
"instantaneous_ops_per_sec:0\r\n" +
"total_net_input_bytes:0\r\n" + // Would need to track
"total_net_output_bytes:0\r\n" + // Would need to track
"instantaneous_input_kbps:0\r\n" +
"instantaneous_output_kbps:0\r\n" +
"rejected_connections:0\r\n" +
"sync_full:0\r\n" +
"sync_partial:0\r\n" +
"expired_keys:0\r\n" +
"evicted_keys:0\r\n" +
"keyspace_hits:0\r\n" +
"keyspace_misses:0\r\n" +
"pubsub_channels:" + strconv.Itoa(s.hub.TotalChannels()) + "\r\n" +
"pubsub_patterns:" + strconv.Itoa(s.hub.PatternCount()) + "\r\n" +
"latest_fork_usec:0\r\n" +
"\r\n" +
"# Replication\r\n" +
"role:master\r\n" +
"connected_slaves:0\r\n" +
"master_failover_state:no-failover\r\n" +
"master_repl_offset:0\r\n" +
"repl_backlog_active:0\r\n" +
"repl_backlog_size:1048576\r\n" +
"repl_backlog_first_byte_offset:0\r\n" +
"repl_backlog_histlen:0\r\n" +
"\r\n" +
"# CPU\r\n" +
"used_cpu_sys:0.00\r\n" +
"used_cpu_user:0.00\r\n" +
"used_cpu_sys_children:0.00\r\n" +
"used_cpu_user_children:0.00\r\n" +
"\r\n" +
"# Keyspace\r\n"
writer.WriteBulk([]byte(info))
return nil
}
// handlePsubscribe processes PSUBSCRIBE command.
// PSUBSCRIBE pattern [pattern ...] - Subscribe to channels matching patterns.
func (s *Server) handlePsubscribe(client *pubsub.Client, patterns [][]byte, writer *ConnWriter) error {
@@ -518,7 +615,7 @@ func (s *Server) handlePsubscribe(client *pubsub.Client, patterns [][]byte, writ
pattern := string(p)
ok, err := s.hub.PatternSubscribe(client, pattern)
if err != nil {
writer.WriteError(fmt.Sprintf("ERR invalid pattern: %v", err))
writer.WriteError(fmt.Sprintf("ERR %v", err))
return nil
}
if ok {

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"io/fs"
"math/rand"
"net"
"os"
"strconv"
@@ -23,21 +24,35 @@ import (
const MaxDatabases = 16 // Redis default maximum databases (0-15)
// generateRunID generates a 40-character hexadecimal run ID like Redis.
// Redis uses SHA1 of random data for 160 bits of randomness.
func generateRunID() string {
b := make([]byte, 20)
_, _ = rand.Read(b)
return fmt.Sprintf("%x", b)
}
// Sentinel errors for connection handling
var (
ErrQuit = errors.New("client quit")
)
// Version is set at build time via -ldflags
var Version = "unknown"
// Server is the main Pekoe server instance.
// It manages the TCP listener, connection lifecycle, and delegates to the pubsub hub.
type Server struct {
cfg *config.Config
hub pubsubHub
ln net.Listener
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
active atomic.Int64 // atomic counter for active connections
cfg *config.Config
hub pubsubHub
ln net.Listener
wg sync.WaitGroup
ctx context.Context
cancel context.CancelFunc
active atomic.Int64 // atomic counter for active connections
version string // server version for INFO command
startTime time.Time // server start time for uptime calculation
runID string // random run ID
}
// PubSubHub defines the interface our server expects from the pubsub implementation.
@@ -65,10 +80,13 @@ type pubsubHub interface {
func New(cfg *config.Config, hub pubsubHub) *Server {
ctx, cancel := context.WithCancel(context.Background())
return &Server{
cfg: cfg,
hub: hub,
ctx: ctx,
cancel: cancel,
cfg: cfg,
hub: hub,
ctx: ctx,
cancel: cancel,
version: Version,
startTime: time.Now(),
runID: generateRunID(),
}
}
@@ -105,6 +123,11 @@ func (s *Server) Run() error {
// createPidFile creates the PID file with the current process ID
func (s *Server) createPidFile() error {
pid := strconv.Itoa(os.Getpid())
// Remove existing pid file first to handle stale files from previous runs
// This also fixes permission denied errors when file already exists
if err := os.Remove(s.cfg.PidFile); err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("failed to remove existing pid file: %w", err)
}
//nolint: gosec
return os.WriteFile(s.cfg.PidFile, []byte(pid), 0644)
}
@@ -280,6 +303,8 @@ func (s *Server) processCommand(client *pubsub.Client, cmd *Command, writer *Con
return s.handleClient(client, cmd.Args[1:], writer)
case "select":
return s.handleSelect(client, cmd.Args[1:], writer)
case "info":
return s.handleInfo(writer)
default:
// Check if client is in pubsub mode - only pubsub commands allowed
if client != nil && (client.SubsCount() > 0 || client.PatternSubsCount() > 0) {
@@ -315,8 +340,27 @@ func (s *Server) waitForSignal() {
logger.Error("event=listener_close_error error=%v", err)
}
// Wait for all active connections to finish
s.wg.Wait()
// Wait for all active connections to finish with timeout
shutdownTimeout := time.Duration(s.cfg.ShutdownTimeout) * time.Second
if shutdownTimeout > 0 {
// Create a channel to wait on wg.Wait() with timeout
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
// All connections finished normally
case <-time.After(shutdownTimeout):
// Timeout reached - force cleanup
logger.Warn("event=shutdown_timeout timeout=%ds active_connections=%d", s.cfg.ShutdownTimeout, s.active.Load())
}
} else {
// No timeout - wait forever (original behavior)
s.wg.Wait()
}
// Remove PID file
s.removePidFile()