diff --git a/cmd/regionio/main.go b/cmd/regionio/main.go index 91317b4..ec40419 100644 --- a/cmd/regionio/main.go +++ b/cmd/regionio/main.go @@ -28,10 +28,12 @@ func main() { seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log), "world seed (overrides REGIONIO_SEED)") worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)") + maxCache := flag.Int("maxcache", cfg.MaxCachedChunks, "max cached chunks, LRU eviction (0 = unbounded)") flag.Parse() cfg.WorldSeed = *seedFlag cfg.WorldDir = *worldDir - log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir) + cfg.MaxCachedChunks = *maxCache + log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir, "maxcache", cfg.MaxCachedChunks) srv, err := server.New(cfg) if err != nil { diff --git a/internal/server/server.go b/internal/server/server.go index 9e5eaf5..d0fca6a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,6 +24,9 @@ type Config struct { // read from and written to /region/*.mca and player edits // survive restarts. Empty disables persistence (in-memory only). WorldDir string + // MaxCachedChunks bounds the in-memory chunk+frame cache (LRU). 0 means + // unbounded (use only for tests/flat worlds). At ~200KiB/chunk, 1024 ≈ 200MB. + MaxCachedChunks int } // DefaultConfig returns sensible defaults matching vanilla expectations. @@ -40,6 +43,9 @@ func DefaultConfig() Config { // WorldDir defaults to "world" so the world persists by default; // set to "" for a throwaway in-memory world. WorldDir: "world", + // MaxCachedChunks keeps the live cache near 200MB at the default; the + // streamer's pre-gen ring and player view distance comfortably fit. + MaxCachedChunks: 1024, } } @@ -56,6 +62,8 @@ type Server struct { func New(cfg Config) (*Server, error) { gen := world.NewVanillaGenerator(cfg.WorldSeed) if cfg.WorldDir == "" { + // No persistence; keep eviction off too (flat/test worlds expect full + // presence). Real servers set WorldDir and MaxCachedChunks together. return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen)}, nil } store, err := world.NewStore(cfg.WorldDir) @@ -64,7 +72,7 @@ func New(cfg Config) (*Server, error) { } return &Server{ cfg: cfg, - chunks: world.NewCacheWithStore(int32(cfg.CompressionThreshold), gen, store), + chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks), store: store, }, nil } diff --git a/internal/world/cache.go b/internal/world/cache.go index 58f13e9..b46910b 100644 --- a/internal/world/cache.go +++ b/internal/world/cache.go @@ -1,6 +1,7 @@ package world import ( + "container/list" "context" "log/slog" "sync" @@ -17,45 +18,109 @@ type Generator func(cx, cz int32) *Chunk // mutates the chunk and invalidates its cached frame so the next request // re-encodes it. // -// When a Store is attached (NewCacheWithStore), the cache is read-through — a -// chunk miss first tries disk, then generation — and edits mark chunks dirty -// for the background autosave. Frames are built for a fixed compression -// threshold shared by all play connections, so one frame is valid for every -// client. +// When a Store is attached (NewCacheWithStore / NewCacheWithLimit), the cache is +// read-through — a chunk miss first tries disk, then generation — and edits mark +// chunks dirty for the background autosave. Frames are built for a fixed +// compression threshold shared by all play connections, so one frame is valid +// for every client. +// +// When maxChunks > 0, the cache evicts least-recently-used chunks to keep memory +// bounded (LRU via a doubly-linked list + index map, O(1) touch/evict). Dirty +// chunks are never evicted until the autosave flushes them, so no edit is lost. // // Generation can be expensive; it runs outside the lock to avoid blocking other -// chunk requests. An eviction policy belongs here once worlds stream far. +// chunk requests. type Cache struct { threshold int32 gen Generator store *Store // nil = in-memory only (tests, flat worlds) + maxChunks int // LRU capacity; 0 = unbounded mu sync.Mutex chunks map[[2]int32]*Chunk frames map[[2]int32][]byte dirty map[[2]int32]struct{} + // LRU bookkeeping: order is MRU(front)→LRU(back); index gives O(1) lookup. + order *list.List // elements are *[2]int32; nil when maxChunks==0 + index map[[2]int32]*list.Element } // NewCache returns a world cache that frames packets at the given compression -// threshold using gen to produce missing chunks. It has no persistence. +// threshold using gen to produce missing chunks. It has no persistence and no +// eviction limit (unbounded; for tests/flat worlds). func NewCache(threshold int32, gen Generator) *Cache { - return &Cache{ + c := &Cache{ threshold: threshold, gen: gen, chunks: make(map[[2]int32]*Chunk), frames: make(map[[2]int32][]byte), dirty: make(map[[2]int32]struct{}), } + return c } // NewCacheWithStore returns a cache backed by store: chunk misses load from disk // first (then fall back to gen), and edits are persisted by the autosave loop. +// The cache is unbounded. func NewCacheWithStore(threshold int32, gen Generator, store *Store) *Cache { c := NewCache(threshold, gen) c.store = store return c } +// NewCacheWithLimit is the full constructor: persistence (store may be nil) and +// an LRU cap of maxChunks chunks (0 = unbounded). When bounded, the cache evicts +// least-recently-used chunks on miss, keeping memory near maxChunks×(chunk+frame) +// ≈ maxChunks×200KiB. +func NewCacheWithLimit(threshold int32, gen Generator, store *Store, maxChunks int) *Cache { + c := NewCacheWithStore(threshold, gen, store) + c.maxChunks = maxChunks + if maxChunks > 0 { + c.order = list.New() + c.index = make(map[[2]int32]*list.Element) + } + return c +} + +// touch marks key as most-recently-used. Must be called under c.mu. +func (c *Cache) touch(key [2]int32) { + if c.maxChunks == 0 { + return + } + if e, ok := c.index[key]; ok { + c.order.MoveToFront(e) + } else { + c.index[key] = c.order.PushFront(&key) + } +} + +// evictIfNeeded drops least-recently-used chunks until len(chunks) <= maxChunks. +// Dirty chunks are skipped (moved back to MRU and the eviction halts) so the +// autosave can persist them first. Must be called under c.mu. +func (c *Cache) evictIfNeeded() { + if c.maxChunks == 0 { + return + } + for len(c.chunks) > c.maxChunks { + back := c.order.Back() + if back == nil { + return + } + key := *back.Value.(*[2]int32) + // Never drop a dirty chunk: it has unsaved edits. Bump it to MRU and + // stop evicting this cycle; the autosave flush will clear it and the + // next eviction pass can reclaim it. + if _, dirty := c.dirty[key]; dirty && c.store != nil { + c.order.MoveToFront(back) + break + } + delete(c.chunks, key) + delete(c.frames, key) + c.order.Remove(back) + delete(c.index, key) + } +} + // chunkAt returns the chunk at (cx, cz). Resolution order: in-memory cache → // disk (if a store is attached) → generation. Generation and disk reads run // outside the lock. @@ -64,6 +129,7 @@ func (c *Cache) chunkAt(cx, cz int32) *Chunk { c.mu.Lock() if ch, ok := c.chunks[key]; ok { + c.touch(key) c.mu.Unlock() return ch } @@ -83,9 +149,12 @@ func (c *Cache) chunkAt(cx, cz int32) *Chunk { c.mu.Lock() defer c.mu.Unlock() if existing, ok := c.chunks[key]; ok { + c.touch(key) return existing // another goroutine won the race } c.chunks[key] = ch + c.touch(key) + c.evictIfNeeded() return ch } @@ -97,6 +166,7 @@ func (c *Cache) Frame(cx, cz int32) []byte { c.mu.Lock() if f, ok := c.frames[key]; ok { + c.touch(key) c.mu.Unlock() return f } @@ -108,9 +178,12 @@ func (c *Cache) Frame(cx, cz int32) []byte { c.mu.Lock() defer c.mu.Unlock() if existing, ok := c.frames[key]; ok { + c.touch(key) return existing } c.frames[key] = frame + c.touch(key) + c.evictIfNeeded() return frame } @@ -133,6 +206,7 @@ func (c *Cache) SetBlock(x, y, z int, state uint16) bool { if c.store != nil { c.dirty[key] = struct{}{} } + c.touch(key) // edited chunk is most-recently-used c.mu.Unlock() return true } diff --git a/internal/world/cache_test.go b/internal/world/cache_test.go new file mode 100644 index 0000000..501d4ed --- /dev/null +++ b/internal/world/cache_test.go @@ -0,0 +1,175 @@ +package world + +import ( + "testing" +) + +// flatGen returns a generator that produces distinct chunks keyed by coordinate, +// so eviction is observable: each (cx,cz) gets a chunk whose only block encodes +// its position (block at local 0,SeaLevel,0 = a sentinel derived from coords). +func flatGen() Generator { + return func(cx, cz int32) *Chunk { + c := NewChunk(cx, cz, BiomePlains) + si := (SeaLevel - MinY) >> 4 + c.section(si) + // Sentinel: the block at column (0,0) of the surface is the chunk's + // low byte cx, and (1,0) is cz, so a reloaded chunk proves it's the + // right coordinate. + c.SetBlock(0, SeaLevel, 0, uint16(cx&0xFF)) + c.SetBlock(1, SeaLevel, 0, uint16(cz&0xFF)) + return c + } +} + +// cachedCount returns the number of chunks currently in the cache. +func cachedCount(c *Cache) int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.chunks) +} + +// hasChunk reports whether the cache holds the given chunk key. +func hasChunk(c *Cache, cx, cz int32) bool { + c.mu.Lock() + defer c.mu.Unlock() + _, ok := c.chunks[[2]int32{cx, cz}] + return ok +} + +// TestEvictionRespectsLimit confirms the cache stays at or below maxChunks after +// a burst of Frame calls that would otherwise grow it unbounded. +func TestEvictionRespectsLimit(t *testing.T) { + c := NewCacheWithLimit(int32(256), flatGen(), nil, 4) + for cx := int32(0); cx < 6; cx++ { + c.Frame(cx, 0) + } + if got := cachedCount(c); got > 4 { + t.Errorf("cached count = %d, want <= 4 after inserting 6", got) + } + // The oldest two (0,0) and (1,0) should have been evicted. + if hasChunk(c, 0, 0) { + t.Error("(0,0) should have been evicted as LRU") + } + if hasChunk(c, 1, 0) { + t.Error("(1,0) should have been evicted as LRU") + } +} + +// TestEvictionLRUOrder confirms a touched (re-accessed) chunk survives while an +// untouched one between is evicted. Insert A B C D (cap 4); touch A; insert E → +// B (not A) is the victim. +func TestEvictionLRUOrder(t *testing.T) { + c := NewCacheWithLimit(int32(256), flatGen(), nil, 4) + c.Frame(0, 0) // A + c.Frame(1, 0) // B + c.Frame(2, 0) // C + c.Frame(3, 0) // D + // Re-access A so it is most-recently-used; B becomes least. + c.Frame(0, 0) + c.Frame(4, 0) // E → evicts B + if !hasChunk(c, 0, 0) { + t.Error("(0,0)/A should survive after being touched") + } + if hasChunk(c, 1, 0) { + t.Error("(1,0)/B should have been evicted (least recently used)") + } +} + +// TestEvictionDropsBothMaps confirms eviction removes the entry from both the +// chunks and frames maps (otherwise memory would still leak). +func TestEvictionDropsBothMaps(t *testing.T) { + c := NewCacheWithLimit(int32(256), flatGen(), nil, 2) + c.Frame(0, 0) + c.Frame(1, 0) + c.Frame(2, 0) // evicts (0,0) + c.mu.Lock() + _, hasChunkMap := c.chunks[[2]int32{0, 0}] + _, hasFrameMap := c.frames[[2]int32{0, 0}] + c.mu.Unlock() + if hasChunkMap { + t.Error("evicted chunk still present in chunks map") + } + if hasFrameMap { + t.Error("evicted chunk still present in frames map") + } +} + +// TestEvictionKeepsDirty confirms a dirty chunk (pending autosave) is NOT +// evicted, so its edits survive until flushed. +func TestEvictionKeepsDirty(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer store.Close() + c := NewCacheWithLimit(int32(256), flatGen(), store, 2) + + c.Frame(0, 0) // load/generate into cache + // Mark it dirty via a block edit (sets dirty + touches). + if !c.SetBlock(0, SeaLevel, 0, StateBedrock) { + t.Fatal("SetBlock failed") + } + // Now insert two more chunks to exceed the cap of 2; (0,0) is dirty and + // must be retained. + c.Frame(1, 0) + c.Frame(2, 0) + if !hasChunk(c, 0, 0) { + t.Error("dirty chunk (0,0) was evicted; edits would be lost") + } +} + +// TestEvictionReloadsOnAccess confirms a chunk evicted then re-requested is +// regenerated (or loaded from disk) transparently and serves a valid frame. +func TestEvictionReloadsOnAccess(t *testing.T) { + c := NewCacheWithLimit(int32(256), flatGen(), nil, 2) + c.Frame(5, 7) + c.Frame(6, 7) + c.Frame(7, 7) // evicts (5,7) + if hasChunk(c, 5, 7) { + t.Fatal("(5,7) should have been evicted") + } + // Re-request: must regenerate and return a non-empty frame. + frame := c.Frame(5, 7) + if len(frame) == 0 { + t.Fatal("reloaded chunk frame is empty") + } + if !hasChunk(c, 5, 7) { + t.Error("re-requested chunk not present in cache after reload") + } +} + +// TestEvictionReloadPreservesEdits confirms that a dirty chunk, once flushed by +// the autosave and then evicted, reloads its saved edits from disk (not a stale +// re-generation). This is the end-to-end "edits survive eviction" guarantee. +func TestEvictionReloadPreservesEdits(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer store.Close() + c := NewCacheWithLimit(int32(256), flatGen(), store, 2) + + // Edit (9,9) and flush it to disk. + c.SetBlock(9*16+0, SeaLevel, 9*16+0, StateBedrock) + if err := c.SaveAll(); err != nil { + t.Fatal(err) + } + // Force eviction of (9,9) by pulling in other chunks (cap is 2; (9,9) is + // dirty-but-now-flushed so it can be evicted). + c.Frame(10, 10) + c.Frame(11, 11) + // Keep touching others until (9,9) is gone or we've filled beyond it. Since + // it's no longer dirty after SaveAll, the next eviction pass can drop it. + c.Frame(12, 12) + // Reload (9,9) — should come from disk with the bedrock edit intact. + frameBefore := c.Frame(9, 9) + if len(frameBefore) == 0 { + t.Fatal("reloaded frame empty") + } + ch := c.chunkAt(9, 9) + if got := ch.GetBlock(9*16+0, SeaLevel, 9*16+0); got != StateBedrock { + t.Errorf("after eviction+reload, edited block = %d, want bedrock %d", got, StateBedrock) + } +}