Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions gemfiles/standard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def standard_dependencies

platform :mri do
gem 'byebug'
gem 'allocation_stats', require: false
end

platform :jruby do
Expand Down
34 changes: 33 additions & 1 deletion lib/mongoid/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Comment thread
skcc321 marked this conversation as resolved.
end
Comment thread
skcc321 marked this conversation as resolved.
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.
Expand All @@ -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)
Expand Down Expand Up @@ -241,14 +263,24 @@ 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")
#
# @param [ String ] name The name of the attribute.
#
# @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
Comment thread
skcc321 marked this conversation as resolved.
!projector.attribute_or_path_allowed?(name)
Comment thread
skcc321 marked this conversation as resolved.
else
!Projector.new(__selected_fields).attribute_or_path_allowed?(name)
end
end

# Return type-casted attributes.
Expand Down
35 changes: 35 additions & 0 deletions lib/mongoid/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
skcc321 marked this conversation as resolved.

# 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.
Expand Down
23 changes: 23 additions & 0 deletions lib/mongoid/document.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require 'concurrent/map'
require 'mongoid/positional'
require 'mongoid/evolvable'
require 'mongoid/extensions'
Expand Down Expand Up @@ -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
Comment thread
skcc321 marked this conversation as resolved.

# Returns the logger
#
# @return [ Logger ] The configured logger or a default Logger instance.
Expand Down Expand Up @@ -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)

Expand Down
117 changes: 94 additions & 23 deletions lib/mongoid/fields.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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('.')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -337,40 +349,60 @@ 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|
fs = i == 0 ? fields : klass&.fields
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<Field, Class> ] 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
skcc321 marked this conversation as resolved.
# 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
Comment thread
skcc321 marked this conversation as resolved.

# 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

Comment thread
skcc321 marked this conversation as resolved.
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
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/persistable/incrementable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/persistable/logical.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/persistable/multipliable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/persistable/poppable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions lib/mongoid/persistable/pullable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions lib/mongoid/persistable/pushable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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 }
Expand Down
Loading