Packages

Describe your system's states, find the invalid ones automatically, and check that your GenServers behave as described.

Current section

Files

Jump to
extla guides tutorial 04-temporal.md
Raw

guides/tutorial/04-temporal.md

# Temporal properties, and the difference fairness makes

ExTLA tutorial. Previous: [Invariants](03-invariants.md).

Nothing so far says the lock ever *does* anything. A spec in which every client waits forever
satisfies `mutual_exclusion` perfectly. Liveness is the other half: what must eventually happen.

<!-- tla:skip reason=a fragment of the modules below, shown on its own first -->
```elixir
temporal :no_starvation do
  Enum.all?(^clients, fn c -> (pc[c] == :waiting) ~> (pc[c] == :holding) end)
end
```

`p ~> q` is *leads-to*: whenever `p` holds, `q` holds at that point or later. The `Enum.all?` over
a constant is TLA+'s `\A c \in Clients`, one property per client. `always`, `eventually` and `~>`
are the operators, and the fragment they may form is bounded and stated
([§5.1](../../spec/02-dsl-semantics.md#5-1-the-supported-temporal-fragment-is-bounded-say-so)); a
formula outside it is a compile error, never a silently weaker check.

`mix extla.check` checks a temporal property whenever the spec declares one; `--safety-only`
turns it off, and under symmetry or `--workers` above 1 it is skipped with a warning. From code it
is the other way round: `ExTLA.ModelChecker.run/2` and `assert_spec/2` check liveness only when
the config says `check_liveness: true`. And the first thing you learn on checking one is that a
leads-to is refuted by a system that is merely *allowed* to stop — so a spec with a temporal
property needs to say which actions the system can be relied on to take. That is **fairness**, and there are two strengths of it.

## Weak fairness, refuted

**Weak** fairness on an action says: if the action stays enabled from some point on, it is
eventually taken. Declared per client — `acquire: [:c]` is one constraint for each value of `c`,
and `02-dsl-semantics.md` §5.2 says why the whole-action form would not do:

<!-- tla:spec -->
```elixir
defmodule Tutorial.WeakLock do
  use ExTLA.Spec

  constant :clients, default: set([:alice, :bob, :carol])

  variable :free, type: [true, false]
  variable :pc,   type: [^clients ~> [:idle, :waiting, :holding]]

  init do
    free = true
    pc   = for_each(^clients, fn _ -> :idle end)
  end

  action :request, params: [c: ^clients] do
    require pc[c] == :idle
    pc = update(pc, c, fn _ -> :waiting end)
  end

  action :acquire, params: [c: ^clients] do
    require pc[c] == :waiting
    require free
    free = false
    pc   = update(pc, c, fn _ -> :holding end)
  end

  action :release, params: [c: ^clients] do
    require pc[c] == :holding
    free = true
    pc   = update(pc, c, fn _ -> :idle end)
  end

  invariant :mutual_exclusion do
    Enum.count(^clients, fn c -> pc[c] == :holding end) <= 1
  end

  invariant :holding_means_taken do
    Enum.all?(^clients, fn c -> pc[c] != :holding end) or free == false
  end

  temporal :no_starvation do
    Enum.all?(^clients, fn c -> (pc[c] == :waiting) ~> (pc[c] == :holding) end)
  end

  fairness :weak, acquire: [:c], release: [:c]
end
```

```sh
mix extla.check Tutorial.WeakLock
```

<!-- tla:liveness module=Tutorial.WeakLock expect=violation property=no_starvation -->
```
Tutorial.WeakLock — LIVENESS: no_starvation
  20 distinct states, 48 transitions, depth 5, 1ms

═══ LIVENESS: no_starvation ════════════════════════════════════════
temporal property :no_starvation, at :alice, is refuted by this behaviour

State 1 (Init):
  free = true
  pc   = %{alice: :idle, bob: :idle, carol: :idle}

── request(c: :alice) ────────────────────

State 2:
  free = true
  pc   = %{alice: :waiting, bob: :idle, carol: :idle}   ← changed

── request(c: :bob) ────────────────────

State 3:
  free = true
  pc   = %{alice: :waiting, bob: :waiting, carol: :idle}   ← changed

── acquire(c: :bob) ────────────────────

State 4:
  free = false   ← changed
  pc   = %{alice: :waiting, bob: :holding, carol: :idle}   ← changed

── release(c: :bob) ────────────────────

State 5 (= state 2, the cycle head):
  free = true   ← changed
  pc   = %{alice: :waiting, bob: :idle, carol: :idle}   ← changed

════════════════════════════════════════════════════════════════════

↺ back to state 2 — states 2-4 repeat forever.


--- COVERAGE: Tutorial.WeakLock ------------------------------------

Actions                   enabled in      states     distinct
  acquire                            7          12          12
  release                           12          12           0
  request                           16          24           7


Invariants                  evaluated   near-miss
  holding_means_taken               20          12
  mutual_exclusion                  20           -
  type_ok                           20           -
--------------------------------------------------------------------
```

A liveness counterexample is not a trace that ends in a bad state; it is a **lasso** — a prefix,
then a cycle the system can repeat forever, along which the property never comes true. The
header says which instance of the `Enum.all?` it refutes — `at :alice`. Then read the end: state 5
is marked `(= state 2, the cycle head)`, so the system is back where the cycle began, and the line
under the trace says states 2 to 4 repeat forever. Alice asks for the lock in state 2 and is still
waiting when the cycle closes. Bob requests, acquires and releases, forever, and every step of
that is one the fairness constraints permit — because `acquire(c: :alice)` is *not continuously
enabled*. It is disabled every time Bob holds the lock.
Weak fairness obliges nothing about an action that keeps being switched off, and this is exactly
the behaviour it does not rule out.

## Strong fairness, holding

**Strong** fairness says: if the action is enabled *infinitely often* — even intermittently — it
is eventually taken. That is the guarantee a lock needs, since a lock is by nature free only now
and then:

<!-- tla:spec -->
```elixir
defmodule Tutorial.StrongLock do
  use ExTLA.Spec

  constant :clients, default: set([:alice, :bob, :carol])

  variable :free, type: [true, false]
  variable :pc,   type: [^clients ~> [:idle, :waiting, :holding]]

  init do
    free = true
    pc   = for_each(^clients, fn _ -> :idle end)
  end

  action :request, params: [c: ^clients] do
    require pc[c] == :idle
    pc = update(pc, c, fn _ -> :waiting end)
  end

  action :acquire, params: [c: ^clients] do
    require pc[c] == :waiting
    require free
    free = false
    pc   = update(pc, c, fn _ -> :holding end)
  end

  action :release, params: [c: ^clients] do
    require pc[c] == :holding
    free = true
    pc   = update(pc, c, fn _ -> :idle end)
  end

  invariant :mutual_exclusion do
    Enum.count(^clients, fn c -> pc[c] == :holding end) <= 1
  end

  invariant :holding_means_taken do
    Enum.all?(^clients, fn c -> pc[c] != :holding end) or free == false
  end

  temporal :no_starvation do
    Enum.all?(^clients, fn c -> (pc[c] == :waiting) ~> (pc[c] == :holding) end)
  end

  fairness :strong, acquire: [:c]
  fairness :weak, release: [:c]
end
```

```sh
mix extla.check Tutorial.StrongLock
```

<!-- tla:liveness module=Tutorial.StrongLock expect=ok output=abridged -->
```
Tutorial.StrongLock — verified
  20 distinct states, 48 transitions, depth 5, 2ms
```

The same twenty states, the same transitions, the same property — and it holds. Nothing changed
but the assumption about how the system is scheduled, which is what fairness is: a claim about the
implementation, that it will not starve an intermittently-enabled step. `release` keeps weak
fairness, because a holder's `release` stays enabled until it is taken, and weak is enough.

## Per client, not per action

One more version, because it is the mistake `02-dsl-semantics.md` §5.2 exists to name. `fairness :strong, [:acquire]`
without the `[:c]` is strong fairness on the action as a whole: if *some* client's `acquire` is
enabled infinitely often, *some* client eventually acquires. Bob acquiring satisfies that. Alice
waits forever anyway:

<!-- tla:spec -->
```elixir
defmodule Tutorial.WholeActionLock do
  use ExTLA.Spec

  constant :clients, default: set([:alice, :bob, :carol])

  variable :free, type: [true, false]
  variable :pc,   type: [^clients ~> [:idle, :waiting, :holding]]

  init do
    free = true
    pc   = for_each(^clients, fn _ -> :idle end)
  end

  action :request, params: [c: ^clients] do
    require pc[c] == :idle
    pc = update(pc, c, fn _ -> :waiting end)
  end

  action :acquire, params: [c: ^clients] do
    require pc[c] == :waiting
    require free
    free = false
    pc   = update(pc, c, fn _ -> :holding end)
  end

  action :release, params: [c: ^clients] do
    require pc[c] == :holding
    free = true
    pc   = update(pc, c, fn _ -> :idle end)
  end

  invariant :mutual_exclusion do
    Enum.count(^clients, fn c -> pc[c] == :holding end) <= 1
  end

  invariant :holding_means_taken do
    Enum.all?(^clients, fn c -> pc[c] != :holding end) or free == false
  end

  temporal :no_starvation do
    Enum.all?(^clients, fn c -> (pc[c] == :waiting) ~> (pc[c] == :holding) end)
  end

  fairness :strong, [:acquire]
  fairness :weak, release: [:c]
end
```

<!-- tla:liveness module=Tutorial.WholeActionLock expect=violation property=no_starvation -->
```
Tutorial.WholeActionLock — LIVENESS: no_starvation
  20 distinct states, 48 transitions, depth 5, 1ms

═══ LIVENESS: no_starvation ════════════════════════════════════════
temporal property :no_starvation, at :alice, is refuted by this behaviour

State 1 (Init):
  free = true
  pc   = %{alice: :idle, bob: :idle, carol: :idle}

── request(c: :alice) ────────────────────

State 2:
  free = true
  pc   = %{alice: :waiting, bob: :idle, carol: :idle}   ← changed

── request(c: :bob) ────────────────────

State 3:
  free = true
  pc   = %{alice: :waiting, bob: :waiting, carol: :idle}   ← changed

── acquire(c: :bob) ────────────────────

State 4:
  free = false   ← changed
  pc   = %{alice: :waiting, bob: :holding, carol: :idle}   ← changed

── release(c: :bob) ────────────────────

State 5 (= state 2, the cycle head):
  free = true   ← changed
  pc   = %{alice: :waiting, bob: :idle, carol: :idle}   ← changed

════════════════════════════════════════════════════════════════════

↺ back to state 2 — states 2-4 repeat forever.


--- COVERAGE: Tutorial.WholeActionLock -----------------------------

Actions                   enabled in      states     distinct
  acquire                            7          12          12
  release                           12          12           0
  request                           16          24           7


Invariants                  evaluated   near-miss
  holding_means_taken               20          12
  mutual_exclusion                  20           -
  type_ok                           20           -
--------------------------------------------------------------------
```

Same lasso as the weak one. TLA+ writes `\A c \in Clients : SF_vars(Acquire(c))`, and so does
ExTLA — `acquire: [:c]`. The named params must draw from a constant, for the reason the compiler
gives if you try otherwise: a state-drawn param has no fixed set of bindings to attach a
constraint to ([§5.2](../../spec/02-dsl-semantics.md#5-2-fairness-is-declared-per-action-or-per-parameter-binding)).

---

Next: [Symmetry, and who is responsible for it](05-symmetry.md).