Skip to content

Commit

Permalink
Enable Rails 7.1 Marshalling format
Browse files Browse the repository at this point in the history
This replace the workaround added in mastodon#24142
and also fixes mastodon#27622 so
mastodon#28521 can be reverted.

That new format isn't backward compatible, so normally you are
supposed to deploy Rails 7.1 without that new format enabled
first, but this can be sidestepped by invalidating old cache
entries, which this PR does by changing the cache prefix.
  • Loading branch information
byroot committed Jan 5, 2024
1 parent 43d800a commit 1a80df9
Show file tree
Hide file tree
Showing 5 changed files with 7 additions and 181 deletions.
155 changes: 2 additions & 153 deletions app/controllers/concerns/cache_concern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,150 +3,6 @@
module CacheConcern
extend ActiveSupport::Concern

module ActiveRecordCoder
EMPTY_HASH = {}.freeze

class << self
def dump(record)
instances = InstanceTracker.new
serialized_associations = serialize_associations(record, instances)
serialized_records = instances.map { |r| serialize_record(r) }
[serialized_associations, *serialized_records]
end

def load(payload)
instances = InstanceTracker.new
serialized_associations, *serialized_records = payload
serialized_records.each { |attrs| instances.push(deserialize_record(*attrs)) }
deserialize_associations(serialized_associations, instances)
end

private

# Records without associations, or which have already been visited before,
# are serialized by their id alone.
#
# Records with associations are serialized as a two-element array including
# their id and the record's association cache.
#
def serialize_associations(record, instances)
return unless record

if (id = instances.lookup(record))
payload = id
else
payload = instances.push(record)

cached_associations = record.class.reflect_on_all_associations.select do |reflection|
record.association_cached?(reflection.name)
end

unless cached_associations.empty?
serialized_associations = cached_associations.map do |reflection|
association = record.association(reflection.name)

serialized_target = if reflection.collection?
association.target.map { |target_record| serialize_associations(target_record, instances) }
else
serialize_associations(association.target, instances)
end

[reflection.name, serialized_target]
end

payload = [payload, serialized_associations]
end
end

payload
end

def deserialize_associations(payload, instances)
return unless payload

id, associations = payload
record = instances.fetch(id)

associations&.each do |name, serialized_target|
begin
association = record.association(name)
rescue ActiveRecord::AssociationNotFoundError
raise AssociationMissingError, "undefined association: #{name}"
end

target = if association.reflection.collection?
serialized_target.map! { |serialized_record| deserialize_associations(serialized_record, instances) }
else
deserialize_associations(serialized_target, instances)
end

association.target = target
end

record
end

def serialize_record(record)
arguments = [record.class.name, attributes_for_database(record)]
arguments << true if record.new_record?
arguments
end

def attributes_for_database(record)
attributes = record.attributes_for_database
attributes.transform_values! { |attr| attr.is_a?(::ActiveModel::Type::Binary::Data) ? attr.to_s : attr }
attributes
end

def deserialize_record(class_name, attributes_from_database, new_record = false) # rubocop:disable Style/OptionalBooleanParameter
begin
klass = Object.const_get(class_name)
rescue NameError
raise ClassMissingError, "undefined class: #{class_name}"
end

# Ideally we'd like to call `klass.instantiate`, however it doesn't allow to pass
# wether the record was persisted or not.
attributes = klass.attributes_builder.build_from_database(attributes_from_database, EMPTY_HASH)
klass.allocate.init_with_attributes(attributes, new_record)
end
end

class Error < StandardError
end

class ClassMissingError < Error
end

class AssociationMissingError < Error
end

class InstanceTracker
def initialize
@instances = []
@ids = {}.compare_by_identity
end

def map(&block)
@instances.map(&block)
end

def fetch(...)
@instances.fetch(...)
end

def push(instance)
id = @ids[instance] = @instances.size
@instances << instance
id
end

def lookup(instance)
@ids[instance]
end
end
end

class_methods do
def vary_by(value, **kwargs)
before_action(**kwargs) do |controller|
Expand Down Expand Up @@ -196,22 +52,15 @@ def cache_collection(raw, klass)
raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation)
return [] if raw.empty?

cached_keys_with_value = begin
Rails.cache.read_multi(*raw).transform_keys(&:id).transform_values { |r| ActiveRecordCoder.load(r) }
rescue ActiveRecordCoder::Error
{} # The serialization format may have changed, let's pretend it's a cache miss.
end
cached_keys_with_value = Rails.cache.read_multi(*raw).transform_keys(&:id)

uncached_ids = raw.map(&:id) - cached_keys_with_value.keys

klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)

unless uncached_ids.empty?
uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id)

uncached.each_value do |item|
Rails.cache.write(item, ActiveRecordCoder.dump(item))
end
Rails.cache.write_multi(uncached.values.to_h { |i| [i, i] })
end

raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] }
Expand Down
19 changes: 2 additions & 17 deletions app/models/custom_filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,8 @@
class CustomFilter < ApplicationRecord
self.ignored_columns += %w(whole_word irreversible)

# NOTE: We previously used `alias_attribute` but this does not play nicely
# with cache
def title
phrase
end

def title=(value)
self.phrase = value
end

def filter_action
action
end

def filter_action=(value)
self.action = value
end
alias_attribute :title, :phrase
alias_attribute :filter_action, :action

VALID_CONTEXTS = %w(
home
Expand Down
10 changes: 1 addition & 9 deletions app/models/custom_filter_keyword.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,7 @@ class CustomFilterKeyword < ApplicationRecord

validates :keyword, presence: true

# NOTE: We previously used `alias_attribute` but this does not play nicely
# with cache
def phrase
keyword
end

def phrase=(value)
self.keyword = value
end
alias_attribute :phrase, :keyword

before_save :prepare_cache_invalidation!
before_destroy :prepare_cache_invalidation!
Expand Down
2 changes: 1 addition & 1 deletion config/initializers/new_framework_defaults_7_1.rb
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@
# not yet been upgraded must be able to read caches from upgraded servers,
# leave this optimization off on the first deploy, then enable it on a
# subsequent deploy.
# Rails.application.config.active_record.marshalling_format_version = 7.1
Rails.application.config.active_record.marshalling_format_version = 7.1

# Run `after_commit` and `after_*_commit` callbacks in the order they are defined in a model.
# This matches the behaviour of all other callbacks.
Expand Down
2 changes: 1 addition & 1 deletion lib/mastodon/redis_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def setup_redis_env_url(prefix = nil, defaults = true)
driver: :hiredis,
url: ENV['CACHE_REDIS_URL'],
expires_in: 10.minutes,
namespace: cache_namespace,
namespace: "#{cache_namespace}:7.1",
connect_timeout: 5,
pool: {
size: Sidekiq.server? ? Sidekiq[:concurrency] : Integer(ENV['MAX_THREADS'] || 5),
Expand Down

0 comments on commit 1a80df9

Please sign in to comment.