rpc and actors
A bank account: service, actor and errors#
Task. Describe operations on an account as a contract, keep the state in an actor and return typed errors to the caller.
@error data InsufficientFunds { need: i64; have: i64 }
@error data UnknownAccount { id: str }
service Bank {
@public rpc balance(id: str) -> i64 | UnknownAccount
@public rpc withdraw(id: str, amount: i64) -> i64 | UnknownAccount | InsufficientFunds
}
actor Account sharded by id: str {
var cents: i64 = 100
msg get() -> i64 { cents }
msg take(n: i64) -> i64 | InsufficientFunds {
if cents < n { fail InsufficientFunds{ need: n, have: cents } }
cents -= n
cents
}
}
impl Bank {
rpc balance(id) {
if id.isEmpty() { fail UnknownAccount{ id } }
Account(id).get()?
}
rpc withdraw(id, amount) {
if id.isEmpty() { fail UnknownAccount{ id } }
Account(id).take(amount)?
}
}
cluster bank {
role all { serves Bank; hosts Account }
}
fn show(label: str, r: i64 | UnknownAccount | InsufficientFunds | NetError) io {
match r {
i64 n => print("{label}: ok {n}")
UnknownAccount e => print("{label}: unknown account '{e.id}'")
InsufficientFunds e => print("{label}: need {e.need}, have {e.have}")
NetError => print("{label}: network error") // NetError is added to the result of any rpc
}
}
fn main() io, net {
show("balance", Bank.balance("ann"))
show("withdraw 30", Bank.withdraw("ann", 30))
show("withdraw 90", Bank.withdraw("ann", 90))
show("no account", Bank.balance(""))
}Output
balance: ok 100 withdraw 30: ok 70 withdraw 90: need 90, have 70 no account: unknown account ''
@error data InsufficientFunds { need; have }is an error with data: the client sees not just "an error" but the facts on which it can decide.service Bankis the contract;actor Account sharded by id: stris one instance per key. The statecentsis saved to the journal and survives a node restart.impl Bank { rpc withdraw(id, amount) { Account(id).take(amount)? } }- parameter types come from the contract;?passes the actor's error to the caller.show(label, r)accepts the union of all possible outcomes andmatchtakes it apart: if you add another error to the contract the compiler demands that everymatchbe updated.NetErroris added to the result of every rpc: in tests and on a single node it does not occur, but in a cluster the network may fail and the code must account for it.- The same code works in-process (as here) and in a cluster of many nodes - only the
clusterdeclaration changes.
An actor as a rate limiter#
Task. Allow at most N operations, safely for any number of callers.
actor RateLimiter {
var used: i32 = 0
msg tryTake(limit: i32) -> bool {
if used >= limit {
false // the limit is used up
} else {
used += 1
true
}
}
msg reset() { used = 0 }
}
fn main() io {
let rl = spawn RateLimiter()
let a = rl.tryTake(2) // messages are processed one at a time, so there are no races
let b = rl.tryTake(2)
let c = rl.tryTake(2)
print("{a} {b} {c}")
rl.reset()
let d = rl.tryTake(2)
print("after reset: {d}")
}Output
true true false after reset: true
- The state
usedis reachable only from the actor'smsgmethods, and messages are processed one at a time - so the counter is correct without mutexes, however many tasks use the actor. msg tryTake(limit) -> boolreturns an answer; the caller is suspended until it arrives.- The same technique suits caches, counters, job queues, sessions: "one entity - one actor".
- Pitfall: do not perform long blocking operations inside one message - while it runs, the others wait.
Back: cookbook.