Documentation
¶
Overview ¶
Package runloop carries Atlas's single-writer boundary for design-time and API state.
HTTP handlers are concurrent; the processor, the state store and the worker registries have exactly one owner (invariant 3, ADR-0002/0006). A Loop is that owner: one goroutine draining a queue of closures, and Loop.Do is the only way to put work on it. A caller holding a *Loop can reach shared state correctly and has no other way to reach it at all.
The type exists so that guarantee is a thing rather than a habit. It was previously a private method on the api server object, which worked only because that object was the single receiver every handler hung off; as the API splits into per-area services (ADR-0147) each one is given a *Loop, so the boundary travels with the dependency instead of being inherited from a shared receiver.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Loop ¶
type Loop struct {
// contains filtered or unexported fields
}
Loop is the single-writer goroutine. Build one with New, run it with Loop.Run, and dispatch onto it with Loop.Do.
func New ¶
func New(quit <-chan struct{}) *Loop
New builds a loop that runs until quit is closed. It does not start the goroutine; the caller runs Loop.Run, usually under its own WaitGroup so shutdown can wait for it.
func (*Loop) Do ¶
func (l *Loop) Do(fn func())
Do runs fn on the loop goroutine and blocks until it completes, so a caller can read results out of fn by assignment.
If the loop is closing, fn does not run and Do returns immediately: callers must treat their result variables' zero values as "not produced" rather than as an answer. The quit arm is also what keeps a caller from blocking forever when it dispatches at the moment the loop stops draining.
func (*Loop) Ping ¶
Ping reports whether the loop is reachable: it hands over an empty closure and waits for it to run, giving up after d. The closure is empty on purpose — the check is that the loop is *reachable*, and anything it did would have to be safe to abandon, since a timed-out closure still runs later with nobody listening.
Handing the closure over is what actually blocks — the queue is unbuffered, so a successful send means the loop has already taken it — and the deadline covers a stopped loop as well as a wedged one, which is why neither select watches quit: a caller that cares about shutdown has already reported it, and a shutdown that races this resolves at the deadline like any other unresponsive writer.
It is deliberately not [Do] with a timeout: Do promises the closure ran, and a readiness probe must be able to give up on one that did not. It is also not reported as a turn (see Metrics): the closure is empty, so counting it would fill the histogram with work nobody performed.
func (*Loop) Run ¶
func (l *Loop) Run()
Run drains the queue on the calling goroutine until quit is closed. Everything dispatched with Loop.Do executes here, one closure at a time, which is what makes the state these closures touch single-writer.
func (*Loop) SetMetrics ¶ added in v0.7.0
SetMetrics attaches turn instrumentation, or detaches it with nil.
It is safe to call while the loop is running, which is not a luxury: the server starts its loop before it builds its metrics registry, and dispatches onto the loop in between — a supervised worker's environment is read from a store the loop owns. Storing the value atomically is what keeps that from being a data race, and it costs one atomic load per turn against a channel rendezvous that costs far more.
type Metrics ¶ added in v0.7.0
type Metrics interface {
// TurnTaken reports one closure dispatched with [Loop.Do]: waited is how long the
// caller spent before the loop picked it up, held is how long the loop then spent
// running it. It is reported after the closure returns, including when the closure
// panicked — a turn that ended badly still held the writer, and is the one most
// worth seeing.
TurnTaken(waited, held time.Duration)
}
Metrics observes the loop's turns.
TurnTaken is called on the loop goroutine, so an implementation must not block, must not dispatch back onto the loop, and must be cheap — it is inside the very duration it is reporting.
A loop with no Metrics — the default — reports nothing and does not even read the clock, so an uninstrumented loop pays literally nothing.