DpExchange.Core.Config (DpExchangeCore v0.3.28)

Copy Markdown View Source

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

  1. Process.get/1 in the calling process — the override a test set for itself.
  2. The $callers ancestor chain. ExUnit propagates :"$callers" to spawned Tasks, 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.
  3. 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 messagesnapshot/1 on the way in, resolve_snapshot/4 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)

opt/3 — the same problem, one layer down

This module resolves seams — where consumer configuration meets Core. opt/3 fixes a related but distinct trap that lives in keyword() options passed on individual calls, not in application config: Keyword.get/3 only substitutes its default for an ABSENT key, never for a key that is present and nil.

That distinction is invisible reading the code and is reachable on every call, because every venue package in this family forwards its own opts unchanged, by convention, through several layers — a Feed passes its opts to PollingFeed.start_link/1, which never itself set interval_ms. When the venue's own caller never configured a key either, it does not vanish; it arrives as key: nil, explicit and present, because something upstream did Keyword.get(callers_opts, :interval_ms) with no default and handed the nil straight through.

Keyword.get(opts, :interval_ms, 30_000) against interval_ms: nil returns nil, not 30_000 — the default silently does not apply. This was fixed once, by hand, at exactly one call site (PollingFeed's :start_delay_ms, with an incident recorded in its own comment) and left open everywhere else that mattered: PollingFeed's :interval_ms crashed Process.send_after/3 outright; HttpClient's :retry_attempts was worse — a nil compares as greater than any integer in Erlang term ordering, so it silently entered the retry branch and then died computing 4 - nil, killing the calling venue process, which this library does not supervise.

opt/3 is Keyword.get/3 with that one difference: a present-and-nil value is treated the same as an absent one. It does not use ||, because || is falsy on false too — an explicit log_requests: false or raw_status: false must survive, and a helper that silently overrode a real false back to its default would be the same bug in the other direction.

Summary

Types

The application key a seam's global default lives under.

The name of the seam.

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.

Reads key from a keyword() options list, treating an explicit nil value the same as an absent key.

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

app()

@type app() :: atom()

The application key a seam's global default lives under.

key()

@type key() :: atom()

The name of the seam.

Functions

delete_override(key)

@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.

find_override(key)

@spec find_override(key()) :: {:ok, term()} | :none

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.

get(app, key, default \\ nil)

@spec get(app(), key(), term()) :: term()

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

opt(opts, key, default)

@spec opt(keyword(), atom(), term()) :: term()

Reads key from a keyword() options list, treating an explicit nil value the same as an absent key.

Unlike Keyword.get/3, opt(opts, :interval_ms, 30_000) returns 30_000 whether :interval_ms is missing from opts OR present as interval_ms: nil — the shape a venue's forwarded opts produces when nothing upstream ever set it. See this module's opt/3 section for why that distinction matters and why this does not use ||.

Examples

iex> DpExchange.Core.Config.opt([], :interval_ms, 30_000)
30_000

iex> DpExchange.Core.Config.opt([interval_ms: nil], :interval_ms, 30_000)
30_000

iex> DpExchange.Core.Config.opt([interval_ms: 5_000], :interval_ms, 30_000)
5_000

iex> DpExchange.Core.Config.opt([log_requests: false], :log_requests, true)
false

put_override(key, value)

@spec put_override(key(), term()) :: :ok

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.

resolve_snapshot(snapshot, app, key, default \\ nil)

@spec resolve_snapshot(%{optional(key()) => term()}, app(), key(), term()) :: term()

Resolves key inside a process that received a snapshot/1.

The snapshot wins; otherwise this falls back to app's application env, exactly as get/3 does — app is a required argument for that reason: this used to hardcode :dp_exchange_core regardless of what a caller passed, so a venue package snapshotting one of ITS OWN seams (as dp_exchange_schwab's poller does with DpExchange.Core.Config.snapshot/1) and resolving it inside its own GenServer would have had this function consult Core's application env instead of its own — silently never finding a value the venue's own consumer configured, no matter how it was set. Found with no live caller yet: every known consumer reapplies a snapshot with put_override/2 in a loop rather than calling this, which is why the mismatch between "falls back exactly as get/3 does" and a hardcoded app went unnoticed. Fixed before a first caller could inherit it.

This 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}, :dp_exchange_core, :seam, :fallback)
:from_caller

iex> DpExchange.Core.Config.resolve_snapshot(%{}, :dp_exchange_core, :never_set_anywhere, :fallback)
:fallback

snapshot(keys)

@spec snapshot([key()]) :: %{optional(key()) => term()}

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/4.

Examples

iex> DpExchange.Core.Config.put_override(:carried, 42)
iex> DpExchange.Core.Config.snapshot([:carried, :absent])
%{carried: 42}