jonnifyfsharp

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).

C4.10.3 · part 1

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

3
rate signal · [10; 12; 8; 20; 16] (plus a trailing 14) moving average (each window position) —
let avgs = signal |> Seq.windowed 3 |> Seq.map Array.average |> Seq.toList
window 3 over 6 values · 4 window positions, each averaged

Backoff.Exponential · base 100, cap 2000

3
cap 2000 ms
let curve = [1..6] |> List.map (delayMs 100 2000)
// val it: int list = [100; 200; 400; 800; 1600; 2000]
attempt 3 · 100 · 2^(3-1) = 400 ms · under the cap

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

Related in this course