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; the next owner 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. Code loaded by a hot push can change a store's
state shape;
state/1notices the version change and re-runs setup, so the first write aftermix mob.pushgets state it can read. - 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.
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 its version is stale.
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.
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 its version is stale.