DeepCover
v0.1.16
| 1 | # frozen_string_literal: true |
| 2 | |
| 3 | begin |
| 4 | gem "redis", ">= 4.0.1" |
| 5 | require "redis" |
| 6 | require "redis/distributed" |
| 7 | rescue 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 |
| 10 | end |
| 11 | |
| 12 | # Prefer the hiredis driver but don't require it. |
| 13 | begin |
| 14 | require "redis/connection/hiredis" |
| 15 | rescue LoadError |
| 16 | end |
| 17 | |
| 18 | require "digest/sha2" |
| 19 | require "active_support/core_ext/marshal" |
| 20 | |
| 21 | module 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 |
| 404 | end |