identity_cache 1.0.0 → 1.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +76 -9
  3. data/.github/workflows/cla.yml +22 -0
  4. data/.gitignore +1 -0
  5. data/.rubocop.yml +7 -3
  6. data/.spin/bootstrap +7 -0
  7. data/.spin/svc.yml +2 -0
  8. data/CAVEATS.md +25 -0
  9. data/CHANGELOG.md +56 -22
  10. data/Gemfile +15 -5
  11. data/LICENSE +1 -1
  12. data/README.md +27 -8
  13. data/Rakefile +13 -12
  14. data/dev.yml +5 -4
  15. data/gemfiles/Gemfile.latest-release +12 -5
  16. data/gemfiles/Gemfile.min-supported +12 -0
  17. data/gemfiles/Gemfile.rails-edge +9 -5
  18. data/identity_cache.gemspec +15 -24
  19. data/{railgun.yml → isogun.yml} +0 -5
  20. data/lib/identity_cache/belongs_to_caching.rb +1 -0
  21. data/lib/identity_cache/cache_fetcher.rb +241 -16
  22. data/lib/identity_cache/cache_hash.rb +7 -6
  23. data/lib/identity_cache/cache_invalidation.rb +2 -1
  24. data/lib/identity_cache/cache_key_generation.rb +22 -19
  25. data/lib/identity_cache/cache_key_loader.rb +2 -2
  26. data/lib/identity_cache/cached/association.rb +2 -4
  27. data/lib/identity_cache/cached/attribute.rb +3 -3
  28. data/lib/identity_cache/cached/attribute_by_multi.rb +1 -1
  29. data/lib/identity_cache/cached/belongs_to.rb +24 -14
  30. data/lib/identity_cache/cached/embedded_fetching.rb +2 -0
  31. data/lib/identity_cache/cached/prefetcher.rb +12 -2
  32. data/lib/identity_cache/cached/primary_index.rb +3 -3
  33. data/lib/identity_cache/cached/recursive/association.rb +55 -12
  34. data/lib/identity_cache/cached/recursive/has_many.rb +1 -0
  35. data/lib/identity_cache/cached/recursive/has_one.rb +1 -0
  36. data/lib/identity_cache/cached/reference/association.rb +1 -0
  37. data/lib/identity_cache/cached/reference/has_many.rb +3 -2
  38. data/lib/identity_cache/cached/reference/has_one.rb +3 -2
  39. data/lib/identity_cache/cached.rb +1 -0
  40. data/lib/identity_cache/configuration_dsl.rb +1 -0
  41. data/lib/identity_cache/encoder.rb +2 -1
  42. data/lib/identity_cache/expiry_hook.rb +2 -1
  43. data/lib/identity_cache/fallback_fetcher.rb +6 -1
  44. data/lib/identity_cache/mem_cache_store_cas.rb +63 -0
  45. data/lib/identity_cache/memoized_cache_proxy.rb +33 -23
  46. data/lib/identity_cache/parent_model_expiration.rb +6 -3
  47. data/lib/identity_cache/query_api.rb +29 -66
  48. data/lib/identity_cache/railtie.rb +1 -0
  49. data/lib/identity_cache/should_use_cache.rb +1 -0
  50. data/lib/identity_cache/version.rb +2 -1
  51. data/lib/identity_cache/with_primary_index.rb +37 -10
  52. data/lib/identity_cache/without_primary_index.rb +7 -3
  53. data/lib/identity_cache.rb +66 -26
  54. data/performance/cache_runner.rb +12 -51
  55. data/performance/cpu.rb +7 -6
  56. data/performance/externals.rb +6 -5
  57. data/performance/profile.rb +7 -6
  58. metadata +32 -112
  59. data/.github/probots.yml +0 -2
  60. data/.travis.yml +0 -45
  61. data/gemfiles/Gemfile.rails52 +0 -6
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module WithPrimaryIndex
4
5
  extend ActiveSupport::Concern
@@ -57,7 +58,7 @@ module IdentityCache
57
58
  cache_attribute_by_alias(attribute_proc, alias_name: :id, by: fields, unique: unique)
58
59
 
59
60
  field_list = fields.join("_and_")
60
- arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(',')
61
+ arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(",")
61
62
 
62
63
  if unique
63
64
  instance_eval(<<-CODE, __FILE__, __LINE__ + 1)
@@ -97,21 +98,47 @@ module IdentityCache
97
98
  !!fetch_by_id(id)
98
99
  end
99
100
 
100
- # Default fetcher added to the model on inclusion, it behaves like
101
- # ActiveRecord::Base.where(id: id).first
102
- def fetch_by_id(id, includes: nil)
101
+ # Fetch the record by its primary key from the cache or read from
102
+ # the database and fill the cache on a cache miss. This behaves like
103
+ # `where(id: id).readonly.first` being called on the model.
104
+ #
105
+ # @param id Primary key value for the record to fetch.
106
+ # @param includes [Hash|Array|Symbol] Cached associations to prefetch from
107
+ # the cache on the returned record
108
+ # @param fill_lock_duration [Float] If provided, take a fill lock around cache fills
109
+ # and wait for this duration for cache to be filled when reading a lock provided
110
+ # by another client. Defaults to not setting the fill lock and trying to fill the
111
+ # cache from the database regardless of the presence of another client's fill lock.
112
+ # Set this to just above the typical amount of time it takes to do a cache fill.
113
+ # @param lock_wait_tries [Integer] Only applicable if fill_lock_duration is provided,
114
+ # in which case it specifies the number of times to do a lock wait. After the first
115
+ # lock wait it will try to take the lock, so will only do following lock waits due
116
+ # to another client taking the lock first. If another lock wait would be needed after
117
+ # reaching the limit, then a `LockWaitTimeout` exception is raised. Default is 2. Use
118
+ # this to control the maximum total lock wait duration
119
+ # (`lock_wait_tries * fill_lock_duration`).
120
+ # @raise [LockWaitTimeout] Timeout after waiting `lock_wait_tries * fill_lock_duration`
121
+ # seconds for `lock_wait_tries` other clients to fill the cache.
122
+ # @return [self|nil] An instance of this model for the record with the specified id or
123
+ # `nil` if no record with this `id` exists in the database
124
+ def fetch_by_id(id, includes: nil, **cache_fetcher_options)
103
125
  ensure_base_model
104
126
  raise_if_scoped
105
- record = cached_primary_index.fetch(id)
127
+ record = cached_primary_index.fetch(id, cache_fetcher_options)
106
128
  prefetch_associations(includes, [record]) if record && includes
107
129
  record
108
130
  end
109
131
 
110
- # Default fetcher added to the model on inclusion, it behaves like
111
- # ActiveRecord::Base.find, but will raise IdentityCache::RecordNotFound
112
- # if the id is not in the cache.
113
- def fetch(id, includes: nil)
114
- fetch_by_id(id, includes: includes) || raise(
132
+ # Fetch the record by its primary key from the cache or read from
133
+ # the database and fill the cache on a cache miss. This behaves like
134
+ # `readonly.find(id)` being called on the model.
135
+ #
136
+ # @param (see #fetch_by_id)
137
+ # @raise (see #fetch_by_id)
138
+ # @raise [ActiveRecord::RecordNotFound] if the record isn't found
139
+ # @return [self] An instance of this model for the record with the specified id
140
+ def fetch(id, **options)
141
+ fetch_by_id(id, **options) || raise(
115
142
  IdentityCache::RecordNotFound, "Couldn't find #{name} with ID=#{id}"
116
143
  )
117
144
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module IdentityCache
3
4
  module WithoutPrimaryIndex
4
5
  extend ActiveSupport::Concern
@@ -12,9 +13,12 @@ module IdentityCache
12
13
  include IdentityCache::ShouldUseCache
13
14
  include ParentModelExpiration
14
15
 
15
- def self.append_features(base) #:nodoc:
16
- raise AlreadyIncludedError if base.include?(WithoutPrimaryIndex)
17
- super
16
+ class << self
17
+ def append_features(base) # :nodoc:
18
+ raise AlreadyIncludedError if base.include?(WithoutPrimaryIndex)
19
+
20
+ super
21
+ end
18
22
  end
19
23
 
20
24
  included do
@@ -1,7 +1,8 @@
1
1
  # frozen_string_literal: true
2
- require 'active_record'
3
- require 'active_support/core_ext/module/attribute_accessors'
4
- require 'ar_transaction_changes'
2
+
3
+ require "active_record"
4
+ require "active_support/core_ext/module/attribute_accessors"
5
+ require "ar_transaction_changes"
5
6
 
6
7
  require "identity_cache/version"
7
8
  require "identity_cache/record_not_found"
@@ -27,23 +28,25 @@ require "identity_cache/cached/reference/association"
27
28
  require "identity_cache/cached/reference/has_one"
28
29
  require "identity_cache/cached/reference/has_many"
29
30
  require "identity_cache/expiry_hook"
30
- require 'identity_cache/memoized_cache_proxy'
31
- require 'identity_cache/belongs_to_caching'
32
- require 'identity_cache/cache_key_generation'
33
- require 'identity_cache/configuration_dsl'
34
- require 'identity_cache/should_use_cache'
35
- require 'identity_cache/parent_model_expiration'
36
- require 'identity_cache/query_api'
31
+ require "identity_cache/memoized_cache_proxy"
32
+ require "identity_cache/belongs_to_caching"
33
+ require "identity_cache/cache_key_generation"
34
+ require "identity_cache/configuration_dsl"
35
+ require "identity_cache/should_use_cache"
36
+ require "identity_cache/parent_model_expiration"
37
+ require "identity_cache/query_api"
37
38
  require "identity_cache/cache_hash"
38
39
  require "identity_cache/cache_invalidation"
39
40
  require "identity_cache/cache_fetcher"
40
41
  require "identity_cache/fallback_fetcher"
41
- require 'identity_cache/without_primary_index'
42
- require 'identity_cache/with_primary_index'
42
+ require "identity_cache/without_primary_index"
43
+ require "identity_cache/with_primary_index"
43
44
 
44
45
  module IdentityCache
45
46
  extend ActiveSupport::Concern
46
47
 
48
+ autoload :MemCacheStoreCAS, "identity_cache/mem_cache_store_cas"
49
+
47
50
  include WithPrimaryIndex
48
51
 
49
52
  CACHED_NIL = :idc_cached_nil
@@ -52,29 +55,37 @@ module IdentityCache
52
55
  DELETED_TTL = 1000
53
56
 
54
57
  class AlreadyIncludedError < StandardError; end
58
+
55
59
  class AssociationError < StandardError; end
60
+
56
61
  class InverseAssociationError < StandardError; end
62
+
57
63
  class UnsupportedScopeError < StandardError; end
64
+
58
65
  class UnsupportedAssociationError < StandardError; end
66
+
59
67
  class DerivedModelError < StandardError; end
60
68
 
69
+ class LockWaitTimeout < StandardError; end
70
+
61
71
  mattr_accessor :cache_namespace
62
72
  self.cache_namespace = "IDC:#{CACHE_VERSION}:"
63
73
 
64
74
  # Fetched records are not read-only and this could sometimes prevent IDC from
65
75
  # reflecting what's truly in the database when fetch_read_only_records is false.
66
76
  # When set to true, it will only return read-only records when cache is used.
67
- mattr_accessor :fetch_read_only_records
68
- self.fetch_read_only_records = true
77
+ @fetch_read_only_records = true
69
78
 
70
79
  class << self
71
80
  include IdentityCache::CacheHash
72
81
 
82
+ attr_writer :fetch_read_only_records
73
83
  attr_accessor :readonly
74
84
  attr_writer :logger
75
85
 
76
- def append_features(base) #:nodoc:
86
+ def append_features(base) # :nodoc:
77
87
  raise AlreadyIncludedError if base.include?(IdentityCache)
88
+
78
89
  super
79
90
  end
80
91
 
@@ -105,8 +116,27 @@ module IdentityCache
105
116
  end
106
117
 
107
118
  def should_use_cache? # :nodoc:
108
- pool = ActiveRecord::Base.connection_pool
109
- !pool.active_connection? || pool.connection.open_transactions == 0
119
+ ActiveRecord::Base.connection_handler.connection_pool_list.none? do |pool|
120
+ pool.active_connection? &&
121
+ # Rails wraps each of your tests in a transaction, so that any changes
122
+ # made to the database during the test can be rolled back afterwards.
123
+ # These transactions are flagged as "unjoinable", which tries to make
124
+ # your application behave as if they weren't there. In particular:
125
+ #
126
+ # - Opening another transaction during the test creates a savepoint,
127
+ # which can be rolled back independently of the main transaction.
128
+ # - When those nested transactions complete, any `after_commit`
129
+ # callbacks for records modified during the transaction will run,
130
+ # even though the changes haven't actually been committed yet.
131
+ #
132
+ # By ignoring unjoinable transactions, IdentityCache's behaviour
133
+ # during your test suite will more closely match production.
134
+ #
135
+ # When there are no open transactions, `current_transaction` returns a
136
+ # special `NullTransaction` object that is unjoinable, meaning we will
137
+ # use the cache.
138
+ pool.connection.current_transaction.joinable?
139
+ end
110
140
  end
111
141
 
112
142
  # Cache retrieval and miss resolver primitive; given a key it will try to
@@ -115,10 +145,13 @@ module IdentityCache
115
145
  #
116
146
  # == Parameters
117
147
  # +key+ A cache key string
148
+ # +cache_fetcher_options+ A hash of options to pass to the cache backend
118
149
  #
119
- def fetch(key)
150
+ def fetch(key, cache_fetcher_options = {})
120
151
  if should_use_cache?
121
- unmap_cached_nil_for(cache.fetch(key) { map_cached_nil_for yield })
152
+ unmap_cached_nil_for(cache.fetch(key, cache_fetcher_options) do
153
+ map_cached_nil_for(yield)
154
+ end)
122
155
  else
123
156
  yield
124
157
  end
@@ -144,7 +177,7 @@ module IdentityCache
144
177
  result = if should_use_cache?
145
178
  fetch_in_batches(keys.uniq) do |missed_keys|
146
179
  results = yield missed_keys
147
- results.map { |e| map_cached_nil_for e }
180
+ results.map { |e| map_cached_nil_for(e) }
148
181
  end
149
182
  else
150
183
  results = yield keys
@@ -159,11 +192,18 @@ module IdentityCache
159
192
  end
160
193
 
161
194
  def with_fetch_read_only_records(value = true)
162
- old_value = fetch_read_only_records
163
- self.fetch_read_only_records = value
195
+ old_value = Thread.current[:identity_cache_fetch_read_only_records]
196
+ Thread.current[:identity_cache_fetch_read_only_records] = value
164
197
  yield
165
198
  ensure
166
- self.fetch_read_only_records = old_value
199
+ Thread.current[:identity_cache_fetch_read_only_records] = old_value
200
+ end
201
+
202
+ def fetch_read_only_records
203
+ v = Thread.current[:identity_cache_fetch_read_only_records]
204
+ return v unless v.nil?
205
+
206
+ @fetch_read_only_records
167
207
  end
168
208
 
169
209
  def eager_load!
@@ -172,12 +212,12 @@ module IdentityCache
172
212
 
173
213
  private
174
214
 
175
- def fetch_in_batches(keys)
215
+ def fetch_in_batches(keys, &block)
176
216
  keys.each_slice(BATCH_SIZE).each_with_object({}) do |slice, result|
177
- result.merge!(cache.fetch_multi(*slice) { |missed_keys| yield missed_keys })
217
+ result.merge!(cache.fetch_multi(*slice, &block))
178
218
  end
179
219
  end
180
220
  end
181
221
  end
182
222
 
183
- require 'identity_cache/railtie' if defined?(Rails)
223
+ require "identity_cache/railtie" if defined?(Rails)
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__))
3
- require 'active_record'
4
- require 'active_support/core_ext'
5
- require 'active_support/cache'
6
- require 'identity_cache'
7
- require 'memcached_store'
8
- require 'active_support/cache/memcached_store'
4
+ require "active_record"
5
+ require "active_support/core_ext"
6
+ require "active_support/cache"
7
+ require "identity_cache"
8
+ require "memcached_store"
9
+ require "active_support/cache/memcached_store"
9
10
 
10
- require File.dirname(__FILE__) + '/../test/helpers/active_record_objects'
11
- require File.dirname(__FILE__) + '/../test/helpers/database_connection'
12
- require File.dirname(__FILE__) + '/../test/helpers/cache_connection'
11
+ require File.dirname(__FILE__) + "/../test/helpers/active_record_objects"
12
+ require File.dirname(__FILE__) + "/../test/helpers/database_connection"
13
+ require File.dirname(__FILE__) + "/../test/helpers/cache_connection"
13
14
 
14
15
  IdentityCache.logger = Logger.new(nil)
15
16
  CacheConnection.setup
@@ -31,6 +32,7 @@ def create_database(count)
31
32
  helper.setup_models
32
33
 
33
34
  return if database_ready(count)
35
+
34
36
  puts "Database not ready for performance testing, generating records"
35
37
 
36
38
  DatabaseConnection.drop_tables
@@ -38,6 +40,7 @@ def create_database(count)
38
40
  existing = Item.all
39
41
  (1..count).to_a.each do |i|
40
42
  next if existing.any? { |e| e.id == i }
43
+
41
44
  a = Item.new
42
45
  a.id = i
43
46
  a.associated = AssociatedRecord.new(name: "Associated for #{i}")
@@ -107,28 +110,6 @@ module DeletedRunner
107
110
  end
108
111
  end
109
112
 
110
- module ConflictRunner
111
- def prepare
112
- super
113
- records = (1..@count).map { |id| ::Item.find(id) }
114
- orig_resolve_cache_miss = ::Item.method(:resolve_cache_miss)
115
-
116
- ::Item.define_singleton_method(:resolve_cache_miss) do |id|
117
- records[id - 1].expire_cache
118
- orig_resolve_cache_miss.call(id)
119
- end
120
- IdentityCache.cache.clear
121
- end
122
- end
123
-
124
- module DeletedConflictRunner
125
- include ConflictRunner
126
- def prepare
127
- super
128
- (1..@count).each { |i| ::Item.find(i).expire_cache }
129
- end
130
- end
131
-
132
113
  class EmbedRunner < CacheRunner
133
114
  def setup_models
134
115
  super
@@ -161,16 +142,6 @@ class FetchEmbedDeletedRunner < EmbedRunner
161
142
  end
162
143
  CACHE_RUNNERS << FetchEmbedDeletedRunner
163
144
 
164
- class FetchEmbedConflictRunner < EmbedRunner
165
- include ConflictRunner
166
- end
167
- CACHE_RUNNERS << FetchEmbedConflictRunner
168
-
169
- class FetchEmbedDeletedConflictRunner < EmbedRunner
170
- include DeletedConflictRunner
171
- end
172
- CACHE_RUNNERS << FetchEmbedDeletedConflictRunner
173
-
174
145
  class NormalizedRunner < CacheRunner
175
146
  def setup_models
176
147
  super
@@ -204,13 +175,3 @@ class FetchNormalizedDeletedRunner < NormalizedRunner
204
175
  include DeletedRunner
205
176
  end
206
177
  CACHE_RUNNERS << FetchNormalizedDeletedRunner
207
-
208
- class FetchNormalizedConflictRunner < EmbedRunner
209
- include ConflictRunner
210
- end
211
- CACHE_RUNNERS << FetchNormalizedConflictRunner
212
-
213
- class FetchNormalizedDeletedConflictRunner < EmbedRunner
214
- include DeletedConflictRunner
215
- end
216
- CACHE_RUNNERS << FetchNormalizedDeletedConflictRunner
data/performance/cpu.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
- require 'rubygems'
3
- require 'benchmark'
4
2
 
5
- require_relative 'cache_runner'
3
+ require "rubygems"
4
+ require "benchmark"
5
+
6
+ require_relative "cache_runner"
6
7
 
7
8
  RUNS = 400
8
9
 
@@ -23,7 +24,7 @@ end
23
24
  def benchmark(runners, label_width = 0)
24
25
  IdentityCache.cache.clear
25
26
  runners.each do |runner|
26
- print "#{runner.name}: ".ljust(label_width)
27
+ print("#{runner.name}: ".ljust(label_width))
27
28
  puts run(runner.new(RUNS))
28
29
  end
29
30
  end
@@ -32,9 +33,9 @@ def bmbm(runners)
32
33
  label_width = runners.map { |r| r.name.size }.max + 2
33
34
  width = label_width + Benchmark::CAPTION.size
34
35
 
35
- puts 'Rehearsal: '.ljust(width, '-')
36
+ puts "Rehearsal: ".ljust(width, "-")
36
37
  benchmark(runners, label_width)
37
- puts '-' * width
38
+ puts "-" * width
38
39
 
39
40
  benchmark(runners, label_width)
40
41
  end
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
- require 'rubygems'
3
- require 'benchmark'
4
- require 'ruby-prof'
5
2
 
6
- require_relative 'cache_runner'
3
+ require "rubygems"
4
+ require "benchmark"
5
+ require "ruby-prof"
6
+
7
+ require_relative "cache_runner"
7
8
 
8
9
  RUNS = 1000
9
10
  RubyProf.measure_mode = RubyProf::CPU_TIME
10
11
 
11
12
  EXTERNALS = { "Memcache" => ["MemCache#set", "MemCache#get"],
12
- "Database" => ["Mysql2::Client#query"] }
13
+ "Database" => ["Mysql2::Client#query"], }
13
14
 
14
15
  def run(obj)
15
16
  obj.prepare
@@ -1,9 +1,10 @@
1
1
  # frozen_string_literal: true
2
- require 'rubygems'
3
- require 'benchmark'
4
- require 'stackprof'
5
2
 
6
- require_relative 'cache_runner'
3
+ require "rubygems"
4
+ require "benchmark"
5
+ require "stackprof"
6
+
7
+ require_relative "cache_runner"
7
8
 
8
9
  RUNS = 1000
9
10
 
@@ -22,9 +23,9 @@ end
22
23
 
23
24
  create_database(RUNS)
24
25
 
25
- if (runner_name = ENV['RUNNER'])
26
+ if (runner_name = ENV["RUNNER"])
26
27
  if (runner = CACHE_RUNNERS.find { |r| r.name == runner_name })
27
- run(runner.new(RUNS), filename: ENV['FILENAME'])
28
+ run(runner.new(RUNS), filename: ENV["FILENAME"])
28
29
  else
29
30
  puts "Couldn't find cache runner #{runner_name.inspect}"
30
31
  exit(1)