jonnifyfsharp

Module C4.09

Branded CHAMP maps & MailboxProcessor

A registry keyed by branded ids, owned by one writer. The chapter's pieces fold into a working store.

This module is the chapter's centerpiece. The persistent map of C4.05–C4.08 becomes the registry behind a queue: a map keyed by branded JOB ids, holding one row per job. One construct guards it — an F# MailboxProcessor, the language's actor and the analog of Elixir's GenServer. Every mint and every claim is a message into one mailbox, so the transitions are serialized through a single writer: two callers cannot race a claim or mint a colliding id. The argument is the one the Elixir bus makes with its single Portal.Engine writer — here the mailbox supplies it. Three dives: the branded CHAMP that is the registry, the MailboxProcessor that owns it, and why one writer makes the queue safe.

The registry behind an actor

The earlier modules built the parts. C4.05–C4.06 made a persistent map that shares structure on every write, so a snapshot is cheap and an old root is never mutated. C4.07 made the key: a branded Snowflake whose three-letter namespace tags the kind and whose integer is time-ordered. C4.08 made the keyspace and the swappable store port. This module folds them together into the working store of a queue — and then answers the question the earlier modules left open: who is allowed to write to it.

The answer is a single writer. In F# that is a MailboxProcessor: an agent with a mailbox and a recursive async receive loop that owns the state and threads a new version on every message. Because there is one mailbox, the broker handles one message at a time. A mint and a claim that arrive together do not interleave — they queue, and the loop applies them in turn. That is what keeps the registry consistent without a lock.

The three dives · select one

C4.09.1 the branded CHAMP C4.09.2 the MailboxProcessor C4.09.3 the single writer the store the actor the payoff
C4.09.1 · The branded CHAMP

The registry is a persistent map keyed by branded JOB ids — one row per job, structurally shared on every write. In the echo_fs port it is EchoMQ.Store.Rows, a Map today; the production branded CHAMP rides the same surface later.

You learn the persistent, branded-keyed registry the broker owns

Open this dive · or pick another above

A persistent map for the data, a mailbox for the writes. The store stays immutable so reads are free; the broker stays single so writes cannot collide.

One registry, keyed by branded id

Enqueue a job. Each carries a branded JOB id; the store writes one row under that key and adds the id to the pending set. A second enqueue of the same id is refused — the registry is a map, so a key already present is a duplicate. The readout shows the verdict and the row count after each post. The store is the in-memory EchoMQ.Store of the F# bus; the broker that owns it is the next two dives.

Map<string, Job> · enqueue a branded id

enqueue JOB…aZxKq9P EchoMQ.Store · Rows: Map<string, Job> 0 rows Map.empty — no rows yet

      
Enqueue a job to write its row. The store is a Map<string, Job> keyed by the branded id; a key already present is refused as a duplicate.

The key is the address. A branded id maps to exactly one row, so admission is a key check and a lookup is constant work — no second index, and a duplicate is caught for free.

The store and its writer, both ways

The Elixir course (E4.09) owns the same store from a GenServer: a process with one mailbox that serializes writes and publishes each new immutable root. F# meets that shape with the MailboxProcessor — a message type, a recursive async loop, and a reply channel for the calls that return a value. The structure is the same; the difference is that the F# agent is one object on the shared CLR, not a supervised BEAM process.

Elixir — a GenServer owns the store, serializes writes

# Elixir — one mailbox, serialized writes (E4.09)
def put(id, value), do: GenServer.call(__MODULE__, {:put, id, value})

def handle_call({:put, id, v}, _from, rows) do
  rows = Map.put_new(rows, id, v)   # one writer; no race
  {:reply, :ok, rows}
end

F# — a MailboxProcessor owns the store (verified in FSI)

type private Message =
    | Enqueue of id: string * payload: string * AsyncReplyChannel<Admission>
    | PendingSize of AsyncReplyChannel<int>

let agent = MailboxProcessor.Start(fun inbox ->
    let rec loop (rows: Map<string, Job>) = async {
        let! msg = inbox.Receive()
        match msg with
        | Enqueue (id, payload, reply) ->
            if rows.ContainsKey id then
                reply.Reply Duplicate; return! loop rows
            else
                reply.Reply Enqueued
                return! loop (rows.Add(id, Job.create id payload))
        | PendingSize reply ->
            reply.Reply rows.Count; return! loop rows }
    loop Map.empty)
// agent.PostAndReply(fun r -> Enqueue(id, payload, r))  ->  Enqueued | Duplicate

Elixir / BEAM

A GenServer serializes writes through one mailbox and publishes an immutable root; reads need no lock.

F# / CLR

A MailboxProcessor is the same shape: one mailbox, a recursive loop, a new map per message. The actor is one object on the shared runtime.

EchoMQ in F#

Status — echo_fs is an in-memory model. The EchoMQ protocol — the Lua transitions executing on a Valkey wire — is not implemented (there is no Lua and no Valkey client in the code); that is the coming-soon transport (EchoMQ.Connector). This block shows how the model uses the structure, not a running bus.

The bus this chapter cites is echo_fs, the F# port of echo_mq. Its single writer is EchoMQ.Broker — a real MailboxProcessor over the branded-keyed store. This module is where the chapter's running example becomes that broker.

runs today echo_fs/EchoMQ/Broker.fs · EchoMQ/Store.fs

EchoMQ.Broker is built and tested — a MailboxProcessor whose private Message type carries Enqueue, Claim, Complete, and PendingSize, each with an AsyncReplyChannel. Its receive loop owns one EchoMQ.Store.QueueState, whose Rows field is a Map<string, Job> keyed by branded id. The module's own comment states the contract: "the single writer … two callers cannot race a claim or mint a colliding id … the Elixir bus gets this from the single Portal.Engine writer; the F# port gets it from the mailbox."

coming soon The store is a Map today, not yet the compressed branded CHAMP, and the Valkey transport EchoMQ.Connector still raises — the wire is the part that lands next. The branded CHAMP and the connector slot in behind the same Broker and Store surfaces, so the actor above does not change when they arrive.

The three dives

Read them in order. The first names the registry — a persistent map keyed by branded ids. The second builds the actor that owns it — the real EchoMQ.Broker, an F# MailboxProcessor. The third is the payoff: why one writer makes the queue safe, with the parallel-posts test that proves it. Every // val it : echo below was produced by dotnet fsi; every cited echo_fs surface is in the tree.

live module hub built dive ready soon specified

References

Sources

Related in this course