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