Dive C4.12.1
The model
Before an actor or a wire, the queue is data. Two types hold everything it needs.
A queue's job is to remember work and to serve it in order. That takes two types. A
Job is a record: its branded JOB id, its lifecycle state, an attempt
count, and a payload. A QueueState holds two collections — a Rows map
from id to Job (the registry) and a Pending set of ids waiting
to be claimed. Three pure transitions thread a new QueueState:
enqueue admits a job, claim takes the oldest, and
complete retires it. Every snippet here was run in dotnet fsi.
The Job record
A job is a value, so a record holds it. The identity is a branded JOB id — a string
the chapter's C4.07 minted, time-ordered. The State tracks the lifecycle. The
Attempts count does double duty: it is the number of tries, and it is the lease's
fencing token — a claim hands back the current count, and a completion must present that same count
or be refused as stale. A small JobState union names the states, and a
create helper builds a fresh pending job.
type Admission = Enqueued | Duplicate // the verdict of an admission type JobState = Pending | Active // the lab's two states type Job = { Id: string; State: JobState; Attempts: int; Payload: string } // type Job = { Id: string; State: JobState; Attempts: int; Payload: string } module Job = let create id payload = { Id = id; State = JobState.Pending; Attempts = 0; Payload = payload } // val create: id: string -> payload: string -> Job
- Id
- the branded
JOBid; the registry key, time-ordered so it dates and orders itself. - State
Pendingon admission,Activeonce claimed — a small union, exhaustively matched.- Attempts
- the try count, which also fences the lease: a completion must carry the claim's count.
- Payload
- the work the job stands for — here a plain string.
The QueueState — a map and a set
The queue's whole state is two persistent collections. Rows is a
Map<string, Job> — the registry, one row per id, so an id already present is a
duplicate. Pending is a Set<string> of the ids waiting
to be claimed. A small Completed count rounds it out.
The choice of a Set for pending is the point. Branded ids are time-ordered, and
Base62 sorts as the integers do, so the id order is the mint order. Set.minElement
is therefore the oldest job — the queue serves mint-order with no second index. That is the bus's "byte order
is mint order" property, here for free from Set's ordering.
type QueueState = { Rows: Map<string, Job>; Pending: Set<string>; Completed: int } // type QueueState = { Rows: Map<string,Job>; Pending: Set<string>; Completed: int } let empty = { Rows = Map.empty; Pending = Set.empty; Completed = 0 } // val empty: QueueState
The three pure transitions
Each transition takes a QueueState and returns a new one with a verdict — no
mutation, so an old state is never disturbed. enqueue refuses a duplicate id, else
writes a row and adds the id to Pending. claim pops the
minimum pending id, marks the job Active, and bumps Attempts
(the token). complete retires a claimed job only if the token matches.
let enqueue id payload st = if (st: QueueState).Rows.ContainsKey id then st, Admission.Duplicate else { st with Rows = st.Rows.Add(id, Job.create id payload) Pending = st.Pending.Add id }, Admission.Enqueued // val enqueue: id: string -> payload: string -> st: QueueState -> QueueState * Admission let claim (st: QueueState) = if Set.isEmpty st.Pending then st, None else let id = Set.minElement st.Pending // the oldest, by mint order let row = { st.Rows.[id] with State = JobState.Active Attempts = st.Rows.[id].Attempts + 1 } { st with Rows = st.Rows.Add(id, row) Pending = st.Pending.Remove id }, Some row // val claim: st: QueueState -> QueueState * Job option let complete id token (st: QueueState) = match Map.tryFind id st.Rows with | Some row when row.Attempts = token -> { st with Rows = st.Rows.Remove id Completed = st.Completed + 1 }, Ok () | _ -> st, Error "gone" // val complete: id: string -> token: int -> st: QueueState -> QueueState * Result<unit,string>
dotnet fsi: three enqueues with one duplicate id leave
pending = 2; the duplicate returns Admission.Duplicate; a
claim returns Some "JOB0NbAb1xcFCy" with Attempts = 1; the
matching complete returns Ok () and a second complete returns
Error "gone".Build the queue state
Enqueue jobs into an empty QueueState over a fixed three-job dataset. Each accepted
enqueue writes one Rows row and adds the id to Pending; a
second enqueue of the same id is refused. The diagram shows the two collections side by side; the readout
names the verdict and the counts, exactly as the pure enqueue computes them.
QueueState · Rows map and Pending set
One write touches both collections: a row in the map for the data, an id in the set for the order. Because the id is time-ordered, the set already holds the queue in the order it will be served.
The same shape, beside Elixir
The Elixir bus holds the same two collections — a map of rows and an ordered set of ids — in its store. The F# record names them as fields; the Elixir version carries them in a struct or a map. The data model is the same; the difference is that F# fixes the types at compile time.
Elixir — the store's two collections
# Elixir — rows by id, plus an ordered set of pending ids %{rows: %{"JOB…" => job}, pending: MapSet.new(["JOB…"])}
F# — the QueueState record (verified in FSI)
// F# — the same two collections, typed as record fields { Rows = Map<string, Job>; Pending = Set<string>; Completed = 0 }
Elixir / the store
A map of rows and a MapSet of pending ids, carried in a struct; dynamically typed.
F# / the QueueState
The same two collections as record fields — Map<string, Job> and Set<string> — fixed at compile time.
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.
These two types are not a teaching sketch — they are the bus's real in-memory state.
runs today echo_fs/EchoMQ/Job.fs · echo_fs/EchoMQ/Store.fs
EchoMQ.Job defines the real Job record and
Job.create; EchoMQ.Store defines
QueueState with Rows: Map<string, Job> and
Pending: Set<string> (plus the schedule, active-lease, and dead sets the full bus
also tracks). The store's own comment states it plainly: "the id order IS the mint order — the 'no second
index' property of the Elixir bus, here for free from Set's ordering." The lab's two
types are this store, trimmed to its core.
coming soon The echo_fs README states the in-memory
Store "stands in for the branded CHAMP / Valkey rows behind the same
enqueue/claim/complete/… surface." The Rows map is an F#
Map today; the branded CHAMP (C4.06) is the production form behind the same surface,
and the Valkey wire — EchoMQ.Connector — is the part marked coming soon.
The store is a Map / Set, not a CHAMP. The branded
CHAMP is taught as the target the same Store surface admits later, not claimed as built.
Where this leads
The model is pure: every transition takes a state and returns a new one. To make it a queue many callers can
use, one writer must own the state and apply the transitions in turn. The next dive wraps the model in F#'s
actor — a MailboxProcessor — the single writer.
References
Sources
- FSharp.Core API reference —
MapandSet, the persistent collections the state is built on. - Microsoft — F# documentation — records and discriminated unions.
- The F# Software Foundation — F# on the .NET runtime.
Related in this course
- Elixir — F4.12 · Lab: build a branded CHAMP store — the same store, modelled in Elixir.
- C4.04 — Maps, sets & hashing — the
MapandSetunderneath. - C4.06 — CHAMP maps — the production form the registry takes.
- C4 — Algorithms & Data Structures — the chapter.