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).
312 lines
8.5 KiB
Go
312 lines
8.5 KiB
Go
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
|
||
}
|
||
|
||
// 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
|
||
}
|