feat(server): add atomic region-file durability layer

This commit is contained in:
Serkyo 2026-07-09 15:07:26 +02:00
parent a4f2bb32bc
commit 1db124e63b
5 changed files with 385 additions and 0 deletions

14
Cargo.lock generated
View file

@ -1863,6 +1863,7 @@ dependencies = [
"glam 0.33.2", "glam 0.33.2",
"serde_json", "serde_json",
"shared", "shared",
"tempfile",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -2008,6 +2009,19 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"

View file

@ -17,3 +17,6 @@ serde_json = { workspace = true }
shared = { version = "0.1.0", path = "../shared" } shared = { version = "0.1.0", path = "../shared" }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
[dev-dependencies]
tempfile = "3.27.0"

View file

@ -7,6 +7,8 @@
/// Entity components describing players and other world-streaming anchors. /// Entity components describing players and other world-streaming anchors.
pub mod player; 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. /// Authoritative chunk storage and generation logic for the server.
pub mod world_server; pub mod world_server;

View file

@ -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};

View file

@ -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<ChunkPos, Vec<u8>>,
/// 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<Self, SaveError> {
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<Option<ChunkData>, 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<Vec<u8>, 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(())
}
}