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] total = {total}`)
  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.

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.

#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 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(ask timeout)

You get back a value you can check, not a crash. See Errors and faults for working with error values. Always put a timeout on an ask; without one, a partition that never replies parks the asker forever.

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

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

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.

#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] total = {total}`)
  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] total = 3
alice scored 2
[scoreboard] total = 11
[scoreboard] total = 13

Notice the order. The three Score messages each print right away, then trigger 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.

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