Cache Eviction

architecture

Cache Eviction

Definition

Cache eviction is the process of selecting and removing entries from a cache to reclaim memory, maintain data freshness, or enforce size constraints. Since caches have finite capacity, an eviction policy determines which entries to remove when the cache reaches its limits or when cached data becomes stale. The choice of eviction policy directly impacts cache hit rates, memory usage, and data consistency.

Every cache eviction decision is fundamentally a trade-off: removing entries too aggressively wastes the work invested in populating the cache (reducing hit rates and increasing origin load), while removing entries too conservatively risks serving stale data and consuming excessive memory. The optimal eviction strategy depends on access patterns, data volatility, memory budget, and consistency requirements.

In the Prismatic Platform, cache eviction operates across multiple layers: ETS tables use TTL-based cleanup with periodic sweeps, Cachex provides built-in LRU eviction with configurable limits, and application-level PubSub notifications enable event-driven invalidation when source data changes. This multi-layer approach ensures that each cache tier uses the eviction strategy best suited to its access patterns and consistency requirements.

Core Concepts

Eviction Policy Comparison

PolicySelection CriteriaHit RateComplexityMemory OverheadPrismatic Usage
TTL (Time-To-Live)Time since insertion/updateGoodO(1) insert, O(n) sweepTimestamp per entryOSINT feed cache, session cache
LRU (Least Recently Used)Least recently accessed entryExcellentO(1) with hash+linked listPointer per entryAPI response cache, Cachex default
LFU (Least Frequently Used)Least access countVery GoodO(log n) with min-heapCounter per entryNot currently used
FIFO (First In, First Out)Oldest insertion timeFairO(1) with queueMinimalTelemetry event buffers
Size-BasedCache exceeds max entry countVariesO(1) checkCounter onlyETS table limits
RandomRandomly selected entryFairO(1)NoneFallback under memory pressure
Event-BasedExternal trigger invalidatesPerfect freshnessO(1) per eventPubSub subscriptionQuality DNA, entity updates
ARC (Adaptive Replacement)Balances recency and frequencyExcellentO(1)Two LRU listsNebulex adapter option

TTL Strategy Variants

VariantBehaviorProsConsUse Case
Absolute TTLEntry expires N seconds after creationPredictable, simpleStale reads possible until expiryExternal API response cache
Sliding TTLExpiry resets on each accessKeeps hot entries aliveStale data persists if accessedSession cache, user preferences
Lazy TTLCheck expiry only on readZero background overheadStale entries consume memoryLow-traffic caches
Active TTLBackground sweep removes expiredMemory reclaimed promptlySweep cost (O(n) per cycle)High-volume caches
Hybrid TTLLazy check on read + periodic sweepBalanced memory and latencyMore complex implementationPrismatic Platform default

Cache Warming Strategies

StrategyDescriptionTrade-off
Cold StartCache starts empty, fills on demandSlow initial performance, no wasted memory
Pre-Warm on BootLoad frequently accessed data at startupFast initial requests, longer boot time
Background RefreshRefresh entries before TTL expiresNever serves stale data, constant background load
Predictive WarmPre-load based on anticipated access patternsOptimal hit rate, complex prediction logic
Cascade WarmHigher-tier cache warms lower tiersReduces origin load, tiered complexity

Technical Deep Dive

ETS-Based Cache Eviction in BEAM/OTP

ETS (Erlang Term Storage) is the foundation of most BEAM caching. ETS tables provide concurrent read/write access from any process, but they have no built-in eviction mechanism -- entries persist until explicitly deleted or the table is destroyed. This means eviction must be implemented as application-level logic.

The standard pattern for TTL-based ETS eviction stores each entry as {key, value, expiry_timestamp} where expiry_timestamp is computed as System.monotonic_time(:millisecond) + ttl_ms at insertion time. Eviction runs in two complementary phases:

  1. Lazy eviction on read: When a value is fetched, its timestamp is checked. If expired, the entry is deleted and a cache miss is returned. This ensures no stale data is ever served, at zero background cost.

  2. Active eviction via periodic sweep: A GenServer or :timer.send_interval/2 triggers periodic scans of the ETS table using :ets.select/2 with match specifications to find expired entries. The sweep interval balances memory reclamation speed against CPU cost.

The critical implementation detail is using System.monotonic_time/1 rather than System.system_time/1 for timestamps. Monotonic time is immune to NTP clock adjustments, preventing entries from suddenly appearing expired (or unexpectedly extended) after a time correction.

Cachex Integration

Cachex is the primary structured caching library used in the Prismatic Platform. It provides:

  • Built-in TTL: Configurable default and per-entry TTL with automatic expiration.
  • Size limits: Maximum entry count with LRU eviction when the limit is reached.
  • Warming: Cache warming hooks for pre-population on startup.
  • Stats: Built-in hit/miss/eviction counters for monitoring.
  • Fallback: Automatic fallback functions that populate cache on miss.
  • Transactions: Atomic get-or-set operations preventing cache stampede.

Cachex uses ETS internally but adds the eviction, TTL, and coordination layers that raw ETS lacks. For the Prismatic Platform, Cachex is the recommended choice for any cache that needs TTL, size limits, or both.

Nebulex for Distributed Caching

For caches that must span multiple nodes (e.g., in a clustered BEAM deployment), Nebulex provides a distributed caching framework with pluggable adapters:

  • Local adapter: Single-node cache (similar to Cachex).
  • Partitioned adapter: Distributes entries across cluster nodes by key hash.
  • Replicated adapter: Full copy on every node, eventual consistency.
  • Multilevel adapter: Tiered caching (L1 local, L2 distributed).

Each adapter supports its own eviction configuration. The multilevel adapter is particularly relevant for the Prismatic Platform's tiered caching architecture, where L1 (ETS/process-local) provides sub-microsecond access and L2 (Cachex/Nebulex) provides millisecond access with persistence across process restarts.

Cache Stampede Prevention

When a popular cache entry expires, multiple concurrent requests may simultaneously attempt to regenerate it, creating a "thundering herd" against the origin. Prevention strategies include:

  • Probabilistic early expiration: Each access has a small probability of triggering a background refresh before the actual TTL expires, spreading regeneration load.
  • Lock-based regeneration: A single process acquires a lock to regenerate the entry while others wait or receive the stale value.
  • Stale-while-revalidate: Serve the expired value while a background process fetches a fresh one.

Cachex provides Cachex.fetch/3 which atomically checks for the entry and calls a fallback function only if missing, preventing stampede at the library level.

Memory Pressure Detection

In production BEAM systems, cache eviction should respond to system-wide memory pressure, not just per-cache limits. The BEAM VM provides :erlang.memory/0 for real-time memory reporting. An adaptive eviction strategy monitors total memory usage and becomes more aggressive when the system approaches its memory budget:

  • Normal (< 70% memory): Standard TTL/LRU eviction.
  • Elevated (70-85% memory): Reduce TTL by 50%, more aggressive size limits.
  • Critical (> 85% memory): Emergency eviction of low-priority caches, alert triggered.

Usage in Prismatic Platform

The platform implements cache eviction across multiple tiers and domains:

CacheTechnologyEviction StrategyTTLMax SizePurpose
OSINT Feed CacheCachexTTL + size limit15 min10,000 entriesExternal API response caching
ASN Lookup CacheETSAbsolute TTL24-72 hoursUnlimitedNetwork intelligence data
API Response CacheCachexLRU + size limit5 min5,000 entriesREST API response caching
Session CacheETSSliding TTL30 minUnlimitedUser session state
Quality DNACachexEvent-drivenNonePer-appQuality score caching
Telemetry BufferETSFIFO + size limitNone50,000 eventsTelemetry event buffering
Agent RegistryETSEvent-drivenNoneUnlimitedAIAD agent metadata
Glossary ContentCachexTTL + pre-warm1 hour2,000 entriesRendered glossary pages
Markdown RenderETSLRU + size limit30 min1,000 entriesCached markdown HTML output
OSINT Tool RegistryETSEvent-driven (compile)NoneUnlimitedSelf-registering tool metadata

The HierarchicalCache shared utility implements a 3-level caching strategy (ETS -> Cachex -> external source) with configurable eviction at each level. This provides sub-50ms response times for cached content while ensuring eventual consistency through TTL and event-driven invalidation.

PubSub-driven invalidation is used for consistency-critical data. When a quality score changes, the Quality Floor Guardian broadcasts on the "quality:updated" topic, and all caches holding quality data evict the affected entries immediately rather than waiting for TTL expiry.

Code Examples

Comprehensive ETS Cache with TTL and Size-Based Eviction

defmodule PrismaticCache.EvictionManager do
  @moduledoc """
  Manages cache eviction across multiple ETS-backed caches with support
  for TTL-based, size-based, and event-driven eviction strategies.

  Each managed cache is registered with its eviction configuration, and
  the manager runs periodic sweeps to enforce TTL and size constraints.
  Telemetry events are emitted for all eviction operations.

  ## Architecture

  The manager supervises a periodic timer that sweeps all registered
  caches. Each sweep:

    1. Checks TTL: removes entries where `expiry < now`
    2. Checks size: removes oldest entries if count exceeds max_size
    3. Emits telemetry: `[:prismatic, :cache, :evicted]` with count and table

  ## Example

      iex> PrismaticCache.EvictionManager.register(:my_cache, %{
      ...>   strategy: :ttl_and_size,
      ...>   ttl_ms: 300_000,
      ...>   max_size: 5_000,
      ...>   check_interval_ms: 30_000
      ...> })
      :ok
  """

  use GenServer

  require Logger

  @type eviction_strategy :: :ttl | :size | :ttl_and_size | :event
  @type eviction_config :: %{
          strategy: eviction_strategy(),
          max_size: non_neg_integer() | :unlimited,
          ttl_ms: non_neg_integer() | nil,
          check_interval_ms: non_neg_integer()
        }

  @default_check_interval_ms 30_000

  @doc """
  Starts the eviction manager GenServer.
  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Registers a cache table for managed eviction with the given config.
  """
  @spec register(atom(), eviction_config()) :: :ok
  def register(table, config) do
    GenServer.call(__MODULE__, {:register, table, config})
  end

  @doc """
  Immediately evicts all expired entries from the given ETS table.

  Entries must be stored as `{key, value, expiry_monotonic_ms}` tuples.
  Returns the count of evicted entries.

  ## Example

      iex> PrismaticCache.EvictionManager.evict_expired(:osint_cache)
      42
  """
  @spec evict_expired(atom()) :: non_neg_integer()
  def evict_expired(table) do
    now = System.monotonic_time(:millisecond)

    match_spec = [{{:"$1", :_, :"$2"}, [{:<, :"$2", now}], [:"$1"]}]
    expired_keys = :ets.select(table, match_spec)

    Enum.each(expired_keys, &:ets.delete(table, &1))

    count = length(expired_keys)

    if count > 0 do
      :telemetry.execute(
        [:prismatic, :cache, :evicted],
        %{count: count},
        %{table: table, strategy: :ttl}
      )

      Logger.debug("Cache eviction completed",
        table: table,
        evicted_count: count,
        strategy: :ttl
      )
    end

    count
  end

  @doc """
  Evicts entries to bring the table size within `max_size`.

  Uses FIFO ordering (earliest inserted entries evicted first) when
  the table is an `:ordered_set`, otherwise evicts by iteration order.
  Returns the count of evicted entries.

  ## Example

      iex> PrismaticCache.EvictionManager.evict_by_size(:api_cache, 5_000)
      150
  """
  @spec evict_by_size(atom(), non_neg_integer()) :: non_neg_integer()
  def evict_by_size(table, max_size) do
    current_size = :ets.info(table, :size)

    if current_size > max_size do
      to_remove = current_size - max_size

      keys =
        :ets.select(table, [{{:"$1", :_, :_}, [], [:"$1"]}], to_remove)
        |> case do
          {matched, _continuation} -> matched
          :"$end_of_table" -> []
        end

      Enum.each(keys, &:ets.delete(table, &1))

      evicted = length(keys)

      if evicted > 0 do
        :telemetry.execute(
          [:prismatic, :cache, :evicted],
          %{count: evicted},
          %{table: table, strategy: :size, max_size: max_size}
        )
      end

      evicted
    else
      0
    end
  end

  @doc """
  Inserts a value into an ETS cache with automatic TTL calculation.

  ## Example

      iex> PrismaticCache.EvictionManager.put_with_ttl(:my_cache, "key1", "value1", 60_000)
      true
  """
  @spec put_with_ttl(atom(), term(), term(), non_neg_integer()) :: true
  def put_with_ttl(table, key, value, ttl_ms) do
    expiry = System.monotonic_time(:millisecond) + ttl_ms
    :ets.insert(table, {key, value, expiry})
  end

  @doc """
  Fetches a value from an ETS cache, performing lazy TTL check.

  Returns `{:ok, value}` if found and not expired, `:error` otherwise.
  Automatically deletes expired entries on access.

  ## Example

      iex> PrismaticCache.EvictionManager.get_with_ttl(:my_cache, "key1")
      {:ok, "value1"}
  """
  @spec get_with_ttl(atom(), term()) :: {:ok, term()} | :error
  def get_with_ttl(table, key) do
    case :ets.lookup(table, key) do
      [{^key, value, expiry}] ->
        now = System.monotonic_time(:millisecond)

        if now < expiry do
          {:ok, value}
        else
          :ets.delete(table, key)
          :error
        end

      [] ->
        :error
    end
  end

  # --- GenServer Callbacks ---

  @impl true
  def init(_opts) do
    {:ok, %{caches: %{}}}
  end

  @impl true
  def handle_call({:register, table, config}, _from, state) do
    interval = Map.get(config, :check_interval_ms, @default_check_interval_ms)
    :timer.send_interval(interval, {:sweep, table})
    new_caches = Map.put(state.caches, table, config)
    {:reply, :ok, %{state | caches: new_caches}}
  end

  @impl true
  def handle_info({:sweep, table}, state) do
    case Map.get(state.caches, table) do
      nil ->
        {:noreply, state}

      config ->
        if config.strategy in [:ttl, :ttl_and_size] and config.ttl_ms do
          evict_expired(table)
        end

        if config.strategy in [:size, :ttl_and_size] and config.max_size != :unlimited do
          evict_by_size(table, config.max_size)
        end

        {:noreply, state}
    end
  end
end

Cachex-Based Application Cache with Warming and Fallback

defmodule PrismaticCache.ApplicationCache do
  @moduledoc """
  High-level application cache built on Cachex with automatic warming,
  fallback functions, and telemetry integration.

  Provides a declarative API for defining caches with eviction policies
  that are automatically enforced by Cachex.

  ## Example

      iex> PrismaticCache.ApplicationCache.get_or_fetch(:glossary, "elixir", fn ->
      ...>   PrismaticWeb.Glossary.load_term("elixir")
      ...> end)
      {:ok, %{title: "Elixir", ...}}
  """

  require Logger

  @type cache_name :: atom()
  @type cache_opts :: [
          ttl: non_neg_integer(),
          limit: non_neg_integer(),
          warmers: [module()]
        ]

  @doc """
  Retrieves a cached value or populates it using the fallback function.

  Uses Cachex.fetch/3 for atomic get-or-set semantics, preventing
  cache stampede when multiple processes request the same missing key.

  ## Example

      iex> PrismaticCache.ApplicationCache.get_or_fetch(:api_cache, "/health", fn ->
      ...>   {:ok, %{status: "healthy"}}
      ...> end)
      {:ok, %{status: "healthy"}}
  """
  @spec get_or_fetch(cache_name(), term(), (() -> {:ok, term()} | {:error, term()})) ::
          {:ok, term()} | {:error, term()}
  def get_or_fetch(cache, key, fallback_fn) do
    case Cachex.fetch(cache, key, fn _key ->
      case fallback_fn.() do
        {:ok, value} -> {:commit, value}
        {:error, _} = error -> {:ignore, error}
      end
    end) do
      {:ok, value} -> {:ok, value}
      {:commit, value} ->
        :telemetry.execute(
          [:prismatic, :cache, :miss],
          %{count: 1},
          %{cache: cache, key: key}
        )
        {:ok, value}
      {:ignore, error} -> error
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Invalidates a specific cache entry, triggering re-fetch on next access.

  ## Example

      iex> PrismaticCache.ApplicationCache.invalidate(:glossary, "elixir")
      :ok
  """
  @spec invalidate(cache_name(), term()) :: :ok
  def invalidate(cache, key) do
    Cachex.del(cache, key)

    :telemetry.execute(
      [:prismatic, :cache, :invalidated],
      %{count: 1},
      %{cache: cache, key: key}
    )

    :ok
  end

  @doc """
  Returns cache statistics including hit rate, miss rate, and eviction count.

  ## Example

      iex> PrismaticCache.ApplicationCache.stats(:glossary)
      %{hits: 4521, misses: 89, evictions: 12, hit_rate: 0.98, size: 1243}
  """
  @spec stats(cache_name()) :: map()
  def stats(cache) do
    case Cachex.stats(cache) do
      {:ok, stats} ->
        total = Map.get(stats, :hits, 0) + Map.get(stats, :misses, 0)
        hit_rate = if total > 0, do: Map.get(stats, :hits, 0) / total, else: 0.0

        %{
          hits: Map.get(stats, :hits, 0),
          misses: Map.get(stats, :misses, 0),
          evictions: Map.get(stats, :evictions, 0),
          hit_rate: Float.round(hit_rate, 4),
          size: Cachex.size!(cache)
        }

      {:error, _} ->
        %{hits: 0, misses: 0, evictions: 0, hit_rate: 0.0, size: 0}
    end
  end
end

PubSub-Driven Cache Invalidation

defmodule PrismaticCache.EventInvalidator do
  @moduledoc """
  Listens for PubSub events and invalidates corresponding cache entries.

  Maps event topics to cache names and key extraction functions, providing
  automatic event-driven cache invalidation without polling or TTL delays.

  ## Configuration

  Invalidation rules are defined as a list of `{topic, cache, key_fn}` tuples:

      rules = [
        {"quality:updated", :quality_cache, fn event -> event.app_name end},
        {"entity:changed", :entity_cache, fn event -> event.entity_id end}
      ]

  ## Example

      iex> PrismaticCache.EventInvalidator.start_link(rules: [
      ...>   {"quality:updated", :quality_cache, &Map.get(&1, :app_name)}
      ...> ])
      {:ok, pid}
  """

  use GenServer

  require Logger

  @type invalidation_rule :: {String.t(), atom(), (map() -> term())}

  @doc """
  Starts the event invalidator with the given invalidation rules.
  """
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl true
  def init(opts) do
    rules = Keyword.get(opts, :rules, [])

    Enum.each(rules, fn {topic, _cache, _key_fn} ->
      Phoenix.PubSub.subscribe(Prismatic.PubSub, topic)
    end)

    {:ok, %{rules: rules}}
  end

  @impl true
  def handle_info({topic, event_data}, state) when is_binary(topic) do
    state.rules
    |> Enum.filter(fn {rule_topic, _cache, _key_fn} -> rule_topic == topic end)
    |> Enum.each(fn {_topic, cache, key_fn} ->
      key = key_fn.(event_data)
      PrismaticCache.ApplicationCache.invalidate(cache, key)

      Logger.debug("Event-driven cache invalidation",
        topic: topic,
        cache: cache,
        key: key
      )
    end)

    {:noreply, state}
  end

  def handle_info(_msg, state), do: {:noreply, state}
end

Common Pitfalls

PitfallProblemSolution
Unbounded cache growthETS table consumes all available memory, causing OOM crashAlways configure max_size limits; monitor :ets.info(table, :memory)
Wall-clock TTL timestampsNTP adjustments cause entries to expire early or persist beyond TTLUse System.monotonic_time/1 for all TTL calculations
Cache stampedeMany concurrent requests regenerate the same expired entry simultaneouslyUse Cachex.fetch/3 for atomic get-or-set; implement locking or stale-while-revalidate
Sweep blockingFull-table ETS scan blocks the sweep process for large tablesUse :ets.select/3 with continuation for chunked sweeps; run sweeps in dedicated process
Over-aggressive evictionTTL too short or max_size too small causes excessive cache missesMonitor hit rates; tune TTL and size based on access patterns
Stale reads in event-drivenPubSub message delay allows brief window of stale dataAccept eventual consistency or combine event-driven with short TTL as safety net
Missing eviction telemetryCannot diagnose cache performance without eviction metricsEmit :telemetry.execute/3 for every eviction operation
Process-local cache lossCaching in process state means cache is lost on process restartUse ETS (survives process restart within node) or Cachex for durable caching
Ignoring memory pressurePer-cache limits do not account for total system memory consumptionImplement adaptive eviction that monitors :erlang.memory(:total)
TTL without lazy checkPeriodic sweep alone means stale data can be served between sweepsAlways check TTL on read (lazy eviction) in addition to periodic sweeps

Best Practices

  1. Match eviction policy to access patterns -- use LRU for request caches with temporal locality, TTL for time-sensitive external data, and event-driven invalidation for consistency-critical application data.

  2. Monitor eviction rates and hit ratios -- high eviction rates indicate the cache is too small or TTL is too short. Target hit rates above 90% for application caches and above 95% for hot-path caches.

  3. Use lazy eviction with periodic sweeps -- check TTL on every read (lazy) to prevent serving stale data, plus run periodic background sweeps to reclaim memory from entries that are never re-accessed.

  4. Set reasonable size limits on every cache -- unbounded caches will eventually consume all available memory. Even if you trust the TTL, set a size limit as a safety net against memory exhaustion.

  5. Emit telemetry events on every eviction -- use [:prismatic, :cache, :evicted] events with table name, strategy, and count metadata for cache tuning and capacity planning.

  6. Use Cachex for application caches -- raw ETS requires implementing TTL, size limits, and stampede prevention manually. Cachex provides all of these out of the box with proven correctness.

  7. Prevent cache stampede with atomic operations -- use Cachex.fetch/3 or implement lock-based regeneration to ensure only one process regenerates an expired entry.

  8. Use monotonic time for all TTL calculations -- System.monotonic_time/1 is immune to NTP adjustments, preventing unexpected expiration behavior.

  9. Implement adaptive eviction under memory pressure -- monitor system memory and become more aggressive with eviction when total memory usage exceeds 70-80% of available capacity.

  10. Warm critical caches on application startup -- for caches where cold-start latency is unacceptable, implement cache warmers that pre-populate entries from the data source during boot.

  • Cache -- the storage layer being managed by eviction policies
  • Configuration -- eviction policy configuration and tuning parameters
  • Consistency -- data freshness guarantees affected by eviction timing
  • Benchmark -- measuring eviction impact on cache hit rates and system performance
  • Telemetry -- the event system for monitoring eviction metrics
  • Response Distribution -- cache hit/miss ratio directly affects response time distribution shape
  • Retry -- retry policies interact with cache TTL when retrying cached responses
  • Logging -- eviction events should be logged for operational visibility
  • Batch Processing -- batch cache warming and eviction strategies
  • Memory Management -- cache eviction is a key component of system memory management
  • Throughput -- cache hit rates directly impact system throughput
  • Supervision Strategy -- cache processes require proper OTP supervision for reliability

See Also


Connect & Contribute

Created by Tomas Korcak (korczis) | Open Source under GHL