The owner and lifecycle every diagnostic store shares.
Mob.Agent.Receipts, Mob.Defect.Bus, Mob.Invariant,
Mob.PostMortem.Registry and Mob.RenderStats keep what they record in
public named ETS tables that callers write directly, with no mailbox on the
hot path. A table dies with the process that created it, so each needs an
owner that outlives its writers. They used to carry five hand-written copies
of that owner, and all of them had the same hole: GenServer.start/3
registers the name before init/1 runs, so a second concurrent first caller
got {:error, {:already_started, pid}} and wrote to a table that did not
exist yet. Measured: up to 1,400 of 1,600 concurrent first calls saw no table.
This module is that owner, once, with the properties a diagnostic needs:
- Ready means ready.
ensure/1returns only after the store's tables exist. The hot path is a single:ets.whereis/1on the store's flag table, which is created last; only a miss pays for a call to the owner, and that call queues behindinit/1. - Evidence outlives its owner. Tables are created with
Mob.Diag.Heiras their ETS heir. If an owner dies, its tables and rows pass to the heir and stay writable, and the heir starts the next owner, which takes them back. The owner re-points the heir if the heir itself restarts. - Setup is idempotent. It creates only what is missing, and a store's
state (counters, sequence numbers) is carried into the new state, so
reload/1re-reads configuration without resetting counters. - State is versioned, and so is this module. Code loaded by a hot push
can change a store's state shape, or the machinery here.
state/1compares the stored entry with both the store'sstate_vsn/0and this module's own version, and re-runs setup when either differs. So the first write aftermix mob.pushgets state it can read, and the first write after amobupgrade restarts an owner that older code left down (an older heir did not restart owners). Every write path therefore readsstate/1, even one with no state to use. - Writes never raise and never lose silently.
guard/3catches any failure on a write path, counts it (lost), repairs missing tables, and returns a fallback.health/1reportslost,resets(tables recreated after being lost) andowner_starts.
A store module implements the callbacks below. The owner is registered as
Mob.Diag.Owner.<Store>, started on first use, and unlinked: mob has no
supervision tree of its own, and a diagnostic must neither die with a writer
nor take one down. The name is deliberately not the <Store>.Owner the
hand-written owners used: an app that hot-pushes this over an older mob
still has those processes running, and calling into one would crash. Here
their tables are simply held by :other and written to until the app
restarts.
Summary
Callbacks
Runs in the owner after every setup, once all tables exist.
Store-specific, value-free fields for health/1.
The store's state, given what was there before (nil the first time).
Version of the map new_state/1 returns. Bump it when that shape changes.
Functions
Returns a specification to start this module under a supervisor.
Return once store's tables exist.
Run a write path. Any failure is followed by a repair of missing tables,
counted as lost and logged the first time; fallback is returned instead.
Value-free health of store. Read-only: never starts an owner or creates a
table, so it reports a broken store instead of repairing it. Never raises:
state left in an older shape by a hot push, which the store's health/1
cannot read, is reported as store: :stale until the next write updates it.
A framework_vsn that differs from the expected one, after a mob upgrade,
leaves the store's state readable; the next write sets the store up again.
Count one write lost by store, outside guard/3. Missing tables or a
missing state are set up first, so a loss before the store's first setup,
or after a hot push onto tables an older mob still holds, is counted too.
Re-run setup: create anything missing and re-read configuration, keeping counters.
The store's current state, re-running setup first if it was set up by another version of the store or of this module.
Callbacks
@callback after_setup() :: :ok
Runs in the owner after every setup, once all tables exist.
Store-specific, value-free fields for health/1.
The store's state, given what was there before (nil the first time).
Must carry counters over from previous rather than replacing them, and must
tolerate a previous of an older shape (after a hot push).
@callback state_vsn() :: pos_integer()
Version of the map new_state/1 returns. Bump it when that shape changes.
The store's tables, in creation order. The last is the flag ensure/1 checks.
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec ensure(module()) :: :ok
Return once store's tables exist.
Run a write path. Any failure is followed by a repair of missing tables,
counted as lost and logged the first time; fallback is returned instead.
Value-free health of store. Read-only: never starts an owner or creates a
table, so it reports a broken store instead of repairing it. Never raises:
state left in an older shape by a hot push, which the store's health/1
cannot read, is reported as store: :stale until the next write updates it.
A framework_vsn that differs from the expected one, after a mob upgrade,
leaves the store's state readable; the next write sets the store up again.
Count one write lost by store, outside guard/3. Missing tables or a
missing state are set up first, so a loss before the store's first setup,
or after a hot push onto tables an older mob still holds, is counted too.
@spec reload(module()) :: :ok
Re-run setup: create anything missing and re-read configuration, keeping counters.
The store's current state, re-running setup first if it was set up by another version of the store or of this module.