Implement ticketed chunk lifecycle

This commit is contained in:
Master290 2026-07-21 10:04:56 +03:00
parent cae06eb97e
commit cbd5c7b546
12 changed files with 696 additions and 100 deletions

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"runtime"
"sync"
"time"
@ -38,11 +39,16 @@ type Cache struct {
store *Store // nil = in-memory only (tests, flat worlds)
maxChunks int // LRU capacity; 0 = unbounded
mu sync.Mutex
lightMu sync.Mutex
chunks map[[2]int32]*Chunk
frames map[[2]int32][]byte
dirty map[[2]int32]uint64
mu sync.Mutex
lightMu sync.Mutex
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
@ -61,13 +67,23 @@ type chunkLoad struct {
// 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),
loads: make(map[[2]int32]*chunkLoad),
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
}
@ -124,6 +140,11 @@ func (c *Cache) evictIfNeeded() {
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.
@ -209,6 +230,25 @@ func (c *Cache) Frame(cx, cz int32) []byte {
// 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)
}
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
key := [2]int32{cx, cz}
for {
@ -258,6 +298,8 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
}
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
@ -267,6 +309,8 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
// 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
@ -298,6 +342,8 @@ func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos)
}
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
@ -321,6 +367,7 @@ func (c *Cache) SetBlockWithLight(x, y, z int, state uint16) (bool, []ChunkPos)
// its light from the authoritative blocks.
ch.mu.Lock()
ch.lightReady = false
ch.lightValidated = false
ch.mu.Unlock()
lightChanged = []ChunkPos{{X: cx, Z: cz}}
}

View file

@ -18,7 +18,7 @@ func (c *Cache) ensureLight(chunk *Chunk) error {
func (c *Cache) ensureLightLocked(chunk *Chunk) error {
for {
center, revision := chunk.snapshot()
if center.lightReady {
if center.lightReady && center.lightValidated {
return nil
}
@ -48,11 +48,18 @@ func (c *Cache) ensureLightLocked(chunk *Chunk) error {
chunk.mu.Unlock()
continue
}
chunk.installLight(volume)
changed := chunk.installLight(volume)
if center.lightReady && changed {
revision = chunk.revision.Add(1)
}
chunk.mu.Unlock()
c.mu.Lock()
delete(c.frames, [2]int32{chunk.X, chunk.Z})
key := [2]int32{chunk.X, chunk.Z}
delete(c.frames, key)
if c.store != nil && center.lightReady && changed {
c.dirty[key] = revision
}
c.mu.Unlock()
return nil
}

View file

@ -0,0 +1,124 @@
package world
import "sync"
func (c *Cache) beginUse(key [2]int32) func() {
c.mu.Lock()
c.inflight[key]++
c.mu.Unlock()
return func() {
c.mu.Lock()
c.inflight[key]--
if c.inflight[key] <= 0 {
delete(c.inflight, key)
}
c.evictIfNeeded()
c.mu.Unlock()
}
}
// TicketLevel describes why a streamer keeps a chunk resident. View tickets
// are client-visible; Prefetch tickets retain the predictive outer ring.
type TicketLevel uint8
const (
TicketView TicketLevel = iota
TicketPrefetch
)
// TicketSet is one owner's atomic chunk-residency claim. A streamer replaces
// the complete set on recenter and closes it on disconnect. Multiple sets may
// overlap; a chunk becomes evictable only after its final owner releases it.
type TicketSet struct {
mu sync.Mutex
cache *Cache
held map[[2]int32]TicketLevel
closed bool
}
// NewTicketSet creates an empty ticket set associated with this cache.
func (c *Cache) NewTicketSet() *TicketSet {
return &TicketSet{cache: c, held: make(map[[2]int32]TicketLevel)}
}
// Replace atomically changes the ticket set. View entries take precedence when
// a coordinate is present in both slices.
func (t *TicketSet) Replace(view, prefetch []ChunkPos) {
if t == nil || t.cache == nil {
return
}
desired := make(map[[2]int32]TicketLevel, len(view)+len(prefetch))
for _, pos := range prefetch {
desired[[2]int32{pos.X, pos.Z}] = TicketPrefetch
}
for _, pos := range view {
desired[[2]int32{pos.X, pos.Z}] = TicketView
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return
}
t.cache.mu.Lock()
for key := range t.held {
if _, keep := desired[key]; keep {
continue
}
t.cache.tickets[key]--
if t.cache.tickets[key] <= 0 {
delete(t.cache.tickets, key)
}
}
for key := range desired {
if _, alreadyHeld := t.held[key]; !alreadyHeld {
t.cache.tickets[key]++
}
}
t.held = desired
t.cache.evictIfNeeded()
t.cache.mu.Unlock()
}
// Close releases every ticket. It is safe to call more than once.
func (t *TicketSet) Close() {
if t == nil || t.cache == nil {
return
}
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return
}
t.cache.mu.Lock()
for key := range t.held {
t.cache.tickets[key]--
if t.cache.tickets[key] <= 0 {
delete(t.cache.tickets, key)
}
}
clear(t.held)
t.closed = true
t.cache.evictIfNeeded()
t.cache.mu.Unlock()
}
// CacheStats is a concurrency-safe lifecycle snapshot used by diagnostics and
// load tests.
type CacheStats struct {
Chunks int
Frames int
TicketedChunks int
Tickets int
}
// Stats returns current cache and ticket counts.
func (c *Cache) Stats() CacheStats {
c.mu.Lock()
defer c.mu.Unlock()
stats := CacheStats{Chunks: len(c.chunks), Frames: len(c.frames), TicketedChunks: len(c.tickets)}
for _, count := range c.tickets {
stats.Tickets += count
}
return stats
}

View file

@ -0,0 +1,78 @@
package world
import (
"context"
"errors"
"testing"
)
func TestTicketPinsChunkUntilRelease(t *testing.T) {
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, nil, 2)
tickets := cache.NewTicketSet()
tickets.Replace([]ChunkPos{{X: 0, Z: 0}}, nil)
for x := int32(0); x < 4; x++ {
if _, err := cache.FrameErr(x, 0); err != nil {
t.Fatal(err)
}
}
if !hasChunk(cache, 0, 0) {
t.Fatal("ticketed chunk was evicted")
}
if got := cache.Stats().Tickets; got != 1 {
t.Fatalf("ticket count = %d, want 1", got)
}
tickets.Close()
if got := cache.Stats(); got.Tickets != 0 || got.Chunks > 2 {
t.Fatalf("after release stats = %+v, want no tickets and <=2 chunks", got)
}
}
func TestOverlappingTicketSetsRequireFinalRelease(t *testing.T) {
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, nil, 1)
first, second := cache.NewTicketSet(), cache.NewTicketSet()
pos := []ChunkPos{{X: 0, Z: 0}}
first.Replace(pos, nil)
second.Replace(nil, pos)
if _, err := cache.FrameErr(0, 0); err != nil {
t.Fatal(err)
}
first.Close()
if got := cache.Stats().Tickets; got != 1 {
t.Fatalf("tickets after first close = %d, want 1", got)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
if !hasChunk(cache, 0, 0) {
t.Fatal("chunk was evicted while second owner still held it")
}
second.Close()
second.Close() // idempotent
if got := cache.Stats(); got.Tickets != 0 || got.Chunks > 1 {
t.Fatalf("after final close stats = %+v", got)
}
}
func TestFrameAdmissionCanBeCancelled(t *testing.T) {
cache := NewCache(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
})
for i := 0; i < cap(cache.frameSlots); i++ {
cache.frameSlots <- struct{}{}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := cache.FrameErrContext(ctx, 0, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("FrameErrContext error = %v, want context.Canceled", err)
}
for i := 0; i < cap(cache.frameSlots); i++ {
<-cache.frameSlots
}
}

View file

@ -77,7 +77,10 @@ type Chunk struct {
skyLight [SectionCount]*[2048]byte
blockLight [SectionCount]*[2048]byte
lightReady bool
biome uint16 // fallback uniform biome when biomes[si] is nil
// lightValidated is runtime-only. Persisted arrays are ready to read but are
// reconciled with current neighbor blocks once after entering a live cache.
lightValidated bool
biome uint16 // fallback uniform biome when biomes[si] is nil
}
// NewChunk returns an empty (all-air) chunk at (x, z) with the given biome.
@ -140,6 +143,7 @@ func (c *Chunk) SetBlock(lx, y, lz int, state uint16) {
if changed {
c.mu.Lock()
c.lightReady = false
c.lightValidated = false
c.mu.Unlock()
}
}
@ -243,6 +247,7 @@ func (c *Chunk) snapshot() (*Chunk, uint64) {
}
}
clone.lightReady = c.lightReady
clone.lightValidated = c.lightValidated
return clone, revision
}

View file

@ -331,6 +331,7 @@ func (c *Chunk) installLight(v *lightVolume) bool {
c.skyLight = sky
c.blockLight = block
c.lightReady = true
c.lightValidated = true
return changed
}

View file

@ -185,6 +185,85 @@ func TestStoreLightRoundTrip(t *testing.T) {
}
}
func TestCacheReconcilesPersistedLightWithLoadedNeighbor(t *testing.T) {
dir := t.TempDir()
store, err := NewStore(dir)
if err != nil {
t.Fatal(err)
}
glowstone := nameToStateID("minecraft:glowstone", nil)
left := NewChunk(0, 0, BiomePlains)
left.SetBlock(15, 100, 8, glowstone)
if err := store.SaveChunk(left); err != nil {
t.Fatal(err)
}
// Simulate a chunk saved before its unloaded neighbor gained a light source.
right := NewChunk(1, 0, BiomePlains)
right.lightReady = true
if err := store.SaveChunk(right); err != nil {
t.Fatal(err)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
cache := NewCacheWithLimit(-1, func(cx, cz int32) *Chunk {
return NewChunk(cx, cz, BiomePlains)
}, store, 1)
tickets := cache.NewTicketSet()
tickets.Replace([]ChunkPos{{X: 1, Z: 0}}, nil)
loaded, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 0 {
t.Fatalf("persisted pre-reconcile light = %d ready=%v, want stale zero", block, ready)
}
if _, err := cache.FrameErr(1, 0); err != nil {
t.Fatal(err)
}
if _, block, ready := loaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("reconciled border light = %d ready=%v, want 14", block, ready)
}
if err := cache.SaveAll(); err != nil {
t.Fatal(err)
}
tickets.Close()
if _, err := cache.FrameErr(10, 0); err != nil {
t.Fatal(err)
}
if hasChunk(cache, 1, 0) {
t.Fatal("released light chunk remained resident after LRU replacement")
}
reloadedAfterEviction, err := cache.chunkAtErr(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloadedAfterEviction.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("light after ticket unload/reload = %d ready=%v, want 14", block, ready)
}
if err := store.Close(); err != nil {
t.Fatal(err)
}
store, err = NewStore(dir)
if err != nil {
t.Fatal(err)
}
defer store.Close()
reloaded, err := store.LoadChunk(1, 0)
if err != nil {
t.Fatal(err)
}
if _, block, ready := reloaded.LightAt(0, 100, 8); !ready || block != 14 {
t.Fatalf("persisted reconciled light = %d ready=%v, want 14", block, ready)
}
}
// TestStoreSaveLoadIntegration is the end-to-end "world survives restart" test:
// generate a chunk via a store-backed cache, edit a block, SaveAll, then open a
// fresh cache over the same store and confirm the edit is present.