Repository navigation
feat(lua): give cross-zone entity state somewhere to live - #577
Merged
Merged
Conversation
🟡 Code Coverage — 76.7%9635 of 12555 lines covered. 🟡 ELP Lint — 192 warnings212 diagnostics found. See job logs for details. ✅ ELP eqWAlizeNo diagnostics. |
Per-entity state that outlives the zone holding it had nowhere to go. The zone's entity map goes with the zone, a table in the zone's VM goes with the VM, and game.storage - which has exactly the right semantics - is a two-statement read-modify-write executed on the tick path with no atomic update, so concurrent writers lose writes silently. And concurrent writers are the normal case here, not an edge one: an entity crossing a seam is legitimately held by two zones at once until it is rehome_margin past the boundary, and both tick. Two tiers, sharing one operator vocabulary: - game.kv.get/set/merge/delete - node-local, in memory, scoped to the calling world or match, TTL'd and refreshed on every write. Reads are ETS reads in the calling process; writes are serialised through one process, which is what makes a merge atomic. Explicitly lost on restart. - game.storage.update/player_update - the same merge applied to a storage row, conditional on the version it read, retried on a stale write and answering storage_conflict rather than spinning a zone. asobi_merge_ops is the shared vocabulary: set, set_if_absent, incr, min, max, latch. Everything but set is commutative, so two zones can each apply what they saw with no lock, no version and no lost write - which is what makes this usable at tick rate rather than merely safe. A bare value is an error rather than an implicit set: it would read exactly like a working merge and lose writes. Closes #572
Taure
force-pushed
the
feat/kv-and-atomic-storage
branch
from
August 30, 2026 22:53
36bf6d4 to
a45c218
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #572.
An entity that crosses the map has state that is neither recomputable nor zone-scoped, and the two places a zone script had are both zone-scoped: the entity map goes with the zone, a table in the zone's VM goes with the VM.
game.storagehas the right semantics and the wrong mechanism - a two-statement read-modify-write on the tick path, with no atomic update, so concurrent writers lose writes silently.And concurrent writers are the normal case: an entity crossing a seam is legitimately held by two zones at once until it is
rehome_marginpast the boundary, and both tick. Two schedulers, no ordering.Two tiers, one operator vocabulary
game.kvgame.storage.updateasobi_merge_opsis the shared vocabulary:set,set_if_absent,incr,min,max,latch. Everything butsetis commutative, so two zones can each apply what they saw with no lock, no version and no re-read - the property that makes this usable at tick rate rather than merely safe.setis documented as last-writer-wins.A bare value (
{ hull = 40 }instead of{ hull = { set = 40 } }) is an error, not an implicitset. Treating it assetwould read exactly like a working merge and lose writes, which is the failure mode this whole change exists to remove.game.kv
getis an ETS read in the calling process, so a tick reading state pays no round trip.mergeis a call, not a cast, because "was that the hit that killed it" is the question the caller actually has.kv_ttl_seconds, an hour by default), so state in active use never ages out and a long-lived node does not accumulate one entry per entity that ever existed. Capped atkv_max_keys; past it a new key is refused and logged, while existing keys stay writable.asobi_supwith no ordering constraint: reads fall through to a miss and writes answerkv_unavailable, so nothing depends on it being up.game.storage.update
Uses the
versioncolumn the schema already carries. The UPDATE is conditional on the version that was read, so a racing writer's update matches zero rows; that re-reads and re-applies, which is correct rather than merely safe because the operators are order-independent. Bounded at five attempts, then{ error = "storage_conflict" }- this runs inside a callback with a millisecond budget, so a hot key gives the script an answer instead of spinning the zone.Both
updatepaths keep the global/per-player namespace split intact: the CAS query is scoped exactly likestorage_find/3, plus the version.Tests
asobi_merge_ops_tests(each operator, order-independence of a two-merge sequence in both orders, and every malformed shape),asobi_kv_tests(set/get/merge/delete, scope isolation, TTL expiry, write-refreshes-TTL, the cap on new vs existing keys),asobi_lua_kv_tests(both namespaces through real Lua, including insert / merge / retry-on-stale / give-up / bad-op-never-reaches-the-database).Checks
fmt --check,xref,dialyzer,elp eqwalize-all(156 errors, one fewer thanorigin/main's 157),elp lint,ex_doc, and the fulleunitsuite (2442 tests, 0 failures).