diff --git a/lib/config/locales/en.yml b/lib/config/locales/en.yml index a5c56921c7..b6f6884ca1 100644 --- a/lib/config/locales/en.yml +++ b/lib/config/locales/en.yml @@ -712,6 +712,17 @@ en: the expression %{javascript} is not allowed." resolution: "Please provide a standard hash to #where when the criteria is for an embedded association." + unsupported_isolation_level: + message: "The isolation level '%{level}' is not supported." + summary: > + You requested an isolation level of '%{level}', which is not + supported. Only `:rails`, `:thread` and `:fiber` isolation + levels are currently supported; note that the `:fiber` level is + only supported on Ruby versions 3.2 and higher. + resolution: > + Use `:thread` as the isolation level. If you are using Ruby 3.2 + or higher, you may also use `:fiber`. If using Rails 7+, you + may also use `:rails` to inherit the isolation level from Rails. validations: message: "Validation of %{document} failed." summary: "The following errors were found: %{errors}" diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index 7ce5ecaa47..fcf622a435 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -110,6 +110,72 @@ module Config # to `:global_thread_pool`. option :global_executor_concurrency, default: nil + VALID_ISOLATION_LEVELS = %i[ rails thread fiber ].freeze + + # Defines the isolation level that Mongoid uses to store its internal + # state. + # + # Valid values are: + # - `:rails` - Uses the isolation level that Rails currently has + # configured. (This is the default.) + # - `:thread` - Uses thread-local storage. + # - `:fiber` - Uses fiber-local storage (only supported in Ruby 3.2+). + # + # If set to `:fiber`, Mongoid will use fiber-local storage instead. This + # may be necessary if you are using libraries like Falcon, which use + # fibers to manage concurrency. + # + # Note that the `:fiber` isolation level is only supported in Ruby 3.2 + # and later, due to semantic differences in how fiber storage is handled + # in earlier Ruby versions. + option :isolation_level, default: :rails, on_change: -> (level) do + validate_isolation_level!(level) + end + + # Returns the (potentially-dereferenced) isolation level that Mongoid + # will use to store its internal state. If `isolation_level` is set to + # `:rails`, this will return the isolation level that Rails is current + # configured to use (`ActiveSupport::IsolatedExecutionState.isolation_level`). + # + # If using an older version of Rails that does not support + # ActiveSupport::IsolatedExecutionState, this will return `:thread` + # instead. + # + # @api private + def real_isolation_level + return isolation_level unless isolation_level == :rails + + if defined?(ActiveSupport::IsolatedExecutionState) + ActiveSupport::IsolatedExecutionState.isolation_level.tap do |level| + # We can't guarantee that Rails will always support the same + # isolation levels as Mongoid, so we check here to make sure + # it's something we can work with. + validate_isolation_level!(level) + end + else + # The default, if Rails does not support IsolatedExecutionState, + :thread + end + end + + # Checks to see if the provided isolation level is something that Mongoid + # supports. Raises Errors::UnsupportedIsolationLevel if it is not. + # + # This will also raise an error if the isolation level is set to `:fiber` + # and the Ruby version is less than 3.2, since fiber-local storage + # is not supported in earlier Ruby versions. + # + # @api private + def validate_isolation_level!(level) + unless VALID_ISOLATION_LEVELS.include?(level) + raise Errors::UnsupportedIsolationLevel.new(level) + end + + if level == :fiber && RUBY_VERSION < '3.2' + raise Errors::UnsupportedIsolationLevel.new(level) + end + end + # When this flag is false, a document will become read-only only once the # #readonly! method is called, and an error will be raised on attempting # to save or update such documents, instead of just on delete. When this diff --git a/lib/mongoid/config/options.rb b/lib/mongoid/config/options.rb index abc85f8bf1..ea6d140b5e 100644 --- a/lib/mongoid/config/options.rb +++ b/lib/mongoid/config/options.rb @@ -40,8 +40,17 @@ def option(name, options = {}) end define_method("#{name}=") do |value| + old_value = settings[name] settings[name] = value - options[:on_change]&.call(value) + + begin + options[:on_change]&.call(value) + rescue + # If the on_change callback raises an error, we need to roll + # the change back. + settings[name] = old_value + raise + end end define_method("#{name}?") do diff --git a/lib/mongoid/errors.rb b/lib/mongoid/errors.rb index cb0e38a745..0927960281 100644 --- a/lib/mongoid/errors.rb +++ b/lib/mongoid/errors.rb @@ -74,5 +74,6 @@ require 'mongoid/errors/unregistered_class' require "mongoid/errors/unsaved_document" require "mongoid/errors/unsupported_javascript" +require "mongoid/errors/unsupported_isolation_level" require "mongoid/errors/validations" require "mongoid/errors/delete_restriction" diff --git a/lib/mongoid/errors/unsupported_isolation_level.rb b/lib/mongoid/errors/unsupported_isolation_level.rb new file mode 100644 index 0000000000..c6a7bc5894 --- /dev/null +++ b/lib/mongoid/errors/unsupported_isolation_level.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mongoid + module Errors + # Raised when an unsupported isolation level is used in Mongoid + # configuration. + class UnsupportedIsolationLevel < MongoidError + # Create the new error caused by attempting to select an unsupported + # isolation level. + # + # @param [ Symbol ] level The requested isolation level. + def initialize(level) + super( + compose_message( + 'unsupported_isolation_level', + { level: level } + ) + ) + end + end + end +end diff --git a/lib/mongoid/threaded.rb b/lib/mongoid/threaded.rb index 6fb1a9ab31..48bb0e45ae 100644 --- a/lib/mongoid/threaded.rb +++ b/lib/mongoid/threaded.rb @@ -6,38 +6,56 @@ module Mongoid # This module contains logic for easy access to objects that have a lifecycle # on the current thread. module Threaded - DATABASE_OVERRIDE_KEY = '[mongoid]:db-override' + # The key for the shared thread- and fiber-local storage. It must be a + # symbol because keys for fiber-local storage must be symbols. + STORAGE_KEY = :'[mongoid]' - # Constant for the key to store clients. - CLIENTS_KEY = '[mongoid]:clients' + DATABASE_OVERRIDE_KEY = 'db-override' # The key to override the client. - CLIENT_OVERRIDE_KEY = '[mongoid]:client-override' + CLIENT_OVERRIDE_KEY = 'client-override' # The key for the current thread's scope stack. - CURRENT_SCOPE_KEY = '[mongoid]:current-scope' + CURRENT_SCOPE_KEY = 'current-scope' - AUTOSAVES_KEY = '[mongoid]:autosaves' + AUTOSAVES_KEY = 'autosaves' - VALIDATIONS_KEY = '[mongoid]:validations' + VALIDATIONS_KEY = 'validations' STACK_KEYS = Hash.new do |hash, key| - hash[key] = "[mongoid]:#{key}-stack" + hash[key] = "#{key}-stack" end # The key for the current thread's sessions. - SESSIONS_KEY = '[mongoid]:sessions' + SESSIONS_KEY = 'sessions' # The key for storing documents modified inside transactions. - MODIFIED_DOCUMENTS_KEY = '[mongoid]:modified-documents' + MODIFIED_DOCUMENTS_KEY = 'modified-documents' # The key storing the default value for whether or not callbacks are # executed on documents. - EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks' + EXECUTE_CALLBACKS = 'execute-callbacks' extend self - # Queries the thread-local variable with the given name. If a block is + # Resets the current thread- or fiber-local storage to its initial state. + # This is useful for making sure the state is clean when starting a new + # thread or fiber. + # + # The value of Mongoid::Config.real_isolation_level is used to determine + # whether to reset the storage for the current thread or fiber. + def reset! + case Config.real_isolation_level + when :thread + Thread.current.thread_variable_set(STORAGE_KEY, nil) + when :fiber + Fiber[STORAGE_KEY] = nil + else + raise "Unknown isolation level: #{Config.real_isolation_level.inspect}" + end + end + + # Queries the thread- or fiber-local variable with the given name. If a block is # given, and the variable does not already exist, the return value of the # block will be set as the value of the variable before returning it. # @@ -57,7 +75,7 @@ module Threaded # @return [ Object | nil ] the value of the queried variable, or nil if # it is not set and no default was given. def get(key, &default) - result = Thread.current.thread_variable_get(key) + result = storage[key] if result.nil? && default result = yield @@ -67,7 +85,7 @@ def get(key, &default) result end - # Sets a thread-local variable with the given name to the given value. + # Sets a variable in local storage with the given name to the given value. # See #get for a discussion of why this method is necessary, and why # Thread#[]= should be avoided in cascading callbacks on embedded children. # @@ -75,35 +93,23 @@ def get(key, &default) # @param [ Object | nil ] value the value of the variable to set (or `nil` # if you wish to unset the variable) def set(key, value) - Thread.current.thread_variable_set(key, value) + storage[key] = value end - # Removes the named variable from thread-local storage. + # Removes the named variable from local storage. # # @param [ String | Symbol ] key the name of the variable to remove. def delete(key) - set(key, nil) + storage.delete(key) end - # Queries the presence of a named variable in thread-local storage. + # Queries the presence of a named variable in local storage. # # @param [ String | Symbol ] key the name of the variable to query. # # @return [ true | false ] whether the given variable is present or not. def has?(key) - # Here we have a classic example of JRuby not behaving like MRI. In - # MRI, if you set a thread variable to nil, it removes it from the list - # and subsequent calls to thread_variable?(key) will return false. Not - # so with JRuby. Once set, you cannot unset the thread variable. - # - # However, because setting a variable to nil is supposed to remove it, - # we can assume a nil-valued variable doesn't actually exist. - - # So, instead of this: - # Thread.current.thread_variable?(key) - - # We have to do this: - !get(key).nil? + storage.key?(key) end # Begin entry into a named thread local stack. @@ -508,5 +514,26 @@ def unset_current_scope(klass) delete(CURRENT_SCOPE_KEY) if scope.empty? end + + # Returns the current thread- or fiber-local storage as a Hash. + def storage + case Config.real_isolation_level + when :thread + storage_hash = Thread.current.thread_variable_get(STORAGE_KEY) + + unless storage_hash + storage_hash = {} + Thread.current.thread_variable_set(STORAGE_KEY, storage_hash) + end + + storage_hash + + when :fiber + Fiber[STORAGE_KEY] ||= {} + + else + raise "Unknown isolation level: #{Config.real_isolation_level.inspect}" + end + end end end diff --git a/spec/integration/isolation_state_spec.rb b/spec/integration/isolation_state_spec.rb new file mode 100644 index 0000000000..08f88a9ea2 --- /dev/null +++ b/spec/integration/isolation_state_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe 'Mongoid::Config.isolation_level' do + def thread_operation(value) + Thread.new do + Mongoid::Threaded.stack(:testing) << value + yield if block_given? + Mongoid::Threaded.stack(:testing) + end.join.value + end + + def fiber_operation(value) + Fiber.new do + Mongoid::Threaded.stack(:testing) << value + yield if block_given? + Mongoid::Threaded.stack(:testing) + end.resume + end + + context 'when set to an unsupported value' do + it 'raises an error' do + old_value = Mongoid::Config.isolation_level + expect { Mongoid::Config.isolation_level = :unsupported } + .to raise_error(Mongoid::Errors::UnsupportedIsolationLevel) + expect(Mongoid::Config.isolation_level).to eq(old_value) + end + end + + context 'when using older Ruby' do + ruby_version_lt '3.2' + + context 'when set to :fiber' do + it 'raises an error' do + expect { Mongoid::Config.isolation_level = :fiber } + .to raise_error(Mongoid::Errors::UnsupportedIsolationLevel) + end + end + + context 'when set to :thread' do + around do |example| + save = Mongoid::Config.isolation_level + example.run + ensure + Mongoid::Config.isolation_level = save + end + + it 'sets the isolation level' do + expect { Mongoid::Config.isolation_level = :thread } + .not_to raise_error + expect(Mongoid::Config.isolation_level).to eq(:thread) + end + end + end + + context 'when set to :rails' do + config_override :isolation_level, :rails + + def self.with_rails_isolation_level(level) + around do |example| + # changing the isolation level in Rails apparently can muck with the + # configured time zone, so we'll save and restore it, too. + tz_saved = Time.zone + + saved, ActiveSupport::IsolatedExecutionState.isolation_level = + ActiveSupport::IsolatedExecutionState.isolation_level, level + + example.run + ensure + ActiveSupport::IsolatedExecutionState.isolation_level = saved + Time.zone = tz_saved + end + end + + context 'when using Rails < 7' do + max_rails_version '6.99' + + it 'returns :thread' do + expect(Mongoid::Config.isolation_level).to eq(:rails) + expect(Mongoid::Config.real_isolation_level).to eq(:thread) + end + end + + context 'when using Rails >= 7' do + min_rails_version '7.0' + + context 'when IsolatedExecutionState.isolation_level is set to :thread' do + with_rails_isolation_level :thread + + it 'returns :thread' do + expect(Mongoid::Config.isolation_level).to eq(:rails) + expect(Mongoid::Config.real_isolation_level).to eq(:thread) + end + end + + context 'when IsolatedExecutionState.isolation_level is set to :fiber' do + with_rails_isolation_level :fiber + + context 'when Ruby version is >= 3.2' do + ruby_version_gte '3.2' + + it 'returns :fiber' do + expect(Mongoid::Config.isolation_level).to eq(:rails) + expect(Mongoid::Config.real_isolation_level).to eq(:fiber) + end + end + + context 'when Ruby version is < 3.2' do + ruby_version_lt '3.2' + + it 'raises an error' do + expect(Mongoid::Config.isolation_level).to eq(:rails) + expect { Mongoid::Config.real_isolation_level }.to raise_error(Mongoid::Errors::UnsupportedIsolationLevel) + end + end + end + end + end + + context 'when set to :thread' do + config_override :isolation_level, :thread + + context 'when not operating inside fibers' do + let(:result1) { thread_operation('a') { thread_operation('b') } } + let(:result2) { thread_operation('b') { thread_operation('c') } } + + it 'isolates state per thread' do + expect(result1).to eq(%w[ a ]) + expect(result2).to eq(%w[ b ]) + end + end + + context 'when operating inside fibers' do + let(:result) { thread_operation('a') { fiber_operation('b') } } + + it 'exposes the thread state within the fiber' do + expect(result).to eq(%w[ a b ]) + end + end + end + + context 'when using Ruby 3.2+' do + ruby_version_gte '3.2' + + context 'when set to :fiber' do + config_override :isolation_level, :fiber + + context 'when operating inside threads' do + let(:result) { fiber_operation('a') { thread_operation('b') } } + + it 'exposes the fiber state within the thread' do + expect(result).to eq(%w[ a b ]) + end + end + + context 'when operating in nested fibers' do + let(:result) { fiber_operation('a') { fiber_operation('b') } } + + it 'propagates fiber state to nested fibers' do + expect(result).to eq(%w[ a b ]) + end + end + + context 'when operating in adjacent fibers' do + let(:result1) { fiber_operation('a') { fiber_operation('b') } } + let(:result2) { fiber_operation('c') { fiber_operation('d') } } + + it 'maintains isolation between adjacent fibers' do + expect(result1).to eq(%w[ a b ]) + expect(result2).to eq(%w[ c d ]) + end + end + + describe '#reset!' do + context 'when operating in nested fibers' do + let (:result) do + fiber_operation('a') do + Mongoid::Threaded.reset! + + # once reset, subsequent nested fibers will each have their own + # state; they won't touch the reset state here. + fiber_operation('b') + fiber_operation('c') + + # If we then add to the stack here, it will be unaffected by + # the previous fiber operations. + Mongoid::Threaded.stack(:testing) << 'd' + end + end + + it 'clears the fiber state' do + expect(result).to eq(%w[ d ]) + end + end + end + end + end +end