Skip to content

Commit 20d3251

Browse files
committed
[~] Fix cache freshness for time zones and mutable values
1 parent 43d1e4f commit 20d3251

3 files changed

Lines changed: 137 additions & 7 deletions

File tree

lib/mongoid/attributes.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,45 @@ def demongoized_cache
181181
end
182182
private :demongoized_cache
183183

184+
# Demongoize and cache a raw field value together with any metadata needed
185+
# to determine whether the cached value remains valid.
186+
#
187+
# Set and Range demongoize into objects that are distinct from their raw
188+
# Array or Hash values, so retain a deep copy to detect in-place raw-value
189+
# mutations. Time and DateTime demongoization depends on the configured
190+
# UTC behavior and the current thread's time zone, so retain that context.
191+
#
192+
# @param [ Hash ] cache The document's demongoized value cache.
193+
# @param [ String ] name The field name.
194+
# @param [ Object ] raw The raw field value.
195+
# @param [ Field ] field The field definition.
196+
# @param [ true | false ] track_raw_content Whether to snapshot raw content.
197+
# @param [ true | false ] track_time_zone Whether to retain time zone context.
198+
#
199+
# @return [ Object ] The demongoized field value.
200+
#
201+
# @api private
202+
def cache_demongoized_value(cache, name, raw, field, track_raw_content, track_time_zone)
203+
if track_time_zone
204+
use_utc = Mongoid::Config.use_utc?
205+
time_zone = use_utc ? nil : ::Time.zone
206+
end
207+
208+
demongoized = process_raw_attribute(name, raw, field)
209+
210+
cache[name] =
211+
if track_raw_content
212+
[ raw, demongoized, raw.__deep_copy__ ]
213+
elsif track_time_zone
214+
[ raw, demongoized, use_utc, time_zone ]
215+
else
216+
[ raw, demongoized ]
217+
end
218+
219+
demongoized
220+
end
221+
private :cache_demongoized_value
222+
184223
# Lazily allocate projector cache to avoid document initialization overhead.
185224
#
186225
# @return [ Concurrent::Map | nil ] The cache map when caching is enabled.

lib/mongoid/fields.rb

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,8 @@ def create_field_getter(name, meth, field)
703703
object_id_field = (name == :_id || name == "_id") && field.object_id_field?
704704
boolean_field = field_type == Mongoid::Boolean
705705
fast_raw_field = FAST_RAW_FIELD_TYPES.include?(field_type) || boolean_field
706+
track_raw_content = field_type == Set || field_type == Range
707+
track_time_zone = field_type == Time || field_type == DateTime
706708

707709
generated_methods.module_eval do
708710
re_define_method(meth) do
@@ -729,19 +731,25 @@ def create_field_getter(name, meth, field)
729731
cache = demongoized_cache
730732

731733
if value = cache[name]
732-
if value[0].equal?(raw)
734+
cache_valid = value[0].equal?(raw)
735+
736+
if cache_valid && track_raw_content
737+
cache_valid = value[2] == raw
738+
elsif cache_valid && track_time_zone
739+
use_utc = Mongoid::Config.use_utc?
740+
time_zone = use_utc ? nil : ::Time.zone
741+
cache_valid = value[2] == use_utc && value[3] == time_zone
742+
end
743+
744+
if cache_valid
733745
demongoized_value = value[1]
734746
attribute_will_change!(name_string) if track_resizable
735747
demongoized_value
736748
else
737-
demongoized = process_raw_attribute(name_string, raw, field)
738-
cache[name] = [raw, demongoized]
739-
demongoized
749+
cache_demongoized_value(cache, name, raw, field, track_raw_content, track_time_zone)
740750
end
741751
else
742-
demongoized = process_raw_attribute(name_string, raw, field)
743-
cache[name] = [raw, demongoized]
744-
demongoized
752+
cache_demongoized_value(cache, name, raw, field, track_raw_content, track_time_zone)
745753
end
746754
else
747755
# Caching disabled - use original behavior

spec/mongoid/fields/performance_spec.rb

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,20 @@
113113
stats = AllocationStats.trace { 10.times { band.decibels } }
114114
expect(stats.new_allocations.size).to eq(0)
115115
end
116+
117+
it 'achieves zero allocations for Set fields' do
118+
set_band_class = Class.new do
119+
include Mongoid::Document
120+
store_in collection: 'bands'
121+
field :genres, type: Set
122+
end
123+
stub_const('SetBand', set_band_class)
124+
125+
band = SetBand.new(genres: Set.new(%w[rock metal]))
126+
band.genres # warm up
127+
stats = AllocationStats.trace { 10.times { band.genres } }
128+
expect(stats.new_allocations.size).to eq(0)
129+
end
116130
end
117131

118132
context 'field access after setter' do
@@ -212,6 +226,52 @@
212226
end
213227
end
214228

229+
context 'timezone-dependent caching' do
230+
config_override :use_utc, false
231+
232+
let(:time_zone_class) do
233+
Class.new do
234+
include Mongoid::Document
235+
field :occurred_at, type: Time
236+
field :scheduled_at, type: DateTime
237+
end
238+
end
239+
240+
before do
241+
stub_const('TimeZoneCacheTest', time_zone_class)
242+
end
243+
244+
it 'refreshes a cached Time when Time.zone changes' do
245+
document = TimeZoneCacheTest.new(occurred_at: Time.utc(2024, 1, 1, 12))
246+
247+
utc = Time.use_zone('UTC') { document.occurred_at }
248+
pacific = Time.use_zone('Pacific Time (US & Canada)') { document.occurred_at }
249+
utc_again = Time.use_zone('UTC') { document.occurred_at }
250+
251+
expect(utc.time_zone.name).to eq('UTC')
252+
expect(utc.hour).to eq(12)
253+
expect(pacific.time_zone.name).to eq('Pacific Time (US & Canada)')
254+
expect(pacific.hour).to eq(4)
255+
expect(utc_again.time_zone.name).to eq('UTC')
256+
expect(utc_again.hour).to eq(12)
257+
end
258+
259+
it 'refreshes a cached DateTime when Time.zone changes' do
260+
document = TimeZoneCacheTest.new(scheduled_at: Time.utc(2024, 1, 1, 12))
261+
262+
utc = Time.use_zone('UTC') { document.scheduled_at }
263+
pacific = Time.use_zone('Pacific Time (US & Canada)') { document.scheduled_at }
264+
utc_again = Time.use_zone('UTC') { document.scheduled_at }
265+
266+
expect(utc.offset).to eq(0)
267+
expect(utc.hour).to eq(12)
268+
expect(pacific.offset).to eq(Rational(-8, 24))
269+
expect(pacific.hour).to eq(4)
270+
expect(utc_again.offset).to eq(0)
271+
expect(utc_again.hour).to eq(12)
272+
end
273+
end
274+
215275
context 'database persistence' do
216276
before { band.save! }
217277

@@ -328,6 +388,29 @@
328388
expect(band.tags).to eq({ 'genre' => 'jazz', 'era' => '80s' })
329389
end
330390

391+
it 'gets fresh Set and Range values after in-place raw attribute mutation' do
392+
raw_mutation_class = Class.new do
393+
include Mongoid::Document
394+
field :genres, type: Set
395+
field :decibels, type: Range
396+
end
397+
stub_const('RawMutationCacheTest', raw_mutation_class)
398+
399+
document = RawMutationCacheTest.new(
400+
genres: Set.new(%w[rock metal]),
401+
decibels: (50..120)
402+
)
403+
404+
expect(document.genres).to eq(Set.new(%w[rock metal]))
405+
expect(document.decibels).to eq(50..120)
406+
407+
document.attributes['genres'] << 'jazz'
408+
document.attributes['decibels']['max'] = 130
409+
410+
expect(document.genres).to eq(Set.new(%w[rock metal jazz]))
411+
expect(document.decibels).to eq(50..130)
412+
end
413+
331414
it 'clears cache when attribute is removed' do
332415
band.name # cache it
333416
expect(band.name).to eq('Test Band')

0 commit comments

Comments
 (0)