diff --git a/lib/cachex/services/overseer.ex b/lib/cachex/services/overseer.ex index 3cea8de..0790965 100644 --- a/lib/cachex/services/overseer.ex +++ b/lib/cachex/services/overseer.ex @@ -62,14 +62,21 @@ defmodule Cachex.Services.Overseer do Retrieving a cache will map the provided argument to a cache record if available, otherwise a nil value. + + When a name resolver is configured (see `resolve_name/1`), the name is + passed through it first, allowing the caller to be transparently routed + to a different cache instance. This makes per-process redirection (e.g. + test sandboxing) possible without intercepting every cache call. """ @spec lookup(Cachex.t()) :: Cachex.t() | nil def lookup(cache() = cache), do: cache def lookup(name) when is_atom(name) do - case :ets.lookup(@table_name, name) do - [{^name, state}] -> + resolved = resolve_name(name) + + case :ets.lookup(@table_name, resolved) do + [{^resolved, state}] -> state _other -> @@ -80,6 +87,34 @@ defmodule Cachex.Services.Overseer do def lookup(_any), do: nil + @doc """ + Resolves a cache name through the optionally-configured resolver. + + By default this is the identity function: the name is returned + unchanged and there is no measurable overhead. Setting + + config :cachex, :name_resolver, &MyModule.resolve/1 + + installs a `(atom -> atom)` function that is consulted on every cache + name resolution. It must return a cache name (an atom); returning the + same name is a no-op. This is the supported extension point for + redirecting cache resolution per process — for example, a test-isolation + library can return a per-test cache name based on the calling process + (via the process dictionary or `$callers`), giving each async test its + own isolated cache without forking or patching Cachex. + + Resolution is **not** applied recursively: the resolver's result is used + directly as the ETS key, so a resolver must return a concrete name, not + another name that itself needs resolving. + """ + @spec resolve_name(atom) :: atom + def resolve_name(name) when is_atom(name) do + case Application.get_env(:cachex, :name_resolver) do + nil -> name + resolver when is_function(resolver, 1) -> resolver.(name) || name + end + end + @doc """ Registers a cache record against a name. """ diff --git a/test/cachex/services/overseer_test.exs b/test/cachex/services/overseer_test.exs index ba94703..6c1294c 100644 --- a/test/cachex/services/overseer_test.exs +++ b/test/cachex/services/overseer_test.exs @@ -126,4 +126,46 @@ defmodule Cachex.OverseerTest do # now we need to make sure our state was forwarded assert_receive({:cache, ^update2}) end + + # With no resolver configured, resolve_name/1 is the identity function + # and lookup/1 behaves exactly as before. + test "resolve_name/1 returns the name unchanged by default" do + assert(Services.Overseer.resolve_name(:some_cache) == :some_cache) + end + + # A configured resolver redirects name resolution, so lookup/1 returns + # the state registered under the resolved name. This is the supported + # hook for per-process redirection (e.g. test sandboxing). + test "lookup/1 routes through a configured name resolver" do + real = TestUtils.create_name() + alias_name = TestUtils.create_name() + + state = cache(name: real) + Services.Overseer.register(real, state) + + # Resolver maps the alias to the real registered name. + Application.put_env(:cachex, :name_resolver, fn + ^alias_name -> real + other -> other + end) + + on_exit(fn -> Application.delete_env(:cachex, :name_resolver) end) + + # Looking up the alias resolves to the real cache's state... + assert(Services.Overseer.lookup(alias_name) == state) + # ...while the real name still resolves to itself. + assert(Services.Overseer.lookup(real) == state) + end + + # A resolver returning nil falls back to the original name (no redirect). + test "a resolver returning nil falls back to the original name" do + name = TestUtils.create_name() + state = cache(name: name) + Services.Overseer.register(name, state) + + Application.put_env(:cachex, :name_resolver, fn _ -> nil end) + on_exit(fn -> Application.delete_env(:cachex, :name_resolver) end) + + assert(Services.Overseer.lookup(name) == state) + end end