runloop

package
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 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