Definition

A cache is a high-speed data storage layer positioned between a data consumer and a primary data source, storing copies of frequently or recently accessed data to reduce access latency and load on the underlying storage system. Caches exploit the principle of temporal locality -- data accessed once is likely to be accessed again soon -- and spatial locality -- data near recently accessed data is likely to be accessed next. The fundamental trade-off of caching is between data freshness (how current cached data is) and performance (how fast access is).

In the Prismatic Platform, ETS (Erlang Term Storage) serves as the foundational caching mechanism, but the platform extends far beyond simple key-value caching. The PrismaticWeb.Cache.HierarchicalCache module implements a 3-level tiered caching architecture (ETS -> Cachex -> external source) that provides sub-50ms response times across all platform domains: the OSINT ToolRegistry (157 tools), Academy TopicRegistry, DD SourceRegistry, glossary content rendering, and API endpoint routing.

Overview

Caching is one of the most impactful performance optimizations available to any system. In the BEAM/OTP ecosystem, ETS tables provide a unique advantage: concurrent reads without process serialization. Unlike GenServer-held state, where every read must pass through the process mailbox, ETS allows any process to read directly from shared memory, yielding sub-microsecond access times.

However, ETS alone is not sufficient for production-grade caching. Real systems need:

  • TTL management -- data must expire to prevent staleness
  • Memory bounds -- unbounded caches exhaust available memory
  • Distributed coordination -- cached data must be coherent across nodes
  • Fallback chains -- cache misses must resolve transparently
  • Observability -- cache hit rates and latency must be measurable via Telemetry

The Prismatic Platform addresses all of these concerns through its HierarchicalCache architecture, which combines ETS speed with Cachex flexibility and transparent external-source fallback.

Technical Deep Dive

Cache Strategies

StrategyDescriptionConsistencyPrismatic Usage
Read-throughCache loads on miss, serves on hitEventualOSINT tool configs, glossary terms
Write-throughWrite to cache and store simultaneouslyStrongQuality DNA state
Write-behindWrite to cache, async flush to storeEventualTelemetry metrics aggregation
Cache-asideApplication manages cache explicitlyApplication-managedAPI responses, search results
Refresh-aheadProactively refresh before expiryNear-real-timeOSINT feed data, monitoring
HierarchicalMulti-level tiered caching with fallbackConfigurable per levelHierarchicalCache (L1/L2/L3)

ETS Caching Fundamentals

ETS is the backbone of all caching in the Prismatic Platform. Key properties that make it ideal for caching:

  • Concurrent reads: Multiple processes read simultaneously without contention
  • Process-independent lifetime: Tables survive owning-process restarts when using :heir option
  • Sub-microsecond lookups: Direct memory access, no message passing
  • Match specifications: Complex queries executed in native code
  • Configurable access: :public, :protected, or :private access levels

The read_concurrency: true option is critical for cache tables -- it optimizes the internal locking strategy for read-heavy workloads at a small cost to write performance.

# ETS table creation optimized for caching
:ets.new(:my_cache, [
  :set,                    # Key-value pairs, unique keys
  :named_table,            # Access by name, not reference
  :public,                 # Any process can read/write
  read_concurrency: true,  # Optimized for concurrent reads
  write_concurrency: true  # Allow concurrent writes to different keys
])

Cachex Integration

Cachex extends basic ETS caching with production-grade features:

  • Built-in TTL: Automatic key expiration with lazy and proactive cleanup
  • Warming: Pre-populate cache on startup via warmers
  • Statistics: Built-in hit/miss counters and cache metrics
  • Fallbacks: Configurable fallback functions for cache misses
  • Transactions: Atomic read-modify-write operations
  • Hooks: Event hooks for cache operations (put, get, evict)
  • Limits: Maximum entry count with configurable eviction policies
defmodule PrismaticWeb.Cache.CachexConfig do
  @moduledoc """
  Cachex cache configuration for the Prismatic Platform.

  Defines named caches with TTL, size limits, and eviction policies
  used as Level 2 in the HierarchicalCache architecture.
  """

  @doc """
  Returns the child spec for the glossary content cache.

  ## Examples

      iex> spec = PrismaticWeb.Cache.CachexConfig.glossary_cache_spec()
      iex> is_map(spec)
      true
  """
  @spec glossary_cache_spec() :: Supervisor.child_spec()
  def glossary_cache_spec do
    %{
      id: :glossary_cache,
      start: {Cachex, :start_link, [
        :glossary_cache,
        [
          expiration: expiration(default: :timer.hours(1)),
          limit: limit(size: 5_000, policy: Cachex.Policy.LRW, reclaim: 0.1),
          stats: true
        ]
      ]}
    }
  end

  @doc """
  Returns the child spec for the OSINT tool configuration cache.
  """
  @spec osint_cache_spec() :: Supervisor.child_spec()
  def osint_cache_spec do
    %{
      id: :osint_cache,
      start: {Cachex, :start_link, [
        :osint_cache,
        [
          expiration: expiration(default: :timer.minutes(30)),
          limit: limit(size: 1_000, policy: Cachex.Policy.LRW, reclaim: 0.1),
          warmers: [warmer(module: PrismaticWeb.Cache.OsintWarmer)],
          stats: true
        ]
      ]}
    }
  end

  defp expiration(opts), do: Cachex.Spec.expiration(opts)
  defp limit(opts), do: Cachex.Spec.limit(opts)
  defp warmer(opts), do: Cachex.Spec.warmer(opts)
end

HierarchicalCache: 3-Level Architecture

The PrismaticWeb.Cache.HierarchicalCache is the platform's primary caching abstraction. It implements a 3-level tiered cache with automatic fallback, telemetry instrumentation, and graceful degradation.

flowchart TD
    A[Client Request] --> B{L1: ETS Cache}
    B -->|HIT ~0.5ms| C[Return Cached Value]
    B -->|MISS| D{L2: Cachex/ContentCache}
    D -->|HIT ~3-5ms| E[Populate L1 + Return]
    D -->|MISS| F{L3: External Source}
    F -->|OK ~20-50ms| G[Populate L1 + L2 + Return]
    F -->|ERROR| H{Negative Cache?}
    H -->|Yes| I[Return Cached Error]
    H -->|No| J[Cache Error for 5min + Return Error]

    style B fill:#059669,stroke:#047857,color:#fff
    style D fill:#2563eb,stroke:#1d4ed8,color:#fff
    style F fill:#7c3aed,stroke:#6d28d9,color:#fff
    style C fill:#10b981,stroke:#059669,color:#fff
    style E fill:#10b981,stroke:#059669,color:#fff
    style G fill:#10b981,stroke:#059669,color:#fff

Level 1 -- ETS (sub-millisecond): Direct ETS table lookups. No process serialization. Ideal for hot data that changes infrequently. Default limit of 10,000 entries with LRU eviction.

Level 2 -- Cachex/ContentCache (milliseconds): Shared cache with built-in TTL, statistics, and warming capabilities. Accessed via PrismaticWeb.SEO.ContentCache. Suitable for data shared across LiveView processes.

Level 3 -- External Source (tens of milliseconds): The original data source -- database queries, API calls, file reads, Meilisearch index lookups. Only reached on L1+L2 cache miss.

defmodule PrismaticWeb.Cache.HierarchicalCache do
  @moduledoc """
  High-performance hierarchical caching system with 3-level architecture.

  Provides a unified interface for caching with automatic fallback through:
  1. **Level 1**: ETS (sub-millisecond, in-memory) - fastest access
  2. **Level 2**: Cachex/ContentCache (millisecond, distributed) - shared across processes
  3. **Level 3**: External source (tens of milliseconds) - database, API, etc.

  ## Performance Characteristics

  - **Cache Hit (L1)**: ~0.5-1ms response time
  - **Cache Hit (L2)**: ~3-5ms response time
  - **Cache Miss**: ~20-50ms (depends on external source)
  - **Bulk Operations**: 10-20x faster than sequential lookups
  """

  use GenServer

  alias PrismaticWeb.SEO.ContentCache

  require Logger

  @default_ets_limit 10_000
  @default_ttl 3600
  @negative_cache_ttl 300
  @cleanup_interval 600_000

  @doc """
  Retrieve a cached value or compute it via the 3-level hierarchy.

  Checks L1 (ETS) first, then L2 (ContentCache), then falls back to
  the provided computation function for L3 (external source).

  ## Parameters

    - `namespace` - Cache namespace string (e.g., "glossary_terms")
    - `key` - Cache key (any term)
    - `ttl` - Time-to-live in seconds
    - `compute_fn` - Zero-arity function to compute the value on cache miss

  ## Examples

      iex> HierarchicalCache.get_or_compute("test", "key1", 3600, fn -> {:ok, 42} end)
      {:ok, 42}
  """
  @spec get_or_compute(String.t(), term(), non_neg_integer(), (-> term())) :: term()
  def get_or_compute(namespace, key, ttl \\ @default_ttl, compute_fn) do
    cache_key = build_key(namespace, key)
    start_time = System.monotonic_time(:microsecond)

    case get_l1(cache_key) do
      {:ok, value} ->
        emit_telemetry(:hit, :l1, namespace, start_time)
        value

      :miss ->
        case get_l2(cache_key) do
          {:ok, value} ->
            put_l1(cache_key, value, ttl)
            emit_telemetry(:hit, :l2, namespace, start_time)
            value

          :miss ->
            result = compute_fn.()
            put_l1(cache_key, result, ttl)
            put_l2(cache_key, result, ttl)
            emit_telemetry(:miss, :l3, namespace, start_time)
            result
        end
    end
  end

  @doc """
  Bulk-fetch multiple keys with concurrent computation for misses.

  For keys not found in L1 or L2, the `batch_fn` is called once with
  all missing keys, then results are populated back into both cache levels.

  ## Examples

      iex> HierarchicalCache.get_bulk("terms", ["a", "b"], 1800, &fetch_batch/1)
      [%{slug: "a", ...}, %{slug: "b", ...}]
  """
  @spec get_bulk(String.t(), [term()], non_neg_integer(), ([term()] -> [term()])) :: [term()]
  def get_bulk(namespace, keys, ttl \\ @default_ttl, batch_fn) do
    {cached, missing_keys} =
      Enum.reduce(keys, {%{}, []}, fn key, {found, missing} ->
        cache_key = build_key(namespace, key)

        case get_l1(cache_key) do
          {:ok, value} -> {Map.put(found, key, value), missing}
          :miss ->
            case get_l2(cache_key) do
              {:ok, value} ->
                put_l1(cache_key, value, ttl)
                {Map.put(found, key, value), missing}
              :miss ->
                {found, [key | missing]}
            end
        end
      end)

    fetched =
      if missing_keys != [] do
        results = batch_fn.(Enum.reverse(missing_keys))

        Enum.zip(missing_keys, results)
        |> Enum.each(fn {key, value} ->
          cache_key = build_key(namespace, key)
          put_l1(cache_key, value, ttl)
          put_l2(cache_key, value, ttl)
        end)

        Enum.zip(missing_keys, results) |> Map.new()
      else
        %{}
      end

    Enum.map(keys, fn key -> Map.get(cached, key) || Map.get(fetched, key) end)
  end

  @doc "Invalidate a specific key across all cache levels."
  @spec invalidate(String.t(), term()) :: :ok
  def invalidate(namespace, key) do
    cache_key = build_key(namespace, key)
    delete_l1(cache_key)
    delete_l2(cache_key)
    :ok
  end

  @doc "Invalidate all keys in a namespace."
  @spec invalidate_namespace(String.t()) :: :ok
  def invalidate_namespace(namespace) do
    GenServer.cast(__MODULE__, {:invalidate_namespace, namespace})
  end

  # GenServer callbacks

  @impl GenServer
  def init(opts) do
    table = :ets.new(:hierarchical_cache_l1, [
      :set, :named_table, :public,
      read_concurrency: true,
      write_concurrency: true
    ])

    ets_limit = Keyword.get(opts, :ets_limit, @default_ets_limit)
    schedule_cleanup()

    {:ok, %{table: table, ets_limit: ets_limit, stats: %{hits: 0, misses: 0}}}
  end

  @impl GenServer
  def handle_info(:cleanup, state) do
    now = System.monotonic_time(:second)

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

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

    :telemetry.execute(
      [:prismatic, :cache, :cleanup],
      %{expired_count: length(expired)},
      %{cache: :hierarchical}
    )

    schedule_cleanup()
    {:noreply, state}
  end

  # Private helpers

  defp build_key(namespace, key), do: {namespace, key}

  defp get_l1(cache_key) do
    case :ets.lookup(:hierarchical_cache_l1, cache_key) do
      [{^cache_key, value, expires_at}] ->
        if System.monotonic_time(:second) < expires_at do
          {:ok, value}
        else
          :ets.delete(:hierarchical_cache_l1, cache_key)
          :miss
        end

      [] ->
        :miss
    end
  end

  defp get_l2(cache_key) do
    case ContentCache.get(cache_key) do
      {:ok, value} -> {:ok, value}
      _ -> :miss
    end
  end

  defp put_l1(cache_key, value, ttl) do
    expires_at = System.monotonic_time(:second) + ttl
    :ets.insert(:hierarchical_cache_l1, {cache_key, value, expires_at})
  end

  defp put_l2(cache_key, value, ttl) do
    ContentCache.put(cache_key, value, ttl: :timer.seconds(ttl))
  end

  defp delete_l1(cache_key), do: :ets.delete(:hierarchical_cache_l1, cache_key)
  defp delete_l2(cache_key), do: ContentCache.del(cache_key)

  defp emit_telemetry(result, level, namespace, start_time) do
    duration = System.monotonic_time(:microsecond) - start_time

    :telemetry.execute(
      [:prismatic, :cache, :lookup],
      %{duration: duration},
      %{result: result, level: level, namespace: namespace}
    )
  end

  defp schedule_cleanup do
    Process.send_after(self(), :cleanup, @cleanup_interval)
  end
end

TTL Strategies

Time-to-Live (TTL) determines how long cached data remains valid before expiration. The correct TTL depends on how frequently data changes and how tolerant the application is to staleness.

Data TypeRecommended TTLRationale
OSINT tool configs30 minutesTool metadata changes rarely, but should refresh within a session
Glossary content1 hourContent changes only via deployments
API responses5-15 minutesBalance freshness vs. backend load
Telemetry aggregations1-5 minutesNear-real-time metrics required
Session data24 hoursUser sessions persist across browsing
DD source configs30 minutesPipeline configurations update infrequently
Failed lookups (negative)5 minutesPrevent repeated failures from hammering the source

Cache Invalidation Strategies

Cache invalidation is famously one of the hardest problems in computer science. The Prismatic Platform employs multiple strategies depending on the data domain:

StrategyTriggerComplexityPrismatic Usage
TTL (Time-to-Live)Time-based expirationLowDefault for all caches
Event-based via PubSubBroadcast notificationMediumDD pipeline updates, OSINT runs
Version-basedData version comparisonMediumGlossary content with checksums
Manual / explicitDirect invalidation callLowAdmin cache flush endpoints
None (immutable)Data never changesMinimalCompiled configuration, PLT
defmodule PrismaticWeb.Cache.Invalidator do
  @moduledoc """
  PubSub-driven cache invalidation for cross-process cache coherence.

  Subscribes to domain-specific PubSub topics and invalidates the
  corresponding HierarchicalCache namespaces when data changes.
  """

  use GenServer

  alias PrismaticWeb.Cache.HierarchicalCache

  require Logger

  @doc "Start the invalidator, subscribing to relevant PubSub topics."
  @spec start_link(keyword()) :: GenServer.on_start()
  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @impl GenServer
  def init(_opts) do
    Phoenix.PubSub.subscribe(Prismatic.PubSub, "glossary:updates")
    Phoenix.PubSub.subscribe(Prismatic.PubSub, "osint:registry")
    Phoenix.PubSub.subscribe(Prismatic.PubSub, "dd:pipeline")
    {:ok, %{}}
  end

  @impl GenServer
  def handle_info({:glossary_updated, slug}, state) do
    Logger.info("Invalidating glossary cache for #{slug}")
    HierarchicalCache.invalidate("glossary_terms", slug)
    {:noreply, state}
  end

  @impl GenServer
  def handle_info({:osint_tool_updated, tool_slug}, state) do
    Logger.info("Invalidating OSINT cache for #{tool_slug}")
    HierarchicalCache.invalidate("osint_tools", tool_slug)
    {:noreply, state}
  end

  @impl GenServer
  def handle_info({:dd_source_updated, source_id}, state) do
    Logger.info("Invalidating DD source cache for #{source_id}")
    HierarchicalCache.invalidate("dd_sources", source_id)
    {:noreply, state}
  end

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

Negative Caching

Negative caching stores the result of failed lookups to prevent repeated expensive failures. When an external source returns an error, the HierarchicalCache stores the error result with a short TTL (default 5 minutes). This prevents cache stampede scenarios where thousands of concurrent requests for a non-existent key all bypass the cache simultaneously.

# Negative caching example within HierarchicalCache
defp handle_source_error(cache_key, error) do
  negative_entry = {:error, :not_found, System.monotonic_time(:second)}
  put_l1(cache_key, negative_entry, @negative_cache_ttl)
  put_l2(cache_key, negative_entry, @negative_cache_ttl)
  error
end

Usage in Prismatic Platform

The HierarchicalCache powers multiple critical subsystems across the platform:

  • OSINT ToolRegistry: 157 tool configurations cached in ETS with sub-microsecond reads. The OsintWarmer pre-populates the cache on startup via Cachex warmers. Tool metadata (name, slug, category, input_fields) is accessed on every /hub/osint/tools page load.

  • Academy TopicRegistry: Topic metadata cached across 3 ETS tables (topics, interconnections, search_index). The hierarchical cache ensures topic hover cards render in under 5ms, even for the first request.

  • Glossary Content System: The PrismaticWeb.MarkdownRenderer uses HierarchicalCache to cache rendered markdown. Raw markdown is fetched from disk (L3), parsed and rendered to HTML (expensive), then cached at L1 and L2. Subsequent page loads serve pre-rendered HTML in sub-millisecond time.

  • DD SourceRegistry: Source configurations for the due diligence pipeline are cached for instant dispatch. When a DD case triggers an investigation, source configs are read from L1 without touching the database.

  • API Endpoint Registry: Auto-discovered REST API endpoints are cached in ETS for request routing. The /api/v1/ prefix router resolves endpoints from cache, avoiding repeated module introspection.

  • Meilisearch Query Results: Search results from Meilisearch are cached for 5-15 minutes to reduce load on the search engine for repeated or popular queries.

  • Dialyzer PLT: The Persistent Lookup Table is cached at priv/plts/dialyzer.plt -- a file-level cache that persists across compilation cycles.

  • ASN Lookups: Network intelligence (IP-to-ASN mappings) cached for 24-72 hours to minimize external API calls during OSINT investigations.

Code Examples

Basic ETS TTL Cache

defmodule PrismaticCache.TTLCache do
  @moduledoc """
  ETS-backed cache with TTL expiration for the Prismatic Platform.

  GenServer manages TTL enforcement; reads bypass directly to ETS.
  This pattern provides sub-microsecond reads with serialized writes.

  ## Performance

  - Read: O(1) via ETS direct lookup, no GenServer bottleneck
  - Write: O(1) via GenServer serialization (prevents race conditions)
  - Cleanup: O(n) periodic sweep of expired entries
  """

  use GenServer

  @table :ttl_cache
  @default_ttl :timer.minutes(15)
  @cleanup_interval :timer.minutes(1)

  @doc """
  Retrieve a value from the cache.

  Returns `{:ok, value}` on cache hit, `{:error, :not_found}` on miss.
  Expired entries are lazily deleted on access.

  ## Examples

      iex> PrismaticCache.TTLCache.put(:my_key, "hello", 60_000)
      :ok
      iex> PrismaticCache.TTLCache.get(:my_key)
      {:ok, "hello"}
  """
  @spec get(term()) :: {:ok, term()} | {:error, :not_found}
  def get(key) do
    case :ets.lookup(@table, key) do
      [{^key, value, expires_at}] ->
        if System.monotonic_time(:millisecond) < expires_at do
          {:ok, value}
        else
          :ets.delete(@table, key)
          {:error, :not_found}
        end

      [] ->
        {:error, :not_found}
    end
  end

  @doc """
  Store a value in the cache with an optional TTL.

  ## Parameters

    - `key` - The cache key (any term)
    - `value` - The value to cache
    - `ttl_ms` - Time-to-live in milliseconds (default: 15 minutes)

  ## Examples

      iex> PrismaticCache.TTLCache.put(:api_result, %{data: [1, 2, 3]})
      :ok
  """
  @spec put(term(), term(), non_neg_integer()) :: :ok
  def put(key, value, ttl_ms \\ @default_ttl) do
    GenServer.call(__MODULE__, {:put, key, value, ttl_ms})
  end

  @doc "Remove a key from the cache immediately."
  @spec invalidate(term()) :: :ok
  def invalidate(key) do
    :ets.delete(@table, key)
    :ok
  end

  @impl GenServer
  def init(_opts) do
    table = :ets.new(@table, [:set, :named_table, :public, read_concurrency: true])
    schedule_cleanup()
    {:ok, %{table: table}}
  end

  @impl GenServer
  def handle_call({:put, key, value, ttl_ms}, _from, state) do
    expires_at = System.monotonic_time(:millisecond) + ttl_ms
    :ets.insert(@table, {key, value, expires_at})
    {:reply, :ok, state}
  end

  @impl GenServer
  def handle_info(:cleanup, state) do
    now = System.monotonic_time(:millisecond)

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

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

    :telemetry.execute(
      [:prismatic, :cache, :cleanup],
      %{expired_count: Enum.count(expired)},
      %{}
    )

    schedule_cleanup()
    {:noreply, state}
  end

  defp schedule_cleanup, do: Process.send_after(self(), :cleanup, @cleanup_interval)
end

Cachex-Based Domain Cache

defmodule PrismaticWeb.Cache.GlossaryCache do
  @moduledoc """
  Domain-specific cache for glossary term content and metadata.

  Uses Cachex as the backing store with TTL, statistics, and
  warm-on-startup for frequently accessed terms.
  """

  @cache_name :glossary_cache
  @default_ttl :timer.hours(1)

  @doc """
  Fetch a glossary term by slug, using cache with fallback to disk.

  ## Examples

      iex> PrismaticWeb.Cache.GlossaryCache.get_term("cache")
      {:ok, %{title: "Cache", content: "...", ...}}
  """
  @spec get_term(String.t()) :: {:ok, map()} | {:error, :not_found}
  def get_term(slug) do
    Cachex.fetch(@cache_name, {:term, slug}, fn _key ->
      case load_term_from_disk(slug) do
        {:ok, term} -> {:commit, term}
        error -> {:ignore, error}
      end
    end)
  end

  @doc "Invalidate a single term and broadcast the update."
  @spec invalidate_term(String.t()) :: :ok
  def invalidate_term(slug) do
    Cachex.del(@cache_name, {:term, slug})
    Phoenix.PubSub.broadcast(Prismatic.PubSub, "glossary:updates", {:glossary_updated, slug})
    :ok
  end

  @doc "Return cache statistics: hit count, miss count, size."
  @spec stats() :: {:ok, map()} | {:error, term()}
  def stats do
    Cachex.stats(@cache_name)
  end

  defp load_term_from_disk(slug) do
    path = Path.join(glossary_dir(), "#{slug}.md")

    if File.exists?(path) do
      {:ok, content} = File.read(path)
      {:ok, parse_glossary_content(content)}
    else
      {:error, :not_found}
    end
  end

  defp glossary_dir do
    Application.app_dir(:prismatic_web, "priv/content/glossary")
  end

  defp parse_glossary_content(content) do
    # Parse TOML frontmatter + markdown body
    %{raw: content, parsed_at: DateTime.utc_now()}
  end
end

Best Practices

  1. Use ETS for read-heavy workloads: ETS concurrent reads bypass GenServer serialization, providing orders-of-magnitude better throughput. Always set read_concurrency: true on cache tables.

  2. Always have an eviction strategy: Unbounded caches consume unbounded memory. Implement TTL, LRU, or size-based eviction. The HierarchicalCache defaults to 10,000 entries in L1 with periodic cleanup.

  3. Monitor hit rates via Telemetry: A cache with low hit rates provides cost without benefit. Track [:prismatic, :cache, :lookup] events and alert when hit rates drop below 80%.

  4. Handle cache misses gracefully: Always implement fallback to the primary data source on cache miss. The HierarchicalCache's 3-level fallback ensures transparent resolution.

  5. Use negative caching for missing keys: Cache the absence of data to prevent thundering herd on non-existent keys. Default negative TTL of 5 minutes prevents repeated failures.

  6. Prefer get_or_compute over manual check-then-set: The HierarchicalCache's get_or_compute/4 eliminates race conditions between cache check and population.

  7. Use PubSub for cross-process invalidation: When data changes, broadcast invalidation events rather than relying solely on TTL expiration. This ensures all caches converge to fresh data quickly.

  8. Warm caches on startup: Use Cachex warmers or application boot hooks to pre-populate caches for data that will definitely be accessed (e.g., OSINT tool configs, glossary term index).

  9. Instrument everything: Emit telemetry events for hits, misses, evictions, and errors. The [:prismatic, :cache, :cleanup] event tracks expired entry counts for capacity planning.

  10. Size your caches based on working set: Monitor which keys are actually accessed and size your L1/L2 limits to hold the hot working set. Over-sizing wastes memory; under-sizing causes excessive eviction.

Common Mistakes

MistakeProblemCorrect Approach
No TTL on cached dataStale data served indefinitelyAlways set explicit TTL; default 15-60 min
Using GenServer state as cacheAll reads serialized through mailboxUse ETS with read_concurrency: true
Unbounded cache sizeMemory exhaustion under loadSet explicit size limits with eviction policy
Cache stampede on cold startAll processes hit external source simultaneouslyUse Cachex warmers or boot-time preloading
Caching errors without TTLPermanent negative caching blocks recoveryUse short TTL (5 min) for negative cache entries
length(list) on cached collectionsO(n) traversal on every checkUse Enum.count/1 or cache the count separately
String.to_atom/1 for cache keysAtom table exhaustionUse string keys or String.to_existing_atom/1
No telemetry on cache operationsInvisible performance degradationEmit :telemetry.execute/3 on every hit/miss
Ignoring cache in test environmentTests pass but production is slowTest cache behavior explicitly; use :test TTL
Manual cache management everywhereInconsistent patterns, missed invalidationsUse HierarchicalCache as single entry point
  • ETS -- Erlang Term Storage, the foundation of L1 caching
  • Cachex -- Feature-rich Elixir caching library used for L2
  • Meilisearch -- Full-text search engine with cached query results
  • GenServer -- OTP server pattern managing cache lifecycle
  • Telemetry -- Observability framework for cache metrics
  • Cache Eviction -- Strategies for removing cached data
  • PubSub -- Event broadcasting for cache invalidation
  • Memory -- Memory management considerations for cache sizing
  • Connection Pool -- Pooled connections complementing cache
  • Consistency -- Cache coherence challenges in distributed systems
  • Benchmark -- Cache performance measurement and validation
  • Batch Processing -- Batch cache population strategies
  • Query -- Database queries that benefit from caching
  • Retention -- Data retention policies affecting cache lifetime
  • Configuration -- Cache configuration management

See Also


Connect & Contribute

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