A live value is a table of settings that the main
script, or the program embedding SCUA, can replace while everything
keeps running. Every partition reads the current version. It is how you
change a goblin's hit points, a drop rate or a feature switch without
restarting a game or a server.
live TUNING = { goblin_hp = 25, speed = 1.0, loot = { rare = 0.05, common = 0.6 } }
partition Goblin
state hp = TUNING.goblin_hp
on Hit(dmg)
hp = hp - dmg
print(`hit for {dmg}: {hp} left, moving at {TUNING.speed}, rare drops {TUNING.loot.rare}`)
end
end
let g = Goblin()
tell g.Hit(5)
$ scua goblin.scua
hit for 5: 20 left, moving at 1.0, rare drops 0.05
#Declaring one
live NAME = { … } goes at the top level of the main
script, with its keys written out. The declaration fixes two things for
as long as the program runs:
- the keys, at every depth:
TUNINGhasgoblin_hp,speedandloot, andloothasrareandcommon; - each key's type, from the value it has when the
declaration runs:
goblin_hpis an int,speeda float.
A key can hold a number, a string, a boolean, or a list or table of
those. A list's element type is fixed too:
waves = [3, 5, 8] is a list of ints from then on.
A module can't declare a live value, and neither can a
function or a block: the main script owns them, since it is the one that
publishes them. A sandboxed program never sees one.
#Reading one
You read a live value one key at a time, from anywhere:
the main script, any partition, any function they call.
TUNING.goblin_hp copies that key out of the current
version. A key that holds a table, such as TUNING.loot,
gives you a fresh copy of the whole table, which is yours to change.
The name on its own is never a value. You can't store it, pass it to a function or send it in a message, and saying so is a compile error that shows you what to write instead:
live TUNING = { goblin_hp = 25, speed = 1.0 }
let t = TUNING
$ scua bare.scua
scua: bare.scua:2: `TUNING` is a `live` value, read one key at a time: `TUNING.goblin_hp`. A read copies that key out of the current version, so a `live` value itself is never stored, passed or sent
A key the declaration doesn't have is a compile error too, listing the keys it does have:
live TUNING = { goblin_hp = 25, speed = 1.0 }
print(TUNING.goblin_HP)
$ scua typo.scua
scua: typo.scua:2: `TUNING` has no key `goblin_HP`; its keys are fixed where it is declared (line 1): goblin_hp, speed
A key you only know when the program runs, such as
TUNING.loot[kind], is looked up then, and a name the
declaration doesn't have stops the code that read it with the same
message.
A read is cheap: the first one in a handler finds the current version, and every read after it is a field read. Inside a hot loop, reading the key once into a local before the loop is still a little faster, and it means the same thing, because a handler reads one version until it next waits (below).
#Publishing a new version
The main script publishes with
live.publish(NAME, { … }). It replaces the keys you give,
at every depth, and leaves the rest as they were:
live TUNING = { goblin_hp = 25, speed = 1.0 }
partition Spawner
on Report() print(`new goblins: {TUNING.goblin_hp} hp, speed {TUNING.speed}`) end
end
let s = Spawner()
tell s.Report()
wait(10ms)
live.publish(TUNING, { goblin_hp = 50 })?
tell s.Report()
$ scua retune.scua
new goblins: 25 hp, speed 1.0
new goblins: 50 hp, speed 1.0
Every publish is checked against the declaration: the keys, each
value's type, and a size limit. An int where the declaration has a float
is stored as a float. Anything else that doesn't fit refuses the whole
publish and changes nothing. live.publish returns
Ok(version) or an Error, so a value that came
from a file or a network can be refused without stopping the
program:
live TUNING = { goblin_hp = 25, speed = 1.0 }
print(live.publish(TUNING, { speed = 2 }))
print(TUNING.speed)
let from_file = { goblin_hp = "lots" }
match live.publish(TUNING, from_file)
Ok(v) -> print(`published version {v}`)
Error(e) -> print(`refused ({e.kind}): {e.message}`)
end
print(TUNING.goblin_hp)
$ scua checked.scua
Ok(1)
2.0
refused (invalid): `TUNING.goblin_hp` holds an int, and the publish gave it a string
25
The Error kinds are invalid (the value
doesn't fit the declaration, or the version number isn't new) and
limit (described below). Ignoring the result is a warning,
as it is for any call that can fail; add ? when a failure
should stop the program.
#Values published together are read together
Several values in one call are one step: no partition ever reads the
new TUNING with the old LOOT.
live TUNING = { goblin_hp = 25 }
live LOOT = { gold = 10 }
partition Goblin
on Die() print(`a {TUNING.goblin_hp} hp goblin drops {LOOT.gold} gold`) end
end
live.publish(TUNING, { goblin_hp = 50 }, LOOT, { gold = 20 })?
let g = Goblin()
tell g.Die()
$ scua together.scua
a 50 hp goblin drops 20 gold
Two separate calls are two steps, and a partition may run between them.
#Only the main script publishes
A partition reads live values and never publishes them.
A live.publish in a handler, or in a function a handler can
reach, is a compile error:
live TUNING = { goblin_hp = 25 }
partition Console
on Set(hp)
live.publish(TUNING, { goblin_hp = hp })?
end
end
$ scua rule.scua
scua: rule.scua:5: the `Set` handler of `Console` publishes `TUNING`, a `live` value. Only the main script and the host publish a `live` value; a partition reads it. Publish it from the main script, or from the host
A function value that publishes and reaches a partition anyway, in a message say, stops the handler that calls it with the same message.
#What a handler sees
A handler reads one version of every live
value from its start until it next waits. Waiting means an
ask, a wait_for, a select, a
tell held up by a full mailbox, or an I/O call that parks
the handler, and a task you spawn waits at its own
waits too. After a wait, and at the start of every new
message, it reads the newest version:
live TUNING = { speed = 1.0 }
partition Runner
on Go()
let before = TUNING.speed
let signal = wait_for(Next) -- a wait: after it, a newer version may be read
print(`before the wait {before}, after it {TUNING.speed}`)
end
end
let r = Runner()
tell r.Go()
wait(10ms)
live.publish(TUNING, { speed = 2.0 })?
tell r.Next()
$ scua stretch.scua
before the wait 1.0, after it 2.0
So a loop over a thousand entities in one handler uses one set of numbers throughout, however often the values are published.
A publish happens before every message sent after it. If the main script publishes and then tells partition A, and A tells B, B reads that version or a newer one. The same holds when the host publishes and then sends a message. The main script reads its own publish straight away.
A partition sees the live values whose declarations had
run when it was started, as it does with every other top-level name. One
started above a declaration stops when it reads that value:
partition Goblin
on Report() print(TUNING.goblin_hp) end
end
let early = Goblin()
live TUNING = { goblin_hp = 25 }
tell early.Report()
$ scua early.scua
scua: early.scua:2: actor fault in partition Goblin (it keeps running): `TUNING` is used before its declaration ran (line 6)
#How many old versions are kept
A version is kept only while a handler that read it is still running.
A handler that waits, even for an hour, has let its version go. The
number kept at once is limited; a publish that would go past the limit
returns Error({ kind = "limit" }) and is counted, and
publishing again once the busy handlers have moved on succeeds. In the
main script, actors.stats() reports
live_versions, the versions kept now, and
live_refused, the publishes refused at the limit.
#Version numbers
Each version has a number, in a lineage. Versions the main script
publishes are numbered by this process, starting from 0 for the value
the declaration gave. live.version(NAME) tells you which
version the code is reading, as { lineage, counter }.
When the values come from a control plane that serves several
processes, give its own number so that versions compare across
processes: a last table
{ lineage = "deploy", version = n }. Within a lineage the
number must go up. A version is never reused, so rolling back is a new
version that holds the old values:
live TUNING = { goblin_hp = 25 }
print(live.version(TUNING).counter)
live.publish(TUNING, { goblin_hp = 30 }, { lineage = "deploy", version = 41 })?
let v = live.version(TUNING)
print(`{v.lineage} {v.counter}`)
print(live.publish(TUNING, { goblin_hp = 25 }, { lineage = "deploy", version = 41 }))
$ scua versions.scua
0
deploy 41
Error({kind = invalid, message = version 41 of `TUNING` is not above 41, the version it has in lineage "deploy". A version is never reused: a rollback is a new version with the old content})
#Snapshots and revives
A snapshot (actors.snapshot) records the versions of the
live values the partition could read. When the blob is
revived in a process that is behind, one whose version
in the same lineage is older, the partition reads that process's
version. The revive is still made, and it is reported: the load report
says live_behind and names the value, and
actors.stats() counts it in live_behind. To
refuse instead, which is what a kill switch needs, revive with
actors.revive(blob, { refuse_live_behind = true }); it
returns Error({ kind = "live_behind" }). Versions from
different lineages, such as two processes each numbering their own,
don't compare, and the load report says live_unordered.
#From a program embedding SCUA
The host publishes with scua_live_publish, giving JSON
keyed by live name. Everything in one call is one step,
checked like live.publish:
const char *v7 = "{\"TUNING\": {\"goblin_hp\": 50}}";
char err[256];
if (scua_live_publish(p, v7, strlen(v7), "deploy", 7, err, sizeof err) != SCUA_OK)
fprintf(stderr, "refused: %s\n", err);
scua_actor_tell_text(p, id, "next wave"); /* handled against version 7 or a newer one */
Pass NULL as the lineage to number the version in this
process's own lineage. scua_get_actor_stats reports
live_versions, live_behind and
live_refused, and scua_last_load_report sets
live_behind to 1 when a revive was behind and 2 when the
lineages did not compare. Nothing a revived partition runs happens
before your next scua_io_poll, so you can read the report
and drop the partition first.
A host that must never run a partition on the declared defaults calls
scua_live_set_start before loading the script.
SCUA_LIVE_START_WAIT holds each partition, so it runs
nothing until every live value it reads has been published
at least once; SCUA_LIVE_START_REFUSE refuses instead: a
revive returns -SCUA_ERR_UNPUBLISHED, and starting a
partition in the script stops with a message naming the value.
#Publishing from a shared config table
A cluster-wide
config table and a live value fit together: the
cluster table carries settings between processes, and a
watch in the main script publishes them into a live value
within this one.
import cluster
live TUNING = { goblin_hp = 25 }
let cfg = cluster.open("game")
cluster.watch(cfg, "tuning", fn(change)
if change.key == "tuning/goblin_hp" then
live.publish(TUNING, { goblin_hp = change.value })?
end
end)
#Related pages
- Partitions and the actor
model covers the partitions that read
livevalues. - Share config across a cluster carries settings between processes.
- Hibernate a session covers snapshots and revives from a host.