changed README.md
 
@@ -88,12 +88,29 @@ Mentat publishes multiple telemetry events.
88
88
89
89
* `cache` - The cache name.
90
90
91
+ ## Contracts
92
+
93
+ Mentat supports `Oath` contracts. This helps ensure that you're using Mentat correctly
94
+ and that Mentat is returning what you expect. You can enable contracts by setting
95
+
96
+ ```elixir
97
+ config :oath,
98
+ enable_contracts: true
99
+ ```
100
+
101
+ And then recompiling Mentat in dev and test environments:
102
+
103
+ ```
104
+ MIX_ENV=dev mix deps.compile mentat --force
105
+ MIX_ENV=test mix deps.compile mentat --force
106
+ ```
107
+
91
108
## Installation
92
109
93
110
```elixir
94
111
def deps do
95
112
[
96
- {:mentat, "~> 0.5"}
113
+ {:mentat, "~> 0.7"}
97
114
]
98
115
end
99
116
```
changed hex_metadata.config
 
@@ -14,9 +14,14 @@
14
14
{<<"optional">>,false},
15
15
{<<"repository">>,<<"hexpm">>},
16
16
{<<"requirement">>,<<"~> 0.4">>}],
17
- [{<<"app">>,<<"nimble_options">>},
18
- {<<"name">>,<<"nimble_options">>},
17
+ [{<<"app">>,<<"norm">>},
18
+ {<<"name">>,<<"norm">>},
19
19
{<<"optional">>,false},
20
20
{<<"repository">>,<<"hexpm">>},
21
- {<<"requirement">>,<<"~> 0.3">>}]]}.
22
- {<<"version">>,<<"0.6.1">>}.
21
+ {<<"requirement">>,<<"~> 0.12">>}],
22
+ [{<<"app">>,<<"oath">>},
23
+ {<<"name">>,<<"oath">>},
24
+ {<<"optional">>,false},
25
+ {<<"repository">>,<<"hexpm">>},
26
+ {<<"requirement">>,<<"~> 0.1">>}]]}.
27
+ {<<"version">>,<<"0.7.0">>}.
changed lib/mentat.ex
 
@@ -6,50 +6,35 @@ defmodule Mentat do
6
6
|> Enum.fetch!(1)
7
7
8
8
use Supervisor
9
+ use Oath
10
+
11
+ @type cache_opts() :: Keyword.t()
12
+ @type name :: atom()
13
+ @type key :: term()
14
+ @type value :: term()
15
+ @type put_opts :: [
16
+ {:ttl, pos_integer() | :infinity},
17
+ ]
9
18
10
19
alias Mentat.Janitor
11
20
12
- @cache_options [
13
- name: [
14
- type: :atom,
15
- required: true,
16
- ],
17
- cleanup_interval: [
18
- type: :pos_integer,
19
- default: 5_000,
20
- doc: "How often the janitor process will remove old keys."
21
- ],
22
- ets_args: [
23
- type: :any,
24
- doc: "Additional arguments to pass to `:ets.new/2`.",
25
- default: [],
26
- ],
27
- limit: [
28
- doc: "Limits to the number of keys a cache will store.",
29
- type: :keyword_list,
30
- required: false,
31
- default: []
32
- ],
33
- ttl: [
34
- doc: "Default ttl in milliseconds to use for all keys",
35
- type: :any,
36
- required: false,
37
- default: :infinity
38
- ]
39
- ]
21
+ defp cache_opts do
22
+ import Norm
40
23
41
- @limit_opts [
42
- size: [
43
- type: :pos_integer,
44
- doc: "The maximum number of values to store in the cache.",
45
- required: true
46
- ],
47
- reclaim: [
48
- type: :any,
49
- doc: "The percentage of keys to reclaim if the limit is exceeded.",
50
- default: 0.1
51
- ]
52
- ]
24
+ coll_of(
25
+ one_of([
26
+ {:name, spec(is_atom)},
27
+ {:cleanup_interval, spec(is_integer and & &1 > 0)},
28
+ {:ets_args, spec(is_list)},
29
+ {:ttl, one_of([spec(is_integer and & &1 > 0), :infinity])},
30
+ {:clock, spec(is_atom)},
31
+ {:limit, coll_of(one_of([
32
+ {:size, spec(is_integer and & &1 > 0)},
33
+ {:reclaim, spec(is_float)},
34
+ ]))}
35
+ ])
36
+ )
37
+ end
53
38
54
39
@doc false
55
40
def child_spec(opts) do
 
@@ -66,36 +51,21 @@ defmodule Mentat do
66
51
Starts a new cache.
67
52
68
53
Options:
69
-
70
- #{NimbleOptions.docs(@cache_options)}
54
+ * `:name` - the cache name as an atom. required.
55
+ * `:cleanup_interval` - How often the janitor process will remove old keys. Defaults to 5_000.
56
+ * `:ets_args` - Additional arguments to pass to `:ets.new/2`.
57
+ * `:ttl` - The default ttl for all keys. Default `:infinity`.
58
+ * `:limit` - Limits to the number of keys a cache will store. Defaults to `:none`.
59
+ * `size` - The maximum number of values to store in the cache.
60
+ * `reclaim` - The percentage of keys to reclaim if the limit is exceeded. Defaults to 0.1.
71
61
"""
62
+ @spec start_link(cache_opts()) :: Supervisor.on_start()
72
63
def start_link(args) do
73
- args = NimbleOptions.validate!(args, @cache_options)
64
+ args = Norm.conform!(args, cache_opts())
74
65
name = args[:name]
75
66
Supervisor.start_link(__MODULE__, args, name: name)
76
67
end
77
68
78
- @doc """
79
- Retrieves a value from a the cache. Returns `nil` if the key is not found.
80
- """
81
- def get(cache, key, opts \\ []) do
82
- now = ms_time(opts)
83
-
84
- case :ets.lookup(cache, key) do
85
- [] ->
86
- :telemetry.execute([:mentat, :get], %{status: :miss}, %{key: key, cache: cache})
87
- nil
88
-
89
- [{^key, _val, ts, ttl}] when is_integer(ttl) and ts + ttl <= now ->
90
- :telemetry.execute([:mentat, :get], %{status: :miss}, %{key: key, cache: cache})
91
- nil
92
-
93
- [{^key, val, _ts, _expire_at}] ->
94
- :telemetry.execute([:mentat, :get], %{status: :hit}, %{key: key, cache: cache})
95
- val
96
- end
97
- end
98
-
99
69
@doc """
100
70
Fetches a value or executes the fallback function. The function can return
101
71
either `{:commit, term()}` or `{:ignore, term()}`. If `{:commit, term()}` is
 
@@ -116,8 +86,9 @@ defmodule Mentat do
116
86
end)
117
87
```
118
88
"""
89
+ @spec fetch(name(), key(), put_opts(), (key() -> {:commit, value()} | {:ignore, value()})) :: value() | nil
119
90
def fetch(cache, key, opts \\ [], fallback) do
120
- with nil <- get(cache, key, opts) do
91
+ with nil <- get(cache, key) do
121
92
case fallback.(key) do
122
93
{:commit, value} ->
123
94
put(cache, key, value, opts)
 
@@ -129,66 +100,126 @@ defmodule Mentat do
129
100
end
130
101
end
131
102
103
+ @doc """
104
+ Retrieves a value from a the cache. Returns `nil` if the key is not found.
105
+ """
106
+ @spec get(name(), key()) :: value() | nil
107
+ def get(cache, key) do
108
+ config = get_config(cache)
109
+ now = ms_time(config.clock)
110
+
111
+ case :ets.lookup(cache, key) do
112
+ [] ->
113
+ :telemetry.execute([:mentat, :get], %{status: :miss}, %{key: key, cache: cache})
114
+ nil
115
+
116
+ [{^key, _val, ts, ttl}] when is_integer(ttl) and ts + ttl <= now ->
117
+ :telemetry.execute([:mentat, :get], %{status: :miss}, %{key: key, cache: cache})
118
+ nil
119
+
120
+ [{^key, val, _ts, _expire_at}] ->
121
+ :telemetry.execute([:mentat, :get], %{status: :hit}, %{key: key, cache: cache})
122
+ val
123
+ end
124
+ end
125
+
126
+
132
127
@doc """
133
128
Puts a new key into the cache. See the "TTLs" section for a list of
134
129
options.
135
130
"""
136
- def put(cache, key, value, opts \\ []) do
137
- %{limit: limit, ttl: default_ttl} = :persistent_term.get({__MODULE__, cache})
131
+ @spec put(name(), key(), value(), put_opts()) :: value() | no_return()
132
+ @decorate pre("ttls are positive", fn _, _, _, opts ->
133
+ if opts[:ttl], do: opts[:ttl] > 0, else: true
134
+ end)
135
+ @decorate post("value is returned", fn _, _, value, _, return ->
136
+ value == return
137
+ end)
138
+ def put(cache, key, value, opts \\ [])
139
+ def put(cache, key, value, opts) do
140
+ config = get_config(cache)
138
141
:telemetry.execute([:mentat, :put], %{}, %{key: key, cache: cache})
139
142
140
- now = ms_time(opts)
141
- ttl = Keyword.get(opts, :ttl) || default_ttl
143
+ now = ms_time(config.clock)
144
+ ttl = opts[:ttl] || config.ttl
142
145
143
- result = :ets.insert(cache, {key, value, now, ttl})
146
+ if ttl < 0 do
147
+ raise ArgumentError, "`:ttl` must be greater than 0"
148
+ end
149
+
150
+ true = :ets.insert(cache, {key, value, now, ttl})
144
151
145
152
# If we've reached the limit on the table, we need to purge a number of old
146
153
# keys. We do this by calling the janitor process and telling it to purge.
147
154
# This will, in turn call immediately back into the remove_oldest function.
148
155
# The back and forth here is confusing to follow, but its necessary because
149
156
# we want to do the purging in a different process.
150
- if limit != :none && :ets.info(cache, :size) > limit.size do
151
- count = ceil(limit.size * limit.reclaim)
157
+ if config.limit != :none && :ets.info(cache, :size) > config.limit.size do
158
+ count = ceil(config.limit.size * config.limit.reclaim)
152
159
Janitor.reclaim(janitor(cache), count)
153
160
end
154
161
155
- result
162
+ value
156
163
end
157
164
158
165
@doc """
159
166
Updates a keys inserted at time. This is useful in conjunction with limits
160
- when you want to evict the oldest keys.
167
+ when you want to evict the oldest keys. Returns `true` if the key was found
168
+ and `false` if it was not.
161
169
"""
162
- def touch(cache, key, opts \\ []) do
163
- :ets.update_element(cache, key, {3, ms_time(opts)})
170
+ @spec touch(name(), key()) :: boolean()
171
+ def touch(cache, key) do
172
+ config = get_config(cache)
173
+ now = ms_time(config.clock)
174
+ :ets.update_element(cache, key, {3, now})
164
175
end
165
176
166
177
@doc """
167
178
Deletes a key from the cache
168
179
"""
180
+ @spec delete(name(), key()) :: true
169
181
def delete(cache, key) do
170
182
:ets.delete(cache, key)
171
183
end
172
184
173
185
@doc """
174
- Returns a list of all keys.
186
+ Returns a list of all keys. By default this function only returns keys
187
+ that have no exceeded their TTL. You can pass the `all: true` option to the function
188
+ in order to return all present keys, which may include keys that have exceeded
189
+ their TTL but have not been purged yet.
175
190
"""
176
- def keys(cache) do
177
- # :ets.fun2ms(fn({key, _, _} -> key end))
178
- ms = [{{:"$1", :_, :_, :_}, [], [:"$1"]}]
191
+ @spec keys(name()) :: [key()]
192
+ def keys(cache, opts \\ []) do
193
+ ms = if opts[:all] == true do
194
+ [{{:"$1", :_, :_, :_}, [], [:"$1"]}]
195
+ else
196
+ config = get_config(cache)
197
+ now = ms_time(config.clock)
198
+ [
199
+ {{:"$1", :_, :"$2", :"$3"},
200
+ [
201
+ {:orelse,
202
+ {:andalso, {:is_integer, :"$3"}, {:>, {:+, :"$2", :"$3"}, now}},
203
+ {:==, :"$3", :infinity}}
204
+ ], [:"$1"]}
205
+ ]
206
+ end
207
+
179
208
:ets.select(cache, ms)
180
209
end
181
210
182
211
@doc """
183
212
Removes all keys from the cache.
184
213
"""
214
+ @spec purge(name()) :: true
185
215
def purge(cache) do
186
216
:ets.delete_all_objects(cache)
187
217
end
188
218
189
219
@doc false
190
- def remove_expired(cache, opts \\ []) do
191
- now = ms_time(opts)
220
+ def remove_expired(cache) do
221
+ config = get_config(cache)
222
+ now = ms_time(config.clock)
192
223
193
224
# Find all expired keys by selecting the timestamp and ttl, adding them together
194
225
# and finding the keys that are lower than the current time
 
@@ -217,16 +248,16 @@ defmodule Mentat do
217
248
end
218
249
219
250
def init(args) do
220
- name = Keyword.get(args, :name)
221
- interval = Keyword.get(args, :cleanup_interval)
222
- limit = Keyword.get(args, :limit)
223
- limit = if limit == [], do: :none, else: Map.new(NimbleOptions.validate!(limit, @limit_opts))
224
- ets_args = Keyword.get(args, :ets_args)
225
- ttl = Keyword.get(args, :ttl) || :infinity
226
-
251
+ name = args[:name]
252
+ interval = args[:cleanup_interval] || 5_000
253
+ limit = args[:limit] || :none
254
+ limit = if limit != :none, do: Map.new(limit), else: limit
255
+ ets_args = args[:ets_args] || []
256
+ clock = args[:clock] || System
257
+ ttl = args[:ttl] || :infinity
227
258
^name = :ets.new(name, [:set, :named_table, :public] ++ ets_args)
228
259
229
- :persistent_term.put({__MODULE__, name}, %{limit: limit, ttl: ttl})
260
+ put_config(name, %{limit: limit, ttl: ttl, clock: clock})
230
261
231
262
janitor_opts = [
232
263
name: janitor(name),
 
@@ -241,12 +272,21 @@ defmodule Mentat do
241
272
Supervisor.init(children, strategy: :one_for_one)
242
273
end
243
274
244
- defp timer(opts) do
245
- Keyword.get(opts, :clock, System)
275
+ def stop(name) do
276
+ Supervisor.stop(name)
246
277
end
247
278
248
- defp ms_time(opts) do
249
- timer(opts).monotonic_time(:millisecond)
279
+ defp put_config(cache, config) do
280
+ :persistent_term.put({__MODULE__, cache}, config)
281
+ end
282
+
283
+ defp get_config(cache) do
284
+ :persistent_term.get({__MODULE__, cache})
285
+ end
286
+
287
+ defp ms_time(clock) do
288
+ # Clock is going `System` in most cases and is set inside the init function
289
+ clock.monotonic_time(:millisecond)
250
290
end
251
291
252
292
defp janitor(name) do
changed mix.exs
 
@@ -2,13 +2,14 @@ defmodule Mentat.MixProject do
2
2
use Mix.Project
3
3
4
4
@source_url "https://github.com/keathley/mentat"
5
- @version "0.6.1"
5
+ @version "0.7.0"
6
6
7
7
def project do
8
8
[
9
9
app: :mentat,
10
10
version: @version,
11
11
elixir: "~> 1.9",
12
+ elixirc_paths: elixirc_paths(Mix.env()),
12
13
start_permanent: Mix.env() == :prod,
13
14
deps: deps(),
14
15
description: description(),
 
@@ -19,6 +20,10 @@ defmodule Mentat.MixProject do
19
20
]
20
21
end
21
22
23
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
24
+ defp elixirc_paths(:dev), do: ["lib", "test/support/test_usage.ex"]
25
+ defp elixirc_paths(_), do: ["lib"]
26
+
22
27
def application do
23
28
[
24
29
extra_applications: [:logger]
 
@@ -28,10 +33,13 @@ defmodule Mentat.MixProject do
28
33
defp deps do
29
34
[
30
35
{:telemetry, "~> 0.4"},
31
- {:nimble_options, "~> 0.3"},
36
+ {:norm, "~> 0.12"},
37
+ {:oath, "~> 0.1"},
32
38
33
- {:credo, "~> 1.3.1", only: [:dev, :test], runtime: false},
34
- {:ex_doc, "~> 0.19", only: [:dev, :test]}
39
+ {:credo, "~> 1.5", only: [:dev, :test], runtime: false},
40
+ {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false},
41
+ {:ex_doc, "~> 0.19", only: [:dev, :test]},
42
+ {:propcheck, "~> 1.3", only: [:dev, :test]},
35
43
]
36
44
end