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
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.
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
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.
The branded CHAMP
The registry as a persistent map keyed by branded JOB ids — Store.Rows, a Map today, the branded CHAMP behind the same surface later.
- the storeone row per branded id; structural sharing on every write
The MailboxProcessor
EchoMQ.Broker is a real MailboxProcessor — F#'s actor, the GenServer analog: a message DU, a receive loop, and PostAndReply.
- the actorbuilt and tested in echo_fs; the message type and the loop
The single writer
Why one writer makes the queue safe: mints and claims serialize through the mailbox, so two callers cannot race a claim or mint a colliding id.
- the payoffthe parallel-posts test — no lost or colliding ids
References
Sources
- Microsoft — F# documentation — async workflows and the
MailboxProcessoragent. - FSharp.Core API reference —
MailboxProcessor,AsyncReplyChannel, and theMapmodule. - Microsoft — .NET documentation — the CLR threading model the agent runs on.
- The F# Software Foundation — F#'s home on the .NET runtime.
Related in this course
- Elixir — F4.09 · Branded CHAMP maps & GenServer — the parity module: the same store, owned by a
GenServer. - C4 — Algorithms & Data Structures — the chapter this module closes.
- C3 — F# for Elixir developers — the runtime contrast: BEAM processes vs the CLR agent.
- F# In Depth — the course home.