feat(server): add LRU chunk cache
This commit is contained in:
parent
e0dfcde529
commit
955d62bfcf
21
Cargo.lock
generated
21
Cargo.lock
generated
|
|
@ -40,6 +40,12 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
|
|
@ -912,6 +918,11 @@ name = "hashbrown"
|
|||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
|
|
@ -1101,6 +1112,15 @@ version = "0.4.29"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b6180140927ee907000b0aa540091f6ea512ead4447c92b8fc35bc72788a5a6"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
|
|
@ -1861,6 +1881,7 @@ dependencies = [
|
|||
"bevy_ecs",
|
||||
"crossbeam-channel",
|
||||
"glam 0.33.2",
|
||||
"lru",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"tempfile",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ anyhow = { workspace = true }
|
|||
bevy_ecs = "0.19"
|
||||
crossbeam-channel = "0.5.16"
|
||||
glam = { workspace = true }
|
||||
lru = "0.18.1"
|
||||
serde_json = { workspace = true }
|
||||
shared = { version = "0.1.0", path = "../shared" }
|
||||
tracing = { workspace = true }
|
||||
|
|
|
|||
114
crates/server/src/chunk_cache.rs
Normal file
114
crates/server/src/chunk_cache.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! A bounded cache of regenerated chunk baselines, shared across the worker pool.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lru::LruCache;
|
||||
use shared::generator::VoxelGenerator;
|
||||
use shared::world::{Chunk, ChunkPos};
|
||||
|
||||
/// A least-recently-used cache of chunk baselines, cloneable so every worker shares one store.
|
||||
#[derive(Clone)]
|
||||
pub struct ChunkCache {
|
||||
/// The shared LRU store.
|
||||
inner: Arc<Mutex<LruCache<ChunkPos, Chunk>>>,
|
||||
}
|
||||
|
||||
impl ChunkCache {
|
||||
/// Creates an empty cache holding at most `capacity` baselines before evicting the least-recently-used entry.
|
||||
#[must_use]
|
||||
pub fn new(capacity: NonZeroUsize) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(LruCache::new(capacity))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the baseline for `pos`, generating and caching it on a miss.
|
||||
#[must_use]
|
||||
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk {
|
||||
// A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache.
|
||||
let mut guard = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(hit) = guard.get(&pos) {
|
||||
return hit.clone();
|
||||
}
|
||||
let chunk = generator.generate_chunk(pos);
|
||||
guard.put(pos, chunk.clone());
|
||||
chunk
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl ChunkCache {
|
||||
/// Number of baselines currently resident. Test-only introspection.
|
||||
fn len(&self) -> usize {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
|
||||
/// Whether a baseline for `pos` is currently resident. Test-only introspection.
|
||||
fn contains(&self, pos: ChunkPos) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.contains(&pos)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ChunkCache;
|
||||
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
||||
use shared::world::{BlockId, ChunkPos};
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
/// Builds a generator with a small, cheap terrain configuration for cache tests.
|
||||
fn test_generator() -> VoxelGenerator {
|
||||
let config = WorldGenConfig {
|
||||
base_height: 8,
|
||||
noise_scale: 0.05,
|
||||
surface_block: BlockId(1),
|
||||
subsurface_block: BlockId(2),
|
||||
stone_block: BlockId(3),
|
||||
};
|
||||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// A second lookup of the same position must be served from the store, not regenerated.
|
||||
#[test]
|
||||
fn repeated_lookup_is_a_cache_hit() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
|
||||
let pos = ChunkPos::new(0, 0, 0);
|
||||
|
||||
let first = cache.get_or_generate(pos, &generator);
|
||||
let second = cache.get_or_generate(pos, &generator);
|
||||
|
||||
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
|
||||
assert_eq!(first.blocks, second.blocks);
|
||||
assert_eq!(cache.len(), 1);
|
||||
}
|
||||
|
||||
/// Inserting beyond capacity evicts the least-recently-used entry.
|
||||
#[test]
|
||||
fn exceeding_capacity_evicts_oldest() {
|
||||
let generator = test_generator();
|
||||
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
|
||||
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
|
||||
// Touch the first so the second becomes the least-recently-used before the overflow.
|
||||
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
||||
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
|
||||
|
||||
assert_eq!(cache.len(), 2);
|
||||
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
|
||||
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
//! The server handles the authoritative game simulation, including world
|
||||
//! management, physics, and combat.
|
||||
|
||||
/// A bounded LRU cache of regenerated chunk baselines, shared across the worker pool.
|
||||
pub mod chunk_cache;
|
||||
/// Entity components describing players and other world-streaming anchors.
|
||||
pub mod player;
|
||||
/// On-disk persistence: region files and the atomic durability layer.
|
||||
|
|
@ -73,11 +75,17 @@ fn main() -> anyhow::Result<()> {
|
|||
|
||||
let generator = VoxelGenerator::new(worldgen_config, seed);
|
||||
|
||||
// The region directory holds the `.region` save files for this world; a later slice will resolve it per named world under the platform user-data directory.
|
||||
// The region directory holds the `.region` save files for this world
|
||||
// TODO: resolve it per named world under a shared save root.
|
||||
let region_dir = std::path::PathBuf::from("saves/default/region");
|
||||
|
||||
// Number of chunk baselines the worker pool retains before evicting the least-recently-used entry
|
||||
// TODO: make this configurable through server configs
|
||||
let cache_capacity =
|
||||
std::num::NonZeroUsize::new(4_096).context("chunk cache capacity is non-zero")?;
|
||||
|
||||
let mut world = World::new();
|
||||
world.insert_resource(ServerWorld::new(generator, region_dir));
|
||||
world.insert_resource(ServerWorld::new(generator, region_dir, cache_capacity));
|
||||
|
||||
// Spawn a single dummy player anchor at the world origin.
|
||||
world.spawn((
|
||||
|
|
|
|||
|
|
@ -11,12 +11,14 @@ use shared::{
|
|||
};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
num::NonZeroUsize,
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
thread::JoinHandle,
|
||||
};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::chunk_cache::ChunkCache;
|
||||
use crate::save::{SaveActor, SaveRequest};
|
||||
|
||||
/// Outcome of a single streaming reconcile pass, surfaced for logging and tests.
|
||||
|
|
@ -54,9 +56,13 @@ pub struct ServerWorld {
|
|||
}
|
||||
|
||||
impl ServerWorld {
|
||||
/// Initializes a new authoritative server world with the provided generator, resolving chunk loads against region files under `region_dir`.
|
||||
/// Initializes a new authoritative server world with the provided generator, resolving chunk loads against region files under `region_dir` and caching up to `cache_capacity` regenerated baselines.
|
||||
#[must_use]
|
||||
pub fn new(generator: VoxelGenerator, region_dir: PathBuf) -> Self {
|
||||
pub fn new(
|
||||
generator: VoxelGenerator,
|
||||
region_dir: PathBuf,
|
||||
cache_capacity: NonZeroUsize,
|
||||
) -> Self {
|
||||
let generator = Arc::new(generator);
|
||||
let (job_tx, job_rx) = crossbeam_channel::unbounded::<ChunkPos>();
|
||||
let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>();
|
||||
|
|
@ -64,20 +70,24 @@ impl ServerWorld {
|
|||
// The actor owns every region file; workers reach it only through cloned request senders.
|
||||
let save_actor = SaveActor::spawn(region_dir);
|
||||
|
||||
// Baselines are shared across the pool through cloned handles onto one bounded store.
|
||||
let cache = ChunkCache::new(cache_capacity);
|
||||
|
||||
let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get);
|
||||
|
||||
let workers = (0..worker_count)
|
||||
.map(|_| {
|
||||
// Each worker shares a handle to the read-only generator, its own view of the shared job queue, its own sender back into the result channel, and its own request sender to the save actor.
|
||||
// Each worker shares a handle to the read-only generator, its own view of the shared job queue, its own sender back into the result channel, its own request sender to the save actor, and a handle onto the shared baseline cache.
|
||||
let generator = Arc::clone(&generator);
|
||||
let job_rx = job_rx.clone();
|
||||
let result_tx = result_tx.clone();
|
||||
let save_tx = save_actor.sender();
|
||||
let cache = cache.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Block until a job arrives.
|
||||
while let Ok(pos) = job_rx.recv() {
|
||||
let chunk = load_chunk(&generator, &save_tx, pos);
|
||||
let chunk = load_chunk(&generator, &save_tx, &cache, pos);
|
||||
// A send error means the main thread has gone away; nothing is left to do but let the worker wind down.
|
||||
if result_tx.send((pos, chunk)).is_err() {
|
||||
break;
|
||||
|
|
@ -168,19 +178,24 @@ impl ServerWorld {
|
|||
}
|
||||
|
||||
/// Resolves a chunk position to dense voxel data: a saved modification is applied over its baseline, otherwise the deterministic baseline is regenerated directly.
|
||||
fn load_chunk(generator: &VoxelGenerator, save_tx: &Sender<SaveRequest>, pos: ChunkPos) -> Chunk {
|
||||
fn load_chunk(
|
||||
generator: &VoxelGenerator,
|
||||
save_tx: &Sender<SaveRequest>,
|
||||
cache: &ChunkCache,
|
||||
pos: ChunkPos,
|
||||
) -> Chunk {
|
||||
match request_saved_chunk(save_tx, pos) {
|
||||
Ok(Some(data)) => {
|
||||
// A saved modification stores only edits, so the baseline is regenerated and the edits are layered on top.
|
||||
// TODO: once worldgen versioning exists, the baseline must be regenerated at `data.worldgen_version()` rather than the current version; today there is a single version, so the current baseline matches.
|
||||
data.materialize(&generator.generate_chunk(pos))
|
||||
data.materialize(&cache.get_or_generate(pos, generator))
|
||||
}
|
||||
// The chunk was never modified, so its content is exactly the deterministic baseline.
|
||||
Ok(None) => generator.generate_chunk(pos),
|
||||
Ok(None) => cache.get_or_generate(pos, generator),
|
||||
Err(error) => {
|
||||
// A save-layer failure must not wedge streaming; the chunk falls back to a fresh baseline and the error is logged.
|
||||
warn!(?error, ?pos, "chunk load failed; regenerating baseline");
|
||||
generator.generate_chunk(pos)
|
||||
cache.get_or_generate(pos, generator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -248,9 +263,10 @@ mod tests {
|
|||
VoxelGenerator::new(config, 42)
|
||||
}
|
||||
|
||||
/// Builds a server world whose saves resolve against `region_dir`.
|
||||
/// Builds a server world whose saves resolve against `region_dir`, backed by a small baseline cache.
|
||||
fn test_world(region_dir: std::path::PathBuf) -> ServerWorld {
|
||||
ServerWorld::new(test_generator(), region_dir)
|
||||
let capacity = std::num::NonZeroUsize::new(64).unwrap_or(std::num::NonZeroUsize::MIN);
|
||||
ServerWorld::new(test_generator(), region_dir, capacity)
|
||||
}
|
||||
|
||||
/// Repeatedly reconciles `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout.
|
||||
|
|
|
|||
Loading…
Reference in a new issue