From 1db124e63b158bb1f5a3dae6838fc0446a7ec5ae Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 9 Jul 2026 15:07:26 +0200 Subject: [PATCH] feat(server): add atomic region-file durability layer --- Cargo.lock | 14 + crates/server/Cargo.toml | 3 + crates/server/src/main.rs | 2 + crates/server/src/save/mod.rs | 13 + crates/server/src/save/region_file.rs | 353 ++++++++++++++++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 crates/server/src/save/mod.rs create mode 100644 crates/server/src/save/region_file.rs diff --git a/Cargo.lock b/Cargo.lock index 3103951..1991352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1863,6 +1863,7 @@ dependencies = [ "glam 0.33.2", "serde_json", "shared", + "tempfile", "tracing", "tracing-subscriber", ] @@ -2008,6 +2009,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index e76c546..d1cecfa 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -17,3 +17,6 @@ serde_json = { workspace = true } shared = { version = "0.1.0", path = "../shared" } tracing = { workspace = true } tracing-subscriber = { workspace = true } + +[dev-dependencies] +tempfile = "3.27.0" diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 5752e95..e87a33a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -7,6 +7,8 @@ /// Entity components describing players and other world-streaming anchors. pub mod player; +/// On-disk persistence: region files and the atomic durability layer. +pub mod save; /// Authoritative chunk storage and generation logic for the server. pub mod world_server; diff --git a/crates/server/src/save/mod.rs b/crates/server/src/save/mod.rs new file mode 100644 index 0000000..92ce02d --- /dev/null +++ b/crates/server/src/save/mod.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Server-side persistence: the durability layer over the `shared` save format. +//! +//! `shared::save` owns the pure, in-memory framing (the `SYNR` index and `SYNC` records). This +//! module owns the filesystem side: reading a `.region` file into memory, mutating its chunks, and +//! flushing it back to disk crash-safely. The write strategy is a whole-file atomic rewrite +//! (`.tmp` + fsync + rename); the on-disk format is unchanged, so a later slice can switch to an +//! append-in-place strategy without a format change. + +mod region_file; + +pub use region_file::{REGION_SIZE, RegionFile, region_coords, region_path}; diff --git a/crates/server/src/save/region_file.rs b/crates/server/src/save/region_file.rs new file mode 100644 index 0000000..26e01ad --- /dev/null +++ b/crates/server/src/save/region_file.rs @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! The durability layer for a single region file. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use shared::save::SaveError; +use shared::save::record; +use shared::save::region::{HeaderEntry, RegionIndex}; +use shared::world::{ChunkData, ChunkPos}; + +/// The side length, in chunk columns, of the square footprint one region file covers. +pub const REGION_SIZE: i32 = 32; + +/// Maps a chunk column `(cx, cz)` to the coordinates `(rx, rz)` of the region that contains it. +#[must_use] +pub fn region_coords(cx: i32, cz: i32) -> (i32, i32) { + (cx.div_euclid(REGION_SIZE), cz.div_euclid(REGION_SIZE)) +} + +/// Builds the on-disk path of the region file containing chunk column `(cx, cz)` within `dir`. +#[must_use] +pub fn region_path(dir: &Path, cx: i32, cz: i32) -> PathBuf { + let (rx, rz) = region_coords(cx, cz); + dir.join(format!("r.{rx}.{rz}.region")) +} + +/// An open region file: its `SYNR` index, the resident chunk records, and its on-disk location. +pub struct RegionFile { + /// The region framing (header table, free list, stamp table) held in memory. + index: RegionIndex, + /// Each resident chunk's raw `SYNC` record bytes, decoded lazily on read. + records: BTreeMap>, + /// The path this region is read from and written back to. + path: PathBuf, + /// Whether an in-memory mutation is pending a flush to disk. + dirty: bool, +} + +impl RegionFile { + /// Opens the region file at `path`, or yields an empty region if the file does not yet exist. + pub fn open(path: PathBuf) -> Result { + if !path.exists() { + return Ok(Self { + index: RegionIndex::new(0), + records: BTreeMap::new(), + path, + dirty: false, + }); + } + + let bytes = fs::read(&path)?; + let index = RegionIndex::decode(&bytes)?; + + // Slice each record out of the file by the absolute (offset, length) the header table records. On-disk bytes are untrusted, so an out-of-range span is rejected rather than panicking on the slice. + let mut records = BTreeMap::new(); + for (pos, entry) in index.entries() { + // Widening u32 -> usize is lossless on every supported (64-bit) target; the u64 offset is range-checked by try_from, failing loudly on a 32-bit target rather than wrapping. + let start = usize::try_from(entry.offset).map_err(|_| SaveError::PayloadTooLarge { + len: entry.length as usize, + })?; + let end = + start + .checked_add(entry.length as usize) + .ok_or(SaveError::PayloadTooLarge { + len: entry.length as usize, + })?; + let record_bytes = bytes + .get(start..end) + .ok_or(SaveError::Truncated { + offset: start, + needed: entry.length as usize, + available: bytes.len().saturating_sub(start), + })? + .to_vec(); + records.insert(*pos, record_bytes); + } + + Ok(Self { + index, + records, + path, + dirty: false, + }) + } + + /// Returns the number of resident chunk records. + #[must_use] + pub fn len(&self) -> usize { + self.records.len() + } + + /// Returns whether the region holds no chunk records. + #[must_use] + pub fn is_empty(&self) -> bool { + self.records.is_empty() + } + + /// Whether an in-memory mutation is pending a flush to disk. + #[must_use] + pub fn is_dirty(&self) -> bool { + self.dirty + } + + /// Decodes and returns the chunk at `pos`, or `None` if the region holds no record for it. + pub fn read_chunk(&self, pos: ChunkPos) -> Result, SaveError> { + match self.records.get(&pos) { + Some(bytes) => { + let (_meta, data) = record::decode(bytes)?; + Ok(Some(data)) + } + None => Ok(None), + } + } + + /// Encodes `data` into a `SYNC` record stamped with `last_modified` and stores it under `pos`. + pub fn write_chunk( + &mut self, + pos: ChunkPos, + data: &ChunkData, + last_modified: u64, + ) -> Result<(), SaveError> { + let bytes = record::encode(data, last_modified)?; + let length = u32::try_from(bytes.len()) + .map_err(|_| SaveError::PayloadTooLarge { len: bytes.len() })?; + self.records.insert(pos, bytes); + self.index.insert( + pos, + HeaderEntry { + offset: 0, + length, + flags: 0, + }, + ); + self.dirty = true; + Ok(()) + } + + /// Removes the chunk at `pos` from the region, if present. + pub fn remove_chunk(&mut self, pos: ChunkPos) { + let removed = self.records.remove(&pos).is_some(); + self.index.remove(pos); + if removed { + self.dirty = true; + } + } + + /// Flushes the region to disk with a crash-safe whole-file atomic rewrite, clearing the dirty flag. + pub fn save(&mut self) -> Result<(), SaveError> { + let image = self.serialize()?; + atomic_write(&self.path, &image)?; + self.dirty = false; + Ok(()) + } + + /// Builds the complete on-disk file image: the encoded index followed by every record. + fn serialize(&mut self) -> Result, SaveError> { + let index_len = self.index.encode()?.len(); + + // Assign each record a contiguous offset in ascending position order (BTreeMap order), the same order the records are concatenated below. + let mut offset = index_len as u64; + for (pos, bytes) in &self.records { + let length = u32::try_from(bytes.len()) + .map_err(|_| SaveError::PayloadTooLarge { len: bytes.len() })?; + self.index.insert( + *pos, + HeaderEntry { + offset, + length, + flags: 0, + }, + ); + offset += bytes.len() as u64; + } + + let mut image = self.index.encode()?; + for bytes in self.records.values() { + image.extend_from_slice(bytes); + } + Ok(image) + } +} + +/// Writes `bytes` to `path` via the POSIX atomic-write pattern: `.tmp` + fsync + rename. +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SaveError> { + // The region directory is created on demand so the first write to a fresh world succeeds. + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + // The temp path appends ".tmp" to the full filename (rather than replacing the extension) so it sits beside the target on the same filesystem, keeping the rename atomic. + let mut tmp_name: OsString = path.as_os_str().to_owned(); + tmp_name.push(".tmp"); + let tmp_path = PathBuf::from(tmp_name); + + let mut file = File::create(&tmp_path)?; + file.write_all(bytes)?; + // fsync the data to disk before the rename, so the rename cannot expose an unwritten file. + file.sync_all()?; + drop(file); + + fs::rename(&tmp_path, path)?; + + // fsync the parent directory so the rename itself is durable. Opening a directory for fsync is a Unix affordance; Windows does not expose a directory handle to sync, so the step is skipped there. + #[cfg(unix)] + if let Some(parent) = path.parent() { + File::open(parent)?.sync_all()?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared::world::BlockId; + + /// Builds a representative modified chunk with a few edits spanning the local index range. + fn sample(pos: ChunkPos) -> ChunkData { + let mut data = ChunkData::new(pos, 7); + data.set(0, BlockId(4)); + data.set(1000, BlockId(9)); + data.set(32_767, BlockId(2)); + data + } + + #[test] + fn region_coords_floor_negative_columns() { + // Truncating division would map -1 to region 0; Euclidean flooring maps it to region -1. + assert_eq!(region_coords(0, 0), (0, 0)); + assert_eq!(region_coords(31, 31), (0, 0)); + assert_eq!(region_coords(-1, -1), (-1, -1)); + assert_eq!(region_coords(-32, -33), (-1, -2)); + } + + #[test] + fn region_path_names_the_region_file() { + let dir = Path::new("/saves/world/region"); + assert_eq!( + region_path(dir, -1, 5), + Path::new("/saves/world/region/r.-1.0.region") + ); + } + + #[test] + fn open_missing_file_is_empty() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let region = RegionFile::open(dir.path().join("r.0.0.region"))?; + assert!(region.is_empty()); + assert_eq!(region.read_chunk(ChunkPos::new(0, 0, 0))?, None); + Ok(()) + } + + #[test] + fn round_trips_chunks_through_disk() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + + let positions = [ + ChunkPos::new(0, 0, 0), + ChunkPos::new(1, 2, 3), + ChunkPos::new(-5, 10, -30), + ]; + + let mut region = RegionFile::open(path.clone())?; + for pos in positions { + region.write_chunk(pos, &sample(pos), 123)?; + } + assert!(region.is_dirty()); + region.save()?; + assert!(!region.is_dirty()); + + // Reopen from disk in a fresh instance and confirm every chunk decodes byte-identically. + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.len(), positions.len()); + for pos in positions { + assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); + } + // A position never written has no record. + assert_eq!(reopened.read_chunk(ChunkPos::new(9, 9, 9))?, None); + Ok(()) + } + + #[test] + fn record_offsets_are_valid_and_contiguous() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + + let mut region = RegionFile::open(path.clone())?; + for pos in [ + ChunkPos::new(0, 0, 0), + ChunkPos::new(2, 0, 1), + ChunkPos::new(-1, 4, -1), + ] { + region.write_chunk(pos, &sample(pos), 0)?; + } + region.save()?; + + let reopened = RegionFile::open(path)?; + let index_len = reopened.index.encode()?.len() as u64; + + // Records are packed contiguously immediately after the index, in ascending position order. + let mut expected_offset = index_len; + for (_pos, entry) in reopened.index.entries() { + assert_eq!(entry.offset, expected_offset); + expected_offset += u64::from(entry.length); + } + Ok(()) + } + + #[test] + fn remove_drops_only_the_named_chunk() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + let kept = ChunkPos::new(0, 0, 0); + let dropped = ChunkPos::new(1, 1, 1); + + let mut region = RegionFile::open(path.clone())?; + region.write_chunk(kept, &sample(kept), 0)?; + region.write_chunk(dropped, &sample(dropped), 0)?; + region.save()?; + + let mut region = RegionFile::open(path.clone())?; + region.remove_chunk(dropped); + region.save()?; + + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.read_chunk(dropped)?, None); + assert_eq!(reopened.read_chunk(kept)?, Some(sample(kept))); + Ok(()) + } + + #[test] + fn stray_tmp_file_does_not_corrupt_reads() -> Result<(), SaveError> { + let dir = tempfile::tempdir()?; + let path = dir.path().join("r.0.0.region"); + let pos = ChunkPos::new(0, 0, 0); + + let mut region = RegionFile::open(path.clone())?; + region.write_chunk(pos, &sample(pos), 0)?; + region.save()?; + + // A leftover .tmp from an interrupted save must be ignored: only the renamed target is read. + fs::write(dir.path().join("r.0.0.region.tmp"), b"garbage")?; + let reopened = RegionFile::open(path)?; + assert_eq!(reopened.read_chunk(pos)?, Some(sample(pos))); + Ok(()) + } +}