Skip to content

feat(lua): give cross-zone entity state somewhere to live - #577

Merged
Taure merged 3 commits into
mainfrom
feat/kv-and-atomic-storage
Aug 30, 2026
Merged

Taure merged 3 commits into
mainfrom
feat/kv-and-atomic-storage

Conversation

@Taure

@Taure Taure commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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.storage has 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_margin past the boundary, and both tick. Two schedulers, no ordering.

Two tiers, one operator vocabulary

game.kv game.storage.update
Where Node memory, scoped to this world/match Database, global
Survives a restart No Yes
Cost per write One message Two DB round trips
Use it for Per-hit damage, per-tick accumulation Per-kill, per-threshold
local r = game.kv.merge("ships", id, {
  hull = { incr = -40 },
  dead = { latch = damage >= 999 },
})
if r.ok.hull <= 0 then ... end

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 re-read - the property that makes this usable at tick rate rather than merely safe. set is documented as last-writer-wins.

A bare value ({ hull = 40 } instead of { hull = { set = 40 } }) is an error, not an implicit set. Treating it as set would read exactly like a working merge and lose writes, which is the failure mode this whole change exists to remove.

game.kv

  • Reads do not touch the server. get is an ETS read in the calling process, so a tick reading state pays no round trip.
  • Writes are serialised through one process, which is what makes a merge atomic without a lock. merge is a call, not a cast, because "was that the hit that killed it" is the question the caller actually has.
  • Scoped to the calling VM's world or match, so two worlds running the same mode script cannot collide on the same collection and key.
  • TTL'd, refreshed on every write (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 at kv_max_keys; past it a new key is refused and logged, while existing keys stay writable.
  • Started from asobi_sup with no ordering constraint: reads fall through to a miss and writes answer kv_unavailable, so nothing depends on it being up.

game.storage.update

Uses the version column 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 update paths keep the global/per-player namespace split intact: the CAS query is scoped exactly like storage_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 than origin/main's 157), elp lint, ex_doc, and the full eunit suite (2442 tests, 0 failures).

@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown

🟡 Code Coverage — 76.7%

9635 of 12555 lines covered.


🟡 ELP Lint — 192 warnings

212 diagnostics found. See job logs for details.


✅ ELP eqWAlize

No diagnostics.

Taure added 3 commits August 31, 2026 00:48
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
Taure force-pushed the feat/kv-and-atomic-storage branch from 36bf6d4 to a45c218 Compare August 30, 2026 22:53
@Taure
Taure merged commit c702823 into main Aug 30, 2026
18 checks passed
@Taure
Taure deleted the feat/kv-and-atomic-storage branch August 30, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-zone entity state has nowhere to live: game.storage is a DB read-modify-write on the tick path

1 participant