# frozen_string_literal: true # Discourse specific cache, enforces 1 day expiry by default # This is a bottom up implementation of ActiveSupport::Cache::Store # this allows us to cleanly implement without using cache entries and version # support which we do not use, in tern this makes the cache as fast as simply # using `Discourse.redis.setex` with a more convenient API # # It only implements a subset of ActiveSupport::Cache::Store as we make no use # of large parts of the interface. # # An additional advantage of this class is that all methods have named params # Rails tends to use options hash for lots of stuff due to legacy reasons # this makes it harder to reason about the API class Cache # nothing is cached for longer than 1 day EVER # there is no reason to have data older than this clogging redis # it is dangerous cause if we rename keys we will be stuck with # pointless data MAX_CACHE_AGE = 1.day unless defined?(MAX_CACHE_AGE) attr_reader :namespace # we don't need this feature, 1 day expiry is enough # it makes lookups a tad cheaper def self.supports_cache_versioning? false end def initialize(namespace: "_CACHE") @namespace = namespace end def redis Discourse.redis end def reconnect redis.reconnect end def keys(pattern = "*") redis.scan_each(match: "#{@namespace}:#{pattern}").to_a end def clear keys.each { |k| redis.del(k) } end def normalize_key(key) "#{@namespace}:#{key}" end def exist?(name) key = normalize_key(name) redis.exists?(key) end # this removes a bunch of stuff we do not need like instrumentation and versioning def read(name) key = normalize_key(name) read_entry(key) end def write(name, value, expires_in: nil) write_entry(normalize_key(name), value, expires_in: expires_in) end def delete(name) redis.del(normalize_key(name)) end def fetch(name, expires_in: nil, force: nil, &blk) if block_given? key = normalize_key(name) raw = nil raw = redis.get(key) if !force if raw begin Marshal.load(raw) # rubocop:disable Security/MarshalLoad rescue => e log_first_exception(e) end else val = blk.call write_entry(key, val, expires_in: expires_in) val end elsif force raise ArgumentError, "Missing block: Calling `Cache#fetch` with `force: true` requires a block." else read(name) end end protected def log_first_exception(e) if !defined?(@logged_a_warning) @logged_a_warning = true Discourse.warn_exception(e, "Corrupt cache... skipping entry for key #{key}") end end def read_entry(key) if data = redis.get(key) Marshal.load(data) # rubocop:disable Security/MarshalLoad end rescue => e # corrupt cache, this can happen if Marshal version # changes. Log it once so we can tell it is happening. # should not happen under any normal circumstances, but we # do not want to flood logs log_first_exception(e) end def write_entry(key, value, expires_in: nil) dumped = Marshal.dump(value) expiry = expires_in || MAX_CACHE_AGE redis.setex(key, expiry, dumped) true end end