Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

## Bug Fixes

- fix: `Cache.Sandbox` no longer raises `"Not Implemented"` from the Redis-only surface. `smembers/3` and `sadd/4` are real in-memory sets with Redis semantics (`SADD` returns the new-member count, `SMEMBERS` of a missing key is `[]`). `command/3` and `pipeline/3` handle `PING`, `GET`, `EXISTS` and `DEL` against the sandbox map and return `{:error, %ErrorMessage{code: :not_implemented}}` for anything else — the error shape the Redis adapter itself produces — with `command!/3`/`pipeline!/3` raising only on that branch. Under `sandbox?: Mix.env() === :test` the `use Cache` wrappers delegate to the sandbox, so dialyzer (running in test) had been correctly reporting every Redis cache module's injected `command/1`, `pipeline/1`, `sadd/2`, `smembers/2` (and their default-arg arities) as having no local return, forcing a `:no_return` ignore entry per Redis cache module in every consuming app. Those ignores can now be deleted.
- fix: `:compression_level` is reachable. It was unusable on every path — no adapter declares it, so `NimbleOptions` rejected it on compile-time adapter opts, and it resolved to `nil` before it could reach the encoder otherwise. It is now an option on the `use Cache` line (`compression_level: 6`), it is taken off the adapter opts before they are validated so the `opts: [compression_level: 6]` spelling works too, and it is never handed to the adapter. Setting it forces encoding on adapters that hold terms natively — asking for compression is asking for bytes. A cache using a strategy adapter raises at compile time rather than ignoring the option.
- fix: `Cache.ConCache.get_or_store/3` followed by `get/1` no longer raises. `get_or_store/3` writes through ConCache directly, bypassing the encode in `put/3`, so the matching `get/1` tried to `binary_to_term/1` a raw term.
- fix: a binary value wrapped in braces but not valid JSON (eg `"{oops}"`) no longer raises `Jason.DecodeError` on read. `Cache.TermEncoder.decode/1` used `Jason.decode!/1`, and now falls back to returning the binary unchanged.
Expand Down
78 changes: 66 additions & 12 deletions lib/cache/sandbox.ex
Original file line number Diff line number Diff line change
Expand Up @@ -397,22 +397,66 @@ defmodule Cache.Sandbox do

# SECTION: Redis compatibility

def pipeline(_cache_name, _commands, _opts) do
raise "Not Implemented"
# Raw command passthrough. The sandbox is a term map, not a Redis server, so
# only the commands whose semantics map directly onto that map are supported;
# anything else is a `:not_implemented` error rather than a raise, so the
# `use Cache` wrappers keep a real return type and callers see the same
# `{:error, %ErrorMessage{}}` shape the Redis adapter produces on failure.
def pipeline(cache_name, commands, opts) do
with {:ok, replies} <- run_commands(cache_name, commands, opts) do
{:ok, Enum.reverse(replies)}
end
end

def pipeline!(cache_name, commands, opts) do
case pipeline(cache_name, commands, opts) do
{:ok, replies} -> replies
{:error, %ErrorMessage{message: message}} -> raise message
end
end

def command(_cache_name, ["PING"], _opts), do: {:ok, "PONG"}

def command(cache_name, ["GET", key], _opts) do
scoped_agent_get(cache_name, fn sub -> {:ok, Map.get(sub, key)} end)
end

def pipeline!(_cache_name, _commands, _opts) do
raise "Not Implemented"
def command(cache_name, ["EXISTS", key], _opts) do
scoped_agent_get(cache_name, fn sub -> {:ok, boolean_to_integer(Map.has_key?(sub, key))} end)
end

def command(_cache_name, _command, _opts) do
raise "Not Implemented"
def command(cache_name, ["DEL", key], _opts) do
scoped_agent_get_and_update(cache_name, fn sub ->
{{:ok, boolean_to_integer(Map.has_key?(sub, key))}, Map.delete(sub, key)}
end)
end

def command!(_cache_name, _command, _opts) do
raise "Not Implemented"
def command(_cache_name, [command | _args], _opts) do
{:error,
ErrorMessage.not_implemented("#{command} is not implemented by the cache sandbox", %{
command: command
})}
end

def command!(cache_name, command, opts) do
case command(cache_name, command, opts) do
{:ok, reply} -> reply
{:error, %ErrorMessage{message: message}} -> raise message
end
end

defp run_commands(cache_name, commands, opts) do
Enum.reduce_while(commands, {:ok, []}, fn command, {:ok, replies} ->
case command(cache_name, command, opts) do
{:ok, reply} -> {:cont, {:ok, [reply | replies]}}
{:error, _} = error -> {:halt, error}
end
end)
end

defp boolean_to_integer(true), do: 1
defp boolean_to_integer(false), do: 0

def scan(cache_name, scan_opts, _opts) do
match = scan_opts[:match] || "*"
count = scan_opts[:count]
Expand Down Expand Up @@ -1171,12 +1215,22 @@ defmodule Cache.Sandbox do
{Enum.reverse(results), continuation}
end

def smembers(_cache_name, _key, _opts) do
raise "Not Implemented"
# SECTION: set API

# Sets are stored as MapSets under the key. Like Redis, SMEMBERS of a
# missing key is the empty set and SADD returns how many members were new.
def smembers(cache_name, key, _opts) do
scoped_agent_get(cache_name, fn sub ->
{:ok, sub |> Map.get(key, MapSet.new()) |> MapSet.to_list()}
end)
end

def sadd(_cache_name, _key, _value, _opts) do
raise "Not Implemented"
def sadd(cache_name, key, value, _opts) do
scoped_agent_get_and_update(cache_name, fn sub ->
set = Map.get(sub, key, MapSet.new())
added = boolean_to_integer(not MapSet.member?(set, value))
{{:ok, added}, Map.put(sub, key, MapSet.put(set, value))}
end)
end

# SECTION: internal helpers
Expand Down
104 changes: 104 additions & 0 deletions test/cache/redis_sandbox_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
defmodule Cache.RedisSandboxTest do
@moduledoc """
Sandbox coverage for the Redis-only surface `use Cache, adapter: Cache.Redis`
injects (set ops, raw command/pipeline). These run against `Cache.Sandbox`,
so no Redis is needed.
"""
use ExUnit.Case, async: true

defmodule RedisSandboxCache do
use Cache,
adapter: Cache.Redis,
name: :test_cache_redis_sandbox,
opts: [uri: "redis://localhost:6379"],
sandbox?: true
end

setup do
Cache.SandboxRegistry.start(RedisSandboxCache)

:ok
end

describe "&sadd/3 and &smembers/2" do
test "smembers of a missing key is an empty list" do
assert {:ok, []} === RedisSandboxCache.smembers("missing", [])
end

test "sadd returns the number of newly added members and smembers returns the set" do
assert {:ok, 1} === RedisSandboxCache.sadd("symbols", "AAPL")
assert {:ok, 1} === RedisSandboxCache.sadd("symbols", "TSLA")
assert {:ok, 0} === RedisSandboxCache.sadd("symbols", "AAPL")

assert {:ok, members} = RedisSandboxCache.smembers("symbols", [])
assert ["AAPL", "TSLA"] === Enum.sort(members)
end

test "members round-trip as terms, not binaries" do
assert {:ok, 1} === RedisSandboxCache.sadd("terms", %{symbol: "AAPL", strike: 150.0})
assert {:ok, [%{symbol: "AAPL", strike: 150.0}]} === RedisSandboxCache.smembers("terms", [])
end

test "sets are isolated per sandbox" do
assert {:ok, []} === RedisSandboxCache.smembers("symbols", [])
end
end

describe "&command/2" do
test "PING" do

Check warning on line 48 in test/cache/redis_sandbox_test.exs

View workflow job for this annotation

GitHub Actions / Credo

Uppercase letter found at beginning of test name
assert {:ok, "PONG"} === RedisSandboxCache.command(["PING"])
end

test "GET / EXISTS / DEL against keys written through the cache API" do

Check warning on line 52 in test/cache/redis_sandbox_test.exs

View workflow job for this annotation

GitHub Actions / Credo

Uppercase letter found at beginning of test name
assert :ok === RedisSandboxCache.put("key", "value")

# Raw GET returns the stored binary undecoded, exactly like Redis would —
# the Redis-backed sandbox term-encodes on put to round-trip faithfully.
assert {:ok, encoded} = RedisSandboxCache.command(["GET", "key"])
assert "value" === Cache.TermEncoder.decode(encoded)
assert {:ok, 1} === RedisSandboxCache.command(["EXISTS", "key"])
assert {:ok, 1} === RedisSandboxCache.command(["DEL", "key"])
assert {:ok, nil} === RedisSandboxCache.command(["GET", "key"])
assert {:ok, 0} === RedisSandboxCache.command(["EXISTS", "key"])
assert {:ok, 0} === RedisSandboxCache.command(["DEL", "key"])
end

test "unsupported commands return a not-implemented error instead of raising" do
assert {:error, %ErrorMessage{code: :not_implemented, details: %{command: "ZADD"}}} =
RedisSandboxCache.command(["ZADD", "key", "1", "member"])
end
end

describe "&pipeline/2" do
test "runs each command in order and returns the replies as a list" do
assert :ok === RedisSandboxCache.put("key", "value")

assert {:ok, ["PONG", encoded, 1]} =
RedisSandboxCache.pipeline([["PING"], ["GET", "key"], ["DEL", "key"]])

assert "value" === Cache.TermEncoder.decode(encoded)
end

test "an unsupported command fails the whole pipeline" do
assert {:error, %ErrorMessage{code: :not_implemented}} =
RedisSandboxCache.pipeline([["PING"], ["ZADD", "key", "1", "member"]])
end
end

describe "&command!/2 and &pipeline!/2" do
test "return the raw reply on success" do
assert "PONG" === RedisSandboxCache.command!(["PING"])
assert ["PONG", "PONG"] === RedisSandboxCache.pipeline!([["PING"], ["PING"]])
end

test "raise on an unsupported command" do
assert_raise RuntimeError, ~r/not implemented/, fn ->
RedisSandboxCache.command!(["ZADD", "key", "1", "member"])
end

assert_raise RuntimeError, ~r/not implemented/, fn ->
RedisSandboxCache.pipeline!([["ZADD", "key", "1", "member"]])
end
end
end
end
Loading