ETS-backed process-local override store with cross-process inheritance –
the shared engine behind Ambient.Clock and Ambient.Config, and behind
any value module you build with Ambient.Value.
Why
Tests need to override values (a config entry, the clock) 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).
Ecto.Adapters.SQL.Sandbox and NimbleOwnership (which backs Mox) both 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 Elixir attaches toTaskspawns. The first ancestor that owns an override wins. No code change needed for plainTask.asynccallers. Note onlyTasksets it: anAgentorGenServerchild has$ancestorsbut no$callers, so it resolves through step 2 or not at all.
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.Value's get_or/2 compiles its lookup away entirely. No setup
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.
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.
Dialyzer
Gate the flag on config_env() != :prod, not == :test. In a build without
overrides the writers raise, so their success typing is none() – and
dialyxir runs in :dev by default, which is exactly the build == :test
leaves without overrides. Ambient's own generated specs say no_return()
there, so the library stays clean either way, but a function of yours that
wraps a writer still can't return:
def enable(flag), do: put_override({:flag, flag}, true)
# warning: Function enable/1 has no local returnMeasured on a small consuming app with one use Ambient.Config and one
use Ambient.Value: zero warnings under != :prod, one under == :test.
If you'd rather keep == :test, move such wrappers behind
if Ambient.ProcessOverride.enabled?(), which compiles them out of the build
that couldn't run them anyway.
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: a counter that hands out the next id, a
budget that decrements. 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 reads see the same
state, compute the same result and overwrite each other. Measured before
this existed: 99 lost updates in 200 concurrent reads, 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.
If fun raises (or returns something other than a two-tuple), the
exception surfaces in the caller in both modes. In shared mode it is
caught inside the Server and re-raised here, because letting it escape
there would take the table owner down with it and void every override in
the table – so the stacktrace is the one captured Server-side. A raise
inside fun keeps its own frame; the MatchError for a malformed return
is reported against the Server. Private mode runs client-side and is
unaffected.
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.
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.