A partition is an isolated unit of state that reacts to messages. It holds private fields, and the only way to touch them is to send the partition a message and let its own handlers do the work. Nothing outside reaches in. This is the actor model, and it's the idea SCUA is built around: state that can't be shared can't be raced on, and state that lives in one place can be saved and moved as one piece.
#Declaring a partition
A partition block has state fields and
message handlers. Here is one that keeps a running total:
partition Scoreboard
state total = 0
on Add(points)
total = total + points
print(`[scoreboard] +{points}`)
end
end
state total = 0 declares a private field with its
starting value. Inside a handler, a bare total reads or
writes that field. on Add(points) is a handler: when an
Add message arrives, this code runs with
points bound to the argument.
Functions written inside a handler can use state fields too,
including the arms of a wait_all or
map_all:
partition Poller
state urls = []
state hits = 0
on Poll()
let pages = map_all(urls, fn(u)
hits = hits + 1 -- writes the partition's real state
return http.get(u)
end)
print(`{hits} fetched`)
end
end
A name is resolved the way you'd expect: a local wins over a state
field, and a state field wins over a global of the same name. So a
let total = … inside the handler shadows
state total, exactly as it would in the handler body
itself.
One thing that does not change: a function that captures state still can't leave the partition. Sending one in a message is refused ("value cannot be sent between partitions"), because it would hand another partition a live view of this one's private memory.
Declaring a partition doesn't create one. Scoreboard()
does that, the same way you'd call a constructor:
let board = Scoreboard()
board is a reference to a live partition. You can pass
it around and store it, but you can't read its total
directly. The only way in is a message.
#Constructor arguments
A partition can take arguments when it is created. Name them after
the partition, and keep what you need in state:
partition Shop(catalog, owner)
state items = catalog
state owner = owner -- the right-hand side is the argument
state sold = 0
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-args.scua
Mara sold a sword for 50 (sale #1)
Mara sold a potion for 10 (sale #2)
The arguments are evaluated where the partition is created and copied
in, like a message. They are visible in the state lines and
nowhere else; a handler that uses one is told to add the
state line that keeps it. When a partition needs several
services, pass one table rather than a long list:
let svc = { log = Logger(), db = Db() }, then
Shop(svc).
#on Start
A handler named on Start() runs once, by itself, when
the partition is created, before any message. It takes no arguments (the
constructor's arguments already reach state), and nobody
can send it:
partition Room(name)
state name = name
state log = []
on Start()
log.push(`{name} opened`)
end
on Say(text) log.push(text) end
on Show() print(log) end
end
let r = Room("hall")
tell r.Say("hello")
tell r.Show()
$ scua room.scua
[hall opened, hello]
Start runs first even if the reference has already been
handed to someone who sent it a message: order between different senders
is not promised, so a message you sent first is not enough to be first.
It runs when a partition is created, and never when one is revived from a
blob: a hibernated session does not re-join its rooms.
#What a partition can reach
A partition's code — its handlers, its state lines, and
every function they call — may use the program's functions, its
constants, the standard library and the modules it imports, including a
module's state,
of which each partition has its own copy. It may not
reach the main script's variables: a top-level let belongs
to the main script. Reaching one directly, or through any 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`
The fixes, by what the variable is for:
- A value that never changes becomes a
const. Every partition reads the same one. - A value the partition needs, chosen when it is
created — including a reference to another partition — is a constructor argument. A constant can't
hold a partition reference, so for one of those the error says "pass it
in", never "make it
const". - A value the partition changes belongs in its
state, and whoever else needs it asks for it, or is told. - The host's data (
world, in an embedding) is a main-script name like any other: give a partition what it needs from it as an argument or in a message.
A function value the compiler can't follow — one that arrives in a message, or sits in a table — is held to the same rule when it is called: calling a function that reaches a main-script variable faults inside the partition, with the same message, whether or not the line that touches the variable would have run. Adding or removing a layer of indirection never changes whether a program runs.
A partition has no self: its own address is
actors.me(), and its state fields are read and
written by name (n = n + 1).
#Two kinds of message
There are two handler keywords, and they answer the question "does the sender wait for a reply?"
on Tag(args) handles a fire-and-forget message. The
sender doesn't wait and gets nothing back. You send one with
tell:
tell board.Add(10)
tell returns immediately. The Add runs on
the scoreboard's own timeline, soon, but not necessarily before the next
line of the sender.
ask Tag(args) handles a request and sends a reply with
return. The sender uses ask and waits for that
reply:
partition Account
state balance = 0
on Deposit(amount) -- fire-and-forget: no reply
balance = balance + amount
end
ask Balance() -- request: `return` is the reply
return balance
end
end
tell account.Deposit(100) sends and moves on.
ask account.Balance() sends and parks the asking handler
until the reply comes back, then evaluates to that value.
A handler can have any name except Ok and
Error, which are reserved for results:
on Ok(x) is a compile error ("Ok is reserved
for results — rename this partition handler"). Since no handler can be
called that, no message can be either.
#A full request/response example
ask has one rule worth knowing up front: you can only
ask from inside a partition handler, never from top-level
code. Top-level code can create partitions and tell them,
but waiting for a reply is something only an actor does. So this example
does its work inside a Main partition:
partition Counter
state n = 0
on Bump(by)
n = n + by
end
ask Value()
return n
end
end
partition Main
on Run()
let c = Counter()
tell c.Bump(3)
tell c.Bump(4)
let total = ask c.Value() timeout 1s
print(`count is {total}`)
end
end
let m = Main()
tell m.Run()
Run it:
$ scua counter.scua
count is 7
The two tells queue up, then the ask waits
for a reply that reflects them. Because all three messages go to the
same partition and a partition handles its mailbox in order, the count
is 7 by the time Value runs.
#The timeout form
ask waits, so it needs a way to give up. Write
ask target.Tag(args) timeout d, where d is a
duration: 1s,
200ms, 2min. If the reply doesn't arrive in
time, the ask doesn't hang and it doesn't fault. It
evaluates to an error value instead:
let r = ask slow.Work() timeout 1s
print(`got: {r}`)
When Work doesn't reply within a second:
got: Error({kind = timeout, message = ask timeout})
You get back a value you can check, not a crash. A reply is otherwise
the handler's own value, exactly as it returned it, so ask
gives you 5 when the handler returns 5, and
if ask p.Ready() timeout 1s then is an ordinary test of a
true or false reply.
The timeout holds whatever the partition you asked is doing. If it is
in the middle of a long loop, the ask still gives up at its
deadline, give or take a few milliseconds, rather than when the loop
ends. And now() has moved on by the time the
ask returns: after a timeout of 200ms,
now() is at least 200 ms past where it was before the
ask. Within a turn now() stays put until the
next point where the turn waits (an ask, a
wait, a select), so two reads with only
computation between them give the same value.
Under --fast and in scua test the clock is
virtual, and it moves only when nothing else can run. There an
ask … timeout fires only once every partition is waiting:
against a partition that is busy computing, it gets the reply when the
computation ends, however long that took. To test how your code handles
a timeout, make the other partition wait (on a wait, or on
an ask of its own) rather than compute.
Every error the runtime gives an ask has this
shape, { kind, message }, and kind
says what happened:
| kind | the ask got it because |
|---|---|
timeout |
its own deadline passed |
gone |
the partition it asked is not running: it was dropped, it is hibernated, or the reference came from another process |
fault |
the handler faulted while answering. message is the
fault |
no_reply |
the handler ended its turn with sys.exit or
sys.emit before replying |
interrupted |
the partition that asked was frozen while it waited and revived, so the request stayed with the old copy |
limit |
the partition already had as many asks waiting as it may (see Asking several partitions at once) |
misuse |
the message could not be sent: it holds a function, say.
message names what |
The same shape comes from the actors and
sandbox calls, and from any error that crosses an
ask reply or a sandbox boundary. A fault you catch with
pcall or try in your own code, with no
partition or sandbox in between, still gives you the fault's message as
a string.
These kinds mean the runtime said so, and nothing else can
produce them. A handler can return an Error of its
own, and the runtime passes it on untouched, with one exception: if its
payload claims one of the runtime's kinds, it arrives with
upstream_ in front, so kind = "timeout"
arrives as kind = "upstream_timeout" and
kind = "gone" as kind = "upstream_gone". That
is what you get from a partition that forwards the result of its own
ask when that one timed out
(return ask backend.Get(k) timeout 1s), and it keeps the
two cases apart: your request expired, or the partition you asked was
let down by one it asked in turn.
fn explain(r)
match r
Error(e) -> do
if e.kind == "timeout" then return "no answer in time" end
if e.kind == "upstream_timeout" then return "it gave up waiting on someone else" end
return `it said no: {e}`
end
_ -> return `got {r}`
end
end
print(explain(Error({ kind = "upstream_timeout", message = "ask timeout" })))
print(explain(5))
$ scua describe.scua
it gave up waiting on someone else
got 5
``` See [Errors and faults](errors.md) for working with error values. Always put a `timeout` on an `ask`. Leave it off and you get five seconds, which is a default rather than an answer: it is almost certainly too long for anything a person is waiting on, and too short for real work. Write the number you actually meant.
One thing to keep in mind: while a handler is parked on an `ask`, that partition isn't processing its own other messages. They queue up and wait until the reply (or the timeout) arrives and the handler finishes. So a partition that `ask`s a slow collaborator on a hot path can hold up everything else sent to it. Keep timeouts short on anything latency-sensitive, and prefer `tell` when you don't actually need a reply.
## A message nothing handles
Send a partition a message name it has no handler for and you get told, at the line that sent it:
$ scua orders.scua scua: orders.scua:14: type error:
tell …Adld(…): partition 'Box' has no handler for 'Adld' —
did you mean 'Add'?
Where the compiler can see which partition a variable holds — a `let b = Box()` — that is a compile error and your program never runs. Where it cannot, because the reference arrived as a function argument or came out of a list, the same refusal happens at runtime, on the same line, with the same wording.
Both count `wait_for` as handling a message: a handler that parks on `wait_for(Resume)` is waiting for `Resume` just as surely as an `on Resume()` would be, so sending one is fine.
This matters more than it looks. A misspelled message used to do **nothing at all** — no error, no warning, exit 0 — because a partition answers the messages it knows and ignores the rest. With `ask`, it looked worse than nothing: the reply never came and you got a timeout, which reads as "that partition is slow" rather than "you wrote `Totl`".
## When a handler faults
A fault in a handler stops that handler, not the partition. The partition keeps its state and its mailbox and goes on to its next message, and the fault is reported on stderr as it happens, naming the partition. If the turn was answering an `ask`, the asker gets `Error({ kind = "fault", message = … })` instead of waiting out its timeout.
**A faulted turn is not rolled back.** Whatever the handler changed before the fault stays changed. Here the balance is taken first and the count is updated after the check, so the withdrawal that faults leaves the balance moved and the count not:
```scua
partition Account
state balance = 100
state withdrawals = 0
on Withdraw(amount)
balance = balance - amount
if balance < 0 then
error(`overdrawn by {-balance}`)
end
withdrawals = withdrawals + 1
end
on Show()
print(`balance {balance}, withdrawals {withdrawals}`)
end
end
let acct = Account()
tell acct.Withdraw(30)
tell acct.Withdraw(90)
tell acct.Show()
$ scua account.scua
scua: account.scua:8: actor fault in partition Account (it keeps running): overdrawn by 20
balance -20, withdrawals 1
The second withdrawal never finished, but its first line did, so
balance and withdrawals now disagree. Anyone
who can send this partition a message that faults can do the same, as
often as they like. Two habits keep a turn whole:
- Check before you change anything. Do the work that
can fail, such as parsing, decrypting or the
ifabove, first, and assign state only once it has all succeeded. Movingbalance = balance - amountbelow the check makes the faulting withdrawal change nothing. - Compute into locals, then assign together at the
end. A handler that builds its new values in
lets and writes its state in the last few lines can fail anywhere above them without leaving a half-done update behind.
The program still exits with status 5 when it ends, so the fault isn't lost even if nobody was watching stderr.
#When a handler returns an
Error
A handler can also stop early without faulting: ? on a
result that failed, or return Error(…) written out. The
turn ends there. The partition keeps the state it had
and goes on to its next message, and since nobody is waiting for an
on handler's answer, the Error is written to
stderr when it happens, naming the partition and the message:
import json
partition Settings
state theme = "light"
state loads = 0
on Load(text)
let cfg = json.decode(text)?
theme = cfg.theme
loads = loads + 1
end
on Show()
print(`theme {theme}, loaded {loads} times`)
end
end
let s = Settings()
tell s.Load("{\"theme\": \"dark\"}")
tell s.Load("{not json")
tell s.Show()
$ scua settings.scua
scua: settings.scua:8: partition Settings, message Load: the turn ended with Error(invalid JSON at line 1, column 2); state kept
theme dark, loaded 1 times
It is not a fault, so it doesn't change the exit status, any more
than a 500 from http.serve does. The same holds for
actor(fn, state) and sandbox.actor: when the
function returns an Error instead of a new state, the state
stays as it was.
In an ask handler the Error is the reply.
? there hands it to whoever asked, exactly as
return Error(…) does, and nothing is written to stderr,
because the caller has it. The same goes for an
actor(fn, state) function whose Error ends a
turn that an ask is waiting on.
As with a fault, the turn is not rolled back:
whatever the handler changed before the ? stays changed.
Put the ? first, as Load does above, and
assign state only once everything that can fail has succeeded.
These lines are rate-limited like fault reports, and separately from them, so a flood of bad messages can't push a fault out of the log. See Faults a running program survives.
#sys.exit,
sys.emit and sys.fail in a handler
At the top level these three end the program. In a handler they end the turn, and the partition carries on, so one partition can't shut down a program that other partitions are still working in:
sys.emit(value)writes its JSON to stdout, as it does at the top level, and ends the turn.sys.fail(reason, code?)ends the turn and marks the run as failed. The reason is written to stderr straight away, naming the partition and the message, and anaskwaiting on the turn gets it as anError. The program then runs to its end, and exits withcode(1 if you leave it out), writing{"error": reason}to stderr last, as a top-levelsys.faildoes.sys.exit(code)ends the turn and writes nothing. A non-zerocodemarks the run as failed, assys.faildoes.
In each case the partition keeps its state and goes on to its next
message. If more than one handler fails the run, the first one's code is
the exit status. A crash, or a limit such as --max-mem,
still wins over it, because then there is no answer at all.
return Error(e) from a handler is different: it refuses
that one message and the run's exit status stays 0. Use
sys.fail when the program as a whole has failed, for
example a tool whose work runs in a partition so that it can
ask.
An ask whose handler ends with sys.exit or
sys.emit before calling reply gets
Error({ kind = "no_reply", message = … }), rather than
waiting until it times out.
import sys
partition Tool
state runs = 0
on Report(total)
runs = runs + 1
sys.emit({ total = total })
end
on Check(path)
runs = runs + 1
if path == "" then sys.fail("no path given") end
print(`checking {path}`)
end
on Stop()
sys.exit(3)
end
on Show()
print(`runs: {runs}`)
end
end
let t = Tool()
tell t.Report(42)
tell t.Check("")
tell t.Stop()
tell t.Show()
$ scua tool.scua
{"total":42}
scua: tool.scua:13: partition Tool, message Check: the turn ended with Error(no path given); state kept
runs: 2
{"error":"no path given"}
The program exits 1: Check's sys.fail was
the first to fail the run, so Stop's 3 doesn't count.
Stop ended its own turn and nothing else, and
Show still ran.
#Isolation is the whole point
The reason to organize a program this way is the guarantee you get in return. Three rules hold, always:
State is private. The fields after state belong to the
partition. No other partition, and no top-level code, can read or write
them. The only code that touches a partition's state is that partition's
own handlers.
Handlers don't overlap. A partition runs one handler at a time over
its own state. While Add is running, no other handler for
that partition runs. So total = total + points is never
half-finished when something else reads total. There is no
lock to take and no race to lose, because there's no sharing to begin
with.
Messages are copied. When you tell or ask
with an argument, the receiver gets its own copy. Mutating your copy
afterward can't reach into the partition:
partition Store
state label = "?"
on Keep(box)
label = box.name
end
ask Label()
return label
end
end
partition Main
on Run()
let s = Store()
let box = { name = "apple" }
tell s.Keep(box) -- box is copied on send
box.name = "banana" -- mutating our copy can't reach the store
let kept = ask s.Label() timeout 1s
print(`store kept: {kept}`)
print(`my box now: {box.name}`)
end
end
let m = Main()
tell m.Run()
$ scua store.scua
store kept: apple
my box now: banana
The store kept apple. Our later edit to
banana changed only our own copy. Two partitions can never
end up pointing at the same mutable record, so there's no spooky action
at a distance to debug.
A record arrives as the
same record type, at any depth of the message: its methods work in the receiving partition,
dispatched on its real type, and it is still sealed, so a handler cannot
add fields to it. Here the manual's Shape interface crosses
to an actor inside an array:
record Circle { r: int }
record Rect { w: int, h: int }
fn area(c: Circle) -> int return c.r * c.r end
fn area(s: Rect) -> int return s.w * s.h end
interface Shape {
fn area(self) -> int
}
partition Surveyor
on Survey(shapes: { Shape })
let sum = 0
for sh in shapes do sum = sum + sh.area() end -- each dispatched to its real type
print(`total area: {sum}`)
end
end
const c: Circle = { r = 4 }
const s: Rect = { w = 3, h = 5 }
tell Surveyor().Survey([c, s])
$ scua survey.scua
total area: 31
Three places still hand you a plain table instead of
a record, and each needs the same one-line fix,
parse(value, Circle), to build the record again: data
decoded from json or durable, a record that
crossed into a sandbox,
and a message from an actor on another machine in a cluster.
References are the exception that proves the rule. A partition
reference (like board or s above) does flow
through a message, which is how partitions learn about each other. What
you can't share is mutable data. You share addresses, then talk by
message.
Because the copy walks the whole value, a message can be at
most 256 levels deep — the same limit a durable save uses. Past
that, the send is a catchable fault naming the limit rather than a
silent failure. Ordinary game and application data is nowhere near it;
you meet the limit with generated or recursively-built structures, and
if you do, the answer is usually to flatten the shape rather than to
raise a limit. (A tell wraps its arguments in a list, so
one argument can itself nest 255 levels.)
#Partitions addressing each other
Because references flow in messages, partitions wire themselves together at runtime. A player learns its scoreboard, then reports to it:
partition Scoreboard
state total = 0
on Add(points)
total = total + points
print(`[scoreboard] +{points}`)
end
end
partition Player
state board = nil
state who = "?"
on Join(b, name)
board = b
who = name
end
on Score(move)
let earned = len(move)
print(`{who} scored {earned}`)
tell board.Add(earned)
end
end
let board = Scoreboard()
let alice = Player()
let bob = Player()
tell alice.Join(board, "alice")
tell bob.Join(board, "bob")
tell alice.Score("hit") -- 3 points
tell bob.Score("critical") -- 8 points
tell alice.Score("ko") -- 2 points
$ scua actors.scua
alice scored 3
bob scored 8
[scoreboard] +3
alice scored 2
[scoreboard] +8
[scoreboard] +2
Each Score message prints right away, then sends a
tell board.Add(...), and those Add messages
run later, on the scoreboard's own timeline. The players and the
scoreboard advance independently; the only thing tying them together is
the messages between them. That decoupling is what makes each partition
something you can pause, save, and resume on its own.
That output is one possible run, and you should not depend on the exact interleaving. What is guaranteed is this:
- Messages from one sender to one partition arrive in the
order they were sent. Alice's
+3lands before her+2, always. - A partition handles one message at a time, so a total is never half-updated.
- The order between independent partitions is not
fixed. Whether
bob scored 8prints before or afteralice scored 3is up to the runtime. Partitions run on several CPU cores by default, so it genuinely varies between runs.
If you need two things to happen in a definite order, make the second one caused by the first — send it from the handler that finishes the first, rather than assuming the runtime will interleave them the way it did today.
#Several cores by default; one core is repeatable
scua runs your partitions on several cores at once: one
thread per full-speed core, up to eight (--workers=auto,
the default). A partition that is busy computing or waiting on the
network no longer holds the others up, and partitions that mostly pass
small messages back and forth cost about the same as on one core: while
there is only a little work at a time it stays on one thread, and the
other cores join in when there is enough to share. Everything above
still holds — per-sender order, one message at a time — but output from
different partitions is grouped by the core that produced it
rather than finely interleaved, and the grouping can change from run to
run. A single print is never split down the middle; whole
lines simply arrive in a different order. The lines one partition prints
keep their order.
When you need the same output every time, pass --workers=0. The
partitions then take their turns one at a time on the main thread, and
with blocking I/O (the default), the same program with the same
input prints the same lines in the same order, every time. That
is a promise you can build on, and scua test,
scua debug and --fast always run that way.
(--workers=1 does the same today, but a later release gives
it one thread of its own, so write 0.) Under --io=async
the values stay the same, but tasks resume in the order their I/O
finishes, so lines they print can come out in a different order. "The
same" includes the same settings. A long handler is paused now and then
so the others get a turn, and compiled code (the default) may pause it
at a slightly different point than --jit=off, so the
interleaving can differ between the two.
So: if a test compares printed output, run it with
scua test or --workers=0. If a program needs a
definite order, get it from causality — the rule just above — not from
the schedule.
To find out whether a program leans on an order nobody promised, run
it under another schedule. SCUA_ACTOR_ORDER=reverse runs
each batch of partitions in the opposite order, and
SCUA_ACTOR_ORDER=shuffle:N in an order drawn from the
number N, so the same N gives the same order
again. Under shuffle the main script may also carry on past
a wait(0ms) before the partitions it just sent to have run,
so a program that reads a partition's state straight after a
wait(0ms) shows up too. Every one of these is a legal
schedule: a program whose output changes under one was relying on luck.
Any other value is refused before the program starts, so a mistyped name
can't quietly test nothing:
$ SCUA_ACTOR_ORDER=shuffle:7 scua --workers=0 report.scua
$ SCUA_ACTOR_ORDER=shufle:7 scua report.scua
scua: SCUA_ACTOR_ORDER=shufle:7 is not a schedule this runtime knows; use index (the default), reverse, shuffle, or shuffle:SEED with a decimal SEED
#References: who is who
A reference names one partition, whichever copy of it you hold. Two
references to the same partition are equal (==), work as
the same table key, and stay equal after the partition is snapshotted
and revived. So a room can keep its members in a table keyed by
reference, and a Leave finds the member its
Join added:
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
actors.me() is the calling partition's
own address. It is the same value every time, so calling it in a loop
costs nothing. The main script has no address, so
actors.me() there is an error.
Two kinds of reference. The reference you get when
you create a partition is a full one: you can send to it, snapshot it
and drop it. actors.me() gives a send-only
reference: whoever you hand it to can send you messages, and
actors.snapshot or actors.drop on it is
refused with kind = "misuse". Use
actors.send_only(r) to make a send-only copy of a reference
you hold before you hand it on. There is no way back to a full
reference. A send-only reference is equal to the full one and is the
same table key.
Printing a reference shows its locator,
<actor 3f1c09a2b7d45e60>: sixteen hex digits derived
from the partition's identity, the same in every process and after every
revive. actors.locator(r) gives the same digits as
"actor:3f1c09a2b7d45e60", and json.encode(r)
writes that string. A locator is for reading in logs; nothing turns it
back into a reference.
Who sent this? In a handler,
actors.sender() names the sender of the message being
handled, as the runtime saw it: "main" for the main script,
"host" for an embedding program, or another partition's
locator, "actor:…", which you can compare with
actors.locator(r). A sender cannot set it, and it is a
name, not a reference: you cannot send to it. It survives a snapshot, so
a revived partition still knows who sent the mail it had queued.
import actors
partition Echo
on Hello() print(`hello from {actors.sender()}`) end
end
let e = Echo()
tell e.Hello()
$ scua echo.scua
hello from main
A message to a partition that is not running
(dropped, hibernated, or one this process never ran) is a dead
letter. It is not delivered, because there is nobody to deliver it
to, but it doesn't vanish either: the runtime counts it and writes a
line to stderr naming the sender and the target by locator, such as
a message from main to actor:3f1c09a2b7d45e60 was not delivered: it was dropped or hibernated.
The line is a report, not a warning, so --no-warnings does
not hide it. After the first few, one line a second is written and the
rest are counted. An ask to such a partition gets
Error({ kind = "gone" }) straight away.
actors.stats(), in the main script, returns the
runtime's counts: how many partitions are running, how many handler
faults they have survived (faults), how many turns ended
with an Error that no ask was waiting for
(errors: a told handler's ?, say), how many
slots the partition table has and how many of those are free for the
next partition, how many partitions are queued to run, dead letters to
partitions this process dropped and to ones it never ran, the log lines
the limit held back, revives refused, by reason, and the counts for live
values. It also counts revives by how each blob was signed —
loads_by_key, by key id, and loads_unkeyed
(see --blob-keys)
— the stored values from constants that a revive kept as the actor's own
(constants_kept), and the parts of newer saves dropped
because the actor had moved on since
(sections_discarded).
#Asking several partitions at once
An ask in a handler waits for its answer before the
handler goes on. To ask many partitions at once, or one partition many
things, put the asks inside a map_all or
wait_all: each arm waits for its own answer, the arms'
requests are all out at the same time, and the results come back in
order.
partition Physics
ask Ray(i) return i * 2 end
end
partition Zone
on Tick(physics, n)
let hits = map_all(range(0, n), fn(i) return ask physics.Ray(i) timeout 1s end, { concurrency = 16 })
let total = 0
for h in hits do
match h
Error(e) -> print(`ray failed: {e.kind}`)
v -> total = total + v
end
end
print(`{len(hits)} rays, total {total}`)
end
end
tell Zone().Tick(Physics(), 40)
$ scua rays.scua
40 rays, total 1560
A partition may have up to 64 asks waiting at once, which is also the
most arms a wait_all or map_all runs at a
time. An ask past that limit is not sent: it gets
Error({ kind = "limit" }) at once, rather than waiting.
#A pool of partitions
A partition handles one message at a time. That is what keeps its
state safe, and it is also a limit when each message waits on something
slow, such as a database query: eight queries sent to one
Db partition take eight round trips, one after another.
actors.pool(n, make) starts n partitions by
calling make and gives you one address for all of them. An
ask sent to it goes to a member that is free, so
n of them are answered at once, and the member's reply goes
straight back to whoever asked.
import actors
partition Db
ask Query(id)
select
wait_for(Never) -> nil
wait(100ms) -> nil -- stands in for the database's round trip
end
return `row {id}`
end
end
partition Report
on Run(db)
let rows = map_all(range(0, 8), fn(i) return ask db.Query(i) timeout 2s end)
print(`{len(rows)} rows, from {rows[0]} to {rows[7]}`)
end
end
tell Report().Run(actors.pool(4, fn() return Db() end))
$ scua report.scua
8 rows, from row 0 to row 7
Four members answer the eight queries in two rounds, about 200 ms in
all; one Db would take 800 ms.
What to know about a pool:
- The address is send-only. It can be told and asked,
and passed to other partitions, but not snapshotted or dropped. That
makes it safe to hand out, including to a web server's handlers in
context:http.serve(handle, { context = { db = actors.pool(8, fn() return Db(URL) end) } }). makestarts a new partition each time it is called and returns it, typicallyfn() return Db(URL) end, whereURLis a constant. It follows the same rule as any partition's code: a top-levelfnthat captures no variables. The members get the same permissions as a partition started whereactors.poolwas called.nis 1 to 256.- A
tellgoes to a free member too. With one member, messages from one sender arrive in the order they were sent; with more, two messages may be handled by different members in either order. - When every member is busy, messages wait in the pool's
queue. An
askstill has its own timeout while it waits, and anaskthat has timed out is never handed to a member. - A member that faults is replaced by calling
makeagain. Theaskit was answering getsError({ kind = "fault" }), as it would from a single partition, and the next message goes to the new member, which starts from its declared state. - A misspelt message is refused where it is sent, checked against the members' handlers, as a message to a single partition is.
- Inside a member,
actors.sender()is whoever sent the message, not the pool, andactors.me()is that member's own address.
#Snapshotting an actor and bringing it back
That last sentence is not a metaphor. An actor's whole state lives in its own isolated heap, so it can be turned into bytes and turned back into a running actor — including a handler that was parked halfway through a turn.
Three functions, in the actors module:
import actors
fn park(session)
let blob = actors.snapshot(session)? -- the bytes. The actor keeps running.
actors.drop(session)? -- stop it and release its memory.
return actors.revive(blob) -- bring it back.
end
snapshot is not a teardown: the actor
is still there, still resident, still costing what it cost. That is why
drop exists — a snapshot on its own gives you a backup, and
it is drop that turns a quiet session into no memory at
all. A long-running server snapshots the sessions nobody is using, drops
them, and revives one when the next request for it arrives. (This used
to be called actors.freeze; freeze now means a
frozen value, see Variables.)
Doing this on every request is fine. A dropped actor leaves nothing behind that later work pays for: its slot is reused for the next actor, and the scheduler visits only actors that have something to do, so a message costs the same after 100,000 drops as after none, and the same beside 100,000 idle actors as beside none.
revive brings back the same actor, not
a copy. The identity travels inside the blob, so a reference you handed
out before the snapshot still reaches it:
import actors
partition Session
state visits = 0
on Visit(page)
visits = visits + 1
print(`visit {visits}: {page}`)
end
end
let s = Session()
tell s.Visit("/home")
wait(0ms) -- let the handler run before the snapshot
match actors.snapshot(s)
Ok(blob) -> do
match actors.generation(blob)
Ok(g) -> print(`snapshot, generation {g}`)
Error(e) -> print(`unreadable: {e.kind}`)
end
match actors.drop(s)
Ok(gone) -> print(`dropped: {gone}`)
Error(e) -> print(`drop failed: {e}`)
end
match actors.revive(blob)
Ok(s2) -> do
tell s2.Visit("/pricing")
tell s.Visit("/checkout") -- the reference from before the snapshot
end
Error(e) -> print(`revive failed: {e.kind}`)
end
end
Error(e) -> print(`snapshot failed: {e.kind}`)
end
$ scua session.scua
visit 1: /home
snapshot, generation 1
dropped: true
visit 2: /pricing
visit 3: /checkout
Both s2 and s reach the revived actor, and
its count carries straight on from 1. That is what you want for a
session. It also means only one copy of an actor can run at a time:
reviving a blob whose actor is still running is refused with
kind = "already_live". When you do want a separate copy,
say so with actors.revive(blob, { fork = true }). The fork
gets an identity of its own, so it is not equal to the original and
messages to the original do not reach it. A fork is a second live copy
of everything the partition holds, so it needs a grant of its own: run
the program with --allow-fork. Without it, a fork is
refused with kind = "not_granted".
A handler that was parked on an ask when it was frozen
does not wait for that answer after the revive: the request went with
the old copy, so the ask gets
Error({ kind = "interrupted" }) as soon as the actor runs.
Check whether the request took effect before you ask again. No turn of a
revived actor runs until the code that revived it next waits, so you can
look at the result first.
#When the partition is in a turn
A partition is snapshotted or dropped between its turns. From the
main script, both wait for the turn in progress to end: up to a second
by default, or as long as actors.snapshot(a, { wait = 5s })
says. { drop = true } drops the partition under the same
hold as the snapshot, so no turn can run between the bytes and the drop,
which is the safe way to hibernate something that may be getting
messages.
Inside a handler (any partition's turn, a request handler included)
they never wait, because waiting there would hold the worker thread that
runs other partitions too. A partition that is in a turn answers
Error({ kind = "busy" }) instead. Try again later from a
task, which may wait:
spawn(fn()
let gone = false
while not gone do
match actors.drop(room)
Ok(_) -> gone = true
Error(e) -> if e.kind == "busy" then wait(10ms) else gone = true end
end
end
end)
#Keeping track of which blob is newest
Every blob carries a generation — 1 for an actor's first snapshot, one higher each time after, and it keeps counting across a revive:
let gen = actors.generation(blob)?
Store that number beside the bytes, and refuse to save a blob whose generation is not higher than the one you already hold. It is a small amount of bookkeeping that answers a specific problem: a process that woke an old copy of a session and carried on working has stale state, and without the check its next save quietly overwrites the newer one. The runtime cannot make that decision for you — it does not know where your blobs live — so it guarantees the number and leaves the comparison where the storage is.
When more than one process can serve a session, the number comes from
your store instead: claim the session there with a compare-and-set, and
revive with the number you claimed,
actors.revive(blob, { generation = g }). Every later
snapshot counts on from g. Hibernate
a session walks through the whole protocol. Within one process the
runtime also refuses a blob that is older than one it has already
revived or dropped for that actor
(kind = "stale_generation").
#What is refused, and why
Both snapshot and revive return
Ok/Error rather than faulting, because the
bytes come from storage and storage goes wrong for ordinary reasons.
e.kind says which:
| kind | means |
|---|---|
gone |
the reference names an actor that is no longer here |
misuse |
the reference is send-only, so it cannot snapshot or drop |
already_live |
that actor is running already. Send to it, or revive with
{ fork = true } for a copy |
stale_generation |
the blob is older than one this process has already run, or the
generation you passed is not above the blob's |
busy |
you tried to snapshot an actor from inside its own handler. Snapshot it from the code that drives it |
host_resource |
the actor can still reach an open file, socket or process. Close it
first; e.message says which |
sandbox |
sandboxes are never snapshotted — their code came from a string that may not exist next time — and a sandbox cannot revive an actor |
bad_blob |
not an actor blob, or damaged inside |
truncated |
the bytes stop short of a whole actor: cut off in storage or in transit |
corrupt |
the blob's own checksum does not match its contents: it was damaged in storage or in transit |
integrity |
the run signs its saves with a deployment key (--blob-keys),
and this blob does not verify: it changed after it was written, it was
signed with a key the run does not hold, or it was saved without a
key |
old_format |
saved by an older version of SCUA, which this version can't load.
e.message names both formats |
newer_format |
saved by a newer version of SCUA. Revive it with that version |
unknown_section |
a newer version of SCUA saved a part of the actor this one can't read, and it can't be left out |
constant_gone, shape_changed |
the run was started with --strict-constants,
and the actor holds a value it took from a constant that is gone, or
whose place has changed |
schema_mismatch |
written by a different build of your program: an enum or flags declaration, or a built-in function the actor holds, has changed |
identity_refused |
something the blob holds does not match this program: a record type,
the partition's state, or a function. e.message names
it |
not_granted |
this partition may not revive actors, or may not fork one. Reviving
is something a partition is given by the code that created it, and the
main script has it unless its host withdrew it. Forking
({ fork = true }) is a separate grant, off unless the
program runs with --allow-fork,
and passed on the same way |
live_behind |
you revived with { refuse_live_behind = true }, and
this process is behind the actor on a live value:
it has seen a newer version than this process has |
live_unpublished |
the host asked that nothing run before its live values
are first published, and one the actor reads has not been |
res_missing |
the save ran with a res:
layer, a pack or a mod, that this run does not have.
e.missing lists them. Revive with
{ res_missing = "allow" } to run without them |
res_changed |
you revived with { refuse_res_changed = true } (or the
run has --strict-res), and a res: layer has
changed since the save, or is new. e.changed lists
them |
schema_mismatch and identity_refused are
the same idea from two distances. A blob carries data and a
parked turn — never code. Revive it with the program that wrote
it; if that program has changed underneath, you get a refusal naming
what no longer matches, rather than a session quietly resuming into the
wrong function.
#What a revive checks about your records
A record is identified by the module that declares it and its name, so a revive finds each record type the actor holds by name in the program doing the reviving, and then checks its fields. Only what the actor actually holds when it is snapshotted counts:
- Revives: records added, removed or reordered that
the actor does not hold; overloads and declarations in a different
order; a held record that gained a field with a constant default
(
level: int = 1,tags: { string } = []) or an optional field (title: string?), which the revive fills in every saved copy, or whose fields are in a different order; a newstatefield with a constant initial value (state retries = 0,state seen = [],state index = {}), which the revive fills in;statefields in a different order. - Refused, naming the record and the field
(
identity_refused): a held record lost a field, retyped one, or gained one with no default, or with a default that is computed (= time.now()). So is any change to the fields of a held record withwhererules, or to the rules themselves, a renamed record, and one moved to another module. The message shows the saved field list and the current one. - Refused, naming the partition: a
statefield was removed, or added with an initial value that is computed (state started = time.now()), because a revive has no value to give it.
Renaming a record, or moving it to another module, is renaming its
type, so saves that hold one are refused. To change such a record,
revive the actor under the program that wrote it, export what you need
as data, and build the new shape from it with parse.
What the actor holds comes back as it was: its state,
its mail, a turn it was parked in. Each module state the
actor had created comes back as its own copy, matched by module and
field name. Everything else — your functions, constants and modules — is
not in the blob at all, so a revived actor reads the current program's,
exactly as a newly created one does: a function added since is there,
and a changed constant has its new value.
#What a revived partition sees of your constants
A partition that stored a table it took from a
top-level constant — state items = ITEMS, or
ITEMS.sword kept in a field — gets the current program's
table back on revive, so a balance patch reaches every saved shop:
import actors
const ITEMS = { sword = { id = "sword", price = 12 }, shield = { id = "shield", price = 5 } }
partition Shop
state items = ITEMS
state favourite = nil
on Pick() favourite = ITEMS.sword end
on Show() print(items.shield.price, favourite.price) end
end
let s = Shop()
tell s.Pick()
let saved = actors.snapshot(s)?
let _ = actors.drop(s)
let back = actors.revive(saved)?
tell back.Show()
$ scua shop.scua
5 12
Revived by a later version of this program in which the sword costs
15, the same save prints 5 15.
The blob records where the table came from — the constant's name and
the keys you followed — and a revive follows the same keys in the
current program. A list element is followed by its position only when it
has an id field (or key, or name)
and that still matches: give list elements an id if
you want stored ones to follow patches. Without one, inserting
an item could turn a stored sword into a shield, so the actor keeps the
copy it stored instead.
It keeps its stored copy, too, when the constant is gone or the place
it came from is no longer the same kind of value (a table where a list
was). That copy is the actor's own data from then on, and the load
report counts it once (constants_kept), naming the
constant. Pass --strict-constants
to refuse such a revive instead.
Two things never follow the program. A number or string you took out
of a constant (state price = ITEMS.sword.price) is yours,
and keeps its value. Nothing you computed from a constant is
recomputed.
A revived actor comes back with the same capabilities a newly created one would get, so a handler that read files before the snapshot can still read them after. It does not inherit anything the program that revived it does not already hold.
The per-user folder (user: paths, granted by --allow-user
or --user-dir) follows its app id, not its path. A snapshot
of an actor that held the folder records the id it was granted under,
and a revive gives the folder back when the reviving run holds one under
the same id, wherever that folder now is: on another machine, or after
the home folder moved. A folder granted with no id, by
--user-dir alone, comes back only into a run that also has
one with no id. Revived under another id, or by a run without the
folder, the actor has no user: paths. A release of SCUA
from before the per-user folder revives such a snapshot without it.
See examples/hibernation.scua for a runnable version of all of this.
#Related pages
- Concurrency and time covers what a handler can do besides reply: spawn background work, wait on durations, and receive messages selectively.
- Modules split a program by file. Partitions split it by isolated state.
- Live values are settings the main script or the host can change while partitions run; every partition reads the current version.