SCUA

Manual

Partitions and the actor model

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 if above, first, and assign state only once it has all succeeded. Moving balance = balance - amount below 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 an ask waiting on the turn gets it as an Error. The program then runs to its end, and exits with code (1 if you leave it out), writing {"error": reason} to stderr last, as a top-level sys.fail does.
  • sys.exit(code) ends the turn and writes nothing. A non-zero code marks the run as failed, as sys.fail does.

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 +3 lands 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 8 prints before or after alice scored 3 is 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) } }).
  • make starts a new partition each time it is called and returns it, typically fn() return Db(URL) end, where URL is a constant. It follows the same rule as any partition's code: a top-level fn that captures no variables. The members get the same permissions as a partition started where actors.pool was called.
  • n is 1 to 256.
  • A tell goes 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 ask still has its own timeout while it waits, and an ask that has timed out is never handed to a member.
  • A member that faults is replaced by calling make again. The ask it was answering gets Error({ 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, and actors.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 new state field with a constant initial value (state retries = 0, state seen = [], state index = {}), which the revive fills in; state fields 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 with where rules, 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 state field 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.

  • 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.