identity_cache 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +17 -0
- data/.travis.yml +10 -0
- data/Gemfile +4 -0
- data/LICENSE +22 -0
- data/README.md +231 -0
- data/Rakefile +16 -0
- data/identity_cache.gemspec +24 -0
- data/lib/belongs_to_caching.rb +39 -0
- data/lib/identity_cache.rb +570 -0
- data/lib/identity_cache/version.rb +3 -0
- data/lib/memoized_cache_proxy.rb +71 -0
- data/test/attribute_cache_test.rb +73 -0
- data/test/denormalized_has_many_test.rb +89 -0
- data/test/denormalized_has_one_test.rb +99 -0
- data/test/fetch_multi_test.rb +144 -0
- data/test/fetch_test.rb +108 -0
- data/test/helpers/cache.rb +60 -0
- data/test/helpers/database_connection.rb +41 -0
- data/test/identity_cache_test.rb +17 -0
- data/test/index_cache_test.rb +96 -0
- data/test/memoized_cache_proxy_test.rb +60 -0
- data/test/normalized_belongs_to_test.rb +46 -0
- data/test/normalized_has_many_test.rb +125 -0
- data/test/recursive_denormalized_has_many_test.rb +97 -0
- data/test/save_test.rb +64 -0
- data/test/test_helper.rb +73 -0
- metadata +154 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
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
|