lib/active_support/cache.rb in activesupport-6.1.7.10 vs lib/active_support/cache.rb in activesupport-7.0.0.alpha1

- old
+ new

@@ -20,17 +20,28 @@ autoload :NullStore, "active_support/cache/null_store" autoload :RedisCacheStore, "active_support/cache/redis_cache_store" # These options mean something to all cache implementations. Individual cache # implementations may support additional options. - UNIVERSAL_OPTIONS = [:namespace, :compress, :compress_threshold, :expires_in, :race_condition_ttl, :coder] + UNIVERSAL_OPTIONS = [:namespace, :compress, :compress_threshold, :expires_in, :expire_in, :expired_in, :race_condition_ttl, :coder, :skip_nil] + DEFAULT_COMPRESS_LIMIT = 1.kilobyte + + # Mapping of canonical option names to aliases that a store will recognize. + OPTION_ALIASES = { + expires_in: [:expire_in, :expired_in] + }.freeze + module Strategy autoload :LocalCache, "active_support/cache/strategy/local_cache" end + @format_version = 6.1 + class << self + attr_accessor :format_version + # Creates a new Store object according to the given options. # # If no arguments are passed to this method, then a new # ActiveSupport::Cache::MemoryStore object will be returned. # @@ -162,12 +173,10 @@ # compression, pass <tt>compress: false</tt> to the initializer or to # individual +fetch+ or +write+ method calls. The 1kB compression # threshold is configurable with the <tt>:compress_threshold</tt> option, # specified in bytes. class Store - DEFAULT_CODER = Marshal - cattr_accessor :logger, instance_writer: true attr_reader :silence, :options alias :silence? :silence @@ -190,12 +199,16 @@ # Creates a new cache. The options will be passed to any write method calls # except for <tt>:namespace</tt> which can be used to set the global # namespace for the cache. def initialize(options = nil) - @options = options ? options.dup : {} - @coder = @options.delete(:coder) { self.class::DEFAULT_CODER } || NullCoder + @options = options ? normalize_options(options) : {} + @options[:compress] = true unless @options.key?(:compress) + @options[:compress_threshold] = DEFAULT_COMPRESS_LIMIT unless @options.key?(:compress_threshold) + + @coder = @options.delete(:coder) { default_coder } || NullCoder + @coder_supports_compression = @coder.respond_to?(:dump_compressed) end # Silences the logger. def silence! @silence = true @@ -253,15 +266,25 @@ # # Setting <tt>:expires_in</tt> will set an expiration time on the cache. # All caches support auto-expiring content after a specified number of # seconds. This value can be specified as an option to the constructor # (in which case all entries will be affected), or it can be supplied to - # the +fetch+ or +write+ method to effect just one entry. + # the +fetch+ or +write+ method to affect just one entry. + # <tt>:expire_in</tt> and <tt>:expired_in</tt> are aliases for + # <tt>:expires_in</tt>. # # cache = ActiveSupport::Cache::MemoryStore.new(expires_in: 5.minutes) # cache.write(key, value, expires_in: 1.minute) # Set a lower value for one entry # + # Setting <tt>:expires_at</tt> will set an absolute expiration time on the cache. + # All caches support auto-expiring content after a specified number of + # seconds. This value can only be supplied to the +fetch+ or +write+ method to + # affect just one entry. + # + # cache = ActiveSupport::Cache::MemoryStore.new + # cache.write(key, value, expires_at: Time.now.at_end_of_hour) + # # Setting <tt>:version</tt> verifies the cache stored under <tt>name</tt> # is of the same version. nil is returned on mismatches despite contents. # This feature is used to support recyclable cache keys. # # Setting <tt>:race_condition_ttl</tt> is very useful in situations where @@ -510,10 +533,14 @@ entry = read_entry(normalize_key(name, options), **options, event: payload) (entry && !entry.expired? && !entry.mismatched?(normalize_version(name, options))) || false end end + def new_entry(value, options = nil) # :nodoc: + Entry.new(value, **merged_options(options)) + end + # Deletes all entries with keys matching the pattern. # # Options are passed to the underlying cache implementation. # # Some implementations may not support this method. @@ -557,10 +584,14 @@ def clear(options = nil) raise NotImplementedError.new("#{self.class.name} does not support clear") end private + def default_coder + Coders[Cache.format_version] + end + # Adds the namespace defined in the options to a pattern designed to # match keys. Implementations that support delete_matched should call # this method to translate a pattern that matches names into one that # matches namespaced keys. def key_matcher(pattern, options) # :doc: @@ -588,12 +619,17 @@ # this method. def write_entry(key, entry, **options) raise NotImplementedError.new end - def serialize_entry(entry) - @coder.dump(entry) + def serialize_entry(entry, **options) + options = merged_options(options) + if @coder_supports_compression && options[:compress] + @coder.dump_compressed(entry, options[:compress_threshold] || DEFAULT_COMPRESS_LIMIT) + else + @coder.dump(entry) + end end def deserialize_entry(payload) payload.nil? ? nil : @coder.load(payload) end @@ -638,20 +674,33 @@ end # Merges the default options with ones specific to a method call. def merged_options(call_options) if call_options + call_options = normalize_options(call_options) if options.empty? call_options else options.merge(call_options) end else options end end + # Normalize aliased options to their canonical form + def normalize_options(options) + options = options.dup + OPTION_ALIASES.each do |canonical_name, aliases| + alias_key = aliases.detect { |key| options.key?(key) } + options[canonical_name] ||= options[alias_key] if alias_key + options.except!(*aliases) + end + + options + end + # Expands and namespaces the cache key. May be overridden by # cache stores to do additional normalization. def normalize_key(key, options = nil) namespace_key expanded_key(key), options end @@ -730,11 +779,11 @@ if entry && entry.expired? race_ttl = options[:race_condition_ttl].to_i if (race_ttl > 0) && (Time.now.to_f - entry.expires_at <= race_ttl) # When an entry has a positive :race_condition_ttl defined, put the stale entry back into the cache # for a brief period while the entry is being recalculated. - entry.expires_at = Time.now + race_ttl + entry.expires_at = Time.now.to_f + race_ttl write_entry(key, entry, expires_in: race_ttl * 2) else delete_entry(key, **options) end entry = nil @@ -756,42 +805,125 @@ result end end module NullCoder # :nodoc: + extend self + + def dump(entry) + entry + end + + def dump_compressed(entry, threshold) + entry.compressed(threshold) + end + + def load(payload) + payload + end + end + + module Coders # :nodoc: + MARK_61 = "\x04\b".b.freeze # The one set by Marshal. + MARK_70_UNCOMPRESSED = "\x00".b.freeze + MARK_70_COMPRESSED = "\x01".b.freeze + class << self + def [](version) + case version + when 6.1 + Rails61Coder + when 7.0 + Rails70Coder + else + raise ArgumentError, "Unknown ActiveSupport::Cache.format_version #{Cache.format_version.inspect}" + end + end + end + + module Loader + extend self + def load(payload) - payload + if !payload.is_a?(String) + ActiveSupport::Cache::Store.logger&.warn %{Payload wasn't a string, was #{payload.class.name} - couldn't unmarshal, so returning nil."} + + return nil + elsif payload.start_with?(MARK_70_UNCOMPRESSED) + members = Marshal.load(payload.byteslice(1..-1)) + elsif payload.start_with?(MARK_70_COMPRESSED) + members = Marshal.load(Zlib::Inflate.inflate(payload.byteslice(1..-1))) + elsif payload.start_with?(MARK_61) + return Marshal.load(payload) + else + ActiveSupport::Cache::Store.logger&.warn %{Invalid cache prefix: #{payload.byteslice(0).inspect}, expected "\\x00" or "\\x01"} + + return nil + end + Entry.unpack(members) end + end + module Rails61Coder + include Loader + extend self + def dump(entry) - entry + Marshal.dump(entry) end + + def dump_compressed(entry, threshold) + Marshal.dump(entry.compressed(threshold)) + end end + + module Rails70Coder + include Loader + extend self + + def dump(entry) + MARK_70_UNCOMPRESSED + Marshal.dump(entry.pack) + end + + def dump_compressed(entry, threshold) + payload = Marshal.dump(entry.pack) + if payload.bytesize >= threshold + compressed_payload = Zlib::Deflate.deflate(payload) + if compressed_payload.bytesize < payload.bytesize + return MARK_70_COMPRESSED + compressed_payload + end + end + + MARK_70_UNCOMPRESSED + payload + end + end end # This class is used to represent cache entries. Cache entries have a value, an optional # expiration time, and an optional version. The expiration time is used to support the :race_condition_ttl option # on the cache. The version is used to support the :version option on the cache for rejecting # mismatches. # # Since cache entries in most instances will be serialized, the internals of this class are highly optimized # using short instance variable names that are lazily defined. class Entry # :nodoc: + class << self + def unpack(members) + new(members[0], expires_at: members[1], version: members[2]) + end + end + attr_reader :version - DEFAULT_COMPRESS_LIMIT = 1.kilobyte - # Creates a new cache entry for the specified value. Options supported are - # +:compress+, +:compress_threshold+, +:version+ and +:expires_in+. - def initialize(value, compress: true, compress_threshold: DEFAULT_COMPRESS_LIMIT, version: nil, expires_in: nil, **) + # +:compressed+, +:version+, +:expires_at+ and +:expires_in+. + def initialize(value, compressed: false, version: nil, expires_in: nil, expires_at: nil, **) @value = value @version = version - @created_at = Time.now.to_f - @expires_in = expires_in && expires_in.to_f - - compress!(compress_threshold) if compress + @created_at = 0.0 + @expires_in = expires_at&.to_f || expires_in && (expires_in.to_f + Time.now.to_f) + @compressed = true if compressed end def value compressed? ? uncompress(@value) : @value end @@ -829,10 +961,42 @@ else @s ||= Marshal.dump(@value).bytesize end end + def compressed? # :nodoc: + defined?(@compressed) + end + + def compressed(compress_threshold) + return self if compressed? + + case @value + when nil, true, false, Numeric + uncompressed_size = 0 + when String + uncompressed_size = @value.bytesize + else + serialized = Marshal.dump(@value) + uncompressed_size = serialized.bytesize + end + + if uncompressed_size >= compress_threshold + serialized ||= Marshal.dump(@value) + compressed = Zlib::Deflate.deflate(serialized) + + if compressed.bytesize < uncompressed_size + return Entry.new(compressed, compressed: true, expires_at: expires_at, version: version) + end + end + self + end + + def local? + false + end + # Duplicates the value in a class. This is used by cache implementations that don't natively # serialize entries to protect against accidental cache modifications. def dup_value! if @value && !compressed? && !(@value.is_a?(Numeric) || @value == true || @value == false) if @value.is_a?(String) @@ -841,36 +1005,16 @@ @value = Marshal.load(Marshal.dump(@value)) end end end - private - def compress!(compress_threshold) - case @value - when nil, true, false, Numeric - uncompressed_size = 0 - when String - uncompressed_size = @value.bytesize - else - serialized = Marshal.dump(@value) - uncompressed_size = serialized.bytesize - end + def pack + members = [value, expires_at, version] + members.pop while !members.empty? && members.last.nil? + members + end - if uncompressed_size >= compress_threshold - serialized ||= Marshal.dump(@value) - compressed = Zlib::Deflate.deflate(serialized) - - if compressed.bytesize < uncompressed_size - @value = compressed - @compressed = true - end - end - end - - def compressed? - defined?(@compressed) - end - + private def uncompress(value) Marshal.load(Zlib::Inflate.inflate(value)) end end end