diff --git a/Cargo.lock b/Cargo.lock index 1991352..79b7579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index d1cecfa..3755ed4 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -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 } diff --git a/crates/server/src/chunk_cache.rs b/crates/server/src/chunk_cache.rs new file mode 100644 index 0000000..4189aa0 --- /dev/null +++ b/crates/server/src/chunk_cache.rs @@ -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>>, +} + +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))); + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 626b242..3fa8377 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -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(( diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index deb2b6b..bf380eb 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -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::(); 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, pos: ChunkPos) -> Chunk { +fn load_chunk( + generator: &VoxelGenerator, + save_tx: &Sender, + 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.