16 KiB
TODO
1. Multiport server
One pekoe instance serves on multiple ip:port pairs simultaneously. Each listener has its own max_clients limit (default 10000 per pair). All listeners share the same pubsub hub — a message published on any listener reaches subscribers on any listener.
Motivation: A single process may need to listen on multiple interfaces (e.g., internal 10.0.0.1:6379 and external 0.0.0.0:6380) without running separate instances. Simplifies deployment and reduces resource overhead.
Config change:
Replace bind + port with a listeners array. Keep old fields for backward compatibility — if listeners is absent, fall back to bind+port as a single listener.
{
"listeners": [
{ "bind": "0.0.0.0", "port": 6379, "max_clients": 10000 },
{ "bind": "10.0.0.1", "port": 6380, "max_clients": 5000 }
]
}
| Option | Type | Default | Description |
|---|---|---|---|
bind |
string | "0.0.0.0" |
IP address to bind |
port |
int | 6379 |
Port number |
max_clients |
int | 10000 |
Max connections for this listener |
Design:
internal/server/server.go— Server holds a slice ofnet.Listener, one per config entryRun()starts an accept loop goroutine per listener, all sharing the same hub- Shutdown closes all listeners and waits for all connections across all listeners
CLIENT LISTandCLIENT INFOwork across all listeners (same hub)- Logging includes
listener=addr:portfield to distinguish sources
Implementation outline:
- Add
Listenerstruct tointernal/config/config.go, addListeners []Listenerfield to Config - Update
SetDefaults()/Validate()— ifListenersempty, build from legacyBind+Port; validate each listener; capmax_clientsper listener at 10000 - Refactor
internal/server/server.go—Run()iterates listeners, starts one accept loop each; shutdown closes all listeners - Add
listenerfield to log messages in accept/connected events - Create
internal/server/server_test.gotests for multi-listener startup, shutdown, per-listener client limits - Update config file example, README.md, AGENTS.md
Edge cases:
- Duplicate
bind:portpairs — reject at config validation - One listener fails to bind — fail startup, don't partially start
- Global
max_clientsfrom legacy config maps to the single listener's limit listenerspresent alongside legacybind/port—listenerstakes precedence
2. Cloud-friendly configuration
Make pekoe cloud-friendly: allow config values to be set via command-line flags or environment variables, not only config file.
Motivation: In container/cloud environments (Docker, Kubernetes, ECS) it's easier to pass configuration via environment variables or flags than to mount config files.
Design:
- Each config option gets a CLI flag (
--port,--bind,--log-level, etc.) - Each config option gets an env var (
PEKOE_PORT,PEKOE_BIND,PEKOE_LOG_LEVEL, etc.) - Precedence: CLI flag > env var > config file > default
- Config file remains the primary source, flags/env override individual values
- Keep
--configfor specifying the config file path
Config options to expose:
| Config key | Flag | Env var |
|---|---|---|
bind |
--bind |
PEKOE_BIND |
port |
--port |
PEKOE_PORT |
max_clients |
--max-clients |
PEKOE_MAX_CLIENTS |
read_timeout |
--read-timeout |
PEKOE_READ_TIMEOUT |
write_timeout |
--write-timeout |
PEKOE_WRITE_TIMEOUT |
max_message_bytes |
--max-message-bytes |
PEKOE_MAX_MESSAGE_BYTES |
log_level |
--log-level |
PEKOE_LOG_LEVEL |
log_file |
--log-file |
PEKOE_LOG_FILE |
pid_file |
--pid-file |
PEKOE_PID_FILE |
Implementation outline:
- Add
FromEnv()method to Config that reads PEKOE_* env vars - Add
FromFlags()method or integrate flag parsing into config loading - Update
config.Load()to apply overrides in order: defaults → file → env → flags - Update
cmd/pekoe/main.goto register all flags and pass them to config - Add tests for precedence and edge cases (invalid env values, conflicting flags)
- Update README.md and AGENTS.md
Example usage:
# Override port via flag
./pekoe --config config.json --port 8080
# Override via env var
PEKOE_PORT=8080 PEKOE_LOG_LEVEL=debug ./pekoe
# Docker
docker run -e PEKOE_PORT=6379 -e PEKOE_LOG_LEVEL=info pekoe
# Kubernetes
env:
- name: PEKOE_PORT
value: "6379"
- name: PEKOE_BIND
value: "0.0.0.0"
Note: Depends on task #1 (multiport). If listeners config is used, env/flag overrides apply to the first listener only. For multi-listener setups, config file is required.
3. In-memory message buffer with TTL for pubsub
Buffer messages published to channels with no subscribers. When a subscriber later joins, it receives all buffered messages as if they were just published. Fully transparent to clients — no new commands, no changed behavior.
Motivation: In ephemeral pub/sub setups subscribers may connect after publishers have already sent messages. Without buffering those messages are lost. A per-channel buffer with TTL lets late subscribers receive recent messages without needing persistence or external storage.
Use case flow:
- Publisher sends
PUBLISH news "hello"— no subscribers onnews - Message is stored in per-channel buffer with configured TTL
- Later, client subscribes to
newsviaSUBSCRIBE news - Buffered message is delivered immediately as a normal
["message", "news", "hello"]response - Message is deleted from buffer after delivery
- If TTL expires before any subscriber joins, message is silently discarded
Design:
- New package
internal/buffer/— per-channel message buffer with TTL - Each channel holds a slice of buffered messages, each with a creation timestamp and TTL
- On
PUBLISHto a channel with zero subscribers, store the message instead of discarding - On
SUBSCRIBE/PSUBSCRIBE, flush matching buffered messages to the new subscriber before processing future live messages - On delivery, remove the message from the buffer immediately
- Background goroutine periodically evicts expired messages
- Pattern subscriptions (
PSUBSCRIBE) also receive buffered messages from channels matching the pattern
What does NOT change:
- No new commands or subcommands
- No changes to existing command syntax or responses
- Client sees normal
["message", channel, payload]frames for buffered messages PUBLISHstill returns the count of live subscribers (buffered messages do not count)
Config options:
| Option | Type | Default | Description |
|---|---|---|---|
message_ttl |
int | 0 |
Default TTL for buffered messages in seconds (0 = buffering disabled) |
buffer_cleanup_interval |
int | 1 |
How often to scan for expired messages (seconds) |
Implementation outline:
- Create
internal/buffer/buffer.go— Message struct, Buffer struct (per-channel map of message slices, TTL, locking), Add/Deliver/Expire methods - Create
internal/buffer/buffer_test.go— tests for add, deliver (FIFO order), TTL expiry, concurrent access, pattern matching - Integrate into
internal/pubsub/hub.go— on Publish if channel has no subscribers, call buffer.Add; on Subscribe after registering, call buffer.Deliver - Integrate pattern matching — on PatternSubscribe, iterate channels matching the pattern and deliver buffered messages
- Add
message_ttlandbuffer_cleanup_intervaltointernal/config/config.go - Start background expiry goroutine in server startup, stop on shutdown
- Update README.md and AGENTS.md
Edge cases:
- Subscriber joins, receives buffer, then unsubscribes, then re-joins — buffer was already flushed, no re-delivery
- Multiple messages buffered — delivered in FIFO order
- Pattern subscriber joins — receives buffered messages from all matching channels
- TTL expires mid-delivery — expired messages skipped during flush
message_ttl=0— buffering disabled, behavior unchanged from current
4. Redis-compatible authentication and encryption
Implement Redis-compatible AUTH command and TLS encryption support.
Based on Redis analysis:
- Redis uses
requirepassconfig for simple password auth (ACL is more advanced) - HELLO command is the modern way to authenticate during handshake (Redis 6+)
- TLS uses separate port (
tls-port) or can coexist with plaintext on different ports - Redis uses OpenSSL for TLS via
SSL_CTXandSSLobjects - HELLO returns map with server info:
server,version,proto,id,mode,role,modules - AUTH is allowed both before and after SUBSCRIBE (Redis 6+)
- Redis uses
CMD_NO_AUTHcommand flag to mark commands that don't require auth
Authentication (AUTH)
Redis requirepass behavior:
- Single password for the "default" user
- AUTH with single argument uses "default" username
- AUTH with two arguments:
AUTH username password(ACL style) - If no password configured, AUTH returns error: "AUTH called without any password configured"
- Wrong password returns: "invalid username and password pair"
Redis HELLO command behavior:
HELLO [protover] [AUTH username password] [SETNAME name]- If protocol version not specified, defaults to RESP2 (2)
- Protocol version must be 2 or 3 (RESP2 or RESP3)
- AUTH in HELLO authenticates during handshake
- Returns map with:
server,version,proto,id,mode,role,modules - If not authenticated after HELLO, returns: "HELLO must be called with the client already authenticated"
Config options:
| Option | Type | Default | Redis equivalent | Description |
|---|---|---|---|---|
require_pass |
string | "" |
requirepass |
Server password (empty = no auth) |
user |
string | "default" |
(ACL default) | Default username |
New commands:
| Command | Description |
|---|---|
AUTH [username] <password> |
Authenticate with password |
HELLO [protover] [AUTH username password] [SETNAME name] |
Extended handshake with optional auth |
TLS Encryption
Redis TLS behavior:
tls-port— separate TLS port, 0 = disabledport— plain TCP port, can be 0 to disable plaintext- If both
tls-portandportset, server listens on both - Certificate files:
tls-cert-file,tls-key-file,tls-client-cert-file,tls-client-key-file - CA:
tls-ca-cert-file,tls-ca-cert-dir - Client auth:
tls-auth-clients— no/yes/optional - Protocol versions:
tls-protocols— "TLSv1.2 TLSv1.3" - Ciphers:
tls-ciphers,tls-ciphersuites - Session caching:
tls-session-caching,tls-session-cache-size,tls-session-cache-timeout - Replication TLS:
tls-replication - Cluster TLS:
tls-cluster
Config options:
| Option | Type | Default | Redis equivalent | Description |
|---|---|---|---|---|
tls_enabled |
bool | false |
(derived from tls-port) | Enable TLS |
tls_port |
int | 0 |
tls-port |
TLS port (0 = same as port) |
port |
int | 6379 |
port |
Plain TCP port (0 = TLS only) |
tls_cert_file |
string | "" |
tls-cert-file |
Path to TLS certificate |
tls_key_file |
string | "" |
tls-key-file |
Path to TLS private key |
tls_key_pass |
string | "" |
tls-key-file-pass |
Private key password |
tls_ca_cert_file |
string | "" |
tls-ca-cert-file |
Path to CA certificate |
tls_ca_cert_dir |
string | "" |
tls-ca-cert-dir |
Path to CA cert directory |
tls_auth_clients |
string | "yes" |
tls-auth-clients |
Client cert auth: no/yes/optional |
tls_client_cert_file |
string | "" |
tls-client-cert-file |
Client certificate |
tls_client_key_file |
string | "" |
tls-client-key-file |
Client private key |
tls_protocols |
string | "TLSv1.2 TLSv1.3" |
tls-protocols |
TLS versions |
tls_ciphers |
string | "" |
tls-ciphers |
Server ciphers |
tls_ciphersuites |
string | "" |
tls-ciphersuites |
TLS 1.3 ciphers |
tls_session_caching |
bool | true |
tls-session-caching |
Enable session cache |
tls_replication |
bool | false |
tls-replication |
TLS for replica connections |
tls_cluster |
bool | false |
tls-cluster |
TLS for cluster bus |
Implementation outline:
- Add config fields to
internal/config/config.go - Add validation — if TLS enabled, require cert+key files
- Add
internal/auth/auth.go— password verification with constant-time compare - Add
AUTHcommand handler - Add
HELLOcommand handler with RESP2/RESP3 support - Add TLS listener support in server — use Go's
crypto/tlspackage - Detect TLS connections via
tls.ConnectionState - Update connection state to track authenticated status
- Add
CMD_NO_AUTHequivalent — allow AUTH/HELLO without auth - Update CLIENT INFO to show TLS/auth status
- Update README.md and AGENTS.md
Edge cases:
- AUTH on server without password configured → error
- HELLO without AUTH when password configured → auth required
- Plaintext connection to TLS-only server → reject
- TLS client cert verification → verify against CA if configured
- Both plaintext and TLS ports configured → support both
- AUTH in HELLO followed by regular AUTH → both should work
Security notes:
- Use
subtle.ConstantTimeCompare()for password comparison - Log failed AUTH attempts with client address
- HELLO response should include
authenticated: true/false - Redis 6+ allows AUTH before SUBSCRIBE and in HELLO
5. Structured JSON logging
In addition to plaintext logging, implement structured JSON logging with config option to switch between them.
Motivation: JSON logging enables easy parsing by log aggregation systems (ELK, Loki, CloudWatch, etc.). Plaintext remains for human readability in development.
Design:
Current format (plaintext)
2024 Apr 2 14:30:15 INFO pekoe[12345]: event=startup addr=0.0.0.0:6379
JSON format
{"timestamp":"2024-04-02T14:30:15Z","level":"INFO","pid":12345,"event":"startup","addr":"0.0.0.0:6379"}
Config options:
| Option | Type | Default | Description |
|---|---|---|---|
log_format |
string | "text" |
Log format: text or json |
log_timestamp_format |
string | "rfc3339" |
Timestamp format for JSON (rfc3339, iso8601, unix) |
Log levels:
- DEBUG, INFO, WARN, ERROR, CRITICAL
JSON fields:
| Field | Type | Description |
|---|---|---|
timestamp |
string | RFC3339 formatted timestamp |
level |
string | Log level (DEBUG, INFO, WARN, ERROR, CRITICAL) |
pid |
int | Process ID |
message |
string | Main log message |
* |
any | Key-value pairs from log message |
Implementation outline:
- Refactor
internal/logger/logger.go— separate formatting from logging - Add
formatText()— current plaintext formatter - Add
formatJSON()— new JSON formatter - Add config field
LogFormatininternal/config/config.go - Update logger initialization to read format from config
- Add tests for both formatters
- Handle special characters in JSON output (escaping)
- Ensure log file output uses same format as stdout
Example output:
text:
2024 Apr 2 14:30:15 INFO pekoe[12345]: event=startup addr=0.0.0.0:6379 max_clients=10000
json:
{"timestamp":"2024-04-02T14:30:15Z","level":"INFO","pid":12345,"event":"startup","addr":"0.0.0.0:6379","max_clients":10000}
Edge cases:
- Empty message → emit JSON with message: ""
- Nested key=value → flatten with underscores
- Binary data in message → base64 encode or skip
- High-frequency logging → consider JSON marshaling optimization
Use cases:
- Text: development, debugging, simple deployments
- JSON: Kubernetes, Docker, log aggregation systems, SIEM