Shared State

concurrency

Shared State

Definition & Overview

Shared state refers to data that is accessible and potentially modifiable by multiple concurrent processes. In traditional multi-threaded programming, shared state is the primary source of race conditions, deadlocks, and data corruption. The BEAM virtual machine takes a fundamentally different approach: processes share nothing by default, communicating exclusively through message passing. When shared state is genuinely needed, Elixir provides controlled mechanisms -- ETS tables, GenServers, Agents, and distributed stores -- that make concurrent access explicit, auditable, and safe.

The distinction between accidental and intentional shared state is critical. Accidental shared state (global variables, mutable singletons) creates hidden coupling and unpredictable behavior. Intentional shared state, mediated through well-defined OTP abstractions, provides the coordination primitives that distributed systems require. The Prismatic Platform embraces this distinction rigorously: every piece of shared state is owned by a specific process or ETS table with documented access patterns and concurrency guarantees.

In the platform's architecture, shared state appears in three primary forms: ETS tables for high-throughput read-heavy workloads (tool registries, session stores, configuration caches), GenServers for stateful coordination with serialized writes (schedulers, health monitors, progress trackers), and distributed stores (Redis, PostgreSQL) for state that must survive node restarts or span cluster boundaries.

Technical Deep Dive

ETS: Lock-Free Concurrent Reads

ETS (Erlang Term Storage) provides the highest-performance shared state mechanism on the BEAM. With read_concurrency: true, multiple processes can read from the same ETS table simultaneously without any locking overhead:

defmodule PrismaticOsintCore.ToolRegistry do
  @moduledoc """
  ETS-backed registry providing sub-microsecond lookups
  for 127 self-registered OSINT tools.
  """

  use GenServer

  @table :osint_tool_registry

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

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

  @spec lookup(String.t()) :: {:ok, map()} | {:error, :not_found}
  def lookup(slug) do
    case :ets.lookup(@table, slug) do
      [{^slug, config}] -> {:ok, config}
      [] -> {:error, :not_found}
    end
  end

  @spec register(map()) :: :ok
  def register(tool_config) do
    GenServer.call(__MODULE__, {:register, tool_config})
  end

  @impl true
  def handle_call({:register, config}, _from, state) do
    :ets.insert(@table, {config.slug, config})
    {:reply, :ok, state}
  end
end

This pattern -- GenServer owns the table, writes are serialized through GenServer calls, reads bypass the GenServer entirely -- is the canonical shared state pattern on the BEAM. It achieves microsecond read latencies under heavy concurrent load because readers never contend with each other or with the writer.

GenServer: Serialized State Management

When shared state requires coordinated mutations (read-modify-write cycles), GenServer serialization provides correctness guarantees:

defmodule PrismaticDd.Scheduler do
  @moduledoc """
  Manages shared scheduling state for DD pipeline sources.
  All state mutations are serialized through GenServer calls.
  """

  use GenServer

  defstruct sources: %{}, paused: false, last_run: %{}

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  def trigger(group) do
    GenServer.call(__MODULE__, {:trigger, group})
  end

  def pause do
    GenServer.call(__MODULE__, :pause)
  end

  def status do
    GenServer.call(__MODULE__, :status)
  end

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

  @impl true
  def handle_call({:trigger, group}, _from, %{paused: true} = state) do
    {:reply, {:error, :paused}, state}
  end

  def handle_call({:trigger, group}, _from, state) do
    now = DateTime.utc_now()
    new_state = put_in(state.last_run[group], now)
    {:reply, {:ok, now}, new_state}
  end

  def handle_call(:pause, _from, state) do
    {:reply, :ok, %{state | paused: true}}
  end

  def handle_call(:status, _from, state) do
    {:reply, {:ok, Map.from_struct(state)}, state}
  end
end

Process Dictionary: Per-Process Private State

The process dictionary provides per-process state that is never shared. It is useful for storing context that should flow through a call stack without being passed as function arguments:

defmodule PrismaticWeb.RequestContext do
  @moduledoc """
  Stores request-scoped context in the process dictionary.
  This is NOT shared state -- each request process has its own copy.
  """

  def put_current_user(user) do
    Process.put(:current_user, user)
  end

  def current_user do
    Process.get(:current_user)
  end
end

Architecture & Implementation

The Prismatic Platform enforces a strict hierarchy for shared state decisions. The rule is: use the simplest mechanism that satisfies the concurrency requirements.

MechanismConcurrency ModelBest ForLatency
Process dictionaryNo sharing (private)Request contextNanoseconds
AgentSimple get/updateCounters, flagsMicroseconds
GenServerSerialized read/writeCoordinated mutationsMicroseconds
ETS (public)Concurrent reads, serialized writesRegistries, cachesSub-microsecond
ETS (protected)Concurrent reads, owner writesConfigurationSub-microsecond
RedisCross-node, persistentDistributed locks, pub/subMilliseconds
PostgreSQLACID transactionsDurable state, audit trailsMilliseconds

The platform's supervision tree ensures that every shared state holder is supervised. If an ETS-owning GenServer crashes, its supervisor restarts it, and the @after_compile hooks in tools, topics, and sources automatically re-register their data. This self-healing property means that shared state loss from process crashes is always recoverable.

A critical anti-pattern the platform avoids is the "God GenServer" -- a single process that holds too much state and becomes a serialization bottleneck. Instead, state is partitioned across domain-specific GenServers (ToolRegistry, TopicRegistry, SourceRegistry), each managing only its own domain.

Usage in Prismatic Platform

The platform uses shared state extensively across its three self-registering subsystems (OSINT, Academy, DD). Each uses the same pattern: a GenServer owns an ETS table, modules register themselves via @after_compile hooks, and runtime lookups bypass the GenServer for maximum throughput.

defmodule PrismaticAcademy.TopicRegistry do
  @moduledoc """
  Shared state for 4 self-registered academy topics.
  Three ETS tables: topics, interconnections, search_index.
  """

  use GenServer

  @topics_table :academy_topics
  @interconnections_table :academy_interconnections
  @search_table :academy_search_index

  def all_topics do
    :ets.tab2list(@topics_table)
    |> Enum.map(fn {_slug, config} -> config end)
  end

  def search(query) do
    pattern = String.downcase(query)
    :ets.foldl(fn {term, data}, acc ->
      if String.contains?(String.downcase(term), pattern) do
        [data | acc]
      else
        acc
      end
    end, [], @search_table)
  end
end

The platform's health monitoring also relies on shared state. The QualityFloorGuardian GenServer maintains quality metrics that are read by the pre-commit hook system and the LiveView dashboard simultaneously, with ETS providing the read path for dashboard rendering.

Cross-References

  • ETS - Primary shared state mechanism for read-heavy workloads
  • GenServer - OTP abstraction for serialized state management
  • Concurrency - Broader context for multi-process coordination
  • Process - BEAM lightweight process that owns state
  • Distributed Systems - Cross-node shared state challenges

Created by Tomas Korcak (korczis) | GitHub