RegionIO/internal/world/cache.go

604 lines
17 KiB
Go
Raw Permalink 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"
"errors"
"fmt"
"log/slog"
"runtime"
"sync"
"time"
"regionio/internal/protocol"
)
// Generator produces the chunk at the given coordinate.
type Generator func(cx, cz int32) *Chunk
// BatchGenerator builds the requested chunk and may return neighboring base
// terrain chunks in the same operation. Free coordinates are published
// together; coordinates with an active load retain that load's ownership.
type BatchGenerator func(cx, cz int32) (map[[2]int32]*Chunk, error)
// 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
batchGen BatchGenerator
store *Store // nil = in-memory only (tests, flat worlds)
maxChunks int // LRU capacity; 0 = unbounded
mu sync.Mutex
lightMu sync.RWMutex
chunks map[[2]int32]*Chunk
frames map[[2]int32][]byte
dirty map[[2]int32]uint64
tickets map[[2]int32]int
inflight map[[2]int32]int
// frameSlots bounds expensive load/light/encode work across all players.
// Streamers may have their own workers, but they share this admission gate.
frameSlots chan 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
loads map[[2]int32]*chunkLoad
}
// SetBatchGenerator enables optional batch generation. It must be called before
// the cache is used; existing single-chunk generators remain the default.
func (c *Cache) SetBatchGenerator(gen BatchGenerator) {
c.mu.Lock()
c.batchGen = gen
c.mu.Unlock()
}
// chunkLoad coordinates concurrent misses for the same coordinate. The first
// caller performs disk I/O or generation; all others wait for that exact result.
type chunkLoad struct {
done chan struct{}
ch *Chunk
err error
}
// 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 {
workers := runtime.GOMAXPROCS(0)
if workers > 8 {
workers = 8
}
if workers < 2 {
workers = 2
}
c := &Cache{
threshold: threshold,
gen: gen,
chunks: make(map[[2]int32]*Chunk),
frames: make(map[[2]int32][]byte),
dirty: make(map[[2]int32]uint64),
tickets: make(map[[2]int32]int),
inflight: make(map[[2]int32]int),
loads: make(map[[2]int32]*chunkLoad),
frameSlots: make(chan struct{}, workers),
}
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)
if maxChunks < 0 {
maxChunks = 0
}
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
}
checked := 0
for len(c.chunks) > c.maxChunks && checked < len(c.chunks) {
back := c.order.Back()
if back == nil {
return
}
key := *back.Value.(*[2]int32)
if c.tickets[key] > 0 || c.inflight[key] > 0 {
c.order.MoveToFront(back)
checked++
continue
}
// 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)
checked++
continue
}
delete(c.chunks, key)
delete(c.frames, key)
c.order.Remove(back)
delete(c.index, key)
checked = 0
}
}
// 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 {
ch, _ := c.chunkAtErr(cx, cz)
return ch
}
// chunkAtErr is the error-preserving form used by network and mutation paths.
// A corrupt or unreadable stored chunk is never replaced by generated terrain.
func (c *Cache) chunkAtErr(cx, cz int32) (*Chunk, error) {
key := [2]int32{cx, cz}
c.mu.Lock()
if ch, ok := c.chunks[key]; ok {
c.touch(key)
c.mu.Unlock()
return ch, nil
}
if pending, ok := c.loads[key]; ok {
c.mu.Unlock()
<-pending.done
return pending.ch, pending.err
}
pending := &chunkLoad{done: make(chan struct{})}
c.loads[key] = pending
c.mu.Unlock()
// Try disk before generation so saved edits survive restarts.
var ch *Chunk
var loadErr error
var generatedBatch map[[2]int32]*Chunk
if c.store != nil {
if loaded, err := c.store.LoadChunk(cx, cz); err == nil {
ch = loaded
} else if !errors.Is(err, ErrChunkNotFound) {
loadErr = fmt.Errorf("world: load chunk (%d,%d): %w", cx, cz, err)
}
}
if ch == nil && loadErr == nil {
c.mu.Lock()
batchGen := c.batchGen
c.mu.Unlock()
if batchGen != nil {
batch, err := batchGen(cx, cz)
if err != nil {
loadErr = err
} else {
ch = batch[key]
if ch == nil {
loadErr = fmt.Errorf("world: batch generator omitted chunk (%d,%d)", cx, cz)
} else if ch.X != cx || ch.Z != cz {
loadErr = fmt.Errorf("world: batch generator returned chunk (%d,%d) for target (%d,%d)", ch.X, ch.Z, cx, cz)
}
if loadErr == nil {
generatedBatch, loadErr = c.reconcileBatchStore(key, batch)
}
}
} else {
ch = c.gen(cx, cz) // generate outside the lock
if ch == nil {
loadErr = fmt.Errorf("world: generator returned nil chunk (%d,%d)", cx, cz)
}
}
}
c.mu.Lock()
if c.loads[key] != pending {
// An overlapping batch completed this target while its own generation was
// running. Keep the first published result and do not close done twice.
ch, loadErr = pending.ch, pending.err
c.mu.Unlock()
return ch, loadErr
}
if loadErr == nil {
for batchKey, batchChunk := range generatedBatch {
if batchKey == key || batchChunk == nil || batchChunk.X != batchKey[0] || batchChunk.Z != batchKey[1] {
continue
}
if _, exists := c.chunks[batchKey]; !exists {
c.chunks[batchKey] = batchChunk
c.touch(batchKey)
}
if neighborLoad := c.loads[batchKey]; neighborLoad != nil {
neighborLoad.ch = c.chunks[batchKey]
delete(c.loads, batchKey)
close(neighborLoad.done)
}
}
c.chunks[key] = ch
c.touch(key)
c.evictIfNeeded()
}
pending.ch, pending.err = ch, loadErr
delete(c.loads, key)
close(pending.done)
c.mu.Unlock()
return ch, loadErr
}
func (c *Cache) reconcileBatchStore(target [2]int32, batch map[[2]int32]*Chunk) (map[[2]int32]*Chunk, error) {
if c.store == nil {
return batch, nil
}
for key, chunk := range batch {
if key == target || chunk == nil || chunk.X != key[0] || chunk.Z != key[1] {
continue
}
loaded, err := c.store.LoadChunk(key[0], key[1])
if err == nil {
batch[key] = loaded
} else if !errors.Is(err, ErrChunkNotFound) {
return nil, fmt.Errorf("world: load batch neighbor (%d,%d): %w", key[0], key[1], err)
}
}
return batch, nil
}
// 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 {
frame, _ := c.FrameErr(cx, cz)
return frame
}
// FrameErr returns a framed chunk packet while preserving storage failures.
// Callers serving clients should prefer it to Frame so corruption is observable.
func (c *Cache) FrameErr(cx, cz int32) ([]byte, error) {
return c.FrameErrContext(context.Background(), cx, cz)
}
// FrameErrContext is FrameErr with cancellable admission to the shared frame
// worker budget. Cancellation prevents obsolete streamer jobs from starting;
// an operation already admitted completes so cache state is never half-built.
func (c *Cache) FrameErrContext(ctx context.Context, cx, cz int32) ([]byte, error) {
select {
case c.frameSlots <- struct{}{}:
defer func() { <-c.frameSlots }()
case <-ctx.Done():
return nil, ctx.Err()
}
release := c.beginUse([2]int32{cx, cz})
defer release()
return c.frameErr(cx, cz)
}
// PreloadErrContext loads or generates a chunk without calculating lighting or
// encoding a network frame. Streamers use it for predictive terrain work so a
// prefetch ring does not recursively expand through lighting neighborhoods.
func (c *Cache) PreloadErrContext(ctx context.Context, cx, cz int32) error {
select {
case c.frameSlots <- struct{}{}:
defer func() { <-c.frameSlots }()
case <-ctx.Done():
return ctx.Err()
}
release := c.beginUse([2]int32{cx, cz})
defer release()
_, err := c.chunkAtErr(cx, cz)
return err
}
// IsChunkLoaded reports whether a chunk is currently resident in the live
// cache. It does not load, generate, touch, or retain the chunk.
func (c *Cache) IsChunkLoaded(cx, cz int32) bool {
c.mu.Lock()
_, ok := c.chunks[[2]int32{cx, cz}]
c.mu.Unlock()
return ok
}
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
key := [2]int32{cx, cz}
for {
c.mu.Lock()
if f, ok := c.frames[key]; ok {
c.touch(key)
c.mu.Unlock()
return f, nil
}
c.mu.Unlock()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return nil, err
}
if err := c.ensureLight(ch); err != nil {
return nil, err
}
snapshot, revision := ch.snapshot()
frame := protocol.AppendPacket(nil, c.threshold, protocol.PlayLevelChunk, snapshot.encode())
c.mu.Lock()
if existing, ok := c.frames[key]; ok {
c.touch(key)
c.mu.Unlock()
return existing, nil
}
// An edit or eviction while the frame was being built makes it stale.
// Retry from a fresh snapshot instead of publishing old bytes forever.
if c.chunks[key] != ch || ch.currentRevision() != revision {
c.mu.Unlock()
continue
}
c.frames[key] = frame
c.touch(key)
c.evictIfNeeded()
c.mu.Unlock()
return frame, nil
}
}
// 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)
release := c.beginUse([2]int32{cx, cz})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return StateAir
}
return ch.GetBlock(x, y, z)
}
// SafeSpawnY returns a feet-level Y with a supporting floor and two air blocks
// above it. Water columns and decorative plants are skipped rather than
// spawning an entity inside them. Loading happens once for the whole column.
func (c *Cache) SafeSpawnY(x, z int) (int, bool) {
cx := int32(x >> 4)
cz := int32(z >> 4)
release := c.beginUse([2]int32{cx, cz})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return 0, false
}
ch.mu.RLock()
defer ch.mu.RUnlock()
for y := MinY + WorldHeight - 3; y >= MinY; y-- {
floor := ch.getBlock(x, y, z)
if !supportsEntitySpawn(floor) {
continue
}
if ch.getBlock(x, y+1, z) == StateAir && ch.getBlock(x, y+2, z) == StateAir {
return y + 1, true
}
}
return 0, false
}
// LightUpdate returns the standalone light_update body for a loaded chunk.
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
release := c.beginUse([2]int32{cx, cz})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return nil, err
}
if err := c.ensureLight(ch); err != nil {
return nil, err
}
return ch.EncodeLightUpdate(), nil
}
// SetBlock changes a block and incrementally updates lighting. Callers that need
// to broadcast every affected light chunk should use SetBlockWithLight.
func (c *Cache) SetBlock(x, y, z int, state uint16) bool {
valid, _ := c.SetBlockWithLight(x, y, z, state)
return valid
}
// ChunkPos identifies a chunk changed by a lighting update.
type ChunkPos struct {
X, Z int32
}
// SetBlockWithLight changes a block and returns the loaded chunks whose stored
// light changed. Lighting operations are serialized so concurrent edits cannot
// publish mutually stale propagation results.
func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos) {
if y < MinY || y >= MinY+WorldHeight {
return false, nil
}
cx := int32(x >> 4)
cz := int32(z >> 4)
release := c.beginUse([2]int32{cx, cz})
defer release()
ch, err := c.chunkAtErr(cx, cz)
if err != nil {
return false, nil
}
c.lightMu.Lock()
defer c.lightMu.Unlock()
live := c.cachedLightNeighborhood(cx, cz)
for _, neighbor := range live {
if err := c.ensureLightLocked(neighbor); err != nil {
return false, nil
}
}
_, changed := ch.setBlock(x, y, z, state)
if !changed {
return true, nil
}
lightChanged, err := c.updateLightAfterBlockLocked(x, y, z, live)
if err != nil {
// The block edit is still valid and dirty; a later Frame call will rebuild
// its light from the authoritative blocks.
ch.mu.Lock()
ch.lightReady = false
ch.lightValidated = false
ch.mu.Unlock()
lightChanged = []ChunkPos{{X: cx, Z: cz}}
}
c.mu.Lock()
key := [2]int32{cx, cz}
delete(c.frames, key)
if c.store != nil {
c.dirty[key] = ch.currentRevision()
}
c.touch(key) // edited chunk is most-recently-used
c.mu.Unlock()
return true, lightChanged
}
// 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()
key := [2]int32{cx, cz}
if ch := c.chunks[key]; ch != nil {
c.dirty[key] = ch.currentRevision()
}
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.mu.Unlock()
var firstErr error
for _, k := range keys {
ch := chunks[k]
if ch == nil {
c.mu.Lock()
delete(c.dirty, k)
c.mu.Unlock()
continue
}
snapshot, savedRevision := ch.snapshot()
if err := c.store.saveSnapshot(snapshot); err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
c.mu.Lock()
if dirtyRevision, ok := c.dirty[k]; ok && dirtyRevision <= savedRevision {
delete(c.dirty, k)
}
c.evictIfNeeded()
c.mu.Unlock()
}
return firstErr
}
// 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
}
return c.flushDirty()
}