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.
@@ -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 = safe_memo_cache_key(method_name, args, kwargs)
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 = safe_memo_cache_key(method_name, args, kwargs)
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 = safe_memo_cache_key(method_name, args, kwargs)
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 = safe_memo_cache_key(method_name, args, kwargs)
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 = safe_memo_cache_key(method_name, args, kwargs)
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
@@ -13,6 +13,8 @@ module SafeMemoize
13
13
  @app = app
14
14
  end
15
15
 
16
+ # @param env [Hash] Rack environment
17
+ # @return [Array] Rack response triplet
16
18
  def call(env)
17
19
  @app.call(env)
18
20
  ensure
@@ -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
@@ -3,6 +3,7 @@
3
3
  require "date"
4
4
 
5
5
  module SafeMemoize
6
+ # @api private
6
7
  module ReleaseTooling
7
8
  module_function
8
9
 
@@ -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