SCUA 0.34.0 changes how partitions, SCUA's actors, are built. Every
partition now shares the program's code, constants and standard library,
so an idle one costs about 2.3 KB of memory and is created in half a
microsecond on Apple silicon. A message costs the same with 100,000 idle
partitions beside it as with none, so a server can keep one per player
or per session and leave most of them asleep. The language around
partitions is tighter as well: a partition reaches only what it is
given, const freezes its value, libraries can keep state
for each partition, and live values let you retune a
program while it runs. An idle partition's save is under 2 KB, and a
revived partition runs the current version of your program. Some
programs need edits, and the compiler names a fix for each one; there is
a short guide to upgrading at the end.
#An idle partition costs 2.3 KB
These figures compare release builds of 0.33 and 0.34 on an Apple M4 Max running macOS and an Intel i7-10700 running Linux:
| Apple M4 Max | Intel i7-10700 | |
|---|---|---|
| Memory for an idle partition | 92.5 KB → 2.3 KB | 77 KB → 2.3 KB |
| Creating a partition | 51 µs → 0.5 µs | 107 µs → 1.5 µs |
An ask beside 100,000 idle partitions |
45 ms → 0.5 µs | 81 ms → 1.1 µs |
| A partition that does a little work often | 1.5 MB → 76 KB | 1.4 MB → 69 KB |
At 2.3 KB each, 100,000 idle partitions fit in about 230 MB. In 0.33 the same number needed over 9 GB on the Mac.
A partition used to start with its own copy of the program. Now each function is compiled once, by the first partition that runs it often enough, and every partition runs that copy with its own caches. In a program with 150 functions, a partition that calls all of them holds about 34 KB instead of 450 KB. A partition that reads a 1 MB constant holds about 5 KB of its own instead of 950 KB. Once a partition has handled its first message it holds about 12 KB, since it now has a mailbox and caches for the functions it called.
The scheduler visits only the partitions that have something to do,
which is why an ask costs the same beside 100,000 idle
partitions as beside none. Dropping a partition leaves nothing behind
for later work to pay for: a program that opened and closed 100,000
partitions peaked at about 12 MB, where it used to pass 200 MB, and
reviving one partition 100,000 times keeps memory flat too.
Code inside a partition runs as fast as before. Some loops got
faster: for … in over an array now runs as compiled code,
so a handler that walks an array of records is about 7 times faster on
Apple silicon and 8 times faster on x86-64. On x86-64, a compiled loop
that reads a top-level const used to drop to a slow path
after a garbage collection; it now stays fast, about 7 times faster per
element than before.
#A partition reaches only what it is given
A partition can use the program's functions, its constants, the
standard library and the modules it imports. It can't reach the main
script's variables. A top-level let belongs to the main
script, and a handler that reads one, directly or through a function it
calls, is a compile error that names the variable, the path to it and a
fix:
let limit = 3
fn allowed(n) return n <= limit end
partition Gate
on Check(n) print(allowed(n)) end
end
tell Gate().Check(2)
$ scua gate.scua
scua: gate.scua:6: the `Check` handler of `Gate` calls `allowed`, which reads `limit`, a variable of the main script (`let limit`, line 1). A partition can't reach the main script's variables. `limit` never changes after line 1, so make it a constant, `const limit = …`, or pass it in: `partition Gate(limit)` with `state limit = limit`
Changing let to const on line 1 makes the
program print true. Between this rule and frozen constants,
nothing a partition reads from the program can change under it, which is
why every partition can share one copy.
To give a partition values when you create it, name them after the
partition and keep what you need in state. A handler named
on Start() runs once when the partition is created, before
any message:
partition Shop(catalog, owner)
state items = catalog
state owner = owner
state sold = 0
on Start()
print(`{owner} opens the shop`)
end
on Buy(item)
sold = sold + 1
print(`{owner} sold a {item} for {items[item]} (sale #{sold})`)
end
end
const PRICES = { sword = 50, potion = 10 }
let s = Shop(PRICES, "Mara")
tell s.Buy("sword")
tell s.Buy("potion")
$ scua shop.scua
Mara opens the shop
Mara sold a sword for 50 (sale #1)
Mara sold a potion for 10 (sale #2)
#const freezes its
value
A const now fixes the value as well as the name, at
every depth. A write the compiler can see is a compile error, and any
other write stops the program; both name the constant:
const CONFIG = { speed = 4, tags = ["fast"] }
CONFIG.tags.push("slow")
$ scua config.scua
scua: config.scua:2: `CONFIG` is a constant: `const` in SCUA freezes the value, not just the name; use `let` for a list you add to
To build a constant step by step, fill it in a let and
freeze it with the new freeze function, which returns a
frozen copy:
let by_name = {}
for item in [{ name = "sword", damage = 5 }, { name = "bow", damage = 3 }] do
by_name[item.name] = item
end
const BY_NAME = freeze(by_name)
print(BY_NAME.sword.damage)
$ scua weapons.scua
5
Everything a module exports is frozen once its top level has run, so every file that imports a library sees the tables it hands out as the library wrote them.
#Libraries keep state for each partition
A module can declare state at its top level. Each
partition that uses the module gets its own copy, made the first time it
calls in, and the main script has one too. Here is a small library that
tracks running tweens:
-- tween.scua
import ids
state active = {}
fn start(obj, to, secs)
let id = ids.next()
active[id] = { obj = obj, to = to, left = secs }
return id
end
fn running() return len(active) end
return { start = start, running = running }
-- game.scua
import tween
partition Zone
on Spawn(what)
tween.start(what, 10, 2)
print(`{what}: {tween.running()} running here`)
end
end
tween.start("title", 1, 1)
tell Zone().Spawn("goblin")
tell Zone().Spawn("bat")
print(`main script: {tween.running()} running`)
$ scua game.scua
main script: 1 running
goblin: 1 running here
bat: 1 running here
A partition's module state is saved with it when you snapshot it.
ids.next(), used above, is new too: it returns a 64-bit
integer id from a counter each partition starts at a random point, so
ids from different partitions and processes don't collide in practice
and no partition has to ask another for one.
#Retune a running
program with live values
A live value is a table of settings that the main
script, or a program embedding SCUA, can replace while every partition
keeps running. It is how you change a goblin's hit points or a feature
switch in a game or a server without a restart:
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()
wait(10ms)
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
$ scua tuning.scua
new goblins: 25 hp, speed 1.0
new goblins: 50 hp, speed 1.0
refused (invalid): `TUNING.goblin_hp` holds an int, and the publish gave it a string
The keys and their types are fixed where the value is declared, so a
publish that doesn't fit changes nothing and comes back as an
Error you can handle. Several values published in one call
are read together, and a handler reads one version from its start until
it next waits, so a loop over a thousand entities uses one set of
numbers throughout. A host publishes from JSON with
scua_live_publish.
#References you can compare and hand out
Two references to the same partition are now equal and work as the
same table key, so a room can keep its members in a table keyed by
reference. actors.me() is a partition's own address, as a
send-only reference: whoever you hand it to can send you messages and
nothing else.
import actors
partition Room
state members = {}
on Join(m)
members[m] = true
print(`joined, {len(members)} in the room`)
end
on Leave(m)
delete(members, m)
print(`left, {len(members)} in the room`)
end
end
partition Player
on Enter(room)
tell room.Join(actors.me())
tell room.Join(actors.me()) -- the same player again: still one member
tell room.Leave(actors.me())
end
end
tell Player().Enter(Room())
$ scua room.scua
joined, 1 in the room
joined, 1 in the room
left, 0 in the room
A reference prints as its locator, such as
<actor 3f1c09a2b7d45e60>, which is the same in every
process and after every revive, so you can follow one partition through
your logs. In a handler, actors.sender() says who sent the
message being handled. A message to a partition that isn't running is
counted and reported on stderr with its sender and target, and an
ask to one gets Error({ kind = "gone" })
straight away.
#Saves revive into the current program
A snapshot now holds the partition's own data and nothing else of your program. An idle one is under 2 KB:
import actors
partition Session
state visits = 0
on Visit() visits = visits + 1 end
end
let blob = actors.snapshot(Session())?
print(`an idle session saves in {len(blob)} bytes`)
$ scua idle.scua
an idle session saves in 1984 bytes
The same session on 0.33 saved 71,832 bytes. Snapshotting a 16-record session takes 0.005 ms and reviving it 0.05 ms.
Because the save holds no code, a revived partition runs the program that revived it. A function added since the save is there, and a constant you changed has its new value, including in a table the partition stored from it. This shop is saved by one version of a program:
import actors
import fs
const ITEMS = { sword = { id = "sword", price = 12 }, shield = { id = "shield", price = 5 } }
partition Shop
state items = ITEMS
on Show() print(`sword {items.sword.price}, shield {items.shield.price}`) end
end
let s = Shop()
tell s.Show()
wait(0ms)
fs.write("shop.blob", actors.snapshot(s)?)?
The next version raises the sword to 15 and revives the saved shop in place of the last three lines:
let back = actors.revive(fs.read("shop.blob")?)?
tell back.Show()
$ scua --allow-fs=. shop_v1.scua
sword 12, shield 5
$ scua --allow-fs=. shop_v2.scua
sword 15, shield 5
A balance patch reaches every saved shop this way. A list element
follows the program when it has an id field, and a value
the partition took out and kept as its own, such as a single price,
stays as it was. A save also revives after you add a field with a
default to a record it holds.
On a server where saves pass through storage you don't fully control,
--blob-keys=FILE signs every snapshot with your
deployment's key and revives only those that verify. Here the shop is
saved and revived under one key, then revived under another:
$ scua --allow-fs=. --blob-keys=../keys/deploy.keys shop_v1.scua
sword 12, shield 5
$ scua --allow-fs=. --blob-keys=../keys/deploy.keys shop_v2.scua
sword 15, shield 5
$ scua --allow-fs=. --blob-keys=../keys/other.keys shop_v2.scua
{"error":{"kind":"integrity","message":"this blob does not verify against this deployment's keys: it was changed after it was written, or signed with a key this deployment does not hold"}}
A key file can list several keys, so you can rotate keys without
stranding old saves, and the run refuses to start if your script could
read the file. Only one copy of a partition runs at a time: reviving a
save whose partition is already running is refused, and
actors.revive(blob, { fork = true }) makes a separate copy
when you run with --allow-fork. A store that hands out a
generation number when it claims a session can pass it to the revive, so
a process still holding an old copy can't overwrite the new one.
#Smaller additions
- An
askinside amap_allorwait_allarm sends every request at once and collects the answers in order. A partition can have up to 64 asks waiting. actors.stats()reports how many partitions are running, how many are queued, dead letters, and revives refused by reason.scua blob-infoshows a save's identity, whether it was signed, and what it is made of.--mem-statsreports the longest garbage collection pause.- Reserved words can be used as field names after a dot and as table keys.
SCUA_ACTOR_ORDER=shuffle:SEEDruns partitions in a seeded random order, to find code that depends on an order the runtime doesn't promise.
#Upgrading to 0.34
Most programs that use partitions need a few edits. The compiler
reports each language change below with an error that names a fix that
compiles, and it reports every module let and renamed
colour in every file your program imports in one compile, so a library
moves over in one pass.
A partition can't reach the main script's variables. Make the value a
const, pass it in as a constructor argument, or keep it in the partition'sstate. A module's top level can't hold aleteither: useconst, orstatefor a value each partition keeps for itself.constfreezes its value.const xs = []followed byxs.push(1)is an error now; uselet, or build the value in aletand writeconst XS = freeze(xs). Everything a module exports is frozen too.chart.palette[0] = "green"in a file that importschartgives a copy to change:scua: main.scua:2: `chart.palette` is an array that the `chart` module exports, and it is frozen: every file that imports `chart` shares it, so it can't be changed from here. Copy it and change the copy: `let my_palette = chart.palette[0:]`actors.freezeis nowactors.snapshot, andfreeze(value)freezes a value.The named colours moved into a module:
import color, thencolor.red().color_hex,rgb,srgbandhsvare unchanged.scua: old.scua:1: `color_red` was renamed: write `color.red`, and add `import color` at the top of this fileon Startis reserved for the handler that runs when a partition is created. A handler that usedStartas an ordinary message needs another name.Every error the runtime gives an actor is
Error({ kind, message }). A handler that faults while answering anasknow answersError({ kind = "fault", message = … }), so reade.messagewhere you used the error as text.Saves from 0.33 and earlier are refused:
actors.revivereturnsError({ kind = "old_format" }), whose message reads "this actor was saved by an older version of SCUA (actor format 7), which this version can't load (it reads actor format 8)". Revive one with the version that wrote it and export what you need as data.Embedding: partitions are named by
scua_actor_idinstead of a bareuint32_t, andSCUA_ABI_VERSIONis 2. Rebuild your host against the new header. A signed session save loads with the newscua_load_keyed.
Rarer changes, such as std becoming a reserved word and
forking a revive needing --allow-fork, are in the
changelog.
#What's next
The next targets for partitions are the memory one holds once it has handled a message, about 12 KB today, and the 1.5 µs it takes to create one on x86-64.
The full changelog has everything in 0.34.0.