ExpirableStore

CI

Lightweight expirable value store for Elixir with cluster-wide or local scoping.

Perfect for caching OAuth tokens, API keys, and other time-sensitive data that shouldn't be repeatedly refreshed.

Features

Installation

def deps do
  [{:expirable_store, "~> 0.7.0"}]
end

Quick Example

defmodule MyApp.Expirables do
  use ExpirableStore

  # Cluster-scoped, lazy refresh (stateless — ignores state)
  expirable :github_access_token do
    fetch fn _state ->
      case GitHubOAuth.fetch_access_token() do
        {:ok, token, expires_at} -> {:ok, token, expires_at, nil}
        :error -> {:error, nil}
      end
    end
    scope :cluster
    refresh :lazy
  end

  # Node-local, eager refresh (30s before expiry)
  expirable :datadog_agent_token do
    fetch fn _state ->
      case DatadogAgent.fetch_local_token() do
        {:ok, token, expires_at} -> {:ok, token, expires_at, nil}
        :error -> {:error, nil}
      end
    end
    scope :local
    refresh {:eager, before_expiry: :timer.seconds(30)}
  end

  # Stateful + externally initialized: state must be provided before first fetch
  expirable :oauth_token do
    fetch fn state ->
      refresh_token = state.refresh_token
      {access_token, new_refresh_token} = OAuth.refresh(refresh_token)
      expires_at = System.system_time(:millisecond) + :timer.hours(1)
      {:ok, access_token, expires_at, %{state | refresh_token: new_refresh_token}}
    end
    require_initial_state true
    scope :cluster
    refresh {:eager, before_expiry: :timer.minutes(5)}
  end

  # Keyed + require_initial_state: per-tenant credentials with runtime state
  expirable :tenant_api_key do
    fetch fn tenant_id, state ->
      api_key = ExternalAPI.rotate_key(tenant_id, state.secret)
      expires_at = System.system_time(:millisecond) + :timer.hours(24)
      {:ok, api_key, expires_at, state}
    end
    keyed true
    require_initial_state true
    scope :cluster
    refresh :lazy
  end
end
# Add require for compile-time name validation (optional but recommended)
require MyApp.Expirables

# Fetch
{:ok, token, _} = MyApp.Expirables.fetch(:github_access_token)
token = MyApp.Expirables.fetch!(:github_access_token)
MyApp.Expirables.clear(:github_access_token)
MyApp.Expirables.clear_all()

# require_initial_state: must call put_state before fetch
MyApp.Expirables.put_state(:oauth_token, %{refresh_token: get_from_db()})
{:ok, access_token, _} = MyApp.Expirables.fetch(:oauth_token)

# Keyed + require_initial_state
MyApp.Expirables.put_state(:tenant_api_key, "tenant_123", %{secret: get_secret()})
{:ok, key, _} = MyApp.Expirables.fetch(:tenant_api_key, "tenant_123")
MyApp.Expirables.clear(:tenant_api_key, "tenant_123")  # clear specific key
MyApp.Expirables.clear(:tenant_api_key)                 # clear all keys

DSL Options

Option Values Default Description
fetchfn state -> {:ok, value, expires_at, next_state} | {:error, next_state} endrequired Stateful fetch function. Use 2-arity fn key, state -> ... end when keyed: true. expires_at is Unix ms or :infinity
keyedtrue, falsefalse When true, each unique key gets its own independent cache entry and timer
require_initial_statetrue, falsefalse When true, put_state must be called before fetch works. Use when the fetch function cannot produce a valid initial state on its own
refresh:lazy, {:eager, before_expiry: ms}:lazy Refresh strategy
scope:cluster, :local:cluster Scope of the store

Refresh Strategies

Scope Options

Keyed Expirables

When keyed: true, the fetch function receives the key as its first argument and state as its second. Each unique key has its own independent Agent, state, and refresh timer. The key can be any Erlang term:

MyApp.Expirables.fetch(:tenant_api_key, "tenant_123")  # string
MyApp.Expirables.fetch(:tenant_api_key, :tenant_a)     # atom
MyApp.Expirables.fetch(:tenant_api_key, {:org, 42})    # tuple

Stateful Fetch

The fetch function receives a state argument and must return next_state. State is persisted in the Agent and passed to subsequent fetch calls (e.g., on expiry refresh).

Two patterns:

require_initial_state

When require_initial_state: true, you must provide an initial state before fetch works:

# Must call put_state before fetch — otherwise fetch returns :error
MyApp.Expirables.put_state(:oauth_token, %{refresh_token: get_from_db()})
{:ok, token, _} = MyApp.Expirables.fetch(:oauth_token)

# After clear, put_state is required again
MyApp.Expirables.clear(:oauth_token)
{:error, :state_required} = MyApp.Expirables.fetch(:oauth_token)

API

All functions are macros — add require MyApp.Expirables to enable compile-time validation of expirable names and keyed/unkeyed arity mismatches.

Key Behaviors

When to Use

Good for lightweight, time-sensitive data: OAuth tokens, API keys, FX rates, per-tenant credentials.

Not recommended for: high-traffic scenarios, large values.

Development

mix test        # single-node tests
mix test.all    # includes distributed multi-node tests