Skip to content

Commit 5da8d7e

Browse files
committed
feat(strategies): add strategy adapter support with compile-time branching
Introduce strategy adapters as a new adapter type that wraps underlying cache adapters. When adapter is a tuple `{strategy_module, config}`, generate strategy-specific code paths that: - Pass strategy config separately to strategy module functions - Skip term encoding/decoding (delegated to strategy layer) - Inject `__cache_module__` into adapter options for strategy use - Validate strategy-specific options at compile time
1 parent 00689fa commit 5da8d7e

8 files changed

Lines changed: 1131 additions & 143 deletions

File tree

lib/cache.ex

Lines changed: 311 additions & 140 deletions
Large diffs are not rendered by default.

lib/cache/multi_layer.ex

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
defmodule Cache.MultiLayer do
2+
@moduledoc """
3+
Multi-layer caching strategy that cascades through multiple cache layers.
4+
5+
Keys are read from fastest to slowest, with automatic backfill on cache hits
6+
from slower layers. Writes go slowest-first to avoid polluting fast layers
7+
with data that failed to persist in slow ones.
8+
9+
## Usage
10+
11+
Pass a list of layers as the strategy config. Each element can be:
12+
13+
- A module that implements `Cache` (already running, not supervised by this adapter)
14+
- An adapter module (e.g. `Cache.ETS`) — will be auto-started and supervised
15+
- A tuple `{AdapterModule, opts}` — adapter with inline opts
16+
17+
```elixir
18+
defmodule MyApp.LayeredCache do
19+
use Cache,
20+
adapter: {Cache.MultiLayer, [Cache.ETS, MyApp.RedisCache]},
21+
name: :layered_cache,
22+
opts: []
23+
end
24+
```
25+
26+
## `__MODULE__` in Layers
27+
28+
You may include `__MODULE__` in the layer list to position the current
29+
module's own underlying cache within the chain. If `__MODULE__` is omitted,
30+
no local cache is created for the defining module—it acts as a pure facade.
31+
32+
```elixir
33+
defmodule MyApp.LayeredCache do
34+
use Cache,
35+
adapter: {Cache.MultiLayer, [Cache.ETS, __MODULE__, MyApp.RedisCache]},
36+
name: :layered_cache,
37+
opts: [uri: "redis://localhost"]
38+
end
39+
```
40+
41+
## Read Behaviour
42+
43+
Layers are iterated fastest → slowest (list order). On a hit from layer N,
44+
the value is backfilled into layers 1..N-1.
45+
46+
## Write Behaviour
47+
48+
Layers are written slowest → fastest (reverse list order). If a slow write
49+
fails, the write stops and an error is returned — preventing polluting faster
50+
layers with potentially-unsaved data.
51+
52+
## Fetch Callback (Optional)
53+
54+
If all layers miss, an optional fetch callback can supply the value. The
55+
fetched value is then backfilled into all layers.
56+
57+
Define it as a module callback or pass it via opts:
58+
59+
```elixir
60+
defmodule MyApp.LayeredCache do
61+
use Cache,
62+
adapter: {Cache.MultiLayer, [Cache.ETS, MyApp.RedisCache]},
63+
name: :layered_cache,
64+
opts: [on_fetch: &__MODULE__.fetch/1]
65+
66+
def fetch(key) do
67+
{:ok, "value_for_\#{key}"}
68+
end
69+
end
70+
```
71+
72+
## Options
73+
74+
#{NimbleOptions.docs([
75+
on_fetch: [
76+
type: {:or, [:mfa, {:fun, 1}]},
77+
doc: "Optional fetch callback invoked on total cache miss. Receives the key, returns `{:ok, value}` or `{:error, reason}`."
78+
],
79+
backfill_ttl: [
80+
type: {:or, [:pos_integer, nil]},
81+
doc: "TTL in milliseconds to use when backfilling layers on a hit from a slower layer. Defaults to nil (no expiry)."
82+
]
83+
])}
84+
"""
85+
86+
@behaviour Cache.Strategy
87+
88+
@opts_definition [
89+
on_fetch: [
90+
type: {:or, [:mfa, {:fun, 1}]},
91+
doc: "Optional fetch callback for cache miss."
92+
],
93+
backfill_ttl: [
94+
type: {:or, [:pos_integer, nil]},
95+
doc: "TTL for backfilled entries."
96+
]
97+
]
98+
99+
@impl Cache.Strategy
100+
def opts_definition, do: @opts_definition
101+
102+
@impl Cache.Strategy
103+
def child_spec({cache_name, _layers, _adapter_opts}) do
104+
%{
105+
id: :"#{cache_name}_multi_layer",
106+
start: {Agent, :start_link, [fn -> :ok end, [name: :"#{cache_name}_multi_layer"]]}
107+
}
108+
end
109+
110+
@impl Cache.Strategy
111+
def get(cache_name, key, layers, adapter_opts) do
112+
backfill_ttl = adapter_opts[:backfill_ttl]
113+
114+
case get_from_layers(cache_name, key, layers, adapter_opts, []) do
115+
{:hit, value, layers_to_backfill} ->
116+
backfill_layers(cache_name, key, layers_to_backfill, value, backfill_ttl)
117+
{:ok, value}
118+
119+
:miss ->
120+
fetch_on_miss(cache_name, key, layers, adapter_opts)
121+
end
122+
end
123+
124+
@impl Cache.Strategy
125+
def put(cache_name, key, ttl, value, layers, adapter_opts) do
126+
reversed = Enum.reverse(layers)
127+
put_to_layers(cache_name, key, ttl, value, reversed, adapter_opts)
128+
end
129+
130+
@impl Cache.Strategy
131+
def delete(cache_name, key, layers, _adapter_opts) do
132+
Enum.reduce_while(layers, :ok, fn layer, _acc ->
133+
case layer_delete(cache_name, key, layer) do
134+
:ok -> {:cont, :ok}
135+
{:error, _} = error -> {:halt, error}
136+
end
137+
end)
138+
end
139+
140+
defp get_from_layers(_cache_name, _key, [], _adapter_opts, _visited), do: :miss
141+
142+
defp get_from_layers(cache_name, key, [layer | rest], adapter_opts, visited) do
143+
case layer_get(cache_name, key, layer) do
144+
{:ok, nil} ->
145+
get_from_layers(cache_name, key, rest, adapter_opts, [layer | visited])
146+
147+
{:ok, value} ->
148+
{:hit, value, visited}
149+
150+
{:error, _} ->
151+
get_from_layers(cache_name, key, rest, adapter_opts, [layer | visited])
152+
end
153+
end
154+
155+
defp fetch_on_miss(cache_name, key, layers, adapter_opts) do
156+
on_fetch = adapter_opts[:on_fetch]
157+
158+
if is_nil(on_fetch) do
159+
{:ok, nil}
160+
else
161+
case invoke_callback(on_fetch, [key]) do
162+
{:ok, value} ->
163+
backfill_ttl = adapter_opts[:backfill_ttl]
164+
backfill_layers(cache_name, key, layers, value, backfill_ttl)
165+
{:ok, value}
166+
167+
{:error, _} = error ->
168+
error
169+
end
170+
end
171+
end
172+
173+
defp put_to_layers(_cache_name, _key, _ttl, _value, [], _adapter_opts), do: :ok
174+
175+
defp put_to_layers(cache_name, key, ttl, value, [layer | rest], adapter_opts) do
176+
case layer_put(cache_name, key, ttl, value, layer) do
177+
:ok -> put_to_layers(cache_name, key, ttl, value, rest, adapter_opts)
178+
{:error, _} = error -> error
179+
end
180+
end
181+
182+
defp backfill_layers(_cache_name, _key, [], _value, _ttl), do: :ok
183+
184+
defp backfill_layers(cache_name, key, [layer | rest], value, ttl) do
185+
layer_put(cache_name, key, ttl, value, layer)
186+
backfill_layers(cache_name, key, rest, value, ttl)
187+
end
188+
189+
defp layer_get(_cache_name, key, layer) when is_atom(layer) do
190+
if cache_module?(layer) do
191+
layer.get(key)
192+
else
193+
{:ok, nil}
194+
end
195+
end
196+
197+
defp layer_put(_cache_name, key, ttl, value, layer) when is_atom(layer) do
198+
if cache_module?(layer) do
199+
layer.put(key, ttl, value)
200+
else
201+
:ok
202+
end
203+
end
204+
205+
defp layer_delete(_cache_name, key, layer) when is_atom(layer) do
206+
if cache_module?(layer) do
207+
layer.delete(key)
208+
else
209+
:ok
210+
end
211+
end
212+
213+
defp cache_module?(module) do
214+
function_exported?(module, :get, 1) and function_exported?(module, :put, 2)
215+
end
216+
217+
defp invoke_callback({module, function, args}, extra_args) do
218+
apply(module, function, args ++ extra_args)
219+
end
220+
221+
defp invoke_callback(fun, args) when is_function(fun) do
222+
apply(fun, args)
223+
end
224+
end

lib/cache/redis.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,11 @@ defmodule Cache.Redis do
9999
end
100100

101101
def hash_set_many(keys_fields_values, ttl \\ nil) do
102+
keys_fields_values =
103+
Enum.map(keys_fields_values, fn {key, fields_values} ->
104+
{maybe_sandbox_key(key), fields_values}
105+
end)
106+
102107
@cache_adapter.hash_set_many(@cache_name, keys_fields_values, ttl, adapter_options())
103108
end
104109

lib/cache/strategy.ex

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
defmodule Cache.Strategy do
2+
@moduledoc """
3+
Behaviour for strategy-based cache adapters.
4+
5+
Strategy adapters compose over existing cache adapters to provide higher-level
6+
caching patterns such as consistent hashing, multi-layer cascading, and
7+
refresh-ahead semantics.
8+
9+
Unlike regular adapters which implement `Cache` directly, strategy adapters
10+
receive the underlying adapter module and its resolved options so they can
11+
delegate operations appropriately.
12+
13+
## Usage
14+
15+
Strategies are specified using the tuple format in `use Cache`:
16+
17+
```elixir
18+
use Cache,
19+
adapter: {Cache.HashRing, Cache.ETS},
20+
name: :my_cache,
21+
opts: [read_concurrency: true]
22+
```
23+
24+
The first element is the strategy module, the second is the underlying adapter
25+
(or strategy-specific configuration).
26+
"""
27+
28+
@doc """
29+
Returns the NimbleOptions schema for validating strategy-level opts.
30+
"""
31+
@callback opts_definition() :: Keyword.t()
32+
33+
@doc """
34+
Returns a supervisor child spec for the strategy.
35+
36+
Receives the cache name, strategy config (the second element of the adapter
37+
tuple), and the resolved underlying adapter opts.
38+
"""
39+
@callback child_spec({
40+
cache_name :: atom,
41+
strategy_config :: term,
42+
adapter_opts :: Keyword.t()
43+
}) :: Supervisor.child_spec() | :supervisor.child_spec()
44+
45+
@doc """
46+
Fetches a value from the cache using the strategy's routing/layering logic.
47+
"""
48+
@callback get(
49+
cache_name :: atom,
50+
key :: atom | String.t(),
51+
strategy_config :: term,
52+
adapter_opts :: Keyword.t()
53+
) :: ErrorMessage.t_res(any)
54+
55+
@doc """
56+
Stores a value in the cache using the strategy's routing/layering logic.
57+
"""
58+
@callback put(
59+
cache_name :: atom,
60+
key :: atom | String.t(),
61+
ttl :: pos_integer | nil,
62+
value :: any,
63+
strategy_config :: term,
64+
adapter_opts :: Keyword.t()
65+
) :: :ok | ErrorMessage.t()
66+
67+
@doc """
68+
Removes a value from the cache using the strategy's routing/layering logic.
69+
"""
70+
@callback delete(
71+
cache_name :: atom,
72+
key :: atom | String.t(),
73+
strategy_config :: term,
74+
adapter_opts :: Keyword.t()
75+
) :: :ok | ErrorMessage.t()
76+
77+
@doc """
78+
Returns true if the given module implements the `Cache.Strategy` behaviour.
79+
"""
80+
@spec strategy?(module()) :: boolean()
81+
def strategy?(module) do
82+
module
83+
|> module_behaviours()
84+
|> Enum.member?(Cache.Strategy)
85+
rescue
86+
_ -> false
87+
end
88+
89+
defp module_behaviours(module) do
90+
module
91+
|> :erlang.apply(:module_info, [:attributes])
92+
|> Keyword.get_values(:behaviour)
93+
|> List.flatten()
94+
rescue
95+
_ -> []
96+
end
97+
end

mix.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
1919
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
2020
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
21+
"libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
2122
"makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
2223
"makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
2324
"makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"},

0 commit comments

Comments
 (0)