feat(server): add atomic region-file durability layer
This commit is contained in:
parent
a4f2bb32bc
commit
1db124e63b
14
Cargo.lock
generated
14
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
13
crates/server/src/save/mod.rs
Normal file
13
crates/server/src/save/mod.rs
Normal 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};
|
||||
353
crates/server/src/save/region_file.rs
Normal file
353
crates/server/src/save/region_file.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue