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).
175 lines
5.5 KiB
Go
175 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|