add simple memory cache server

This commit is contained in:
juancwu 2026-04-29 12:31:00 +00:00
commit 5f9c7813df
2 changed files with 299 additions and 0 deletions

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module git.juancwu.dev/juancwu/cubby
go 1.26.2

296
server.go Normal file
View file

@ -0,0 +1,296 @@
package main
import (
"bufio"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
type entry struct {
value string
expiresAt time.Time // zero means no expiry
}
type Store struct {
mu sync.RWMutex
data map[string]entry
}
func NewStore() *Store {
s := &Store{data: make(map[string]entry)}
go s.janitor()
return s
}
// janitor sweeps expired keys once per second.
func (s *Store) janitor() {
t := time.NewTicker(time.Second)
defer t.Stop()
for range t.C {
now := time.Now()
s.mu.Lock()
for k, e := range s.data {
if !e.expiresAt.IsZero() && now.After(e.expiresAt) {
delete(s.data, k)
}
}
s.mu.Unlock()
}
}
func (s *Store) Get(k string) (string, bool) {
s.mu.RLock()
e, ok := s.data[k]
s.mu.RUnlock()
if !ok {
return "", false
}
if !e.expiresAt.IsZero() && time.Now().After(e.expiresAt) {
s.mu.Lock()
delete(s.data, k)
s.mu.Unlock()
return "", false
}
return e.value, true
}
func (s *Store) Set(k, v string, ttl time.Duration) {
var exp time.Time
if ttl > 0 {
exp = time.Now().Add(ttl)
}
s.mu.Lock()
s.data[k] = entry{value: v, expiresAt: exp}
s.mu.Unlock()
}
func (s *Store) Del(k string) bool {
s.mu.Lock()
_, existed := s.data[k]
delete(s.data, k)
s.mu.Unlock()
return existed
}
func (s *Store) Keys() []string {
now := time.Now()
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.data))
for k, e := range s.data {
if e.expiresAt.IsZero() || now.Before(e.expiresAt) {
out = append(out, k)
}
}
return out
}
// ----- Protocol -----
//
// One command per line. Whitespace-separated. Values containing spaces or
// special characters are Go-quoted ("hello world", "line\nbreak").
//
// SET <key> <value> [ttl_seconds]
// GET <key>
// DEL <key>
// KEYS
// PING
// QUIT
//
// Replies start with a single sigil and end with \n:
// +<text> OK / simple string
// -<text> error
// $<value> value (Go-quoted)
// _ nil (key not found)
// #<n> count
//
// KEYS replies with a count line followed by N value lines.
func handleConn(conn net.Conn, store *Store) {
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
reply := func(s string) {
w.WriteString(s)
w.WriteByte('\n')
w.Flush()
}
for {
line, err := r.ReadString('\n')
if err != nil {
return // client closed, or read error
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
continue
}
args, err := tokenize(line)
if err != nil {
reply("-" + err.Error())
continue
}
cmd := strings.ToUpper(args[0])
switch cmd {
case "PING":
reply("+PONG")
case "GET":
if len(args) != 2 {
reply("-usage: GET <key>")
break
}
if v, ok := store.Get(args[1]); ok {
reply("$" + strconv.Quote(v))
} else {
reply("_")
}
case "SET":
if len(args) < 3 || len(args) > 4 {
reply("-usage: SET <key> <value> [ttl_seconds]")
break
}
var ttl time.Duration
if len(args) == 4 {
n, err := strconv.Atoi(args[3])
if err != nil || n <= 0 {
reply("-ttl must be a positive integer")
break
}
ttl = time.Duration(n) * time.Second
}
store.Set(args[1], args[2], ttl)
reply("+OK")
case "DEL":
if len(args) != 2 {
reply("-usage: DEL <key>")
break
}
if store.Del(args[1]) {
reply("#1")
} else {
reply("#0")
}
case "KEYS":
keys := store.Keys()
w.WriteString("#" + strconv.Itoa(len(keys)) + "\n")
for _, k := range keys {
w.WriteString("$" + strconv.Quote(k) + "\n")
}
w.Flush()
case "QUIT":
reply("+BYE")
return
default:
reply("-unknown command: " + args[0])
}
}
}
// tokenize splits a command line into args. A token is either a bare word
// (no whitespace) or a Go-style quoted string. This lets values contain
// spaces, newlines, etc., without inventing a new escape format.
func tokenize(line string) ([]string, error) {
var args []string
i := 0
for i < len(line) {
// Skip whitespace.
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
i++
}
if i >= len(line) {
break
}
if line[i] == '"' {
// Find the matching close quote, honoring backslash escapes.
j := i + 1
for j < len(line) {
if line[j] == '\\' && j+1 < len(line) {
j += 2
continue
}
if line[j] == '"' {
break
}
j++
}
if j >= len(line) {
return nil, fmt.Errorf("unterminated quoted string")
}
s, err := strconv.Unquote(line[i : j+1])
if err != nil {
return nil, fmt.Errorf("bad quoted string: %v", err)
}
args = append(args, s)
i = j + 1
} else {
// Bare word — read until whitespace.
j := i
for j < len(line) && line[j] != ' ' && line[j] != '\t' {
j++
}
args = append(args, line[i:j])
i = j
}
}
if len(args) == 0 {
return nil, fmt.Errorf("empty command")
}
return args, nil
}
func main() {
sock := flag.String("socket", "/tmp/cubby.sock", "unix socket path")
flag.Parse()
// Clean up any stale socket file from a previous run.
if _, err := os.Stat(*sock); err == nil {
_ = os.Remove(*sock)
}
ln, err := net.Listen("unix", *sock)
if err != nil {
log.Fatalf("listen: %v", err)
}
_ = os.Chmod(*sock, 0600) // current user only
store := NewStore()
// Remove the socket file on Ctrl-C / SIGTERM.
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
log.Println("shutting down")
_ = ln.Close()
_ = os.Remove(*sock)
os.Exit(0)
}()
log.Printf("listening on %s", *sock)
for {
conn, err := ln.Accept()
if err != nil {
return // listener closed by signal handler
}
go handleConn(conn, store)
}
}