A live registry of the machines currently running: one row each, updated as they move.
It exists so that a running system can be watched from outside — a terminal table, a web view, a control command — without polling the machines themselves or instrumenting them by hand. A row is created the first time a machine reports, and recycled when its slot is cleared.
The registry is optional and inert until started. Every reporting helper checks whether it is running and returns immediately if it is not, so a production run that does not want it pays nothing.
A row
| Column | Holds |
|---|---|
scenario | the machine's module name |
state | the state it is in now |
event | what caused the last transition |
event_type | that event's category, from FSL.Host.event_type/1 |
command | the last command the machine issued |
command_type | that command's category |
account | who the run is about, from FSL.Host.account/2 |
slot | the key the row is filed under |
depth | 0 for a machine, 1 for a sub-FSM below its parent |
Rows are keyed by slot id: an integer when a caller manages numbered slots,
{parent_slot, name} for a machine started with spawn_fsm, and the machine's
pid otherwise. Clearing a parent's slot clears its children's rows too.
Columns an application adds
The columns above are the machine's. An application that wants more declares them, with their defaults, when it starts the registry:
FSL.Monitor.start(columns: [medias: "n/a", server: "none"])and writes them with note/2:
FSL.Monitor.note(:medias, "AV")The registry never learns what those keys mean. Defaults are worth choosing:
"n/a" states that this run negotiated nothing, where an empty string reads as
"not measured yet".
A row is flat — row.medias, not row.extra.medias. Consumers declare the
columns they display by plain key, and a nested map would make every one of
them aware of which half a column came from.
Writing to it
FSL.Runner reports every transition. The verbs an embedding supplies report
their commands with note_command/2, which is what fills the command column;
FSL.Host.account/2 supplies the account column on every report.
Reading it
calls/0 returns every row, ordered so that a sub-FSM follows its parent.
For a live view, subscribe/1 is better than polling: it returns the current
snapshot and registers the caller for {:fsl_monitor, {:updated, slot, row}} on every change and {:fsl_monitor, {:cleared, slot}} when a slot is
recycled. Subscribers are monitored, so one that dies is dropped without an
unsubscribe/1. See subscribe/1.
Example: what this looks like in a SIP application
Elixip is one embedding of FSL, where a
machine is a SIP scenario and a run is a call. Its host declares three columns
of its own — the media the call negotiated, the media server it uses, and the
destination it dialled — and its session verbs report commands such as
send_INVITE. A kelixip server joins these rows with its own, and serves the
result over a control API so an operator sees, live, which call is in which
state and what moved it there.
Summary
Types
One row: the machine's own columns, plus whatever the embedding declared.
The category of a command or an event.
Functions
Every row, ordered so that a sub-FSM follows its parent.
Returns a specification to start this module under a supervisor.
Remove a row so its slot is reused by the next machine that reports under it. Also removes the rows of any sub-FSMs filed beneath it.
Write one of the embedding's own columns on the current scenario's row.
Set the account column of the current machine's row.
Record the last command the current machine issued, with its category.
Record where a machine is now. Called by FSL.Runner on every transition; an
embedding does not normally call it.
Start the registry unlinked, for a caller that decides at run time that it
wants one — a CLI given a --monitor flag, for instance. Idempotent: an
already-running registry is reused, and its columns are the ones declared by
whoever started it first.
Start the registry under a supervisor. Takes the same options as start/1.
Subscribe pid to call changes and return the snapshot — the same rows
calls/0 would give, taken inside the call that registers the subscriber.
Stop a subscription. Rarely needed — a subscriber that dies is dropped on its own — and there for a process that stops caring without stopping.
Types
One row: the machine's own columns, plus whatever the embedding declared.
@type command_type() :: :sip | :media | :http | :db | :scenario | :control | atom() | nil
The category of a command or an event.
FSL sets :scenario and :control for its own vocabulary; every other value
comes from FSL.Host.event_type/1 or from whatever an embedding passes to
note_command/2. The atoms below are the ones a SIP application uses, listed
as an illustration rather than as a closed set: any atom is valid, and
FSL.Diagram draws an unfamiliar one as coming from the peer.
Functions
@spec calls() :: [call_info()]
Every row, ordered so that a sub-FSM follows its parent.
Each row carries its :slot, the key it was filed under, so that a caller that
assigns those slots can join this view with records of its own — a server that
keys its instances by id, for instance. A renderer that displays named columns
ignores it.
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear(term()) :: :ok
Remove a row so its slot is reused by the next machine that reports under it. Also removes the rows of any sub-FSMs filed beneath it.
Write one of the embedding's own columns on the current scenario's row.
The registry does not know what the key means — that is the point — so a host adds a column by declaring it at start and writing it here. No-op if the monitor is not running, so it stays free when monitoring is off.
@spec note_account(String.t()) :: :ok
Set the account column of the current machine's row.
For a run that learns who it is about only once it is under way — after an
identity is verified, or once it knows which conversation it joined. Reported
values from FSL.Host.account/2 will not overwrite it as long as that
callback answers "" afterwards.
No-op if the registry is not running.
@spec note_command(command_type(), String.t() | atom()) :: :ok
Record the last command the current machine issued, with its category.
This is what an embedding's verbs call, so that the command column shows what
the machine last did rather than only where it is:
def send_message(ctx, text) do
FSL.Monitor.note_command(:chat, "send_message")
# …
endThe category decides which lane the command is drawn on in a sequence diagram
(FSL.Diagram). It also feeds FSL.Journal, so a command is recorded whether
or not the registry is running.
Record where a machine is now. Called by FSL.Runner on every transition; an
embedding does not normally call it.
call_id is the slot the row is filed under. An empty username leaves the
account column as it was, which is how a run that named itself once is not
overwritten by every later transition.
Start the registry unlinked, for a caller that decides at run time that it
wants one — a CLI given a --monitor flag, for instance. Idempotent: an
already-running registry is reused, and its columns are the ones declared by
whoever started it first.
A supervised owner should use start_link/1 instead.
Options
:columns— the columns this application adds, as a keyword list ofname: default. See the moduledoc.
@spec start_link(keyword()) :: GenServer.on_start()
Start the registry under a supervisor. Takes the same options as start/1.
children = [
{FSL.Monitor, columns: [medias: "n/a", server: "none"]},
# …
]
Subscribe pid to call changes and return the snapshot — the same rows
calls/0 would give, taken inside the call that registers the subscriber.
Returning it is the contract and not a convenience. A subscriber needs both:
the rows that already exist, and the changes from now on. Taking them in two
calls leaves a window, and only one order of the two is even survivable —
subscribe first, then snapshot, so a change landing in between arrives as a
push and in the snapshot, a duplicate upsert that is idempotent and
harmless. Snapshot-first loses it outright, and the row then stays stale until
the call happens to change again. That reads like a tidying opportunity and it
is a data-loss bug, so the window is removed rather than documented.
pid is monitored: a subscriber that dies is dropped, with no
unsubscribe/1 needed. In a library that matters more than it did in one
application — subscribing is the normal way to use this, and a MapSet that
only ever grows means every later change send/2s into the void.
@spec unsubscribe(pid()) :: :ok
Stop a subscription. Rarely needed — a subscriber that dies is dropped on its own — and there for a process that stops caring without stopping.