synvael/crates/server/src/save/region_file.rs

246 lines
9 KiB
Rust

// 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.
///
/// # Errors
///
/// Returns [`SaveError::Io`] if the file cannot be read, a decoding error from [`RegionIndex::decode`] if the index is malformed, or [`SaveError::PayloadTooLarge`] / [`SaveError::Truncated`] if a header entry's span falls outside the file.
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.
///
/// # Errors
///
/// Returns a decoding error from [`record::decode`] if the stored record is malformed.
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`.
///
/// # Errors
///
/// Returns an encoding error from [`record::encode`] if serialization fails, or [`SaveError::PayloadTooLarge`] if the encoded record exceeds `u32::MAX` bytes.
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.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if a record's length exceeds `u32::MAX`, or [`SaveError::Io`] if the atomic write to disk fails.
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.
///
/// # Errors
///
/// Returns [`SaveError::PayloadTooLarge`] if the index or any record length exceeds `u32::MAX` bytes.
// * NOTE: this is a whole-file rewrite. The right way to do it for large saves is to append changed records into free space and rewriting only the header table, so save cost scales with chunks modified rather than total file size. The free list and absolute offsets already on disk support that switch without a format change.
// TODO: incremental save.
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.
///
/// # Errors
///
/// Returns [`SaveError::Io`] if the parent directory cannot be created, or if writing, syncing, or renaming the temporary file fails.
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)]
#[path = "../tests/region_file.rs"]
mod tests;