record-cache 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/lib/record-cache.rb +1 -0
- data/lib/record_cache/active_record.rb +318 -0
- data/lib/record_cache/base.rb +136 -0
- data/lib/record_cache/dispatcher.rb +90 -0
- data/lib/record_cache/multi_read.rb +51 -0
- data/lib/record_cache/query.rb +85 -0
- data/lib/record_cache/statistics.rb +82 -0
- data/lib/record_cache/strategy/base.rb +154 -0
- data/lib/record_cache/strategy/id_cache.rb +93 -0
- data/lib/record_cache/strategy/index_cache.rb +122 -0
- data/lib/record_cache/strategy/request_cache.rb +49 -0
- data/lib/record_cache/test/resettable_version_store.rb +49 -0
- data/lib/record_cache/version.rb +5 -0
- data/lib/record_cache/version_store.rb +54 -0
- data/lib/record_cache.rb +11 -0
- data/spec/db/database.yml +6 -0
- data/spec/db/schema.rb +42 -0
- data/spec/db/seeds.rb +40 -0
- data/spec/initializers/record_cache.rb +14 -0
- data/spec/lib/dispatcher_spec.rb +86 -0
- data/spec/lib/multi_read_spec.rb +51 -0
- data/spec/lib/query_spec.rb +148 -0
- data/spec/lib/statistics_spec.rb +140 -0
- data/spec/lib/strategy/base_spec.rb +241 -0
- data/spec/lib/strategy/id_cache_spec.rb +168 -0
- data/spec/lib/strategy/index_cache_spec.rb +223 -0
- data/spec/lib/strategy/request_cache_spec.rb +85 -0
- data/spec/lib/version_store_spec.rb +104 -0
- data/spec/models/apple.rb +8 -0
- data/spec/models/banana.rb +8 -0
- data/spec/models/pear.rb +6 -0
- data/spec/models/person.rb +11 -0
- data/spec/models/store.rb +13 -0
- data/spec/spec_helper.rb +44 -0
- data/spec/support/after_commit.rb +71 -0
- data/spec/support/matchers/hit_cache_matcher.rb +53 -0
- data/spec/support/matchers/miss_cache_matcher.rb +53 -0
- data/spec/support/matchers/use_cache_matcher.rb +53 -0
- metadata +253 -0
@@ -0,0 +1,82 @@
|
|
1
|
+
module RecordCache
|
2
|
+
|
3
|
+
# Collect cache hit/miss statistics for each cache strategy
|
4
|
+
module Statistics
|
5
|
+
|
6
|
+
class << self
|
7
|
+
|
8
|
+
# returns +true+ if statistics need to be collected
|
9
|
+
def active?
|
10
|
+
!!@active
|
11
|
+
end
|
12
|
+
|
13
|
+
# start statistics collection
|
14
|
+
def start
|
15
|
+
@active = true
|
16
|
+
end
|
17
|
+
|
18
|
+
# stop statistics collection
|
19
|
+
def stop
|
20
|
+
@active = false
|
21
|
+
end
|
22
|
+
|
23
|
+
# toggle statistics collection
|
24
|
+
def toggle
|
25
|
+
@active = !@active
|
26
|
+
end
|
27
|
+
|
28
|
+
# reset all statistics
|
29
|
+
def reset!(base = nil)
|
30
|
+
stats = find(base).values
|
31
|
+
stats = stats.map(&:values).flatten unless base # flatten hash of hashes in case base was nil
|
32
|
+
stats.each{ |s| s.reset! }
|
33
|
+
end
|
34
|
+
|
35
|
+
# Retrieve the statistics for the given base and strategy_id
|
36
|
+
# Returns a hash {<stategy_id> => <statistics} for a model if no strategy is provided
|
37
|
+
# Returns a hash of hashes { <model_name> => {<stategy_id> => <statistics} } if no parameter is provided
|
38
|
+
def find(base = nil, strategy_id = nil)
|
39
|
+
stats = (@stats ||= {})
|
40
|
+
stats = (stats[base.name] ||= {}) if base
|
41
|
+
stats = (stats[strategy_id] ||= Counter.new) if strategy_id
|
42
|
+
stats
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
class Counter
|
47
|
+
attr_accessor :calls, :hits, :misses
|
48
|
+
|
49
|
+
def initialize
|
50
|
+
reset!
|
51
|
+
end
|
52
|
+
|
53
|
+
# add hit statatistics for the given cache strategy
|
54
|
+
# @param queried: nr of ids queried
|
55
|
+
# @param found: nr of records found in the cache
|
56
|
+
def add(queried, found)
|
57
|
+
@calls += 1
|
58
|
+
@hits += found
|
59
|
+
@misses += (queried - found)
|
60
|
+
end
|
61
|
+
|
62
|
+
def reset!
|
63
|
+
@hits = 0
|
64
|
+
@misses = 0
|
65
|
+
@calls = 0
|
66
|
+
end
|
67
|
+
|
68
|
+
def active?
|
69
|
+
RecordCache::Statistics.active?
|
70
|
+
end
|
71
|
+
|
72
|
+
def percentage
|
73
|
+
return 0.0 if @hits == 0
|
74
|
+
(@hits.to_f / (@hits + @misses)) * 100
|
75
|
+
end
|
76
|
+
|
77
|
+
def inspect
|
78
|
+
"#{percentage}% (#{@hits}/#{@hits + @misses})"
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
module RecordCache
|
2
|
+
module Strategy
|
3
|
+
class Base
|
4
|
+
CLASS_KEY = :c
|
5
|
+
ATTRIBUTES_KEY = :a
|
6
|
+
|
7
|
+
def initialize(base, strategy_id, record_store, options)
|
8
|
+
@base = base
|
9
|
+
@strategy_id = strategy_id
|
10
|
+
@record_store = record_store
|
11
|
+
@cache_key_prefix = "rc/#{options[:key] || @base.name}/".freeze
|
12
|
+
end
|
13
|
+
|
14
|
+
# Fetch all records and sort and filter locally
|
15
|
+
def fetch(query)
|
16
|
+
records = fetch_records(query)
|
17
|
+
filter!(records, query.wheres) if query.wheres.size > 0
|
18
|
+
sort!(records, query.sort_orders) if query.sorted?
|
19
|
+
records
|
20
|
+
end
|
21
|
+
|
22
|
+
# Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
|
23
|
+
def record_change(record, action)
|
24
|
+
raise NotImplementedError
|
25
|
+
end
|
26
|
+
|
27
|
+
# Can the cache retrieve the records based on this query?
|
28
|
+
def cacheable?(query)
|
29
|
+
raise NotImplementedError
|
30
|
+
end
|
31
|
+
|
32
|
+
# Handle invalidation call
|
33
|
+
def invalidate(id)
|
34
|
+
raise NotImplementedError
|
35
|
+
end
|
36
|
+
|
37
|
+
protected
|
38
|
+
|
39
|
+
def fetch_records(query)
|
40
|
+
raise NotImplementedError
|
41
|
+
end
|
42
|
+
|
43
|
+
# ------------------------- Utility methods ----------------------------
|
44
|
+
|
45
|
+
# retrieve the version store (unique store for the whole application)
|
46
|
+
def version_store
|
47
|
+
RecordCache::Base.version_store
|
48
|
+
end
|
49
|
+
|
50
|
+
# retrieve the record store (store for records for this cache strategy)
|
51
|
+
def record_store
|
52
|
+
@record_store
|
53
|
+
end
|
54
|
+
|
55
|
+
# find the statistics for this cache strategy
|
56
|
+
def statistics
|
57
|
+
@statistics ||= RecordCache::Statistics.find(@base, @strategy_id)
|
58
|
+
end
|
59
|
+
|
60
|
+
# retrieve the cache key for the given id, e.g. rc/person/14
|
61
|
+
def cache_key(id)
|
62
|
+
"#{@cache_key_prefix}#{id}".freeze
|
63
|
+
end
|
64
|
+
|
65
|
+
# retrieve the versioned record key, e.g. rc/person/14v1
|
66
|
+
def versioned_key(cache_key, version)
|
67
|
+
"#{cache_key}v#{version.to_s}".freeze
|
68
|
+
end
|
69
|
+
|
70
|
+
# serialize one record before adding it to the cache
|
71
|
+
# creates a shallow clone with a version and without associations
|
72
|
+
def serialize(record)
|
73
|
+
{CLASS_KEY => record.class.name,
|
74
|
+
ATTRIBUTES_KEY => record.instance_variable_get(:@attributes)}.freeze
|
75
|
+
end
|
76
|
+
|
77
|
+
# deserialize a cached record
|
78
|
+
def deserialize(serialized)
|
79
|
+
record = serialized[CLASS_KEY].constantize.new
|
80
|
+
attributes = serialized[ATTRIBUTES_KEY]
|
81
|
+
record.instance_variable_set(:@attributes, Hash[attributes])
|
82
|
+
record.instance_variable_set(:@new_record, false)
|
83
|
+
record.instance_variable_set(:@changed_attributes, {})
|
84
|
+
record.instance_variable_set(:@previously_changed, {})
|
85
|
+
record
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
# Filter the cached records in memory
|
91
|
+
# only simple x = y or x IN (a,b,c) can be handled
|
92
|
+
def filter!(records, wheres)
|
93
|
+
wheres.each_pair do |attr, value|
|
94
|
+
if value.is_a?(Array)
|
95
|
+
records.reject! { |record| !value.include?(record.send(attr)) }
|
96
|
+
else
|
97
|
+
records.reject! { |record| record.send(attr) != value }
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Sort the cached records in memory
|
103
|
+
def sort!(records, sort_orders)
|
104
|
+
records.sort!(&sort_proc(sort_orders))
|
105
|
+
Collator.clear
|
106
|
+
records
|
107
|
+
end
|
108
|
+
|
109
|
+
# Retrieve the Proc based on the order by attributes
|
110
|
+
# Note: Case insensitive sorting with collation is used for Strings
|
111
|
+
def sort_proc(sort_orders)
|
112
|
+
# [['(COLLATER.collate(x.name) || NIL_COMES_FIRST)', 'COLLATER.collate(y.name)'], ['(y.updated_at || NIL_COMES_FIRST)', 'x.updated_at']]
|
113
|
+
sort = sort_orders.map do |attr, asc|
|
114
|
+
lr = ["x.", "y."]
|
115
|
+
lr.reverse! unless asc
|
116
|
+
lr.each{ |s| s << attr }
|
117
|
+
lr.each{ |s| s.replace("Collator.collate(#{s})") } if @base.columns_hash[attr].type == :string
|
118
|
+
lr[0].replace("(#{lr[0]} || NIL_COMES_FIRST)")
|
119
|
+
lr
|
120
|
+
end
|
121
|
+
# ['[(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)]', '[COLLATER.collate(y.name), x.updated_at]']
|
122
|
+
sort = sort.transpose.map{|s| s.size == 1 ? s.first : "[#{s.join(',')}]"}
|
123
|
+
# Proc.new{ |x,y| { ([(COLLATER.collate(x.name) || NIL_COMES_FIRST), (y.updated_at || NIL_COMES_FIRST)] <=> [COLLATER.collate(y.name), x.updated_at]) || 1 }
|
124
|
+
eval("Proc.new{ |x,y| (#{sort[0]} <=> #{sort[1]}) || 1 }")
|
125
|
+
end
|
126
|
+
|
127
|
+
# If +x.nil?+ this class will return -1 for +x <=> y+
|
128
|
+
NIL_COMES_FIRST = ((class NilComesFirst; def <=>(y); -1; end; end); NilComesFirst.new)
|
129
|
+
|
130
|
+
# StringCollator uses the Rails transliterate method for collation
|
131
|
+
module Collator
|
132
|
+
@collated = []
|
133
|
+
|
134
|
+
def self.clear
|
135
|
+
@collated.each { |string| string.send(:remove_instance_variable, :@rc_collated) }
|
136
|
+
@collated.clear
|
137
|
+
end
|
138
|
+
|
139
|
+
def self.collate(string)
|
140
|
+
collated = string.instance_variable_get(:@rc_collated)
|
141
|
+
return collated if collated
|
142
|
+
normalized = ActiveSupport::Multibyte::Unicode.normalize(ActiveSupport::Multibyte::Unicode.tidy_bytes(string), :c).mb_chars
|
143
|
+
collated = I18n.transliterate(normalized).downcase.mb_chars
|
144
|
+
# transliterate will replace ignored/unknown chars with ? the following line replaces ? with the original character
|
145
|
+
collated.chars.each_with_index{ |c, i| collated[i] = normalized[i] if c == '?' } if collated.index('?')
|
146
|
+
# puts "collation: #{string} => #{collated.to_s}"
|
147
|
+
string.instance_variable_set(:@rc_collated, collated)
|
148
|
+
@collated << string
|
149
|
+
collated
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module RecordCache
|
2
|
+
module Strategy
|
3
|
+
class IdCache < Base
|
4
|
+
|
5
|
+
# Can the cache retrieve the records based on this query?
|
6
|
+
def cacheable?(query)
|
7
|
+
ids = query.where_ids(:id)
|
8
|
+
ids && (query.limit.nil? || (query.limit == 1 && ids.size == 1))
|
9
|
+
end
|
10
|
+
|
11
|
+
# Update the version store and the record store
|
12
|
+
def record_change(record, action)
|
13
|
+
key = cache_key(record.id)
|
14
|
+
if action == :destroy
|
15
|
+
version_store.delete(key)
|
16
|
+
else
|
17
|
+
# update the version store and add the record to the cache
|
18
|
+
new_version = version_store.increment(key)
|
19
|
+
record_store.write(versioned_key(key, new_version), serialize(record))
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Handle invalidation call
|
24
|
+
def invalidate(id)
|
25
|
+
version_store.delete(cache_key(id))
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
|
30
|
+
# retrieve the record(s) with the given id(s) as an array
|
31
|
+
def fetch_records(query)
|
32
|
+
ids = query.where_ids(:id)
|
33
|
+
query.wheres.delete(:id) # make sure CacheCase.filter! does not see this where anymore
|
34
|
+
id_to_key_map = ids.inject({}){|h,id| h[id] = cache_key(id); h }
|
35
|
+
# retrieve the current version of the records
|
36
|
+
current_versions = version_store.current_multi(id_to_key_map)
|
37
|
+
# get the keys for the records for which a current version was found
|
38
|
+
id_to_version_key_map = Hash[id_to_key_map.map{ |id, key| current_versions[id] ? [id, versioned_key(key, current_versions[id])] : nil }]
|
39
|
+
# retrieve the records from the cache
|
40
|
+
records = id_to_version_key_map.size > 0 ? from_cache(id_to_version_key_map) : []
|
41
|
+
# query the records with missing ids
|
42
|
+
id_to_key_map.except!(*records.map(&:id))
|
43
|
+
# logging (only in debug mode!) and statistics
|
44
|
+
log_id_cache_hit(ids, id_to_key_map.keys) if RecordCache::Base.logger.debug?
|
45
|
+
statistics.add(ids.size, records.size) if statistics.active?
|
46
|
+
# retrieve records from DB in case there are some missing ids
|
47
|
+
records += from_db(id_to_key_map, id_to_version_key_map) if id_to_key_map.size > 0
|
48
|
+
# return the array
|
49
|
+
records
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# ---------------------------- Querying ------------------------------------
|
55
|
+
|
56
|
+
# retrieve the records from the cache with the given keys
|
57
|
+
def from_cache(id_to_versioned_key_map)
|
58
|
+
records = record_store.read_multi(*(id_to_versioned_key_map.values)).values.compact
|
59
|
+
records.map{ |record| deserialize(record) }
|
60
|
+
end
|
61
|
+
|
62
|
+
# retrieve the records with the given ids from the database
|
63
|
+
def from_db(id_to_key_map, id_to_version_key_map)
|
64
|
+
RecordCache::Base.without_record_cache do
|
65
|
+
# retrieve the records from the database
|
66
|
+
records = @base.where(:id => id_to_key_map.keys).to_a
|
67
|
+
records.each do |record|
|
68
|
+
versioned_key = id_to_version_key_map[record.id]
|
69
|
+
unless versioned_key
|
70
|
+
# renew the key in the version store in case it was missing
|
71
|
+
key = id_to_key_map[record.id]
|
72
|
+
versioned_key = versioned_key(key, version_store.renew(key))
|
73
|
+
end
|
74
|
+
# store the record based on the versioned key
|
75
|
+
record_store.write(versioned_key, serialize(record))
|
76
|
+
end
|
77
|
+
records
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# ------------------------- Utility methods ----------------------------
|
82
|
+
|
83
|
+
# log cache hit/miss to debug log
|
84
|
+
def log_id_cache_hit(ids, missing_ids)
|
85
|
+
hit = missing_ids.empty? ? "hit" : ids.size == missing_ids.size ? "miss" : "partial hit"
|
86
|
+
missing = missing_ids.empty? || ids.size == missing_ids.size ? "" : ": missing #{missing_ids.inspect}"
|
87
|
+
msg = "IdCache #{hit} for ids #{ids.size == 1 ? ids.first : ids.inspect}#{missing}"
|
88
|
+
RecordCache::Base.logger.debug(msg)
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,122 @@
|
|
1
|
+
module RecordCache
|
2
|
+
module Strategy
|
3
|
+
class IndexCache < Base
|
4
|
+
|
5
|
+
def initialize(base, strategy_id, record_store, options)
|
6
|
+
super
|
7
|
+
@index = options[:index]
|
8
|
+
# check the index
|
9
|
+
type = @base.columns_hash[@index.to_s].try(:type)
|
10
|
+
raise "No column found for index '#{@index}' on #{@base.name}." unless type
|
11
|
+
raise "Incorrect type (expected integer, found #{type}) for index '#{@index}' on #{@base.name}." unless type == :integer
|
12
|
+
@index_cache_key_prefix = cache_key(@index) # "/rc/<model>/<index>"
|
13
|
+
end
|
14
|
+
|
15
|
+
# Can the cache retrieve the records based on this query?
|
16
|
+
def cacheable?(query)
|
17
|
+
query.where_id(@index) && query.limit.nil?
|
18
|
+
end
|
19
|
+
|
20
|
+
# Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
|
21
|
+
def record_change(record, action)
|
22
|
+
if action == :destroy
|
23
|
+
remove_from_index(record.send(@index), record.id)
|
24
|
+
elsif action == :create
|
25
|
+
add_to_index(record.send(@index), record.id)
|
26
|
+
else
|
27
|
+
index_change = record.previous_changes[@index.to_s]
|
28
|
+
return unless index_change
|
29
|
+
remove_from_index(index_change[0], record.id)
|
30
|
+
add_to_index(index_change[1], record.id)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Explicitly invalidate the record cache for the given value
|
35
|
+
def invalidate(value)
|
36
|
+
version_store.increment(index_cache_key(value))
|
37
|
+
end
|
38
|
+
|
39
|
+
protected
|
40
|
+
|
41
|
+
# retrieve the record(s) based on the given query
|
42
|
+
def fetch_records(query)
|
43
|
+
value = query.where_id(@index)
|
44
|
+
# make sure CacheCase.filter! does not see this where clause anymore
|
45
|
+
query.wheres.delete(@index)
|
46
|
+
# retrieve the cache key for this index and value
|
47
|
+
key = index_cache_key(value)
|
48
|
+
# retrieve the current version of the ids list
|
49
|
+
current_version = version_store.current(key)
|
50
|
+
# create the versioned key, renew the version in case it was missing in the version store
|
51
|
+
versioned_key = versioned_key(key, current_version || version_store.renew(key))
|
52
|
+
# retrieve the ids from the local cache based on the current version from the version store
|
53
|
+
ids = current_version ? fetch_ids_from_cache(versioned_key) : nil
|
54
|
+
# logging (only in debug mode!) and statistics
|
55
|
+
log_cache_hit(versioned_key, ids) if RecordCache::Base.logger.debug?
|
56
|
+
statistics.add(1, ids ? 1 : 0) if statistics.active?
|
57
|
+
# retrieve the ids from the DB if the result was not fresh
|
58
|
+
ids = fetch_ids_from_db(versioned_key, value) unless ids
|
59
|
+
# use the IdCache to retrieve the records based on the ids
|
60
|
+
@base.record_cache[:id].send(:fetch_records, ::RecordCache::Query.new({:id => ids}))
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
# ---------------------------- Querying ------------------------------------
|
66
|
+
|
67
|
+
# key to retrieve the ids for a given value
|
68
|
+
def index_cache_key(value)
|
69
|
+
"#{@index_cache_key_prefix}=#{value}"
|
70
|
+
end
|
71
|
+
|
72
|
+
# Retrieve the ids from the local cache
|
73
|
+
def fetch_ids_from_cache(versioned_key)
|
74
|
+
record_store.read(versioned_key)
|
75
|
+
end
|
76
|
+
|
77
|
+
# retrieve the ids from the database and update the local cache
|
78
|
+
def fetch_ids_from_db(versioned_key, value)
|
79
|
+
RecordCache::Base.without_record_cache do
|
80
|
+
# go straight to SQL result for optimal performance
|
81
|
+
sql = @base.select('id').where(@index => value).to_sql
|
82
|
+
ids = []; @base.connection.execute(sql).each{ |row| ids << (row.is_a?(Hash) ? row['id'] : row.first).to_i }
|
83
|
+
record_store.write(versioned_key, ids)
|
84
|
+
ids
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# ---------------------------- Local Record Changes ---------------------------------
|
89
|
+
|
90
|
+
# add one record(id) to the index with the given value
|
91
|
+
def add_to_index(value, id)
|
92
|
+
increment_version(value.to_i) { |ids| ids << id } if value
|
93
|
+
end
|
94
|
+
|
95
|
+
# remove one record(id) from the index with the given value
|
96
|
+
def remove_from_index(value, id)
|
97
|
+
increment_version(value.to_i) { |ids| ids.delete(id) } if value
|
98
|
+
end
|
99
|
+
|
100
|
+
# increment the version store and update the local store
|
101
|
+
def increment_version(value, &block)
|
102
|
+
# retrieve local version and increment version store
|
103
|
+
key = index_cache_key(value)
|
104
|
+
version = version_store.increment(key)
|
105
|
+
# try to update the ids list based on the last version
|
106
|
+
ids = fetch_ids_from_cache(versioned_key(key, version - 1))
|
107
|
+
if ids
|
108
|
+
ids = Array.new(ids)
|
109
|
+
yield ids
|
110
|
+
record_store.write(versioned_key(key, version), ids)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# ------------------------- Utility methods ----------------------------
|
115
|
+
|
116
|
+
# log cache hit/miss to debug log
|
117
|
+
def log_cache_hit(key, ids)
|
118
|
+
RecordCache::Base.logger.debug("IndexCache #{ids ? 'hit' : 'miss'} for #{key}: found #{ids ? ids.size : 'no'} ids")
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# Remembers the queries performed during a single Request.
|
2
|
+
# If the same query is requested again the result is provided straight from local memory.
|
3
|
+
#
|
4
|
+
# Records are invalidated per model-klass, when any record is created, updated or destroyed.
|
5
|
+
module RecordCache
|
6
|
+
module Strategy
|
7
|
+
|
8
|
+
class RequestCache < Base
|
9
|
+
@@request_store = {}
|
10
|
+
|
11
|
+
# call before each request: in application_controller.rb
|
12
|
+
# before_filter { |c| RecordCache::Strategy::RequestCache.clear }
|
13
|
+
def self.clear
|
14
|
+
@@request_store.clear
|
15
|
+
end
|
16
|
+
|
17
|
+
# Handle record change
|
18
|
+
def record_change(record, action)
|
19
|
+
@@request_store.delete(@base.name)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Handle invalidation call
|
23
|
+
def invalidate(value)
|
24
|
+
@@request_store.delete(@base.name)
|
25
|
+
end
|
26
|
+
|
27
|
+
# return the records from the request cache, execute block in case
|
28
|
+
# this is the first time this query is performed during this request
|
29
|
+
def fetch(query, &block)
|
30
|
+
klass_store = (@@request_store[@base.name] ||= {})
|
31
|
+
key = query.cache_key
|
32
|
+
# logging (only in debug mode!) and statistics
|
33
|
+
log_cache_hit(key, klass_store.key?(key)) if RecordCache::Base.logger.debug?
|
34
|
+
statistics.add(1, klass_store.key?(key) ? 1 : 0) if statistics.active?
|
35
|
+
klass_store[key] ||= yield
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# ------------------------- Utility methods ----------------------------
|
41
|
+
|
42
|
+
# log cache hit/miss to debug log
|
43
|
+
def log_cache_hit(key, hit)
|
44
|
+
RecordCache::Base.logger.debug("RequestCache #{hit ? 'hit' : 'miss'} for #{key}")
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# Make sure the version store can be reset to it's starting point after each test
|
2
|
+
# Usage:
|
3
|
+
# require 'record_cache/test/resettable_version_store'
|
4
|
+
# after(:each) { RecordCache::Base.version_store.reset! }
|
5
|
+
module RecordCache
|
6
|
+
module Test
|
7
|
+
|
8
|
+
module ResettableVersionStore
|
9
|
+
|
10
|
+
def self.included(base)
|
11
|
+
base.extend ClassMethods
|
12
|
+
base.send(:include, InstanceMethods)
|
13
|
+
base.instance_eval do
|
14
|
+
alias_method_chain :increment, :reset
|
15
|
+
alias_method_chain :renew, :reset
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
end
|
21
|
+
|
22
|
+
module InstanceMethods
|
23
|
+
|
24
|
+
def increment_with_reset(key)
|
25
|
+
updated_version_keys << key
|
26
|
+
increment_without_reset(key)
|
27
|
+
end
|
28
|
+
|
29
|
+
def renew_with_reset(key)
|
30
|
+
updated_version_keys << key
|
31
|
+
renew_without_reset(key)
|
32
|
+
end
|
33
|
+
|
34
|
+
def reset!
|
35
|
+
RecordCache::Strategy::RequestCache.clear
|
36
|
+
updated_version_keys.each { |key| delete(key) }
|
37
|
+
updated_version_keys.clear
|
38
|
+
end
|
39
|
+
|
40
|
+
def updated_version_keys
|
41
|
+
@updated_version_keys ||= []
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
RecordCache::VersionStore.send(:include, RecordCache::Test::ResettableVersionStore)
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module RecordCache
|
2
|
+
class VersionStore
|
3
|
+
attr_accessor :store
|
4
|
+
|
5
|
+
def initialize(store)
|
6
|
+
raise "Must be an ActiveSupport::Cache::Store" unless store.is_a?(ActiveSupport::Cache::Store)
|
7
|
+
@store = store
|
8
|
+
end
|
9
|
+
|
10
|
+
# Retrieve the current versions for the given key
|
11
|
+
# @return nil in case the key is not known in the version store
|
12
|
+
def current(key)
|
13
|
+
@store.read(key)
|
14
|
+
end
|
15
|
+
|
16
|
+
# Retrieve the current versions for the given keys
|
17
|
+
# @param id_key_map is a map with {id => cache_key}
|
18
|
+
# @return a map with {id => current_version}
|
19
|
+
# version nil for all keys unknown to the version store
|
20
|
+
def current_multi(id_key_map)
|
21
|
+
current_versions = @store.read_multi(*(id_key_map.values))
|
22
|
+
Hash[id_key_map.map{ |id, key| [id, current_versions[key]] }]
|
23
|
+
end
|
24
|
+
|
25
|
+
# In case the version store did not have a key anymore, call this methods
|
26
|
+
# to reset the key with a unique new key
|
27
|
+
def renew(key)
|
28
|
+
new_version = (Time.current.to_f * 10000).to_i
|
29
|
+
@store.write(key, new_version)
|
30
|
+
RecordCache::Base.logger.debug("Version Store: renew #{key}: nil => #{new_version}") if RecordCache::Base.logger.debug?
|
31
|
+
new_version
|
32
|
+
end
|
33
|
+
|
34
|
+
# Increment the current version for the given key, in case of record updates
|
35
|
+
def increment(key)
|
36
|
+
version = @store.increment(key, 1)
|
37
|
+
# renew key in case the version store already purged the key
|
38
|
+
if version.nil? || version == 1
|
39
|
+
version = renew(key)
|
40
|
+
else
|
41
|
+
RecordCache::Base.logger.debug("Version Store: incremented #{key}: #{version - 1} => #{version}") if RecordCache::Base.logger.debug?
|
42
|
+
end
|
43
|
+
version
|
44
|
+
end
|
45
|
+
|
46
|
+
# Delete key from the version store, in case the record(s) are destroyed
|
47
|
+
def delete(key)
|
48
|
+
deleted = @store.delete(key)
|
49
|
+
RecordCache::Base.logger.debug("Version Store: deleted #{key}") if RecordCache::Base.logger.debug?
|
50
|
+
deleted
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
end
|
data/lib/record_cache.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
# Record Cache shared files
|
2
|
+
["query", "version_store", "multi_read",
|
3
|
+
"strategy/base", "strategy/id_cache", "strategy/index_cache", "strategy/request_cache",
|
4
|
+
"statistics", "dispatcher", "base"].each do |file|
|
5
|
+
require File.dirname(__FILE__) + "/record_cache/#{file}.rb"
|
6
|
+
end
|
7
|
+
|
8
|
+
# Support for Active Record
|
9
|
+
require 'active_record'
|
10
|
+
ActiveRecord::Base.send(:include, RecordCache::Base)
|
11
|
+
require File.dirname(__FILE__) + "/record_cache/active_record.rb"
|
data/spec/db/schema.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
ActiveRecord::Schema.define :version => 0 do
|
2
|
+
|
3
|
+
create_table :people, :force => true do |t|
|
4
|
+
t.integer :id
|
5
|
+
t.string :name
|
6
|
+
t.date :birthday
|
7
|
+
t.float :height
|
8
|
+
end
|
9
|
+
|
10
|
+
create_table :stores, :force => true do |t|
|
11
|
+
t.integer :id
|
12
|
+
t.string :name
|
13
|
+
t.integer :owner_id
|
14
|
+
end
|
15
|
+
|
16
|
+
create_table :people_stores, :id => false, :force => true do |t|
|
17
|
+
t.integer :person_id
|
18
|
+
t.string :store_id
|
19
|
+
end
|
20
|
+
|
21
|
+
create_table :apples, :force => true do |t|
|
22
|
+
t.integer :id
|
23
|
+
t.string :name
|
24
|
+
t.integer :store_id
|
25
|
+
t.integer :person_id
|
26
|
+
end
|
27
|
+
|
28
|
+
create_table :bananas, :force => true do |t|
|
29
|
+
t.integer :id
|
30
|
+
t.string :name
|
31
|
+
t.integer :store_id
|
32
|
+
t.integer :person_id
|
33
|
+
end
|
34
|
+
|
35
|
+
create_table :pears, :force => true do |t|
|
36
|
+
t.integer :id
|
37
|
+
t.string :name
|
38
|
+
t.integer :store_id
|
39
|
+
t.integer :person_id
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|