Skip to content

Commit 7150a40

Browse files
authored
MONGOID-5782 Add block-based Timeless API to fix cascaded timestamp leak (#6165)
* MONGOID-5782 Add block-based Timeless API to fix cascaded timestamp leak The counter-based timeless mechanism was consumed by the first timestamp callback in a save, so cascaded embedded children (and nested children, whose callbacks run more than once) lost suppression and had their updated_at bumped. Introduce a block-based API, timeless { ... }, backed by a thread/fiber nesting depth that is only cleared when the block exits. This suppresses timestamping for everything persisted in the block, at any cascade depth, and is easier to reason about than the implicit next-operation scope. The block-less chained form still works but is deprecated (removal in Mongoid 10.0) via Mongoid::Deprecation. process_touch_option now uses the block form internally so touch: false updates no longer leak on nested embeds and no longer emit an internal deprecation warning. * bump drivers-evergreen-tools to get NPM fix * simplify timeless tracking -- it only needs to be true/false * bump DEG in the test workflow
1 parent 416ae7e commit 7150a40

5 files changed

Lines changed: 281 additions & 23 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636

3737
- id: start-mongodb
3838
name: start mongodb
39-
uses: mongodb-labs/drivers-evergreen-tools@b26580ac7b03a7eb282053871da0a050c7075288
39+
uses: mongodb-labs/drivers-evergreen-tools@18aed4f176dab1ed505c9a2a53466ddf3f4796ec
4040
with:
4141
version: "${{matrix.mongodb}}"
4242
topology: "${{matrix.topology}}"

lib/mongoid/persistable/updatable.rb

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def prepare_update(options = {})
103103

104104
process_flagged_destroys
105105
update_children = cascadable_children(:update)
106-
process_touch_option(options, update_children) do
106+
process_touch_option(options) do
107107
run_all_callbacks_for_update(update_children) do
108108
result = yield(self)
109109
self.previously_new_record = false
@@ -160,24 +160,22 @@ def update_document(options = {})
160160
end
161161
end
162162

163-
# If there is a touch option and it is false, this method will call the
164-
# timeless method so that the updated_at attribute is not updated. It
165-
# will call the timeless method on all of the cascadable children as
166-
# well. Note that timeless is cleared in the before_update callback.
163+
# If there is a touch option and it is false, this method suppresses
164+
# timestamping for the duration of the update using a block-based
165+
# timeless scope, which covers this document and every cascaded child
166+
# (at any nesting depth), and also suppresses touch callbacks.
167167
#
168168
# @param [ Hash ] options The options.
169-
# @param [ Array<Document> ] children The children that the :update
170-
# callbacks will be executed on.
171169
#
172170
# @option options [ true | false ] :touch Whether or not the updated_at
173171
# attribute will be updated with the current time.
174-
def process_touch_option(options, children, &block)
172+
def process_touch_option(options, &block)
175173
if options.fetch(:touch, true)
176174
yield
177175
else
178-
timeless
179-
children.each(&:timeless)
180-
suppress_touch_callbacks(&block)
176+
Mongoid::Timestamps::Timeless.with_timeless do
177+
suppress_touch_callbacks(&block)
178+
end
181179
end
182180
end
183181

lib/mongoid/timestamps/timeless.rb

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ module Timestamps
77
module Timeless
88
extend ActiveSupport::Concern
99

10+
# Deprecator for the block-less form of the timeless API. Its removal
11+
# horizon is computed automatically as (current major + 1).0.
12+
DEPRECATION = Mongoid::Deprecation.new
13+
1014
# Clears out the timeless option.
1115
#
1216
# @example Clear the timeless option.
@@ -22,13 +26,20 @@ def clear_timeless_option
2226
true
2327
end
2428

25-
# Begin an execution that should skip timestamping.
29+
# Skip timestamping for the duration of the given block, or (in the
30+
# deprecated, block-less form) for the next persistence operation.
31+
#
32+
# @example Save a document but don't timestamp (block form).
33+
# person.timeless { person.save }
2634
#
27-
# @example Save a document but don't timestamp.
35+
# @example Save a document but don't timestamp (deprecated chained form).
2836
# person.timeless.save
2937
#
30-
# @return [ Document ] The document this was called on.
31-
def timeless
38+
# @return [ Object | Document ] The return value of the block, or (in the
39+
# block-less form) the document this was called on.
40+
def timeless(&block)
41+
return Timeless.with_timeless(&block) if block
42+
3243
self.class.timeless
3344
self
3445
end
@@ -47,6 +58,9 @@ class << self
4758
# The key to use to store the timeless table
4859
TIMELESS_TABLE_KEY = '[mongoid]:timeless'
4960

61+
# The key to use to store the block-based timeless flag.
62+
TIMELESS_FLAG_KEY = '[mongoid]:timeless-flag'
63+
5064
# Returns the in-memory thread cache of classes
5165
# for which to skip timestamping.
5266
#
@@ -58,16 +72,70 @@ def timeless_table
5872
end
5973

6074
def_delegators :timeless_table, :[]=, :[]
75+
76+
# Skip timestamping for the duration of the given block, on the
77+
# current thread or fiber. This applies to every document persisted
78+
# while the block is executing, regardless of class, including
79+
# cascaded embedded children at any nesting depth.
80+
#
81+
# @example Skip timestamping for a block.
82+
# Mongoid::Timestamps::Timeless.with_timeless do
83+
# person.save
84+
# end
85+
#
86+
# @return [ Object ] The return value of the block.
87+
def with_timeless
88+
# Only the outermost block owns the flag: if we are already inside a
89+
# timeless scope, we leave the suppression in place when this block
90+
# ends. This avoids tracking a nesting depth that could drift out of
91+
# sync.
92+
already_timeless = suppressing_timestamps?
93+
set_suppressing_timestamps(true) unless already_timeless
94+
yield
95+
ensure
96+
set_suppressing_timestamps(false) unless already_timeless
97+
end
98+
99+
# Whether a block-based timeless scope is currently active on this
100+
# thread/fiber.
101+
#
102+
# @return [ true | false ] Whether timestamps are being suppressed.
103+
#
104+
# @api private
105+
def suppressing_timestamps?
106+
!!Threaded.get(TIMELESS_FLAG_KEY) { false }
107+
end
108+
109+
# Set whether a block-based timeless scope is active on this
110+
# thread/fiber.
111+
#
112+
# @param [ true | false ] value Whether to suppress timestamps.
113+
#
114+
# @api private
115+
def set_suppressing_timestamps(value)
116+
Threaded.set(TIMELESS_FLAG_KEY, value)
117+
end
61118
end
62119

63120
module ClassMethods
64-
# Begin an execution that should skip timestamping.
121+
# Skip timestamping for the duration of the given block, or (in the
122+
# deprecated, block-less form) for the next persistence operation.
65123
#
66-
# @example Create a document but don't timestamp.
124+
# @example Create a document but don't timestamp (block form).
125+
# Person.timeless { Person.create(title: "Sir") }
126+
#
127+
# @example Create a document but don't timestamp (deprecated form).
67128
# Person.timeless.create(:title => "Sir")
68129
#
69-
# @return [ Class ] The class this was called on.
70-
def timeless
130+
# @return [ Object | Class ] The return value of the block, or (in the
131+
# block-less form) the class this was called on.
132+
def timeless(&block)
133+
return Timeless.with_timeless(&block) if block
134+
135+
DEPRECATION.warn(
136+
'Calling #timeless without a block is deprecated; pass a block ' \
137+
'instead, e.g. `record.timeless { record.save }`.'
138+
)
71139
counter = 0
72140
counter += 1 if self < Mongoid::Timestamps::Created
73141
counter += 1 if self < Mongoid::Timestamps::Updated
@@ -109,12 +177,14 @@ def set_timeless_counter(counter)
109177
Timeless[name] = (counter == 0) ? nil : counter
110178
end
111179

112-
# Returns whether the current class should skip timestamping.
180+
# Returns whether the current class should skip timestamping. This is
181+
# true when either a block-based timeless scope is active on the
182+
# current thread/fiber, or the deprecated per-class counter is set.
113183
#
114184
# @return [ true | false ] Whether the current class should
115185
# skip timestamping.
116186
def timeless?
117-
!!Timeless[name]
187+
Timeless.suppressing_timestamps? || !!Timeless[name]
118188
end
119189
end
120190
end

spec/mongoid/timestamps/timeless_spec.rb

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,194 @@ class Egg
135135
end
136136
end
137137
end
138+
139+
describe '#timeless with a block' do
140+
before(:all) do
141+
class TimelessOther
142+
include Mongoid::Document
143+
include Mongoid::Timestamps
144+
end
145+
end
146+
147+
after(:all) do
148+
Object.send(:remove_const, :TimelessOther)
149+
end
150+
151+
context 'when called on an instance' do
152+
let(:document) { Dokument.new }
153+
154+
it 'executes the block and persists the document' do
155+
document.timeless { document.save! }
156+
expect(document).to be_persisted
157+
end
158+
159+
it 'does not set the created timestamp' do
160+
document.timeless { document.save! }
161+
expect(document.created_at).to be_nil
162+
end
163+
164+
it 'does not set the updated timestamp' do
165+
document.timeless { document.save! }
166+
expect(document.updated_at).to be_nil
167+
end
168+
169+
it 'returns the value of the block' do
170+
expect(document.timeless { 42 }).to eq(42)
171+
end
172+
173+
it 'resumes timestamping after the block' do
174+
document.timeless { document.save! }
175+
document.update_attribute(:title, 'Sir')
176+
expect(document.updated_at).not_to be_nil
177+
end
178+
179+
it 'is not timeless outside the block' do
180+
document.timeless { document.save! }
181+
expect(document).not_to be_timeless
182+
end
183+
184+
it 'restores state even when the block raises' do
185+
expect do
186+
document.timeless { raise 'boom' }
187+
end.to raise_error('boom')
188+
expect(document).not_to be_timeless
189+
end
190+
end
191+
192+
context 'when called on the class' do
193+
it 'does not set timestamps for documents created in the block' do
194+
document = Dokument.timeless { Dokument.create! }
195+
expect(document.created_at).to be_nil
196+
expect(document.updated_at).to be_nil
197+
end
198+
end
199+
200+
context 'when nested' do
201+
let(:document) { Dokument.new }
202+
203+
it 'remains timeless until the outermost block exits' do
204+
Dokument.timeless do
205+
Dokument.timeless { document.save! }
206+
# inner block has exited, but we are still inside the outer block
207+
expect(document).to be_timeless
208+
end
209+
expect(document).not_to be_timeless
210+
expect(document.created_at).to be_nil
211+
end
212+
end
213+
214+
context 'when other documents are persisted in the block' do
215+
it 'suppresses timestamps globally on the thread for the block duration' do
216+
other = nil
217+
Dokument.timeless { other = TimelessOther.create! }
218+
expect(other.created_at).to be_nil
219+
end
220+
end
221+
end
222+
223+
# Regression for MONGOID-5782: saving a parent timeless must not bump the
224+
# updated_at of embedded children, at any nesting depth, when the
225+
# associations cascade callbacks.
226+
describe 'MONGOID-5782 cascaded embedded timestamps' do
227+
before(:all) do
228+
class TimelessBaz
229+
include Mongoid::Document
230+
include Mongoid::Timestamps
231+
232+
embedded_in :timeless_bar
233+
field :val, type: String
234+
end
235+
236+
class TimelessBar
237+
include Mongoid::Document
238+
include Mongoid::Timestamps
239+
240+
embedded_in :timeless_foo
241+
embeds_many :timeless_bazs, cascade_callbacks: true
242+
field :val, type: String
243+
end
244+
245+
class TimelessFoo
246+
include Mongoid::Document
247+
include Mongoid::Timestamps
248+
249+
embeds_many :timeless_bars, cascade_callbacks: true
250+
field :val, type: String
251+
end
252+
end
253+
254+
after(:all) do
255+
Object.send(:remove_const, :TimelessBaz)
256+
Object.send(:remove_const, :TimelessBar)
257+
Object.send(:remove_const, :TimelessFoo)
258+
end
259+
260+
let!(:start_time) { Timecop.freeze(Time.at(Time.now.to_i)) }
261+
262+
let!(:foo) do
263+
TimelessFoo.create!(timeless_bars: [ { val: 'a', timeless_bazs: [ { val: 'x' } ] } ])
264+
end
265+
266+
let(:bar) { foo.timeless_bars.first }
267+
let(:baz) { bar.timeless_bazs.first }
268+
269+
after do
270+
Timecop.return
271+
end
272+
273+
it 'does not bump the embedded child updated_at' do
274+
original = bar.updated_at
275+
bar.val = 'b'
276+
Timecop.freeze(Time.at(Time.now.to_i) + 2)
277+
foo.timeless { foo.save! }
278+
# the change was actually persisted (the block ran)...
279+
expect(foo.reload.timeless_bars.first.val).to eq('b')
280+
# ...but the timestamp was suppressed.
281+
expect(bar.updated_at).to eq(original)
282+
end
283+
284+
it 'does not bump the nested embedded child updated_at' do
285+
original = baz.updated_at
286+
baz.val = 'y'
287+
Timecop.freeze(Time.at(Time.now.to_i) + 2)
288+
foo.timeless { foo.save! }
289+
expect(foo.reload.timeless_bars.first.timeless_bazs.first.val).to eq('y')
290+
expect(baz.updated_at).to eq(original)
291+
end
292+
293+
it 'still preserves the parent updated_at' do
294+
original = foo.updated_at
295+
foo.val = 'c'
296+
Timecop.freeze(Time.at(Time.now.to_i) + 2)
297+
foo.timeless { foo.save! }
298+
expect(TimelessFoo.find(foo.id).val).to eq('c')
299+
expect(foo.updated_at).to eq(original)
300+
end
301+
end
302+
303+
describe 'deprecation of the block-less form' do
304+
let(:document) { Dokument.new }
305+
306+
it 'warns when called on an instance without a block' do
307+
expect(Mongoid.logger).to receive(:warn).with(/timeless/).and_call_original
308+
document.timeless.save!
309+
end
310+
311+
it 'warns when called on the class without a block' do
312+
expect(Mongoid.logger).to receive(:warn).with(/timeless/).and_call_original
313+
Dokument.timeless.create!
314+
end
315+
316+
it 'does not warn when called with a block' do
317+
expect(Mongoid.logger).not_to receive(:warn)
318+
document.timeless { document.save! }
319+
end
320+
321+
it 'does not warn from the internal touch: false path' do
322+
document.save!
323+
document.title = 'changed'
324+
expect(Mongoid.logger).not_to receive(:warn).with(/timeless/)
325+
document.save!(touch: false)
326+
end
327+
end
138328
end

0 commit comments

Comments
 (0)