sinal/forwarder
A bounded forwarder: a process that runs the handlers of the events handed to it, so a slow or blocked handler stalls the forwarder instead of the emitter.
Isolating a library’s events
A library emits with sinal.emit. The application decides, per
event-name prefix, whether those events run synchronously (the default)
or in a forwarder. It supervises one forwarder and routes the library’s
prefix to it, once, at start:
import gleam/erlang/process
import gleam/otp/static_supervisor as supervisor
import sinal/forwarder
pub fn start(name: process.Name(forwarder.Message)) {
let observations = forwarder.new(name)
let assert Ok(_) =
supervisor.new(supervisor.OneForOne)
|> supervisor.add(forwarder.supervised(observations))
|> supervisor.start
forwarder.route(["http_gun"], observations)
}
Every sinal.emit of an event whose name starts with http_gun then
returns as soon as the event is handed off. A package that owns its
forwarder (one per database, say) calls forwarder.emit instead, which
returns the Refusal when the event is dropped.
Operational limits
- Best-effort delivery. A send beyond capacity (1,024 events by
default, queued plus executing) is refused with
CapacityExceededand counted asrejected. A send to a forwarder that is not running (not yet started, or mid-restart) is refused withForwarderUnavailableand counted asunavailable. Neither blocks or retries, and a routed event never falls back to inline delivery. - Per-producer FIFO, no global order. A single producer’s forwarded events run in the order it sent them. Interleaving across producers is unspecified, as it already is for native telemetry handler order.
- The forwarder is the handler’s
self(). Handlers run inside the forwarder process. Process-dictionary context on the producer is not carried across the hop, and a native span cannot be forwarded (its start and stop must share one process to measure duration); spans and this module’s own drop report ignore routes. - Shutdown does not drain. Pending events are lost when the process
stops. A restart takes a best-effort snapshot of the in-flight count
and reports it as
lost. This count is not a delivery receipt. - Capacity belongs to one incarnation. Each running process owns a
one-row named ETS table pairing its event subject with its capacity
and fresh admission counters, published after initialisation. Before
that, producers receive
ForwarderUnavailable. A delayed producer keeps the old subject and counters; it cannot enqueue into the replacement. The table’s name is the forwarder’s process name in the separate ETS namespace; a pre-existing ETS table of that name, or a capacity below 1, makes the start fail withInitFailed. - One set of counters per name. The drop counters live in
persistent_termunder the forwarder’s process name, so everyForwardervalue built from oneprocess.Nameshares them. Create the name once at application start, never per request: each name keeps its counters for the life of the node. - A handler’s exit, not just its raise, can take the forwarder down.
Native telemetry isolates a handler that raises. A handler that links
to another process and receives an exit signal, or is sent
kill, can still stop the forwarder. - Concurrent emitters may see a spurious, safe rejection near the boundary. Admission is a single atomic increment-then-check: it never admits past capacity, but under concurrent load it can reject a send that would have fit under a different ordering.
- Routed ordering holds per route. A producer’s events that resolve to the same forwarder keep its send order, and so do its unrouted ones. Events split across routes, or across a route change, have no relative order.
- Routes are node-global, set-up-time values. They live in
persistent_term: an emit reads them without locking, and a node with no routes pays one lookup persinal.emit. Changing a route is expensive and belongs at application start and shutdown.
When drops are reported
The three counts survive every incarnation. Only a running incarnation
reports them, as dropped_event from its own process, and each report
resets exactly the counts it carries:
- When an incarnation starts, it drains all three counts and reports them, if any is nonzero. Unavailable drops made while it was down arrive here.
- After a drop while it runs, the next drain reports
rejectedandunavailabletogether (lostis zero outside a start). Concurrent drops in one drain fold into one report. - If no incarnation starts again, the counts are never reported.
The
ForwarderUnavailablerefusal is the only signal meanwhile. - A report can be lost with its incarnation, if it is stopped before handling the report it queued at start.
- Reports never loop. A drop report is emitted directly, never through a route, so reporting a drop never causes one.
Types
Measurements of dropped_event, one best-effort diagnostic report.
Read it by label; sinal may add fields.
rejected: sends refused withCapacityExceeded.lost: admitted events still in flight when the previous incarnation stopped (zero outside the report an incarnation makes when it starts).unavailable: sends refused withForwarderUnavailable.
pub type Dropped {
Dropped(rejected: Int, lost: Int, unavailable: Int)
}
Constructors
-
Dropped(rejected: Int, lost: Int, unavailable: Int)
Metadata of dropped_event: the forwarder’s process name.
pub type DroppedMetadata {
DroppedMetadata(forwarder: String)
}
Constructors
-
DroppedMetadata(forwarder: String)
A forwarder’s process name, capacity and shared drop counters. Build it
with new, run it with supervised.
pub opaque type Forwarder
The forwarder process’s message type, needed only to create its
process.Name.
pub opaque type Message
Values
pub const default_capacity: Int
The capacity of a forwarder built with new: queued plus executing
events.
pub fn describe_refusal(refusal: Refusal) -> String
Describes a refusal for logs.
pub fn dropped_event() -> sinal.Event(Dropped, DroppedMetadata)
The [sinal, forwarder, dropped] event a forwarder emits from its own
process when a drain finds a nonzero count. Its measurements are the
integer keys rejected, lost and unavailable; its metadata is the
forwarder’s name under forwarder. It is synchronous and never routed.
pub fn emit(
forwarder: Forwarder,
event: sinal.Event(m, d),
measurements: m,
metadata: d,
) -> Result(Nil, Refusal)
Hands an event to this forwarder, ignoring routes, and returns as soon
as it is handed off or refused; it never waits for a handler. A refused
event is dropped and counted in the forwarder’s next dropped_event.
Use it in a package that owns its forwarder; a library that does not
calls sinal.emit and lets the application route it.
pub fn new(name: process.Name(Message)) -> Forwarder
Describes a forwarder with the default capacity of 1,024 events. Starts
no process; pair it with supervised. Every Forwarder built from one
name shares its drop counters.
pub fn route(prefix: List(String), forwarder: Forwarder) -> Nil
Sends every sinal.emit event whose name starts with prefix through
forwarder, for the whole node. The empty prefix matches every event;
when several prefixes match, the longest wins. Routing a prefix again
replaces its forwarder.
This is application setup, like attaching a handler: call it once the
forwarder is supervised. Changing a route is expensive (routes live in
persistent_term), so do not route per request.
Panics when a segment of prefix breaks the name grammar.
pub fn supervised(
forwarder: Forwarder,
) -> supervision.ChildSpecification(Forwarder)
Describes the forwarder’s process for an OTP supervisor. Drop counts survive a restart; admission counters and the event subject do not.
pub fn unroute(prefix: List(String)) -> Nil
Removes the route of exactly prefix, if there is one. Events it
matched fall back to a shorter routed prefix, or to synchronous
delivery. Events already handed to its forwarder are still delivered
there, so they can run after later, now synchronous, events.