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

One module, five nouns

The rate limiter from the series, whole, on one page. Every line compiles on the current toolchain, each section links to the post that explains it, and the install is at the bottom.

Alejandro M. RamalloOct 20267 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.

Here is the whole thing. Seventy lines, one module and its manifest, compiled on the current toolchain. Walk it top to bottom and every construct the series introduced that the compiler has today appears once.

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

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

import GenReactor.{type Reply, NoReply}

%% Constants, evaluated at compile time. A public one is exported.
public const default_plan = "free"
const key_pattern = "^[A-Za-z0-9_-]{1,64}$"

%% A structural type whose field carries a refinement.
type Config = {max_tokens: Int & > 0 & =< 10000}

%% Relation role: facts you look up.
relation Plan = {name: String, max_tokens: Int}
fact Plan(name: "free", max_tokens: 10)
fact Plan(name: "pro",  max_tokens: 1000)

%% Constraint role: facts you impose on the store.
constraint Bucket  = {id: String, tokens: Int, max_tokens: Int}
constraint Request = {id: String}

%% Event role: what the store tells the world.
event Allowed = {id: String, remaining: Int}
event Denied  = {id: String}

%% Rewrite rules over the store.
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

%% The process that owns the store.
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(_, _)                 -> NoReply()

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

%% Functions.
public function plan_limit(name: String) ->
    query Plan(name: name, max_tokens: m) end

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

public function request(server, id: String) ->
    case Re.matches(id, key_pattern) of
        true  -> GenReactor.tell(server, Request(id: id))
        false -> Error(:invalid_key)
    end

The project describes itself in manifest.bcl:

bcl
import Library

manifest: Library.Manifest & {
    project: {
        name: :limiter,
        version: "0.1.0",
        description: "A token bucket rate limiter"
    }
}

The nouns, in order of appearance ​

Constants. default_plan and key_pattern are evaluated when the module compiles, not when it runs. default_plan is public, so another module reads it as RateLimiter.default_plan. key_pattern is private. request checks an id against it with Re.matches before it tells the store, and a malformed id gets Error(:invalid_key). A constant must be an expression the compiler can evaluate, so the pattern is kept as a string and compiled when it is used.

Functions. plan_limit, create_bucket, request. Functions over immutable values, pattern matching in the heads, let bindings that shadow rather than mutate: the functional core you already have on the BEAM, spelled differently. The compiler also knows what each of them may do, which is why t > 0 and any function you call from a guard are checked. The seventh post explains how.

Shapes. Config is a structural type from the third post: a named shape whose field carries its own refinement. Write Config(max_tokens: -3) and the compiler rejects it. Build one from a runtime value and the constructor checks the same rule. Bucket, Request, Allowed and Plan are structural types too, each declared directly in the role it plays.

Relations. Plan is held as facts, and plan_limit asks it a question with query. The sixth post is about this role and the derivation rules that run over it. The limiter only needs the facts, so there is no :- here. To run plan_limit, Plan also needs a relation store, which that post shows. The module leaves it out to stay short. Add relation Includes(plan: String, feature: String) and a recursive rule and you have the transitive queries that post replaces.

Constraints. Bucket and Request are facts in the store that the rules act on. What a bucket is, and what happens to it, are declared next to the type, not spread across a handle_call.

Events. Allowed and Denied are values that leave. They exist for one round of the rules, are seen by handle_event, and are swept away. The fifth post explains the sweep, and why it separates the rule that emits from the process that decides who hears.

Rules. consume_token and deny_when_empty are from the fourth post: they match several facts at once, have guards, and are fired by the store rather than called by you. rid in both patterns is the join.

Processes. implements GenReactor(...), init, handle_event, start_link. A supervised OTP process whose state is the store and whose logic is the rules. The fifth post is about this noun, and how it compares with the gen_statem you would otherwise write.

The manifest. limiter is a library: RateLimiter runs under the supervisor of whichever application starts it, so the manifest names no start_module. The toolchain generates the .app.src from it. The ninth post shows what an application manifest adds, and how a manifest's types reach the code and the releases.

Fluents and signals. Not in this module, because they are not in the compiler yet. The eighth post is the design. When they land, the limiter grows a signal load := ... for how full each bucket is and a fluent for "bucket exhausted since t", and it stays one module.

Running it ​

From Erlang, because that is where most readers will call it from first:

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

Compiled modules are named Bondy.<Module>. A value of a structural type is a tagged tuple at runtime, with one element per field in declaration order. So Cfg and the reply read the way they do, and you can pattern match on them from Erlang without a header file. Building the Config on the Erlang side skips its refinement check, so build it in Bondy when the value is untrusted.

Getting it ​

The toolchain is a compiler, a REPL and a project scaffold, with rebar3 and Mix integration, on Erlang/OTP 29 or newer. It opens on 21 October, when I show Bondy at Code BEAM Europe. Until then, join us on Zulip: #announcements is where it lands first, and the design discussion is already there.

Where the series leaves off ​

Ten posts. The claim was that a mainstream language gives a value one role and makes you tangle every other role into your code by hand. The fix it proposed was to let one kind of value play several roles, each with its own verbs, under one type system. The program above is the claim with a compiler behind it: shapes describe the values, and each role takes one line to declare. Rules and processes sit beside the functions, and the compiler knows what every function may do.

One role and one noun are still design: fluents and signals. The distribution story is OTP's, with Partisan next. The compiler checks that rule guards cannot flip from true to false, but not yet that a rule set terminates. I have tried to say each of those where it applied rather than once at the end. That was deliberate: a language that promises more than it compiles is the thing I wrote this series against.

Code BEAM is in a few days. Come and see it run.

← Previous

The manifest is a module

Next

The next dispatch is on its way.