Skip to content
Closed
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
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
36 changes: 36 additions & 0 deletions lib/mongoid/attributes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions lib/mongoid/attributes/accessor.rb
Original file line number Diff line number Diff line change
@@ -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
95 changes: 95 additions & 0 deletions lib/mongoid/attributes/caching_accessor.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions lib/mongoid/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
6 changes: 6 additions & 0 deletions lib/mongoid/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
47 changes: 41 additions & 6 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,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
Expand Down Expand Up @@ -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|
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
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
attribute_accessor.invalidate(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
attribute_accessor.invalidate(field)
ops[atomic_attribute_name(field)] = values
end
{ "$bit" => ops } unless ops.empty?
Expand Down
Loading