← All dispatches
Saying What · Part 5 of 10 · Language

The process that owns the store

A rule store has to live somewhere, and on the BEAM that somewhere is a process. GenReactor is a supervised process whose state is a store and whose logic is rules, and the event is how it tells the world what happened.

Alejandro M. RamalloOct 202618 min readLanguage

I'm publishing this series ahead of Code BEAM Europe this October, where I'll reveal Bondy, a new declarative language for the BEAM: statically typed, functional, relational and rule-based.

The rules in the last post had no process around them. That was deliberate, because the rules are the new part. But a store needs an owner, and on the BEAM the owner is a process.

If you set out to write the limiter as a proper state machine on the BEAM, you would reach for gen_statem. It is OTP's behaviour for a process with named states and a clause for each event in each state. Bondy has it too, as GenStatem: the same behaviour, the same callbacks, with types. So that is the baseline for this post, written in Bondy.

The limiter as a GenStatem ​

Here is the limiter built the OTP way: one process per bucket, a supervisor to start them, and a registry (global) to find each bucket. The two Erlang tabs have the same module written with gen_statem, to compare line by line. One uses handle_event_function mode, as the Bondy version does. The other has a function per state (state_functions), the style an Erlang developer would more often reach for.

bondy
module Bucket implements GenStatem(State = Atom, Data = Tokens)

@moduledoc """
A token bucket rate limiter.
"""

import Kernel.Gen.{InitOk, NextStateActions, KeepStateActions}
import BucketSup

type Tokens = {tokens: Int, max: Int}

type Allowed  = {remaining: Int}
type Refilled = {new_count: Int}

%% --- API ------------------------------------

public function start_link(id: String, max: Int) ->
    GenStatem.start_link(name(id), __MODULE__, max, [])

public function create(id: String, max: Int) ->
    DynamicSupervisor.start_child(
        BucketSup, 
        #{
            :id => id,
            :start => (__MODULE__, :start_link, [id, max])
        }
    )

public function request(id: String) ->
    call(id, :request)

public function refill(id: String, n: Int) ->
    call(id, (:refill, n))

function name(id: String) -> 
    (:via, :global, (__MODULE__, id))

function call(id: String, msg) ->
    case rescue(fun() -> GenStatem.call(name(id), msg) end) of
        Ok(reply) -> 
            reply
        Error(Exception(class: :exit, reason: (:noproc, _))) -> 
            Error(:no_bucket)
        Error(e) -> 
            exit(e.reason)
    end

%% --- Callbacks -------------------------------------

public function callback_mode() -> 
    :handle_event_function

public function init(max) ->
    InitOk(:available, Tokens(tokens: max, max: max))


public function handle_event((:call, from), :request, :available, d: Tokens) 
    when d.tokens == 1 ->
    NextStateActions(
        :empty, 
        Tokens(tokens: 0, max: d.max), 
        [(:reply, from, Allowed(remaining: 0))]
    )

public function handle_event((:call, from), :request, :available, d: Tokens) ->
    let t = d.tokens - 1
    KeepStateActions(
        Tokens(tokens: t, max: d.max), 
        [(:reply, from, Allowed(remaining: t))]
    )

public function handle_event((:call, from), (:refill, n), :available, d: Tokens) ->
    let t = min(d.tokens + n, d.max)
    KeepStateActions(
        Tokens(tokens: t, max: d.max), 
        [(:reply, from, Refilled(new_count: t))]
    )

public function handle_event((:call, from), :request, :empty, _d) ->
    (:keep_state_and_data, [(:reply, from, :denied)])

public function handle_event((:call, from), (:refill, n), :empty, d: Tokens) when n > 0 ->
    let t = min(n, d.max)
    NextStateActions(
        :available, 
        Tokens(tokens: t, max: d.max), 
        [(:reply, from, Refilled(new_count: t))]
    )
erlang
-module(bucket).
-moduledoc """
A token bucket rate limiter.
""".

-behaviour(gen_statem).

-export([create/2, request/1, refill/2]).
-export([start_link/2, init/1, callback_mode/0, handle_event/4]).

%% --- API ------------------------------------

start_link(Id, Max) ->
    gen_statem:start_link({via, global, {?MODULE, Id}}, ?MODULE, Max, []).


%% One process per bucket, started under bucket_sup and found by id.
create(Id, Max) -> 
    supervisor:start_child(bucket_sup, [Id, Max]).

request(Id) -> 
    call(Id, request).

refill(Id, N) -> 
    call(Id, {refill, N}).

call(Id, Msg) ->
    try gen_statem:call({via, global, {?MODULE, Id}}, Msg)
    catch exit:{noproc, _} -> {error, no_bucket}
    end.


%% --- Callbacks -------------------------------------
callback_mode() -> 
    handle_event_function.

init(Max) -> 
    {ok, available, #{tokens => Max, max => Max}}.

%% --- State Management-------------------------------

handle_event({call, From}, request, available, #{tokens := 1} = D) ->
    Reply = {reply, From, {allowed, 0}},
    {next_state, empty, D#{tokens := 0}, [Reply]};

handle_event({call, From}, request, available, #{tokens := T} = D) ->
    Reply = {reply, From, {allowed, T - 1}},
    {keep_state, D#{tokens := T - 1}, [Reply]};

handle_event({call, From}, {refill, N}, available, #{tokens := T, max := M} = D) ->
    T1 = min(T + N, M),
    Reply = {reply, From, {refilled, T1}},
    {keep_state, D#{tokens := T1}, [Reply]};

handle_event({call, From}, request, empty, _D) ->
    Reply = {reply, From, denied},
    {keep_state_and_data, [Reply]};

handle_event({call, From}, {refill, N}, empty, #{max := M} = D) when N > 0 ->
    T1 = min(N, M),
    Reply = {reply, From, {refilled, T1}},
    {next_state, available, D#{tokens := T1}, [Reply]}.
erlang
-module(bucket).
-moduledoc """
A token bucket rate limiter.
""".

-behaviour(gen_statem).

-export([create/2, request/1, refill/2]).
-export([start_link/2, init/1, callback_mode/0, available/3, empty/3]).

%% --- API ------------------------------------

start_link(Id, Max) ->
    gen_statem:start_link({via, global, {?MODULE, Id}}, ?MODULE, Max, []).


%% One process per bucket, started under bucket_sup and found by id.
create(Id, Max) -> 
    supervisor:start_child(bucket_sup, [Id, Max]).

request(Id) -> 
    call(Id, request).

refill(Id, N) -> 
    call(Id, {refill, N}).

call(Id, Msg) ->
    try gen_statem:call({via, global, {?MODULE, Id}}, Msg)
    catch exit:{noproc, _} -> {error, no_bucket}
    end.


%% --- Callbacks -------------------------------------
callback_mode() -> 
    state_functions.

init(Max) -> 
    {ok, available, #{tokens => Max, max => Max}}.

%% --- State Management-------------------------------

available({call, From}, request, #{tokens := 1} = D) ->
    Reply = {reply, From, {allowed, 0}},
    {next_state, empty, D#{tokens := 0}, [Reply]};

available({call, From}, request, #{tokens := T} = D) ->
    Reply = {reply, From, {allowed, T - 1}},
    {keep_state, D#{tokens := T - 1}, [Reply]};

available({call, From}, {refill, N}, #{tokens := T, max := M} = D) ->
    T1 = min(T + N, M),
    Reply = {reply, From, {refilled, T1}},
    {keep_state, D#{tokens := T1}, [Reply]}.

empty({call, From}, request, _D) ->
    Reply = {reply, From, denied},
    {keep_state_and_data, [Reply]};

empty({call, From}, {refill, N}, #{max := M} = D) when N > 0 ->
    T1 = min(N, M),
    Reply = {reply, From, {refilled, T1}},
    {next_state, available, D#{tokens := T1}, [Reply]}.

If you know gen_statem, everything is where you expect it. GenStatem.start_link and GenStatem.call are gen_statem:start_link and gen_statem:call, and the callbacks are callback_mode/0, init/1 and handle_event/4. A (:via, :global, …) name registers the process, and DynamicSupervisor starts one bucket per id. What Bondy adds is types. The data is a Tokens struct, the replies are typed values, and each transition is a typed constructor such as NextStateActions. Bondy has no try/catch, so rescue turns the exit from a bucket that does not exist into Error(:no_bucket) and lets any other exit through.

Run it: create a bucket with two tokens, request three times, refill by five, request once more, and then request a bucket that does not exist:

[
    Ok(<0.206.0>),
    Allowed(1),
    Allowed(0),
    :denied,
    Refilled(2),
    Allowed(1),
    Error(:no_bucket)
]

For one bucket this reads well. Its whole life is visible at a glance: available until it runs out, then empty, denying every request until a refill moves it back. That is what a state machine is for: one thing, a handful of named modes, and a clear answer for each event in each mode. Think of a connection that is connecting, connected or closing, or a door that is open, closed or locked.

This version runs one process per bucket, so it needs a supervisor to start them and a registry to find them. That is a choice about deployment, not a property of GenStatem: one GenStatem could as well hold every bucket in its data. For two states and two events, a GenStatem is hard to beat.

The difference shows when the machine grows, and real limiters do. Premium buckets that are never denied. A daily spending cap on top of the token count. A bucket an operator has suspended. A throttled state after repeated denials.

In a GenStatem each of these is a new state or a flag in the data. A new state needs a clause for every event it can receive, and every clause has to check a flag. The premium override has to appear in every state that can deny, and the cap has to appear in every clause that can allow. The logic is organised by state, so one sentence about the system gets copied into each state it applies to.

Then there is what you do when something worth knowing happens inside. It is a log call with the details flattened into a string, a callback module passed in at start, or a pg group you publish to. Three mechanisms for "something happened", none of them typed, each tied to a particular listener.

None of the OTP part is wasted. The process is the right unit. A store has an owner, the owner has a mailbox, the mailbox handles one message at a time, and the supervisor restarts the owner when it dies. Erlang got this right in 1986 and Bondy keeps it: every store is owned by one OTP process and settles inside that process. What changes is where the logic lives.

What implements checks ​

One line of the code above deserves a closer look before moving on: the first. In Erlang, -behaviour(gen_statem) is a promise the compiler barely checks. Omit init/1 and erlc prints a warning and builds the module anyway. Return "denied" from a state function and it compiles cleanly, then crashes the state machine the first time that clause runs. (Both checked with OTP 29.)

In Bondy, implements is checked the way a type is. A behaviour is an ordinary module whose contract functions are marked @callback, each with a full type, plus abstract types: placeholders the behaviour leaves to whoever implements it. GenStatem has three, State, Data and Event. The implements line binds them for this module: GenStatem(State = Atom, Data = Tokens). Data and Event default to Any if left out. State has no default, so leaving it out is an error, and so is binding a name the behaviour does not declare.

The compiler then substitutes those bindings into every callback's type and checks the module's functions against the result:

  • Every required callback must be there. handle_event/4 missing is an error, not a warning. The optional ones (terminate/3, code_change/4, format_status/1) can be left out, as they are here.[1]
  • Each function must accept at least what the callback can be given, and return no more than the callback allows. A parameter can be wider than the contract but never narrower, which is why init takes its argument unannotated: GenStatem.start_link can pass anything there, so init(max: Int) is rejected. A result can be narrower than the contract but never wider.

Because Data = Tokens, "no more than the callback allows" means every transition in every clause must carry a Tokens. Write one clause that returns a plain map as the new data, and the module does not compile:

-- TYPE CHECK FAILED ---------------------------------

I ran into a problem in your code:

type checking failed with 1 error

  --> lib/bucket.bondy:57:1
   |
57 | public function handle_event((:call, from), :request, :available, d: Tokens) 
   | ^ callback type mismatch for `handle_event/4`; expected: ((Atom | {Atom, {Pid, Any}}, Any, Atom, Bondy.Bucket.Tokens) :: Atom | {Atom, List(Any)} | Bondy.Kernel.Gen.KeepState | Bondy.Kernel.Gen.KeepStateActions | Bondy.Kernel.Gen.NextState | Bondy.Kernel.Gen.NextStateActions | Bondy.Kernel.Gen.Stop | Bondy.Kernel.Gen.StopAndReply); found: ((Any, Any, Any, Any) :: {keep_state_and_data, NonEmptyList({reply, {type_var,44,0,meta}, denied})} | Bondy.Kernel.Gen.KeepStateActions | Bondy.Kernel.Gen.NextStateActions); required by GenStatem

That is the benefit. A behaviour stops being a list of function names you hope you spelled right and becomes a contract the compiler holds you to. The mistake surfaces when you build, as an error pointing at the callback, not as a crash on whichever event first reaches the wrong clause. The implements line also documents the process: anyone reading it knows what its state is and what data it holds, before reading a single clause. And it is the same mechanism for every behaviour: GenServer, Supervisor, GenReactor below, and any behaviour you write yourself, an ordinary module with @callback functions.

GenReactor - a rule based approach ​

GenReactor is Bondy's behaviour for a process whose state is a store of facts and whose logic is rules. Here is the same limiter as a GenReactor, every bucket in one process. It compiles on the current toolchain:

bondy
module RateLimiter implements GenReactor(Event = Allowed | Denied | Refilled)

@moduledoc """
A token bucket rate limiter.
"""

import GenReactor.{type Reply, NoReply}

constraint Bucket  = {id: String, tokens: Int, max_tokens: Int}
constraint Request = {id: String}
constraint Refill  = {id: String, amount: Int}

event Allowed  = {id: String, remaining: Int}
event Denied   = {id: String}
event Refilled = {id: String, new_count: Int}

%% --- Rules--- ----------------------

rule consume_token =
    on
        Request(id: rid) as req,
        Bucket(id: rid, tokens: t, max_tokens: m) as bucket
    remove
        req, bucket
    when
        t > 0
    tell
        Bucket(id: rid, tokens: t - 1, max_tokens: m),
        Allowed(id: rid, remaining: t - 1)
    end

rule deny_when_empty =
    on
        Bucket(id: rid, tokens: 0),
        Request(id: rid) as req
    remove
        req
    tell
        Denied(id: rid)
    end

rule refill_within_cap =
    on
        Refill(id: rid, amount: amt) as refill,
        Bucket(id: rid, tokens: t, max_tokens: m) as bucket
    remove
        refill, bucket
    when
        t + amt =< m
    tell
        Bucket(id: rid, tokens: t + amt, max_tokens: m),
        Refilled(id: rid, new_count: t + amt)
    end

rule refill_to_cap =
    on
        Refill(id: rid, amount: amt) as refill,
        Bucket(id: rid, tokens: t, max_tokens: m) as bucket
    remove
        refill, bucket
    when
        t + amt > m
    tell
        Bucket(id: rid, tokens: m, max_tokens: m),
        Refilled(id: rid, new_count: m)
    end

%% --- API ------------------------------------

public function start_link() ->
    GenReactor.start_link(__MODULE__, [], [])

public function start_link(name) ->
    GenReactor.start_link(__MODULE__, [], #{:name => name})

public function create_bucket(reactor, id, max_tokens) ->
    GenReactor.tell(reactor, Bucket(id: id, tokens: max_tokens, max_tokens: max_tokens))

public function request(reactor, id) ->
    GenReactor.tell(reactor, Request(id: id))

public function refill(reactor, id, amount) ->
    GenReactor.tell(reactor, Refill(id: id, amount: amount))

public function stop(reactor) ->
    GenReactor.stop(reactor)


%% --- Callbacks -------------------------------------

public function init(_args) ->
    Ok([])

public function handle_event(Allowed() as v, _ctx)  -> 
    Reply(value: v)
    
public function handle_event(Denied() as v, _ctx)   -> 
    Reply(value: v)

public function handle_event(Refilled() as v, _ctx) -> 
    Reply(value: v)

public function handle_event(_, _)                  -> 
    NoReply()

Read it top to bottom: three constraints, three events, four rules that connect them, then an API and two callbacks. The API is the interesting part, because of how little it does.

implements GenReactor(Event = Allowed | Denied | Refilled) says what this process can say to the outside world. The union is the process's public vocabulary, and it is typed. Nothing else leaves.

init/1 returns the initial store, here empty. A store starts as a list of constraints, so a limiter with pre-provisioned buckets would return them here.

handle_event/2 is the one decision the process makes that the rules do not: which events the caller gets to hear. Reply(value: v) sends the event back to whoever added the fact that caused it. NoReply() drops it. Omit the catch-all clause and an event nobody handles crashes the process. That is the right default for a typed vocabulary: an event you forgot about is a bug, not something to drop quietly.

The API is one verb: tell, which means adding a fact to the store. GenReactor.tell/2 is the synchronous form: it waits until the rules have settled and the resulting event has come back. GenReactor.tell_request/2 tells the store a constraint and returns a request id at once, so you decide when to fetch the response. The one other verb is ask: GenReactor.ask queries the store without adding anything to it. There is no handle_call, because there is nothing to dispatch on. A fact arrives, the rules do what they do, the events come out.

Run it with the same steps as the GenStatem above: create a bucket with two tokens, request three times, then refill by five.

[Ok(Allowed(id: "k", remaining: 1)), Ok(Allowed(id: "k", remaining: 0)),
 Ok(Denied("k")), Ok(Refilled(id: "k", new_count: 2))]

The refill lands on refill_to_cap, because five more tokens would overflow a bucket that holds two.

The event role ​

This is where the event role from the second post stops being a definition and becomes a mechanism.

A constraint stays in the store until a rule removes it. An event is declared with event instead of constraint, and the difference is one thing: every time the rules settle, the events are swept out of the store. Allowed never piles up. It exists for as long as handle_event needs to see it, and then it is gone. That is what "it happened" means at runtime: a moment, not a state.

That makes the event the answer to the three mechanisms for "something happened" from the top of this post. It is typed, so the listener gets Allowed(id: "api-key-1", remaining: 2), not a log line. It is addressed to nobody in particular, so the same event can go back to the caller, go to a subscriber, or be ignored. The rule that emitted it neither knows nor cares. And it is checked like every other value, so the same type checker that checks a Bucket in a rule checks an Allowed in a handle_event clause.

The rule emits. The process decides who hears. The sweep keeps the two apart.

Side by side comparison ​

The two versions organise the logic differently.

In the GenStatem, the logic is organised by state: what available does with each event, what empty does with each event. That is the right shape when there are a few states and each event means something clear in each of them. The number of clauses grows roughly as states times events, which is nothing at two by two.

In the GenReactor, the logic is organised by sentence. The state is a set of facts, and a rule fires when a combination of facts matches it: this request, with that bucket, when it has tokens. "Empty" is not a state to keep in sync: it is a bucket with zero tokens, and a rule that cares says so in its pattern. A new requirement is a new rule next to the others. The premium flag from the previous post is one rule, not a clause added to every state that can deny. The rules grow with the number of things you need to say, not with states times events.

Everything else is the same for both. Each is a supervised OTP process. Each can run one per bucket, with a supervisor and a registry, or one for many buckets. A GenReactor holding every bucket is the same choice as a GenStatem holding a map of them. Either way you shard by id when a single mailbox becomes the bottleneck.

GenServerGenStatemGenReactor
Stateany term you manageone named state, plus dataa store of facts
Inputcall, cast, infocall, cast, info, timeouts, per statetell (and ask, to query the store)
Logiccallback clausesclauses per state and eventrules that fire when facts combine
Outputreplies and side effectsreplies and actionstyped events, routed by handle_event
Fitsa service with some statea few states, each event clear in eacha machine that keeps growing, with rules that combine conditions

None of them replaces the others, and Bondy has all three: a door is still best written as a state machine. Use a GenReactor when the question is not "what mode am I in?" but "what follows from everything I know?".

The process you already know ​

Underneath, this is an OTP process. start_link returns a pid, the named form registers it, and it goes into a supervisor's child list like any other worker. When it crashes, the supervisor restarts it, init rebuilds the store, and the facts it had collected are gone. That is the same durability contract a GenStatem's data has, for the same reason. If a store must survive a restart, its facts go somewhere durable and init reloads them, which is what you do today.

It compiles to a standard .beam module named Bondy.RateLimiter, so you can call it from Erlang:

erlang
{ok, Pid} = 'Bondy.RateLimiter':start_link(),
'Bondy.RateLimiter':create_bucket(Pid, <<"api-key-1">>, 3),
'Bondy.RateLimiter':request(Pid, <<"api-key-1">>).
%% => {ok,{'Bondy.RateLimiter.Allowed',<<"api-key-1">>,2}}

The reply is {ok, Event}, with the event as a tagged tuple, because that is how a Bondy value built from a structural type looks at runtime.

Distribution is OTP distribution. Register the reactor globally, tell it something from another node, and the constraint crosses the wire like any other message. There is no Bondy-specific transport in this release. The Partisan layer that runs Bondy Connect in production is the next step, not something the compiler does today.

What is left of the GenStatem ​

Look back at the GenStatem at the top. The process around the logic is the same: a supervisor, and a registry if you run one process per bucket. What changed is the inside. The clauses per state and event are gone, replaced by rules, and the rules are the part that grows when the system does. Each new requirement is one more sentence, not a clause in every state it touches. The logging calls, callback modules or pg groups you would add for "something happened" are replaced by a typed event union and one function that decides who hears.

Plain messages that are not facts, such as a socket's data, still have a place: a GenReactor can define an optional handle_info.

What you have is a supervised process whose state is a set of facts and whose logic is a set of sentences about those facts. That is the noun the BEAM already gives you, the process, doing the job it always did, with the state machine inside it written in the language that was missing.

The store in this post holds constraints, and rules rewrite them. The next post is about the other kind of store: one that holds facts you look up rather than facts you impose. It also covers the other kind of rule, the one that runs over it.


  1. This is why the limiter uses :handle_event_function, one callback for every state, rather than :state_functions, one function per state, which an Erlang version would often use. Bondy accepts :state_functions at runtime, but its conformance check does not follow it yet. It still requires handle_event/4, so the module needs a placeholder that is never called. And it does not check the state functions themselves, because their names are the module's own rather than the behaviour's. A state function that returns a string compiles, and fails at runtime with bad_return_from_state_function, exactly as in Erlang. With :handle_event_function, every transition passes through the one callback the compiler checks. ↩︎