SCUA

Manual

Live values: tuning a program while it runs

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: TUNING has goblin_hp, speed and loot, and loot has rare and common;
  • each key's type, from the value it has when the declaration runs: goblin_hp is an int, speed a 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)