Skip to content

MONGOID-5882 Isolation state #6009

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
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
11 changes: 11 additions & 0 deletions lib/config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
66 changes: 66 additions & 0 deletions lib/mongoid/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/mongoid/config/options.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/mongoid/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
22 changes: 22 additions & 0 deletions lib/mongoid/errors/unsupported_isolation_level.rb
Original file line number Diff line number Diff line change
@@ -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
89 changes: 58 additions & 31 deletions lib/mongoid/threaded.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand All @@ -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
Expand All @@ -67,43 +85,31 @@ 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.
#
# @param [ String | Symbol ] key the name of the variable to set.
# @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.
Expand Down Expand Up @@ -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
Loading