Definition & Overview

Time-To-Live (TTL) is a mechanism that assigns a maximum lifespan to data, after which the data is considered stale and is either automatically removed or refreshed from its authoritative source. TTL is fundamental to caching systems, DNS resolution, network protocols, and any system where data freshness must be balanced against the cost of retrieving or recomputing it.

In caching, TTL prevents serving indefinitely stale data. When data is cached with a TTL of 300 seconds, any request arriving after 300 seconds will trigger a cache miss, forcing a fresh fetch from the origin. This simple mechanism provides eventual consistency without requiring complex invalidation protocols. The trade-off is straightforward: shorter TTLs ensure fresher data at the cost of more origin requests, while longer TTLs reduce origin load at the cost of serving potentially outdated information.

The Prismatic Platform uses TTL extensively across its multi-tier caching architecture. ETS-cached OSINT tool configurations have long TTLs (hours) since they rarely change. API response caches use moderate TTLs (minutes) to balance freshness with performance. DNS-based service discovery uses short TTLs (seconds) to react quickly to infrastructure changes. Each TTL value is carefully tuned to the data's volatility and the cost of staleness.

Technical Deep Dive

The platform implements TTL-aware caching in ETS with automatic expiry via periodic sweep:

defmodule PrismaticCache.TTLStore do
  @moduledoc """
  ETS-backed cache with TTL support. Entries automatically
  expire after their configured TTL via periodic sweep.
  """

  use GenServer

  @sweep_interval_ms 10_000
  @default_ttl_ms 300_000

  defstruct [:table, :sweep_ref]

  def start_link(opts) do
    name = Keyword.fetch!(opts, :name)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @impl true
  def init(opts) do
    table_name = Keyword.fetch!(opts, :table)

    table = :ets.new(table_name, [
      :set, :named_table, :public,
      read_concurrency: true,
      write_concurrency: true
    ])

    sweep_ref = schedule_sweep()
    {:ok, %__MODULE__{table: table, sweep_ref: sweep_ref}}
  end

  @spec put(atom(), term(), term(), pos_integer()) :: :ok
  def put(table, key, value, ttl_ms \\ @default_ttl_ms) do
    expires_at = System.monotonic_time(:millisecond) + ttl_ms
    :ets.insert(table, {key, value, expires_at})
    :ok
  end

  @spec get(atom(), term()) :: {:ok, term()} | :miss
  def get(table, key) do
    now = System.monotonic_time(:millisecond)

    case :ets.lookup(table, key) do
      [{^key, value, expires_at}] when expires_at > now ->
        {:ok, value}

      [{^key, _value, _expired}] ->
        :ets.delete(table, key)
        :miss

      [] ->
        :miss
    end
  end

  @spec get_or_fetch(atom(), term(), pos_integer(), (() -> {:ok, term()})) :: {:ok, term()}
  def get_or_fetch(table, key, ttl_ms, fetch_fn) do
    case get(table, key) do
      {:ok, value} ->
        {:ok, value}

      :miss ->
        case fetch_fn.() do
          {:ok, value} ->
            put(table, key, value, ttl_ms)
            {:ok, value}

          error ->
            error
        end
    end
  end

  @impl true
  def handle_info(:sweep, %{table: table} = state) do
    now = System.monotonic_time(:millisecond)
    expired_count = sweep_expired(table, now)

    if expired_count > 0 do
      :telemetry.execute(
        [:prismatic, :cache, :ttl, :sweep],
        %{expired_count: expired_count},
        %{table: table}
      )
    end

    sweep_ref = schedule_sweep()
    {:noreply, %{state | sweep_ref: sweep_ref}}
  end

  defp sweep_expired(table, now) do
    :ets.foldl(fn
      {key, _value, expires_at}, count when expires_at <= now ->
        :ets.delete(table, key)
        count + 1

      _, count ->
        count
    end, 0, table)
  end

  defp schedule_sweep do
    Process.send_after(self(), :sweep, @sweep_interval_ms)
  end
end

For layered caching with different TTLs per tier:

defmodule PrismaticCache.Layered do
  @moduledoc """
  Multi-tier cache with different TTLs per layer.
  L1 (ETS, short TTL) -> L2 (Redis, medium TTL) -> Origin.
  """

  @l1_ttl_ms 30_000
  @l2_ttl_ms 300_000

  @spec get(String.t()) :: {:ok, term()} | {:error, :not_found}
  def get(key) do
    case PrismaticCache.TTLStore.get(:l1_cache, key) do
      {:ok, value} ->
        {:ok, value}

      :miss ->
        case redis_get(key) do
          {:ok, value} ->
            PrismaticCache.TTLStore.put(:l1_cache, key, value, @l1_ttl_ms)
            {:ok, value}

          :miss ->
            {:error, :not_found}
        end
    end
  end

  @spec put(String.t(), term()) :: :ok
  def put(key, value) do
    PrismaticCache.TTLStore.put(:l1_cache, key, value, @l1_ttl_ms)
    redis_set(key, value, @l2_ttl_ms)
    :ok
  end

  defp redis_get(key) do
    case Redix.command(:prismatic_redis, ["GET", key]) do
      {:ok, nil} -> :miss
      {:ok, value} -> {:ok, Jason.decode!(value)}
      _ -> :miss
    end
  end

  defp redis_set(key, value, ttl_ms) do
    Redix.command(:prismatic_redis, ["SET", key, Jason.encode!(value), "PX", ttl_ms])
  end
end

Architecture & Implementation

TTL management in the platform follows domain-specific policies:

OSINT Tool Registry: Tool configurations loaded from @after_compile hooks are effectively permanent (no TTL) since they only change on redeployment. However, tool execution results are cached with source-specific TTLs. Czech ARES data changes infrequently (TTL: 24 hours), while Shodan scan results change frequently (TTL: 1 hour).

API Gateway: The PrismaticAPI on port 4004 caches endpoint discovery results with a 5-minute TTL. This prevents redundant module introspection while ensuring newly deployed modules are discovered within 5 minutes.

DD Pipeline: Fetch records from the DD Client are cached with group-specific TTLs matching the Scheduler's refresh intervals. Forbes data refreshes every 168 hours, Parliament data every 24 hours. The TTL ensures the cache aligns with the expected data freshness.

Security Ratings: Perimeter security ratings are cached with a 1-hour TTL. Active scans invalidate the cache immediately through explicit deletion rather than waiting for TTL expiry, providing fresh ratings after discovery operations.

Usage in Prismatic Platform

The OSINT toolbox uses TTL-based caching to avoid redundant API calls to external providers:

defmodule PrismaticOsintCore.ResultCache do
  @moduledoc """
  TTL-aware result caching for OSINT tool executions.
  Per-provider TTL configuration respects data volatility.
  """

  @provider_ttls %{
    "ares-lookup" => 86_400_000,
    "shodan-search" => 3_600_000,
    "virustotal-scan" => 1_800_000,
    "companies-house" => 43_200_000
  }

  @default_ttl_ms 3_600_000

  @spec cached_execute(String.t(), map(), (() -> {:ok, map()})) :: {:ok, map()}
  def cached_execute(tool_slug, params, execute_fn) do
    cache_key = build_key(tool_slug, params)
    ttl = Map.get(@provider_ttls, tool_slug, @default_ttl_ms)

    PrismaticCache.TTLStore.get_or_fetch(:osint_results, cache_key, ttl, execute_fn)
  end

  defp build_key(slug, params) do
    hash = :crypto.hash(:sha256, Jason.encode!(params)) |> Base.hex_encode32(case: :lower, padding: false)
    "#{slug}:#{hash}"
  end
end

Cross-References

  • Cache - Storage layer using TTL
  • ETS - In-memory storage with TTL support
  • Write-Through - Cache write pattern
  • Throttling - Rate limiting using TTL windows
  • Warmup - Cache pre-population strategy

Created by Tomas Korcak (korczis) | GitHub