Kepler.Trigger (Kepler v0.1.0)

Copy Markdown View Source

The debounce state machine behind every watch.

Every reactive system dies of alert storms, so sustained: and cooldown: are part of the declaration rather than something a consumer bolts on later. This module is where they are enforced, and it is deliberately pure: it takes the current state, whether the condition was met, and the clock, and returns what to do.

States

  • :idle — the condition is not met.
  • :pending — the condition is met but has not held for sustained: yet.
  • :firing — the condition has been met long enough. A notice may or may not have gone out, depending on cooldown:.

:firing and "a notice was sent" are deliberately separate. Inside a cooldown window the watch is still firing; it just stops saying so.

What cooldown: means

A watch is edge-triggered by default: with cooldown: 0 it notifies once when the condition becomes true and then stays quiet for as long as it holds, however many ticks that is. Setting cooldown: :timer.minutes(5) opts into repeating — a reminder at most every five minutes while the condition is still true.

The same window also gates a fresh episode, so a condition that flaps on and off cannot notify more than once per cooldown either way.

Summary

Types

What the caller should do about this transition.

Debounce settings, taken straight from the watch declaration.

The lifecycle state of a single watch.

t()

Functions

Whether the watch is currently considered to be firing.

A fresh trigger, idle and never fired.

Advances the machine one tick.

Types

action()

@type action() :: :none | :fire | :resolve

What the caller should do about this transition.

opts()

@type opts() :: [
  sustained: non_neg_integer(),
  cooldown: non_neg_integer(),
  notify_resolved: boolean()
]

Debounce settings, taken straight from the watch declaration.

  • :sustained — how long, in milliseconds, the condition must hold before the watch fires.
  • :cooldown — the minimum gap, in milliseconds, between notices. Zero means "notify once per episode and do not repeat".
  • :notify_resolved — whether clearing a firing watch emits a notice.

state()

@type state() :: :idle | :pending | :firing

The lifecycle state of a single watch.

t()

@type t() :: %Kepler.Trigger{
  last_fired_at: integer() | nil,
  since: integer() | nil,
  state: state()
}

Functions

firing?(trigger)

@spec firing?(t()) :: boolean()

Whether the watch is currently considered to be firing.

new()

@spec new() :: t()

A fresh trigger, idle and never fired.

step(trigger, met?, now, opts)

@spec step(t(), boolean(), integer(), opts()) :: {action(), t()}

Advances the machine one tick.

met? is the result of evaluating the watch's condition, now is a monotonic millisecond timestamp, and opts are the watch's debounce settings. Returns the action to take and the next state.

Callers must skip this entirely when a watch has no data for the tick — holding state is the correct response to "no opinion", and feeding in a spurious false would silently reset a sustained window.