From 73d43dae178dc3948b9540b3dec97c892c51fe1b Mon Sep 17 00:00:00 2001 From: MikaAK Date: Sun, 12 Apr 2026 17:45:41 -0700 Subject: [PATCH 1/2] fix: isolate Cache.Sandbox scan results per sandbox Previously scan/1 returned every key in the shared sandbox Agent, including keys written by concurrent async tests registered to the same cache module. This caused leakage between tests (duplicate results from one sandbox, or keys from another sandbox appearing in scan output). The scan macro now passes the current sandbox_id as a sandbox_prefix option, and Cache.Sandbox.scan filters stored keys to only those matching that prefix. Non-sandbox mode and the Redis adapter are unaffected (the opt is only set when sandbox? is true). --- lib/cache.ex | 8 ++++++++ lib/cache/redis.ex | 2 +- lib/cache/sandbox.ex | 5 +++++ test/cache_sandbox_test.exs | 22 ++++++++++++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lib/cache.ex b/lib/cache.ex index 9c119f2..9abffbe 100644 --- a/lib/cache.ex +++ b/lib/cache.ex @@ -406,10 +406,18 @@ defmodule Cache do "#{sandbox_id}:#{key}" end + + defp maybe_sandbox_scan_opts(opts) do + sandbox_id = Cache.SandboxRegistry.find!(__MODULE__) + + Keyword.put(opts, :sandbox_prefix, "#{sandbox_id}:") + end else defp maybe_sandbox_key(key) do key end + + defp maybe_sandbox_scan_opts(opts), do: opts end end end diff --git a/lib/cache/redis.ex b/lib/cache/redis.ex index 5e651b7..f7a8101 100644 --- a/lib/cache/redis.ex +++ b/lib/cache/redis.ex @@ -63,7 +63,7 @@ defmodule Cache.Redis do defmacro __using__(_opts) do quote do def scan(scan_opts \\ []) do - @cache_adapter.scan(@cache_name, scan_opts, adapter_options()) + @cache_adapter.scan(@cache_name, maybe_sandbox_scan_opts(scan_opts), adapter_options()) end def hash_scan(key, scan_opts \\ []) do diff --git a/lib/cache/sandbox.ex b/lib/cache/sandbox.ex index d2c0f7a..476da6c 100644 --- a/lib/cache/sandbox.ex +++ b/lib/cache/sandbox.ex @@ -357,10 +357,12 @@ defmodule Cache.Sandbox do match = scan_opts[:match] || "*" count = scan_opts[:count] type = scan_opts[:type] + sandbox_prefix = scan_opts[:sandbox_prefix] Agent.get(cache_name, fn state -> values = state + |> Stream.filter(fn {key, _value} -> scan_sandbox_match?(key, sandbox_prefix) end) |> Stream.filter(fn {_key, value} -> scan_type_match?(value, type) end) |> Stream.map(fn {key, _value} -> {key, scan_key(key)} end) |> Stream.filter(fn {_key, match_key} -> scan_match?(match_key, match) end) @@ -371,6 +373,9 @@ defmodule Cache.Sandbox do end) end + defp scan_sandbox_match?(_key, nil), do: true + defp scan_sandbox_match?(key, prefix), do: String.starts_with?(to_string(key), prefix) + def hash_scan(cache_name, key, scan_opts, _opts) do match = scan_opts[:match] || "*" count = scan_opts[:count] diff --git a/test/cache_sandbox_test.exs b/test/cache_sandbox_test.exs index 5c45ebd..541bfe8 100644 --- a/test/cache_sandbox_test.exs +++ b/test/cache_sandbox_test.exs @@ -39,6 +39,28 @@ defmodule CacheSandboxTest do test "adapter gets swapped to sandbox adapter" do assert TestCache.cache_adapter() === Cache.Sandbox end + + test "scan only returns keys from the current sandbox" do + assert :ok = TestCache.put("alpha", 1) + assert :ok = TestCache.put("beta", 2) + + task = + Task.async(fn -> + Cache.SandboxRegistry.register_caches(TestCache) + :ok = TestCache.put("gamma", 3) + TestCache.scan() + end) + + assert {:ok, other_keys} = Task.await(task) + assert "gamma" in other_keys + refute "alpha" in other_keys + refute "beta" in other_keys + + assert {:ok, my_keys} = TestCache.scan() + assert "alpha" in my_keys + assert "beta" in my_keys + refute "gamma" in my_keys + end end describe "&json_get/1" do From e4e86052c76480a58ca674a3466cb13f11bb3d37 Mon Sep 17 00:00:00 2001 From: MikaAK Date: Sun, 12 Apr 2026 18:18:05 -0700 Subject: [PATCH 2/2] refactor: scope Cache.Sandbox state by sandbox_id internally Previously, per-sandbox isolation was layered on top of the sandbox Agent by prefixing keys with the sandbox_id in Cache (maybe_sandbox_key) and filtering scan results via a sandbox_prefix option threaded through Cache.Redis. This spread sandbox concerns across three modules and still leaked data through operations that bypassed the prefix path. Cache.Sandbox now owns isolation directly: its Agent state is shaped as %{sandbox_id => %{key => value}} and every operation (core cache, hash, json, scan, plus all ETS/DETS-style helpers) reads and writes only the sub-map for the current caller's sandbox_id. The sandbox_id is resolved via SandboxRegistry in the caller process before entering the Agent callback, so caller $callers/$ancestors resolution still works. Falls back to :__unscoped__ when no registry entry exists. With isolation handled inside the adapter, Cache no longer needs to prefix keys in sandbox mode (maybe_sandbox_key is now a plain no-op) and Cache.Redis no longer threads a sandbox_prefix into scan opts. --- lib/cache.ex | 24 +-- lib/cache/redis.ex | 2 +- lib/cache/sandbox.ex | 493 ++++++++++++++++++++++--------------------- 3 files changed, 260 insertions(+), 259 deletions(-) diff --git a/lib/cache.ex b/lib/cache.ex index 9abffbe..4c8de4f 100644 --- a/lib/cache.ex +++ b/lib/cache.ex @@ -400,25 +400,11 @@ defmodule Cache do Cache.get_or_create(__MODULE__, key, fnc) end - if @cache_opts[:sandbox?] do - defp maybe_sandbox_key(key) do - sandbox_id = Cache.SandboxRegistry.find!(__MODULE__) - - "#{sandbox_id}:#{key}" - end - - defp maybe_sandbox_scan_opts(opts) do - sandbox_id = Cache.SandboxRegistry.find!(__MODULE__) - - Keyword.put(opts, :sandbox_prefix, "#{sandbox_id}:") - end - else - defp maybe_sandbox_key(key) do - key - end - - defp maybe_sandbox_scan_opts(opts), do: opts - end + # In sandbox mode, Cache.Sandbox handles per-caller isolation internally + # by scoping state under the current SandboxRegistry sandbox_id. Keys + # are not rewritten here, so scan/match operations see only the current + # sandbox's data without needing a prefix filter. + defp maybe_sandbox_key(key), do: key end end end diff --git a/lib/cache/redis.ex b/lib/cache/redis.ex index f7a8101..5e651b7 100644 --- a/lib/cache/redis.ex +++ b/lib/cache/redis.ex @@ -63,7 +63,7 @@ defmodule Cache.Redis do defmacro __using__(_opts) do quote do def scan(scan_opts \\ []) do - @cache_adapter.scan(@cache_name, maybe_sandbox_scan_opts(scan_opts), adapter_options()) + @cache_adapter.scan(@cache_name, scan_opts, adapter_options()) end def hash_scan(key, scan_opts \\ []) do diff --git a/lib/cache/sandbox.ex b/lib/cache/sandbox.ex index 476da6c..d56442f 100644 --- a/lib/cache/sandbox.ex +++ b/lib/cache/sandbox.ex @@ -32,6 +32,14 @@ defmodule Cache.Sandbox do proper isolation between test cases. > **Note**: This adapter should not be used in production environments. + + ## Isolation + + Each Agent process keeps state shaped as `%{sandbox_id => %{key => value}}`. The + `sandbox_id` is resolved per-caller via `Cache.SandboxRegistry` BEFORE entering + the Agent callback (so the caller's `self()`/`$callers`/`$ancestors` are used + for lookup, not the Agent's). When no sandbox is registered for the caller we + fall back to `:__unscoped__` — keeping the adapter usable outside of test. """ use Agent @@ -41,6 +49,9 @@ defmodule Cache.Sandbox do @behaviour Cache + @sandbox_registry :elixir_cache_sandbox + @unscoped :__unscoped__ + @impl Cache def opts_definition, do: [] @@ -59,85 +70,123 @@ defmodule Cache.Sandbox do } end + # SECTION: sandbox scoping helpers + + # Resolve the sandbox id for the current caller. Must be called from the + # caller process (NOT inside the Agent callback) so SandboxRegistry can walk + # the caller's $callers/$ancestors. + defp current_sandbox_id(cache_name) do + case Process.whereis(@sandbox_registry) do + nil -> + @unscoped + + _pid -> + case SandboxRegistry.lookup(@sandbox_registry, cache_name) do + {:ok, id} -> id + {:error, _} -> @unscoped + end + end + end + + defp scoped_get(state, sid), do: Map.get(state, sid, %{}) + defp scoped_put(state, sid, sub), do: Map.put(state, sid, sub) + + defp scoped_agent_get(cache_name, fun) do + sid = current_sandbox_id(cache_name) + Agent.get(cache_name, fn state -> fun.(scoped_get(state, sid)) end) + end + + defp scoped_agent_update(cache_name, fun) do + sid = current_sandbox_id(cache_name) + Agent.update(cache_name, fn state -> scoped_put(state, sid, fun.(scoped_get(state, sid))) end) + end + + defp scoped_agent_get_and_update(cache_name, fun) do + sid = current_sandbox_id(cache_name) + + Agent.get_and_update(cache_name, fn state -> + {reply, new_sub} = fun.(scoped_get(state, sid)) + {reply, scoped_put(state, sid, new_sub)} + end) + end + + # SECTION: core cache API + @impl Cache def get(cache_name, key, _opts \\ []) do - Agent.get(cache_name, fn state -> - {:ok, Map.get(state, key)} - end) + scoped_agent_get(cache_name, fn sub -> {:ok, Map.get(sub, key)} end) end @impl Cache def put(cache_name, key, _ttl \\ nil, value, _opts \\ []) do - Agent.update(cache_name, fn state -> - Map.put(state, key, value) - end) + scoped_agent_update(cache_name, fn sub -> Map.put(sub, key, value) end) end @impl Cache def delete(cache_name, key, _opts \\ []) do - Agent.update(cache_name, fn state -> - Map.delete(state, key) - end) + scoped_agent_update(cache_name, fn sub -> Map.delete(sub, key) end) end def get_or_store(cache_name, key, _ttl, store_fun) do - Agent.get_and_update(cache_name, fn state -> - case Map.fetch(state, key) do + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.fetch(sub, key) do {:ok, value} -> - {value, state} + {value, sub} :error -> value = store_fun.() - {value, Map.put(state, key, value)} + {value, Map.put(sub, key, value)} end end) end def dirty_get_or_store(cache_name, key, store_fun) do - Agent.get_and_update(cache_name, fn state -> - case Map.fetch(state, key) do + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.fetch(sub, key) do {:ok, value} -> - {value, state} + {value, sub} :error -> value = store_fun.() - {value, Map.put(state, key, value)} + {value, Map.put(sub, key, value)} end end) end + # SECTION: hash API + def hash_delete(cache_name, key, hash_key, _opts) do - Agent.get_and_update(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.get(sub, key) do nil -> - {{:ok, 0}, state} + {{:ok, 0}, sub} value when is_map(value) -> if Map.has_key?(value, hash_key) do updated = Map.delete(value, hash_key) - new_state = + new_sub = if Enum.empty?(updated) do - Map.delete(state, key) + Map.delete(sub, key) else - Map.put(state, key, updated) + Map.put(sub, key, updated) end - {{:ok, 1}, new_state} + {{:ok, 1}, new_sub} else - {{:ok, 0}, state} + {{:ok, 0}, sub} end _ -> - {{:ok, 0}, state} + {{:ok, 0}, sub} end end) end def hash_get(cache_name, key, hash_key, _opts) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> value = - state + sub |> Map.get(key, %{}) |> Map.get(hash_key) @@ -146,8 +195,8 @@ defmodule Cache.Sandbox do end def hash_get_all(cache_name, key, _opts) do - Agent.get(cache_name, fn state -> - case state[key] do + scoped_agent_get(cache_name, fn sub -> + case sub[key] do nil -> {:ok, %{}} value -> {:ok, value} end @@ -155,10 +204,10 @@ defmodule Cache.Sandbox do end def hash_get_many(cache_name, keys_fields, _opts) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> values = Enum.map(keys_fields, fn {key, fields} -> - hash = Map.get(state, key, %{}) + hash = Map.get(sub, key, %{}) Enum.map(fields, &Map.get(hash, &1)) end) @@ -167,19 +216,19 @@ defmodule Cache.Sandbox do end def hash_values(cache_name, key, _opts) do - Agent.get(cache_name, fn state -> - {:ok, Map.values(state[key] || %{})} + scoped_agent_get(cache_name, fn sub -> + {:ok, Map.values(sub[key] || %{})} end) end def hash_set(cache_name, key, field, value, ttl, _opts) do count = - Agent.get_and_update(cache_name, fn state -> - hash = Map.get(state, key, %{}) + scoped_agent_get_and_update(cache_name, fn sub -> + hash = Map.get(sub, key, %{}) is_new_field = not Map.has_key?(hash, field) updated_hash = Map.put(hash, field, value) - {if(is_new_field, do: 1, else: 0), Map.put(state, key, updated_hash)} + {if(is_new_field, do: 1, else: 0), Map.put(sub, key, updated_hash)} end) if ttl do @@ -191,9 +240,9 @@ defmodule Cache.Sandbox do def hash_set_many(cache_name, keys_fields_values, ttl, _opts) do counts = - Agent.get_and_update(cache_name, fn state -> - {counts, new_state} = - Enum.map_reduce(keys_fields_values, state, fn {key, fields_values}, acc -> + scoped_agent_get_and_update(cache_name, fn sub -> + {counts, new_sub} = + Enum.map_reduce(keys_fields_values, sub, fn {key, fields_values}, acc -> hash = Map.get(acc, key, %{}) {updated_hash, count} = @@ -208,7 +257,7 @@ defmodule Cache.Sandbox do {count, Map.put(acc, key, updated_hash)} end) - {counts, new_state} + {counts, new_sub} end) if ttl do @@ -219,6 +268,8 @@ defmodule Cache.Sandbox do end end + # SECTION: JSON API + def json_get(cache_name, key, path, _opts) when path in [nil, ["."]] do get(cache_name, key) end @@ -240,16 +291,16 @@ defmodule Cache.Sandbox do end def json_set(cache_name, key, path, value, _opts) do - state = Agent.get(cache_name, & &1) + sub = scoped_agent_get(cache_name, & &1) path = JSON.serialize_path(path) - with :ok <- check_key_exists(state, key), - :ok <- check_path_exists(state, key, path) do + with :ok <- check_key_exists(sub, key), + :ok <- check_path_exists(sub, key, path) do path = add_defaults([key | String.split(path, ".")]) value = stringify_value(value) - Agent.update(cache_name, fn state -> - put_in(state, path, value) + scoped_agent_update(cache_name, fn sub -> + put_in(sub, path, value) end) end end @@ -258,14 +309,14 @@ defmodule Cache.Sandbox do path_parts = json_path_parts(path) path_string = json_path_string(path) - Agent.get_and_update(cache_name, fn state -> - case get_in(state, [key | path_parts]) do + scoped_agent_get_and_update(cache_name, fn sub -> + case get_in(sub, [key | path_parts]) do nil -> - {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, state} + {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub} value -> new_value = value + incr - {{:ok, new_value}, put_in(state, [key | path_parts], new_value)} + {{:ok, new_value}, put_in(sub, [key | path_parts], new_value)} end end) end @@ -273,10 +324,10 @@ defmodule Cache.Sandbox do def json_clear(cache_name, key, path, _opts) do path_parts = json_path_parts(path) - Agent.get_and_update(cache_name, fn state -> - case get_in(state, [key | path_parts]) do + scoped_agent_get_and_update(cache_name, fn sub -> + case get_in(sub, [key | path_parts]) do nil -> - {{:ok, 0}, state} + {{:ok, 0}, sub} value -> updated_value = @@ -287,7 +338,7 @@ defmodule Cache.Sandbox do _ -> nil end - {{:ok, 1}, put_in(state, [key | path_parts], updated_value)} + {{:ok, 1}, put_in(sub, [key | path_parts], updated_value)} end end) end @@ -295,13 +346,13 @@ defmodule Cache.Sandbox do def json_delete(cache_name, key, path, _opts) do path_parts = json_path_parts(path) - Agent.get_and_update(cache_name, fn state -> - case get_in(state, [key | path_parts]) do + scoped_agent_get_and_update(cache_name, fn sub -> + case get_in(sub, [key | path_parts]) do nil -> - {{:ok, 0}, state} + {{:ok, 0}, sub} _value -> - {_, updated} = pop_in(state, [key | path_parts]) + {_, updated} = pop_in(sub, [key | path_parts]) {{:ok, 1}, updated} end end) @@ -320,23 +371,24 @@ defmodule Cache.Sandbox do path_string = json_path_string(path) updated_values = Enum.map(values, value_transformer) - Agent.get_and_update(cache_name, fn state -> - case get_in(state, [key | path_parts]) do + scoped_agent_get_and_update(cache_name, fn sub -> + case get_in(sub, [key | path_parts]) do nil -> - {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, state} + {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub} list when is_list(list) -> updated_list = list ++ updated_values - new_state = put_in(state, [key | path_parts], updated_list) - {{:ok, enum_length(updated_list)}, new_state} + new_sub = put_in(sub, [key | path_parts], updated_list) + {{:ok, enum_length(updated_list)}, new_sub} _ -> - {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, state} + {{:error, ErrorMessage.not_found("ERR Path '$.#{path_string}' does not exist")}, sub} end end) end - # Redis Compatibility + # SECTION: Redis compatibility + def pipeline(_cache_name, _commands, _opts) do raise "Not Implemented" end @@ -357,31 +409,26 @@ defmodule Cache.Sandbox do match = scan_opts[:match] || "*" count = scan_opts[:count] type = scan_opts[:type] - sandbox_prefix = scan_opts[:sandbox_prefix] - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> values = - state - |> Stream.filter(fn {key, _value} -> scan_sandbox_match?(key, sandbox_prefix) end) + sub |> Stream.filter(fn {_key, value} -> scan_type_match?(value, type) end) - |> Stream.map(fn {key, _value} -> {key, scan_key(key)} end) - |> Stream.filter(fn {_key, match_key} -> scan_match?(match_key, match) end) - |> Enum.map(fn {_key, match_key} -> match_key end) + |> Stream.map(fn {key, _value} -> key end) + |> Stream.filter(fn key -> scan_match?(key, match) end) + |> Enum.to_list() |> apply_scan_count(count) {:ok, values} end) end - defp scan_sandbox_match?(_key, nil), do: true - defp scan_sandbox_match?(key, prefix), do: String.starts_with?(to_string(key), prefix) - def hash_scan(cache_name, key, scan_opts, _opts) do match = scan_opts[:match] || "*" count = scan_opts[:count] - Agent.get(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get(cache_name, fn sub -> + case Map.get(sub, key) do map when is_map(map) -> elements = map @@ -396,31 +443,36 @@ defmodule Cache.Sandbox do end) end - # ETS & DETS Compatibility + # SECTION: ETS & DETS compatibility + # + # All ETS/DETS-style helpers below operate on the scoped sub-map for the + # current sandbox so tests stay isolated from each other. def all do - Agent.get(Cache.Sandbox, fn state -> Map.keys(state) end) + sid = current_sandbox_id(Cache.Sandbox) + + Agent.get(Cache.Sandbox, fn state -> state |> scoped_get(sid) |> Map.keys() end) rescue _ -> [] end def delete_table(cache_name) do - Agent.update(cache_name, fn _ -> %{} end) + scoped_agent_update(cache_name, fn _ -> %{} end) true end def delete_all_objects(cache_name) do - Agent.update(cache_name, fn _ -> %{} end) + scoped_agent_update(cache_name, fn _ -> %{} end) true end def delete_object(cache_name, object) when is_tuple(object) do key = elem(object, 0) - Agent.update(cache_name, fn state -> - case Map.get(state, key) do - ^object -> Map.delete(state, key) - _ -> state + scoped_agent_update(cache_name, fn sub -> + case Map.get(sub, key) do + ^object -> Map.delete(sub, key) + _ -> sub end end) @@ -428,8 +480,8 @@ defmodule Cache.Sandbox do end def first(cache_name) do - Agent.get(cache_name, fn state -> - case Map.keys(state) do + scoped_agent_get(cache_name, fn sub -> + case Map.keys(sub) do [] -> :"$end_of_table" [key | _] -> key end @@ -437,8 +489,8 @@ defmodule Cache.Sandbox do end def first_lookup(cache_name) do - Agent.get(cache_name, fn state -> - case Map.to_list(state) do + scoped_agent_get(cache_name, fn sub -> + case Map.to_list(sub) do [] -> :"$end_of_table" [{key, value} | _] -> {key, [{key, value}]} end @@ -446,8 +498,8 @@ defmodule Cache.Sandbox do end def last(cache_name) do - Agent.get(cache_name, fn state -> - case state |> Map.keys() |> Enum.reverse() do + scoped_agent_get(cache_name, fn sub -> + case sub |> Map.keys() |> Enum.reverse() do [] -> :"$end_of_table" [key | _] -> key end @@ -455,8 +507,8 @@ defmodule Cache.Sandbox do end def last_lookup(cache_name) do - Agent.get(cache_name, fn state -> - case state |> Map.to_list() |> Enum.reverse() do + scoped_agent_get(cache_name, fn sub -> + case sub |> Map.to_list() |> Enum.reverse() do [] -> :"$end_of_table" [{key, value} | _] -> {key, [{key, value}]} end @@ -464,37 +516,37 @@ defmodule Cache.Sandbox do end def next(cache_name, key) do - Agent.get(cache_name, fn state -> - keys = state |> Map.keys() |> Enum.sort() + scoped_agent_get(cache_name, fn sub -> + keys = sub |> Map.keys() |> Enum.sort() find_next_key(keys, key) end) end def next_lookup(cache_name, key) do - Agent.get(cache_name, fn state -> - keys = state |> Map.keys() |> Enum.sort() + scoped_agent_get(cache_name, fn sub -> + keys = sub |> Map.keys() |> Enum.sort() case find_next_key(keys, key) do :"$end_of_table" -> :"$end_of_table" - next_key -> {next_key, [{next_key, Map.get(state, next_key)}]} + next_key -> {next_key, [{next_key, Map.get(sub, next_key)}]} end end) end def prev(cache_name, key) do - Agent.get(cache_name, fn state -> - keys = state |> Map.keys() |> Enum.sort() |> Enum.reverse() + scoped_agent_get(cache_name, fn sub -> + keys = sub |> Map.keys() |> Enum.sort() |> Enum.reverse() find_next_key(keys, key) end) end def prev_lookup(cache_name, key) do - Agent.get(cache_name, fn state -> - keys = state |> Map.keys() |> Enum.sort() |> Enum.reverse() + scoped_agent_get(cache_name, fn sub -> + keys = sub |> Map.keys() |> Enum.sort() |> Enum.reverse() case find_next_key(keys, key) do :"$end_of_table" -> :"$end_of_table" - prev_key -> {prev_key, [{prev_key, Map.get(state, prev_key)}]} + prev_key -> {prev_key, [{prev_key, Map.get(sub, prev_key)}]} end end) end @@ -509,8 +561,8 @@ defmodule Cache.Sandbox do defp find_next_key([_ | rest], key), do: find_next_key(rest, key) def foldl(cache_name, function, acc) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() |> Enum.reduce(acc, fn {key, value}, acc_inner -> function.({key, value}, acc_inner) @@ -519,8 +571,8 @@ defmodule Cache.Sandbox do end def foldr(cache_name, function, acc) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() |> Enum.reverse() |> Enum.reduce(acc, fn {key, value}, acc_inner -> @@ -530,14 +582,12 @@ defmodule Cache.Sandbox do end def member(cache_name, key) do - Agent.get(cache_name, fn state -> - Map.has_key?(state, key) - end) + scoped_agent_get(cache_name, fn sub -> Map.has_key?(sub, key) end) end def lookup(cache_name, key) do - Agent.get(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get(cache_name, fn sub -> + case Map.get(sub, key) do nil -> [] value -> [{key, value}] end @@ -545,8 +595,8 @@ defmodule Cache.Sandbox do end def lookup_element(cache_name, key, pos) do - Agent.get(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get(cache_name, fn sub -> + case Map.get(sub, key) do nil -> raise ArgumentError, "key not found" value when is_tuple(value) -> elem(value, pos - 1) value -> value @@ -555,8 +605,8 @@ defmodule Cache.Sandbox do end def lookup_element(cache_name, key, pos, default) do - Agent.get(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get(cache_name, fn sub -> + case Map.get(sub, key) do nil -> default value when is_tuple(value) -> elem(value, pos - 1) value -> value @@ -565,33 +615,33 @@ defmodule Cache.Sandbox do end def update_counter(cache_name, key, {_pos, incr}) do - Agent.get_and_update(cache_name, fn state -> - current_value = state[key] || 0 + scoped_agent_get_and_update(cache_name, fn sub -> + current_value = sub[key] || 0 new_value = current_value + incr - {new_value, Map.put(state, key, new_value)} + {new_value, Map.put(sub, key, new_value)} end) end def update_counter(cache_name, key, incr) when is_integer(incr) do - Agent.get_and_update(cache_name, fn state -> - current_value = state[key] || 0 + scoped_agent_get_and_update(cache_name, fn sub -> + current_value = sub[key] || 0 new_value = current_value + incr - {new_value, Map.put(state, key, new_value)} + {new_value, Map.put(sub, key, new_value)} end) end def update_counter(cache_name, key, update_op, default) do - Agent.get_and_update(cache_name, fn state -> - if Map.has_key?(state, key) do - current_value = state[key] + scoped_agent_get_and_update(cache_name, fn sub -> + if Map.has_key?(sub, key) do + current_value = sub[key] incr = if is_tuple(update_op), do: elem(update_op, 1), else: update_op new_value = current_value + incr - {new_value, Map.put(state, key, new_value)} + {new_value, Map.put(sub, key, new_value)} else default_value = if is_tuple(default), do: elem(default, 1), else: default incr = if is_tuple(update_op), do: elem(update_op, 1), else: update_op new_value = default_value + incr - {new_value, Map.put(state, key, new_value)} + {new_value, Map.put(sub, key, new_value)} end end) end @@ -600,16 +650,13 @@ defmodule Cache.Sandbox do key = elem(data, 0) value = if tuple_size(data) === 2, do: elem(data, 1), else: data - Agent.update(cache_name, fn state -> - Map.put(state, key, value) - end) - + scoped_agent_update(cache_name, fn sub -> Map.put(sub, key, value) end) true end def insert_raw(cache_name, data) when is_list(data) do - Agent.update(cache_name, fn state -> - Enum.reduce(data, state, fn tuple, acc -> + scoped_agent_update(cache_name, fn sub -> + Enum.reduce(data, sub, fn tuple, acc -> key = elem(tuple, 0) value = if tuple_size(tuple) === 2, do: elem(tuple, 1), else: tuple Map.put(acc, key, value) @@ -623,67 +670,61 @@ defmodule Cache.Sandbox do key = elem(data, 0) value = if tuple_size(data) === 2, do: elem(data, 1), else: data - Agent.get_and_update(cache_name, fn state -> - if Map.has_key?(state, key) do - {false, state} + scoped_agent_get_and_update(cache_name, fn sub -> + if Map.has_key?(sub, key) do + {false, sub} else - {true, Map.put(state, key, value)} + {true, Map.put(sub, key, value)} end end) end def insert_new(cache_name, data) when is_list(data) do - Agent.get_and_update(cache_name, fn state -> - keys_exist = Enum.any?(data, fn tuple -> Map.has_key?(state, elem(tuple, 0)) end) + scoped_agent_get_and_update(cache_name, fn sub -> + keys_exist = Enum.any?(data, fn tuple -> Map.has_key?(sub, elem(tuple, 0)) end) if keys_exist do - {false, state} + {false, sub} else - new_state = - Enum.reduce(data, state, fn tuple, acc -> + new_sub = + Enum.reduce(data, sub, fn tuple, acc -> key = elem(tuple, 0) value = if tuple_size(tuple) === 2, do: elem(tuple, 1), else: tuple Map.put(acc, key, value) end) - {true, new_state} + {true, new_sub} end end) end def take(cache_name, key) do - Agent.get_and_update(cache_name, fn state -> - case Map.pop(state, key) do - {nil, state} -> {[], state} - {value, new_state} -> {[{key, value}], new_state} + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.pop(sub, key) do + {nil, sub} -> {[], sub} + {value, new_sub} -> {[{key, value}], new_sub} end end) end def tab2list(cache_name) do - Agent.get(cache_name, fn state -> - Map.to_list(state) - end) + scoped_agent_get(cache_name, fn sub -> Map.to_list(sub) end) end def match_object(cache_name, pattern) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() - |> Enum.filter(fn {key, value} -> - match_pattern?({key, value}, pattern) - end) + |> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end) end) end def match_object(cache_name, pattern, limit) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> results = - state + sub |> Map.to_list() - |> Enum.filter(fn {key, value} -> - match_pattern?({key, value}, pattern) - end) + |> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end) |> Enum.take(limit) {results, :end_of_table} @@ -691,24 +732,20 @@ defmodule Cache.Sandbox do end def match_pattern(cache_name, pattern) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() - |> Enum.filter(fn {key, value} -> - match_pattern?({key, value}, pattern) - end) + |> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end) |> Enum.map(fn obj -> extract_bindings(obj, pattern) end) end) end def match_pattern(cache_name, pattern, limit) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> results = - state + sub |> Map.to_list() - |> Enum.filter(fn {key, value} -> - match_pattern?({key, value}, pattern) - end) + |> Enum.filter(fn {key, value} -> match_pattern?({key, value}, pattern) end) |> Enum.take(limit) |> Enum.map(fn obj -> extract_bindings(obj, pattern) end) @@ -725,9 +762,7 @@ defmodule Cache.Sandbox do object_list |> Enum.zip(pattern_list) - |> Enum.all?(fn {obj_elem, pat_elem} -> - match_element?(obj_elem, pat_elem) - end) + |> Enum.all?(fn {obj_elem, pat_elem} -> match_element?(obj_elem, pat_elem) end) else false end @@ -767,23 +802,19 @@ defmodule Cache.Sandbox do defp extract_bindings(_object, _pattern), do: [] def select(cache_name, match_spec) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() - |> Enum.flat_map(fn {key, value} -> - apply_match_spec({key, value}, match_spec) - end) + |> Enum.flat_map(fn {key, value} -> apply_match_spec({key, value}, match_spec) end) end) end def select(cache_name, match_spec, limit) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> results = - state + sub |> Map.to_list() - |> Enum.flat_map(fn {key, value} -> - apply_match_spec({key, value}, match_spec) - end) + |> Enum.flat_map(fn {key, value} -> apply_match_spec({key, value}, match_spec) end) |> Enum.take(limit) {results, :end_of_table} @@ -791,9 +822,7 @@ defmodule Cache.Sandbox do end defp apply_match_spec(object, match_spec) when is_list(match_spec) do - Enum.flat_map(match_spec, fn spec -> - apply_single_match_spec(object, spec) - end) + Enum.flat_map(match_spec, fn spec -> apply_single_match_spec(object, spec) end) end # NOTE: Guards are not evaluated in the sandbox. Implementing a full guard @@ -878,8 +907,8 @@ defmodule Cache.Sandbox do defp resolve_bindings(value, _bindings), do: value def select_count(cache_name, match_spec) do - Agent.get(cache_name, fn state -> - state + scoped_agent_get(cache_name, fn sub -> + sub |> Map.to_list() |> Enum.count(fn {key, value} -> result = apply_match_spec({key, value}, match_spec) @@ -889,9 +918,9 @@ defmodule Cache.Sandbox do end def select_delete(cache_name, match_spec) do - Agent.get_and_update(cache_name, fn state -> + scoped_agent_get_and_update(cache_name, fn sub -> {to_delete, to_keep} = - state + sub |> Map.to_list() |> Enum.split_with(fn {key, value} -> result = apply_match_spec({key, value}, match_spec) @@ -903,11 +932,11 @@ defmodule Cache.Sandbox do end def select_replace(cache_name, match_spec) do - Agent.get_and_update(cache_name, fn state -> - {count, new_state} = - state + scoped_agent_get_and_update(cache_name, fn sub -> + {count, new_sub} = + sub |> Map.to_list() - |> Enum.reduce({0, state}, fn {key, value}, {cnt, acc} -> + |> Enum.reduce({0, sub}, fn {key, value}, {cnt, acc} -> result = apply_match_spec({key, value}, match_spec) case result do @@ -921,17 +950,15 @@ defmodule Cache.Sandbox do end end) - {count, new_state} + {count, new_sub} end) end def match_delete(cache_name, pattern) do - Agent.update(cache_name, fn state -> - state + scoped_agent_update(cache_name, fn sub -> + sub |> Map.to_list() - |> Enum.reject(fn {key, value} -> - match_pattern?({key, value}, pattern) - end) + |> Enum.reject(fn {key, value} -> match_pattern?({key, value}, pattern) end) |> Map.new() end) @@ -939,10 +966,10 @@ defmodule Cache.Sandbox do end def info(cache_name) do - Agent.get(cache_name, fn state -> + scoped_agent_get(cache_name, fn sub -> [ name: cache_name, - size: map_size(state), + size: map_size(sub), type: :set, named_table: true, keypos: 1, @@ -957,8 +984,8 @@ defmodule Cache.Sandbox do end def slot(cache_name, i) do - Agent.get(cache_name, fn state -> - list = Map.to_list(state) + scoped_agent_get(cache_name, fn sub -> + list = Map.to_list(sub) if i >= length(list) do :"$end_of_table" @@ -973,10 +1000,7 @@ defmodule Cache.Sandbox do end def init_table(cache_name, init_fun) do - Agent.update(cache_name, fn _state -> - read_init_fun(init_fun, %{}) - end) - + scoped_agent_update(cache_name, fn _sub -> read_init_fun(init_fun, %{}) end) true end @@ -1031,27 +1055,27 @@ defmodule Cache.Sandbox do end def traverse(cache_name, fun) do - Agent.get_and_update(cache_name, fn state -> - {results, new_state} = - state + scoped_agent_get_and_update(cache_name, fn sub -> + {results, new_sub} = + sub |> Map.to_list() - |> Enum.reduce({[], state}, fn {key, value}, {acc, current_state} -> + |> Enum.reduce({[], sub}, fn {key, value}, {acc, current_sub} -> case fun.({key, value}) do :continue -> - {acc, current_state} + {acc, current_sub} {:continue, result} -> - {[result | acc], current_state} + {[result | acc], current_sub} {:done, result} -> - {[result | acc], current_state} + {[result | acc], current_sub} :done -> - {acc, current_state} + {acc, current_sub} end end) - {Enum.reverse(results), new_state} + {Enum.reverse(results), new_sub} end) end @@ -1147,33 +1171,33 @@ defmodule Cache.Sandbox do end def update_element(cache_name, key, element_spec) do - Agent.get_and_update(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.get(sub, key) do nil -> - {false, state} + {false, sub} value when is_tuple(value) -> new_value = apply_element_spec(value, element_spec) - {true, Map.put(state, key, new_value)} + {true, Map.put(sub, key, new_value)} _ -> - {false, state} + {false, sub} end end) end def update_element(cache_name, key, element_spec, default) do - Agent.get_and_update(cache_name, fn state -> - case Map.get(state, key) do + scoped_agent_get_and_update(cache_name, fn sub -> + case Map.get(sub, key) do nil -> - {true, Map.put(state, key, default)} + {true, Map.put(sub, key, default)} value when is_tuple(value) -> new_value = apply_element_spec(value, element_spec) - {true, Map.put(state, key, new_value)} + {true, Map.put(sub, key, new_value)} _ -> - {false, state} + {false, sub} end end) end @@ -1206,6 +1230,7 @@ defmodule Cache.Sandbox do raise "Not Implemented" end + # SECTION: internal helpers defp check_key_exists(state, key) do if Map.has_key?(state, key) do @@ -1247,16 +1272,6 @@ defmodule Cache.Sandbox do defp apply_scan_count(values, nil), do: values defp apply_scan_count(values, count) when is_integer(count), do: Enum.take(values, count) - defp scan_key(key) do - key - |> to_string() - |> String.split(":", parts: 2) - |> case do - [_prefix, rest] -> rest - [value] -> value - end - end - defp scan_match?(value, pattern) do value |> to_string() @@ -1307,8 +1322,8 @@ defmodule Cache.Sandbox do defp serialize_path_and_get_value(cache_name, key, path) do path = JSON.serialize_path(path) - Agent.get(cache_name, fn state -> - case get_in(state, [key | String.split(path, ".")]) do + scoped_agent_get(cache_name, fn sub -> + case get_in(sub, [key | String.split(path, ".")]) do nil -> {:error, ErrorMessage.not_found("ERR Path '$.#{path}' does not exist")} value -> {:ok, value} end