identity_cache 1.0.0 → 1.2.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.
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)