Skip to content

Commit 512e165

Browse files
MONGOID-5658 Fix callbacks for embedded (#5727)
* 5658 * 5658 * 5658 * Document the config option * Add warning * Add docstrings * Update for ActiveSupport 7.1 * Add test case * Add release notes * Update specs
1 parent 355ecd7 commit 512e165

File tree

5 files changed

+516
-161
lines changed

5 files changed

+516
-161
lines changed

docs/release-notes/mongoid-9.0.txt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ please consult GitHub releases for detailed release notes and JIRA for
1818
the complete list of issues fixed in each release, including bug fixes.
1919

2020

21+
``around_*`` callbacks for embedded documents are now ignored
22+
-------------------------------------------------------------
23+
24+
Mongoid 8.x and older allows user to define ``around_*`` callbacks for embedded
25+
documents. Starting from 9.0 these callbacks are ignored and will not be executed.
26+
A warning will be printed to the console if such callbacks are defined.
27+
28+
If you want to restore the old behavior, you can set
29+
``Mongoid.around_embedded_document_callbacks`` to true in your application.
30+
31+
.. note::
32+
Enabling ``around_*`` callbacks for embedded documents is not recommended
33+
as it may cause ``SystemStackError`` exceptions when a document has many
34+
embedded documents. See `MONGOID-5658 <https://jira.mongodb.org/browse/MONGOID-5658>`_
35+
for more details.
36+
37+
2138
``for_js`` method is deprecated
2239
-------------------------------
2340

lib/mongoid/config.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ module Config
137137
# See https://jira.mongodb.org/browse/MONGOID-5542
138138
option :prevent_multiple_calls_of_embedded_callbacks, default: true
139139

140+
# When this flag is false, callbacks for embedded documents will not be
141+
# called. This is the default in 9.0.
142+
#
143+
# Setting this flag to true restores the pre-9.0 behavior, where callbacks
144+
# for embedded documents are called. This may lead to stack overflow errors
145+
# if there are more than cicrca 1000 embedded documents in the root
146+
# document's dependencies graph.
147+
# See https://jira.mongodb.org/browse/MONGOID-5658 for more details.
148+
option :around_callbacks_for_embeds, default: false
149+
140150
# Returns the Config singleton, for use in the configure DSL.
141151
#
142152
# @return [ self ] The Config singleton.

lib/mongoid/interceptable.rb

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,28 @@ def run_callbacks(kind, with_children: true, skip_if: nil, &block)
149149
#
150150
# @api private
151151
def _mongoid_run_child_callbacks(kind, children: nil, &block)
152+
if Mongoid::Config.around_callbacks_for_embeds
153+
_mongoid_run_child_callbacks_with_around(kind, children: children, &block)
154+
else
155+
_mongoid_run_child_callbacks_without_around(kind, children: children, &block)
156+
end
157+
end
158+
159+
# Execute the callbacks of given kind for embedded documents including
160+
# around callbacks.
161+
#
162+
# @note This method is prone to stack overflow errors if the document
163+
# has a large number of embedded documents. It is recommended to avoid
164+
# using around callbacks for embedded documents until a proper solution
165+
# is implemented.
166+
#
167+
# @param [ Symbol ] kind The type of callback to execute.
168+
# @param [ Array<Document> ] children Children to execute callbacks on. If
169+
# nil, callbacks will be executed on all cascadable children of
170+
# the document.
171+
#
172+
# @api private
173+
def _mongoid_run_child_callbacks_with_around(kind, children: nil, &block)
152174
child, *tail = (children || cascadable_children(kind))
153175
with_children = !Mongoid::Config.prevent_multiple_calls_of_embedded_callbacks
154176
if child.nil?
@@ -157,11 +179,73 @@ def _mongoid_run_child_callbacks(kind, children: nil, &block)
157179
child.run_callbacks(child_callback_type(kind, child), with_children: with_children, &block)
158180
else
159181
child.run_callbacks(child_callback_type(kind, child), with_children: with_children) do
160-
_mongoid_run_child_callbacks(kind, children: tail, &block)
182+
_mongoid_run_child_callbacks_with_around(kind, children: tail, &block)
161183
end
162184
end
163185
end
164186

187+
# Execute the callbacks of given kind for embedded documents without
188+
# around callbacks.
189+
#
190+
# @param [ Symbol ] kind The type of callback to execute.
191+
# @param [ Array<Document> ] children Children to execute callbacks on. If
192+
# nil, callbacks will be executed on all cascadable children of
193+
# the document.
194+
#
195+
# @api private
196+
def _mongoid_run_child_callbacks_without_around(kind, children: nil, &block)
197+
children = (children || cascadable_children(kind))
198+
callback_list = _mongoid_run_child_before_callbacks(kind, children: children)
199+
return false if callback_list == false
200+
value = block&.call
201+
callback_list.each do |_next_sequence, env|
202+
env.value &&= value
203+
end
204+
return false if _mongoid_run_child_after_callbacks(callback_list: callback_list) == false
205+
206+
value
207+
end
208+
209+
# Execute the before callbacks of given kind for embedded documents.
210+
#
211+
# @param [ Symbol ] kind The type of callback to execute.
212+
# @param [ Array<Document> ] children Children to execute callbacks on.
213+
# @param [ Array<ActiveSupport::Callbacks::CallbackSequence, ActiveSupport::Callbacks::Filters::Environment> ] callback_list List of
214+
# pairs of callback sequence and environment. This list will be later used
215+
# to execute after callbacks in reverse order.
216+
#
217+
# @api private
218+
def _mongoid_run_child_before_callbacks(kind, children: [], callback_list: [])
219+
children.each do |child|
220+
chain = child.__callbacks[child_callback_type(kind, child)]
221+
env = ActiveSupport::Callbacks::Filters::Environment.new(child, false, nil)
222+
next_sequence = compile_callbacks(chain)
223+
unless next_sequence.final?
224+
Mongoid.logger.warn("Around callbacks are disabled for embedded documents. Skipping around callbacks for #{child.class.name}.")
225+
Mongoid.logger.warn("To enable around callbacks for embedded documents, set Mongoid::Config.around_callbacks_for_embeds to true.")
226+
end
227+
next_sequence.invoke_before(env)
228+
return false if env.halted
229+
env.value = !env.halted
230+
callback_list << [next_sequence, env]
231+
if (grandchildren = child.send(:cascadable_children, kind))
232+
_mongoid_run_child_before_callbacks(kind, children: grandchildren, callback_list: callback_list)
233+
end
234+
end
235+
callback_list
236+
end
237+
238+
# Execute the after callbacks.
239+
#
240+
# @param [ Array<ActiveSupport::Callbacks::CallbackSequence, ActiveSupport::Callbacks::Filters::Environment> ] callback_list List of
241+
# pairs of callback sequence and environment.
242+
def _mongoid_run_child_after_callbacks(callback_list: [])
243+
callback_list.reverse_each do |next_sequence, env|
244+
next_sequence.invoke_after(env)
245+
return false if env.halted
246+
end
247+
end
248+
165249
# Returns the stored callbacks to be executed later.
166250
#
167251
# @return [ Array<Symbol> ] Method symbols of the stored pending callbacks.
@@ -314,13 +398,7 @@ def run_targeted_callbacks(place, kind)
314398
end
315399
self.class.send :define_method, name do
316400
env = ActiveSupport::Callbacks::Filters::Environment.new(self, false, nil)
317-
sequence = if chain.method(:compile).arity == 0
318-
# ActiveSupport < 7.1
319-
chain.compile
320-
else
321-
# ActiveSupport >= 7.1
322-
chain.compile(nil)
323-
end
401+
sequence = compile_callbacks(chain)
324402
sequence.invoke_before(env)
325403
env.value = !env.halted
326404
sequence.invoke_after(env)
@@ -330,5 +408,24 @@ def run_targeted_callbacks(place, kind)
330408
end
331409
send(name)
332410
end
411+
412+
# Compile the callback chain.
413+
#
414+
# This method hides the differences between ActiveSupport implementations
415+
# before and after 7.1.
416+
#
417+
# @param [ ActiveSupport::Callbacks::CallbackChain ] chain The callback chain.
418+
# @param [ Symbol | nil ] type The type of callback chain to compile.
419+
#
420+
# @return [ ActiveSupport::Callbacks::CallbackSequence ] The compiled callback sequence.
421+
def compile_callbacks(chain, type = nil)
422+
if chain.method(:compile).arity == 0
423+
# ActiveSupport < 7.1
424+
chain.compile
425+
else
426+
# ActiveSupport >= 7.1
427+
chain.compile(type)
428+
end
429+
end
333430
end
334431
end

spec/integration/callbacks_spec.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,7 @@ def will_save_change_to_attribute_values_before
558558

559559
context 'nested embedded documents' do
560560
config_override :prevent_multiple_calls_of_embedded_callbacks, true
561+
config_override :around_callbacks_for_embeds, true
561562

562563
let(:logger) { Array.new }
563564

@@ -582,4 +583,23 @@ def will_save_change_to_attribute_values_before
582583
expect(logger).to eq(%i[embedded_twice embedded_once root])
583584
end
584585
end
586+
587+
context 'cascade callbacks' do
588+
ruby_version_gte '3.0'
589+
590+
let(:book) do
591+
Book.new
592+
end
593+
594+
before do
595+
1500.times do
596+
book.pages.build
597+
end
598+
end
599+
600+
# https://jira.mongodb.org/browse/MONGOID-5658
601+
it 'does not raise SystemStackError' do
602+
expect { book.save! }.not_to raise_error(SystemStackError)
603+
end
604+
end
585605
end

0 commit comments

Comments
 (0)