diff --git a/Gemfile b/Gemfile index 4597f3fb01..ec5c500947 100644 --- a/Gemfile +++ b/Gemfile @@ -18,5 +18,9 @@ end gem 'i18n', *i18n_versions +platforms :mri do + gem 'allocation_stats', require: false +end + # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem "tzinfo-data", platforms: %i[ windows jruby ] diff --git a/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb b/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb index c9b07fdab1..b88df398af 100644 --- a/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb +++ b/lib/mongoid/association/referenced/has_and_belongs_to_many/proxy.rb @@ -122,6 +122,7 @@ def build(attributes = {}, type = nil) append(doc) doc.apply_post_processed_defaults _base.public_send(foreign_key).push(doc.public_send(_association.primary_key)) + _base.attribute_accessor.invalidate(foreign_key) unsynced(doc, inverse_foreign_key) yield(doc) if block_given? doc.run_pending_callbacks @@ -367,6 +368,7 @@ def append_document(doc, ids, docs, inserts) ids[pk] = true save_or_delay(doc, docs, inserts) else + _base.attribute_accessor.invalidate(foreign_key) existing = _base.public_send(foreign_key) return if existing.include?(pk) diff --git a/lib/mongoid/attributes.rb b/lib/mongoid/attributes.rb index 7cebcb2ea3..af75e34ddf 100644 --- a/lib/mongoid/attributes.rb +++ b/lib/mongoid/attributes.rb @@ -2,6 +2,8 @@ # rubocop:todo all require "active_model/attribute_methods" +require "mongoid/attributes/accessor" +require "mongoid/attributes/caching_accessor" require "mongoid/attributes/dynamic" require "mongoid/attributes/embedded" require "mongoid/attributes/nested" @@ -143,6 +145,7 @@ def remove_attribute(name) validate_writable_field_name!(name.to_s) as_writable_attribute!(name) do |access| _assigning do + attribute_accessor.invalidate(access) attribute_will_change!(access) delayed_atomic_unsets[atomic_attribute_name(access)] = [] unless new_record? attributes.delete(access) @@ -173,6 +176,9 @@ def write_attribute(name, value) if attribute_writable?(field_name) _assigning do + # Invalidate cache for this field + attribute_accessor.invalidate(field_name) + localized = fields[field_name].try(:localized?) attributes_before_type_cast[name.to_s] = value typed_value = typed_value_for(field_name, value) @@ -261,8 +267,38 @@ def typed_attributes attribute_names.map { |name| [name, send(name)] }.to_h end + # Get the attribute accessor strategy for this document. + # Returns a caching accessor if enabled, otherwise a basic accessor. + # + # @return [ Accessor | CachingAccessor ] The attribute accessor. + # + # @api private + def attribute_accessor + @attribute_accessor ||= build_attribute_accessor + end + private + # Build an attribute accessor instance based on current configuration. + # + # This method centralizes the choice between the basic and caching + # accessor implementations, ensuring consistent behavior based on + # the configuration at document creation time. + # + # For non-caching mode, returns a shared singleton to avoid per-document + # memory overhead. For caching mode, creates a new instance with its own cache. + # + # @return [ Accessor | CachingAccessor ] The attribute accessor instance. + # + # @api private + def build_attribute_accessor + if Mongoid::Config.cache_attribute_values + Attributes::CachingAccessor.new + else + Attributes::Accessor.instance + end + end + # Does the string contain dot syntax for accessing hashes? # # @api private diff --git a/lib/mongoid/attributes/accessor.rb b/lib/mongoid/attributes/accessor.rb new file mode 100644 index 0000000000..f69cba7e83 --- /dev/null +++ b/lib/mongoid/attributes/accessor.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Mongoid + module Attributes + # Base accessor for attribute reading and writing. + # This accessor performs no caching - every read demongoizes the raw value. + # + # This class is stateless and uses a singleton pattern to minimize memory + # overhead when caching is disabled (the default configuration). + # + # @api private + class Accessor + # Shared singleton instance for non-caching accessor (stateless) + @instance = new + + class << self + attr_reader :instance + end + + # Read an attribute value from a document. + # + # @param [ Document ] document The document to read from. + # @param [ String | Symbol ] field_name The name of the field. + # @param [ Fields::Standard ] field The field definition. + # + # @return [ Object ] The demongoized attribute value. + def read(document, field_name, field) + raw = document.send(:read_raw_attribute, field_name) + + # Handle lazy defaults + if lazy_settable?(document, field, raw) + document.write_attribute(field_name, field.eval_default(document)) + else + document.process_raw_attribute(field_name.to_s, raw, field) + end + end + + # Invalidate any cached value for the given field. + # No-op for base accessor. + # + # @param [ String | Symbol ] field_name The name of the field. + def invalidate(field_name) + # No-op for base accessor + end + + # Reset all cached values. + # No-op for base accessor. + def reset! + # No-op for base accessor + end + + private + + # Check if a field should have its default value lazily set. + # + # @param [ Document ] document The document. + # @param [ Fields::Standard ] field The field definition. + # @param [ Object ] value The current value. + # + # @return [ Boolean ] True if the default should be lazily set. + def lazy_settable?(document, field, value) + !document.frozen? && value.nil? && field.lazy? + end + end + end +end diff --git a/lib/mongoid/attributes/caching_accessor.rb b/lib/mongoid/attributes/caching_accessor.rb new file mode 100644 index 0000000000..92861d9dcc --- /dev/null +++ b/lib/mongoid/attributes/caching_accessor.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'concurrent/map' + +module Mongoid + module Attributes + # Caching accessor for attribute reading and writing. + # This accessor caches demongoized values for performance. + # + # Each instance maintains its own cache (Concurrent::Map), so each document + # that uses caching gets a unique CachingAccessor instance to avoid sharing + # cached values across documents. + # + # @api private + class CachingAccessor < Accessor + def initialize + super + @demongoized_cache = Concurrent::Map.new + end + + # Read an attribute value from a document with caching. + # + # @param [ Document ] document The document to read from. + # @param [ String | Symbol ] field_name The name of the field. + # @param [ Fields::Standard ] field The field definition. + # + # @return [ Object ] The demongoized attribute value. + def read(document, field_name, field) + # Fast paths - no caching for special cases + # If field is nil, process raw attribute directly (dynamic fields) + if field.nil? + return document.process_raw_attribute(field_name.to_s, document.send(:read_raw_attribute, field_name), + nil) + end + return super if field.localized? + + # Atomically fetch or compute and cache the demongoized value + value = @demongoized_cache.fetch_or_store(field_name) do + raw = document.send(:read_raw_attribute, field_name) + + if lazy_settable?(document, field, raw) + evaluate_default(document, field_name, field, raw) + else + document.process_raw_attribute(field_name.to_s, raw, field) + end + end + + # For resizable values (like arrays), we need to track changes on every read + # because mutations can happen to the cached object. However, we skip this + # for relation foreign keys as they have their own change tracking mechanism. + is_relation = document.relations.key?(field_name.to_s) + document.send(:attribute_will_change!, field_name.to_s) if value.resizable? && !is_relation + + value + end + + # Invalidate the cached value for the given field. + # + # @param [ String | Symbol ] field_name The name of the field. + def invalidate(field_name) + @demongoized_cache.delete(field_name) + end + + # Reset all cached values. + def reset! + @demongoized_cache.clear + end + + private + + # Evaluate and set the default value for a lazy field. + # + # This method handles a subtle interaction with the cache: + # 1. fetch_or_store (line 38) executes this block + # 2. write_attribute invalidates the cache for this field + # 3. We re-read the raw value and demongoize it + # 4. fetch_or_store caches the returned demongoized value + # + # This ensures lazy-settable fields are properly cached after + # their default value is evaluated and written. + # + # @param [ Document ] document The document. + # @param [ String | Symbol ] field_name The field name. + # @param [ Fields::Standard ] field The field definition. + # @param [ Object ] raw The raw value. + # + # @return [ Object ] The demongoized default value. + def evaluate_default(document, field_name, field, _raw) + document.write_attribute(field_name, field.eval_default(document)) + # Re-read to get the demongoized value (write_attribute stores mongoized value) + document.process_raw_attribute(field_name.to_s, document.send(:read_raw_attribute, field_name), field) + end + end + end +end diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index f6df943d1a..04203f9fc2 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -38,6 +38,24 @@ module Config # error. option :belongs_to_required_by_default, default: true + # Cache attribute values for improved performance. + # When enabled, attribute reads will cache the type-casted (demongoized) value + # to avoid repeated type conversions on subsequent reads. + # + # Memory considerations: + # - When enabled: Each document instance allocates a CachingAccessor object + # with its own Concurrent::Map for the cache. Each cached field value is + # stored in this map. + # - When disabled (default): All documents share a single stateless Accessor + # instance, resulting in zero memory overhead. + # + # For applications that keep many document instances in memory (for example, + # thousands or more), this additional per-document and per-field state can lead + # to noticeable memory overhead. Enable this option only when the performance + # benefits of avoiding repeated type conversions justify the extra memory use + # for your workload. + option :cache_attribute_values, default: false + # Set the global discriminator key. option :discriminator_key, default: "_type" diff --git a/lib/mongoid/document.rb b/lib/mongoid/document.rb index 1e5dc18ae7..d6d4e61965 100644 --- a/lib/mongoid/document.rb +++ b/lib/mongoid/document.rb @@ -235,6 +235,9 @@ def construct_document(attrs = nil, options = {}) def prepare_to_process_attributes @new_record = true @attributes ||= {} + # Eagerly initialize attribute accessor to capture configuration at document creation time. + # Uses singleton for non-caching mode to avoid per-document memory overhead. + @attribute_accessor = build_attribute_accessor apply_pre_processed_defaults apply_default_scoping end @@ -415,6 +418,9 @@ 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) + # Eagerly initialize attribute accessor to capture configuration at document load time. + # Uses singleton for non-caching mode to avoid per-document memory overhead. + doc.instance_variable_set(:@attribute_accessor, doc.send(:build_attribute_accessor)) doc._handle_callbacks_after_instantiation(execute_callbacks, &block) diff --git a/lib/mongoid/fields.rb b/lib/mongoid/fields.rb index b92301ae0d..061bde76d9 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,8 @@ def apply_default(name) default = field.eval_default(self) unless default.nil? || field.lazy? attribute_will_change!(name) + # Invalidate cache when applying defaults to ensure fresh reads + attribute_accessor.invalidate(name) if attribute_accessor.respond_to?(:invalidate) attributes[name] = default end end @@ -338,6 +351,26 @@ def options # # @api private def traverse_association_tree(key, fields, associations, aliased_associations) + # Fast path: if no dots, it's a simple field lookup + unless key.include?(".") + aliased = key + if aliased_associations && a = aliased_associations.fetch(key, nil) + aliased = a.to_s + end + + if fields && f = fields[aliased] + yield(key, f, true) if block_given? + return f + elsif associations && rel = associations[aliased] + yield(key, rel, false) if block_given? + return nil + else + yield(key, nil, false) if block_given? + return nil + end + end + + # Slow path for nested fields (original logic) klass = nil field = nil key.split('.').each_with_index do |meth, i| @@ -416,6 +449,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,12 +687,7 @@ def create_accessors(name, meth, options = {}) def create_field_getter(name, meth, field) generated_methods.module_eval do re_define_method(meth) do - raw = read_raw_attribute(name) - if lazy_settable?(field, raw) - write_attribute(name, field.eval_default(self)) - else - process_raw_attribute(name.to_s, raw, field) - end + attribute_accessor.read(self, name, field) end end end diff --git a/lib/mongoid/persistable/incrementable.rb b/lib/mongoid/persistable/incrementable.rb index 1fd5b9a458..d9e89720e5 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 + attribute_accessor.invalidate(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..12651ffac3 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 + attribute_accessor.invalidate(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..77fe20bcd4 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 + attribute_accessor.invalidate(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..25c45c426c 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 + attribute_accessor.invalidate(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..5b6bdf2a18 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) + attribute_accessor.invalidate(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) } + attribute_accessor.invalidate(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..0e6e48c0d4 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 + attribute_accessor.invalidate(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) } + attribute_accessor.invalidate(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..e91f1b54e1 100644 --- a/lib/mongoid/persistable/renamable.rb +++ b/lib/mongoid/persistable/renamable.rb @@ -22,6 +22,8 @@ def rename(renames) prepare_atomic_operation do |ops| process_atomic_operations(renames) do |old_field, new_field| new_name = new_field.to_s + attribute_accessor.invalidate(old_field) + attribute_accessor.invalidate(new_name) if executing_atomically? process_attribute new_name, attributes[old_field] process_attribute old_field, nil diff --git a/lib/mongoid/persistable/unsettable.rb b/lib/mongoid/persistable/unsettable.rb index 44ce6cbabb..6e229a4ddb 100644 --- a/lib/mongoid/persistable/unsettable.rb +++ b/lib/mongoid/persistable/unsettable.rb @@ -22,6 +22,7 @@ def unset(*fields) prepare_atomic_operation do |ops| fields.flatten.each do |field| normalized = database_field_name(field) + attribute_accessor.invalidate(normalized) if executing_atomically? process_attribute normalized, nil else diff --git a/lib/mongoid/reloadable.rb b/lib/mongoid/reloadable.rb index 6530814cb2..fc28a5328e 100644 --- a/lib/mongoid/reloadable.rb +++ b/lib/mongoid/reloadable.rb @@ -39,6 +39,9 @@ def reload def reset_object!(attributes) reset_atomic_updates! + # Reset attribute cache + attribute_accessor.reset! + @attributes = attributes @attributes_before_type_cast = @attributes.dup @changed_attributes = {} diff --git a/spec/mongoid/attributes/accessor_spec.rb b/spec/mongoid/attributes/accessor_spec.rb new file mode 100644 index 0000000000..25b2f9f8ad --- /dev/null +++ b/spec/mongoid/attributes/accessor_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration +describe 'Strategy Pattern Implementation' do + before(:all) do + class TestPerson + include Mongoid::Document + field :name, type: String + field :age, type: Integer + field :score, type: Float + end + end + + after(:all) do + Object.send(:remove_const, :TestPerson) + end + # rubocop:enable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration + + describe 'with caching disabled' do + before do + Mongoid::Config.cache_attribute_values = false + end + + it 'uses Accessor strategy' do + person = TestPerson.new(name: 'John', age: 30) + expect(person.attribute_accessor).to be_a(Mongoid::Attributes::Accessor) + expect(person.attribute_accessor).not_to be_a(Mongoid::Attributes::CachingAccessor) + end + + it 'reads attributes correctly' do + person = TestPerson.new(name: 'John', age: 30) + expect(person.name).to eq('John') + expect(person.age).to eq(30) + end + + it 'writes attributes correctly' do + person = TestPerson.new + person.name = 'Jane' + person.age = 25 + expect(person.name).to eq('Jane') + expect(person.age).to eq(25) + end + end + + describe 'with caching enabled' do + before do + Mongoid::Config.cache_attribute_values = true + end + + after do + Mongoid::Config.cache_attribute_values = false + end + + it 'uses CachingAccessor strategy' do + person = TestPerson.new(name: 'John', age: 30) + expect(person.attribute_accessor).to be_a(Mongoid::Attributes::CachingAccessor) + end + + it 'reads attributes correctly' do + person = TestPerson.new(name: 'John', age: 30) + expect(person.name).to eq('John') + expect(person.age).to eq(30) + end + + it 'writes attributes correctly' do + person = TestPerson.new + person.name = 'Jane' + person.age = 25 + expect(person.name).to eq('Jane') + expect(person.age).to eq(25) + end + + it 'invalidates cache on write' do + person = TestPerson.new(name: 'John', age: 30) + + # Read to populate cache + expect(person.name).to eq('John') + + # Write should invalidate cache + person.name = 'Jane' + expect(person.name).to eq('Jane') + end + + it 'invalidates cache on remove_attribute' do + person = TestPerson.new(name: 'John', age: 30) + + # Read to populate cache + expect(person.name).to eq('John') + + # Remove should invalidate cache + person.remove_attribute(:name) + expect(person.name).to be_nil + end + + it 'resets cache on reload' do + person = TestPerson.create!(name: 'John', age: 30) + + # Read to populate cache + expect(person.name).to eq('John') + + # Update in database + TestPerson.collection.find(_id: person.id).update_one('$set' => { 'name' => 'Jane' }) + + # Reload should reset cache and reflect new value + person.reload + expect(person.name).to eq('Jane') + + person.delete + end + end + + describe 'when configuration changes after document creation' do + after do + # Ensure configuration is reset so other specs are not affected + Mongoid::Config.cache_attribute_values = false + end + + it 'uses accessor strategy corresponding to config at document creation time' do + Mongoid::Config.cache_attribute_values = false + person = TestPerson.new(name: 'John', age: 30) + + # Accessor should be initialized during document creation + expect(person.attribute_accessor).to be_a(Mongoid::Attributes::Accessor) + expect(person.attribute_accessor).not_to be_a(Mongoid::Attributes::CachingAccessor) + + # Change configuration after document creation + Mongoid::Config.cache_attribute_values = true + + # Should still use the accessor from creation time + expect(person.attribute_accessor).to be_a(Mongoid::Attributes::Accessor) + expect(person.name).to eq('John') + end + + it 'new documents use new configuration' do + Mongoid::Config.cache_attribute_values = false + person1 = TestPerson.new(name: 'John', age: 30) + + # Change configuration + Mongoid::Config.cache_attribute_values = true + person2 = TestPerson.new(name: 'Jane', age: 25) + + # First document uses old config, second uses new config + expect(person1.attribute_accessor).to be_a(Mongoid::Attributes::Accessor) + expect(person2.attribute_accessor).to be_a(Mongoid::Attributes::CachingAccessor) + end + + it 'database-loaded documents use config at load time' do + # Create with caching disabled + Mongoid::Config.cache_attribute_values = false + person = TestPerson.create!(name: 'John', age: 30) + person_id = person.id + + # Enable caching before loading from database + Mongoid::Config.cache_attribute_values = true + + # Document loaded from database should use caching accessor + loaded_person = TestPerson.find(person_id) + expect(loaded_person.attribute_accessor).to be_a(Mongoid::Attributes::CachingAccessor) + expect(loaded_person.name).to eq('John') + + loaded_person.delete + end + end +end diff --git a/spec/mongoid/fields/performance_spec.rb b/spec/mongoid/fields/performance_spec.rb new file mode 100644 index 0000000000..69863a85ee --- /dev/null +++ b/spec/mongoid/fields/performance_spec.rb @@ -0,0 +1,738 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/ContextWording, RSpec/ExampleLength + +require 'spec_helper' + +# 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 + before(:all) do + # Enable caching for all performance tests + Mongoid::Config.cache_attribute_values = true + end + + after(:all) do + # Reset to default + Mongoid::Config.cache_attribute_values = false + 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 + 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) + + if Mongoid::Config.use_utc? + expect(reloaded.updated.utc?).to be(true) + expect(reloaded.updated.hour).to eq(9) # 12:00 +03:00 = 09:00 UTC + end + 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) if Mongoid::Config.use_utc? + 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 + + threads = Array.new(10) do + Thread.new do + 100.times do + band.name + band.rating + rescue StandardError => e + errors << e + end + end + end + + threads.each(&:join) + expect(errors).to be_empty + end + + it 'handles concurrent projector cache access safely' do + band = Band.create!(name: 'Test') + errors = Concurrent::Array.new + + threads = Array.new(10) do + Thread.new do + 100.times do + limited = Band.only(:name).find(band.id) + 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 + + example.run + + I18n.available_locales = previous_available_locales + I18n.locale = previous_locale + 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..e3a0540fd9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -180,6 +180,8 @@ class Query config.extend(Mongoid::Macros) config.before(:suite) do + # Enable attribute value caching globally for tests + Mongoid::Config.cache_attribute_values = true Mongoid.purge! end