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:
- self –
{self(), key}in the table. - allow chain –
{:allow, child}→owner, then recurse onowner. Used when a long-lived process (a GenServer the test didn't spawn) must read the test's overrides. $callers– the implicit caller chain Erlang attaches to Task/Agent spawns. The first ancestor that owns an override wins. No code change needed for plainTask.asynccallers.
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() != :prodAlways 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 runin:prod, unlike a release, does not perform that check.) - It resolves per
_buildenv, so a release built withMIX_ENV=testwould carry the machinery. Build releases withMIX_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
Functions
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.
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.
@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.
@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
endBranching on it at runtime is harmless but pointless: the value is fixed when Ambient is compiled, so one arm is simply dead code.
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.
@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.
Report whether table is process-scoped or globally shared.
Returns :private for an unknown table, so it is safe to call anywhere.
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.
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.
@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.