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