NodesCharsBranches
508 / 6933536 / 474219 / 37
DeepCover v0.1.16
1# frozen_string_literal: true
2
3begin
4 gem "redis", ">= 4.0.1"
5 require "redis"
6 require "redis/distributed"
7rescue LoadError
8 warn "The Redis cache store requires the redis gem, version 4.0.1 or later. Please add it to your Gemfile: `gem \"redis\", \"~> 4.0\"`"
9 raise
10end
11
12# Prefer the hiredis driver but don't require it.
13begin
14 require "redis/connection/hiredis"
15rescue LoadError
16end
17
18require "digest/sha2"
19require "active_support/core_ext/marshal"
20
21module ActiveSupport
22 module Cache
23 # Redis cache store.
24 #
25 # Deployment note: Take care to use a *dedicated Redis cache* rather
26 # than pointing this at your existing Redis server. It won't cope well
27 # with mixed usage patterns and it won't expire cache entries by default.
28 #
29 # Redis cache server setup guide: https://redis.io/topics/lru-cache
30 #
31 # * Supports vanilla Redis, hiredis, and Redis::Distributed.
32 # * Supports Memcached-like sharding across Redises with Redis::Distributed.
33 # * Fault tolerant. If the Redis server is unavailable, no exceptions are
34 # raised. Cache fetches are all misses and writes are dropped.
35 # * Local cache. Hot in-memory primary cache within block/middleware scope.
36 # * +read_multi+ and +write_multi+ support for Redis mget/mset. Use Redis::Distributed
37 # 4.0.1+ for distributed mget support.
38 # * +delete_matched+ support for Redis KEYS globs.
39 class RedisCacheStore < Store
40 # Keys are truncated with their own SHA2 digest if they exceed 1kB
41 MAX_KEY_BYTESIZE = 1024
42
43 DEFAULT_REDIS_OPTIONS = {
44 connect_timeout: 20,
45 read_timeout: 1,
46 write_timeout: 1,
47 reconnect_attempts: 0,
48 }
49
50 DEFAULT_ERROR_HANDLER = -> (method:, returning:, exception:) {
51 logger.error { "RedisCacheStore: #{method} failed, returned #{returning.inspect}: #{e.class}: #{e.message}" } if logger
52 }
53
54 DELETE_GLOB_LUA = "for i, name in ipairs(redis.call('KEYS', ARGV[1])) do redis.call('DEL', name); end"
55 private_constant :DELETE_GLOB_LUA
56
57 # Support raw values in the local cache strategy.
58 module LocalCacheWithRaw # :nodoc:
59 private
60 def read_entry(key, options)
61 entry = super
62 if options[:raw] && local_cache && entry
63 entry = deserialize_entry(entry.value)
64 end
65 entry
66 end
67
68 def write_entry(key, entry, options)
69 if options[:raw] && local_cache
70 raw_entry = Entry.new(entry.value.to_s)
71 raw_entry.expires_at = entry.expires_at
72 super(key, raw_entry, options)
73 else
74 super
75 end
76 end
77
78 def write_multi_entries(entries, options)
79 if options[:raw] && local_cache
80 raw_entries = entries.map do |key, entry|
81 raw_entry = Entry.new(entry.value.to_s)
82 raw_entry.expires_at = entry.expires_at
83 end.to_h
84
85 super(raw_entries, options)
86 else
87 super
88 end
89 end
90 end
91
92 prepend Strategy::LocalCache
93 prepend LocalCacheWithRaw
94
95 class << self
96 # Factory method to create a new Redis instance.
97 #
98 # Handles four options: :redis block, :redis instance, single :url
99 # string, and multiple :url strings.
100 #
101 # Option Class Result
102 # :redis Proc -> options[:redis].call
103 # :redis Object -> options[:redis]
104 # :url String -> Redis.new(url: …)
105 # :url Array -> Redis::Distributed.new([{ url: … }, { url: … }, …])
106 #
107 def build_redis(redis: nil, url: nil, **redis_options) #:nodoc:
108 urls = Array(url)
109
110 if redis.respond_to?(:call)
111 redis.call
112 elsif redis
113 redis
114 elsif urls.size > 1
115 build_redis_distributed_client urls: urls, **redis_options
116 else
117 build_redis_client url: urls.first, **redis_options
118 end
119 end
120
121 private
122 def build_redis_distributed_client(urls:, **redis_options)
123 ::Redis::Distributed.new([], DEFAULT_REDIS_OPTIONS.merge(redis_options)).tap do |dist|
124 urls.each { |u| dist.add_node url: u }
125 end
126 end
127
128 def build_redis_client(url:, **redis_options)
129 ::Redis.new DEFAULT_REDIS_OPTIONS.merge(redis_options.merge(url: url))
130 end
131 end
132
133 attr_reader :redis_options
134 attr_reader :max_key_bytesize
135
136 # Creates a new Redis cache store.
137 #
138 # Handles three options: block provided to instantiate, single URL
139 # provided, and multiple URLs provided.
140 #
141 # :redis Proc -> options[:redis].call
142 # :url String -> Redis.new(url: …)
143 # :url Array -> Redis::Distributed.new([{ url: … }, { url: … }, …])
144 #
145 # No namespace is set by default. Provide one if the Redis cache
146 # server is shared with other apps: <tt>namespace: 'myapp-cache'<tt>.
147 #
148 # Compression is enabled by default with a 1kB threshold, so cached
149 # values larger than 1kB are automatically compressed. Disable by
150 # passing <tt>cache: false</tt> or change the threshold by passing
151 # <tt>compress_threshold: 4.kilobytes</tt>.
152 #
153 # No expiry is set on cache entries by default. Redis is expected to
154 # be configured with an eviction policy that automatically deletes
155 # least-recently or -frequently used keys when it reaches max memory.
156 # See https://redis.io/topics/lru-cache for cache server setup.
157 #
158 # Race condition TTL is not set by default. This can be used to avoid
159 # "thundering herd" cache writes when hot cache entries are expired.
160 # See <tt>ActiveSupport::Cache::Store#fetch</tt> for more.
161 def initialize(namespace: nil, compress: true, compress_threshold: 1.kilobyte, expires_in: nil, race_condition_ttl: nil, error_handler: DEFAULT_ERROR_HANDLER, **redis_options)
162 @redis_options = redis_options
163
164 @max_key_bytesize = MAX_KEY_BYTESIZE
165 @error_handler = error_handler
166
167 super namespace: namespace,
168 compress: compress, compress_threshold: compress_threshold,
169 expires_in: expires_in, race_condition_ttl: race_condition_ttl
170 end
171
172 def redis
173 @redis ||= self.class.build_redis(**redis_options)
174 end
175
176 def inspect
177 instance = @redis || @redis_options
178 "<##{self.class} options=#{options.inspect} redis=#{instance.inspect}>"
179 end
180
181 # Cache Store API implementation.
182 #
183 # Read multiple values at once. Returns a hash of requested keys ->
184 # fetched values.
185 def read_multi(*names)
186 if mget_capable?
187 read_multi_mget(*names)
188 else
189 super
190 end
191 end
192
193 # Cache Store API implementation.
194 #
195 # Supports Redis KEYS glob patterns:
196 #
197 # h?llo matches hello, hallo and hxllo
198 # h*llo matches hllo and heeeello
199 # h[ae]llo matches hello and hallo, but not hillo
200 # h[^e]llo matches hallo, hbllo, ... but not hello
201 # h[a-b]llo matches hallo and hbllo
202 #
203 # Use \ to escape special characters if you want to match them verbatim.
204 #
205 # See https://redis.io/commands/KEYS for more.
206 #
207 # Failsafe: Raises errors.
208 def delete_matched(matcher, options = nil)
209 instrument :delete_matched, matcher do
210 case matcher
211 when String
212 redis.eval DELETE_GLOB_LUA, [], [namespace_key(matcher, options)]
213 else
214 raise ArgumentError, "Only Redis glob strings are supported: #{matcher.inspect}"
215 end
216 end
217 end
218
219 # Cache Store API implementation.
220 #
221 # Increment a cached value. This method uses the Redis incr atomic
222 # operator and can only be used on values written with the :raw option.
223 # Calling it on a value not stored with :raw will initialize that value
224 # to zero.
225 #
226 # Failsafe: Raises errors.
227 def increment(name, amount = 1, options = nil)
228 instrument :increment, name, amount: amount do
229 redis.incrby normalize_key(name, options), amount
230 end
231 end
232
233 # Cache Store API implementation.
234 #
235 # Decrement a cached value. This method uses the Redis decr atomic
236 # operator and can only be used on values written with the :raw option.
237 # Calling it on a value not stored with :raw will initialize that value
238 # to zero.
239 #
240 # Failsafe: Raises errors.
241 def decrement(name, amount = 1, options = nil)
242 instrument :decrement, name, amount: amount do
243 redis.decrby normalize_key(name, options), amount
244 end
245 end
246
247 # Cache Store API implementation.
248 #
249 # Removes expired entries. Handled natively by Redis least-recently-/
250 # least-frequently-used expiry, so manual cleanup is not supported.
251 def cleanup(options = nil)
252 super
253 end
254
255 # Clear the entire cache on all Redis servers. Safe to use on
256 # shared servers if the cache is namespaced.
257 #
258 # Failsafe: Raises errors.
259 def clear(options = nil)
260 failsafe :clear do
261 if namespace = merged_options(options)[namespace]
262 delete_matched "*", namespace: namespace
263 else
264 redis.flushdb
265 end
266 end
267 end
268
269 def mget_capable? #:nodoc:
270 set_redis_capabilities unless defined? @mget_capable
271 @mget_capable
272 end
273
274 def mset_capable? #:nodoc:
275 set_redis_capabilities unless defined? @mset_capable
276 @mset_capable
277 end
278
279 private
280 def set_redis_capabilities
281 case redis
282 when Redis::Distributed
283 @mget_capable = true
284 @mset_capable = false
285 else
286 @mget_capable = true
287 @mset_capable = true
288 end
289 end
290
291 # Store provider interface:
292 # Read an entry from the cache.
293 def read_entry(key, options = nil)
294 failsafe :read_entry do
295 deserialize_entry redis.get(key)
296 end
297 end
298
299 def read_multi_mget(*names)
300 options = names.extract_options!
301 options = merged_options(options)
302
303 keys = names.map { |name| normalize_key(name, options) }
304 values = redis.mget(*keys)
305
306 names.zip(values).each_with_object({}) do |(name, value), results|
307 if value
308 entry = deserialize_entry(value)
309 unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(name, options))
310 results[name] = entry.value
311 end
312 end
313 end
314 end
315
316 # Write an entry to the cache.
317 #
318 # Requires Redis 2.6.12+ for extended SET options.
319 def write_entry(key, entry, unless_exist: false, raw: false, expires_in: nil, race_condition_ttl: nil, **options)
320 value = raw ? entry.value.to_s : serialize_entry(entry)
321
322 # If race condition TTL is in use, ensure that cache entries
323 # stick around a bit longer after they would have expired
324 # so we can purposefully serve stale entries.
325 if race_condition_ttl && expires_in && expires_in > 0 && !raw
326 expires_in += 5.minutes
327 end
328
329 failsafe :write_entry do
330 if unless_exist || expires_in
331 modifiers = {}
332 modifiers[:nx] = unless_exist
333 modifiers[:px] = (1000 * expires_in.to_f).ceil if expires_in
334
335 redis.set key, value, modifiers
336 else
337 redis.set key, value
338 end
339 end
340 end
341
342 # Delete an entry from the cache.
343 def delete_entry(key, options)
344 failsafe :delete_entry, returning: false do
345 redis.del key
346 end
347 end
348
349 # Nonstandard store provider API to write multiple values at once.
350 def write_multi_entries(entries, expires_in: nil, **options)
351 if entries.any?
352 if mset_capable? && expires_in.nil?
353 failsafe :write_multi_entries do
354 redis.mapped_mset(entries)
355 end
356 else
357 super
358 end
359 end
360 end
361
362 # Truncate keys that exceed 1kB.
363 def normalize_key(key, options)
364 truncate_key super
365 end
366
367 def truncate_key(key)
368 if key.bytesize > max_key_bytesize
369 suffix = ":sha2:#{Digest::SHA2.hexdigest(key)}"
370 truncate_at = max_key_bytesize - suffix.bytesize
371 "#{key.byteslice(0, truncate_at)}#{suffix}"
372 else
373 key
374 end
375 end
376
377 def deserialize_entry(raw_value)
378 if raw_value
379 entry = Marshal.load(raw_value) rescue raw_value
380 entry.is_a?(Entry) ? entry : Entry.new(entry)
381 end
382 end
383
384 def serialize_entry(entry)
385 Marshal.dump(entry)
386 end
387
388 def failsafe(method, returning: nil)
389 yield
390 rescue ::Redis::BaseConnectionError => e
391 handle_exception exception: e, method: method, returning: returning
392 returning
393 end
394
395 def handle_exception(exception:, method:, returning:)
396 if @error_handler
397 @error_handler.(method: method, exception: exception, returning: returning)
398 end
399 rescue => failsafe
400 warn "RedisCacheStore ignored exception in handle_exception: #{failsafe.class}: #{failsafe.message}\n #{failsafe.backtrace.join("\n ")}"
401 end
402 end
403 end
404end