Mob.Diag.Store behaviour (mob v0.9.7)

Copy Markdown View Source

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/1 returns only after the store's tables exist. The hot path is a single :ets.whereis/1 on the store's flag table, which is created last; only a miss pays for a call to the owner, and that call queues behind init/1.
  • Evidence outlives its owner. Tables are created with Mob.Diag.Heir as 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/1 re-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/1 compares the stored entry with both the store's state_vsn/0 and this module's own version, and re-runs setup when either differs. So the first write after mix mob.push gets state it can read, and the first write after a mob upgrade restarts an owner that older code left down (an older heir did not restart owners). Every write path therefore reads state/1, even one with no state to use.
  • Writes never raise and never lose silently. guard/3 catches any failure on a write path, counts it (lost), repairs missing tables, and returns a fallback. health/1 reports lost, resets (tables recreated after being lost) and owner_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.

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.

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

after_setup()

(optional)
@callback after_setup() :: :ok

Runs in the owner after every setup, once all tables exist.

health(state)

(optional)
@callback health(state :: map()) :: map()

Store-specific, value-free fields for health/1.

new_state(previous)

@callback new_state(previous :: map() | nil) :: map()

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).

state_vsn()

@callback state_vsn() :: pos_integer()

Version of the map new_state/1 returns. Bump it when that shape changes.

tables()

@callback tables() :: [{atom(), list()}]

The store's tables, in creation order. The last is the flag ensure/1 checks.

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

ensure(store)

@spec ensure(module()) :: :ok

Return once store's tables exist.

guard(store, fallback, fun)

@spec guard(module(), term(), (-> term())) :: term()

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.

health(store)

@spec health(module()) :: map()

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.

note_lost(store, kind, reason)

@spec note_lost(module(), atom(), term()) :: :ok

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.

reload(store)

@spec reload(module()) :: :ok

Re-run setup: create anything missing and re-read configuration, keeping counters.

state(store)

@spec state(module()) :: map()

The store's current state, re-running setup first if it was set up by another version of the store or of this module.