Help us shape the future of Go! Take the 2026 Go Developer Survey

runloop

package
v0.21.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 29, 2026 License: AGPL-3.0 Imports: 3 Imported by: 0

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

func (l *Loop) Ping(ctx context.Context, d time.Duration) bool

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

func (l *Loop) SetMetrics(m Metrics)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL