-
Notifications
You must be signed in to change notification settings - Fork 68
/
caching.ex
488 lines (374 loc) · 16.6 KB
/
caching.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
if Code.ensure_loaded?(Decorator.Define) do
defmodule Nebulex.Caching do
@moduledoc ~S"""
Declarative annotation-based caching via function
[decorators](https://github.com/arjan/decorator).
For caching declaration, the abstraction provides three Elixir function
decorators: `cacheable `, `cache_evict`, and `cache_put`, which allow
functions to trigger cache population or cache eviction.
Let us take a closer look at each annotation.
> Inspired by [Spring Cache Abstraction](https://docs.spring.io/spring/docs/3.2.x/spring-framework-reference/html/cache.html).
## `cacheable` decorator
As the name implies, `cacheable` is used to demarcate functions that are
cacheable - that is, functions for whom the result is stored into the cache
so, on subsequent invocations (with the same arguments), the value in the
cache is returned without having to actually execute the function. In its
simplest form, the decorator/annotation declaration requires the name of
the cache associated with the annotated function:
@decorate cacheable(cache: Cache)
def get_account(id) do
# the logic for retrieving the account ...
end
In the snippet above, the function `get_account/1` is associated with the
cache named `Cache`. Each time the function is called, the cache is checked
to see whether the invocation has been already executed and does not have
to be repeated.
### Default Key Generation
Since caches are essentially key-value stores, each invocation of a cached
function needs to be translated into a suitable key for cache access.
Out of the box, the caching abstraction uses a simple key-generator
based on the following algorithm: `:erlang.phash2({module, func_name})`.
### Custom Key Generation Declaration
Since caching is generic, it is quite likely the target functions have
various signatures that cannot be simply mapped on top of the cache
structure. This tends to become obvious when the target function has
multiple arguments out of which only some are suitable for caching
(while the rest are used only by the function logic). For example:
@decorate cacheable(cache: Cache)
def get_account(email, include_users?) do
# the logic for retrieving the account ...
end
At first glance, while the boolean argument influences the way the account
is found, it is no use for the cache.
For such cases, the `cacheable` decorator allows the user to specify the
key explicitly based on the function attributes.
@decorate cacheable(cache: Cache, key: {Account, email})
def get_account(email, include_users?) do
# the logic for retrieving the account ...
end
@decorate cacheable(cache: Cache, key: {Account, user.account_id})
def get_user_account(%User{} = user) do
# the logic for retrieving the account ...
end
It is also possible passing options to the cache, like so:
@decorate cacheable(cache: Cache, key: {Account, email}, opts: [ttl: 300_000])
def get_account(email, include_users?) do
# the logic for retrieving the account ...
end
See the **"Shared Options"** section below.
### Functions with multiple clauses
Since [decorator lib](https://github.com/arjan/decorator#functions-with-multiple-clauses)
is used, it is important to be aware of the recommendations, warns,
limitations, and so on. In this case, for functions with multiple clauses
the general advice is to create an empty function head, and call the
decorator on that head, like so:
@decorate cacheable(cache: Cache, key: email)
def get_account(email \\ nil)
def get_account(nil), do: nil
def get_account(email) do
# the logic for retrieving the account ...
end
## `cache_put` decorator
For cases where the cache needs to be updated without interfering with the
function execution, one can use the `cache_put` decorator. That is, the
method will always be executed and its result placed into the cache
(according to the `cache_put` options). It supports the same options as
`cacheable`.
@decorate cache_put(cache: Cache, key: {Account, acct.email})
def update_account(%Account{} = acct, attrs) do
# the logic for updating the account ...
end
Note that using `cache_put` and `cacheable` annotations on the same function
is generally discouraged because they have different behaviors. While the
latter causes the method execution to be skipped by using the cache, the
former forces the execution in order to execute a cache update. This leads
to unexpected behavior and with the exception of specific corner-cases
(such as decorators having conditions that exclude them from each other),
such declarations should be avoided.
## `cache_evict` decorator
The cache abstraction allows not just the population of a cache store but
also eviction. This process is useful for removing stale or unused data from
the cache. Opposed to `cacheable`, the decorator `cache_evict` demarcates
functions that perform cache eviction, which are functions that act as
triggers for removing data from the cache. The `cache_evict` decorator not
only allows a key to be specified, but also a set of keys. Besides, extra
options like`all_entries` which indicates whether a cache-wide eviction
needs to be performed rather than just an entry one (based on the key or
keys):
@decorate cache_evict(cache: Cache, key: {Account, email})
def delete_account_by email(email) do
# the logic for deleting the account ...
end
@decorate cacheable(cache: Cache, keys: [{Account, acct.id}, {Account, acct.email}])
def delete_account(%Account{} = acct) do
# the logic for deleting the account ...
end
@decorate cacheable(cache: Cache, all_entries: true)
def delete_all_accounts do
# the logic for deleting all the accounts ...
end
The option `all_entries:` comes in handy when an entire cache region needs
to be cleared out - rather than evicting each entry (which would take a
long time since it is inefficient), all the entries are removed in one
operation as shown above.
## Shared Options
All three cache annotations explained previously accept the following
options:
* `:cache` - Defines what cache to use (required). Raises `ArgumentError`
if the option is not present.
* `:key` - Defines the cache access key (optional). If this option
is not present, a default key is generated by hashing the tuple
`{module, fun_name}`; the first element is the caller module and the
second one the function name (`:erlang.phash2({module, fun})`).
* `:opts` - Defines the cache options that will be passed as argument
to the invoked cache function (optional).
* `:match` - Match function `(term -> boolean | {true, term})` (optional).
This function is for matching and decide whether or not the code-block
evaluation result is cached or not. If `true` the code-block evaluation
result is cached as it is (the default). If `{true, value}` is returned,
then the `value` is what is cached (useful to control what to cache).
Otherwise, none result is stored in the cache.
## Putting all together
Supposing we are using `Ecto` and we want to define some cacheable functions
within the context `MyApp.Accounts`:
# The config
config :my_app, MyApp.Cache,
gc_interval: 86_400_000, #=> 1 day
backend: :shards
# The Cache
defmodule MyApp.Cache do
use Nebulex.Cache,
otp_app: :my_app,
adapter: Nebulex.Adapters.Local
end
# Some Ecto schema
defmodule MyApp.Accounts.User do
use Ecto.Schema
schema "users" do
field(:username, :string)
field(:password, :string)
field(:role, :string)
end
def changeset(user, attrs) do
user
|> cast(attrs, [:username, :password, :role])
|> validate_required([:username, :password, :role])
end
end
# Accounts context
defmodule MyApp.Accounts do
use Nebulex.Caching
alias MyApp.Accounts.User
alias MyApp.{Cache, Repo}
@ttl Nebulex.Time.expiry_time(1, :hour)
@decorate cacheable(cache: Cache, key: {User, id}, opts: [ttl: @ttl])
def get_user!(id) do
Repo.get!(User, id)
end
@decorate cacheable(cache: Cache, key: {User, username}, opts: [ttl: @ttl])
def get_user_by_username(username) do
Repo.get_by(User, [username: username])
end
@decorate cache_put(
cache: Cache,
keys: [{User, usr.id}, {User, usr.username}],
match: &match_update/1
)
def update_user(%User{} = usr, attrs) do
usr
|> User.changeset(attrs)
|> Repo.update()
end
defp match_update({:ok, usr}), do: {true, usr}
defp match_update({:error, _}), do: false
@decorate cache_evict(cache: Cache, keys: [{User, usr.id}, {User, usr.username}])
def delete_user(%User{} = usr) do
Repo.delete(usr)
end
def create_user(attrs \\ %{}) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
end
See [Cache Usage Patters Guide](http://hexdocs.pm/nebulex/cache-usage-patterns.html).
"""
use Decorator.Define, cacheable: 1, cache_evict: 1, cache_put: 1
alias Nebulex.Caching
@doc """
Provides a way of annotating functions to be cached (cacheable aspect).
The returned value by the code block is cached if it doesn't exist already
in cache, otherwise, it is returned directly from cache and the code block
is not executed.
## Options
See the "Shared options" section at the module documentation.
## Examples
defmodule MyApp.Example do
use Nebulex.Caching
alias MyApp.Cache
@ttl Nebulex.Time.expiry_time(1, :hour)
@decorate cacheable(cache: Cache, key: name)
def get_by_name(name, age) do
# your logic (maybe the loader to retrieve the value from the SoR)
end
@decorate cacheable(cache: Cache, key: age, opts: [ttl: @ttl])
def get_by_age(age) do
# your logic (maybe the loader to retrieve the value from the SoR)
end
@decorate cacheable(cache: Cache, key: clauses, match: &match_fun/1)
def all(clauses) do
# your logic (maybe the loader to retrieve the value from the SoR)
end
defp match_fun([]), do: false
defp match_fun(_), do: true
end
The **Read-through** pattern is supported by this decorator. The loader to
retrieve the value from the system-of-record (SoR) is your function's logic
and the rest is provided by the macro under-the-hood.
"""
def cacheable(attrs, block, context) do
caching_action(:cacheable, attrs, block, context)
end
@doc """
Provides a way of annotating functions to be evicted; but updating the
cached key instead of deleting it.
The content of the cache is updated without interfering with the function
execution. That is, the method would always be executed and the result
cached.
The difference between `cacheable/3` and `cache_put/3` is that `cacheable/3`
will skip running the function if the key exists in the cache, whereas
`cache_put/3` will actually run the function and then put the result in
the cache.
## Options
See the "Shared options" section at the module documentation.
## Examples
defmodule MyApp.Example do
use Nebulex.Caching
alias MyApp.Cache
@ttl Nebulex.Time.expiry_time(1, :hour)
@decorate cache_put(cache: Cache, key: id, opts: [ttl: @ttl])
def update!(id, attrs \\ %{}) do
# your logic (maybe write data to the SoR)
end
@decorate cache_put(cache: Cache, key: id, match: &match_fun/1, opts: [ttl: @ttl])
def update(id, attrs \\ %{}) do
# your logic (maybe write data to the SoR)
end
defp match_fun({:ok, updated}), do: {true, updated}
defp match_fun({:error, _}), do: false
end
The **Write-through** pattern is supported by this decorator. Your function
provides the logic to write data to the system-of-record (SoR) and the rest
is provided by the decorator under-the-hood.
"""
def cache_put(attrs, block, context) do
caching_action(:cache_put, attrs, block, context)
end
@doc """
Provides a way of annotating functions to be evicted (eviction aspect).
On function's completion, the given key or keys (depends on the `:key` and
`:keys` options) are deleted from the cache.
## Options
* `:keys` - Defines the set of keys to be evicted from cache on function
completion.
* `:all_entries` - Defines if all entries must be removed on function
completion. Defaults to `false`.
See the "Shared options" section at the module documentation.
## Examples
defmodule MyApp.Example do
use Nebulex.Caching
alias MyApp.Cache
@decorate cache_evict(cache: Cache, key: id)
def delete(id) do
# your logic (maybe write/delete data to the SoR)
end
@decorate cache_evict(cache: Cache, keys: [object.name, object.id])
def delete_object(object) do
# your logic (maybe write/delete data to the SoR)
end
@decorate cache_evict(cache: Cache, all_entries: true)
def delete_all do
# your logic (maybe write/delete data to the SoR)
end
end
The **Write-through** pattern is supported by this decorator. Your function
provides the logic to write data to the system-of-record (SoR) and the rest
is provided by the decorator under-the-hood. But in contrast with `update`
decorator, when the data is written to the SoR, the key for that value is
deleted from cache instead of updated.
"""
def cache_evict(attrs, block, context) do
caching_action(:cache_evict, attrs, block, context)
end
## Private Functions
defp caching_action(action, attrs, block, context) do
cache = attrs[:cache] || raise ArgumentError, "expected cache: to be given as argument"
key_var =
Keyword.get(
attrs,
:key,
quote(do: :erlang.phash2({unquote(context.module), unquote(context.name)}))
)
keys_var = Keyword.get(attrs, :keys, [])
match_var = Keyword.get(attrs, :match, quote(do: fn _ -> true end))
opts_var = Keyword.get(attrs, :opts, [])
action_logic = action_logic(action, block, attrs)
quote do
cache = unquote(cache)
key = unquote(key_var)
keys = unquote(keys_var)
opts = unquote(opts_var)
match = unquote(match_var)
unquote(action_logic)
end
end
defp action_logic(:cacheable, block, _attrs) do
quote do
if value = cache.get(key, opts) do
value
else
unquote(match_logic(block))
end
end
end
defp action_logic(:cache_put, block, _attrs) do
match_logic(block)
end
defp match_logic(block) do
quote do
Caching.eval_match(unquote(block), match, cache, key, opts)
end
end
@doc """
This function is for internal purposes.
> **NOTE:** Workaround to avoid dialyzer warnings when using declarative
annotation-based caching via decorators.
"""
@spec eval_match(term, (term -> boolean | {true, term}), module, term, Keyword.t()) :: term
def eval_match(result, match, cache, key, opts) do
case match.(result) do
{true, value} ->
:ok = cache.put(key, value, opts)
result
true ->
:ok = cache.put(key, result, opts)
result
false ->
result
end
end
defp action_logic(:cache_evict, block, attrs) do
all_entries? = Keyword.get(attrs, :all_entries, false)
quote do
if unquote(all_entries?) do
cache.flush()
else
Enum.each([key | keys], fn k ->
if k, do: cache.delete(k)
end)
end
unquote(block)
end
end
end
end