BB.Loop (bb v0.27.0)

Copy Markdown View Source

Timing and health accounting for periodic components.

Components that run a periodic loop - controllers, policy runners, hardware bus managers - each need the same three things: a tick that doesn't drift, a measured time delta to hand to whatever algorithm they're driving, and some way to tell an operator the loop isn't keeping up. BB.Loop is a struct you embed in your component's state that provides all three.

It is deliberately not a behaviour or a process. Your component stays a plain BB.Controller (or GenServer, or whatever); the loop is a value it threads through its own callbacks.

Clock sources

A loop is clocked one of two ways, chosen at new/2:

  • {:rate, hertz} - the loop schedules its own :tick messages. Use this when output must be produced on a fixed cadence regardless of input.
  • :external - something else decides when to step, typically the arrival of a message. The loop does no scheduling and only does the delta and health accounting. Use this when a loop's natural clock is its input; a control loop fed by a sensor is almost always better clocked by that sensor than by an independent timer.

Rate-clocked usage

def init(opts) do
  bb = Keyword.fetch!(opts, :bb)
  loop = BB.Loop.new(bb, clock: {:rate, ~u(100 hertz)})
  {:ok, %{bb: bb, loop: BB.Loop.arm(loop)}}
end

def handle_info(:tick, state) do
  {dt, skipped, loop} = BB.Loop.tick(state.loop)
  {:noreply, step(%{state | loop: loop}, dt, skipped)}
end

def terminate(_reason, state) do
  BB.Loop.cancel(state.loop)
  :ok
end

tick/1 re-arms the timer itself, so there is one call per tick rather than a tick/schedule pair.

Externally-clocked usage

Call observe/2 with the monotonic timestamp of whatever triggered the step - for a BB.Message, its :monotonic_time, so that the delta reflects the sensor's own sampling interval rather than when the message was dequeued:

def handle_info({:bb, _topic, %BB.Message{} = message}, state) do
  {dt, _skipped, loop} = BB.Loop.observe(state.loop, message.monotonic_time)
  {:noreply, step(%{state | loop: loop}, dt, 0)}
end

arm/1 and cancel/1 are no-ops on an externally-clocked loop, so components supporting both clocks don't need to branch on which they were given.

The first delta, and deltas that don't advance

dt is nil on a loop's first tick, because there is no previous tick to measure from. It is also nil when an observe/2 timestamp doesn't advance the clock, which is what a duplicate or reordered message looks like; the loop keeps its previous timestamp in that case so the next message still measures a correct interval.

Both cases mean "there is no valid interval here". Match on it and skip the step - handing a nil or negative dt to an integrator or a derivative term is exactly the kind of thing this module exists to prevent:

defp step(state, nil, _skipped), do: state
defp step(state, dt, _skipped), do: # ... real work

Missed deadlines

A rate-clocked loop schedules against an absolute monotonic deadline that accumulates in nanoseconds, so a slow handler doesn't push the schedule later and rounding doesn't compound.

When a handler overruns, whole missed periods are skipped, not queued. This is the important part: re-arming to a deadline that has already passed makes Process.send_after/4 fire immediately, and a loop that has fallen ten periods behind would otherwise deliver ten back-to-back ticks with a dt of effectively zero. For anything with an integral or derivative term that is far worse than missing the ticks outright.

The number of periods dropped is returned from tick/1 and reported as the :skipped telemetry measurement. It is the loop's overrun metric: a loop that is consistently skipping is configured faster than the machine can actually run it.

Achievable rates

Process.send_after/4 has millisecond resolution, so a period below 1ms cannot be represented and rates much above 1kHz will skip most of their periods. Well below that ceiling the BEAM's scheduling tail dominates: on a general-purpose kernel, tick latency has a floor of tens of milliseconds regardless of the period asked for, so a loop nominally at 500Hz may deliver half that. This is not a reason to avoid high rates, but it is a reason to watch :skipped rather than trust the configured rate.

Telemetry

Each tick with a valid dt emits [:bb, :loop, :tick]:

  • Measurements: %{dt: float_seconds, skipped: integer, deadline_error: integer_ns}
  • Metadata: %{robot: module, path: [atom], clock: {:rate, float} | :external}

:deadline_error is how late the tick was against its scheduled deadline. It and :skipped are always 0 for externally-clocked loops, which have no deadline of their own. No event is emitted when dt is nil, since there is no interval to report.

What this does not cover

BB.Loop answers "is this loop meeting its own deadline?". It knows nothing about a component's inputs, so input freshness is not its job - a component that must stop acting on a stale sensor reading owns that timeout itself, and should report it separately via BB.Diagnostic.

Summary

Types

A %{robot: module, path: [atom]} map, as injected into component options.

How a loop is clocked. Normalised to {:rate, float} in hertz, or :external.

t()

Functions

Schedule the loop's first tick.

Cancel any pending tick.

Build a loop for a component.

Record a step on an externally-clocked loop at the given monotonic timestamp.

Record a tick on a rate-clocked loop and schedule the next one.

Types

bb()

@type bb() :: %{robot: module(), path: [atom()]}

A %{robot: module, path: [atom]} map, as injected into component options.

clock()

@type clock() :: {:rate, float()} | :external

How a loop is clocked. Normalised to {:rate, float} in hertz, or :external.

t()

@type t() :: %BB.Loop{
  bb: bb(),
  clock: clock(),
  deadline_ns: integer(),
  last_ns: integer() | nil,
  period_ns: pos_integer() | nil,
  skipped: non_neg_integer(),
  tick_ref: reference() | nil,
  ticks: non_neg_integer()
}

Functions

arm(loop)

@spec arm(t()) :: t()

Schedule the loop's first tick.

Call once from init/1. Subsequent ticks are armed by tick/1. A no-op on an externally-clocked loop.

cancel(loop)

@spec cancel(t()) :: t()

Cancel any pending tick.

Call from terminate/2, and before replacing a running loop with one at a different rate. A no-op on an externally-clocked loop, or one that was never armed.

If the timer had already fired, the delivered :tick is discarded too. Leaving it queued would make the next arm/1 deliver a tick immediately with a delta of roughly zero - the exact thing this module exists to keep out of a control loop - and would leave a second timer in flight that a later cancel/1 could not clean up.

new(bb, opts)

@spec new(
  bb(),
  keyword()
) :: t()

Build a loop for a component.

bb is the %{robot: _, path: _} map injected into component options.

Options

  • :clock (required) - {:rate, hertz} or :external. A rate may be given as a Localize.Unit in any frequency unit (~u(100 hertz)) or as a plain positive number of hertz.

Building a loop does not start it; call arm/1 once the component is ready to receive ticks.

Examples

BB.Loop.new(bb, clock: {:rate, ~u(100 hertz)})
BB.Loop.new(bb, clock: {:rate, 100})
BB.Loop.new(bb, clock: :external)

observe(loop, at_ns)

@spec observe(t(), integer()) :: {float() | nil, 0, t()}

Record a step on an externally-clocked loop at the given monotonic timestamp.

at_ns should be the monotonic nanosecond timestamp of whatever triggered the step - for a BB.Message, its :monotonic_time field.

Returns {dt, 0, loop}. dt is nil for the first observation, and for any timestamp that doesn't advance past the previous one; see the module docs.

tick(loop)

@spec tick(t()) :: {float() | nil, non_neg_integer(), t()}

Record a tick on a rate-clocked loop and schedule the next one.

Returns {dt, skipped, loop} where dt is the seconds elapsed since the previous tick (nil on the first), and skipped is the number of whole periods dropped because the loop had fallen behind.

Call this at the top of your :tick handler. The next deadline is absolute, so arming before doing the tick's work is correct - the work's duration doesn't move the schedule, and an overrun is reported by the following tick.