Skip to content

Commit

Permalink
Refactor cache stores
Browse files Browse the repository at this point in the history
  • Loading branch information
danschultzer committed Oct 19, 2019
1 parent 2606d81 commit 77f650e
Show file tree
Hide file tree
Showing 18 changed files with 747 additions and 366 deletions.
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@
* `Pow.Plug.Session` now stores a keyword list with metadata for the session rather than just the timestamp
* `Pow.Phoenix.Router` now only filters routes that has equal number of bindings
* `Pow.Phoenix.Routes.user_not_authenticated_path/1` now only puts the `:request_path` param if the request is using "GET" method
* The stores has been refactored so the command conforms with ETS store. This means that put commands now accept `{key, value}` record element(s), and keys may be lists or tuples for easier lookup.
* `Pow.Store.Backend.Base` behaviour now requires to;
* Accept `Pow.Store.Backend.Base.record/0` values for `put/2`
* Accept `Pow.Store.Backend.Base.key/0` for `delete/2` and `get/2`
* Implement `all/2`
* Remove `keys/1`
* Remove `put/3`
* `Pow.Store.Backend.EtsCache.keys/1` deprecated
* `Pow.Store.Backend.MnesiaCache.keys/1` deprecated
* `Pow.Store.Backend.EtsCache` and `Pow.Store.Backend.MnesiaCache` now uses `:ordered_set` instead of `:set` for efficiency
* `Pow.Store.Base` behaviour now requires to;
* Accept erlang term value for keys in all methods
* Remove `keys/2`
* Remove `put/4`
* `Pow.Store.Base.all/3` added
* `Pow.Store.Base.keys/2` deprecated
* `Pow.Store.Base.put/4` deprecated
* Added `Pow.Store.CredentialsCache.users/3`
* Added `Pow.Store.CredentialsCache.put/3`
* Deprecated `Pow.Store.CredentialsCache.put/4`
* Deprecated `Pow.Store.CredentialsCache.user_session_keys/3`

## v1.0.13 (2019-08-25)

Expand Down
164 changes: 139 additions & 25 deletions guides/redis_cache_store_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,50 +23,140 @@ defmodule MyAppWeb.PowRedisCache do

@redix_instance_name :redix

def put(config, key, value) do
key = redis_key(config, key)
ttl = Config.get(config, :ttl)
value = :erlang.term_to_binary(value)
command = put_command(key, value, ttl)

Redix.noreply_command(@redix_instance_name, command)
@impl true
def put(config, record_or_records) do
ttl = Config.get(config, :ttl) || raise_ttl_error()
commands =
record_or_records
|> List.wrap()
|> Enum.map(fn {key, value} ->
{redis_key(config, key), value}
end)
|> Enum.map(fn {key, value} ->
put_command(key, value, ttl)
end)

Redix.noreply_pipeline(@redix_instance_name, commands)
end

defp put_command(key, value, ttl) when is_integer(ttl) and ttl > 0, do: ["SET", key, value, "PX", ttl]
defp put_command(key, value, _ttl), do: ["SET", key, value]
defp put_command(key, value, ttl) do
key = to_binary_redis_key(key)
value = :erlang.term_to_binary(value)

["SET", key, value, "PX", ttl]
end

@impl true
def delete(config, key) do
key = redis_key(config, key)
key =
config
|> redis_key(key)
|> to_binary_redis_key()

Redix.noreply_command(@redix_instance_name, ["DEL", key])
end

@impl true
def get(config, key) do
key = redis_key(config, key)
key =
config
|> redis_key(key)
|> to_binary_redis_key()

case Redix.command(@redix_instance_name, ["GET", key]) do
{:ok, nil} -> :not_found
{:ok, value} -> :erlang.binary_to_term(value)
end
end

def keys(config) do
namespace = redis_key(config, "")
length = String.length(namespace)
@impl true
def all(config, match_spec) do
compiled_match_spec = :ets.match_spec_compile([{match_spec, [], [:"$_"]}])

Stream.resource(
fn -> do_scan(config, compiled_match_spec, "0") end,
&stream_scan(config, compiled_match_spec, &1),
fn _ -> :ok end)
|> Enum.to_list()
|> case do
[] -> []
keys -> fetch_values_for_keys(keys, config)
end
end

defp fetch_values_for_keys(keys, config) do
binary_keys = Enum.map(keys, &binary_redis_key(config, &1))

case Redix.command(@redix_instance_name, ["MGET"] ++ binary_keys) do
{:ok, values} ->
values = Enum.map(values, &:erlang.binary_to_term/1)

keys
|> Enum.zip(values)
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
end
end

defp stream_scan(_config, _compiled_match_spec, {[], "0"}), do: {:halt, nil}
defp stream_scan(config, compiled_match_spec, {[], iterator}), do: do_scan(config, compiled_match_spec, iterator)
defp stream_scan(_config, _compiled_match_spec, {keys, iterator}), do: {keys, {[], iterator}}

defp do_scan(config, compiled_match_spec, iterator) do
prefix = to_binary_redis_key([namespace(config)]) <> ":*"

{:ok, values} = Redix.command(@redix_instance_name, ["KEYS", "#{namespace}*"])
case Redix.command(@redix_instance_name, ["SCAN", iterator, "MATCH", prefix]) do
{:ok, [iterator, res]} -> {filter_or_load_value(compiled_match_spec, res), iterator}
end
end

Enum.map(values, &String.slice(&1, length..-1))
defp filter_or_load_value(compiled_match_spec, keys) do
keys
|> Enum.map(&convert_key/1)
|> Enum.sort()
|> :ets.match_spec_run(compiled_match_spec)
end

defp convert_key(key) do
key
|> from_binary_redis_key()
|> unwrap()
end

defp unwrap([_namespace, key]), do: key
defp unwrap([_namespace | key]), do: key

defp binary_redis_key(config, key) do
config
|> redis_key(key)
|> to_binary_redis_key()
end

defp redis_key(config, key) do
namespace = Config.get(config, :namespace, "cache")
[namespace(config)] ++ List.wrap(key)
end

defp namespace(config), do: Config.get(config, :namespace, "cach")

defp to_binary_redis_key(key) do
key
|> Enum.map(&:erlang.term_to_binary/1)
|> Enum.join(":")
end

"#{namespace}:#{key}"
defp from_binary_redis_key(key) do
key
|> String.split(":")
|> Enum.map(&:erlang.binary_to_term/1)
end

@spec raise_ttl_error :: no_return
defp raise_ttl_error,
do: Config.raise_error("`:ttl` configuration option is required for #{inspect(__MODULE__)}")
end
```

We are converting keys to binary keys since we can't directly use the Erlang terms as with ETS and Mnesia.

We'll need to start the Redix application on our app startup, so in `application.ex` add `{Redix, name: :redix}` to your supervision tree:

```elixir
Expand Down Expand Up @@ -107,10 +197,17 @@ defmodule MyAppWeb.PowRedisCacheTest do

@default_config [namespace: "test", ttl: :timer.hours(1)]

setup do
start_supervised!({Redix, host: "localhost", port: 6379, name: :redix})
Redix.command!(:redix, ["FLUSHALL"])

:ok
end

test "can put, get and delete records" do
assert PowRedisCache.get(@default_config, "key") == :not_found

PowRedisCache.put(@default_config, "key", "value")
PowRedisCache.put(@default_config, {"key", "value"})
:timer.sleep(100)
assert PowRedisCache.get(@default_config, "key") == "value"

Expand All @@ -119,22 +216,39 @@ defmodule MyAppWeb.PowRedisCacheTest do
assert PowRedisCache.get(@default_config, "key") == :not_found
end

test "fetch keys" do
PowRedisCache.put(@default_config, "key1", "value")
PowRedisCache.put(@default_config, "key2", "value")
test "can put multiple records at once" do
PowRedisCache.put(@default_config, [{"key1", "1"}, {"key2", "2"}])
:timer.sleep(100)
assert PowRedisCache.get(@default_config, "key1") == "1"
assert PowRedisCache.get(@default_config, "key2") == "2"
end

test "can match fetch all" do
PowRedisCache.put(@default_config, {"key1", "value"})
PowRedisCache.put(@default_config, {"key2", "value"})
:timer.sleep(100)

assert Enum.sort(PowRedisCache.keys(@default_config)) == ["key1", "key2"]
assert PowRedisCache.all(@default_config, :_) == [{"key1", "value"}, {"key2", "value"}]

PowRedisCache.put(@default_config, {["namespace", "key"], "value"})
:timer.sleep(100)

assert PowRedisCache.all(@default_config, ["namespace", :_]) == [{["namespace", "key"], "value"}]
end

test "records auto purge" do
config = Keyword.put(@default_config, :ttl, 100)

PowRedisCache.put(config, "key", "value")
PowRedisCache.put(config, {"key", "value"})
PowRedisCache.put(config, [{"key1", "1"}, {"key2", "2"}])
:timer.sleep(50)
assert PowRedisCache.get(config, "key") == "value"
assert PowRedisCache.get(config, "key1") == "1"
assert PowRedisCache.get(config, "key2") == "2"
:timer.sleep(100)
assert PowRedisCache.get(config, "key") == :not_found
assert PowRedisCache.get(config, "key1") == :not_found
assert PowRedisCache.get(config, "key2") == :not_found
end
end
```
```
12 changes: 8 additions & 4 deletions lib/pow/store/backend/base.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@ defmodule Pow.Store.Backend.Base do
"""
alias Pow.Config

@callback put(Config.t(), binary(), any()) :: :ok
@callback delete(Config.t(), binary()) :: :ok
@callback get(Config.t(), binary()) :: any() | :not_found
@callback keys(Config.t()) :: [any()]
@type key() :: [binary() | atom()] | binary()
@type record() :: {key(), any()}
@type key_match() :: [atom() | binary()]

@callback put(Config.t(), record() | [record()]) :: :ok
@callback delete(Config.t(), key()) :: :ok
@callback get(Config.t(), key()) :: any() | :not_found
@callback all(Config.t(), key_match()) :: [record()]
end
Loading

0 comments on commit 77f650e

Please sign in to comment.