From c91fe70d6ecf13f7ffb8bd527d08121f5c0d001c Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Tue, 8 Jul 2025 16:41:53 -0600 Subject: [PATCH 1/5] MONGOID-5882 isolation state --- lib/mongoid/config.rb | 13 ++++ lib/mongoid/threaded.rb | 85 +++++++++++++-------- spec/integration/isolation_state_spec.rb | 97 ++++++++++++++++++++++++ 3 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 spec/integration/isolation_state_spec.rb diff --git a/lib/mongoid/config.rb b/lib/mongoid/config.rb index 7ce5ecaa47..8fbb19a3e6 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -110,6 +110,19 @@ module Config # to `:global_thread_pool`. option :global_executor_concurrency, default: nil + # Defines the isolation level that Mongoid uses to store its internal + # state. + # + # This option may be set to either `:thread` (the default) or `:fiber`. + # + # If set to `:thread`, Mongoid will use thread-local storage to manage + # its internal state. + # + # 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. + option :isolation_level, default: :thread + # 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/threaded.rb b/lib/mongoid/threaded.rb index 6fb1a9ab31..eae67cef7c 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.isolation_level is used to determine + # whether to reset the storage for the current thread or fiber. + def reset! + case Config.isolation_level + when :thread + Thread.current.thread_variable_set(STORAGE_KEY, nil) + when :fiber + Fiber[STORAGE_KEY] = nil + else + raise "Unknown isolation level: #{Config.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,22 @@ 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.isolation_level + when :thread + if !Thread.current.thread_variable?(STORAGE_KEY) + Thread.current.thread_variable_set(STORAGE_KEY, {}) + end + + Thread.current.thread_variable_get(STORAGE_KEY) + when :fiber + Fiber[STORAGE_KEY] ||= {} + else + raise "Unknown isolation level: #{Config.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..523c6aa22a --- /dev/null +++ b/spec/integration/isolation_state_spec.rb @@ -0,0 +1,97 @@ +# 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 :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 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 From f93cb4ec18708c431e237fff5b19247f09b7c5a5 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Wed, 9 Jul 2025 09:48:56 -0600 Subject: [PATCH 2/5] :fiber level only works reliably on Ruby 3.2+ --- lib/config/locales/en.yml | 10 ++ lib/mongoid/config.rb | 14 ++- lib/mongoid/config/options.rb | 11 +- lib/mongoid/errors.rb | 1 + .../errors/unsupported_isolation_level.rb | 23 ++++ spec/integration/isolation_state_spec.rb | 111 ++++++++++++------ 6 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 lib/mongoid/errors/unsupported_isolation_level.rb diff --git a/lib/config/locales/en.yml b/lib/config/locales/en.yml index a5c56921c7..27c3273604 100644 --- a/lib/config/locales/en.yml +++ b/lib/config/locales/en.yml @@ -712,6 +712,16 @@ 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 `: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`. 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 8fbb19a3e6..bc444c4f86 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -121,7 +121,19 @@ module Config # 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. - option :isolation_level, default: :thread + # + # 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: :thread, on_change: -> (level) do + if %i[ thread fiber ].exclude?(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 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..1cb560626c --- /dev/null +++ b/lib/mongoid/errors/unsupported_isolation_level.rb @@ -0,0 +1,23 @@ +# 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/spec/integration/isolation_state_spec.rb b/spec/integration/isolation_state_spec.rb index 523c6aa22a..39b1914dd5 100644 --- a/spec/integration/isolation_state_spec.rb +++ b/spec/integration/isolation_state_spec.rb @@ -20,6 +20,41 @@ def fiber_operation(value) 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 :thread' do config_override :isolation_level, :thread @@ -42,54 +77,58 @@ def fiber_operation(value) end end - context 'when set to :fiber' do - config_override :isolation_level, :fiber + context 'when using Ruby 3.2+' do + ruby_version_gte '3.2' - context 'when operating inside threads' do - let(:result) { fiber_operation('a') { thread_operation('b') } } + context 'when set to :fiber' do + config_override :isolation_level, :fiber - it 'exposes the fiber state within the thread' do - expect(result).to eq(%w[ a b ]) + 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 - end - context 'when operating in nested fibers' do - let(:result) { fiber_operation('a') { fiber_operation('b') } } + 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 ]) + it 'propagates fiber state to nested fibers' do + expect(result).to eq(%w[ a b ]) + end 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') } } + 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 ]) + it 'maintains isolation between adjacent fibers' do + expect(result1).to eq(%w[ a b ]) + expect(result2).to eq(%w[ c d ]) + end 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' + 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 - end - it 'clears the fiber state' do - expect(result).to eq(%w[ d ]) + it 'clears the fiber state' do + expect(result).to eq(%w[ d ]) + end end end end From 5dd21fadc92dca310a61b265a18cfa71d5bc19b1 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Wed, 9 Jul 2025 09:55:32 -0600 Subject: [PATCH 3/5] rubocop appeasement --- lib/mongoid/errors/unsupported_isolation_level.rb | 3 +-- lib/mongoid/threaded.rb | 14 +++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/lib/mongoid/errors/unsupported_isolation_level.rb b/lib/mongoid/errors/unsupported_isolation_level.rb index 1cb560626c..c6a7bc5894 100644 --- a/lib/mongoid/errors/unsupported_isolation_level.rb +++ b/lib/mongoid/errors/unsupported_isolation_level.rb @@ -2,7 +2,6 @@ module Mongoid module Errors - # Raised when an unsupported isolation level is used in Mongoid # configuration. class UnsupportedIsolationLevel < MongoidError @@ -13,7 +12,7 @@ class UnsupportedIsolationLevel < MongoidError def initialize(level) super( compose_message( - "unsupported_isolation_level", + 'unsupported_isolation_level', { level: level } ) ) diff --git a/lib/mongoid/threaded.rb b/lib/mongoid/threaded.rb index eae67cef7c..fd81682db5 100644 --- a/lib/mongoid/threaded.rb +++ b/lib/mongoid/threaded.rb @@ -515,21 +515,25 @@ def unset_current_scope(klass) delete(CURRENT_SCOPE_KEY) if scope.empty? end - # Returns the current thread- or fiber-local storage as a Hash. + # Returns the current thread- or fiber-local storage as a Hash. def storage case Config.isolation_level when :thread - if !Thread.current.thread_variable?(STORAGE_KEY) - Thread.current.thread_variable_set(STORAGE_KEY, {}) + storage_hash = Thread.current.thread_variable_get(STORAGE_KEY) + + unless storage_hash + storage_hash = {} + Thread.current.thread_variable_set(STORAGE_KEY, storage_hash) end - Thread.current.thread_variable_get(STORAGE_KEY) + storage_hash + when :fiber Fiber[STORAGE_KEY] ||= {} + else raise "Unknown isolation level: #{Config.isolation_level.inspect}" end end - end end From 1b186f749b70d17521c2699d8d8b4f6023c089d4 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Thu, 10 Jul 2025 11:52:33 -0600 Subject: [PATCH 4/5] inherit the isolation level from Rails by default --- lib/config/locales/en.yml | 9 ++-- lib/mongoid/config.rb | 53 ++++++++++++++++++--- lib/mongoid/threaded.rb | 10 ++-- spec/integration/isolation_state_spec.rb | 59 ++++++++++++++++++++++++ 4 files changed, 116 insertions(+), 15 deletions(-) diff --git a/lib/config/locales/en.yml b/lib/config/locales/en.yml index 27c3273604..b6f6884ca1 100644 --- a/lib/config/locales/en.yml +++ b/lib/config/locales/en.yml @@ -716,12 +716,13 @@ en: message: "The isolation level '%{level}' is not supported." summary: > You requested an isolation level of '%{level}', which is not - supported. Only `:thread` and `:fiber` isolation levels are - currently supported; note that the `:fiber` level is only - supported on Ruby versions 3.2 and higher. + 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`. + 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 bc444c4f86..fcf622a435 100644 --- a/lib/mongoid/config.rb +++ b/lib/mongoid/config.rb @@ -110,13 +110,16 @@ 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. # - # This option may be set to either `:thread` (the default) or `:fiber`. - # - # If set to `:thread`, Mongoid will use thread-local storage to manage - # 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 @@ -125,8 +128,46 @@ module Config # 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: :thread, on_change: -> (level) do - if %i[ thread fiber ].exclude?(level) + 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 diff --git a/lib/mongoid/threaded.rb b/lib/mongoid/threaded.rb index fd81682db5..48bb0e45ae 100644 --- a/lib/mongoid/threaded.rb +++ b/lib/mongoid/threaded.rb @@ -42,16 +42,16 @@ module Threaded # This is useful for making sure the state is clean when starting a new # thread or fiber. # - # The value of Mongoid::Config.isolation_level is used to determine + # 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.isolation_level + 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.isolation_level.inspect}" + raise "Unknown isolation level: #{Config.real_isolation_level.inspect}" end end @@ -517,7 +517,7 @@ def unset_current_scope(klass) # Returns the current thread- or fiber-local storage as a Hash. def storage - case Config.isolation_level + case Config.real_isolation_level when :thread storage_hash = Thread.current.thread_variable_get(STORAGE_KEY) @@ -532,7 +532,7 @@ def storage Fiber[STORAGE_KEY] ||= {} else - raise "Unknown isolation level: #{Config.isolation_level.inspect}" + raise "Unknown isolation level: #{Config.real_isolation_level.inspect}" end end end diff --git a/spec/integration/isolation_state_spec.rb b/spec/integration/isolation_state_spec.rb index 39b1914dd5..6bbfa8928f 100644 --- a/spec/integration/isolation_state_spec.rb +++ b/spec/integration/isolation_state_spec.rb @@ -55,6 +55,65 @@ def fiber_operation(value) end end + context 'when set to :rails' do + config_override :isolation_level, :rails + + def self.with_rails_isolation_level(level) + puts "Rails version: #{ActiveSupport.version}" + around do |example| + saved, ActiveSupport::IsolatedExecutionState.isolation_level = + ActiveSupport::IsolatedExecutionState.isolation_level, level + example.run + ensure + ActiveSupport::IsolatedExecutionState.isolation_level = 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 From a2600cd0b2de65990e2f3d3b73cf21a422d930a5 Mon Sep 17 00:00:00 2001 From: Jamis Buck Date: Thu, 10 Jul 2025 12:53:53 -0600 Subject: [PATCH 5/5] make sure time zone is restored, too, when restoring isolation level --- spec/integration/isolation_state_spec.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/integration/isolation_state_spec.rb b/spec/integration/isolation_state_spec.rb index 6bbfa8928f..08f88a9ea2 100644 --- a/spec/integration/isolation_state_spec.rb +++ b/spec/integration/isolation_state_spec.rb @@ -59,13 +59,18 @@ def fiber_operation(value) config_override :isolation_level, :rails def self.with_rails_isolation_level(level) - puts "Rails version: #{ActiveSupport.version}" 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