26. Terminal project: an LRU cache library and CLI cache server
Goal: implement the design from
projects/lru_cache.md as generic, reusable Go
— then wrap it in a tiny TCP server so it's a real, runnable service.
// lru/lru.go
package lru
import "sync"
type node[K comparable, V any] struct {
key K
value V
prev, next *node[K, V]
}
type Cache[K comparable, V any] struct {
mu sync.Mutex
capacity int
items map[K]*node[K, V]
head, tail *node[K, V] // head = most recently used
}
func New[K comparable, V any](capacity int) *Cache[K, V] {
if capacity <= 0 {
panic("lru: capacity must be positive")
}
return &Cache[K, V]{
capacity: capacity,
items: make(map[K]*node[K, V], capacity),
}
}
func (c *Cache[K, V]) unlink(n *node[K, V]) {
if n.prev != nil {
n.prev.next = n.next
} else {
c.head = n.next
}
if n.next != nil {
n.next.prev = n.prev
} else {
c.tail = n.prev
}
}
func (c *Cache[K, V]) pushFront(n *node[K, V]) {
n.prev, n.next = nil, c.head
if c.head != nil {
c.head.prev = n
}
c.head = n
if c.tail == nil {
c.tail = n
}
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.Lock()
defer c.mu.Unlock()
n, ok := c.items[key]
if !ok {
var zero V
return zero, false
}
c.unlink(n)
c.pushFront(n)
return n.value, true
}
func (c *Cache[K, V]) Put(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
if n, ok := c.items[key]; ok {
n.value = value
c.unlink(n)
c.pushFront(n)
return
}
n := &node[K, V]{key: key, value: value}
c.items[key] = n
c.pushFront(n)
if len(c.items) > c.capacity {
lru := c.tail
c.unlink(lru)
delete(c.items, lru.key)
}
}
func (c *Cache[K, V]) Len() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.items)
}
// lru/lru_test.go
package lru
import "testing"
func TestEviction(t *testing.T) {
c := New[string, int](2)
c.Put("a", 1)
c.Put("b", 2)
c.Put("c", 3) // evicts "a", the least recently used
if _, ok := c.Get("a"); ok {
t.Fatal("expected 'a' to be evicted")
}
if v, ok := c.Get("b"); !ok || v != 2 {
t.Fatalf("got (%v, %v), want (2, true)", v, ok)
}
}
func TestGetPromotesToMostRecentlyUsed(t *testing.T) {
c := New[string, int](2)
c.Put("a", 1)
c.Put("b", 2)
c.Get("a") // promotes "a"; "b" is now least recently used
c.Put("c", 3) // evicts "b", not "a"
if _, ok := c.Get("b"); ok {
t.Fatal("expected 'b' to be evicted")
}
if _, ok := c.Get("a"); !ok {
t.Fatal("expected 'a' to survive")
}
}
go test -race ./lru/...
Now wrap it in a minimal line-protocol TCP server (GET key, PUT key value) — the same pattern that memcached and Redis expose, minus the
distributed part:
// cmd/cacheserver/main.go
package main
import (
"bufio"
"fmt"
"log"
"net"
"strings"
"example.com/cacheserver/lru"
)
func main() {
cache := lru.New[string, string](1000)
ln, err := net.Listen("tcp", ":9090")
if err != nil {
log.Fatal(err)
}
log.Println("cache server listening on :9090")
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
go handleConn(conn, cache)
}
}
func handleConn(conn net.Conn, cache *lru.Cache[string, string]) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) == 0 {
continue
}
switch strings.ToUpper(fields[0]) {
case "GET":
if len(fields) != 2 {
fmt.Fprintln(conn, "ERR usage: GET key")
continue
}
if v, ok := cache.Get(fields[1]); ok {
fmt.Fprintln(conn, "OK", v)
} else {
fmt.Fprintln(conn, "MISS")
}
case "PUT":
if len(fields) < 3 {
fmt.Fprintln(conn, "ERR usage: PUT key value")
continue
}
cache.Put(fields[1], strings.Join(fields[2:], " "))
fmt.Fprintln(conn, "OK")
default:
fmt.Fprintln(conn, "ERR unknown command")
}
}
}
go run ./cmd/cacheserver &
nc localhost 9090
PUT session:42 alice
GET session:42
GET missing
Each connection gets its own goroutine (go handleConn(...)); the shared
*lru.Cache is safe under concurrent access because every method locks
internally — this is the "one goroutine per connection, shared
synchronized state" pattern that describes most simple Go TCP servers,
extended fully in Chapter 38.