Dive C4.10.3
Windowing — in space and in time
Group a sequence by position with one function. Group it by time, and you have paced the work.
Windowing cuts a sequence into pieces: fixed batches with List.chunkBySize,
overlapping slices with Seq.windowed. The same shape is how a queue spreads work across
time — when a job fails, the bus does not retry at once; it waits a growing window, and the curve is a pure
function. This dive does the collection windows first, then the bus's backoff curve and the pause that holds a
backlog. Each result below was produced by dotnet fsi (9.0.303).
Batches and sliding windows
List.chunkBySize cuts a list into fixed-size pieces; the last is short if the length
does not divide evenly. Seq.windowed slides a fixed-width window one element at a time,
yielding each window as an array. A moving average is a window plus Array.average —
the everyday use over a rate signal.
// fixed-size batches [1..10] |> List.chunkBySize 3 // val it: int list list = [[1; 2; 3]; [4; 5; 6]; [7; 8; 9]; [10]] // a sliding window, one element at a time [1..6] |> Seq.windowed 3 |> Seq.toList // val it: int array list = [[|1; 2; 3|]; [|2; 3; 4|]; [|3; 4; 5|]; [|4; 5; 6|]] // a moving average over a rate signal [10.0; 12.0; 8.0; 20.0; 16.0] |> Seq.windowed 3 |> Seq.map Array.average |> Seq.toList // val it: float list = [10.0; 13.33333333; 14.66666667]
Efficiency. Seq.windowed is lazy — it produces one window
at a time and never holds the whole input, so it streams over a large source. List.chunkBySize
is eager, allocating the list of chunks up front, which is the right trade when you mean to process every batch
anyway.
Slide a window, walk the curve
The top control slides a window over a fixed rate signal and reports the moving average — the windows
recompute live. The bottom control walks the bus's backoff curve attempt by attempt:
base · 2^(n-1), clamped at the cap. Both readouts show the exact values
dotnet fsi produces.
Seq.windowed over a rate signal
let avgs = signal |> Seq.windowed 3 |> Seq.map Array.average |> Seq.toList
Backoff.Exponential · base 100, cap 2000
let curve = [1..6] |> List.map (delayMs 100 2000) // val it: int list = [100; 200; 400; 800; 1600; 2000]
A window over a collection and a curve over time are the same instinct: take the whole only when you must, and let the shape pace the rest. The bus paces failures so a struggling dependency is not hammered.
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.
A queue does its windowing in time. When a job fails, retrying at once would hammer whatever it depends on;
the bus spreads the retry over a growing window. The curve is echo_fs/EchoMQ/Backoff.fs,
a pure function from a policy and an attempt count to a literal delay in milliseconds:
// echo_fs/EchoMQ/Backoff.fs — verbatim | Exponential (baseMs, capMs) -> let shift = attempts - 1 if shift >= 62 then capMs else int (min (int64 baseMs * (1L <<< shift)) (int64 capMs))
base · 2^(attempts-1), clamped to a ceiling. The wire takes the literal the curve
computes and never recomputes it, so the cadence is a value the caller can table and test without a server. When
many jobs fail together, Backoff.Jitter draws a uniform delay in
0..bound so the retries fan out across the window instead of landing in one spike — the
module names it the full-jitter spread that breaks up a retry storm. The only non-deterministic policy is that
one; Fixed and Exponential are exact.
Backpressure of a coarser kind is Admin.pause, which holds the whole queue without
losing a job:
// echo_fs/EchoMQ/Admin.fs — verbatim let pause (st: QueueState) : QueueState = { st with Paused = true } let resume (st: QueueState) : QueueState = { st with Paused = false }
A paused queue answers a claim with nothing while the pending set stays intact — work resumes exactly where it stopped. The backoff curve spreads retries over a window of time; the pause holds the whole window. Both are ways the bus paces work rather than dropping it.
The backoff curve and the pause are built and tested today. The Valkey-backed
EchoMQ.Connector lands next and takes the literal delay the curve computes — the wire
carries the value, it does not recompute the curve.
The F# idiom
List.chunkBySize and Seq.windowed window a collection; a recurrence windows time.
The bus's use
Backoff spreads retries over a growing window; Admin.pause holds a queue without losing its backlog.
The recipes, together
Three recipes close the module, and they are one bus. Deduplication governs whether a job is admitted; priority
governs whose job is served; windowing governs when the work happens. Each is a few lines of idiomatic F#, and
each is a real echo_fs surface — Store.enqueue,
Lanes.next, Backoff and Admin.pause.
The structures of C4 were the means; these recipes were the end.
References
Sources
- FSharp.Core API reference —
List.chunkBySize,Seq.windowed, andArray.average. - Microsoft — F# documentation — sequences, laziness, and discriminated-union policies such as
Backoff.Policy. - The F# Software Foundation — F# on the .NET runtime.
- Microsoft — .NET documentation —
System.Randombehind the jitter policy.
Related in this course
- Elixir — Practical recipes (E4.10) — the parity module this one reflects.
- C4.09 — Branded CHAMP maps & MailboxProcessor — the actor that drives the retry pump.
- C1.07 — Collections — lists and sequences, the structures the windows run over.
- C4.10 — Practical recipes — the module hub and the other two recipes.