add simple memory cache server
This commit is contained in:
parent
bb6fcf3ba7
commit
5f9c7813df
2 changed files with 299 additions and 0 deletions
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module git.juancwu.dev/juancwu/cubby
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
296
server.go
Normal file
296
server.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue