safe_memoize 0.9.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +10 -0
- data/CHANGELOG.md +29 -0
- data/README.md +181 -2
- data/ROADMAP.md +5 -36
- data/Rakefile +14 -1
- data/UPGRADING.md +197 -0
- data/lib/safe_memoize/adapters/opentelemetry.rb +25 -0
- data/lib/safe_memoize/adapters/statsd.rb +31 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +1 -0
- data/lib/safe_memoize/cache_record_methods.rb +1 -0
- data/lib/safe_memoize/cache_store_methods.rb +1 -0
- data/lib/safe_memoize/class_methods.rb +173 -19
- data/lib/safe_memoize/configuration.rb +47 -2
- data/lib/safe_memoize/custom_key_methods.rb +1 -0
- data/lib/safe_memoize/hooks_methods.rb +1 -0
- data/lib/safe_memoize/inspection_methods.rb +2 -1
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/lru_methods.rb +1 -0
- data/lib/safe_memoize/public_custom_key_methods.rb +23 -0
- data/lib/safe_memoize/public_methods.rb +162 -5
- data/lib/safe_memoize/public_metrics_methods.rb +20 -0
- data/lib/safe_memoize/rails/middleware.rb +2 -0
- data/lib/safe_memoize/rails/request_scoped.rb +3 -0
- data/lib/safe_memoize/release_tooling.rb +1 -0
- data/lib/safe_memoize/stores/base.rb +85 -0
- data/lib/safe_memoize/stores/memory.rb +70 -0
- data/lib/safe_memoize/stores/rails_cache.rb +128 -0
- data/lib/safe_memoize/stores/redis.rb +111 -0
- data/lib/safe_memoize/version.rb +2 -1
- data/lib/safe_memoize.rb +56 -0
- data/rbi/safe_memoize.rbi +245 -0
- data/sig/safe_memoize.rbs +43 -6
- metadata +8 -1
|
@@ -1,19 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
|
+
# Public instance methods mixed into every class that prepends {SafeMemoize}.
|
|
4
5
|
module PublicMethods
|
|
6
|
+
# Returns +true+ if the given call is currently cached (and not expired).
|
|
7
|
+
#
|
|
8
|
+
# Always returns +false+ when a block is provided, because block-taking methods
|
|
9
|
+
# cannot be safely keyed by arguments alone.
|
|
10
|
+
#
|
|
11
|
+
# @param method_name [Symbol, String]
|
|
12
|
+
# @param args [Array] positional arguments used to look up the entry
|
|
13
|
+
# @param kwargs [Hash] keyword arguments used to look up the entry
|
|
14
|
+
# @return [Boolean]
|
|
5
15
|
def memoized?(method_name, *args, **kwargs, &block)
|
|
6
16
|
return false if block
|
|
7
17
|
|
|
8
|
-
cache_key =
|
|
18
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
9
19
|
|
|
10
20
|
with_memo_lock do
|
|
11
21
|
memo_cache_hit?(cache_key)
|
|
12
22
|
end
|
|
13
23
|
end
|
|
14
24
|
|
|
25
|
+
# Returns the number of seconds until the cached entry expires.
|
|
26
|
+
#
|
|
27
|
+
# @param method_name [Symbol, String]
|
|
28
|
+
# @param args [Array]
|
|
29
|
+
# @param kwargs [Hash]
|
|
30
|
+
# @return [Float] seconds remaining (may be 0 if already expired)
|
|
31
|
+
# @return [nil] if the entry has no TTL or is not cached
|
|
15
32
|
def memo_ttl_remaining(method_name, *args, **kwargs)
|
|
16
|
-
cache_key =
|
|
33
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
17
34
|
|
|
18
35
|
with_memo_lock do
|
|
19
36
|
record = memo_cache_record(cache_key)
|
|
@@ -27,6 +44,10 @@ module SafeMemoize
|
|
|
27
44
|
end
|
|
28
45
|
end
|
|
29
46
|
|
|
47
|
+
# Returns the number of live cached entries for a method (or all methods).
|
|
48
|
+
#
|
|
49
|
+
# @param method_name [Symbol, String, nil] when omitted, counts all methods
|
|
50
|
+
# @return [Integer]
|
|
30
51
|
def memo_count(*method_name)
|
|
31
52
|
scoped_method = safe_memo_scoped_method(method_name)
|
|
32
53
|
|
|
@@ -35,6 +56,13 @@ module SafeMemoize
|
|
|
35
56
|
end
|
|
36
57
|
end
|
|
37
58
|
|
|
59
|
+
# Returns metadata hashes describing each cached entry.
|
|
60
|
+
#
|
|
61
|
+
# Each hash contains +:args+, +:kwargs+ (or +:custom_key+ for custom-keyed entries),
|
|
62
|
+
# and +:method+ when no +method_name+ filter is applied.
|
|
63
|
+
#
|
|
64
|
+
# @param method_name [Symbol, String, nil] when omitted, returns entries for all methods
|
|
65
|
+
# @return [Array<Hash>]
|
|
38
66
|
def memo_keys(*method_name)
|
|
39
67
|
scoped_method = safe_memo_scoped_method(method_name)
|
|
40
68
|
|
|
@@ -43,6 +71,12 @@ module SafeMemoize
|
|
|
43
71
|
end
|
|
44
72
|
end
|
|
45
73
|
|
|
74
|
+
# Returns metadata hashes including the cached value for each entry.
|
|
75
|
+
#
|
|
76
|
+
# Each hash contains all fields from {#memo_keys} plus +:value+.
|
|
77
|
+
#
|
|
78
|
+
# @param method_name [Symbol, String, nil] when omitted, returns entries for all methods
|
|
79
|
+
# @return [Array<Hash>]
|
|
46
80
|
def memo_values(*method_name)
|
|
47
81
|
scoped_method = safe_memo_scoped_method(method_name)
|
|
48
82
|
|
|
@@ -51,42 +85,90 @@ module SafeMemoize
|
|
|
51
85
|
end
|
|
52
86
|
end
|
|
53
87
|
|
|
88
|
+
# Registers a hook that fires on every cache hit.
|
|
89
|
+
#
|
|
90
|
+
# @yield [cache_key, record] called synchronously inside the cache lock
|
|
91
|
+
# @yieldparam cache_key [Array] the internal cache key
|
|
92
|
+
# @yieldparam record [Hash] the cache record (+:value+, +:expires_at+, +:cached_at+)
|
|
93
|
+
# @return [void]
|
|
94
|
+
# @raise [ArgumentError] if no block is given
|
|
54
95
|
def on_memo_expire(&block)
|
|
55
96
|
raise ArgumentError, "block required" unless block
|
|
56
97
|
|
|
57
98
|
register_memo_hook(:on_expire, &block)
|
|
58
99
|
end
|
|
59
100
|
|
|
101
|
+
# Registers a hook that fires when an LRU eviction occurs.
|
|
102
|
+
#
|
|
103
|
+
# @yield [cache_key, record]
|
|
104
|
+
# @return [void]
|
|
105
|
+
# @raise [ArgumentError] if no block is given
|
|
60
106
|
def on_memo_evict(&block)
|
|
61
107
|
raise ArgumentError, "block required" unless block
|
|
62
108
|
|
|
63
109
|
register_memo_hook(:on_evict, &block)
|
|
64
110
|
end
|
|
65
111
|
|
|
112
|
+
# Registers a hook that fires on every cache hit.
|
|
113
|
+
#
|
|
114
|
+
# @yield [cache_key, record]
|
|
115
|
+
# @return [void]
|
|
116
|
+
# @raise [ArgumentError] if no block is given
|
|
66
117
|
def on_memo_hit(&block)
|
|
67
118
|
raise ArgumentError, "block required" unless block
|
|
68
119
|
|
|
69
120
|
register_memo_hook(:on_hit, &block)
|
|
70
121
|
end
|
|
71
122
|
|
|
123
|
+
# Registers a hook that fires on every cache miss (before the value is stored).
|
|
124
|
+
#
|
|
125
|
+
# @yield [cache_key, record]
|
|
126
|
+
# @return [void]
|
|
127
|
+
# @raise [ArgumentError] if no block is given
|
|
72
128
|
def on_memo_miss(&block)
|
|
73
129
|
raise ArgumentError, "block required" unless block
|
|
74
130
|
|
|
75
131
|
register_memo_hook(:on_miss, &block)
|
|
76
132
|
end
|
|
77
133
|
|
|
134
|
+
# Registers a hook that fires whenever a value is written to the cache
|
|
135
|
+
# (miss, {#warm_memo}, or {#load_memo}).
|
|
136
|
+
#
|
|
137
|
+
# @yield [cache_key, record]
|
|
138
|
+
# @return [void]
|
|
139
|
+
# @raise [ArgumentError] if no block is given
|
|
78
140
|
def on_memo_store(&block)
|
|
79
141
|
raise ArgumentError, "block required" unless block
|
|
80
142
|
|
|
81
143
|
register_memo_hook(:on_store, &block)
|
|
82
144
|
end
|
|
83
145
|
|
|
146
|
+
# Removes all registered hooks, or only hooks of a specific type.
|
|
147
|
+
#
|
|
148
|
+
# @param hook_type [Symbol, nil] one of +:on_hit+, +:on_miss+, +:on_store+,
|
|
149
|
+
# +:on_expire+, +:on_evict+; when +nil+ all hooks are cleared
|
|
150
|
+
# @return [void]
|
|
84
151
|
def clear_memo_hooks(hook_type = nil)
|
|
85
152
|
with_memo_lock do
|
|
86
153
|
_clear_memo_hooks(hook_type)
|
|
87
154
|
end
|
|
88
155
|
end
|
|
89
156
|
|
|
157
|
+
# Pre-populates a cache entry with the value returned by the block without
|
|
158
|
+
# calling the memoized method itself.
|
|
159
|
+
#
|
|
160
|
+
# Useful for warming caches from a serialized snapshot or an external source.
|
|
161
|
+
#
|
|
162
|
+
# @param method_name [Symbol, String]
|
|
163
|
+
# @param args [Array] positional arguments that identify the cache slot
|
|
164
|
+
# @param ttl [Numeric, nil] optional expiry for the warmed entry
|
|
165
|
+
# @param kwargs [Hash] keyword arguments that identify the cache slot
|
|
166
|
+
# @yield [] must return the value to store
|
|
167
|
+
# @return [Object] the value returned by the block
|
|
168
|
+
# @raise [ArgumentError] if no block is given
|
|
169
|
+
#
|
|
170
|
+
# @example
|
|
171
|
+
# obj.warm_memo(:find, 42) { User.new(id: 42, name: "cached") }
|
|
90
172
|
def warm_memo(method_name, *args, ttl: nil, **kwargs, &block)
|
|
91
173
|
raise ArgumentError, "block required" unless block
|
|
92
174
|
|
|
@@ -104,6 +186,17 @@ module SafeMemoize
|
|
|
104
186
|
value
|
|
105
187
|
end
|
|
106
188
|
|
|
189
|
+
# Calls the memoized method for each argument set and caches all results.
|
|
190
|
+
#
|
|
191
|
+
# Equivalent to calling the method for each arg set individually, but expressed
|
|
192
|
+
# as a single call for clarity.
|
|
193
|
+
#
|
|
194
|
+
# @param method_name [Symbol, String]
|
|
195
|
+
# @param arg_sets [Array<Array>] each element is an argument list for one call
|
|
196
|
+
# @return [Array] cached values in input order
|
|
197
|
+
#
|
|
198
|
+
# @example
|
|
199
|
+
# obj.memo_preload(:find, [1], [2], [3])
|
|
107
200
|
def memo_preload(method_name, *arg_sets)
|
|
108
201
|
method_name = method_name.to_sym
|
|
109
202
|
arg_sets.map do |args|
|
|
@@ -111,6 +204,11 @@ module SafeMemoize
|
|
|
111
204
|
end
|
|
112
205
|
end
|
|
113
206
|
|
|
207
|
+
# Exports live cache entries as a plain hash suitable for serialization.
|
|
208
|
+
#
|
|
209
|
+
# @param method_name [Symbol, String, nil] when given, exports only entries for
|
|
210
|
+
# that method; when +nil+, exports all methods
|
|
211
|
+
# @return [Hash] mapping cache keys to their cached values (expired entries excluded)
|
|
114
212
|
def dump_memo(method_name = nil)
|
|
115
213
|
method_name = method_name&.to_sym
|
|
116
214
|
|
|
@@ -122,6 +220,14 @@ module SafeMemoize
|
|
|
122
220
|
end
|
|
123
221
|
end
|
|
124
222
|
|
|
223
|
+
# Restores cache entries from a snapshot produced by {#dump_memo}.
|
|
224
|
+
#
|
|
225
|
+
# Existing entries are not cleared; snapshot keys are merged in.
|
|
226
|
+
# Each restored entry fires the +:on_store+ hook.
|
|
227
|
+
#
|
|
228
|
+
# @param snapshot [Hash] a hash previously returned by {#dump_memo}
|
|
229
|
+
# @return [nil]
|
|
230
|
+
# @raise [ArgumentError] if +snapshot+ is not a +Hash+
|
|
125
231
|
def load_memo(snapshot)
|
|
126
232
|
raise ArgumentError, "snapshot must be a Hash" unless snapshot.is_a?(Hash)
|
|
127
233
|
|
|
@@ -137,9 +243,17 @@ module SafeMemoize
|
|
|
137
243
|
nil
|
|
138
244
|
end
|
|
139
245
|
|
|
246
|
+
# Resets the expiry clock on a live cached entry without recomputing its value.
|
|
247
|
+
#
|
|
248
|
+
# @param method_name [Symbol, String]
|
|
249
|
+
# @param args [Array]
|
|
250
|
+
# @param ttl [Numeric, nil] new TTL to apply; when +nil+, uses the original TTL
|
|
251
|
+
# derived from the entry's +cached_at+ and +expires_at+ timestamps
|
|
252
|
+
# @param kwargs [Hash]
|
|
253
|
+
# @return [Boolean] +true+ if the entry existed and was touched; +false+ otherwise
|
|
140
254
|
def memo_touch(method_name, *args, ttl: nil, **kwargs)
|
|
141
255
|
method_name = method_name.to_sym
|
|
142
|
-
cache_key =
|
|
256
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
143
257
|
|
|
144
258
|
with_memo_lock do
|
|
145
259
|
cache = memo_cache_or_nil
|
|
@@ -162,14 +276,27 @@ module SafeMemoize
|
|
|
162
276
|
end
|
|
163
277
|
end
|
|
164
278
|
|
|
279
|
+
# Clears the cached entry and immediately re-calls the method to populate a
|
|
280
|
+
# fresh value.
|
|
281
|
+
#
|
|
282
|
+
# @param method_name [Symbol, String]
|
|
283
|
+
# @param args [Array]
|
|
284
|
+
# @param kwargs [Hash]
|
|
285
|
+
# @return [Object] the freshly computed and cached value
|
|
165
286
|
def memo_refresh(method_name, *args, **kwargs)
|
|
166
287
|
method_name = method_name.to_sym
|
|
167
288
|
reset_memo(method_name, *args, **kwargs)
|
|
168
289
|
send(method_name, *args, **kwargs)
|
|
169
290
|
end
|
|
170
291
|
|
|
292
|
+
# Returns how many seconds ago the entry was cached, or +nil+ if not cached.
|
|
293
|
+
#
|
|
294
|
+
# @param method_name [Symbol, String]
|
|
295
|
+
# @param args [Array]
|
|
296
|
+
# @param kwargs [Hash]
|
|
297
|
+
# @return [Float, nil]
|
|
171
298
|
def memo_age(method_name, *args, **kwargs)
|
|
172
|
-
cache_key =
|
|
299
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
173
300
|
|
|
174
301
|
with_memo_lock do
|
|
175
302
|
record = memo_cache_record(cache_key)
|
|
@@ -182,8 +309,14 @@ module SafeMemoize
|
|
|
182
309
|
end
|
|
183
310
|
end
|
|
184
311
|
|
|
312
|
+
# Returns +true+ if the entry exists but its TTL has elapsed.
|
|
313
|
+
#
|
|
314
|
+
# @param method_name [Symbol, String]
|
|
315
|
+
# @param args [Array]
|
|
316
|
+
# @param kwargs [Hash]
|
|
317
|
+
# @return [Boolean]
|
|
185
318
|
def memo_stale?(method_name, *args, **kwargs)
|
|
186
|
-
cache_key =
|
|
319
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
187
320
|
|
|
188
321
|
with_memo_lock do
|
|
189
322
|
cache = memo_cache_or_nil
|
|
@@ -196,6 +329,16 @@ module SafeMemoize
|
|
|
196
329
|
end
|
|
197
330
|
end
|
|
198
331
|
|
|
332
|
+
# Removes one or all cached entries for a method.
|
|
333
|
+
#
|
|
334
|
+
# When called with only +method_name+, all entries for that method are cleared.
|
|
335
|
+
# When called with +method_name+ *and* arguments, only the exact matching entry
|
|
336
|
+
# is cleared. Each evicted entry fires the +:on_evict+ hook.
|
|
337
|
+
#
|
|
338
|
+
# @param method_name [Symbol, String]
|
|
339
|
+
# @param args [Array] positional arguments identifying a specific entry
|
|
340
|
+
# @param kwargs [Hash] keyword arguments identifying a specific entry
|
|
341
|
+
# @return [void]
|
|
199
342
|
def reset_memo(method_name, *args, **kwargs)
|
|
200
343
|
method_name = method_name.to_sym
|
|
201
344
|
|
|
@@ -215,6 +358,10 @@ module SafeMemoize
|
|
|
215
358
|
end
|
|
216
359
|
end
|
|
217
360
|
|
|
361
|
+
# Clears all cached entries for every method on this instance.
|
|
362
|
+
# Each evicted entry fires the +:on_evict+ hook.
|
|
363
|
+
#
|
|
364
|
+
# @return [void]
|
|
218
365
|
def reset_all_memos
|
|
219
366
|
with_memo_lock do
|
|
220
367
|
if defined?(@__safe_memo_cache__) && @__safe_memo_cache__
|
|
@@ -227,6 +374,16 @@ module SafeMemoize
|
|
|
227
374
|
end
|
|
228
375
|
end
|
|
229
376
|
|
|
377
|
+
# Returns a detailed snapshot of a single cached entry, or +nil+ if not cached.
|
|
378
|
+
#
|
|
379
|
+
# All reads are performed inside a single mutex hold.
|
|
380
|
+
#
|
|
381
|
+
# @param method_name [Symbol, String]
|
|
382
|
+
# @param args [Array]
|
|
383
|
+
# @param kwargs [Hash]
|
|
384
|
+
# @return [Hash, nil] hash with keys +:cached+, +:value+, +:hits+, +:misses+,
|
|
385
|
+
# +:ttl_remaining+, +:age+, +:custom_key+, +:lru_position+; or +nil+ when
|
|
386
|
+
# the entry is not present
|
|
230
387
|
def memo_inspect(method_name, *args, **kwargs)
|
|
231
388
|
method_name = method_name.to_sym
|
|
232
389
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
|
+
# Per-instance cache metrics: hit/miss counts and average computation time.
|
|
4
5
|
module PublicMetricsMethods
|
|
6
|
+
# Returns aggregate metrics across all memoized methods on this instance.
|
|
7
|
+
#
|
|
8
|
+
# @return [Hash] with keys +:total_hits+, +:total_misses+, +:hit_rate+,
|
|
9
|
+
# +:miss_rate+, +:average_computation_time+, and +:entries+ (one entry
|
|
10
|
+
# per cached argument combination)
|
|
5
11
|
def cache_stats
|
|
6
12
|
with_memo_lock do
|
|
7
13
|
metrics = memo_metrics_store
|
|
@@ -11,6 +17,11 @@ module SafeMemoize
|
|
|
11
17
|
end
|
|
12
18
|
end
|
|
13
19
|
|
|
20
|
+
# Returns metrics for a single memoized method.
|
|
21
|
+
#
|
|
22
|
+
# @param method_name [Symbol, String]
|
|
23
|
+
# @return [Hash] same shape as {#cache_stats} but scoped to one method,
|
|
24
|
+
# with an extra +:method+ key
|
|
14
25
|
def cache_stats_for(method_name)
|
|
15
26
|
method_name = method_name.to_sym
|
|
16
27
|
|
|
@@ -22,14 +33,23 @@ module SafeMemoize
|
|
|
22
33
|
end
|
|
23
34
|
end
|
|
24
35
|
|
|
36
|
+
# Returns the overall cache hit rate as a percentage (0.0–100.0).
|
|
37
|
+
# @return [Float]
|
|
25
38
|
def cache_hit_rate
|
|
26
39
|
cache_stats[:hit_rate]
|
|
27
40
|
end
|
|
28
41
|
|
|
42
|
+
# Returns the overall cache miss rate as a percentage (0.0–100.0).
|
|
43
|
+
# @return [Float]
|
|
29
44
|
def cache_miss_rate
|
|
30
45
|
cache_stats[:miss_rate]
|
|
31
46
|
end
|
|
32
47
|
|
|
48
|
+
# Resets hit/miss counters, either for one method or for all methods.
|
|
49
|
+
#
|
|
50
|
+
# @param method_name [Symbol, String, nil] when given, resets only that method's
|
|
51
|
+
# metrics; when +nil+, resets all
|
|
52
|
+
# @return [void]
|
|
33
53
|
def cache_metrics_reset(method_name = nil)
|
|
34
54
|
with_memo_lock do
|
|
35
55
|
if method_name
|
|
@@ -25,10 +25,13 @@ module SafeMemoize
|
|
|
25
25
|
# end
|
|
26
26
|
# end
|
|
27
27
|
module RequestScoped
|
|
28
|
+
# @api private
|
|
28
29
|
def self.included(base)
|
|
29
30
|
base.after_action :reset_all_memos if base.respond_to?(:after_action)
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# Resets all memoized values on this instance. Delegates to {PublicMethods#reset_all_memos}.
|
|
34
|
+
# @return [void]
|
|
32
35
|
def reset_request_memos
|
|
33
36
|
reset_all_memos
|
|
34
37
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Stores
|
|
5
|
+
# Abstract base class for SafeMemoize cache store adapters.
|
|
6
|
+
#
|
|
7
|
+
# Subclass this and implement all abstract methods to plug in a custom backend
|
|
8
|
+
# (Redis, Memcached, Rails.cache, etc.). The {Stores::Memory} class is the
|
|
9
|
+
# built-in reference implementation.
|
|
10
|
+
#
|
|
11
|
+
# @abstract
|
|
12
|
+
#
|
|
13
|
+
# @example Minimal inline implementation
|
|
14
|
+
# class MyStore < SafeMemoize::Stores::Base
|
|
15
|
+
# def initialize = (@h = {})
|
|
16
|
+
# def read(key) = @h.fetch(key, MISS)
|
|
17
|
+
# def write(key, value, expires_in: nil) = (@h[key] = value)
|
|
18
|
+
# def delete(key) = @h.delete(key)
|
|
19
|
+
# def clear = @h.clear
|
|
20
|
+
# def keys = @h.keys
|
|
21
|
+
# end
|
|
22
|
+
class Base
|
|
23
|
+
# Sentinel returned by {#read} to signal a cache miss.
|
|
24
|
+
#
|
|
25
|
+
# Distinct from +nil+ and +false+, which are valid cached values.
|
|
26
|
+
MISS = Object.new.freeze
|
|
27
|
+
|
|
28
|
+
# Read a value from the store.
|
|
29
|
+
#
|
|
30
|
+
# @param key [Object] cache key
|
|
31
|
+
# @return [Object] the stored value, or {MISS} if absent or expired
|
|
32
|
+
# @abstract
|
|
33
|
+
def read(key)
|
|
34
|
+
raise NotImplementedError, "#{self.class}#read must be implemented"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Write a value to the store.
|
|
38
|
+
#
|
|
39
|
+
# @param key [Object] cache key
|
|
40
|
+
# @param value [Object] value to cache (may be +nil+ or +false+)
|
|
41
|
+
# @param expires_in [Numeric, nil] seconds until expiry; +nil+ means no expiry
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @abstract
|
|
44
|
+
def write(key, value, expires_in: nil)
|
|
45
|
+
raise NotImplementedError, "#{self.class}#write must be implemented"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Delete a single entry.
|
|
49
|
+
#
|
|
50
|
+
# @param key [Object] cache key
|
|
51
|
+
# @return [void]
|
|
52
|
+
# @abstract
|
|
53
|
+
def delete(key)
|
|
54
|
+
raise NotImplementedError, "#{self.class}#delete must be implemented"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Remove all entries from the store.
|
|
58
|
+
#
|
|
59
|
+
# @return [void]
|
|
60
|
+
# @abstract
|
|
61
|
+
def clear
|
|
62
|
+
raise NotImplementedError, "#{self.class}#clear must be implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Return all live (non-expired) keys.
|
|
66
|
+
#
|
|
67
|
+
# @return [Array<Object>]
|
|
68
|
+
# @abstract
|
|
69
|
+
def keys
|
|
70
|
+
raise NotImplementedError, "#{self.class}#keys must be implemented"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Check whether a live entry exists for the given key.
|
|
74
|
+
#
|
|
75
|
+
# The default delegates to {#read}; subclasses may override for stores
|
|
76
|
+
# with a native existence check.
|
|
77
|
+
#
|
|
78
|
+
# @param key [Object]
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def exist?(key)
|
|
81
|
+
read(key) != MISS
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Stores
|
|
5
|
+
# Default in-process cache store backed by a plain +Hash+.
|
|
6
|
+
#
|
|
7
|
+
# Thread-safe via an internal +Mutex+. Supports per-entry TTL with lazy
|
|
8
|
+
# expiry: stale entries are not proactively removed but are treated as
|
|
9
|
+
# misses on read and excluded from {#keys}.
|
|
10
|
+
class Memory < Base
|
|
11
|
+
def initialize
|
|
12
|
+
@data = {}
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param key [Object]
|
|
17
|
+
# @return [Object] stored value, or {MISS} if absent or expired
|
|
18
|
+
def read(key)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
entry = @data[key]
|
|
21
|
+
return MISS unless entry
|
|
22
|
+
return MISS if expired?(entry)
|
|
23
|
+
|
|
24
|
+
entry[:value]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param key [Object]
|
|
29
|
+
# @param value [Object]
|
|
30
|
+
# @param expires_in [Numeric, nil] seconds until expiry
|
|
31
|
+
# @return [void]
|
|
32
|
+
def write(key, value, expires_in: nil)
|
|
33
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
34
|
+
expires_at = expires_in ? now + expires_in.to_f : nil
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
@data[key] = {value: value, expires_at: expires_at, cached_at: now}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param key [Object]
|
|
42
|
+
# @return [void]
|
|
43
|
+
def delete(key)
|
|
44
|
+
@mutex.synchronize { @data.delete(key) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Removes all entries.
|
|
48
|
+
# @return [void]
|
|
49
|
+
def clear
|
|
50
|
+
@mutex.synchronize { @data.clear }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns all live (non-expired) keys.
|
|
54
|
+
# @return [Array<Object>]
|
|
55
|
+
def keys
|
|
56
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
57
|
+
@mutex.synchronize do
|
|
58
|
+
@data.filter_map { |k, entry| k unless entry[:expires_at] && entry[:expires_at] <= now }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def expired?(entry)
|
|
65
|
+
expires_at = entry[:expires_at]
|
|
66
|
+
expires_at && expires_at <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "safe_memoize"
|
|
4
|
+
|
|
5
|
+
module SafeMemoize
|
|
6
|
+
module Stores
|
|
7
|
+
# Cache store adapter backed by any +ActiveSupport::Cache::Store+.
|
|
8
|
+
#
|
|
9
|
+
# Not auto-required. Add to your Rails initializer:
|
|
10
|
+
# require "safe_memoize/stores/rails_cache"
|
|
11
|
+
#
|
|
12
|
+
# Compatible with any +ActiveSupport::Cache::Store+ implementation
|
|
13
|
+
# (+MemoryStore+, +FileStore+, +MemCacheStore+, +RedisCacheStore+, etc.)
|
|
14
|
+
# and with +Rails.cache+ directly.
|
|
15
|
+
#
|
|
16
|
+
# Because +ActiveSupport::Cache+ returns +nil+ for both a cache miss and
|
|
17
|
+
# a cached +nil+ value, this adapter wraps every value in a two-element
|
|
18
|
+
# sentinel envelope before writing. The envelope is transparent to callers.
|
|
19
|
+
#
|
|
20
|
+
# TTL is forwarded as +expires_in:+ to the cache, so the underlying store
|
|
21
|
+
# manages expiry natively — there is no lazy-expiry overhead on read.
|
|
22
|
+
#
|
|
23
|
+
# {#clear} uses +delete_matched+ scoped to the adapter's namespace, so it
|
|
24
|
+
# never clears entries belonging to other parts of the application. The
|
|
25
|
+
# backend must respond to +delete_matched+ (all standard Rails cache stores
|
|
26
|
+
# do); a +NotImplementedError+ is raised if it does not.
|
|
27
|
+
#
|
|
28
|
+
# {#keys} returns an empty array — +ActiveSupport::Cache::Store+ does not
|
|
29
|
+
# expose a standard key enumeration API. Override the method if your
|
|
30
|
+
# backend supports it.
|
|
31
|
+
#
|
|
32
|
+
# @example Basic setup
|
|
33
|
+
# # config/initializers/safe_memoize.rb
|
|
34
|
+
# require "safe_memoize/stores/rails_cache"
|
|
35
|
+
#
|
|
36
|
+
# MEMO_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
|
|
37
|
+
#
|
|
38
|
+
# class MyService
|
|
39
|
+
# prepend SafeMemoize
|
|
40
|
+
# def fetch(id) = http_get(id)
|
|
41
|
+
# memoize :fetch, store: MEMO_STORE, ttl: 300
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# @example Dedicated cache store (recommended for production)
|
|
45
|
+
# MEMO_STORE = SafeMemoize::Stores::RailsCache.new(
|
|
46
|
+
# ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"]),
|
|
47
|
+
# namespace: "myapp:memo"
|
|
48
|
+
# )
|
|
49
|
+
class RailsCache < Base
|
|
50
|
+
# Tag prepended to every stored value so cached +nil+/+false+ are
|
|
51
|
+
# distinguishable from a cache miss.
|
|
52
|
+
VALUE_TAG = "safe_memoize:v1"
|
|
53
|
+
|
|
54
|
+
# @param cache [ActiveSupport::Cache::Store] the cache store to use;
|
|
55
|
+
# typically +Rails.cache+ or a dedicated store instance
|
|
56
|
+
# @param namespace [String] key prefix used to scope all entries;
|
|
57
|
+
# defaults to +"safe_memoize"+
|
|
58
|
+
def initialize(cache, namespace: "safe_memoize")
|
|
59
|
+
@cache = cache
|
|
60
|
+
@namespace = namespace
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param key [Object] cache key (serialized with Marshal + Base64)
|
|
64
|
+
# @return [Object] the stored value, or {MISS} if absent or unrecognised
|
|
65
|
+
def read(key)
|
|
66
|
+
raw = @cache.read(cache_key(key))
|
|
67
|
+
return MISS unless raw.is_a?(Array) && raw.length == 2 && raw[0] == VALUE_TAG
|
|
68
|
+
|
|
69
|
+
raw[1]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param key [Object] cache key
|
|
73
|
+
# @param value [Object] value to store (may be +nil+ or +false+)
|
|
74
|
+
# @param expires_in [Numeric, nil] TTL in seconds forwarded to the cache
|
|
75
|
+
# as +expires_in:+; +nil+ means no expiry
|
|
76
|
+
# @return [void]
|
|
77
|
+
def write(key, value, expires_in: nil)
|
|
78
|
+
opts = expires_in ? {expires_in: expires_in} : {}
|
|
79
|
+
@cache.write(cache_key(key), [VALUE_TAG, value], **opts)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @param key [Object]
|
|
83
|
+
# @return [void]
|
|
84
|
+
def delete(key)
|
|
85
|
+
@cache.delete(cache_key(key))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Removes all entries written by this adapter (scoped to the namespace).
|
|
89
|
+
#
|
|
90
|
+
# Delegates to +delete_matched+ on the underlying store; raises
|
|
91
|
+
# +NotImplementedError+ if the store does not support it.
|
|
92
|
+
#
|
|
93
|
+
# @return [void]
|
|
94
|
+
# @raise [NotImplementedError] if the backing store does not respond to
|
|
95
|
+
# +delete_matched+
|
|
96
|
+
def clear
|
|
97
|
+
unless @cache.respond_to?(:delete_matched)
|
|
98
|
+
raise NotImplementedError,
|
|
99
|
+
"#{@cache.class} does not support delete_matched — " \
|
|
100
|
+
"implement clear manually or use a store that supports it (e.g. MemoryStore, RedisCacheStore)"
|
|
101
|
+
end
|
|
102
|
+
@cache.delete_matched(/\A#{Regexp.escape(@namespace)}:/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns an empty array.
|
|
106
|
+
#
|
|
107
|
+
# +ActiveSupport::Cache::Store+ does not expose a key enumeration API.
|
|
108
|
+
# Override this method if your backend supports key listing.
|
|
109
|
+
#
|
|
110
|
+
# @return [Array]
|
|
111
|
+
def keys
|
|
112
|
+
[]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param key [Object]
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
def exist?(key)
|
|
118
|
+
@cache.exist?(cache_key(key))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def cache_key(key)
|
|
124
|
+
"#{@namespace}:#{[Marshal.dump(key)].pack("m0")}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|