Ambient.ProcessOverride (Ambient v0.1.0)

Copy Markdown View Source

ETS-backed process-local override store with cross-process inheritance – the shared engine behind Ambient.Clock, Ambient.Random, Ambient.Env and Ambient.Config, and behind any value module you build with Ambient.Value.

Why

Tests need to override values (config, the clock, a random seed) for the duration of a single test process without polluting concurrent peers. The naive approach, Process.put/2, breaks the moment work crosses a process boundary (Task.async, GenServer.cast, an Oban worker spawned inline). Both Phoenix's ConnTest and Ecto's SQL sandbox solve this with an explicit "allow" mechanism plus the $callers chain. This module is the equivalent for arbitrary key/value overrides.

Lookup chain

When the calling process reads a value, the resolver walks:

  1. self{self(), key} in the table.
  2. allow chain{:allow, child}owner, then recurse on owner. Used when a long-lived process (a GenServer the test didn't spawn) must read the test's overrides.
  3. $callers – the implicit caller chain Erlang attaches to Task/Agent spawns. The first ancestor that owns an override wins. No code change needed for plain Task.async callers.

Modes

A table is private by default: each process resolves its own value through the chain above, which is what makes async: true safe.

set_shared/2 switches a table to shared mode, where one owner's overrides are what every process reads, however it was spawned – the equivalent of Ecto.Adapters.SQL.Sandbox's shared mode or Mox.set_mox_global/0, and subject to the same rule: async: false only. While shared, only the owner may write (put/3 from anyone else raises {:not_shared_owner, pid}) and allow/3 is refused. The owner is monitored, so its exit returns the table to private on its own.

The mode lives in the table as a :mode row rather than in the Server, so a read stays a plain ETS lookup with no message round-trip.

Cleanup

Each Ambient.ProcessOverride.Server instance owns one ETS table and monitors every PID that put a value. When a monitored PID exits, its rows and any {:allow, …} rows pointing at it are cleared. No leaks across tests.

The compile-time switch

The whole override machinery is gated on one compile-time flag:

# config/config.exs
config :ambient, enable_overrides: config_env() != :prod

Always derive it from config_env/0. Hard-coding true would put the machinery in your release, which is the one way to defeat everything below. Compiling this library with the flag on under MIX_ENV=prod emits a warning for exactly that reason.

It defaults to false. In a build that didn't opt in, no Ambient API can produce an override: put/3 and allow/3 raise; Ambient.start_servers/1, Server.start_link/1 and Server.init/1 refuse to create the ETS table; and Ambient.Random's seeded code paths aren't compiled at all. No seeds script, remote console, $callers chain or allow/3 grant re-opens them.

What it is not: fetch/2 keeps its ETS lookup in disabled builds (see its docs for why), so code that hand-rolls :ets.new(:ambient_clock_overrides, [:named_table, :public]) and inserts a row is visible to anything reading through fetch/2 directly – including mode/1 and the built-ins' overridden?/1. It is not visible to the built-ins' actual reads: Ambient.Value's get_or/2 compiles the lookup away, so Clock.utc_now/0 and a generated config get/2 ignore such a row entirely. Forging one takes arbitrary code execution inside the node anyway. That is what makes bytes/1 safe for credential material in production while staying deterministic under Ambient.Random.seed/1 in tests.

Two more properties worth knowing:

  • Mix records the value in the app manifest, so a release whose runtime config disagrees aborts at boot rather than drifting. (A mix run in :prod, unlike a release, does not perform that check.)
  • It resolves per _build env, so a release built with MIX_ENV=test would carry the machinery. Build releases with MIX_ENV=prod.

Check the current build with enabled?/0.

API

All functions take the ETS table atom – each consumer module owns the naming so two domains can't collide. Convention: :ambient_<domain>_overrides.

Ambient.ProcessOverride.put(:ambient_clock_overrides, :clock, ~U[2026-01-01 00:00:00Z])
Ambient.ProcessOverride.fetch(:ambient_clock_overrides, :clock)
Ambient.ProcessOverride.allow(:ambient_clock_overrides, worker_pid)
Ambient.ProcessOverride.delete(:ambient_clock_overrides, :clock)

Summary

Functions

Authorise child_pid to inherit overrides from owner_pid.

Remove the current process's override for key. No-op if absent, if the table doesn't exist, or if overrides aren't compiled in – teardown helpers stay safe to call unconditionally.

Remove every override the calling process owns in table. Same no-op guarantees as delete/2 – safe on an unknown table or a disabled build.

Whether the override machinery was compiled into this build.

Fetch the override in effect for the calling process.

Atomically read the value for key, run fun over it, and store the result. Returns {:ok, value} where value is fun's first element, or :error if no override is in scope.

Report whether table is process-scoped or globally shared.

Store a process-local override. The key lets one table host multiple keys per owner (e.g. config). Pass a sentinel like :clock for single-value-per-owner tables.

Compute the registered name of the Server instance that owns table. Public so the Server can register itself under the same name put/3 calls.

Return table to private (process-scoped) mode. Idempotent, and a no-op if the table was never shared. Existing overrides are left alone – they simply resolve per process again.

Switch table to shared mode: owner_pid's overrides become the ones every process reads, no matter how it was spawned.

Types

key()

@type key() :: term()

table()

@type table() :: atom()

value()

@type value() :: term()

Functions

allow(table, child_pid, owner_pid \\ self())

@spec allow(table(), pid(), pid()) :: :ok

Authorise child_pid to inherit overrides from owner_pid.

Use this for long-lived processes (GenServers, Oban workers, Tasks spawned outside the $callers chain) that need to read the test's overrides. Mirrors Ecto.Adapters.SQL.Sandbox.allow/3.

delete(table, key)

@spec delete(table(), key()) :: :ok

Remove the current process's override for key. No-op if absent, if the table doesn't exist, or if overrides aren't compiled in – teardown helpers stay safe to call unconditionally.

delete_all(table)

@spec delete_all(table()) :: :ok

Remove every override the calling process owns in table. Same no-op guarantees as delete/2 – safe on an unknown table or a disabled build.

Does not touch allow grants or the table's mode; use set_private/1 for the latter.

enabled?()

@spec enabled?() :: boolean()

Whether the override machinery was compiled into this build.

false unless the consuming app opted the env in. When false, put/3, allow/3, Server.start_link/1 and Server.init/1 all refuse, so no Ambient API can create a table or an override – see the moduledoc for the one thing that is still possible, and why.

Intended for compile-time branching – put it in a module body:

if Ambient.ProcessOverride.enabled?() do
  def helper, do: :test_only
else
  def helper, do: :real
end

Branching on it at runtime is harmless but pointless: the value is fixed when Ambient is compiled, so one arm is simply dead code.

fetch(table, key)

@spec fetch(table(), key()) :: {:ok, value()} | :error

Fetch the override in effect for the calling process.

In private mode (the default) that means the lookup chain self → allow → $callers; in shared mode it is always the shared owner's value, whoever asks. Returns :error if no override is in effect – including when the table doesn't exist, which is every build that didn't opt in, since nothing there can create one.

Deliberately not compiled away in disabled builds: a clause hard-wired to :error makes every caller's {:ok, _} branch provably dead, and the compiler reports those as warnings in consuming apps. The cost of keeping it is a single :ets.whereis/1.

get_and_update(table, key, fun)

@spec get_and_update(table(), key(), (value() -> {result, value()})) ::
  {:ok, result} | :error
when result: term()

Atomically read the value for key, run fun over it, and store the result. Returns {:ok, value} where value is fun's first element, or :error if no override is in scope.

For values whose reads write: Ambient.Random advances its seed state on every draw. A plain fetch/2 then put/3 is fine in private mode, where each process owns its own row, but in shared mode every process is reading and writing the same row – so two concurrent draws read the same state, compute the same number and overwrite each other. Measured before this existed: 99 lost updates in 200 concurrent draws, i.e. half the callers got a duplicate.

In shared mode the whole read-modify-write therefore happens inside the Server, which serialises it. Private mode stays client-side, since a process can't race itself.

Note what this does not buy: which concurrent caller gets which value still depends on scheduling. One advancing stream and reproducible ordering are mutually exclusive under concurrency – see Ambient.Random.

mode(table)

@spec mode(table()) :: :private | {:shared, pid()}

Report whether table is process-scoped or globally shared.

Returns :private for an unknown table, so it is safe to call anywhere.

put(table, key, value)

@spec put(table(), key(), value()) :: :ok

Store a process-local override. The key lets one table host multiple keys per owner (e.g. config). Pass a sentinel like :clock for single-value-per-owner tables.

The current process is monitored – its rows clear automatically on exit.

server_name(table)

@spec server_name(table()) :: atom()

Compute the registered name of the Server instance that owns table. Public so the Server can register itself under the same name put/3 calls.

set_private(table)

@spec set_private(table()) :: :ok

Return table to private (process-scoped) mode. Idempotent, and a no-op if the table was never shared. Existing overrides are left alone – they simply resolve per process again.

Deliberately callable by any process, unlike the other writers: it is the way back to a sane state, and ExUnit's on_exit/1 runs in a different process from the test that took the table shared.

set_shared(table, owner_pid \\ self())

@spec set_shared(table(), pid()) :: :ok

Switch table to shared mode: owner_pid's overrides become the ones every process reads, no matter how it was spawned.

For async: false tests only – it is global state, exactly like Ecto.Adapters.SQL.Sandbox's shared mode or Mox.set_mox_global/0. A concurrent test would see the shared owner's clock.

While shared, only owner_pid may write to the table (put/3 from anyone else raises {:not_shared_owner, pid}) and allow/3 is refused – every process already reads the owner's values, so there is nothing to grant. get_and_update/3 is the exception, so read-modify-write modules like Ambient.Random keep working – atomically – from every process.

Handing over to a different owner is the current owner's call: once shared, set_shared/2 from anyone else raises {:not_shared_owner, pid} rather than silently stealing the table.

The owner is monitored: if it exits, the table drops back to private mode on its own, so a crashed test can't leave the suite globally overridden.