jonnifyfsharp

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.

C4.12.1 · part 1

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 JOB id; the registry key, time-ordered so it dates and orders itself.
State
Pending on admission, Active once 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.
C4.12.1 · part 2

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
C4.12.1 · part 3

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>
Run in 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

Rows : Map<string, Job> 0 rows empty registry Pending : Set<string> 0 ids no pending ids
an empty queue state. Rows = 0 · Pending = 0. Enqueue a job to write the first row.

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

Related in this course