OxL Reference

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.

oxl✓ checked in CI
@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 Bank is the contract; actor Account sharded by id: str is one instance per key. The state cents is 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 and match takes it apart: if you add another error to the contract the compiler demands that every match be updated.
  • NetError is 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 cluster declaration changes.

An actor as a rate limiter#

Task. Allow at most N operations, safely for any number of callers.

oxl✓ checked in CI
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 used is reachable only from the actor's msg methods, and messages are processed one at a time - so the counter is correct without mutexes, however many tasks use the actor.
  • msg tryTake(limit) -> bool returns 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.

See also

OxL 1.0.0-beta · Documentation version 1.0.0-beta© OxwaldLink · generated from the Standard, the std sources and the guides