LRU chunk eviction (bounded cache, default 1024 chunks / ~200MB)
The in-memory chunk+frame cache no longer grows unbounded as players explore. An LRU policy (doubly-linked list + index map, O(1) per op) evicts least-recently-used chunks when the cache exceeds MaxCachedChunks, dropping both the chunk and its cached frame. Dirty chunks are skipped until the autosave flushes them, so no edit is ever lost to eviction. - world/cache.go: maxChunks field + order/index LRU bookkeeping; touch (move-to-front) on every chunkAt/Frame/SetBlock hit; evictIfNeeded on miss; NewCacheWithLimit constructor (0 = unbounded, backward-compat). Dirty chunks are bumped to MRU and left in place rather than doing region I/O under the cache mutex. - server/server.go: Config.MaxCachedChunks (default 1024); New wires it into NewCacheWithLimit when a world dir is set. - cmd/regionio/main.go: -maxcache flag. - Tests: limit cap, LRU ordering (touched chunk survives), both-maps drop, dirty-keep, reload-on-access, and edits-survive-eviction+reload (end-to-end via the store).
This commit is contained in:
parent
d7c80a8858
commit
65a7445a78
4 changed files with 269 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
175
internal/world/cache_test.go
Normal file
175
internal/world/cache_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue