record-cache 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|