`:atomics` and `:persistent_term`

elixir

Posted

I learned some tricks from an LLM today.

Suppose you want to build a "circuit breaker", which is "open" whenever something has failed enough times and "closes" when it succeeds. This pattern is used for things like "don't keep calling this service when we already know it's down", or "only notify the user the first few times a problem happens."

iex> Breaker.init()         # :ok
iex> Breaker.open?()        # false
iex> Breaker.failed()       # 1
iex> Breaker.open?()        # false
iex> Breaker.failed()       # 2
iex> Breaker.open?()        # false
iex> Breaker.failed()       # 3
iex> Breaker.open?()        # true
iex> Breaker.failed()       # 4
iex> Breaker.open?()        # true
iex> Breaker.succeeded()    # :ok
iex> Breaker.open?()        # false
iex> Breaker.failed()       # 1
iex> Breaker.open?()        # false

One way is to use these:

Put together:

defmodule Breaker do
  # node-wide name, so namespace it with the module
  @key {__MODULE__, :failures}  
  @threshold 3

  # Run once, at startup. Writing to :persistent_term
  # is expensive, so store the reference once and
  # never change it
  def init do
    # Create an atomically mutable 1-element array of integers
    ref = :atomics.new(1, signed: false)
    # Store the reference under a known key so we know how to
    # get it and mutate it
    :persistent_term.put(@key, ref)
  end

  # Reading from :persistent_term is cheap
  defp get_atomics, do: :persistent_term.get(@key)

  # We have a 1-slot atomic and we always work with that slot
  @slot 1

  # Increments the failure counter atomically.
  def failed, do: :atomics.add_get(get_atomics(), @slot, 1)

  # Resets the failure counter to 0
  def succeeded, do: :atomics.put(get_atomics(), @slot, 0)

  # True if we've had too many failures
  def open?, do: :atomics.get(get_atomics(), @slot) >= @threshold
end