record-cache 0.1.2 → 0.1.3

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.
Files changed (52) hide show
  1. checksums.yaml +15 -0
  2. data/lib/record_cache.rb +2 -1
  3. data/lib/record_cache/base.rb +63 -22
  4. data/lib/record_cache/datastore/active_record.rb +5 -3
  5. data/lib/record_cache/datastore/active_record_30.rb +95 -38
  6. data/lib/record_cache/datastore/active_record_31.rb +157 -54
  7. data/lib/record_cache/datastore/active_record_32.rb +444 -0
  8. data/lib/record_cache/dispatcher.rb +47 -47
  9. data/lib/record_cache/multi_read.rb +14 -1
  10. data/lib/record_cache/query.rb +36 -25
  11. data/lib/record_cache/statistics.rb +5 -5
  12. data/lib/record_cache/strategy/base.rb +49 -19
  13. data/lib/record_cache/strategy/full_table_cache.rb +81 -0
  14. data/lib/record_cache/strategy/index_cache.rb +38 -36
  15. data/lib/record_cache/strategy/unique_index_cache.rb +130 -0
  16. data/lib/record_cache/strategy/util.rb +12 -12
  17. data/lib/record_cache/test/resettable_version_store.rb +2 -9
  18. data/lib/record_cache/version.rb +1 -1
  19. data/lib/record_cache/version_store.rb +23 -16
  20. data/spec/db/schema.rb +12 -0
  21. data/spec/db/seeds.rb +10 -0
  22. data/spec/lib/active_record/visitor_spec.rb +22 -0
  23. data/spec/lib/base_spec.rb +21 -0
  24. data/spec/lib/dispatcher_spec.rb +24 -46
  25. data/spec/lib/multi_read_spec.rb +6 -6
  26. data/spec/lib/query_spec.rb +43 -43
  27. data/spec/lib/statistics_spec.rb +28 -28
  28. data/spec/lib/strategy/base_spec.rb +98 -87
  29. data/spec/lib/strategy/full_table_cache_spec.rb +68 -0
  30. data/spec/lib/strategy/index_cache_spec.rb +112 -69
  31. data/spec/lib/strategy/query_cache_spec.rb +83 -0
  32. data/spec/lib/strategy/unique_index_on_id_cache_spec.rb +317 -0
  33. data/spec/lib/strategy/unique_index_on_string_cache_spec.rb +168 -0
  34. data/spec/lib/strategy/util_spec.rb +67 -49
  35. data/spec/lib/version_store_spec.rb +22 -41
  36. data/spec/models/address.rb +9 -0
  37. data/spec/models/apple.rb +1 -1
  38. data/spec/models/banana.rb +21 -2
  39. data/spec/models/language.rb +5 -0
  40. data/spec/models/person.rb +1 -1
  41. data/spec/models/store.rb +2 -1
  42. data/spec/spec_helper.rb +7 -4
  43. data/spec/support/after_commit.rb +2 -0
  44. data/spec/support/matchers/hit_cache_matcher.rb +10 -6
  45. data/spec/support/matchers/log.rb +45 -0
  46. data/spec/support/matchers/miss_cache_matcher.rb +10 -6
  47. data/spec/support/matchers/use_cache_matcher.rb +10 -6
  48. metadata +156 -161
  49. data/lib/record_cache/strategy/id_cache.rb +0 -93
  50. data/lib/record_cache/strategy/request_cache.rb +0 -49
  51. data/spec/lib/strategy/id_cache_spec.rb +0 -168
  52. data/spec/lib/strategy/request_cache_spec.rb +0 -85
@@ -6,6 +6,8 @@
6
6
  #
7
7
  # Important: Because of a bug in Dalli, read_multi is quite slow on some machines.
8
8
  # @see https://github.com/mperham/dalli/issues/106
9
+ require "set"
10
+
9
11
  module RecordCache
10
12
  module MultiRead
11
13
  @tested = Set.new
@@ -48,4 +50,15 @@ module RecordCache
48
50
  end
49
51
  end
50
52
  end
51
- end
53
+ end
54
+
55
+ # Exposes Dalli's +multi+ functionality in ActiveSupport::Cache implementation of Dalli
56
+ module ActiveSupport
57
+ module Cache
58
+ class DalliStore
59
+ def multi(&block)
60
+ dalli.multi(&block)
61
+ end
62
+ end
63
+ end
64
+ end if defined?(::ActiveSupport::Cache::DalliStore)
@@ -8,7 +8,7 @@ module RecordCache
8
8
  @wheres = equality || {}
9
9
  @sort_orders = []
10
10
  @limit = nil
11
- @where_ids = {}
11
+ @where_values = {}
12
12
  end
13
13
 
14
14
  # Set equality of an attribute (usually found in where clause)
@@ -16,19 +16,23 @@ module RecordCache
16
16
  @wheres[attribute.to_sym] = values if attribute
17
17
  end
18
18
 
19
- # Retrieve the ids (array of positive integers) for the given attribute from the where statements
19
+ # Retrieve the values for the given attribute from the where statements
20
20
  # Returns nil if no the attribute is not present
21
- def where_ids(attribute)
22
- return @where_ids[attribute] if @where_ids.key?(attribute)
23
- @where_ids[attribute] ||= array_of_positive_integers(@wheres[attribute])
21
+ # @param attribute: the attribute name
22
+ # @param type: the type to be retrieved, :integer or :string (defaults to :integer)
23
+ def where_values(attribute, type = :integer)
24
+ return @where_values[attribute] if @where_values.key?(attribute)
25
+ @where_values[attribute] ||= array_of_values(@wheres[attribute], type)
24
26
  end
25
27
 
26
- # Retrieve the single id (positive integer) for the given attribute from the where statements
27
- # Returns nil if no the attribute is not present, or if it contains an array
28
- def where_id(attribute)
29
- ids = where_ids(attribute)
30
- return nil unless ids && ids.size == 1
31
- ids.first
28
+ # Retrieve the single value for the given attribute from the where statements
29
+ # Returns nil if the attribute is not present, or if it contains multiple values
30
+ # @param attribute: the attribute name
31
+ # @param type: the type to be retrieved, :integer or :string (defaults to :integer)
32
+ def where_value(attribute, type = :integer)
33
+ values = where_values(attribute, type)
34
+ return nil unless values && values.size == 1
35
+ values.first
32
36
  end
33
37
 
34
38
  # Add a sort order to the query
@@ -44,17 +48,18 @@ module RecordCache
44
48
  @limit = limit.to_i
45
49
  end
46
50
 
47
- # retrieve a unique key for this Query (used in RequestCache)
51
+ # DEPRECATED: retrieve a unique key for this Query (used in RequestCache)
48
52
  def cache_key
49
53
  @cache_key ||= generate_key
50
54
  end
51
55
 
56
+ # DEPRECATED
52
57
  def to_s
53
58
  s = "SELECT "
54
59
  s << @wheres.map{|k,v| "#{k} = #{v.inspect}"}.join(" AND ")
55
- if @sort_orders.size > 0
56
- order_by = @sort_orders.map{|attr,asc| "#{attr} #{asc ? 'ASC' : 'DESC'}"}.join(', ')
57
- s << " ORDER_BY #{order_by}"
60
+ if sorted?
61
+ order_by_clause = @sort_orders.map{|attr,asc| "#{attr} #{asc ? 'ASC' : 'DESC'}"}.join(', ')
62
+ s << " ORDER_BY #{order_by_clause}"
58
63
  end
59
64
  s << " LIMIT #{@limit}" if @limit
60
65
  s
@@ -63,20 +68,26 @@ module RecordCache
63
68
  private
64
69
 
65
70
  def generate_key
66
- key = @wheres.map{|k,v| "#{k}=#{v.inspect}"}.join("&")
67
- if @sort_orders
68
- order_by = @sort_orders.map{|attr,asc| "#{attr}=#{asc ? 'A' : 'D'}"}.join('-')
69
- key << ".#{order_by}"
71
+ key = ""
72
+ key << @limit.to_s if @limit
73
+ key << @sort_orders.map{|attr,asc| "#{asc ? '+' : '-'}#{attr}"}.join if sorted?
74
+ if @wheres.any?
75
+ key << "?"
76
+ key << @wheres.map{|k,v| "#{k}=#{v.inspect}"}.join("&")
70
77
  end
71
- key << "L#{@limit}" if @limit
72
78
  key
73
79
  end
74
-
75
- def array_of_positive_integers(values)
80
+
81
+ def array_of_values(values, type)
76
82
  return nil unless values
77
- values = [values] unless values.is_a?(Array)
78
- values = values.map{|value| value.to_i} unless values.first.is_a?(Fixnum)
79
- return nil unless values.all?{ |value| value > 0 } # all values must be positive integers
83
+ values = values.is_a?(Array) ? values.dup : [values]
84
+ values.compact!
85
+ if type == :integer
86
+ values = values.map{|value| value.to_i} unless values.first.is_a?(Fixnum)
87
+ return nil unless values.all?{ |value| value && value > 0 } # all values must be positive integers
88
+ elsif type == :string
89
+ values = values.map{|value| value.to_s} unless values.first.is_a?(String)
90
+ end
80
91
  values
81
92
  end
82
93
 
@@ -32,13 +32,13 @@ module RecordCache
32
32
  stats.each{ |s| s.reset! }
33
33
  end
34
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)
35
+ # Retrieve the statistics for the given base and attribute
36
+ # Returns a hash {<attribute> => <statistics} for a model if no strategy is provided
37
+ # Returns a hash of hashes { <model_name> => {<attribute> => <statistics} } if no parameter is provided
38
+ def find(base = nil, attribute = nil)
39
39
  stats = (@stats ||= {})
40
40
  stats = (stats[base.name] ||= {}) if base
41
- stats = (stats[strategy_id] ||= Counter.new) if strategy_id
41
+ stats = (stats[attribute] ||= Counter.new) if attribute
42
42
  stats
43
43
  end
44
44
  end
@@ -2,11 +2,23 @@ module RecordCache
2
2
  module Strategy
3
3
  class Base
4
4
 
5
- def initialize(base, strategy_id, record_store, options)
5
+ # Parse the options and return (an array of) instances of this strategy.
6
+ def self.parse(base, record_store, options)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def initialize(base, attribute, record_store, options)
6
11
  @base = base
7
- @strategy_id = strategy_id
8
- @record_store = record_store
9
- @cache_key_prefix = "rc/#{options[:key] || @base.name}/".freeze
12
+ @attribute = attribute
13
+ @record_store = with_multi_support(record_store)
14
+ @cache_key_prefix = "rc/#{options[:key] || @base.name}/"
15
+ @version_opts = options[:ttl] ? { :ttl => options[:ttl] } : {}
16
+ end
17
+
18
+ # Retrieve the +attribute+ for this strategy (unique per model).
19
+ # May be a non-existing attribute in case a cache is not based on a single attribute.
20
+ def attribute
21
+ @attribute
10
22
  end
11
23
 
12
24
  # Fetch all records and sort and filter locally
@@ -14,57 +26,75 @@ module RecordCache
14
26
  records = fetch_records(query)
15
27
  Util.filter!(records, query.wheres) if query.wheres.size > 0
16
28
  Util.sort!(records, query.sort_orders) if query.sorted?
29
+ records = records[0..query.limit-1] if query.limit
17
30
  records
18
31
  end
19
32
 
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)
33
+ # Can the cache retrieve the records based on this query?
34
+ def cacheable?(query)
22
35
  raise NotImplementedError
23
36
  end
24
37
 
25
- # Can the cache retrieve the records based on this query?
26
- def cacheable?(query)
38
+ # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
39
+ def record_change(record, action)
27
40
  raise NotImplementedError
28
41
  end
29
42
 
30
43
  # Handle invalidation call
31
44
  def invalidate(id)
32
- raise NotImplementedError
45
+ version_store.renew(cache_key(id), version_opts)
33
46
  end
34
47
 
35
48
  protected
36
-
49
+
37
50
  def fetch_records(query)
38
51
  raise NotImplementedError
39
52
  end
40
-
53
+
41
54
  # ------------------------- Utility methods ----------------------------
42
-
55
+
43
56
  # retrieve the version store (unique store for the whole application)
44
57
  def version_store
45
58
  RecordCache::Base.version_store
46
59
  end
47
-
60
+
61
+ # should be used when calling version_store.renew(..., version_opts)
62
+ def version_opts
63
+ @version_opts
64
+ end
65
+
48
66
  # retrieve the record store (store for records for this cache strategy)
49
67
  def record_store
50
68
  @record_store
51
69
  end
52
-
70
+
53
71
  # find the statistics for this cache strategy
54
72
  def statistics
55
- @statistics ||= RecordCache::Statistics.find(@base, @strategy_id)
73
+ @statistics ||= RecordCache::Statistics.find(@base, @attribute)
56
74
  end
57
75
 
58
76
  # retrieve the cache key for the given id, e.g. rc/person/14
59
77
  def cache_key(id)
60
- "#{@cache_key_prefix}#{id}".freeze
78
+ "#{@cache_key_prefix}#{id}"
61
79
  end
62
-
80
+
63
81
  # retrieve the versioned record key, e.g. rc/person/14v1
64
82
  def versioned_key(cache_key, version)
65
- "#{cache_key}v#{version.to_s}".freeze
83
+ "#{cache_key}v#{version.to_s}"
84
+ end
85
+
86
+ private
87
+
88
+ # add default implementation for multi support to perform multiple cache calls in a pipelined fashion
89
+ def with_multi_support(cache_store)
90
+ unless cache_store.respond_to?(:multi)
91
+ cache_store.send(:define_singleton_method, :multi) do |&block|
92
+ block.call
93
+ end
94
+ end
95
+ cache_store
66
96
  end
67
-
97
+
68
98
  end
69
99
  end
70
100
  end
@@ -0,0 +1,81 @@
1
+ module RecordCache
2
+ module Strategy
3
+ class FullTableCache < Base
4
+ FULL_TABLE = 'full-table'
5
+
6
+ # parse the options and return (an array of) instances of this strategy
7
+ def self.parse(base, record_store, options)
8
+ return nil unless options[:full_table]
9
+ return nil unless base.table_exists?
10
+
11
+ FullTableCache.new(base, :full_table, record_store, options)
12
+ end
13
+
14
+ # Can the cache retrieve the records based on this query?
15
+ def cacheable?(query)
16
+ true
17
+ end
18
+
19
+ # Clear the cache on any record change
20
+ def record_change(record, action)
21
+ version_store.delete(cache_key(FULL_TABLE))
22
+ end
23
+
24
+ protected
25
+
26
+ # retrieve the record(s) with the given id(s) as an array
27
+ def fetch_records(query)
28
+ key = cache_key(FULL_TABLE)
29
+ # retrieve the current version of the records
30
+ current_version = version_store.current(key)
31
+ # get the records from the cache if there is a current version
32
+ records = current_version ? from_cache(key, current_version) : nil
33
+ # logging (only in debug mode!) and statistics
34
+ log_full_table_cache_hit(key, records) if RecordCache::Base.logger.debug?
35
+ statistics.add(1, records ? 1 : 0) if statistics.active?
36
+ # no records found?
37
+ unless records
38
+ # renew the version in case the version was not known
39
+ current_version ||= version_store.renew(key, version_opts)
40
+ # retrieve all records from the DB
41
+ records = from_db(key, current_version)
42
+ end
43
+ # return the array
44
+ records
45
+ end
46
+
47
+ def cache_key(id)
48
+ super(FULL_TABLE)
49
+ end
50
+
51
+ private
52
+
53
+ # ---------------------------- Querying ------------------------------------
54
+
55
+ # retrieve the records from the cache with the given keys
56
+ def from_cache(key, version)
57
+ records = record_store.read(versioned_key(key, version))
58
+ records.map{ |record| Util.deserialize(record) } if records
59
+ end
60
+
61
+ # retrieve the records with the given ids from the database
62
+ def from_db(key, version)
63
+ RecordCache::Base.without_record_cache do
64
+ # retrieve the records from the database
65
+ records = @base.all.to_a
66
+ # write all records to the cache
67
+ record_store.write(versioned_key(key, version), records.map{ |record| Util.serialize(record) })
68
+ records
69
+ end
70
+ end
71
+
72
+ # ------------------------- Utility methods ----------------------------
73
+
74
+ # log cache hit/miss to debug log
75
+ def log_full_table_cache_hit(key, records)
76
+ RecordCache::Base.logger.debug{ "FullTableCache #{records ? 'hit' : 'miss'} for model #{@base.name}" }
77
+ end
78
+
79
+ end
80
+ end
81
+ end
@@ -2,53 +2,59 @@ module RecordCache
2
2
  module Strategy
3
3
  class IndexCache < Base
4
4
 
5
- def initialize(base, strategy_id, record_store, options)
5
+ # parse the options and return (an array of) instances of this strategy
6
+ def self.parse(base, record_store, options)
7
+ return nil unless options[:index]
8
+ return nil unless base.table_exists?
9
+
10
+ raise "Index cache '#{options[:index].inspect}' on #{base.name} is redundant as index cache queries are handled by the full table cache." if options[:full_table]
11
+ raise ":index => #{options[:index].inspect} option cannot be used unless 'id' is present on #{base.name}" unless base.columns_hash['id']
12
+ [options[:index]].flatten.compact.map do |attribute|
13
+ type = base.columns_hash[attribute.to_s].try(:type)
14
+ raise "No column found for index '#{attribute}' on #{base.name}." unless type
15
+ raise "Incorrect type (expected integer, found #{type}) for index '#{attribute}' on #{base.name}." unless type == :integer
16
+ IndexCache.new(base, attribute, record_store, options)
17
+ end
18
+ end
19
+
20
+ def initialize(base, attribute, record_store, options)
6
21
  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>"
22
+ @cache_key_prefix << "#{attribute}="
13
23
  end
14
24
 
15
25
  # Can the cache retrieve the records based on this query?
16
26
  def cacheable?(query)
17
- query.where_id(@index) && query.limit.nil?
27
+ # allow limit of 1 for has_one
28
+ query.where_value(@attribute) && (query.limit.nil? || (query.limit == 1 && !query.sorted?))
18
29
  end
19
30
 
20
31
  # Handle create/update/destroy (use record.previous_changes to find the old values in case of an update)
21
32
  def record_change(record, action)
22
33
  if action == :destroy
23
- remove_from_index(record.send(@index), record.id)
34
+ remove_from_index(record.send(@attribute), record.id)
24
35
  elsif action == :create
25
- add_to_index(record.send(@index), record.id)
36
+ add_to_index(record.send(@attribute), record.id)
26
37
  else
27
- index_change = record.previous_changes[@index.to_s]
38
+ index_change = record.previous_changes[@attribute.to_s] || record.previous_changes[@attribute]
28
39
  return unless index_change
29
40
  remove_from_index(index_change[0], record.id)
30
41
  add_to_index(index_change[1], record.id)
31
42
  end
32
43
  end
33
44
 
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
45
  protected
40
46
 
41
47
  # retrieve the record(s) based on the given query
42
48
  def fetch_records(query)
43
- value = query.where_id(@index)
49
+ value = query.where_value(@attribute)
44
50
  # make sure CacheCase.filter! does not see this where clause anymore
45
- query.wheres.delete(@index)
51
+ query.wheres.delete(@attribute)
46
52
  # retrieve the cache key for this index and value
47
- key = index_cache_key(value)
53
+ key = cache_key(value)
48
54
  # retrieve the current version of the ids list
49
55
  current_version = version_store.current(key)
50
56
  # 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))
57
+ versioned_key = versioned_key(key, current_version || version_store.renew(key, version_opts))
52
58
  # retrieve the ids from the local cache based on the current version from the version store
53
59
  ids = current_version ? fetch_ids_from_cache(versioned_key) : nil
54
60
  # logging (only in debug mode!) and statistics
@@ -59,16 +65,11 @@ module RecordCache
59
65
  # use the IdCache to retrieve the records based on the ids
60
66
  @base.record_cache[:id].send(:fetch_records, ::RecordCache::Query.new({:id => ids}))
61
67
  end
62
-
68
+
63
69
  private
64
70
 
65
71
  # ---------------------------- Querying ------------------------------------
66
72
 
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
73
  # Retrieve the ids from the local cache
73
74
  def fetch_ids_from_cache(versioned_key)
74
75
  record_store.read(versioned_key)
@@ -78,7 +79,7 @@ module RecordCache
78
79
  def fetch_ids_from_db(versioned_key, value)
79
80
  RecordCache::Base.without_record_cache do
80
81
  # go straight to SQL result for optimal performance
81
- sql = @base.select('id').where(@index => value).to_sql
82
+ sql = @base.select('id').where(@attribute => value).to_sql
82
83
  ids = []; @base.connection.execute(sql).each{ |row| ids << (row.is_a?(Hash) ? row['id'] : row.first).to_i }
83
84
  record_store.write(versioned_key, ids)
84
85
  ids
@@ -89,25 +90,26 @@ module RecordCache
89
90
 
90
91
  # add one record(id) to the index with the given value
91
92
  def add_to_index(value, id)
92
- increment_version(value.to_i) { |ids| ids << id } if value
93
+ renew_version(value.to_i) { |ids| ids << id } if value
93
94
  end
94
95
 
95
96
  # remove one record(id) from the index with the given value
96
97
  def remove_from_index(value, id)
97
- increment_version(value.to_i) { |ids| ids.delete(id) } if value
98
+ renew_version(value.to_i) { |ids| ids.delete(id) } if value
98
99
  end
99
100
 
100
- # increment the version store and update the local store
101
- def increment_version(value, &block)
101
+ # renew the version store and update the local store
102
+ def renew_version(value, &block)
102
103
  # retrieve local version and increment version store
103
- key = index_cache_key(value)
104
- version = version_store.increment(key)
104
+ key = cache_key(value)
105
+ old_version = version_store.current(key)
106
+ new_version = version_store.renew(key, version_opts)
105
107
  # try to update the ids list based on the last version
106
- ids = fetch_ids_from_cache(versioned_key(key, version - 1))
108
+ ids = fetch_ids_from_cache(versioned_key(key, old_version))
107
109
  if ids
108
110
  ids = Array.new(ids)
109
111
  yield ids
110
- record_store.write(versioned_key(key, version), ids)
112
+ record_store.write(versioned_key(key, new_version), ids)
111
113
  end
112
114
  end
113
115
 
@@ -115,7 +117,7 @@ module RecordCache
115
117
 
116
118
  # log cache hit/miss to debug log
117
119
  def log_cache_hit(key, ids)
118
- RecordCache::Base.logger.debug("IndexCache #{ids ? 'hit' : 'miss'} for #{key}: found #{ids ? ids.size : 'no'} ids")
120
+ RecordCache::Base.logger.debug{ "IndexCache #{ids ? 'hit' : 'miss'} for #{key}: found #{ids ? ids.size : 'no'} ids" }
119
121
  end
120
122
  end
121
123
  end