ExTLA.Invariant (ExTLA v0.6.0)

Copy Markdown View Source

Layer 1: invariants and properties on a running OTP process, checked at every transition.

defmodule Account do
  use GenServer
  use ExTLA.Invariant

  invariant :never_negative, desc: "an account may not go below zero" do
    state.balance >= 0
  end

  action_property :no_free_money do
    new.balance <= old.balance or new.deposits > old.deposits
  end

  precondition {:withdraw, amount} do
    amount > 0
  end
end

state is bound in an invariant, old and new in an action_property, and in a precondition or postcondition the pattern's own bindings are in scope alongside them. Nothing is passed in explicitly, because a property is a statement about the process rather than a function of arguments.

defoverridable and super, not renaming

The @before_compile hook wraps each callback the module defines by marking it overridable and defining a new one that calls super, rather than renaming the original. Every real GenServer has several handle_call/3 clauses with guards; renaming would have to reproduce every clause head and its guards exactly, and any mistake silently changes dispatch. super keeps the original clauses intact and dispatches to them with their own pattern matching.

use ExTLA.Invariant goes directly after use GenServer and before the callbacks, and the order is load-bearing rather than stylistic. Only the callbacks the module's own author writes are wrapped, and they are identified with @on_definition, which sees a definition only after it is installed. That is what keeps the wrapper off the default handle_call/3, handle_cast/2 and handle_info/2 that use GenServer injects — Module.defines?/2 cannot tell those from the author's, calling super on them is deprecated, and wrapping a default that exists to stop the server with a :bad_call is checking a transition nobody wrote.

init/1 is the exception, and it needs a second guard. GenServer injects it from its own @before_compile, which runs after the hook is installed rather than before — so @on_definition does see it, and {:init, 1} lands in the wrapped set for a module whose author wrote no init/1. By then GenServer's defoverridable init: 1 has moved it out of the definitions table, and Module.make_overridable/2 refuses:

** (ArgumentError) cannot make function init/1 overridable because it was not defined

naming neither this library nor the cause — for a module as plain as the example at the top of this moduledoc. So the wrapper is emitted only for a callback still in the definitions table when __before_compile__/1 runs — which is precisely what make_overridable/2 requires, asked as a question rather than assumed.

:disabled emits nothing

When ExTLA.Config.mode/0 resolves to :disabled where the module compiles, the hook emits no wrapper and no defoverridable, and the module compiles exactly as written. That is what "zero production cost" has to mean: not cheap emitted code, but none. It is also why the mode is a compile_env macro — a runtime lookup would leave an Application.get_env/2 call and a branch on every callback, which alone would exceed the budget the whole layer is meant to fit in.

Cost is decided at compile time wherever it can be

callbacks: restricts an invariant to particular callbacks, and the restriction is resolved when the wrapper is generated. A hot callback in a module with nine invariants emits a call naming the two that apply, rather than nine names and a filter. Where a callback ends up with nothing to check, no call is emitted for it at all.

enabled: false is evaluated at compile time and emits nothing anywhere, for a check too costly to run outside a particular environment.

Summary

Functions

A property of a transition. old and new are bound; same options as invariant/3.

A state invariant, checked after every transition.

A condition checked after the matching callback returns. old, new and the pattern's bindings are in scope.

A condition checked before the matching callback runs.

Functions

action_property(name, opts \\ [], list)

(macro)

A property of a transition. old and new are bound; same options as invariant/3.

invariant(name, opts \\ [], list)

(macro)

A state invariant, checked after every transition.

state is bound in the block. Options: desc: for the violation message, enabled: (compile time — false emits nothing), and callbacks: to restrict it to some of them.

postcondition(pattern, opts \\ [], list)

(macro)

A condition checked after the matching callback returns. old, new and the pattern's bindings are in scope.

precondition(pattern, opts \\ [], list)

(macro)

A condition checked before the matching callback runs.

pattern is matched against the incoming message and its bindings are in scope in the block, alongside state. A message the pattern does not match has nothing to say about it and passes — which is a different thing from a predicate that returned false, and the two are reported differently.