Skip to content

Commit 4f14e14

Browse files
authored
MONGOID-4680 implement cache_version for better Rails caching support (#5874)
* MONGOID-4680 implement cache_version for better Rails caching support * $count was introduced after 5.0; use $sum for better compatibility
1 parent 4a3796b commit 4f14e14

File tree

6 files changed

+449
-28
lines changed

6 files changed

+449
-28
lines changed

lib/mongoid/association/many.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ def unscoped
178178
criteria.unscoped
179179
end
180180

181+
# For compatibility with Rails' caching. Returns a string based on the
182+
# given timestamp, and includes the number of records in the relation
183+
# in the version.
184+
#
185+
# @param [ String | Symbol ] timestamp_column the timestamp column to
186+
# use when constructing the key.
187+
#
188+
# @return [ String ] the cache version string
189+
def cache_version(timestamp_column = :updated_at)
190+
@cache_version ||= {}
191+
@cache_version[timestamp_column] ||= compute_cache_version(timestamp_column)
192+
end
193+
181194
private
182195

183196
def _session
@@ -198,6 +211,54 @@ def find_or(method, attrs = {}, type = nil, &block)
198211
attrs[klass.discriminator_key] = type.discriminator_value if type
199212
where(attrs).first || send(method, attrs, type, &block)
200213
end
214+
215+
# Computes the cache version for the relation using the given
216+
# timestamp colum; see `#cache_version`.
217+
#
218+
# @param [ String | Symbol ] timestamp_column the timestamp column to
219+
# use when constructing the key.
220+
#
221+
# @return [ String ] the cache version string
222+
def compute_cache_version(timestamp_column)
223+
timestamp_column = timestamp_column.to_s
224+
225+
loaded = _target.respond_to?(:_loaded?) ?
226+
_target._loaded? : # has_many
227+
true # embeds_many
228+
229+
size, timestamp = loaded ?
230+
analyze_loaded_target(timestamp_column) :
231+
analyze_unloaded_target(timestamp_column)
232+
233+
if timestamp
234+
"#{size}-#{timestamp.utc.to_formatted_s(klass.cache_timestamp_format)}"
235+
else
236+
size.to_s
237+
end
238+
end
239+
240+
# Return a 2-tuple of the number of elements in the relation, and the
241+
# largest timestamp value.
242+
def analyze_loaded_target(timestamp_column)
243+
newest = _target.select { |elem| elem.respond_to?(timestamp_column) }
244+
.max { |a, b| a[timestamp_column] <=> b[timestamp_column] }
245+
[ _target.length, newest ? newest[timestamp_column] : nil ]
246+
end
247+
248+
# Returns a 2-tuple of the number of elements in the relation, and the
249+
# largest timestamp value. This will query the database to perform a
250+
# $sum and a $max.
251+
def analyze_unloaded_target(timestamp_column)
252+
pipeline = criteria
253+
.group(_id: nil,
254+
count: { '$sum' => 1 },
255+
latest: { '$max' => "$#{timestamp_column}" })
256+
.pipeline
257+
258+
result = klass.collection.aggregate(pipeline).to_a.first
259+
260+
result ? [ result["count"], result["latest"] ] : [ 0 ]
261+
end
201262
end
202263
end
203264
end

lib/mongoid/cacheable.rb

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,39 @@ module Cacheable
1515
# Print out the cache key. This will append different values on the
1616
# plural model name.
1717
#
18-
# If new_record? - will append /new
19-
# If not - will append /id-updated_at.to_formatted_s(cache_timestamp_format)
20-
# Without updated_at - will append /id
18+
# If new_record? - will append /new
19+
# Non-nil cache_version? - append /id
20+
# Non-nil updated_at - append /id-updated_at.to_formatted_s(cache_timestamp_format)
21+
# Otherwise - append /id
2122
#
2223
# This is usually called inside a cache() block
2324
#
2425
# @example Returns the cache key
2526
# document.cache_key
2627
#
27-
# @return [ String ] the string with or without updated_at
28+
# @return [ String ] the generated cache key
2829
def cache_key
2930
return "#{model_key}/new" if new_record?
31+
return "#{model_key}/#{_id}" if cache_version
3032
return "#{model_key}/#{_id}-#{updated_at.utc.to_formatted_s(cache_timestamp_format)}" if try(:updated_at)
3133
"#{model_key}/#{_id}"
3234
end
35+
36+
# Return the cache version for this model. By default, it returns the updated_at
37+
# field (if present) formatted as a string, or nil if the model has no
38+
# updated_at field. Models with different needs may override this method to
39+
# suit their desired behavior.
40+
#
41+
# @return [ String | nil ] the cache version value
42+
#
43+
# TODO: we can test this by using a MemoryStore, putting something in
44+
# it, then updating the timestamp on the record and trying to read the
45+
# value from the memory store. It shouldn't find it, because the version
46+
# has changed.
47+
def cache_version
48+
if has_attribute?('updated_at') && updated_at.present?
49+
updated_at.utc.to_formatted_s(cache_timestamp_format)
50+
end
51+
end
3352
end
3453
end

spec/integration/caching_spec.rb

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# frozen_string_literal: true
2+
3+
require 'spec_helper'
4+
5+
describe 'caching integration tests' do
6+
let(:store) { ActiveSupport::Cache::MemoryStore.new }
7+
8+
context 'without updated_at' do
9+
let(:model1) { Person.create }
10+
let(:model2) { Person.create }
11+
12+
before do
13+
store.write(model1, 'model1')
14+
store.write(model2, 'model2')
15+
end
16+
17+
it 'uses a unique key' do
18+
expect(store.read(model1)).to be == 'model1'
19+
expect(store.read(model2)).to be == 'model2'
20+
end
21+
22+
context 'when updating' do
23+
before do
24+
model1.update title: 'updated'
25+
model2.update title: 'updated'
26+
end
27+
28+
let(:reloaded_model1) { Person.find(model1.id) }
29+
let(:reloaded_model2) { Person.find(model2.id) }
30+
31+
it 'still finds the models' do
32+
expect(store.read(reloaded_model1)).to be == 'model1'
33+
expect(store.read(reloaded_model2)).to be == 'model2'
34+
end
35+
end
36+
end
37+
38+
context 'with updated_at' do
39+
let(:model1) { Dokument.create }
40+
let(:model2) { Dokument.create }
41+
42+
before do
43+
store.write(model1, 'model1')
44+
store.write(model2, 'model2')
45+
end
46+
47+
it 'uses a unique key' do
48+
expect(store.read(model1)).to be == 'model1'
49+
expect(store.read(model2)).to be == 'model2'
50+
end
51+
52+
context 'when updating' do
53+
before do
54+
model1.update title: 'updated'
55+
model2.update title: 'updated'
56+
end
57+
58+
let(:reloaded_model1) { Dokument.find(model1.id) }
59+
let(:reloaded_model2) { Dokument.find(model2.id) }
60+
61+
it 'does not find the models' do
62+
# because the update caused the cache_version to change
63+
expect(store.read(reloaded_model1)).to be_nil
64+
expect(store.read(reloaded_model2)).to be_nil
65+
end
66+
end
67+
end
68+
end

spec/mongoid/association/embedded/embeds_many/proxy_spec.rb

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4896,4 +4896,105 @@ class DNS::Record
48964896
expect(user.orders.map(&:sku).sort).to eq([ 1, 2 ])
48974897
end
48984898
end
4899+
4900+
describe '#cache_version' do
4901+
context 'when the model does not have an updated_at column' do
4902+
let(:root_model) { Quiz.create! }
4903+
let(:root) { Quiz.find(root_model.id) }
4904+
let(:pages) { root.pages }
4905+
4906+
let(:prepopulated_root) do
4907+
root_model.pages << Page.new(content: 'Page #1')
4908+
root_model.pages << Page.new(content: 'Page #2')
4909+
Quiz.find(root_model.id)
4910+
end
4911+
4912+
shared_examples_for 'a cache_version generator' do
4913+
it 'produces a trivial cache_version' do
4914+
expect(pages.cache_version).to be == "#{pages.length}"
4915+
end
4916+
end
4917+
4918+
context 'when the relation is empty' do
4919+
it_behaves_like 'a cache_version generator'
4920+
end
4921+
4922+
context 'when the relation is not empty' do
4923+
let(:root) { prepopulated_root }
4924+
4925+
it_behaves_like 'a cache_version generator'
4926+
end
4927+
end
4928+
4929+
context 'when the model has an updated_at column' do
4930+
let(:root_model) { Book.create(title: 'Root') }
4931+
let(:root) { Book.find(root_model.id) }
4932+
4933+
let(:cover) { root_model.covers.first }
4934+
let(:covers) { root.covers }
4935+
let(:original_cache_version) { root.covers.cache_version }
4936+
4937+
let(:prepopulated_root) do
4938+
root_model.covers << Cover.new(title: 'Cover #1')
4939+
root_model.covers << Cover.new(title: 'Cover #2')
4940+
Book.find(root_model.id)
4941+
end
4942+
4943+
shared_examples_for 'a cache_version generator' do
4944+
it 'produces a consistent cache_version' do
4945+
expect(covers.cache_version).not_to be_nil
4946+
expect(covers.cache_version).to be == covers.cache_version
4947+
end
4948+
end
4949+
4950+
context 'when the relation is empty' do
4951+
it_behaves_like 'a cache_version generator'
4952+
end
4953+
4954+
context 'when the relation is not empty' do
4955+
let(:root) { prepopulated_root }
4956+
it_behaves_like 'a cache_version generator'
4957+
end
4958+
4959+
context 'when an element is updated' do
4960+
let(:updated_cache_version) do
4961+
cover.update title: 'modified'
4962+
cover.book.save!
4963+
cover.book.reload.covers.cache_version
4964+
end
4965+
4966+
let(:root) { prepopulated_root }
4967+
4968+
it 'changes the cache_version' do
4969+
expect(original_cache_version).not_to be == updated_cache_version
4970+
end
4971+
end
4972+
4973+
context 'when an element is added' do
4974+
let(:updated_cache_version) do
4975+
root.covers << Cover.new(title: 'Another Cover')
4976+
root.reload.covers.cache_version
4977+
end
4978+
4979+
let(:root) { prepopulated_root }
4980+
4981+
it 'changes the cache_version' do
4982+
expect(original_cache_version).not_to be == updated_cache_version
4983+
end
4984+
end
4985+
4986+
context 'when an element is removed' do
4987+
let(:updated_cache_version) do
4988+
cover.destroy
4989+
root.reload.covers.cache_version
4990+
end
4991+
4992+
let(:root) { prepopulated_root }
4993+
4994+
it 'changes the cache_version' do
4995+
expect(original_cache_version).not_to be == updated_cache_version
4996+
end
4997+
end
4998+
end
4999+
end
48995000
end

0 commit comments

Comments
 (0)