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

165 lines
6.9 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
//! A dedicated thread that owns every open region file and services load, write-back, remove, and flush requests over a channel.
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::path::{Path, PathBuf};
use std::thread::{self, JoinHandle};
use crossbeam_channel::{Receiver, Sender};
use shared::save::SaveError;
use shared::world::{ChunkData, ChunkPos};
use tracing::warn;
use super::region_file::{RegionFile, region_coords, region_path};
/// A request sent to the save actor. Read and flush requests carry a one-shot reply channel; write and remove requests are fire-and-forget, mutating only the in-memory region image until a flush.
pub enum SaveRequest {
/// Reads the stored chunk at a position, replying with the saved modification if one exists.
Read {
/// The chunk position to look up.
pos: ChunkPos,
/// The one-shot channel the actor replies on: `Ok(Some(data))` for a saved modification, `Ok(None)` when the chunk was never modified, or `Err` on a save-layer failure.
reply: Sender<Result<Option<ChunkData>, SaveError>>,
},
/// Writes a modified chunk's diff into its region, replacing any prior record. Mutates only the in-memory image; durability waits for a `Flush`.
Write {
/// The chunk position the diff is stored under.
pos: ChunkPos,
/// The baseline-relative diff to persist.
data: ChunkData,
},
/// Drops any stored record for a position, reclaiming its space into the region free list. Used when a clean chunk is unloaded.
Remove {
/// The chunk position whose record is dropped.
pos: ChunkPos,
},
/// Flushes every dirty region to disk, replying once all are written.
Flush {
/// The one-shot channel the actor replies on: `Ok(())` when every dirty region flushed, or the first `Err` encountered.
reply: Sender<Result<(), SaveError>>,
},
}
/// A handle to the running save actor: the request sender plus the owning thread's join handle.
pub struct SaveActor {
/// The sending end of the request channel; cloned into every worker so it can issue reads.
request_tx: Sender<SaveRequest>,
/// The actor thread handle, retained so it can be joined on shutdown.
#[expect(
dead_code,
reason = "retained for a future graceful-shutdown join path"
)]
handle: JoinHandle<()>,
}
impl SaveActor {
/// Spawns the actor thread, which owns the region files beneath `region_dir` for its lifetime.
#[must_use]
pub fn spawn(region_dir: PathBuf) -> Self {
let (request_tx, request_rx) = crossbeam_channel::unbounded::<SaveRequest>();
let handle = thread::spawn(move || actor_loop(&region_dir, &request_rx));
Self { request_tx, handle }
}
/// Returns a fresh sender for a worker to issue requests through.
#[must_use]
pub fn sender(&self) -> Sender<SaveRequest> {
self.request_tx.clone()
}
}
/// The actor's run loop: it owns the region-file map and answers requests until every sender is dropped.
fn actor_loop(region_dir: &Path, request_rx: &Receiver<SaveRequest>) {
// The actor is the sole owner of this map, so region files need no lock of their own.
let mut regions: HashMap<(i32, i32, i32), RegionFile> = HashMap::new();
while let Ok(request) = request_rx.recv() {
match request {
SaveRequest::Read { pos, reply } => {
let result = read_chunk(&mut regions, region_dir, pos);
// A send error means the requesting worker has gone away; the reply is simply dropped.
let _ = reply.send(result);
}
SaveRequest::Write { pos, data } => {
// `last_modified` is record metadata only, not a worldgen input, so a zero placeholder is acceptable until a real timestamp source is wired.
let last_modified = 0;
match region_mut(&mut regions, region_dir, pos) {
Ok(region) => {
// A failed encode must not go unnoticed; the write is otherwise silently lost.
if let Err(error) = region.write_chunk(pos, &data, last_modified) {
warn!(?error, ?pos, "chunk write-back failed; edit dropped");
}
}
Err(error) => warn!(?error, ?pos, "region open failed; write-back dropped"),
}
}
SaveRequest::Remove { pos } => match region_mut(&mut regions, region_dir, pos) {
Ok(region) => region.remove_chunk(pos),
Err(error) => warn!(?error, ?pos, "region open failed; record not removed"),
},
SaveRequest::Flush { reply } => {
let result = flush_dirty(&mut regions);
// A send error means the requester has gone away; the reply is simply dropped.
let _ = reply.send(result);
}
}
}
}
/// Flushes every dirty region to disk, returning the first error while still attempting the rest.
///
/// # Errors
///
/// Returns the first [`SaveError`] produced by [`RegionFile::save`]; remaining dirty regions are still flushed.
fn flush_dirty(regions: &mut HashMap<(i32, i32, i32), RegionFile>) -> Result<(), SaveError> {
let mut result = Ok(());
for region in regions.values_mut() {
// Clean regions are skipped so a flush never rewrites an unchanged file.
if !region.is_dirty() {
continue;
}
if let Err(error) = region.save() {
warn!(?error, "region flush failed");
// The first failure is reported; later regions are still flushed.
if result.is_ok() {
result = Err(error);
}
}
}
result
}
/// Returns the region file covering `pos`, opening and caching it on first access.
///
/// # Errors
///
/// Returns a [`SaveError`] from [`RegionFile::open`] if the region file exists but cannot be read or decoded.
fn region_mut<'a>(
regions: &'a mut HashMap<(i32, i32, i32), RegionFile>,
region_dir: &Path,
pos: ChunkPos,
) -> Result<&'a mut RegionFile, SaveError> {
let key = region_coords(pos.x, pos.y, pos.z);
// The region file is opened once on first touch; every later access hits the in-memory copy.
match regions.entry(key) {
Entry::Occupied(entry) => Ok(entry.into_mut()),
Entry::Vacant(entry) => Ok(entry.insert(RegionFile::open(region_path(
region_dir, pos.x, pos.y, pos.z,
))?)),
}
}
/// Reads the stored chunk at `pos`, opening and caching its region file on first access.
///
/// # Errors
///
/// Returns a [`SaveError`] if the region file cannot be opened or the stored record cannot be decoded.
fn read_chunk(
regions: &mut HashMap<(i32, i32, i32), RegionFile>,
region_dir: &Path,
pos: ChunkPos,
) -> Result<Option<ChunkData>, SaveError> {
region_mut(regions, region_dir, pos)?.read_chunk(pos)
}