RegionIO/internal/world/cache.go
2026-06-27 21:16:39 +03:00

324 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package world
import (
"container/list"
"context"
"log/slog"
"sync"
"time"
"regionio/internal/protocol"
)
// Generator produces the chunk at the given coordinate.
type Generator func(cx, cz int32) *Chunk
// Cache is the live world: it owns the mutable chunk data and memoizes the
// framed, compression-ready level_chunk packet for each chunk. A block edit
// mutates the chunk and invalidates its cached frame so the next request
// re-encodes it.
//
// 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.
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 and no
// eviction limit (unbounded; for tests/flat worlds).
func NewCache(threshold int32, gen Generator) *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.
func (c *Cache) chunkAt(cx, cz int32) *Chunk {
key := [2]int32{cx, cz}
c.mu.Lock()
if ch, ok := c.chunks[key]; ok {
c.touch(key)
c.mu.Unlock()
return ch
}
c.mu.Unlock()
// Try disk before generation so saved edits survive restarts.
var ch *Chunk
if c.store != nil {
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
ch = loaded
}
}
if ch == nil {
ch = c.gen(cx, cz) // generate outside the lock
}
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
}
// Frame returns the prebuilt level_chunk packet for (cx, cz), building it on
// first request and caching until the chunk is edited. The slice must not be
// mutated.
func (c *Cache) Frame(cx, cz int32) []byte {
key := [2]int32{cx, cz}
c.mu.Lock()
if f, ok := c.frames[key]; ok {
c.touch(key)
c.mu.Unlock()
return f
}
c.mu.Unlock()
ch := c.chunkAt(cx, cz)
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, ch.Encode())
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
}
// GetBlock returns the block state at world coordinates (x, y, z).
// It loads or generates the chunk if necessary.
func (c *Cache) GetBlock(x, y, z int) uint16 {
if y < MinY || y >= MinY+WorldHeight {
return 0 // StateAir
}
cx := int32(x >> 4)
cz := int32(z >> 4)
ch := c.chunkAt(cx, cz)
return ch.GetBlock(x, y, z)
}
// SetBlock changes the block at world coordinates (x, y, z), invalidating the
// affected chunk's cached frame and marking it dirty for autosave. It reports
// whether a chunk was actually touched (false if y is out of range).
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
if y < MinY || y >= MinY+WorldHeight {
return false
}
cx := int32(x >> 4)
cz := int32(z >> 4)
ch := c.chunkAt(cx, cz)
ch.SetBlock(x, y, z, state)
c.mu.Lock()
key := [2]int32{cx, cz}
delete(c.frames, key)
if c.store != nil {
c.dirty[key] = struct{}{}
}
c.touch(key) // edited chunk is most-recently-used
c.mu.Unlock()
return true
}
// markDirty flags the chunk at (cx, cz) for the next autosave. Public so tests
// can simulate edits that the generator made worth persisting.
func (c *Cache) markDirty(cx, cz int32) {
if c.store == nil {
return
}
c.mu.Lock()
c.dirty[[2]int32{cx, cz}] = struct{}{}
c.mu.Unlock()
}
// StartAutosave launches a goroutine that periodically persists dirty chunks
// until ctx is cancelled, then performs a final flush. It returns a done channel
// that is closed once the goroutine has fully exited (including the final
// SaveAll) — callers must wait on it before closing the underlying Store to
// avoid racing the saver against Close. Call this once per server lifetime.
func (c *Cache) StartAutosave(ctx context.Context, log *slog.Logger, interval time.Duration) <-chan struct{} {
done := make(chan struct{})
if c.store == nil {
close(done)
return done // in-memory cache: nothing to save
}
go func() {
defer close(done)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
if err := c.SaveAll(); err != nil && log != nil {
log.Error("world: final autosave failed", "err", err)
}
return
case <-t.C:
if err := c.flushDirty(); err != nil && log != nil {
log.Error("world: autosave failed", "err", err)
}
}
}
}()
return done
}
// flushDirty saves every chunk currently marked dirty and clears the set.
func (c *Cache) flushDirty() error {
c.mu.Lock()
keys := make([][2]int32, 0, len(c.dirty))
for k := range c.dirty {
keys = append(keys, k)
}
chunks := make(map[[2]int32]*Chunk, len(keys))
for _, k := range keys {
chunks[k] = c.chunks[k]
}
c.dirty = make(map[[2]int32]struct{})
c.mu.Unlock()
for _, k := range keys {
ch := chunks[k]
if ch == nil {
continue
}
if err := c.store.SaveChunk(ch); err != nil {
c.mu.Lock()
c.dirty[k] = struct{}{} // re-mark; retry next cycle
c.mu.Unlock()
return err
}
}
return nil
}
// SaveAll synchronously persists every chunk currently in memory. Used at
// shutdown to guarantee no edit is lost.
func (c *Cache) SaveAll() error {
if c.store == nil {
return nil
}
c.mu.Lock()
keys := make([][2]int32, 0, len(c.chunks))
for k := range c.chunks {
keys = append(keys, k)
}
chunks := make(map[[2]int32]*Chunk, len(keys))
for _, k := range keys {
chunks[k] = c.chunks[k]
}
c.mu.Unlock()
var firstErr error
for _, k := range keys {
if ch := chunks[k]; ch != nil {
if err := c.store.SaveChunk(ch); err != nil && firstErr == nil {
firstErr = err
}
}
}
return firstErr
}