feat/region-incremental-save #4
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
scope/assets
scope/client
scope/networking
scope/renderer
scope/scripting
scope/server
scope/shared
scope/workspace
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Synvael/synvael!4
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/region-incremental-save"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What does this PR do?
I made region saves incremental, and I added the observed bitset that depends on the same groundwork.
The format change comes first, in
crates/shared/src/save/region.rs.RegionIndexgains two fields:payload_start, the offset the first chunk record may begin at, andobserved, a packed 4096-byte bitset with one bit per chunk slot in the region's 32x32x32 cube.encodenow pads out topayload_startand raises the newSaveError::IndexReservationExceededif the index content no longer fits its reservation, so the index has fixed-size space on disk it can grow into without shifting a single record offset.encoded_lenreports the index's natural length so a caller can size a reservation before laying out records.observed_slotmaps aChunkPosto its local slot withrem_euclidon each axis, andis_observed/mark_observed/clear_observed/observed_countread and write the bits. I also exposedtake_free,clear_free,payload_startandset_payload_start, which the allocator needs.REGION_FORMAT_VERSIONgoes from 1 to 3.crates/server/src/save/region_file.rscarries the actual save rework.RegionFiletracksfile_len,file_existsand adirty_recordsset, andwrite_chunkno longer assigns an offset: it records the bytes and marks the position dirty, leaving placement to save time.savenow branches. The common path iswrite_incremental, which allocates a slot per dirty record throughallocate(best-fit over the free list, splitting the leftover back onto it, falling back to an append atfile_len), writes those records at their offsets, fsyncs, then rewrites the header in place and fsyncs again. Records first and header second, so a crash between the two leaves the file readable at its previous state. A brand-new region, or one whose index has outgrown its reservation and hitIndexReservationExceeded, falls through torewrite_whole_file, which clears the free list, sizes a fresh reservation vianext_reservation(doubling from a 64 KiB default), packs every record contiguously after it and commits the image through the existingatomic_write. That replaces theserializewhole-file path and theTODO: incremental savethat sat on it.remove_chunknow pushes the vacated span onto the free list instead of dropping it.read_chunkincrates/server/src/save/region_actor.rsmarks the chunk's slot observed as it reads, since every read on that path is an LOD0 load today.flush_dirtyevicts any region whose save failed rather than keeping it cached, because a partial incremental write can leave the in-memory index naming offsets the on-disk header never got, and reopening from disk is the only sound recovery.The autosave timer closes the loop.
ServerWorld::flushincrates/server/src/world_server.rswraps theSaveRequest::Flushround trip that the tests were building by hand, andrun_simulationincrates/server/src/main.rsfires it every 45 seconds off a newAUTOSAVE_INTERVAL, seeded at startup so the first autosave lands one interval in.I added
crates/shared/examples/inspect_save.rsto read this back. It prints a summary of a world'slevel.datand its region files through the same decode functions the server uses, so the observed counts and the payload-start reservation can be checked on real files rather than only in tests.Why is this change necessary?
Every save was a whole-file rewrite, so save cost scaled with a region's total size rather than with what changed. That was tolerable while only edits dirtied a region. It stops being tolerable with the observed bitset, because marking a slot observed dirties the region on a plain read: exploring a world would have triggered repeated full rewrites to set single bits.
The blocker on incremental save was that
header_tableis variable-length, so index growth shifted every absolute record offset and forced the whole file out. Padding the index to a fixed reservation is what removes that coupling, and it is what makes the observed bits affordable, which is why Voxel Chunk System II cards the two as wanting to be done together.The bitset itself answers a question
base_worldgen_versionand the stamp table cannot. Those say at what version a chunk is pinned; they cannot say whether a chunk was observed at all, because a chunk pinned to the region base has no stamp entry and absence therefore means both "never seen" and "pinned to base". Worldgen versioning needs that distinction: an unvisited chunk in an old region must generate at the new version while its visited neighbour must not.Architecture/Worldgen.mdalso needs it for the no-re-blend rule. One bit per chunk is 4096 fixed bytes per region against 512 KiB for a fully explored region's worth of 16-byte stamp entries, andRegionIndex::encodewrites raw little-endian with no zstd wrapper, so nothing downstream would compress that away.The format bump is the reason to land this now rather than later. No world exists off my machine, so changing the
SYNRheader costs nothing today and costs a permanent migrator afterwards.Scope of Changes
server, shared
Testing
Automated, all on this branch at
88319a0:cargo check -p shared,cargo check -p server: clean.cargo test -p shared: 215 passed, 0 failed.cargo test -p server: 54 passed, 0 failed.cargo clippy --all-targets --all-features -- -D warnings: clean.cargo fmt --all -- --check: clean.No Lua changed, so
seleneandstyluawere not run.New tests covering the change:
crates/shared/src/tests/region.rs: the observed bit round-trips through encode/decode, marking and clearing are independent per slot,observed_slotwraps to the same slot regardless of which region the position falls in,encodepads out to the reservation,encoderejects a reservation smaller than its content, a pre-payload_startregion is rejected rather than misread, andtake_freeremoves exactly the named span.crates/server/src/tests/region_file.rs: an incremental save leaves the other records untouched on disk, a reservation overflow falls back to a full rewrite, an observed mark round-trips through disk, marking an already-observed slot does not re-dirty the region, and a failed incremental write is reported as an error rather than swallowed.crates/server/src/tests/region_actor.rs: a failed flush evicts the region so the next access reopens it from disk.crates/server/src/tests/world_server.rs: loading a chunk marks its region slot observed even when no diff is ever stored for it.Additional Context
REGION_FORMAT_VERSIONmoves 1 to 3, and there is no migrator. Any region file written before this branch fails to decode. That is intended while no world exists outside my machine, and it is the window in which this change is free. The second bump ispayload_start, which changes the header's byte layout ahead of the bitset and needed its own version so an old file fails loudly instead of reading a record offset as a reservation.RegionFile::savehas a documented failure contract worth a reviewer's attention: an I/O error partway throughwrite_incrementalcan leave the in-memory index naming offsets the on-disk header never received. It propagates rather than swallowing soflush_dirtycan evict the region, and that eviction is now the correctness boundary rather than a nicety.The reservation policy is a decision that could have gone the other way. I picked a 64 KiB default doubling on overflow, which means a region whose index outgrows it pays one full rewrite and then has headroom again. A tighter default would save disk on regions nobody visits, at the cost of more full rewrites early in a world's life.
Related Issues
No response
Checklist
dev(or a feature branch offdev) and my PR targetsdev.///doc comments for Rust) where necessary.