Links
Tags
Creators
Details
0.11.0
Compatibility
Changes
v0.11.0 — Relay Protocol Redesign
The pendingSnapshot relay protocol (the mechanism that relays the latest-generation snapshot to disk when in-flight IO collides with new edits) used to spread its state across four independent atomic units with no single linearization point; multiple rounds of interleaving attacks proved that patching could not converge. This release consolidates it into a single-word SlotWord CAS state machine and systematically seals the collision, terminal and shutdown paths — 10 Critical / 3 Major / 1 Minor fixes, unit tests 139 -> 179. Features and config unchanged. Each entry reads "call site: 0.10 consequence; current behavior"
Relay protocol (chunk)
- ChunkMapSaveMixin collision branch, old order capture -> dispatch -> register: in 0.10, an in-flight IO landing during dispatch found an empty slot, and the relay snapshot registered afterwards became an orphan with no consumer — the latest generation was silently lost; registration now precedes dispatch, so the callback can see the relay slot at any moment
- Same branch with register moved first, while a third-party ChunkDataEvent.Save listener is still mutating the tag on the main thread during dispatch: in 0.10 the in-flight callback could take the not-yet-ready tag and resubmit it, three threads sharing one NBT without barriers, potentially corrupting what hit the disk; the relay slot is now a three-state EMPTY/PREPARING/READY machine — consumption rights never leave the main thread before dispatch returns, callbacks only mark missed and the main-thread publish self-kicks
- ChunkSaveTask terminal-death exits (onUnhandledError / retries exhausted) used drainPendingSnapshot to seize the slot unconditionally: in 0.10 they could equally seize a PREPARING tag still being mutated by the main thread; replaced by takeReadyForTerminalConsumer with atomic three-way consumption — READY is relayed directly, PREPARING is handed back to the main thread, only EMPTY falls through to the vanilla fallback
- Stale missed marker surviving across save cycles at enterSerializing: in 0.10 a new cycle could inherit it and the main thread would resubmit concurrently before the current generation's IO had landed — with multiple workers, an older generation could overwrite the newest one, silently losing data; replaced by a generational fence (globally monotonic cycleSeq), missed is only valid within its own cycle
- Landing and slot-take split into two steps (ioCompletedSuccessfully + takeReadyPendingSnapshot): in 0.10, getting descheduled between the two steps stamped missed into the wrong cycle, causing premature relays or a false CLEAN that settled the books early; landAndTake merges both into a single CAS, missed is always stamped with the cycle at landing time
- Protocol fields (phase / mustDrain / relay slot / in-flight generation) consolidated into the single-word SlotWord: in 0.10, the interleaving of a terminal consumer clearing mustDrain with the main thread's begin occupying the slot could drop the gauge to 0, so the shutdown join stopped waiting for an in-flight relay and lost its increment; with the single CAS, a non-empty slot now guarantees gauge >= 1
- begin landing after the callback has already exited via CLEAN_LANDED (phase==CLEAN): in 0.10 neither self-kick trigger fired and publish released a READY orphan with no consumer ever coming — increments were silently lost on the unload path and mustDrainPending leaked +1 permanently; begin now sets a no-in-flight-consumer flag in the same CAS, and publish uses it to hand the pending back to the main thread for an immediate resubmit
- markMustDrain in the terminal-failure HANDED_TO_MAIN branch overwrote TERMINAL_HANDED: in 0.10 this deactivated one of publish's self-kick triggers (the redundant trigger covered it, no data loss); the overwrite is removed, both triggers now work independently
Entity path
- EntitySaveTask CLEAN_LANDED branch evicted without taking the slot: in 0.10, when the callback misjudged CLEAN from a stale generation read, the freshest entity list just registered by the main thread was garbage-collected together with the state object — the entities had already been evicted by vanilla and there is no recovery channel, a permanent silent loss; CLEAN_LANDED now always takes the slot before evicting, and relays if non-empty
- EntityStorageMixin registration and callback slot-take invisible to each other: in 0.10, a REQUEUE callback taking the slot before registration got null and skipped the resubmit, and the pending registered afterwards fell into a dead state — stuck forever with mustDrainPending leaking +1; a write-then-read duality is now established (callback writes the terminal phase then takes the slot, main thread writes the slot then re-reads phase) so at least one side always sees the other, getAndSet arbitrates the sole consumer, and the main thread self-kicks a resubmit when it detects no in-flight consumer
- EntitySaveState.ioFailed published a transient DIRTY during in-place REQUEUE resubmits: in 0.10 a main-thread re-read hitting that window misjudged "no in-flight consumer" and stole the relay slot, running a second in-flight cycle against the retrying callback and overwriting inFlightGeneration; the resubmit now stays IO_PENDING throughout, and DIRTY is narrowed to the true terminal "no consumer, wait for next cycle"
- Entity collision branch not clearing mustDrain after capturePending throws (typically OOM): in 0.10 the gauge leaked +1 permanently for that position; the catch now performs the CAS cleanup, symmetric with the chunk-side cancellation path
IO failure and shutdown
- A synchronous throw from submit inside submitIo was swallowed by a dependent future nobody joins: in 0.10 the task was stuck in IO_PENDING forever, inFlightIoPending leaked +1, and the shutdown drainPending never saw zero; submit is now wrapped in its own try — a synchronous throw rebalances the gauge and goes through the existing IO-failure terminal compensation
- A synchronous throw (OOM) at the REQUEUE relay-resubmit sink: in 0.10 the already-taken relay generation was lost outright and the serializing gauge leak poisoned the shutdown verdict; resubmits are wrapped in safeReoffer, the sink balances its own inc/dec, and an exceptionally handler at the end of the callback chain guarantees no path stays silent
- The workersStopping residual window (a relay arriving after shutdown began) only logged without settling: in 0.10 mustDrain became an ownerless orphan — the diagnostics idle gate never closed, drain-unload reported false timeouts, and the Prometheus gauge drifted permanently; the residual-window branch now performs the real CAS cleanup, idempotent against double decrements
Upgrading
Drop-in jar replacement, config unchanged
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle. To learn more about the Modrinth Maven API, click here.
Note: When available, you should use the creator's maven repo instead as it will have transitive dependency information that the Modrinth Maven API does not. You may also end up with duplicate dependencies if you use a mix of Modrinth and non-Modrinth Maven repositories for your dependencies, because the group identifier will be different when served through the Modrinth Maven API.
Maven coordinates:
Version ID:
build.gradle:
repositories {
exclusiveContent {
forRepository {
maven {
name = "Modrinth"
url = "https://api.modrinth.com/maven"
}
}
// forRepositories(fg.repository) // Uncomment when using ForgeGradle
filter {
includeGroup "maven.modrinth"
}
}
}
// Standard Gradle dependency
dependencies {
implementation "maven.modrinth:WAXR3mM5:GAKmNyk1"
}
// Legacy Loom dependency
dependencies {
modImplementation "maven.modrinth:WAXR3mM5:GAKmNyk1"
}

