mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
PERF: 2x ~ 30x faster dirty tracking
Currently, although using both dirty tracking (ivar backed and attributes backed) on one model is not supported (doesn't fully work at least), both dirty tracking are being performed, that is very slow. As long as attributes backed dirty tracking is used, ivar backed dirty tracking should not need to be performed. I've refactored to extract new `ForcedMutationTracker` which only tracks `force_change` to be performed for ivar backed dirty tracking, that makes dirty tracking on Active Record 2x ~ 30x faster. https://gist.github.com/kamipo/971dfe0891f0fe1ec7db8ab31f016435 Before: ``` Warming up -------------------------------------- changed? 4.467k i/100ms changed 5.134k i/100ms changes 3.023k i/100ms changed_attributes 4.358k i/100ms title_change 3.185k i/100ms title_was 3.381k i/100ms Calculating ------------------------------------- changed? 42.197k (±28.5%) i/s - 187.614k in 5.050446s changed 50.481k (±16.0%) i/s - 246.432k in 5.045759s changes 30.799k (± 7.2%) i/s - 154.173k in 5.030765s changed_attributes 51.530k (±14.2%) i/s - 252.764k in 5.041106s title_change 44.667k (± 9.0%) i/s - 222.950k in 5.040646s title_was 44.635k (±16.6%) i/s - 216.384k in 5.051098s ``` After: ``` Warming up -------------------------------------- changed? 24.130k i/100ms changed 13.503k i/100ms changes 6.511k i/100ms changed_attributes 9.226k i/100ms title_change 48.221k i/100ms title_was 96.060k i/100ms Calculating ------------------------------------- changed? 245.478k (±16.1%) i/s - 1.182M in 5.015837s changed 157.641k (± 4.9%) i/s - 796.677k in 5.066734s changes 70.633k (± 5.7%) i/s - 358.105k in 5.086553s changed_attributes 95.155k (±13.6%) i/s - 470.526k in 5.082841s title_change 566.481k (± 3.5%) i/s - 2.845M in 5.028852s title_was 1.487M (± 3.9%) i/s - 7.493M in 5.046774s ```
This commit is contained in:
parent
d4e2824d8d
commit
6b0a9de906
3 changed files with 122 additions and 128 deletions
|
@ -1,14 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "active_support/core_ext/hash/indifferent_access"
|
||||
require "active_support/core_ext/object/duplicable"
|
||||
|
||||
module ActiveModel
|
||||
class AttributeMutationTracker # :nodoc:
|
||||
OPTION_NOT_GIVEN = Object.new
|
||||
|
||||
def initialize(attributes)
|
||||
def initialize(attributes, forced_changes = Set.new)
|
||||
@attributes = attributes
|
||||
@forced_changes = Set.new
|
||||
@forced_changes = forced_changes
|
||||
end
|
||||
|
||||
def changed_attribute_names
|
||||
|
@ -18,24 +19,22 @@ module ActiveModel
|
|||
def changed_values
|
||||
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
|
||||
if changed?(attr_name)
|
||||
result[attr_name] = attributes[attr_name].original_value
|
||||
result[attr_name] = original_value(attr_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def changes
|
||||
attr_names.each_with_object({}.with_indifferent_access) do |attr_name, result|
|
||||
change = change_to_attribute(attr_name)
|
||||
if change
|
||||
if change = change_to_attribute(attr_name)
|
||||
result.merge!(attr_name => change)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def change_to_attribute(attr_name)
|
||||
attr_name = attr_name.to_s
|
||||
if changed?(attr_name)
|
||||
[attributes[attr_name].original_value, attributes.fetch_value(attr_name)]
|
||||
[original_value(attr_name), fetch_value(attr_name)]
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -44,29 +43,26 @@ module ActiveModel
|
|||
end
|
||||
|
||||
def changed?(attr_name, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN)
|
||||
attr_name = attr_name.to_s
|
||||
forced_changes.include?(attr_name) ||
|
||||
attributes[attr_name].changed? &&
|
||||
(OPTION_NOT_GIVEN == from || attributes[attr_name].original_value == from) &&
|
||||
(OPTION_NOT_GIVEN == to || attributes[attr_name].value == to)
|
||||
attribute_changed?(attr_name) &&
|
||||
(OPTION_NOT_GIVEN == from || original_value(attr_name) == from) &&
|
||||
(OPTION_NOT_GIVEN == to || fetch_value(attr_name) == to)
|
||||
end
|
||||
|
||||
def changed_in_place?(attr_name)
|
||||
attributes[attr_name.to_s].changed_in_place?
|
||||
attributes[attr_name].changed_in_place?
|
||||
end
|
||||
|
||||
def forget_change(attr_name)
|
||||
attr_name = attr_name.to_s
|
||||
attributes[attr_name] = attributes[attr_name].forgetting_assignment
|
||||
forced_changes.delete(attr_name)
|
||||
end
|
||||
|
||||
def original_value(attr_name)
|
||||
attributes[attr_name.to_s].original_value
|
||||
attributes[attr_name].original_value
|
||||
end
|
||||
|
||||
def force_change(attr_name)
|
||||
forced_changes << attr_name.to_s
|
||||
forced_changes << attr_name
|
||||
end
|
||||
|
||||
private
|
||||
|
@ -75,45 +71,108 @@ module ActiveModel
|
|||
def attr_names
|
||||
attributes.keys
|
||||
end
|
||||
|
||||
def attribute_changed?(attr_name)
|
||||
forced_changes.include?(attr_name) || !!attributes[attr_name].changed?
|
||||
end
|
||||
|
||||
def fetch_value(attr_name)
|
||||
attributes.fetch_value(attr_name)
|
||||
end
|
||||
end
|
||||
|
||||
class ForcedMutationTracker < AttributeMutationTracker # :nodoc:
|
||||
def initialize(attributes, forced_changes = {})
|
||||
super
|
||||
@finalized_changes = nil
|
||||
end
|
||||
|
||||
def changed_in_place?(attr_name)
|
||||
false
|
||||
end
|
||||
|
||||
def change_to_attribute(attr_name)
|
||||
if finalized_changes&.include?(attr_name)
|
||||
finalized_changes[attr_name].dup
|
||||
else
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def forget_change(attr_name)
|
||||
forced_changes.delete(attr_name)
|
||||
end
|
||||
|
||||
def original_value(attr_name)
|
||||
if changed?(attr_name)
|
||||
forced_changes[attr_name]
|
||||
else
|
||||
fetch_value(attr_name)
|
||||
end
|
||||
end
|
||||
|
||||
def force_change(attr_name)
|
||||
forced_changes[attr_name] = clone_value(attr_name) unless attribute_changed?(attr_name)
|
||||
end
|
||||
|
||||
def finalize_changes
|
||||
@finalized_changes = changes
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :finalized_changes
|
||||
|
||||
def attr_names
|
||||
forced_changes.keys
|
||||
end
|
||||
|
||||
def attribute_changed?(attr_name)
|
||||
forced_changes.include?(attr_name)
|
||||
end
|
||||
|
||||
def fetch_value(attr_name)
|
||||
attributes.send(:_read_attribute, attr_name)
|
||||
end
|
||||
|
||||
def clone_value(attr_name)
|
||||
value = fetch_value(attr_name)
|
||||
value.duplicable? ? value.clone : value
|
||||
rescue TypeError, NoMethodError
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
class NullMutationTracker # :nodoc:
|
||||
include Singleton
|
||||
|
||||
def changed_attribute_names(*)
|
||||
def changed_attribute_names
|
||||
[]
|
||||
end
|
||||
|
||||
def changed_values(*)
|
||||
def changed_values
|
||||
{}
|
||||
end
|
||||
|
||||
def changes(*)
|
||||
def changes
|
||||
{}
|
||||
end
|
||||
|
||||
def change_to_attribute(attr_name)
|
||||
end
|
||||
|
||||
def any_changes?(*)
|
||||
def any_changes?
|
||||
false
|
||||
end
|
||||
|
||||
def changed?(*)
|
||||
def changed?(attr_name, **)
|
||||
false
|
||||
end
|
||||
|
||||
def changed_in_place?(*)
|
||||
def changed_in_place?(attr_name)
|
||||
false
|
||||
end
|
||||
|
||||
def forget_change(*)
|
||||
end
|
||||
|
||||
def original_value(*)
|
||||
end
|
||||
|
||||
def force_change(*)
|
||||
def original_value(attr_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "active_support/hash_with_indifferent_access"
|
||||
require "active_support/core_ext/object/duplicable"
|
||||
require "active_model/attribute_mutation_tracker"
|
||||
|
||||
module ActiveModel
|
||||
|
@ -122,9 +120,6 @@ module ActiveModel
|
|||
extend ActiveSupport::Concern
|
||||
include ActiveModel::AttributeMethods
|
||||
|
||||
OPTION_NOT_GIVEN = Object.new # :nodoc:
|
||||
private_constant :OPTION_NOT_GIVEN
|
||||
|
||||
included do
|
||||
attribute_method_suffix "_changed?", "_change", "_will_change!", "_was"
|
||||
attribute_method_suffix "_previously_changed?", "_previous_change"
|
||||
|
@ -145,10 +140,9 @@ module ActiveModel
|
|||
# +mutations_from_database+ to +mutations_before_last_save+ respectively.
|
||||
def changes_applied
|
||||
unless defined?(@attributes)
|
||||
@previously_changed = changes
|
||||
mutations_from_database.finalize_changes
|
||||
end
|
||||
@mutations_before_last_save = mutations_from_database
|
||||
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
|
||||
forget_attribute_assignments
|
||||
@mutations_from_database = nil
|
||||
end
|
||||
|
@ -159,7 +153,7 @@ module ActiveModel
|
|||
# person.name = 'bob'
|
||||
# person.changed? # => true
|
||||
def changed?
|
||||
changed_attributes.present?
|
||||
mutations_from_database.any_changes?
|
||||
end
|
||||
|
||||
# Returns an array with the name of the attributes with unsaved changes.
|
||||
|
@ -168,42 +162,37 @@ module ActiveModel
|
|||
# person.name = 'bob'
|
||||
# person.changed # => ["name"]
|
||||
def changed
|
||||
changed_attributes.keys
|
||||
mutations_from_database.changed_attribute_names
|
||||
end
|
||||
|
||||
# Handles <tt>*_changed?</tt> for +method_missing+.
|
||||
def attribute_changed?(attr, from: OPTION_NOT_GIVEN, to: OPTION_NOT_GIVEN) # :nodoc:
|
||||
!!changes_include?(attr) &&
|
||||
(to == OPTION_NOT_GIVEN || to == _read_attribute(attr)) &&
|
||||
(from == OPTION_NOT_GIVEN || from == changed_attributes[attr])
|
||||
def attribute_changed?(attr_name, **options) # :nodoc:
|
||||
mutations_from_database.changed?(attr_name.to_s, options)
|
||||
end
|
||||
|
||||
# Handles <tt>*_was</tt> for +method_missing+.
|
||||
def attribute_was(attr) # :nodoc:
|
||||
attribute_changed?(attr) ? changed_attributes[attr] : _read_attribute(attr)
|
||||
def attribute_was(attr_name) # :nodoc:
|
||||
mutations_from_database.original_value(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Handles <tt>*_previously_changed?</tt> for +method_missing+.
|
||||
def attribute_previously_changed?(attr) #:nodoc:
|
||||
previous_changes_include?(attr)
|
||||
def attribute_previously_changed?(attr_name) # :nodoc:
|
||||
mutations_before_last_save.changed?(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Restore all previous data of the provided attributes.
|
||||
def restore_attributes(attributes = changed)
|
||||
attributes.each { |attr| restore_attribute! attr }
|
||||
def restore_attributes(attr_names = changed)
|
||||
attr_names.each { |attr_name| restore_attribute!(attr_name) }
|
||||
end
|
||||
|
||||
# Clears all dirty data: current changes and previous changes.
|
||||
def clear_changes_information
|
||||
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
|
||||
@mutations_before_last_save = nil
|
||||
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
|
||||
forget_attribute_assignments
|
||||
@mutations_from_database = nil
|
||||
end
|
||||
|
||||
def clear_attribute_changes(attr_names)
|
||||
attributes_changed_by_setter.except!(*attr_names)
|
||||
attr_names.each do |attr_name|
|
||||
clear_attribute_change(attr_name)
|
||||
end
|
||||
|
@ -216,13 +205,7 @@ module ActiveModel
|
|||
# person.name = 'robert'
|
||||
# person.changed_attributes # => {"name" => "bob"}
|
||||
def changed_attributes
|
||||
# This should only be set by methods which will call changed_attributes
|
||||
# multiple times when it is known that the computed value cannot change.
|
||||
if defined?(@cached_changed_attributes)
|
||||
@cached_changed_attributes
|
||||
else
|
||||
attributes_changed_by_setter.reverse_merge(mutations_from_database.changed_values).freeze
|
||||
end
|
||||
mutations_from_database.changed_values
|
||||
end
|
||||
|
||||
# Returns a hash of changed attributes indicating their original
|
||||
|
@ -232,9 +215,7 @@ module ActiveModel
|
|||
# person.name = 'bob'
|
||||
# person.changes # => { "name" => ["bill", "bob"] }
|
||||
def changes
|
||||
cache_changed_attributes do
|
||||
ActiveSupport::HashWithIndifferentAccess[changed.map { |attr| [attr, attribute_change(attr)] }]
|
||||
end
|
||||
mutations_from_database.changes
|
||||
end
|
||||
|
||||
# Returns a hash of attributes that were changed before the model was saved.
|
||||
|
@ -244,27 +225,23 @@ module ActiveModel
|
|||
# person.save
|
||||
# person.previous_changes # => {"name" => ["bob", "robert"]}
|
||||
def previous_changes
|
||||
@previously_changed ||= ActiveSupport::HashWithIndifferentAccess.new
|
||||
@previously_changed.merge(mutations_before_last_save.changes)
|
||||
mutations_before_last_save.changes
|
||||
end
|
||||
|
||||
def attribute_changed_in_place?(attr_name) # :nodoc:
|
||||
mutations_from_database.changed_in_place?(attr_name)
|
||||
mutations_from_database.changed_in_place?(attr_name.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
def clear_attribute_change(attr_name)
|
||||
mutations_from_database.forget_change(attr_name)
|
||||
mutations_from_database.forget_change(attr_name.to_s)
|
||||
end
|
||||
|
||||
def mutations_from_database
|
||||
unless defined?(@mutations_from_database)
|
||||
@mutations_from_database = nil
|
||||
end
|
||||
@mutations_from_database ||= if defined?(@attributes)
|
||||
ActiveModel::AttributeMutationTracker.new(@attributes)
|
||||
else
|
||||
NullMutationTracker.instance
|
||||
ActiveModel::ForcedMutationTracker.new(self)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -276,68 +253,28 @@ module ActiveModel
|
|||
@mutations_before_last_save ||= ActiveModel::NullMutationTracker.instance
|
||||
end
|
||||
|
||||
def cache_changed_attributes
|
||||
@cached_changed_attributes = changed_attributes
|
||||
yield
|
||||
ensure
|
||||
clear_changed_attributes_cache
|
||||
end
|
||||
|
||||
def clear_changed_attributes_cache
|
||||
remove_instance_variable(:@cached_changed_attributes) if defined?(@cached_changed_attributes)
|
||||
end
|
||||
|
||||
# Returns +true+ if attr_name is changed, +false+ otherwise.
|
||||
def changes_include?(attr_name)
|
||||
attributes_changed_by_setter.include?(attr_name) || mutations_from_database.changed?(attr_name)
|
||||
end
|
||||
alias attribute_changed_by_setter? changes_include?
|
||||
|
||||
# Returns +true+ if attr_name were changed before the model was saved,
|
||||
# +false+ otherwise.
|
||||
def previous_changes_include?(attr_name)
|
||||
previous_changes.include?(attr_name)
|
||||
end
|
||||
|
||||
# Handles <tt>*_change</tt> for +method_missing+.
|
||||
def attribute_change(attr)
|
||||
[changed_attributes[attr], _read_attribute(attr)] if attribute_changed?(attr)
|
||||
def attribute_change(attr_name)
|
||||
mutations_from_database.change_to_attribute(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Handles <tt>*_previous_change</tt> for +method_missing+.
|
||||
def attribute_previous_change(attr)
|
||||
previous_changes[attr]
|
||||
def attribute_previous_change(attr_name)
|
||||
mutations_before_last_save.change_to_attribute(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Handles <tt>*_will_change!</tt> for +method_missing+.
|
||||
def attribute_will_change!(attr)
|
||||
unless attribute_changed?(attr)
|
||||
begin
|
||||
value = _read_attribute(attr)
|
||||
value = value.duplicable? ? value.clone : value
|
||||
rescue TypeError, NoMethodError
|
||||
end
|
||||
|
||||
set_attribute_was(attr, value)
|
||||
end
|
||||
mutations_from_database.force_change(attr)
|
||||
def attribute_will_change!(attr_name)
|
||||
mutations_from_database.force_change(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Handles <tt>restore_*!</tt> for +method_missing+.
|
||||
def restore_attribute!(attr)
|
||||
if attribute_changed?(attr)
|
||||
__send__("#{attr}=", changed_attributes[attr])
|
||||
clear_attribute_changes([attr])
|
||||
end
|
||||
end
|
||||
|
||||
def attributes_changed_by_setter
|
||||
@attributes_changed_by_setter ||= ActiveSupport::HashWithIndifferentAccess.new
|
||||
end
|
||||
|
||||
# Force an attribute to have a particular "before" value
|
||||
def set_attribute_was(attr, old_value)
|
||||
attributes_changed_by_setter[attr] = old_value
|
||||
def restore_attribute!(attr_name)
|
||||
attr_name = attr_name.to_s
|
||||
if attribute_changed?(attr_name)
|
||||
__send__("#{attr_name}=", attribute_was(attr_name))
|
||||
clear_attribute_change(attr_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -29,9 +29,7 @@ module ActiveRecord
|
|||
# <tt>reload</tt> the record and clears changed attributes.
|
||||
def reload(*)
|
||||
super.tap do
|
||||
@previously_changed = ActiveSupport::HashWithIndifferentAccess.new
|
||||
@mutations_before_last_save = nil
|
||||
@attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new
|
||||
@mutations_from_database = nil
|
||||
end
|
||||
end
|
||||
|
@ -51,7 +49,7 @@ module ActiveRecord
|
|||
# +to+ When passed, this method will return false unless the value was
|
||||
# changed to the given value
|
||||
def saved_change_to_attribute?(attr_name, **options)
|
||||
mutations_before_last_save.changed?(attr_name, **options)
|
||||
mutations_before_last_save.changed?(attr_name.to_s, options)
|
||||
end
|
||||
|
||||
# Returns the change to an attribute during the last save. If the
|
||||
|
@ -63,7 +61,7 @@ module ActiveRecord
|
|||
# invoked as +saved_change_to_name+ instead of
|
||||
# <tt>saved_change_to_attribute("name")</tt>.
|
||||
def saved_change_to_attribute(attr_name)
|
||||
mutations_before_last_save.change_to_attribute(attr_name)
|
||||
mutations_before_last_save.change_to_attribute(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Returns the original value of an attribute before the last save.
|
||||
|
@ -73,7 +71,7 @@ module ActiveRecord
|
|||
# invoked as +name_before_last_save+ instead of
|
||||
# <tt>attribute_before_last_save("name")</tt>.
|
||||
def attribute_before_last_save(attr_name)
|
||||
mutations_before_last_save.original_value(attr_name)
|
||||
mutations_before_last_save.original_value(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Did the last call to +save+ have any changes to change?
|
||||
|
@ -101,7 +99,7 @@ module ActiveRecord
|
|||
# +to+ When passed, this method will return false unless the value will be
|
||||
# changed to the given value
|
||||
def will_save_change_to_attribute?(attr_name, **options)
|
||||
mutations_from_database.changed?(attr_name, **options)
|
||||
mutations_from_database.changed?(attr_name.to_s, options)
|
||||
end
|
||||
|
||||
# Returns the change to an attribute that will be persisted during the
|
||||
|
@ -115,7 +113,7 @@ module ActiveRecord
|
|||
# If the attribute will change, the result will be an array containing the
|
||||
# original value and the new value about to be saved.
|
||||
def attribute_change_to_be_saved(attr_name)
|
||||
mutations_from_database.change_to_attribute(attr_name)
|
||||
mutations_from_database.change_to_attribute(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Returns the value of an attribute in the database, as opposed to the
|
||||
|
@ -127,7 +125,7 @@ module ActiveRecord
|
|||
# saved. It can be invoked as +name_in_database+ instead of
|
||||
# <tt>attribute_in_database("name")</tt>.
|
||||
def attribute_in_database(attr_name)
|
||||
mutations_from_database.original_value(attr_name)
|
||||
mutations_from_database.original_value(attr_name.to_s)
|
||||
end
|
||||
|
||||
# Will the next call to +save+ have any changes to persist?
|
||||
|
|
Loading…
Reference in a new issue