Skip to content

Commit 2be8277

Browse files
committed
Merge branch 'master' into 5888-grandchild-validations
2 parents 692335b + 2bc1d12 commit 2be8277

File tree

7 files changed

+368
-32
lines changed

7 files changed

+368
-32
lines changed

lib/config/locales/en.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,17 @@ en:
712712
the expression %{javascript} is not allowed."
713713
resolution: "Please provide a standard hash to #where when the criteria
714714
is for an embedded association."
715+
unsupported_isolation_level:
716+
message: "The isolation level '%{level}' is not supported."
717+
summary: >
718+
You requested an isolation level of '%{level}', which is not
719+
supported. Only `:rails`, `:thread` and `:fiber` isolation
720+
levels are currently supported; note that the `:fiber` level is
721+
only supported on Ruby versions 3.2 and higher.
722+
resolution: >
723+
Use `:thread` as the isolation level. If you are using Ruby 3.2
724+
or higher, you may also use `:fiber`. If using Rails 7+, you
725+
may also use `:rails` to inherit the isolation level from Rails.
715726
validations:
716727
message: "Validation of %{document} failed."
717728
summary: "The following errors were found: %{errors}"

lib/mongoid/config.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,72 @@ module Config
110110
# to `:global_thread_pool`.
111111
option :global_executor_concurrency, default: nil
112112

113+
VALID_ISOLATION_LEVELS = %i[ rails thread fiber ].freeze
114+
115+
# Defines the isolation level that Mongoid uses to store its internal
116+
# state.
117+
#
118+
# Valid values are:
119+
# - `:rails` - Uses the isolation level that Rails currently has
120+
# configured. (This is the default.)
121+
# - `:thread` - Uses thread-local storage.
122+
# - `:fiber` - Uses fiber-local storage (only supported in Ruby 3.2+).
123+
#
124+
# If set to `:fiber`, Mongoid will use fiber-local storage instead. This
125+
# may be necessary if you are using libraries like Falcon, which use
126+
# fibers to manage concurrency.
127+
#
128+
# Note that the `:fiber` isolation level is only supported in Ruby 3.2
129+
# and later, due to semantic differences in how fiber storage is handled
130+
# in earlier Ruby versions.
131+
option :isolation_level, default: :rails, on_change: -> (level) do
132+
validate_isolation_level!(level)
133+
end
134+
135+
# Returns the (potentially-dereferenced) isolation level that Mongoid
136+
# will use to store its internal state. If `isolation_level` is set to
137+
# `:rails`, this will return the isolation level that Rails is current
138+
# configured to use (`ActiveSupport::IsolatedExecutionState.isolation_level`).
139+
#
140+
# If using an older version of Rails that does not support
141+
# ActiveSupport::IsolatedExecutionState, this will return `:thread`
142+
# instead.
143+
#
144+
# @api private
145+
def real_isolation_level
146+
return isolation_level unless isolation_level == :rails
147+
148+
if defined?(ActiveSupport::IsolatedExecutionState)
149+
ActiveSupport::IsolatedExecutionState.isolation_level.tap do |level|
150+
# We can't guarantee that Rails will always support the same
151+
# isolation levels as Mongoid, so we check here to make sure
152+
# it's something we can work with.
153+
validate_isolation_level!(level)
154+
end
155+
else
156+
# The default, if Rails does not support IsolatedExecutionState,
157+
:thread
158+
end
159+
end
160+
161+
# Checks to see if the provided isolation level is something that Mongoid
162+
# supports. Raises Errors::UnsupportedIsolationLevel if it is not.
163+
#
164+
# This will also raise an error if the isolation level is set to `:fiber`
165+
# and the Ruby version is less than 3.2, since fiber-local storage
166+
# is not supported in earlier Ruby versions.
167+
#
168+
# @api private
169+
def validate_isolation_level!(level)
170+
unless VALID_ISOLATION_LEVELS.include?(level)
171+
raise Errors::UnsupportedIsolationLevel.new(level)
172+
end
173+
174+
if level == :fiber && RUBY_VERSION < '3.2'
175+
raise Errors::UnsupportedIsolationLevel.new(level)
176+
end
177+
end
178+
113179
# When this flag is false, a document will become read-only only once the
114180
# #readonly! method is called, and an error will be raised on attempting
115181
# to save or update such documents, instead of just on delete. When this

lib/mongoid/config/options.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,17 @@ def option(name, options = {})
4040
end
4141

4242
define_method("#{name}=") do |value|
43+
old_value = settings[name]
4344
settings[name] = value
44-
options[:on_change]&.call(value)
45+
46+
begin
47+
options[:on_change]&.call(value)
48+
rescue
49+
# If the on_change callback raises an error, we need to roll
50+
# the change back.
51+
settings[name] = old_value
52+
raise
53+
end
4554
end
4655

4756
define_method("#{name}?") do

lib/mongoid/errors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,6 @@
7474
require 'mongoid/errors/unregistered_class'
7575
require "mongoid/errors/unsaved_document"
7676
require "mongoid/errors/unsupported_javascript"
77+
require "mongoid/errors/unsupported_isolation_level"
7778
require "mongoid/errors/validations"
7879
require "mongoid/errors/delete_restriction"
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# frozen_string_literal: true
2+
3+
module Mongoid
4+
module Errors
5+
# Raised when an unsupported isolation level is used in Mongoid
6+
# configuration.
7+
class UnsupportedIsolationLevel < MongoidError
8+
# Create the new error caused by attempting to select an unsupported
9+
# isolation level.
10+
#
11+
# @param [ Symbol ] level The requested isolation level.
12+
def initialize(level)
13+
super(
14+
compose_message(
15+
'unsupported_isolation_level',
16+
{ level: level }
17+
)
18+
)
19+
end
20+
end
21+
end
22+
end

lib/mongoid/threaded.rb

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,56 @@ module Mongoid
66
# This module contains logic for easy access to objects that have a lifecycle
77
# on the current thread.
88
module Threaded
9-
DATABASE_OVERRIDE_KEY = '[mongoid]:db-override'
9+
# The key for the shared thread- and fiber-local storage. It must be a
10+
# symbol because keys for fiber-local storage must be symbols.
11+
STORAGE_KEY = :'[mongoid]'
1012

11-
# Constant for the key to store clients.
12-
CLIENTS_KEY = '[mongoid]:clients'
13+
DATABASE_OVERRIDE_KEY = 'db-override'
1314

1415
# The key to override the client.
15-
CLIENT_OVERRIDE_KEY = '[mongoid]:client-override'
16+
CLIENT_OVERRIDE_KEY = 'client-override'
1617

1718
# The key for the current thread's scope stack.
18-
CURRENT_SCOPE_KEY = '[mongoid]:current-scope'
19+
CURRENT_SCOPE_KEY = 'current-scope'
1920

20-
AUTOSAVES_KEY = '[mongoid]:autosaves'
21+
AUTOSAVES_KEY = 'autosaves'
2122

22-
VALIDATIONS_KEY = '[mongoid]:validations'
23+
VALIDATIONS_KEY = 'validations'
2324

2425
STACK_KEYS = Hash.new do |hash, key|
25-
hash[key] = "[mongoid]:#{key}-stack"
26+
hash[key] = "#{key}-stack"
2627
end
2728

2829
# The key for the current thread's sessions.
29-
SESSIONS_KEY = '[mongoid]:sessions'
30+
SESSIONS_KEY = 'sessions'
3031

3132
# The key for storing documents modified inside transactions.
32-
MODIFIED_DOCUMENTS_KEY = '[mongoid]:modified-documents'
33+
MODIFIED_DOCUMENTS_KEY = 'modified-documents'
3334

3435
# The key storing the default value for whether or not callbacks are
3536
# executed on documents.
36-
EXECUTE_CALLBACKS = '[mongoid]:execute-callbacks'
37+
EXECUTE_CALLBACKS = 'execute-callbacks'
3738

3839
extend self
3940

40-
# Queries the thread-local variable with the given name. If a block is
41+
# Resets the current thread- or fiber-local storage to its initial state.
42+
# This is useful for making sure the state is clean when starting a new
43+
# thread or fiber.
44+
#
45+
# The value of Mongoid::Config.real_isolation_level is used to determine
46+
# whether to reset the storage for the current thread or fiber.
47+
def reset!
48+
case Config.real_isolation_level
49+
when :thread
50+
Thread.current.thread_variable_set(STORAGE_KEY, nil)
51+
when :fiber
52+
Fiber[STORAGE_KEY] = nil
53+
else
54+
raise "Unknown isolation level: #{Config.real_isolation_level.inspect}"
55+
end
56+
end
57+
58+
# Queries the thread- or fiber-local variable with the given name. If a block is
4159
# given, and the variable does not already exist, the return value of the
4260
# block will be set as the value of the variable before returning it.
4361
#
@@ -57,7 +75,7 @@ module Threaded
5775
# @return [ Object | nil ] the value of the queried variable, or nil if
5876
# it is not set and no default was given.
5977
def get(key, &default)
60-
result = Thread.current.thread_variable_get(key)
78+
result = storage[key]
6179

6280
if result.nil? && default
6381
result = yield
@@ -67,43 +85,31 @@ def get(key, &default)
6785
result
6886
end
6987

70-
# Sets a thread-local variable with the given name to the given value.
88+
# Sets a variable in local storage with the given name to the given value.
7189
# See #get for a discussion of why this method is necessary, and why
7290
# Thread#[]= should be avoided in cascading callbacks on embedded children.
7391
#
7492
# @param [ String | Symbol ] key the name of the variable to set.
7593
# @param [ Object | nil ] value the value of the variable to set (or `nil`
7694
# if you wish to unset the variable)
7795
def set(key, value)
78-
Thread.current.thread_variable_set(key, value)
96+
storage[key] = value
7997
end
8098

81-
# Removes the named variable from thread-local storage.
99+
# Removes the named variable from local storage.
82100
#
83101
# @param [ String | Symbol ] key the name of the variable to remove.
84102
def delete(key)
85-
set(key, nil)
103+
storage.delete(key)
86104
end
87105

88-
# Queries the presence of a named variable in thread-local storage.
106+
# Queries the presence of a named variable in local storage.
89107
#
90108
# @param [ String | Symbol ] key the name of the variable to query.
91109
#
92110
# @return [ true | false ] whether the given variable is present or not.
93111
def has?(key)
94-
# Here we have a classic example of JRuby not behaving like MRI. In
95-
# MRI, if you set a thread variable to nil, it removes it from the list
96-
# and subsequent calls to thread_variable?(key) will return false. Not
97-
# so with JRuby. Once set, you cannot unset the thread variable.
98-
#
99-
# However, because setting a variable to nil is supposed to remove it,
100-
# we can assume a nil-valued variable doesn't actually exist.
101-
102-
# So, instead of this:
103-
# Thread.current.thread_variable?(key)
104-
105-
# We have to do this:
106-
!get(key).nil?
112+
storage.key?(key)
107113
end
108114

109115
# Begin entry into a named thread local stack.
@@ -508,5 +514,26 @@ def unset_current_scope(klass)
508514

509515
delete(CURRENT_SCOPE_KEY) if scope.empty?
510516
end
517+
518+
# Returns the current thread- or fiber-local storage as a Hash.
519+
def storage
520+
case Config.real_isolation_level
521+
when :thread
522+
storage_hash = Thread.current.thread_variable_get(STORAGE_KEY)
523+
524+
unless storage_hash
525+
storage_hash = {}
526+
Thread.current.thread_variable_set(STORAGE_KEY, storage_hash)
527+
end
528+
529+
storage_hash
530+
531+
when :fiber
532+
Fiber[STORAGE_KEY] ||= {}
533+
534+
else
535+
raise "Unknown isolation level: #{Config.real_isolation_level.inspect}"
536+
end
537+
end
511538
end
512539
end

0 commit comments

Comments
 (0)