Resolves a configurable seam, per process rather than per node.
Every seam in this family that a consumer's tests may need to vary goes through here — which fake is in play, what that fake returns, which rate limiter is active, anything added later. There is one resolver so that five venue packages cannot each invent a variant of it.
Why this is not Application.get_env/3
Application.put_env/3 is node-wide. A consumer running async: true — which is
every well-built Elixir suite — has many tests on one node at once, so a test that
configures a seam globally configures it for every other test running beside it.
That is not hypothetical. In the application these packages were extracted from, seven
tests set a global rate-limiting flag for their duration, and for that duration every
other async test on the node was suddenly metered against a one-request bucket. An
unrelated WebSocket test came back {:error, {:rate_limited, 1}} while asserting on
connection errors. The failure was silent, intermittent and seed-dependent — the worst
combination, and it cost a day to find.
Lookup order
Process.get/1in the calling process — the override a test set for itself.- The
$callersancestor chain. ExUnit propagates:"$callers"to spawnedTasks, so a task launched inside a test finds the override the test set without the test threading it through every closure. This is the step that gets omitted, and omitting it makes the seam work in simple tests and fail in exactly the concurrent ones it exists for. Application.get_env/3— the global default. This is what production reads and what a consumer configures normally; the process-scoped steps are a test affordance layered above it, not a replacement.
Crossing a process boundary
A GenServer runs in its own process and will not find the caller's dictionary at all,
because it is not in the caller's $callers chain. Resolve in the caller and put the
answer in the message — snapshot/2 on the way in, resolve_snapshot/3 on the way
out. Resolving inside the server is too late, and it fails in the direction that looks
like it works: production is unaffected, so only the consumer's async suite breaks.
Examples
# Production: reads application env, as normal.
Config.get(:dp_exchange_core, :rate_limit_module, DefaultRateLimiter)
# A test, for its own process tree only:
Config.put_override(:rate_limit_module, AlwaysRateLimited)
Summary
Functions
Removes a process-scoped override set by put_override/2.
Finds the process-scoped override for key without falling back to application env.
Resolves key, preferring a process-scoped override and falling back to app's
application environment.
Sets a process-scoped override for key, visible to this process and anything it
spawns that carries $callers.
Resolves key inside a process that received a snapshot/1.
Captures the caller's overrides for keys so they can be carried across a process
boundary in a message.
Types
Functions
@spec delete_override(key()) :: :ok
Removes a process-scoped override set by put_override/2.
Only clears the calling process's own override; an ancestor's is untouched, because a child must not be able to reconfigure the test that spawned it.
Finds the process-scoped override for key without falling back to application env.
Returns {:ok, value} or :none. The two are distinguished deliberately: an override
whose value is nil is an override, and collapsing it into "not set" would make nil
unconfigurable.
Resolves key, preferring a process-scoped override and falling back to app's
application environment.
Examples
iex> DpExchange.Core.Config.get(:dp_exchange_core, :nothing_configured, :fallback)
:fallback
iex> DpExchange.Core.Config.put_override(:some_seam, :overridden)
iex> DpExchange.Core.Config.get(:dp_exchange_core, :some_seam, :fallback)
:overridden
Sets a process-scoped override for key, visible to this process and anything it
spawns that carries $callers.
Scoped to the calling process, so it never reaches a test running beside this one.
Resolves key inside a process that received a snapshot/1.
The snapshot wins; otherwise this falls back to application env exactly as get/3
does. It deliberately does not consult the server's own process dictionary: a
long-lived server's dictionary is not scoped to any one caller, so honouring it would
leak one caller's configuration into another's request.
Examples
iex> DpExchange.Core.Config.resolve_snapshot(%{seam: :from_caller}, :seam, :fallback)
:from_caller
iex> DpExchange.Core.Config.resolve_snapshot(%{}, :dp_exchange_core, :fallback)
:fallback
Captures the caller's overrides for keys so they can be carried across a process
boundary in a message.
Call this in the process that has the override — the caller — and pass the result into
the GenServer.call/3 or cast/2. The server then resolves with
resolve_snapshot/3.
Examples
iex> DpExchange.Core.Config.put_override(:carried, 42)
iex> DpExchange.Core.Config.snapshot([:carried, :absent])
%{carried: 42}