RegionIO/internal/world/cache_test.go

385 lines
11 KiB
Go

package world
import (
"bytes"
"sync"
"sync/atomic"
"testing"
"time"
"regionio/internal/protocol"
)
// 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)
}
}
func TestConcurrentMissGeneratesChunkOnce(t *testing.T) {
var targetCalls atomic.Int32
gen := func(cx, cz int32) *Chunk {
if cx == 4 && cz == -7 {
targetCalls.Add(1)
}
time.Sleep(10 * time.Millisecond)
return NewChunk(cx, cz, BiomePlains)
}
c := NewCache(256, gen)
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if frame, err := c.FrameErr(4, -7); err != nil || len(frame) == 0 {
t.Errorf("FrameErr = %d bytes, %v", len(frame), err)
}
}()
}
wg.Wait()
if got := targetCalls.Load(); got != 1 {
t.Fatalf("target generator calls = %d, want 1", got)
}
}
func TestBatchGeneratorPublishesNeighborsAtomically(t *testing.T) {
var calls atomic.Int32
c := NewCache(-1, GenerateFlat)
c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
calls.Add(1)
return map[[2]int32]*Chunk{
{cx, cz}: NewChunk(cx, cz, BiomePlains),
{cx + 1, cz}: NewChunk(cx+1, cz, BiomePlains),
{cx, cz + 1}: NewChunk(cx, cz+1, BiomePlains),
}, nil
})
if got := c.chunkAt(2, 3); got == nil {
t.Fatal("batch target was not published")
}
if got := c.chunkAt(3, 3); got == nil {
t.Fatal("batch east neighbor was not published")
}
if got := c.chunkAt(2, 4); got == nil {
t.Fatal("batch south neighbor was not published")
}
if got := calls.Load(); got != 1 {
t.Fatalf("batch generator calls = %d, want 1", got)
}
}
func TestConcurrentOverlappingBatchLoadsShareFirstResult(t *testing.T) {
started := make(chan struct{}, 2)
release := make(chan struct{})
c := NewCache(-1, GenerateFlat)
c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
started <- struct{}{}
<-release
biome := uint16(cx + 10)
return map[[2]int32]*Chunk{
{cx, cz}: NewChunk(cx, cz, biome),
{cx + 1, cz}: NewChunk(cx+1, cz, biome),
{cx - 1, cz}: NewChunk(cx-1, cz, biome),
}, nil
})
type result struct {
chunk *Chunk
err error
}
results := make(chan result, 2)
go func() {
chunk, err := c.chunkAtErr(0, 0)
results <- result{chunk, err}
}()
go func() {
chunk, err := c.chunkAtErr(1, 0)
results <- result{chunk, err}
}()
<-started
<-started
close(release)
loaded := make(map[[2]int32]*Chunk)
for range 2 {
result := <-results
if result.err != nil || result.chunk == nil {
t.Fatalf("overlapping batch load = %v, %v", result.chunk, result.err)
}
loaded[[2]int32{result.chunk.X, result.chunk.Z}] = result.chunk
}
left, right := c.chunkAt(0, 0), c.chunkAt(1, 0)
if left == nil || right == nil {
t.Fatal("overlapping targets were not retained")
}
if left != loaded[[2]int32{0, 0}] || right != loaded[[2]int32{1, 0}] {
t.Fatal("requesters did not receive the canonical cached batch results")
}
if left.GetBiome(0, MinY, 0) != right.GetBiome(0, MinY, 0) {
t.Fatal("overlapping chunks came from different batch results")
}
}
func TestBatchGeneratorRejectsWrongTargetCoordinates(t *testing.T) {
c := NewCache(-1, GenerateFlat)
c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
return map[[2]int32]*Chunk{{cx, cz}: NewChunk(cx+1, cz, BiomePlains)}, nil
})
if _, err := c.chunkAtErr(2, 3); err == nil {
t.Fatal("batch generator with wrong target coordinates succeeded")
}
}
func TestBatchGeneratorPrefersPersistedNeighbors(t *testing.T) {
store, err := NewStore(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer store.Close()
persisted := NewChunk(1, 0, BiomePlains)
persisted.SetBlock(0, SeaLevel, 0, StateBedrock)
if err := store.SaveChunk(persisted); err != nil {
t.Fatal(err)
}
c := NewCacheWithStore(-1, GenerateFlat, store)
c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
generatedNeighbor := NewChunk(cx+1, cz, BiomePlains)
generatedNeighbor.SetBlock(0, SeaLevel, 0, StateGrass)
return map[[2]int32]*Chunk{
{cx, cz}: NewChunk(cx, cz, BiomePlains),
{cx + 1, cz}: generatedNeighbor,
}, nil
})
if got := c.chunkAt(0, 0); got == nil {
t.Fatal("batch target was not loaded")
}
neighbor := c.chunkAt(1, 0)
if got := neighbor.GetBlock(0, SeaLevel, 0); got != StateBedrock {
t.Fatalf("batch neighbor state = %d, want persisted bedrock %d", got, StateBedrock)
}
}
func TestBatchGeneratorRespectsBoundedCache(t *testing.T) {
c := NewCacheWithLimit(-1, GenerateFlat, nil, 2)
c.SetBatchGenerator(func(cx, cz int32) (map[[2]int32]*Chunk, error) {
return map[[2]int32]*Chunk{
{cx, cz}: NewChunk(cx, cz, BiomePlains),
{cx + 1, cz}: NewChunk(cx+1, cz, BiomePlains),
{cx, cz + 1}: NewChunk(cx, cz+1, BiomePlains),
}, nil
})
if got := c.chunkAt(0, 0); got == nil {
t.Fatal("batch target was not loaded")
}
c.mu.Lock()
got := len(c.chunks)
c.mu.Unlock()
if got > 2 {
t.Fatalf("cached batch chunks = %d, want at most 2", got)
}
}
func TestConcurrentFrameAndBlockEdits(t *testing.T) {
c := NewCache(256, flatGen())
if len(c.Frame(0, 0)) == 0 {
t.Fatal("initial frame is empty")
}
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < 250; i++ {
state := StateStone
if i%2 == 0 {
state = StateBedrock
}
c.SetBlock(3, SeaLevel, 3, state)
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
if len(c.Frame(0, 0)) == 0 {
t.Error("frame became empty")
return
}
}
}()
wg.Wait()
c.SetBlock(3, SeaLevel, 3, StateBedrock)
if got := c.GetBlock(3, SeaLevel, 3); got != StateBedrock {
t.Fatalf("final block = %d, want %d", got, StateBedrock)
}
finalFrame := c.Frame(0, 0)
if len(finalFrame) == 0 {
t.Fatal("final frame is empty")
}
snapshot, _ := c.chunkAt(0, 0).snapshot()
wantFrame := protocol.AppendPacket(nil, 256, protocol.PlayLevelChunk, snapshot.encode())
if !bytes.Equal(finalFrame, wantFrame) {
t.Fatal("cached frame does not represent the final chunk revision")
}
}