identity_cache 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.9.3
4
+
5
+ services:
6
+ - memcache
7
+ - mysql
8
+
9
+ before_script:
10
+ - mysql -e 'create database identity_cache_test'
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in identity_cache.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Shopify
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # IdentityCache
2
+ [![Build Status](https://api.travis-ci.org/Shopify/identity_cache.png)](http://travis-ci.org/Shopify/identity_cache)
3
+
4
+ Opt in read through ActiveRecord caching used in production and extracted from Shopify. IdentityCache lets you specify how you want to cache your model objects, at the model level, and adds a number of convenience methods for accessing those objects through the cache. Memcached is used as the backend cache store, and the database is only hit when a copy of the object cannot be found in Memcached.
5
+
6
+ IdentityCache keeps track of the objects that have cached indexes and uses an `after_commit` hook to expire those objects, and any up the tree, when they are changed.
7
+
8
+ ## Installation
9
+
10
+ Add this line to your application's Gemfile:
11
+
12
+ ```ruby
13
+ gem 'identity_cache', :github => 'Shopify/identity_cache'
14
+ ```
15
+
16
+ And then execute:
17
+
18
+ $ bundle
19
+
20
+ Add the following to your environment/production.rb:
21
+
22
+ ```ruby
23
+ config.identity_cache_store = :mem_cache_store, Memcached::Rails.new(:servers => ["mem1.server.com"])
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### Basic Usage
29
+
30
+ ``` ruby
31
+ class Product < ActiveRecord::Base
32
+ include IdentityCache
33
+
34
+ has_many :images
35
+
36
+ cache_has_many :images, :embed => true
37
+ end
38
+
39
+ # Fetch the product by its id, the primary index.
40
+ @product = Product.fetch(id)
41
+
42
+ # Fetch the images for the Product. Images are embedded so the product fetch would have already loaded them.
43
+ @images = @product.fetch_images
44
+ ```
45
+
46
+ Note: You must include the IdentityCache module into the classes where you want to use it.
47
+
48
+ ### Secondary Indexes
49
+
50
+ IdentifyCache lets you lookup records by fields other than `id`. You can have multiple of these indexes with any other combination of fields:
51
+
52
+ ``` ruby
53
+ class Product < ActiveRecord::Base
54
+ include IdentityCache
55
+ cache_index :handle, :unique => true
56
+ cache_index :vendor, :product_type
57
+ end
58
+
59
+ # Fetch the product from the cache by the index.
60
+ # If the object isn't in the cache it is pulled from the db and stored in the cache.
61
+ product = Product.fetch_by_handle(handle)
62
+
63
+ products = Product.fetch_by_vendor_and_product_type(handle)
64
+ ```
65
+
66
+ This gives you a lot of freedom to use your objects the way you want to, and doesn't get in your way. This does keep an independent cache copy in Memcached so you might want to watch the number of different caches that are being added.
67
+
68
+
69
+ ### Reading from the cache
70
+
71
+ IdentityCache adds `fetch_*` methods to the classes that you mark with cache indexes, based on those indexes. The example below will add a `fetch_by_domain` method to the class.
72
+
73
+ ``` ruby
74
+ class Shop < ActiveRecord::Base
75
+ include IdentityCache
76
+ cache_index :domain
77
+ end
78
+ ```
79
+
80
+ Association caches follow suit and add `fetch_*` methods based on the indexes added for those associations.
81
+
82
+ ``` ruby
83
+ class Product < ActiveRecord::Base
84
+ include IdentityCache
85
+ has_many :images
86
+ has_one :featured_image
87
+
88
+ cache_has_many :images
89
+ cache_has_one :featured_image
90
+ end
91
+
92
+ @product.fetch_featured_image
93
+ @product.fetch_images
94
+ ```
95
+
96
+ ### Embedding Associations
97
+
98
+ IdentityCache can easily embed objects into the parents' cache entry. This means loading the parent object will also load the association and add it to the cache along with the parent. Subsequent cache requests will load the parent along with the association in one fetch. This can again mean some duplication in the cache if you want to be able to cache objects on their own as well, so it should be done with care. This works with both `cache_has_many` and `cache_has_one` methods.
99
+
100
+ ``` ruby
101
+ class Product < ActiveRecord::Base
102
+ include IdentityCache
103
+
104
+ has_many :images
105
+ cache_has_many :images, :embed => true
106
+ end
107
+
108
+ @product = Product.fetch(id)
109
+ @product.fetch_images
110
+ ```
111
+
112
+ With this code, on cache miss, the product and its associated images will be loaded from the db. All this data will be stored into the single cache key for the product. Later requests will load the entire blob of data; `@product.fetch_images` will not need to hit the db since the images are loaded with the product from the cache.
113
+
114
+ ### Caching Polymorphic Associations
115
+
116
+ IdentityCache tries to figure out both sides of an association whenever it can so it can set those up when rebuilding the object from the cache. In some cases this is hard to determine so you can tell IdentityCache what the association should be. This is most often the case when embedding polymorphic associations. The `inverse_name` option on `cache_has_many` and `cache_has_one` lets you specify the inverse name of the association.
117
+
118
+ ``` ruby
119
+ class Metafield < ActiveRecord::Base
120
+ belongs_to :owner, :polymorphic => true
121
+ end
122
+
123
+ class Product < ActiveRecord::Base
124
+ include IdentityCache
125
+ has_many :metafields, :as => 'owner'
126
+ cache_has_many :metafields, :inverse_name => :owner
127
+ end
128
+ ```
129
+
130
+ The `:inverse_name => :owner` option tells IdentityCache what the association on the other side is named so that it can correctly set the assocation when loading the metafields from the cache.
131
+
132
+
133
+ ### Caching Attributes
134
+
135
+ For cases where you may not need the entire object to be cached, just an attribute from record, `cache_attribute` can be used. This will cache the single attribute by the key specified.
136
+
137
+ ``` ruby
138
+ class Redirect < ActiveRecord::Base
139
+ cache_attribute :target, :by => [:shop_id, :path]
140
+ end
141
+
142
+ Redirect.fetch_target_by_shop_id_and_path(shop_id, path)
143
+ ```
144
+
145
+ This will read the attribute from the cache or query the database for the attribute and store it in the cache.
146
+
147
+
148
+ ## Methods Added to ActiveRecord::Base
149
+
150
+ #### cache_index
151
+
152
+ Options:
153
+ _[:unique]_ Allows you to say that an index is unique (only one object stored at the index) or not unique, which allows there to be multiple objects matching the index key. The default value is false.
154
+
155
+ Example:
156
+ `cache_index :handle`
157
+
158
+ #### cache_has_many
159
+
160
+ Options:
161
+ _[:embed]_ Specifies that the association should be included with the parent when caching. This means the associated objects will be loaded already when the parent is loaded from the cache and will not need to be fetched on their own.
162
+
163
+ _[:inverse_name]_ Specifies the name of parent object used by the association. This is useful for polymorphic associations when the association is often named something different between the parent and child objects.
164
+
165
+ Example:
166
+ `cache_has_many :metafields, :inverse_name => :owner, :embed => true`
167
+
168
+ #### cache_has_one
169
+
170
+ Options:
171
+ _[:embed]_ Specifies that the association should be included with the parent when caching. This means the associated objects will be loaded already when the parent is loaded from the cache and will not need to be fetched on their own.
172
+
173
+ _[:inverse_name]_ Specifies the name of parent object used by the association. This is useful for polymorphic associations when the association is often named something different between the parent and child objects.
174
+
175
+ Example:
176
+ `cache_has_one :configuration, :embed => true`
177
+
178
+ #### cache_attribute
179
+
180
+ Options:
181
+ _[:by]_ Specifies what key(s) you want the attribute cached by. Defaults to :id.
182
+
183
+ Example:
184
+ `cache_attribute :target, :by => [:shop_id, :path]`
185
+
186
+ ## Memoized Cache Proxy
187
+
188
+ Cache reads and writes can be memoized for a block of code to serve duplicate identity cache requests from memory. This can be done for an http request by adding this around filter in your `ApplicationController`.
189
+
190
+ ``` ruby
191
+ class ApplicationController < ActionController::Base
192
+ around_filter :identity_cache_memoization
193
+
194
+ def identity_cache_memoization
195
+ IdentityCache.cache.with_memoization{ yield }
196
+ end
197
+ end
198
+ ```
199
+
200
+ ## Caveats
201
+
202
+ A word of warning. Some versions of rails will silently rescue all exceptions in `after_commit` hooks. If an `after_commit` fails before the cache expiry `after_commit` the cache will not be expired and you will be left with stale data.
203
+
204
+ Since everything is being marshalled and unmarshalled from Memcached changing Ruby or Rails versions could mean your objects cannot be unmarshalled from Memcached. There are a number of ways to get around this such as namespacing keys when you upgrade or rescuing marshal load errors and treating it as a cache miss. Just something to be aware of if you are using IdentityCache and upgrade Ruby or Rails.
205
+
206
+ ## Contributing
207
+
208
+ Caching is hard. Chances are that if some feature was left out, it was left out on purpose because it didn't make sense to cache in that way. This is used in production at Shopify so we are very opinionated about the types of features we're going to add. Please start the discussion early, before even adding code, so that we can talk about the feature you are proposing and decide if it makes sense in IdentityCache.
209
+
210
+ Types of contributions we are looking for:
211
+
212
+ - Bug fixes
213
+ - Performance improvements
214
+ - Documentation and/or clearer interfaces
215
+
216
+ ### How To Contribute
217
+
218
+ 1. Fork it
219
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
220
+ 3. Commit your changes (`git commit -am 'Added some feature'`)
221
+ 4. Push to the branch (`git push origin my-new-feature`)
222
+ 5. Create new Pull Request
223
+
224
+ ## Contributors
225
+
226
+ Camilo Lopez (@camilo)
227
+ Tom Burns (@boourns)
228
+ Harry Brundage (@hornairs)
229
+ Dylan Smith (@dylanahsmith)
230
+ Tobias Lütke (@tobi)
231
+ John Duff (@jduff)
data/Rakefile ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env rake
2
+ require 'bundler/gem_tasks'
3
+
4
+ require 'rake/testtask'
5
+ require 'rdoc/task'
6
+
7
+ desc 'Default: run unit tests.'
8
+ task :default => :test
9
+
10
+ desc 'Test the identity_cache plugin.'
11
+ Rake::TestTask.new(:test) do |t|
12
+ t.libs << 'lib'
13
+ t.libs << 'test'
14
+ t.pattern = 'test/**/*_test.rb'
15
+ t.verbose = true
16
+ end
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/identity_cache/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Camilo Lopez", "Tom Burns", "Harry Brundage", "Dylan Smith", "Tobias Lütke"]
6
+ gem.email = ["harry.brundage@shopify.com"]
7
+ gem.description = %q{Opt in read through ActiveRecord caching.}
8
+ gem.summary = %q{IdentityCache lets you specify how you want to cache your model objects, at the model level, and adds a number of convenience methods for accessing those objects through the cache. Memcached is used as the backend cache store, and the database is only hit when a copy of the object cannot be found in Memcached.}
9
+ gem.homepage = "https://github.com/Shopify/identity_cache"
10
+
11
+ gem.files = `git ls-files`.split($\)
12
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
13
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
14
+ gem.name = "identity_cache"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = IdentityCache::VERSION
17
+
18
+
19
+ gem.add_dependency('ar_transaction_changes', '0.0.1')
20
+ gem.add_dependency('cityhash', '0.6.0')
21
+ gem.add_development_dependency('rake')
22
+ gem.add_development_dependency('mocha')
23
+ gem.add_development_dependency('mysql2')
24
+ end
@@ -0,0 +1,39 @@
1
+ module IdentityCache
2
+ module BelongsToCaching
3
+
4
+ def self.included(base)
5
+ base.send(:extend, ClassMethods)
6
+ base.class_attribute :cached_belongs_tos
7
+ end
8
+
9
+ module ClassMethods
10
+ def cache_belongs_to(association, options = {})
11
+ self.cached_belongs_tos ||= {}
12
+ self.cached_belongs_tos[association] = options
13
+
14
+ options[:embed] ||= false
15
+ options[:cached_accessor_name] ||= "fetch_#{association}"
16
+ options[:foreign_key] ||= reflect_on_association(association).foreign_key
17
+ options[:associated_class] ||= reflect_on_association(association).class_name
18
+
19
+ if options[:embed]
20
+ raise NotImplementedError
21
+ else
22
+ build_normalized_belongs_to_cache(association, options)
23
+ end
24
+ end
25
+
26
+ def build_normalized_belongs_to_cache(association, options)
27
+ self.class_eval(ruby = <<-CODE, __FILE__, __LINE__)
28
+ def #{options[:cached_accessor_name]}
29
+ if IdentityCache.should_cache? && #{options[:foreign_key]}.present? && !association(:#{association}).loaded?
30
+ self.#{association} = #{options[:associated_class]}.fetch_by_id(#{options[:foreign_key]})
31
+ else
32
+ #{association}
33
+ end
34
+ end
35
+ CODE
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,570 @@
1
+ require "identity_cache/version"
2
+ require 'cityhash'
3
+ require 'ar_transaction_changes'
4
+ require File.dirname(__FILE__) + '/memoized_cache_proxy'
5
+ require File.dirname(__FILE__) + '/belongs_to_caching'
6
+
7
+ module IdentityCache
8
+ CACHED_NIL = :idc_cached_nil
9
+
10
+ class << self
11
+
12
+ attr_accessor :logger, :readonly
13
+ attr_reader :cache
14
+
15
+ def cache_backend=(memcache)
16
+ cache.memcache = memcache
17
+ end
18
+
19
+ def cache
20
+ @cache ||= MemoizedCacheProxy.new
21
+ end
22
+
23
+ def logger
24
+ @logger || Rails.logger
25
+ end
26
+
27
+ def should_cache?
28
+ !readonly && ActiveRecord::Base.connection.open_transactions == 0
29
+ end
30
+
31
+ def fetch(key, &block)
32
+ result = cache.read(key) if should_cache?
33
+
34
+ if result.nil?
35
+ if block_given?
36
+ ActiveRecord::Base.connection.with_master do
37
+ result = yield
38
+ end
39
+ result = map_cached_nil_for(result)
40
+ if should_cache?
41
+ cache.write(key, result)
42
+ end
43
+ end
44
+ logger.debug "[IdentityCache] cache miss for #{key}"
45
+ else
46
+ logger.debug "[IdentityCache] cache hit for #{key}"
47
+ end
48
+
49
+ unmap_cached_nil_for(result)
50
+ end
51
+
52
+ def map_cached_nil_for(value)
53
+ value.nil? ? IdentityCache::CACHED_NIL : value
54
+ end
55
+
56
+
57
+ def unmap_cached_nil_for(value)
58
+ value == IdentityCache::CACHED_NIL ? nil : value
59
+ end
60
+
61
+ def fetch_multi(*keys, &block)
62
+ return {} if keys.size == 0
63
+ result = {}
64
+ result = cache.read_multi(*keys) if should_cache?
65
+
66
+ missed_keys = keys - result.select {|key, value| value.present? }.keys
67
+
68
+ if missed_keys.size > 0
69
+ if block_given?
70
+ replacement_results = nil
71
+ ActiveRecord::Base.connection.with_master do
72
+ replacement_results = yield missed_keys
73
+ end
74
+ missed_keys.zip(replacement_results) do |(key, replacement_result)|
75
+ if should_cache?
76
+ replacement_result = map_cached_nil_for(replacement_result )
77
+ cache.write(key, replacement_result)
78
+ logger.debug "[IdentityCache] cache miss for #{key} (multi)"
79
+ end
80
+ result[key] = replacement_result
81
+ end
82
+ end
83
+ else
84
+ result.keys.each do |key|
85
+ logger.debug "[IdentityCache] cache hit for #{key} (multi)"
86
+ end
87
+ end
88
+
89
+ result.keys.each do |key|
90
+ result[key] = unmap_cached_nil_for(result[key])
91
+ end
92
+
93
+ result
94
+ end
95
+
96
+ def included(base)
97
+ raise AlreadyIncludedError if base.respond_to? :cache_indexes
98
+
99
+ unless ActiveRecord::Base.connection.respond_to?(:with_master)
100
+ ActiveRecord::Base.connection.class.class_eval(ruby = <<-CODE, __FILE__, __LINE__)
101
+ def with_master
102
+ yield
103
+ end
104
+ CODE
105
+ end
106
+
107
+ base.send(:include, ArTransactionChanges) unless base.include?(ArTransactionChanges)
108
+ base.send(:include, IdentityCache::BelongsToCaching)
109
+ base.after_commit :expire_cache
110
+ base.after_touch :expire_cache
111
+ base.class_attribute :cache_indexes
112
+ base.class_attribute :cache_attributes
113
+ base.class_attribute :cached_has_manys
114
+ base.class_attribute :cached_has_ones
115
+ base.send(:extend, ClassMethods)
116
+
117
+ base.private_class_method :require_if_necessary, :build_normalized_has_many_cache, :build_denormalized_association_cache, :add_parent_expiry_hook,
118
+ :identity_cache_multiple_value_dynamic_fetcher, :identity_cache_single_value_dynamic_fetcher
119
+
120
+ base.instance_eval(ruby = <<-CODE, __FILE__, __LINE__)
121
+ private :expire_cache, :was_new_record?, :fetch_denormalized_cached_association, :populate_denormalized_cached_association
122
+ CODE
123
+ end
124
+
125
+ def memcache_hash(key)
126
+ CityHash.hash64(key)
127
+ end
128
+ end
129
+
130
+ module ClassMethods
131
+
132
+ def cache_index(*fields)
133
+ options = fields.extract_options!
134
+ self.cache_indexes ||= []
135
+ self.cache_indexes.push fields
136
+
137
+ field_list = fields.join("_and_")
138
+ arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(',')
139
+ where_list = fields.each_with_index.collect { |f, i| "#{f} = \#{quote_value(arg#{i})}" }.join(" AND ")
140
+
141
+ if options[:unique]
142
+ self.instance_eval(ruby = <<-CODE, __FILE__, __LINE__)
143
+ def fetch_by_#{field_list}(#{arg_list})
144
+ sql = "SELECT id FROM #{table_name} WHERE #{where_list} LIMIT 1"
145
+ identity_cache_single_value_dynamic_fetcher(#{fields.inspect}, [#{arg_list}], sql)
146
+ end
147
+
148
+ # exception throwing variant
149
+ def fetch_by_#{field_list}!(#{arg_list})
150
+ fetch_by_#{field_list}(#{arg_list}) or raise ActiveRecord::RecordNotFound
151
+ end
152
+ CODE
153
+ else
154
+ self.instance_eval(ruby = <<-CODE, __FILE__, __LINE__)
155
+ def fetch_by_#{field_list}(#{arg_list})
156
+ sql = "SELECT id FROM #{table_name} WHERE #{where_list}"
157
+ identity_cache_multiple_value_dynamic_fetcher(#{fields.inspect}, [#{arg_list}], sql)
158
+ end
159
+ CODE
160
+ end
161
+ end
162
+
163
+ def identity_cache_single_value_dynamic_fetcher(fields, values, sql_on_miss)
164
+ cache_key = rails_cache_index_key_for_fields_and_values(fields, values)
165
+ id = IdentityCache.fetch(cache_key) { connection.select_value(sql_on_miss) }
166
+ unless id.nil?
167
+ record = fetch_by_id(id.to_i)
168
+ IdentityCache.cache.delete(cache_key) unless record
169
+ end
170
+
171
+ record
172
+ end
173
+
174
+ def identity_cache_multiple_value_dynamic_fetcher(fields, values, sql_on_miss)
175
+ cache_key = rails_cache_index_key_for_fields_and_values(fields, values)
176
+ ids = IdentityCache.fetch(cache_key) { connection.select_values(sql_on_miss) }
177
+
178
+ ids.empty? ? [] : fetch_multi(*ids)
179
+ end
180
+
181
+ def cache_has_many(association, options = {})
182
+ options[:embed] ||= false
183
+ options[:inverse_name] ||= self.name.underscore.to_sym
184
+ raise InverseAssociationError unless self.reflect_on_association(association)
185
+ self.cached_has_manys ||= {}
186
+ self.cached_has_manys[association] = options
187
+
188
+ if options[:embed]
189
+ build_denormalized_association_cache(association, options)
190
+ else
191
+ build_normalized_has_many_cache(association, options)
192
+ end
193
+ end
194
+
195
+ def cache_has_one(association, options = {})
196
+ options[:embed] ||= true
197
+ options[:inverse_name] ||= self.name.underscore.to_sym
198
+ raise InverseAssociationError unless self.reflect_on_association(association)
199
+ self.cached_has_ones ||= {}
200
+ self.cached_has_ones[association] = options
201
+
202
+ build_denormalized_association_cache(association, options)
203
+ end
204
+
205
+ def build_denormalized_association_cache(association, options)
206
+ options[:cached_accessor_name] ||= "fetch_#{association}"
207
+ options[:cache_variable_name] ||= "cached_#{association}"
208
+ options[:population_method_name] ||= "populate_#{association}_cache"
209
+
210
+ unless instance_methods.include?(options[:cached_accessor_name].to_sym)
211
+ self.class_eval(ruby = <<-CODE, __FILE__, __LINE__)
212
+ def #{options[:cached_accessor_name]}
213
+ fetch_denormalized_cached_association('#{options[:cache_variable_name]}', :#{association})
214
+ end
215
+
216
+ def #{options[:population_method_name]}
217
+ populate_denormalized_cached_association('#{options[:cache_variable_name]}', :#{association})
218
+ end
219
+ CODE
220
+
221
+ association_class = reflect_on_association(association).klass
222
+ add_parent_expiry_hook(association_class, options.merge(:only_on_foreign_key_change => false))
223
+ end
224
+ end
225
+
226
+ def build_normalized_has_many_cache(association, options)
227
+ singular_association = association.to_s.singularize
228
+ association_class = reflect_on_association(association).klass
229
+ options[:cached_accessor_name] ||= "fetch_#{association}"
230
+ options[:ids_name] ||= "#{singular_association}_ids"
231
+ options[:ids_cache_name] ||= "cached_#{options[:ids_name]}"
232
+ options[:population_method_name] ||= "populate_#{association}_cache"
233
+
234
+ self.class_eval(ruby = <<-CODE, __FILE__, __LINE__)
235
+ attr_reader :#{options[:ids_cache_name]}
236
+
237
+ def #{options[:population_method_name]}
238
+ @#{options[:ids_cache_name]} = #{options[:ids_name]}
239
+ end
240
+
241
+ def #{options[:cached_accessor_name]}
242
+ if IdentityCache.should_cache? || #{association}.loaded?
243
+ populate_#{association}_cache unless @#{options[:ids_cache_name]}
244
+ @cached_#{association} ||= #{association_class}.fetch_multi(*@#{options[:ids_cache_name]})
245
+ else
246
+ #{association}
247
+ end
248
+ end
249
+ CODE
250
+
251
+ add_parent_expiry_hook(association_class, options.merge(:only_on_foreign_key_change => true))
252
+ end
253
+
254
+ def cache_attribute(attribute, options = {})
255
+ options[:by] ||= :id
256
+ fields = Array(options[:by])
257
+
258
+ self.cache_attributes ||= []
259
+ self.cache_attributes.push [attribute, fields]
260
+
261
+ field_list = fields.join("_and_")
262
+ arg_list = (0...fields.size).collect { |i| "arg#{i}" }.join(',')
263
+ where_list = fields.each_with_index.collect { |f, i| "#{f} = \#{quote_value(arg#{i})}" }.join(" AND ")
264
+
265
+ self.instance_eval(ruby = <<-CODE, __FILE__, __LINE__)
266
+ def fetch_#{attribute}_by_#{field_list}(#{arg_list})
267
+ sql = "SELECT #{attribute} FROM #{table_name} WHERE #{where_list} LIMIT 1"
268
+ attribute_dynamic_fetcher(#{attribute.inspect}, #{fields.inspect}, [#{arg_list}], sql)
269
+ end
270
+ CODE
271
+ end
272
+
273
+ def attribute_dynamic_fetcher(attribute, fields, values, sql_on_miss)
274
+ cache_key = rails_cache_key_for_attribute_and_fields_and_values(attribute, fields, values)
275
+ IdentityCache.fetch(cache_key) { connection.select_value(sql_on_miss) }
276
+ end
277
+
278
+ def exists_with_identity_cache?(id)
279
+ !!fetch_by_id(id)
280
+ end
281
+
282
+ def fetch_by_id(id)
283
+ if IdentityCache.should_cache?
284
+
285
+ require_if_necessary do
286
+ object = IdentityCache.fetch(rails_cache_key(id)){ resolve_cache_miss(id) }
287
+ object.clear_association_cache if object.respond_to?(:clear_association_cache)
288
+ IdentityCache.logger.error "[IDC id mismatch] fetch_by_id_requested=#{id} fetch_by_id_got=#{object.id} for #{object.inspect[(0..100)]} " if object && object.id != id.to_i
289
+ object
290
+ end
291
+
292
+ else
293
+ self.find_by_id(id)
294
+ end
295
+ end
296
+
297
+ def fetch(id)
298
+ fetch_by_id(id) or raise(ActiveRecord::RecordNotFound, "Couldn't find #{self.class.name} with ID=#{id}")
299
+ end
300
+
301
+ def fetch_multi(*ids)
302
+ if IdentityCache.should_cache?
303
+
304
+ require_if_necessary do
305
+ cache_keys = ids.map {|id| rails_cache_key(id) }
306
+ key_to_id_map = Hash[ cache_keys.zip(ids) ]
307
+
308
+ objects_by_key = IdentityCache.fetch_multi(*key_to_id_map.keys) do |unresolved_keys|
309
+ ids = unresolved_keys.map {|key| key_to_id_map[key] }
310
+ records = find_batch(ids)
311
+ records.compact.each(&:populate_association_caches)
312
+ records
313
+ end
314
+
315
+ objects_in_order = cache_keys.map {|key| objects_by_key[key] }
316
+ objects_in_order.each do |object|
317
+ object.clear_association_cache if object.respond_to?(:clear_association_cache)
318
+ end
319
+
320
+ objects_in_order.compact
321
+ end
322
+
323
+ else
324
+ find_batch(ids)
325
+ end
326
+ end
327
+
328
+ def require_if_necessary
329
+ # mem_cache_store returns raw value if unmarshal fails
330
+ rval = yield
331
+ case rval
332
+ when String
333
+ rval = Marshal.load(rval)
334
+ when Array
335
+ rval.map!{ |v| v.kind_of?(String) ? Marshal.load(v) : v }
336
+ end
337
+ rval
338
+ rescue ArgumentError => e
339
+ if e.message =~ /undefined [\w\/]+ (\w+)/
340
+ ok = Kernel.const_get($1) rescue nil
341
+ retry if ok
342
+ end
343
+ raise
344
+ end
345
+
346
+ module ParentModelExpiration
347
+ def expire_parent_cache_on_changes(parent_name, foreign_key, parent_class, options = {})
348
+ new_parent = send(parent_name)
349
+
350
+ if new_parent && new_parent.respond_to?(:expire_primary_index, true)
351
+ if should_expire_identity_cache_parent?(foreign_key, options[:only_on_foreign_key_change])
352
+ new_parent.expire_primary_index
353
+ new_parent.expire_parent_cache if new_parent.respond_to?(:expire_parent_cache)
354
+ end
355
+ end
356
+
357
+ if transaction_changed_attributes[foreign_key].present?
358
+ begin
359
+ old_parent = parent_class.find(transaction_changed_attributes[foreign_key])
360
+ old_parent.expire_primary_index if old_parent.respond_to?(:expire_primary_index)
361
+ old_parent.expire_parent_cache if old_parent.respond_to?(:expire_parent_cache)
362
+ rescue ActiveRecord::RecordNotFound => e
363
+ # suppress errors finding the old parent if its been destroyed since it will have expired itself in that case
364
+ end
365
+ end
366
+
367
+ true
368
+ end
369
+
370
+ def should_expire_identity_cache_parent?(foreign_key, only_on_foreign_key_change)
371
+ if only_on_foreign_key_change
372
+ destroyed? || was_new_record? || transaction_changed_attributes[foreign_key].present?
373
+ else
374
+ true
375
+ end
376
+ end
377
+ end
378
+
379
+ def add_parent_expiry_hook(child_class, options = {})
380
+ child_association = child_class.reflect_on_association(options[:inverse_name])
381
+ raise InverseAssociationError unless child_association
382
+ foreign_key = child_association.association_foreign_key
383
+ parent_class ||= self.name
384
+ new_parent = options[:inverse_name]
385
+
386
+ child_class.send(:include, ArTransactionChanges) unless child_class.include?(ArTransactionChanges)
387
+ child_class.send(:include, ParentModelExpiration) unless child_class.include?(ParentModelExpiration)
388
+
389
+ child_class.class_eval(ruby = <<-CODE, __FILE__, __LINE__)
390
+ after_commit :expire_parent_cache
391
+ after_touch :expire_parent_cache
392
+
393
+ def expire_parent_cache
394
+ expire_parent_cache_on_changes(:#{options[:inverse_name]}, '#{foreign_key}', #{parent_class}, #{options.inspect})
395
+ end
396
+ CODE
397
+ end
398
+
399
+ def resolve_cache_miss(id)
400
+ self.find_by_id(id, :include => cache_fetch_includes).tap do |object|
401
+ object.try(:populate_association_caches)
402
+ end
403
+ end
404
+
405
+ def all_cached_associations
406
+ (cached_has_manys || {}).merge(cached_has_ones || {})
407
+ end
408
+
409
+ def cache_fetch_includes
410
+ all_cached_associations.select{|k, v| v[:embed]}.map do |child_association, options|
411
+ child_class = reflect_on_association(child_association).try(:klass)
412
+ child_includes = child_class.respond_to?(:cache_fetch_includes) ? child_class.cache_fetch_includes : []
413
+ if child_includes.empty?
414
+ child_association
415
+ else
416
+ { child_association => child_class.cache_fetch_includes }
417
+ end
418
+ end
419
+ end
420
+
421
+ def find_batch(ids)
422
+ @id_column ||= columns.detect {|c| c.name == "id"}
423
+ ids = ids.map{ |id| @id_column.type_cast(id) }
424
+ records = where('id IN (?)', ids).includes(cache_fetch_includes).all
425
+ records_by_id = records.index_by(&:id)
426
+ records = ids.map{ |id| records_by_id[id] }
427
+ mismatching_ids = records.compact.map(&:id) - ids
428
+ IdentityCache.logger.error "[IDC id mismatch] fetch_batch_requested=#{ids.inspect} fetch_batch_got=#{mismatchig_ids.inspect} mismatching ids " unless mismatching_ids.empty?
429
+ records
430
+ end
431
+
432
+ def rails_cache_key(id)
433
+ rails_cache_key_prefix + id.to_s
434
+ end
435
+
436
+ def rails_cache_key_prefix
437
+ @rails_cache_key_prefix ||= begin
438
+ column_list = columns.sort_by(&:name).map {|c| "#{c.name}:#{c.type}"} * ","
439
+ "IDC:blob:#{base_class.name}:#{IdentityCache.memcache_hash(column_list)}:"
440
+ end
441
+ end
442
+
443
+ def rails_cache_index_key_for_fields_and_values(fields, values)
444
+ "IDC:index:#{base_class.name}:#{rails_cache_string_for_fields_and_values(fields, values)}"
445
+ end
446
+
447
+ def rails_cache_key_for_attribute_and_fields_and_values(attribute, fields, values)
448
+ "IDC:attribute:#{base_class.name}:#{attribute}:#{rails_cache_string_for_fields_and_values(fields, values)}"
449
+ end
450
+
451
+ def rails_cache_string_for_fields_and_values(fields, values)
452
+ "#{fields.join('/')}:#{IdentityCache.memcache_hash(values.join('/'))}"
453
+ end
454
+ end
455
+
456
+ def populate_association_caches
457
+ self.class.all_cached_associations.each do |cached_association, options|
458
+ send(options[:population_method_name])
459
+ reflection = options[:embed] && self.class.reflect_on_association(cached_association)
460
+ if reflection && reflection.klass.respond_to?(:cached_has_manys)
461
+ child_objects = Array.wrap(send(options[:cached_accessor_name]))
462
+ child_objects.each(&:populate_association_caches)
463
+ end
464
+ end
465
+ end
466
+
467
+ def fetch_denormalized_cached_association(ivar_name, association_name)
468
+ ivar_full_name = :"@#{ivar_name}"
469
+ if IdentityCache.should_cache?
470
+ populate_denormalized_cached_association(ivar_name, association_name)
471
+ IdentityCache.unmap_cached_nil_for(instance_variable_get(ivar_full_name))
472
+ else
473
+ send(association_name.to_sym)
474
+ end
475
+ end
476
+
477
+ def populate_denormalized_cached_association(ivar_name, association_name)
478
+ ivar_full_name = :"@#{ivar_name}"
479
+
480
+ value = instance_variable_get(ivar_full_name)
481
+ return value unless value.nil?
482
+
483
+ reflection = association(association_name)
484
+ reflection.load_target unless reflection.loaded?
485
+
486
+ loaded_association = send(association_name)
487
+ instance_variable_set(ivar_full_name, IdentityCache.map_cached_nil_for(loaded_association))
488
+ end
489
+
490
+ def primary_cache_index_key
491
+ self.class.rails_cache_key(id)
492
+ end
493
+
494
+ def secondary_cache_index_key_for_current_values(fields)
495
+ self.class.rails_cache_index_key_for_fields_and_values(fields, fields.collect {|field| self.send(field)})
496
+ end
497
+
498
+ def secondary_cache_index_key_for_previous_values(fields)
499
+ self.class.rails_cache_index_key_for_fields_and_values(fields, old_values_for_fields(fields))
500
+ end
501
+
502
+ def attribute_cache_key_for_attribute_and_previous_values(attribute, fields)
503
+ self.class.rails_cache_key_for_attribute_and_fields_and_values(attribute, fields, old_values_for_fields(fields))
504
+ end
505
+
506
+ def old_values_for_fields(fields)
507
+ fields.map do |field|
508
+ field_string = field.to_s
509
+ if destroyed? && transaction_changed_attributes.has_key?(field_string)
510
+ transaction_changed_attributes[field_string]
511
+ elsif persisted? && transaction_changed_attributes.has_key?(field_string)
512
+ transaction_changed_attributes[field_string]
513
+ else
514
+ self.send(field)
515
+ end
516
+ end
517
+ end
518
+
519
+ def expire_primary_index
520
+ extra_keys = if respond_to? :updated_at
521
+ old_updated_at = old_values_for_fields([:updated_at]).first
522
+ "expiring_last_updated_at=#{old_updated_at}"
523
+ else
524
+ ""
525
+ end
526
+ IdentityCache.logger.debug "[IdentityCache] expiring=#{self.class.name} expiring_id=#{id} #{extra_keys}"
527
+
528
+ IdentityCache.cache.delete(primary_cache_index_key)
529
+ end
530
+
531
+ def expire_secondary_indexes
532
+ cache_indexes.try(:each) do |fields|
533
+ if self.destroyed?
534
+ IdentityCache.cache.delete(secondary_cache_index_key_for_previous_values(fields))
535
+ else
536
+ new_cache_index_key = secondary_cache_index_key_for_current_values(fields)
537
+ IdentityCache.cache.delete(new_cache_index_key)
538
+
539
+ if !was_new_record?
540
+ old_cache_index_key = secondary_cache_index_key_for_previous_values(fields)
541
+ IdentityCache.cache.delete(old_cache_index_key) unless old_cache_index_key == new_cache_index_key
542
+ end
543
+ end
544
+ end
545
+ end
546
+
547
+ def expire_attribute_indexes
548
+ cache_attributes.try(:each) do |(attribute, fields)|
549
+ IdentityCache.cache.delete(attribute_cache_key_for_attribute_and_previous_values(attribute, fields)) unless was_new_record?
550
+ end
551
+ end
552
+
553
+ def expire_cache
554
+ expire_primary_index
555
+ expire_secondary_indexes
556
+ expire_attribute_indexes
557
+ true
558
+ end
559
+
560
+ def was_new_record?
561
+ !destroyed? && transaction_changed_attributes.has_key?('id') && transaction_changed_attributes['id'].nil?
562
+ end
563
+
564
+ class AlreadyIncludedError < StandardError; end
565
+ class InverseAssociationError < StandardError
566
+ def initialize
567
+ super "Inverse name for association could not be determined. Please use the :inverse_name option to specify the inverse association name for this cache."
568
+ end
569
+ end
570
+ end