diff --git a/gemfiles/standard.rb b/gemfiles/standard.rb index fab9ee0ffa..a1f71416a2 100644 --- a/gemfiles/standard.rb +++ b/gemfiles/standard.rb @@ -28,6 +28,7 @@ def standard_dependencies platform :mri do gem 'byebug' + gem 'allocation_stats', require: false end platform :jruby do diff --git a/lib/mongoid/attributes.rb b/lib/mongoid/attributes.rb index 7cebcb2ea3..5c7c8245f8 100644 --- a/lib/mongoid/attributes.rb +++ b/lib/mongoid/attributes.rb @@ -146,10 +146,29 @@ def remove_attribute(name) attribute_will_change!(access) delayed_atomic_unsets[atomic_attribute_name(access)] = [] unless new_record? attributes.delete(access) + # Clear cache since the attribute is being removed + clear_demongoized_cache(access) end end end + # Clear the demongoized cache for a specific field. + # + # This method centralizes cache invalidation to ensure all attribute + # mutation paths (write, remove, unset, rename) remain consistent. + # + # @param [ String ] name The field name to clear from cache. + # + # @return [ void ] + # + # @since 9.1.0 + # + # @api private + def clear_demongoized_cache(name) + @__demongoized_cache.delete(name) if Mongoid::Config.cache_attribute_values? + end + private :clear_demongoized_cache + # Write a single attribute to the document attribute hash. This will # also fire the before and after update callbacks, and perform any # necessary typecasting. @@ -173,6 +192,9 @@ def write_attribute(name, value) if attribute_writable?(field_name) _assigning do + # Clear demongoized cache for this field since we're writing a new value + clear_demongoized_cache(field_name) + localized = fields[field_name].try(:localized?) attributes_before_type_cast[name.to_s] = value typed_value = typed_value_for(field_name, value) @@ -241,6 +263,9 @@ def write_attributes(attrs = nil) # Determine if the attribute is missing from the document, due to loading # it from the database with missing fields. # + # Cache the projector keyed by __selected_fields to automatically handle + # invalidation when selected fields change (only if caching is enabled). + # # @example Is the attribute missing? # document.attribute_missing?("test") # @@ -248,7 +273,14 @@ def write_attributes(attrs = nil) # # @return [ true | false ] If the attribute is missing. def attribute_missing?(name) - !Projector.new(__selected_fields).attribute_or_path_allowed?(name) + if Mongoid::Config.cache_attribute_values? + projector = @__projector_cache.compute_if_absent(__selected_fields) do + Projector.new(__selected_fields) + end + !projector.attribute_or_path_allowed?(name) + else + !Projector.new(__selected_fields).attribute_or_path_allowed?(name) + end end # Return type-casted attributes. diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index f6df943d1a..8c906b87ae 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -217,6 +217,41 @@ def validate_isolation_level!(level) # document might be ignored, or it might work, depending on the situation. option :immutable_ids, default: true + # When this flag is true, Mongoid will cache demongoized attribute values + # to improve read performance. The cache stores both the raw BSON value + # and the demongoized Ruby object, automatically invalidating when the + # underlying raw value changes. + # + # This optimization can significantly improve performance for fields with + # expensive demongoization (e.g., Time, Date, custom types), especially + # in read-heavy workloads. + # + # The cache is disabled by default to maintain backward compatibility. + # Enable it to gain performance improvements: + # + # Mongoid.configure do |config| + # config.cache_attribute_values = true + # end + # + # @note This option must be set during application initialization and + # should not be changed at runtime. Changing this flag at runtime is + # unsupported and may lead to undefined behavior or errors. + # + # If caching is enabled after documents are created: + # - Pre-existing documents will not have cache instance variables + # initialized, causing NoMethodError when field accessors attempt + # to use the cache + # - Only newly created documents will benefit from caching + # + # If caching is disabled after documents are created: + # - Pre-existing documents will retain their cache instance variables + # but the caches will not be consulted, wasting memory + # - Newly created documents will not have caches + # + # To avoid inconsistent behavior, always configure this option once + # at application boot and do not modify it thereafter. + option :cache_attribute_values, default: false + # When this flag is true, callbacks for every embedded document will be # called only once, even if the embedded document is embedded in multiple # documents in the root document's dependencies graph. diff --git a/lib/mongoid/document.rb b/lib/mongoid/document.rb index 1e5dc18ae7..c19436d5b2 100644 --- a/lib/mongoid/document.rb +++ b/lib/mongoid/document.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'concurrent/map' require 'mongoid/positional' require 'mongoid/evolvable' require 'mongoid/extensions' @@ -235,10 +236,31 @@ def construct_document(attrs = nil, options = {}) def prepare_to_process_attributes @new_record = true @attributes ||= {} + initialize_field_caches apply_pre_processed_defaults apply_default_scoping end + # Initialize field cache instance variables to ensure consistent object shape. + # + # Initializes @__projector_cache and @__demongoized_cache early in all document + # creation paths. This ensures all documents have the same instance variable + # layout from the start, allowing Ruby's JIT compilers (YJIT, MJIT) to generate + # optimized code. Without this, lazy cache creation would cause shape polymorphism, + # preventing JIT optimizations. + # + # @return [ void ] + # + # @since 9.1.0 + # + # @api private + def initialize_field_caches + return unless Mongoid::Config.cache_attribute_values? + + @__projector_cache = Concurrent::Map.new + @__demongoized_cache = Concurrent::Map.new + end + # Returns the logger # # @return [ Logger ] The configured logger or a default Logger instance. @@ -415,6 +437,7 @@ def instantiate_document(attrs = nil, selected_fields = nil, options = {}, &bloc doc.__selected_fields = selected_fields doc.instance_variable_set(:@attributes, attributes) doc.instance_variable_set(:@attributes_before_type_cast, attributes.dup) + doc.send(:initialize_field_caches) doc._handle_callbacks_after_instantiation(execute_callbacks, &block) diff --git a/lib/mongoid/fields.rb b/lib/mongoid/fields.rb index b92301ae0d..5d6d64bf98 100644 --- a/lib/mongoid/fields.rb +++ b/lib/mongoid/fields.rb @@ -100,6 +100,17 @@ def extract_id_field(attributes) def cleanse_localized_field_names(name) name = database_field_name(name.to_s) + # Fast path: if no dots, avoid array allocations entirely + unless name.include?(".") + # Simple field without nesting - just check for translation suffix + if !fields.key?(name) && !relations.key?(name) && name.end_with?(TRANSLATIONS_SFX) + return name.delete_suffix(TRANSLATIONS_SFX) + end + + return name + end + + # Slow path for nested fields (original logic) klass = self [].tap do |res| ar = name.split('.') @@ -186,6 +197,7 @@ def apply_default(name) default = field.eval_default(self) unless default.nil? || field.lazy? attribute_will_change!(name) + clear_demongoized_cache(name) attributes[name] = default end end @@ -337,7 +349,14 @@ def options # or no field was found for the given key. # # @api private - def traverse_association_tree(key, fields, associations, aliased_associations) + def traverse_association_tree(key, fields, associations, aliased_associations, &block) + # Fast path: if no dots, it's a simple field lookup + unless key.include?(".") + field, _klass = process_field_or_association(key, key, fields, associations, aliased_associations, &block) + return field + end + + # Slow path for nested fields (original logic) klass = nil field = nil key.split('.').each_with_index do |meth, i| @@ -345,32 +364,45 @@ def traverse_association_tree(key, fields, associations, aliased_associations) rs = i == 0 ? associations : klass&.relations as = i == 0 ? aliased_associations : klass&.aliased_associations - # Associations can possibly have two "keys", their name and their alias. - # The fields name is what is used to store it in the klass's relations - # and field hashes, and the alias is what's used to store that field - # in the database. The key inputted to this function is the aliased - # key. We can convert them back to their names by looking in the - # aliased_associations hash. - aliased = meth - if as && a = as.fetch(meth, nil) - aliased = a.to_s - end - - field = nil - klass = nil - if fs && f = fs[aliased] - field = f - yield(meth, f, true) if block_given? - elsif rs && rel = rs[aliased] - klass = rel.klass - yield(meth, rel, false) if block_given? - else - yield(meth, nil, false) if block_given? - end + field, klass = process_field_or_association(meth, meth, fs, rs, as, &block) end field end + # Process a single field or association segment. + # + # @param [ String ] key The original key for yielding. + # @param [ String ] meth The method/segment name to look up. + # @param [ Hash ] fields The fields hash to search. + # @param [ Hash ] associations The associations hash to search. + # @param [ Hash ] aliased_associations The aliased associations hash. + # + # @return [ Array ] Returns [field, klass] where field is + # the found field (or nil) and klass is the relation klass (or nil). + # + # @api private + def process_field_or_association(key, meth, fields, associations, aliased_associations) + # Resolve alias if present + aliased = meth + if aliased_associations && a = aliased_associations.fetch(meth, nil) + aliased = a.to_s + end + + field = nil + klass = nil + if fields && f = fields[aliased] + field = f + yield(key, f, true) if block_given? + elsif associations && rel = associations[aliased] + klass = rel.klass + yield(key, rel, false) if block_given? + else + yield(key, nil, false) if block_given? + end + + [field, klass] + end + # Get the name of the provided field as it is stored in the database. # Used in determining if the field is aliased or not. Recursively # finds aliases for embedded documents and fields, delimited with @@ -416,6 +448,13 @@ def database_field_name(name, relations, aliased_fields, aliased_associations) return "" unless name.present? key = name.to_s + + # Fast path: if no dots, avoid split allocation + unless key.include?(".") + return aliased_fields[key]&.dup || key + end + + # Slow path for nested fields (original logic with split) segment, remaining = key.split('.', 2) # Don't get the alias for the field when a belongs_to association @@ -647,10 +686,42 @@ def create_accessors(name, meth, options = {}) def create_field_getter(name, meth, field) generated_methods.module_eval do re_define_method(meth) do + # Handle lazy defaults first (before reading raw value) raw = read_raw_attribute(name) if lazy_settable?(field, raw) write_attribute(name, field.eval_default(self)) + # Don't cache localized fields as they depend on I18n.locale + elsif field.localized? + process_raw_attribute(name.to_s, raw, field) + # Check if caching is enabled + elsif Mongoid::Config.cache_attribute_values? + # Atomically fetch or compute the cached value + # Cache stores [raw_value, demongoized_value] to detect stale cache + value = @__demongoized_cache.compute_if_absent(name) do + # Cache miss - re-read raw inside block to avoid race conditions + current_raw = read_raw_attribute(name) + demongoized = process_raw_attribute(name.to_s, current_raw, field) + [current_raw, demongoized] + end + + # Check if cached raw value matches current raw value + if value[0] != raw + # Raw value changed (direct attributes hash modification) - recompute + demongoized = process_raw_attribute(name.to_s, raw, field) + value = @__demongoized_cache[name] = [raw, demongoized] + end + + demongoized_value = value[1] + + # For resizable values (like arrays), we need to track changes on every read + # because mutations can happen to the cached object. This matches master behavior + # where process_raw_attribute is called on every read. + is_relation = relations.key?(name.to_s) + attribute_will_change!(name.to_s) if demongoized_value.resizable? && !is_relation + + demongoized_value else + # Caching disabled - use original behavior process_raw_attribute(name.to_s, raw, field) end end diff --git a/lib/mongoid/persistable/incrementable.rb b/lib/mongoid/persistable/incrementable.rb index 1fd5b9a458..c5092ef697 100644 --- a/lib/mongoid/persistable/incrementable.rb +++ b/lib/mongoid/persistable/incrementable.rb @@ -26,6 +26,7 @@ def inc(increments) new_value = (current || 0) + increment process_attribute field, new_value if executing_atomically? attributes[field] = new_value + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = increment end { "$inc" => ops } unless ops.empty? diff --git a/lib/mongoid/persistable/logical.rb b/lib/mongoid/persistable/logical.rb index 4d8a36693e..ec52a92f3c 100644 --- a/lib/mongoid/persistable/logical.rb +++ b/lib/mongoid/persistable/logical.rb @@ -27,6 +27,7 @@ def bit(operations) end process_attribute field, value if executing_atomically? attributes[field] = value + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = values end { "$bit" => ops } unless ops.empty? diff --git a/lib/mongoid/persistable/multipliable.rb b/lib/mongoid/persistable/multipliable.rb index 787d940590..2048ce0f57 100644 --- a/lib/mongoid/persistable/multipliable.rb +++ b/lib/mongoid/persistable/multipliable.rb @@ -26,6 +26,7 @@ def mul(factors) new_value = (current || 0) * factor process_attribute field, new_value if executing_atomically? attributes[field] = new_value + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = factor end { "$mul" => ops } unless ops.empty? diff --git a/lib/mongoid/persistable/poppable.rb b/lib/mongoid/persistable/poppable.rb index a0bfd3658a..15be6b05a8 100644 --- a/lib/mongoid/persistable/poppable.rb +++ b/lib/mongoid/persistable/poppable.rb @@ -27,6 +27,7 @@ def pop(pops) process_atomic_operations(pops) do |field, value| values = send(field) value > 0 ? values.pop : values.shift + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = value end { "$pop" => ops } diff --git a/lib/mongoid/persistable/pullable.rb b/lib/mongoid/persistable/pullable.rb index 4d881c9599..1d719a8683 100644 --- a/lib/mongoid/persistable/pullable.rb +++ b/lib/mongoid/persistable/pullable.rb @@ -22,6 +22,7 @@ def pull(pulls) prepare_atomic_operation do |ops| process_atomic_operations(pulls) do |field, value| (send(field) || []).delete(value) + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = value end { "$pull" => ops } @@ -41,6 +42,7 @@ def pull_all(pulls) process_atomic_operations(pulls) do |field, value| existing = send(field) || [] value.each{ |val| existing.delete(val) } + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = value end { "$pullAll" => ops } diff --git a/lib/mongoid/persistable/pushable.rb b/lib/mongoid/persistable/pushable.rb index 8c533b226e..b8b4fde086 100644 --- a/lib/mongoid/persistable/pushable.rb +++ b/lib/mongoid/persistable/pushable.rb @@ -31,6 +31,7 @@ def add_to_set(adds) values.each do |val| existing.push(val) unless existing.include?(val) end + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = { "$each" => values } end { "$addToSet" => ops } @@ -57,6 +58,7 @@ def push(pushes) end values = [ value ].flatten(1) values.each{ |val| existing.push(val) } + clear_demongoized_cache(field) ops[atomic_attribute_name(field)] = { "$each" => values } end { "$push" => ops } diff --git a/lib/mongoid/persistable/renamable.rb b/lib/mongoid/persistable/renamable.rb index 81840f668b..9a73b534ae 100644 --- a/lib/mongoid/persistable/renamable.rb +++ b/lib/mongoid/persistable/renamable.rb @@ -27,6 +27,8 @@ def rename(renames) process_attribute old_field, nil else attributes[new_name] = attributes.delete(old_field) + clear_demongoized_cache(old_field) + clear_demongoized_cache(new_name) end ops[atomic_attribute_name(old_field)] = atomic_attribute_name(new_name) end diff --git a/lib/mongoid/persistable/unsettable.rb b/lib/mongoid/persistable/unsettable.rb index 44ce6cbabb..cc132385fe 100644 --- a/lib/mongoid/persistable/unsettable.rb +++ b/lib/mongoid/persistable/unsettable.rb @@ -26,6 +26,7 @@ def unset(*fields) process_attribute normalized, nil else attributes.delete(normalized) + clear_demongoized_cache(normalized) end ops[atomic_attribute_name(normalized)] = true end diff --git a/lib/mongoid/stateful.rb b/lib/mongoid/stateful.rb index 334db80e3c..283e5eb3dd 100644 --- a/lib/mongoid/stateful.rb +++ b/lib/mongoid/stateful.rb @@ -156,6 +156,7 @@ def updateable? def reset_readonly self.__selected_fields = nil + initialize_field_caches end end end diff --git a/perf/benchmark_cache_attribute_values.rb b/perf/benchmark_cache_attribute_values.rb new file mode 100755 index 0000000000..7ed36b60fa --- /dev/null +++ b/perf/benchmark_cache_attribute_values.rb @@ -0,0 +1,245 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# rubocop:todo all + +# Benchmark script to compare field access performance between master and current branch. +# This measures timing improvements from field access caching optimizations. +# +# Usage: +# ruby perf/benchmark_field_cache.rb +# +# The script will: +# 1. Run benchmark on current branch +# 2. Switch to master and run benchmark +# 3. Switch back to original branch +# 4. Display comparison + +require "tempfile" +require "fileutils" + +# Save original branch +ORIGINAL_BRANCH = `git rev-parse --abbrev-ref HEAD`.strip + +# Create temp benchmark script that can run on any branch +BENCHMARK_SCRIPT = <<~'RUBY' + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) + + require "benchmark/ips" + require "mongoid" + + # Define models inline to work on both branches + class Band + include Mongoid::Document + + field :name, type: String + field :origin, type: String + field :tags, type: Hash + field :genres, type: Array + field :rating, type: Float + field :member_count, type: Integer + field :active, type: Mongoid::Boolean + field :founded, type: Date + field :updated, type: Time + field :decibels, type: Range + end + + class Person + include Mongoid::Document + + field :birth_date, type: Date + field :title, type: String + + embeds_many :addresses, validate: false + end + + class Address + include Mongoid::Document + + field :street, type: String + field :city, type: String + field :post_code, type: String + embedded_in :person + end + + Mongoid.connect_to("mongoid_perf_field_cache") + Mongo::Logger.logger.level = ::Logger::FATAL + + # Configure time zone for Date handling + Time.zone = "UTC" + + puts "Branch: #{`git rev-parse --abbrev-ref HEAD`.strip} | Commit: #{`git rev-parse --short HEAD`.strip}" + + # Check if caching config is available (current branch has it, master doesn't) + cache_available = Mongoid::Config.respond_to?(:cache_attribute_values=) + + if cache_available + Mongoid::Config.cache_attribute_values = true + puts "Field caching: enabled" + else + puts "Field caching: not available (baseline)" + end + + Mongoid.purge! + + # Create test data with all field types from performance_spec + band = Band.create!( + name: 'Test Band', + origin: 'Test City', + tags: { 'genre' => 'rock', 'era' => '80s' }, + genres: %w[rock metal], + rating: 8.5, + member_count: 4, + active: true, + founded: Date.current, + updated: Time.current, + decibels: (50..120) + ) + + # Also test with embedded documents + person = Person.create!( + title: "Senior Engineer", + birth_date: Date.new(1985, 6, 15) + ) + + 5.times do |n| + person.addresses.create!( + street: "Wienerstr. #{n}", + city: "Berlin", + post_code: "10999" + ) + end + + # Disable GC during benchmark for more stable results + GC.disable + + # Run GC before starting to ensure clean state + 3.times { GC.start } + + Benchmark.ips do |x| + # Balance between accuracy and speed + x.config(time: 5, warmup: 2) + + puts "\n[ Repeated Field Access (10x per iteration) ]" + x.report("String 10x") { 10.times { band.name } } + x.report("Integer 10x") { 10.times { band.member_count } } + x.report("Float 10x") { 10.times { band.rating } } + x.report("Boolean 10x") { 10.times { band.active } } + x.report("Date 10x") { 10.times { band.founded } } + x.report("Time 10x") { 10.times { band.updated } } + x.report("Hash 10x") { 10.times { band.tags } } + x.report("Array 10x") { 10.times { band.genres } } + x.report("Range 10x") { 10.times { band.decibels } } + x.report("BSON::ObjectId 10x") { 10.times { band.id } } + + puts "\n[ Embedded Documents ]" + x.report("iterate embedded (5 docs)") do + person.addresses.each do |addr| + addr.street + addr.city + addr.post_code + end + end + + puts "\n[ Read After Write ]" + x.report("write then read") do + band.name = "Modified Band" + band.name + end + end + + # Re-enable GC after benchmark + GC.enable +RUBY + +def parse_results(text) + results = {} + calculating_section = text.split('Calculating -------------------------------------')[1] + return results unless calculating_section + + calculating_section.scan(/^\s*(.+?)\s+([\d.]+[kM])\s+\([^)]+\)\s+i\/s/) do |name, ips| + multiplier = ips.include?('M') ? 1_000_000 : 1_000 + value = ips.gsub(/[kM]/, '').to_f * multiplier + results[name.strip] = value + end + results +end + +def compare_results(current_file, master_file) + current = File.read(current_file) + master = File.read(master_file) + + current_results = parse_results(current) + master_results = parse_results(master) + + puts "" + puts "=" * 80 + puts "BENCHMARK COMPARISON: Current Branch vs Master" + puts "=" * 80 + puts "" + printf "%-30s %15s %15s %12s\n", "Test", "Current", "Master", "Improvement" + puts "-" * 80 + + current_results.each do |test, current_ips| + master_ips = master_results[test] + next unless master_ips + + improvement = ((current_ips - master_ips) / master_ips * 100).round(1) + + printf "%-30s %15s %15s %11s%%\n", + test, + "%.2fM" % (current_ips / 1_000_000), + "%.2fM" % (master_ips / 1_000_000), + "%+.1f" % improvement + end + + puts "=" * 80 +end + +# Main execution +if ORIGINAL_BRANCH == 'master' + # Just run benchmark on master + eval(BENCHMARK_SCRIPT) +else + # Get repository root + repo_root = File.expand_path('..', __dir__) + + # Create temp script + temp_script = Tempfile.new(['benchmark', '.rb']) + temp_script.write(BENCHMARK_SCRIPT) + temp_script.close + + # Run on current branch in isolated process + puts "Running benchmark on current branch (this will take ~90 seconds)..." + current_output = Tempfile.new(['benchmark_current', '.txt']) + current_output.close + + # Use bundle exec to ensure proper gem environment, run in fresh process + system("cd #{repo_root} && bundle exec ruby #{temp_script.path} > #{current_output.path} 2>&1", exception: true) + + # Switch to master and run + puts "\nSwitching to master branch..." + master_output = Tempfile.new(['benchmark_master', '.txt']) + master_output.close + + system("git checkout master -q", exception: true) + + # Bundle install might be needed if dependencies differ + puts "Ensuring dependencies are installed on master..." + system("cd #{repo_root} && bundle install --quiet > /dev/null 2>&1") + + puts "Running benchmark on master branch (this will take ~90 seconds)..." + # Use bundle exec to ensure proper gem environment, run in fresh process + system("cd #{repo_root} && bundle exec ruby #{temp_script.path} > #{master_output.path} 2>&1", exception: true) + + # Switch back to original branch + system("git checkout #{ORIGINAL_BRANCH} -q", exception: true) + puts "Switched back to #{ORIGINAL_BRANCH}" + + # Display comparison + compare_results(current_output.path, master_output.path) + + # Cleanup + temp_script.unlink + current_output.unlink + master_output.unlink +end diff --git a/spec/mongoid/config_spec.rb b/spec/mongoid/config_spec.rb index 5e1f8c02a6..916c802e05 100644 --- a/spec/mongoid/config_spec.rb +++ b/spec/mongoid/config_spec.rb @@ -846,6 +846,91 @@ end end + describe 'cache_attribute_values option' do + context 'when not set in the config' do + it 'defaults to false' do + Mongoid::Config.reset + configuration = CONFIG.merge(options: {}) + + Mongoid.configure { |config| config.load_configuration(configuration) } + + expect(Mongoid::Config.cache_attribute_values).to be(false) + end + end + + context 'when set to true in the config' do + it 'enables field value caching' do + Mongoid::Config.reset + configuration = CONFIG.merge(options: { cache_attribute_values: true }) + + Mongoid.configure { |config| config.load_configuration(configuration) } + + expect(Mongoid::Config.cache_attribute_values).to be(true) + end + end + + context 'when set to false in the config' do + it 'disables field value caching' do + Mongoid::Config.reset + configuration = CONFIG.merge(options: { cache_attribute_values: false }) + + Mongoid.configure { |config| config.load_configuration(configuration) } + + expect(Mongoid::Config.cache_attribute_values).to be(false) + end + end + + context 'functional behavior' do + let(:band_class) do + Class.new do + include Mongoid::Document + store_in collection: 'bands' + field :name, type: String + field :updated, type: Time + end + end + + before do + stub_const('CacheBand', band_class) + end + + around do |example| + original_value = Mongoid::Config.cache_attribute_values + example.run + ensure + Mongoid::Config.cache_attribute_values = original_value + end + + it 'uses caching when enabled' do + Mongoid::Config.cache_attribute_values = true + + band = CacheBand.new(name: 'Test', updated: Time.current) + + # First access should populate cache + first_result = band.updated + + # Second access should return cached value (same object_id if caching works) + second_result = band.updated + + expect(first_result.object_id).to eq(second_result.object_id) + end + + it 'does not use caching when disabled' do + Mongoid::Config.cache_attribute_values = false + + band = CacheBand.new(name: 'Test', updated: Time.current) + + # Each access should call process_raw_attribute + first_result = band.updated + second_result = band.updated + + # When caching is disabled, cache objects should not be initialized + expect(band.instance_variable_get(:@__demongoized_cache)).to be_nil + expect(band.instance_variable_get(:@__projector_cache)).to be_nil + end + end + end + describe 'deprecations' do {}.each do |option, default| diff --git a/spec/mongoid/fields/performance_spec.rb b/spec/mongoid/fields/performance_spec.rb new file mode 100644 index 0000000000..4fdf8d29a1 --- /dev/null +++ b/spec/mongoid/fields/performance_spec.rb @@ -0,0 +1,748 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ContextWording, RSpec/ExampleLength + +require 'spec_helper' +require 'concurrent/array' + +# Allocation tracking is optional (only available on MRI with allocation_stats gem) +begin + require 'allocation_stats' + allocation_stats_available = true +rescue LoadError + allocation_stats_available = false +end + +# Performance tests validate that field access optimizations achieve zero allocations. +# Core field types (String, Integer, Float, etc.) must have exactly 0 allocations +# to verify the caching optimization is working correctly. +describe 'Mongoid::Fields performance optimizations' do + # Enable caching for all performance tests + around do |example| + original_value = Mongoid::Config.cache_attribute_values + Mongoid::Config.cache_attribute_values = true + example.run + ensure + Mongoid::Config.cache_attribute_values = original_value + end + + let(:band) do + Band.new( + name: 'Test Band', + origin: 'Test City', + tags: { 'genre' => 'rock', 'era' => '80s' }, + genres: %w[rock metal], + rating: 8.5, + member_count: 4, + active: true, + founded: Date.current, + updated: Time.current + ) + end + + shared_examples 'zero allocation field access' do |field_name| + it "achieves zero allocations for #{field_name}" do + subject.public_send(field_name) # warm up + stats = AllocationStats.trace { 10.times { subject.public_send(field_name) } } + expect(stats.new_allocations.size).to eq(0) + end + end + + describe 'allocation optimizations' do + before do + skip 'allocation_stats gem not available' unless allocation_stats_available + end + + context 'field access' do + subject { band } + + %i[name tags genres member_count rating active updated founded].each do |field| + include_examples 'zero allocation field access', field + end + + it 'achieves zero allocations for BSON::ObjectId fields' do + band.save! + band.id # warm up + stats = AllocationStats.trace { 10.times { band.id } } + expect(stats.new_allocations.size).to eq(0) + end + + it 'achieves zero allocations for Symbol fields' do + # Use a test-specific class to avoid polluting Band with extra fields + symbol_band_class = Class.new do + include Mongoid::Document + store_in collection: 'bands' + field :status, type: Symbol + end + stub_const('SymbolBand', symbol_band_class) + + band = SymbolBand.new(status: :active) + band.status # warm up + stats = AllocationStats.trace { 10.times { band.status } } + expect(stats.new_allocations.size).to eq(0) + end + + it 'achieves zero allocations for Range fields' do + band.decibels = (50..120) + band.decibels # warm up + stats = AllocationStats.trace { 10.times { band.decibels } } + expect(stats.new_allocations.size).to eq(0) + end + end + + context 'field access after setter' do + subject { band } + + { tags: { 'new' => 'value' }, name: 'New Name', updated: Time.current }.each do |field, value| + it "maintains zero allocations after #{field} setter" do + band.public_send("#{field}=", value) + band.public_send(field) # warm up + stats = AllocationStats.trace { 10.times { band.public_send(field) } } + expect(stats.new_allocations.size).to eq(0) + end + end + end + + context 'class-level methods' do + it 'achieves zero allocations for database_field_name' do + Band.database_field_name('name') # warm up + stats = AllocationStats.trace { 10.times { Band.database_field_name('name') } } + # NOTE: aliased fields require .dup for safety, which allocates. + # This test uses 'name' which is not aliased, achieving zero allocations. + expect(stats.new_allocations.size).to eq(0) + end + + it 'achieves zero allocations for cleanse_localized_field_names' do + Band.cleanse_localized_field_names('name') # warm up + stats = AllocationStats.trace { 10.times { Band.cleanse_localized_field_names('name') } } + expect(stats.new_allocations.size).to eq(0) + end + + it 'handles aliased fields correctly' do + # 'id' is aliased to '_id' + expect(Band.database_field_name('id')).to eq('_id') + expect(Band.database_field_name('name')).to eq('name') + end + end + + context 'database-loaded documents' do + subject { loaded_band } + + before { band.save! } + + let(:loaded_band) { Band.find(band.id) } + + %i[name tags genres member_count rating active updated].each do |field| + include_examples 'zero allocation field access', field + end + end + end + + describe 'correctness verification' do + it 'returns correct values for all field types' do + expect(band.name).to eq('Test Band') + expect(band.tags).to eq({ 'genre' => 'rock', 'era' => '80s' }) + expect(band.genres).to eq(%w[rock metal]) + expect(band.rating).to eq(8.5) + expect(band.member_count).to eq(4) + expect(band.active).to be(true) + end + + it 'preserves getter-after-setter behavior' do + band.name = 'New Band' + expect(band.name).to eq('New Band') + + band.tags = { 'key' => 'value' } + expect(band.tags).to eq({ 'key' => 'value' }) + + band.rating = 9.5 + expect(band.rating).to eq(9.5) + + band.name = nil + expect(band.name).to be_nil + end + end + + describe 'critical edge cases' do + context 'Time field transformations' do + config_override :use_utc, true + + it 'applies UTC conversion when configured' do + time_with_zone = Time.new(2020, 1, 1, 12, 0, 0, '+03:00') + band.updated = time_with_zone + band.save! + + reloaded = Band.find(band.id) + + expect(reloaded.updated.utc?).to be(true) + expect(reloaded.updated.hour).to eq(9) # 12:00 +03:00 = 09:00 UTC + end + + it 'preserves timezone conversions after caching' do + band.save! + band.updated # First read - caches value + band.updated # Second read - from cache + + expect(band.updated.utc?).to be(true) + end + end + + context 'database persistence' do + before { band.save! } + + it 'correctly demongoizes fields loaded from database' do + loaded_band = Band.find(band.id) + + expect(loaded_band.name).to be_a(String) + expect(loaded_band.tags).to be_a(Hash) + expect(loaded_band.genres).to be_a(Array) + expect(loaded_band.rating).to be_a(Float) + expect(loaded_band.member_count).to be_a(Integer) + expect(loaded_band.updated).to be_a(Time) + end + + it 'converts BSON::Document to Hash' do + loaded_band = Band.find(band.id) + + # MongoDB returns BSON::Document, should be converted to Hash + expect(loaded_band.tags).to be_a(Hash) + expect(loaded_band.tags).to eq({ 'genre' => 'rock', 'era' => '80s' }) + end + end + + context 'cache invalidation' do + before { band.save! } + + it 'clears cache on reload' do + band.name = 'Modified Name' + band.reload + expect(band.name).to eq('Test Band') # Original value + end + + it 'handles projector cache when selected_fields change' do + # Load with different field selections + limited1 = Band.only(:name).find(band.id) + limited2 = Band.only(:name, :rating).find(band.id) + + # Both should work correctly with different projections + expect(limited1.attribute_missing?('rating')).to be(true) + expect(limited2.attribute_missing?('rating')).to be(false) + + # Projector cache is keyed by selected_fields, so both are cached independently + expect(limited1.attribute_missing?('rating')).to be(true) + expect(limited2.attribute_missing?('rating')).to be(false) + end + + it 'correctly caches nil values' do + nil_test_class = Class.new do + include Mongoid::Document + store_in collection: 'nil_cache_tests' + field :name, type: String + field :optional_field, type: String + field :nullable_int, type: Integer + end + + stub_const('NilCacheTest', nil_test_class) + + # Create with explicit nil values + doc = NilCacheTest.create!(name: 'Test', optional_field: nil, nullable_int: nil) + + # First read should cache nil + expect(doc.optional_field).to be_nil + expect(doc.nullable_int).to be_nil + + # Second read should return cached nil (not re-demongoize) + expect(doc.optional_field).to be_nil + expect(doc.nullable_int).to be_nil + + # Verify zero allocations if available + if allocation_stats_available + stats = AllocationStats.trace { 10.times { doc.optional_field } } + expect(stats.new_allocations.size).to eq(0) + end + + # Change from nil to value and back to nil + doc.optional_field = 'something' + expect(doc.optional_field).to eq('something') + + doc.optional_field = nil + expect(doc.optional_field).to be_nil + + # Verify cached nil still works + 3.times { expect(doc.optional_field).to be_nil } + end + + it 'clears cache for written field only' do + next unless allocation_stats_available + + band.name # cache it + band.rating # cache it + + band.name = 'New Name' # Only clears name cache + + # rating cache should still work + stats = AllocationStats.trace { 10.times { band.rating } } + expect(stats.new_allocations.size).to eq(0) + end + + it 'gets fresh value after write' do + band.name # cache it + original_name = band.name + + band.name = 'New Name' + + expect(band.name).to eq('New Name') + expect(band.name).not_to eq(original_name) + end + + it 'clears cache when attribute is removed' do + band.name # cache it + expect(band.name).to eq('Test Band') + + band.remove_attribute(:name) + + expect(band.name).to be_nil + end + + it 'clears cache when attribute is unset' do + band.name # cache it + expect(band.name).to eq('Test Band') + + band.unset(:name) + + expect(band.name).to be_nil + end + + it 'clears cache when field is renamed' do + band.name # cache it + expect(band.name).to eq('Test Band') + + band.rename(name: :band_name) + + # Old field should be nil + expect(band.attributes['name']).to be_nil + # New field should have the value + expect(band.attributes['band_name']).to eq('Test Band') + end + + it 'clears cache when defaults are applied via apply_default' do + # Test for the fix: apply_default must invalidate cache + doc_class = Class.new do + include Mongoid::Document + store_in collection: 'apply_default_tests' + field :_id, type: String, overwrite: true, default: -> { name.try(:parameterize) } + field :name, type: String + end + + stub_const('ApplyDefaultTest', doc_class) + + # Create document without executing callbacks (simulating build in associations) + doc = Mongoid::Factory.execute_build(ApplyDefaultTest, { name: 'test value' }, execute_callbacks: false) + + # Reading _id before apply_post_processed_defaults might cache nil + cached_id = doc._id + expect(cached_id).to be_nil + + # apply_post_processed_defaults sets the _id + doc.apply_post_processed_defaults + + # This should return the actual _id, not the cached nil + # (tests that apply_default invalidates the cache) + expect(doc._id).to eq('test-value') + expect(doc._id).not_to be_nil + end + + it 'tracks changes for resizable fields on every read' do + # Test for the fix: resizable fields must call attribute_will_change! on every read + person_class = Class.new do + include Mongoid::Document + store_in collection: 'resizable_tracking_tests' + has_and_belongs_to_many :preferences + end + + preference_class = Class.new do + include Mongoid::Document + store_in collection: 'preferences_tracking_tests' + field :name, type: String + has_and_belongs_to_many :people + end + + stub_const('PersonTracking', person_class) + stub_const('PreferenceTracking', preference_class) + + person = PersonTracking.create! + pref = PreferenceTracking.create!(name: 'test') + + # First read - caches the array + ids = person.preference_ids + expect(ids).to eq([]) + + # Mutate the array (simulating << operator) + ids << pref.id + + # Person should be marked as changed + # (tests that attribute_will_change! is called on cached reads) + expect(person.changed?).to be(true) + expect(person.changes.keys).to include('preference_ids') + end + + it 'maintains correct array identity across reads' do + # Verify that cached arrays maintain object identity (mutations persist) + person_class = Class.new do + include Mongoid::Document + store_in collection: 'array_identity_tests' + has_and_belongs_to_many :items + end + + stub_const('PersonArrayIdentity', person_class) + + person = PersonArrayIdentity.create! + + # First read + arr1 = person.item_ids + + # Mutate it + test_id = BSON::ObjectId.new + arr1 << test_id + + # Second read should return same object with mutation + arr2 = person.item_ids + expect(arr2.object_id).to eq(arr1.object_id) + expect(arr2).to include(test_id) + end + end + + context 'field projections' do + before { band.save! } + + it 'works with .only() projection' do + limited = Band.only(:name, :rating).find(band.id) + + expect(limited.name).to eq('Test Band') + expect(limited.rating).to eq(8.5) + expect(limited.attribute_missing?('origin')).to be(true) + end + + it 'works with .without() projection' do + limited = Band.without(:tags).find(band.id) + + expect(limited.name).to eq('Test Band') + expect(limited.attribute_missing?('tags')).to be(true) + end + end + + context 'thread safety' do + it 'handles concurrent field access safely' do + band = Band.new(name: 'Test Band', rating: 8.5) + errors = Concurrent::Array.new + results = Concurrent::Array.new + + threads = Array.new(10) do + Thread.new do + 100.times do + name = band.name + rating = band.rating + results << [ name, rating ] + rescue StandardError => e + errors << e + end + end + end + + threads.each(&:join) + expect(errors).to be_empty + + # Verify all threads read correct values + results.each do |name, rating| + expect(name).to eq('Test Band') + expect(rating).to eq(8.5) + end + end + + it 'handles concurrent projector cache access safely' do + band = Band.create!(name: 'Test') + limited = Band.only(:name).find(band.id) + errors = Concurrent::Array.new + + threads = Array.new(10) do + Thread.new do + 100.times do + limited.attribute_missing?('rating') + rescue StandardError => e + errors << e + end + end + end + + threads.each(&:join) + expect(errors).to be_empty + end + end + + context 'localized fields' do + around do |example| + previous_available_locales = I18n.available_locales + previous_locale = I18n.locale + + I18n.available_locales = %i[en es] + I18n.locale = :en + + begin + example.run + ensure + I18n.available_locales = previous_available_locales + I18n.locale = previous_locale + end + end + + it 'does not cache localized fields to preserve i18n behavior' do + # Create a simple model with localized field using stub_const to avoid test pollution + localized_band_class = Class.new do + include Mongoid::Document + field :title, type: String, localize: true + end + stub_const('LocalizedBand', localized_band_class) + + band = LocalizedBand.new + band.title = 'English Title' + + I18n.locale = :es + band.title = 'Spanish Title' + + # Verify both locales return correct values + expect(band.title).to eq('Spanish Title') + I18n.locale = :en + expect(band.title).to eq('English Title') + + # Verify repeated reads work correctly (not cached) + I18n.locale = :es + 3.times { expect(band.title).to eq('Spanish Title') } + I18n.locale = :en + 3.times { expect(band.title).to eq('English Title') } + end + end + + context 'with lazy-settable fields' do + it 'correctly handles foreign key Array fields with default values' do + # Create test models with has_and_belongs_to_many relationship + # This creates a foreign key field with Array type and default: [] + team_class = Class.new do + include Mongoid::Document + store_in collection: 'teams' + field :name, type: String + has_and_belongs_to_many :players + end + + player_class = Class.new do + include Mongoid::Document + store_in collection: 'players' + field :name, type: String + has_and_belongs_to_many :teams + end + + stub_const('Team', team_class) + stub_const('Player', player_class) + + team = Team.new(name: 'Test Team') + + # First access triggers lazy evaluation of default value + expect(team.player_ids).to eq([]) + + # Verify the field is properly cached and subsequent access is zero-allocation + if allocation_stats_available + team.player_ids # warm up + stats = AllocationStats.trace { 10.times { team.player_ids } } + expect(stats.new_allocations.size).to eq(0) + end + end + + it 'correctly handles Hash foreign key fields with default values' do + # Create a model with a Hash-type foreign key field + metadata_doc_class = Class.new do + include Mongoid::Document + store_in collection: 'metadata_docs' + field :refs, type: Hash, default: -> { {} } + end + + stub_const('MetadataDoc', metadata_doc_class) + + doc = MetadataDoc.new + + # First access triggers lazy evaluation + expect(doc.refs).to eq({}) + + # Verify proper caching + if allocation_stats_available + doc.refs # warm up + stats = AllocationStats.trace { 10.times { doc.refs } } + expect(stats.new_allocations.size).to eq(0) + end + end + + it 'does not cache before lazy evaluation' do + team_class = Class.new do + include Mongoid::Document + store_in collection: 'teams' + has_and_belongs_to_many :players + end + + player_class = Class.new do + include Mongoid::Document + store_in collection: 'players' + has_and_belongs_to_many :teams + end + + stub_const('Team', team_class) + stub_const('Player', player_class) + + team = Team.new + + # Before first access, the field should be nil in attributes + expect(team.attributes['player_ids']).to be_nil + + # First access evaluates and sets the default + result = team.player_ids + expect(result).to eq([]) + + # Now it should be present in attributes + expect(team.attributes['player_ids']).to eq([]) + end + + it 'handles modifications to lazy-evaluated fields' do + team_class = Class.new do + include Mongoid::Document + store_in collection: 'teams' + has_and_belongs_to_many :players + end + + player_class = Class.new do + include Mongoid::Document + store_in collection: 'players' + field :name, type: String + has_and_belongs_to_many :teams + end + + stub_const('Team', team_class) + stub_const('Player', player_class) + + team = Team.new + player = Player.new(name: 'John') + + # Lazy evaluation happens on first access + team.player_ids # => [] + + # Modification should work correctly + team.player_ids << player.id + expect(team.player_ids).to eq([ player.id ]) + + # Cache should be invalidated and re-read correctly + expect(team.player_ids).to eq([ player.id ]) + end + end + + context 'atomic operations' do + it 'invalidates cache on inc operations' do + atomic_test_class = Class.new do + include Mongoid::Document + store_in collection: 'atomic_tests' + field :counter, type: Integer, default: 0 + field :score, type: Integer, default: 0 + end + + stub_const('AtomicTest', atomic_test_class) + + doc = AtomicTest.new(counter: 10, score: 5) + + # First read to cache the value + expect(doc.counter).to eq(10) + expect(doc.score).to eq(5) + + # Perform atomic increment + doc.inc(counter: 5, score: 3) + + # Verify cache was invalidated and new values are returned + expect(doc.counter).to eq(15) + expect(doc.score).to eq(8) + + # Verify repeated reads return correct values (from fresh cache) + 3.times do + expect(doc.counter).to eq(15) + expect(doc.score).to eq(8) + end + + # Verify zero allocations on cached reads if available + if allocation_stats_available + stats = AllocationStats.trace { 10.times { doc.counter } } + expect(stats.new_allocations.size).to eq(0) + end + end + + it 'invalidates cache on mul operations' do + atomic_test_class = Class.new do + include Mongoid::Document + store_in collection: 'atomic_mul_tests' + field :multiplier, type: Integer, default: 1 + end + + stub_const('AtomicMulTest', atomic_test_class) + + doc = AtomicMulTest.new(multiplier: 5) + + # First read to cache the value + expect(doc.multiplier).to eq(5) + + # Perform atomic multiplication + doc.mul(multiplier: 3) + + # Verify cache was invalidated and new value is returned + expect(doc.multiplier).to eq(15) + + # Verify repeated reads return correct value + 3.times { expect(doc.multiplier).to eq(15) } + + # Verify zero allocations on cached reads if available + if allocation_stats_available + stats = AllocationStats.trace { 10.times { doc.multiplier } } + expect(stats.new_allocations.size).to eq(0) + end + end + + it 'invalidates cache on bit operations' do + atomic_test_class = Class.new do + include Mongoid::Document + store_in collection: 'atomic_bit_tests' + field :flags, type: Integer, default: 0 + end + + stub_const('AtomicBitTest', atomic_test_class) + + doc = AtomicBitTest.new(flags: 15) # Binary: 1111 + + # First read to cache the value + expect(doc.flags).to eq(15) + + # Perform atomic bitwise AND operation + doc.bit(flags: { and: 7 }) # Binary: 0111, result should be 7 (0111) + + # Verify cache was invalidated and new value is returned + expect(doc.flags).to eq(7) + + # Perform atomic bitwise OR operation + doc.bit(flags: { or: 8 }) # Binary: 1000, result should be 15 (1111) + + # Verify cache was invalidated and new value is returned + expect(doc.flags).to eq(15) + + # Verify repeated reads return correct value + 3.times { expect(doc.flags).to eq(15) } + + # Verify zero allocations on cached reads if available + if allocation_stats_available + stats = AllocationStats.trace { 10.times { doc.flags } } + expect(stats.new_allocations.size).to eq(0) + end + end + end + end +end +# rubocop:enable RSpec/ContextWording, RSpec/ExampleLength diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 469e85b37d..73e1b79e4b 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -126,6 +126,20 @@ def test_model(name: 'TestModel', &block) # Set the database that the spec suite connects to. Mongoid.configure do |config| config.load_configuration(CONFIG) + # NOTE: Attribute caching is disabled by default (cache_attribute_values = false), + # but the test suite runs with it ENABLED to exercise cache-aware behavior and + # validate the optimization works correctly across all existing tests. + # + # This means the default configuration (caching disabled) is not tested by the + # main test suite. The caching feature has its own dedicated tests that verify + # both enabled and disabled states (see spec/mongoid/config_spec.rb and + # spec/mongoid/fields/performance_spec.rb). + # + # Running with caching enabled ensures: + # 1. All existing tests pass with the optimization active + # 2. No regressions are introduced by the caching layer + # 3. The feature is production-ready when users opt-in + config.cache_attribute_values = ENV['MONGOID_CACHING_ENABLED'] == '1' end # Autoload every model for the test suite that sits in spec/support/models.