record_cache 0.9.6

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.
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ record_cache.gemspec
2
+ pkg
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2008 Justin Balthrop, Geni.com
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.rdoc ADDED
@@ -0,0 +1,34 @@
1
+ = RecordCache
2
+
3
+ RecordCache is a simple yet powerful extension to ActiveRecord that caches indexes
4
+ and ActiveRecord models using MemCache. If you use it correctly, it will drastically
5
+ reduce your database load.
6
+
7
+ == Usage:
8
+
9
+ class Foo < ActiveRecord
10
+ record_cache :by => :id
11
+ record_cache :id, :by => :owner_id
12
+ end
13
+
14
+ # These will use the cache now.
15
+ Foo.find(1)
16
+ Foo.find_by_id(2)
17
+ Foo.find_all_by_owner_id(3)
18
+
19
+ Invalidation is handled for you using callbacks.
20
+
21
+ == Install:
22
+
23
+ sudo gem install record_cache -s http://gemcutter.org
24
+
25
+ == Dependencies:
26
+
27
+ * {after_commit}[http://github.com/freelancing-god/after_commit]
28
+ * {deferrable}[http://github.com/ninjudd/deferrable]
29
+ * {memcache}[http://github.com/ninjudd/memcache]
30
+ * {cache_version}[http://github.com/ninjudd/cache_version]
31
+
32
+ == License:
33
+
34
+ Copyright (c) 2009 Justin Balthrop, Geni.com; Published under The MIT License, see LICENSE
data/Rakefile ADDED
@@ -0,0 +1,47 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ begin
6
+ require 'jeweler'
7
+ Jeweler::Tasks.new do |s|
8
+ s.name = "record_cache"
9
+ s.summary = %Q{Active Record caching and indexing in memcache. An alternative to cache_fu}
10
+ s.email = "code@justinbalthrop.com"
11
+ s.homepage = "http://github.com/ninjudd/record_cache"
12
+ s.description = "Active Record caching and indexing in memcache"
13
+ s.authors = ["Justin Balthrop"]
14
+ s.add_dependency('after_commit', '> 1.0.0')
15
+ s.add_dependency('deferrable', '> 0.1.0')
16
+ s.add_dependency('memcache', '> 1.0.0')
17
+ s.add_dependency('cache_version', '> 0.9.4')
18
+ end
19
+ rescue LoadError
20
+ puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
21
+ end
22
+
23
+ Rake::TestTask.new do |t|
24
+ t.libs << 'lib'
25
+ t.pattern = 'test/**/*_test.rb'
26
+ t.verbose = false
27
+ end
28
+
29
+ Rake::RDocTask.new do |rdoc|
30
+ rdoc.rdoc_dir = 'rdoc'
31
+ rdoc.title = 'record_cache'
32
+ rdoc.options << '--line-numbers' << '--inline-source'
33
+ rdoc.rdoc_files.include('README*')
34
+ rdoc.rdoc_files.include('lib/**/*.rb')
35
+ end
36
+
37
+ begin
38
+ require 'rcov/rcovtask'
39
+ Rcov::RcovTask.new do |t|
40
+ t.libs << 'test'
41
+ t.test_files = FileList['test/**/*_test.rb']
42
+ t.verbose = true
43
+ end
44
+ rescue LoadError
45
+ end
46
+
47
+ task :default => :test
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.9.6
@@ -0,0 +1,382 @@
1
+ module RecordCache
2
+ class Index
3
+ include Deferrable
4
+
5
+ attr_reader :model_class, :index_field, :fields, :order_by, :limit, :expiry, :name, :prefix
6
+
7
+ NULL = 'NULL'
8
+
9
+ def initialize(opts)
10
+ raise ':by => index_field required for cache' if opts[:by].nil?
11
+ raise 'explicit name or prefix required with scope' if opts[:scope] and opts[:name].nil? and opts[:prefix].nil?
12
+
13
+ @auto_name = opts[:name].nil?
14
+ @write_ahead = opts[:write_ahead]
15
+ @cache = opts[:cache] || CACHE
16
+ @expiry = opts[:expiry]
17
+ @model_class = opts[:class]
18
+ @set_class = opts[:set_class] || "#{@model_class}Set"
19
+ @index_field = opts[:by].to_s
20
+ @fields = opts[:fields].collect {|field| field.to_s}
21
+ @prefix = opts[:prefix]
22
+ @name = ( opts[:name] || [prefix, 'by', index_field].compact.join('_') ).to_s
23
+ @order_by = opts[:order_by]
24
+ @limit = opts[:limit]
25
+ @disallow_null = opts[:null] == false
26
+ @scope_query = opts[:scope] || {}
27
+ end
28
+
29
+ def cache
30
+ if RecordCache.config[:thread_safe]
31
+ Thread.current[:record_cache] ||= @cache.clone
32
+ else
33
+ @cache
34
+ end
35
+ end
36
+
37
+ def auto_name?; @auto_name; end
38
+ def write_ahead?; @write_ahead; end
39
+ def disallow_null?; @disallow_null; end
40
+
41
+ def full_record?
42
+ fields.empty?
43
+ end
44
+
45
+ def includes_id?
46
+ full_record? or fields.include?('id')
47
+ end
48
+
49
+ def set_class
50
+ @set_class.constantize
51
+ end
52
+
53
+ def namespace
54
+ "#{model_class.name}_#{model_class.version}_#{RecordCache.version}:#{name}" << ( full_record? ? '' : ":#{fields.join(',')}" )
55
+ end
56
+
57
+ def fields_hash
58
+ if @fields_hash.nil?
59
+ if full_record?
60
+ @fields_hash ||= model_class.column_names.hash
61
+ else
62
+ @fields_hash ||= fields.collect {|field| field.to_s}.hash
63
+ end
64
+ end
65
+ @fields_hash
66
+ end
67
+
68
+ def find_by_ids(ids, model_class)
69
+ expects_array = ids.first.kind_of?(Array)
70
+ ids = ids.flatten.compact.collect {|id| id.to_i}
71
+ ids = stringify(ids)
72
+
73
+ if ids.empty?
74
+ return [] if expects_array
75
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} without an ID"
76
+ end
77
+
78
+ records_by_id = get_records(ids)
79
+
80
+ models = ids.collect do |id|
81
+ records = records_by_id[id]
82
+ model = records.instantiate_first(model_class, full_record?) if records
83
+
84
+ # try to get record from db again before we throw an exception
85
+ if model.nil?
86
+ invalidate(id)
87
+ records = get_records([id])[id]
88
+ model = records.instantiate_first(model_class, full_record?) if records
89
+ end
90
+
91
+ raise ActiveRecord::RecordNotFound, "Couldn't find #{model_class} with ID #{id}" unless model
92
+ model
93
+ end
94
+
95
+ if models.size == 1 and not expects_array
96
+ models.first
97
+ else
98
+ models
99
+ end
100
+ end
101
+
102
+ def find_by_field(keys, model_class, type)
103
+ keys = [keys] if not keys.kind_of?(Array)
104
+ keys = stringify(keys)
105
+ records_by_key = get_records(keys)
106
+
107
+ case type
108
+ when :first
109
+ keys.each do |key|
110
+ model = records_by_key[key].instantiate_first(model_class, full_record?)
111
+ return model if model
112
+ end
113
+ return nil
114
+ when :all
115
+ models = []
116
+ keys.each do |key|
117
+ models.concat( records_by_key[key].instantiate(model_class, full_record?) )
118
+ end
119
+ models
120
+ when :set, :ids
121
+ ids = []
122
+ keys.each do |key|
123
+ ids.concat( records_by_key[key].ids(model_class) )
124
+ end
125
+ type == :set ? set_class.new(ids) : ids
126
+ when :raw
127
+ raw_records = []
128
+ keys.each do |key|
129
+ raw_records.concat( records_by_key[key].records(model_class) )
130
+ end
131
+ raw_records
132
+ end
133
+ end
134
+
135
+ def field_lookup(keys, model_class, field, flag = nil)
136
+ keys = [*keys]
137
+ keys = stringify(keys)
138
+ field = field.to_s if field
139
+ records_by_key = get_records(keys)
140
+
141
+ field_by_index = {}
142
+ all_fields = []
143
+ keys.each do |key|
144
+ records = records_by_key[key]
145
+ fields = field ? records.fields(field, model_class) : records.all_fields(model_class, :except => index_field)
146
+ if flag == :all
147
+ all_fields.concat(fields)
148
+ elsif flag == :first
149
+ next if fields.empty?
150
+ field_by_index[index_column.type_cast(key)] = fields.first
151
+ else
152
+ field_by_index[index_column.type_cast(key)] = fields
153
+ end
154
+ end
155
+ if flag == :all
156
+ all_fields.uniq
157
+ else
158
+ field_by_index
159
+ end
160
+ end
161
+
162
+ def invalidate(*keys)
163
+ keys = stringify(keys)
164
+ cache.in_namespace(namespace) do
165
+ keys.each do |key|
166
+ cache.delete(key)
167
+ end
168
+ end
169
+ end
170
+
171
+ def invalidate_from_conditions_lambda(conditions)
172
+ sql = "SELECT #{index_field} FROM #{table_name} "
173
+ model_class.send(:add_conditions!, sql, conditions, model_class.send(:scope, :find))
174
+ ids = db.select_values(sql)
175
+ lambda { invalidate(*ids) }
176
+ end
177
+
178
+ def invalidate_from_conditions(conditions)
179
+ invalidate_from_conditions_lambda(conditions).call
180
+ end
181
+
182
+ def invalidate_model(model)
183
+ attribute = model.send(index_field)
184
+ attribute_was = model.attr_was(index_field)
185
+ if scope.match_previous?(model)
186
+ if write_ahead?
187
+ remove_from_cache(model)
188
+ else
189
+ now_and_later do
190
+ invalidate(attribute_was)
191
+ end
192
+ end
193
+ end
194
+
195
+ if scope.match_current?(model)
196
+ if write_ahead?
197
+ add_to_cache(model)
198
+ elsif not (scope.match_previous?(model) and attribute_was == attribute)
199
+ now_and_later do
200
+ invalidate(attribute)
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def scope_query
207
+ @scope_query[:type] ||= model_class.to_s if sub_class?
208
+ @scope_query
209
+ end
210
+
211
+ def scope
212
+ @scope ||= Scope.new(model_class, scope_query)
213
+ end
214
+
215
+ @@disable_db = false
216
+ def self.disable_db
217
+ @@disable_db = true
218
+ end
219
+
220
+ def self.enable_db
221
+ @@disable_db = false
222
+ end
223
+
224
+ def find_method_name(type)
225
+ if name =~ /(^|_)by_/
226
+ if type == :first
227
+ "find_#{name}"
228
+ else
229
+ "find_#{type}_#{name}"
230
+ end
231
+ else
232
+ case type
233
+ when :all
234
+ "find_#{name}"
235
+ when :first
236
+ "find_#{type}_#{name.singularize}"
237
+ else
238
+ "find_#{name.singularize}_#{type}"
239
+ end
240
+ end
241
+ end
242
+
243
+ def cached_set(id)
244
+ # Used for debugging. Gives you the RecordCache::Set that is currently in the cache.
245
+ id = stringify([id]).first
246
+ cache.in_namespace(namespace) do
247
+ cache.get(id)
248
+ end
249
+ end
250
+
251
+ private
252
+
253
+ MAX_FETCH = 1000
254
+ def get_records(keys)
255
+ cache.in_namespace(namespace) do
256
+ opts = {
257
+ :expiry => expiry,
258
+ :disable_write => model_class.record_cache_config[:disable_write],
259
+ :validation => lambda {|key, record_set| record_set and record_set.fields_hash == fields_hash},
260
+ }
261
+ cache.get_some(keys, opts) do |keys_to_fetch|
262
+ raise 'db access is disabled' if @@disable_db
263
+ fetched_records = {}
264
+ keys_to_fetch.each do |key|
265
+ fetched_records[key] = RecordCache::Set.new(model_class, fields_hash)
266
+ end
267
+
268
+ keys_to_fetch.each_slice(MAX_FETCH) do |keys_batch|
269
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE (#{in_clause(keys_batch)})"
270
+ sql << " AND #{scope.conditions}" if not scope.empty?
271
+ sql << " ORDER BY #{order_by}" if order_by
272
+ sql << " LIMIT #{limit}" if limit
273
+
274
+ db.select_all(sql).each do |record|
275
+ key = record[index_field] || NULL
276
+ fetched_records[key] << record
277
+ end
278
+ end
279
+ fetched_records
280
+ end
281
+ end
282
+ end
283
+
284
+ def model_to_record(model)
285
+ sql = "SELECT #{select_fields} FROM #{table_name} WHERE id = #{model.id}"
286
+ db.select_all(sql).first
287
+ end
288
+
289
+ def in_clause(keys)
290
+ conditions = []
291
+ conditions << "#{index_field} IS NULL" if keys.delete(NULL)
292
+
293
+ if keys.any?
294
+ values = keys.collect {|value| quote_index_value(value)}.join(',')
295
+ conditions << "#{index_field} IN (#{values})"
296
+ end
297
+ conditions.join(' OR ')
298
+ end
299
+
300
+ def remove_from_cache(model)
301
+ record = model.attributes
302
+ key = model.attr_was(index_field)
303
+
304
+ now_and_later do
305
+ cache.in_namespace(namespace) do
306
+ cache.with_lock(key) do
307
+ if records = cache.get(key)
308
+ records.delete(record)
309
+ cache.set(key, records)
310
+ end
311
+ end
312
+ end
313
+ end
314
+ end
315
+
316
+ def add_to_cache(model)
317
+ record = model_to_record(model)
318
+ return unless record
319
+ key = record[index_field].to_s
320
+
321
+ now_and_later do
322
+ cache.in_namespace(namespace) do
323
+ cache.with_lock(key) do
324
+ if records = cache.get(key)
325
+ records.delete(record)
326
+ records << record
327
+ records.sort!(order_by) if order_by
328
+ records.limit!(limit) if limit
329
+ cache.set(key, records)
330
+ end
331
+ end
332
+ end
333
+ end
334
+ end
335
+
336
+ def select_fields
337
+ if @select_fields.nil?
338
+ if full_record?
339
+ @select_fields = model_class.respond_to?(:default_select, true) ? model_class.send(:default_select, nil) : '*'
340
+ else
341
+ @select_fields = [index_field, 'id'] + fields
342
+ @select_fields << 'type' if base_class?
343
+ @select_fields = @select_fields.uniq.join(', ')
344
+ end
345
+ end
346
+ @select_fields
347
+ end
348
+
349
+ def base_class?
350
+ @base_class ||= single_table_inheritance? and model_class == model_class.base_class
351
+ end
352
+
353
+ def sub_class?
354
+ @sub_class ||= single_table_inheritance? and model_class != model_class.base_class
355
+ end
356
+
357
+ def single_table_inheritance?
358
+ @single_table_inheritance ||= model_class.columns_hash.has_key?(model_class.inheritance_column)
359
+ end
360
+
361
+ def quote_index_value(value)
362
+ model_class.quote_value(value, index_column)
363
+ end
364
+
365
+ def index_column
366
+ @index_column ||= model_class.columns_hash[index_field]
367
+ end
368
+
369
+ def table_name
370
+ model_class.table_name
371
+ end
372
+
373
+ def stringify(keys)
374
+ keys.compact! if disallow_null?
375
+ keys.collect {|key| key.nil? ? NULL : key.to_s}.uniq
376
+ end
377
+
378
+ def db
379
+ RecordCache.db(model_class)
380
+ end
381
+ end
382
+ end
@@ -0,0 +1,64 @@
1
+ module RecordCache
2
+ class Scope
3
+ attr_reader :model_class, :query
4
+
5
+ def initialize(model_class, query)
6
+ @model_class = model_class
7
+ @query = query
8
+ end
9
+
10
+ def empty?
11
+ query.empty?
12
+ end
13
+
14
+ def fields
15
+ query.keys
16
+ end
17
+
18
+ def match_current?(model)
19
+ fields.all? do |field|
20
+ match?( field, model.send(field) )
21
+ end
22
+ end
23
+
24
+ def match_previous?(model)
25
+ fields.all? do |field|
26
+ match?( field, model.attr_was(field) )
27
+ end
28
+ end
29
+
30
+ def match?(field, value)
31
+ scope = query[field]
32
+ if defined?(AntiObject) and scope.kind_of?(AntiObject)
33
+ scope = ~scope
34
+ invert = true
35
+ end
36
+
37
+ match = [*scope].include?(value)
38
+ invert ? !match : match
39
+ end
40
+
41
+ def conditions
42
+ @conditions ||= begin
43
+ query.collect do |field, scope|
44
+ if defined?(AntiObject) and scope.kind_of?(AntiObject)
45
+ scope = ~scope
46
+ invert = true
47
+ end
48
+
49
+ if scope.nil?
50
+ op = invert ? 'IS NOT' : 'IS'
51
+ "#{field} #{op} NULL"
52
+ elsif scope.is_a?(Array)
53
+ op = invert ? 'NOT IN' : 'IN'
54
+ model_class.send(:sanitize_sql, ["#{field} #{op} (?)", scope])
55
+ else
56
+ op = invert ? '!=' : '='
57
+ model_class.send(:sanitize_sql, ["#{field} #{op} ?", scope])
58
+ end
59
+ end.join(' AND ')
60
+ end
61
+ @conditions
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,141 @@
1
+ module RecordCache
2
+ class Set
3
+ attr_reader :model_class, :fields_hash, :created_at, :updated_at, :hostname, :dbhost
4
+
5
+ def self.source_tracking?
6
+ @source_tracking
7
+ end
8
+
9
+ def self.source_tracking=(value)
10
+ @source_tracking = value
11
+ end
12
+
13
+ def self.hostname
14
+ @hostname ||= Socket.gethostname
15
+ end
16
+
17
+ def initialize(model_class, fields_hash = nil)
18
+ raise 'valid model class required' unless model_class
19
+ @model_class = model_class
20
+ @fields_hash = fields_hash
21
+ @records_by_type = {}
22
+
23
+ if self.class.source_tracking?
24
+ @created_at = Time.now
25
+ @hostname = self.class.hostname
26
+ @dbhost = RecordCache.db(model_class).instance_variable_get(:@config)[:host]
27
+ end
28
+ end
29
+
30
+ def sort!(order_by)
31
+ field, order = order_by.strip.squeeze.split
32
+ descending = (order == 'DESC')
33
+ @records_by_type.values.each do |records|
34
+ sorted_records = records.sort_by do |record|
35
+ type_cast(field, record[field])
36
+ end
37
+ sorted_records.reverse! if descending
38
+ records.replace(sorted_records)
39
+ end
40
+ end
41
+
42
+ def limit!(limit)
43
+ all_records = records
44
+ if all_records.length > limit
45
+ removed_records = all_records.slice!(limit..-1)
46
+ removed_records.each do |record|
47
+ type = record['type']
48
+ records_by_type(type).delete(record) if type
49
+ end
50
+ end
51
+ end
52
+
53
+ def <<(record)
54
+ mark_updated!
55
+ record_type = record['type']
56
+ record['id'] = record['id'].to_i if record.has_key?('id')
57
+
58
+ [record_type, model_class.to_s].uniq.each do |type|
59
+ records_by_type(type) << record
60
+ end
61
+ end
62
+
63
+ def delete(record)
64
+ raise 'cannot delete record without id' unless record.has_key?('id')
65
+ mark_updated!
66
+ record_type = record['type']
67
+ id = record['id'].to_i
68
+
69
+ [record_type, model_class.to_s].uniq.each do |type|
70
+ records_by_type(type).reject! {|r| r['id'] == id}
71
+ end
72
+ end
73
+
74
+ def records_by_type(type)
75
+ @records_by_type[type.to_s] ||= []
76
+ end
77
+
78
+ def records(type = model_class)
79
+ records_by_type(type)
80
+ end
81
+
82
+ def size
83
+ records.size
84
+ end
85
+
86
+ def empty?
87
+ records.empty?
88
+ end
89
+
90
+ def ids(type = model_class)
91
+ records(type).collect {|r| r['id']}
92
+ end
93
+
94
+ def fields(field, type)
95
+ records(type).collect {|r| type_cast(field, r[field])}
96
+ end
97
+
98
+ def all_fields(type = model_class, opts = {})
99
+ records(type).collect do |r|
100
+ record = {}
101
+ r.each do |field, value|
102
+ next if field == opts[:except]
103
+ record[field.to_sym] = type_cast(field, value)
104
+ end
105
+ record
106
+ end
107
+ end
108
+
109
+ def instantiate_first(type = model_class, full_record = false)
110
+ if full_record
111
+ record = records(type).first
112
+ type.send(:instantiate, record) if record
113
+ else
114
+ id = ids(type).first
115
+ type.find_by_id(id) if id
116
+ end
117
+ end
118
+
119
+ def instantiate(type = model_class, full_record = false)
120
+ if full_record
121
+ records(type).collect do |record|
122
+ type.send(:instantiate, record)
123
+ end
124
+ else
125
+ type.find_all_by_id(ids(type))
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def mark_updated!
132
+ @updated_at = Time.now if self.class.source_tracking?
133
+ end
134
+
135
+ def type_cast(field, value)
136
+ column = model_class.columns_hash[field.to_s]
137
+ raise 'column not found in #{model_class} for field #{field}' unless column
138
+ column.type_cast(value)
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,264 @@
1
+ require 'rubygems'
2
+ require 'memcache'
3
+ require 'active_record'
4
+ require 'cache_version'
5
+ require 'deferrable'
6
+
7
+ $:.unshift(File.dirname(__FILE__))
8
+ require 'record_cache/index'
9
+ require 'record_cache/set'
10
+ require 'record_cache/scope'
11
+
12
+ module RecordCache
13
+ def self.config(opts = nil)
14
+ if opts
15
+ config.merge!(opts)
16
+ else
17
+ @config ||= {}
18
+ end
19
+ end
20
+
21
+ def self.db(model_class)
22
+ # Always use the master connection since we are caching.
23
+ db = model_class.connection
24
+ if defined?(DataFabric::ConnectionProxy) and db.kind_of?(DataFabric::ConnectionProxy)
25
+ model_class.record_cache_config[:use_slave] ? db.send(:connection) : db.send(:master)
26
+ else
27
+ db
28
+ end
29
+ end
30
+
31
+ module InstanceMethods
32
+ def invalidate_record_cache
33
+ self.class.each_cached_index do |index|
34
+ index.invalidate_model(self)
35
+ index.clear_deferred
36
+ end
37
+ end
38
+
39
+ def invalidate_record_cache_deferred
40
+ self.class.each_cached_index do |index|
41
+ # Have to invalidate both before and after commit.
42
+ index.invalidate_model(self)
43
+ end
44
+ end
45
+
46
+ def complete_deferred_record_cache_invalidations
47
+ self.class.each_cached_index do |index|
48
+ index.complete_deferred
49
+ end
50
+ end
51
+
52
+ def attr_was(attr)
53
+ attr = attr.to_s
54
+ ['id', 'type'].include?(attr) ? send(attr) : send(:attribute_was, attr)
55
+ end
56
+ end
57
+
58
+ module ClassMethods
59
+ def find_with_caching(*args, &block)
60
+ if args.last.is_a?(Hash)
61
+ args.last.delete_if {|k,v| v.nil?}
62
+ args.pop if args.last.empty?
63
+ end
64
+
65
+ if [:all, :first, :last].include?(args.first)
66
+ opts = args.last
67
+ if opts.is_a?(Hash) and opts.keys == [:conditions]
68
+ # Try to match the SQL.
69
+ if opts[:conditions] =~ /^"?#{table_name}"?.(\w*) = (\d*)$/
70
+ field, value = $1, $2
71
+ index = cached_index("by_#{field}")
72
+ return index.find_by_field([value], self, args.first) if index
73
+ end
74
+ end
75
+ elsif not args.last.is_a?(Hash)
76
+ # This is a find with just ids.
77
+ index = cached_index('by_id')
78
+ return index.find_by_ids(args, self) if index
79
+ end
80
+
81
+ find_without_caching(*args, &block)
82
+ end
83
+
84
+ def update_all_with_invalidate(updates, conditions = nil)
85
+ invalidate_from_conditions(conditions, :update) do |conditions|
86
+ update_all_without_invalidate(updates, conditions)
87
+ end
88
+ end
89
+
90
+ def delete_all_with_invalidate(conditions = nil)
91
+ invalidate_from_conditions(conditions) do |conditions|
92
+ delete_all_without_invalidate(conditions)
93
+ end
94
+ end
95
+
96
+ def invalidate_from_conditions(conditions, flag = nil)
97
+ if conditions.nil?
98
+ # Just invalidate all indexes.
99
+ result = yield(nil)
100
+ self.increment_version
101
+ return result
102
+ end
103
+
104
+ # Freeze ids to avoid race conditions.
105
+ sql = "SELECT id FROM #{table_name} "
106
+ self.send(:add_conditions!, sql, conditions, self.send(:scope, :find))
107
+ ids = RecordCache.db(self).select_values(sql)
108
+
109
+ return if ids.empty?
110
+ conditions = "id IN (#{ids.join(',')})"
111
+
112
+ if block_given?
113
+ # Capture the ids to invalidate in lambdas.
114
+ lambdas = []
115
+ each_cached_index do |index|
116
+ lambdas << index.invalidate_from_conditions_lambda(conditions)
117
+ end
118
+
119
+ result = yield(conditions)
120
+
121
+ # Finish invalidating with prior attributes.
122
+ lambdas.each {|l| l.call}
123
+ end
124
+
125
+ # Invalidate again afterwards if we are updating (or for the first time if no block was given).
126
+ if flag == :update or not block_given?
127
+ each_cached_index do |index|
128
+ index.invalidate_from_conditions(conditions)
129
+ end
130
+ end
131
+
132
+ result
133
+ end
134
+
135
+ def cached_indexes
136
+ @cached_indexes ||= {}
137
+ end
138
+
139
+ def cached_index(name)
140
+ name = name.to_s
141
+ index = cached_indexes[name]
142
+ index ||= base_class.cached_index(name) if base_class != self and base_class.respond_to?(:cached_index)
143
+ index
144
+ end
145
+
146
+ def add_cached_index(index)
147
+ name = index.name
148
+ count = nil
149
+ # Make sure the key is unique.
150
+ while cached_indexes["#{name}#{count}"]
151
+ count ||= 0
152
+ count += 1
153
+ end
154
+ cached_indexes["#{name}#{count}"] = index
155
+ end
156
+
157
+ def each_cached_index
158
+ cached_index_names.each do |index_name|
159
+ yield cached_index(index_name)
160
+ end
161
+ end
162
+
163
+ def cached_index_names
164
+ names = cached_indexes.keys
165
+ names.concat(base_class.cached_index_names) if base_class != self and base_class.respond_to?(:cached_index_names)
166
+ names.uniq
167
+ end
168
+
169
+ def record_cache_config(opts = nil)
170
+ if opts
171
+ record_cache_config.merge!(opts)
172
+ else
173
+ @record_cache_config ||= RecordCache.config.clone
174
+ end
175
+ end
176
+ end
177
+
178
+ module ActiveRecordExtension
179
+ def self.extended(mod)
180
+ mod.send(:class_inheritable_accessor, :cached_indexes)
181
+ end
182
+
183
+ def record_cache(*args)
184
+ extend RecordCache::ClassMethods
185
+ include RecordCache::InstanceMethods
186
+
187
+ opts = args.pop
188
+ opts[:fields] = args
189
+ opts[:class] = self
190
+ field_lookup = opts.delete(:field_lookup) || []
191
+
192
+ index = RecordCache::Index.new(opts)
193
+ add_cached_index(index)
194
+ first_index = (cached_indexes.size == 1)
195
+
196
+ (class << self; self; end).module_eval do
197
+ if index.includes_id?
198
+ [:first, :all, :set, :raw, :ids].each do |type|
199
+ next if type == :ids and index.name == 'by_id'
200
+ define_method( index.find_method_name(type) ) do |keys|
201
+ if self.send(:scope,:find) and self.send(:scope,:find).any?
202
+ self.method_missing(index.find_method_name(type), keys)
203
+ else
204
+ index.find_by_field(keys, self, type)
205
+ end
206
+ end
207
+ end
208
+ end
209
+
210
+ if not index.auto_name? and not index.full_record?
211
+ field = index.fields.first if index.fields.size == 1
212
+
213
+ define_method( "all_#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
214
+ index.field_lookup(keys, self, field, :all)
215
+ end
216
+
217
+ define_method( "#{index.name.pluralize}_by_#{index.index_field}" ) do |keys|
218
+ index.field_lookup(keys, self, field)
219
+ end
220
+
221
+ define_method( "#{index.name.singularize}_by_#{index.index_field}" ) do |keys|
222
+ index.field_lookup(keys, self, field, :first)
223
+ end
224
+ end
225
+
226
+ if index.auto_name?
227
+ (field_lookup + index.fields).each do |field|
228
+ next if field == index.index_field
229
+ plural_field = field.pluralize
230
+ prefix = index.prefix
231
+ prefix = "#{prefix}_" if prefix
232
+
233
+ define_method( "all_#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
234
+ index.field_lookup(keys, self, field, :all)
235
+ end
236
+
237
+ define_method( "#{prefix}#{plural_field}_by_#{index.index_field}" ) do |keys|
238
+ index.field_lookup(keys, self, field)
239
+ end
240
+
241
+ define_method( "#{prefix}#{field}_by_#{index.index_field}" ) do |keys|
242
+ index.field_lookup(keys, self, field, :first)
243
+ end
244
+ end
245
+ end
246
+
247
+ if first_index
248
+ alias_method_chain :find, :caching
249
+ alias_method_chain :update_all, :invalidate
250
+ alias_method_chain :delete_all, :invalidate
251
+ end
252
+ end
253
+
254
+ if first_index
255
+ after_save :invalidate_record_cache_deferred
256
+ after_destroy :invalidate_record_cache_deferred
257
+ after_commit :complete_deferred_record_cache_invalidations
258
+ after_rollback :complete_deferred_record_cache_invalidations
259
+ end
260
+ end
261
+ end
262
+ end
263
+
264
+ ActiveRecord::Base.send(:extend, RecordCache::ActiveRecordExtension)
@@ -0,0 +1,208 @@
1
+ require File.dirname(__FILE__) + '/test_helper.rb'
2
+
3
+ RecordCache::Set.source_tracking = true
4
+
5
+ class CreateTables < ActiveRecord::Migration
6
+ def self.up
7
+ down
8
+ create_table :pets do |t|
9
+ t.column :breed_id, :bigint
10
+ t.column :name, :string
11
+ t.column :color_id, :bigint
12
+ t.column :sex, :char
13
+ t.column :type, :string
14
+ end
15
+
16
+ create_table :breeds do |t|
17
+ t.column :name, :string
18
+ end
19
+
20
+ create_table :colors do |t|
21
+ t.column :name, :string
22
+ end
23
+ end
24
+
25
+ def self.down
26
+ drop_table :pets rescue nil
27
+ drop_table :breeds rescue nil
28
+ drop_table :colors rescue nil
29
+ end
30
+ end
31
+
32
+ CreateTables.up
33
+
34
+ class Pet < ActiveRecord::Base
35
+ belongs_to :breed
36
+ belongs_to :color
37
+
38
+ record_cache :by => :id
39
+ record_cache :id, :by => :breed_id
40
+ record_cache :id, :by => :color_id, :write_ahead => true
41
+
42
+ record_cache :id, :by => :color_id, :scope => {:sex => 'm'}, :prefix => 'male'
43
+ record_cache :id, :by => :color_id, :scope => {:sex => 'f'}, :prefix => 'female'
44
+ record_cache :id, :by => :color_id, :scope => {:sex => ['m','f']}, :name => 'all_colors'
45
+ end
46
+
47
+ class Dog < Pet
48
+ end
49
+
50
+ class Cat < Pet
51
+ end
52
+
53
+ class Breed < ActiveRecord::Base
54
+ end
55
+
56
+ class Color < ActiveRecord::Base
57
+ end
58
+
59
+ class RecordCacheTest < Test::Unit::TestCase
60
+ context "With a memcache and db connection" do
61
+ setup do
62
+ system('memcached -d')
63
+ CACHE.reset
64
+ CreateTables.up
65
+ CacheVersionMigration.up
66
+ end
67
+
68
+ teardown do
69
+ system('killall memcached')
70
+ CreateTables.down
71
+ CacheVersionMigration.down
72
+ RecordCache::Index.enable_db
73
+ end
74
+
75
+ should 'create field lookup functions' do
76
+ dog = Breed.new(:name => 'pitbull retriever')
77
+ cat = Breed.new(:name => 'house cat')
78
+ willy = Cat.create(:name => 'Willy', :breed => cat)
79
+ daisy = Dog.create(:name => 'Daisy', :breed => dog)
80
+
81
+ expected = {dog.id => daisy.id, cat.id => willy.id}
82
+ assert_equal expected, Pet.id_by_breed_id([dog.id, cat.id, 100, 101])
83
+ end
84
+
85
+ should 'return cached values without accessing the database' do
86
+ color = Color.new(:name => 'black & white')
87
+ dog = Breed.new(:name => 'pitbull retriever')
88
+ cat = Breed.new(:name => 'house cat')
89
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => dog)
90
+ willy = Cat.create(:name => 'Willy', :color => color, :breed => cat)
91
+
92
+ Pet.find(daisy.id, willy.id)
93
+ Dog.find_all_by_color_id(color.id)
94
+ Dog.find_all_by_breed_id(dog.id)
95
+
96
+ RecordCache::Index.disable_db
97
+
98
+ assert_equal Dog, Dog.find(daisy.id).class
99
+ assert_equal daisy, Dog.find(daisy.id)
100
+ assert_equal Cat, Cat.find(willy.id).class
101
+ assert_equal willy, Cat.find(willy.id)
102
+ assert_equal [daisy], Dog.find_all_by_color_id(color.id)
103
+ assert_equal [willy], Cat.find_all_by_color_id(color.id)
104
+ assert_equal [daisy], Dog.find_all_by_breed_id(dog.id)
105
+
106
+ RecordCache::Index.enable_db
107
+
108
+ assert_raises(ActiveRecord::RecordNotFound) do
109
+ Dog.find(willy.id)
110
+ end
111
+
112
+ assert_raises(ActiveRecord::RecordNotFound) do
113
+ Cat.find(daisy.id)
114
+ end
115
+ end
116
+
117
+ should 'return multiple cached values without accessing the database' do
118
+ color1 = Color.new(:name => 'black & white')
119
+ color2 = Color.new(:name => 'speckled')
120
+ breed1 = Breed.new(:name => 'pitbull retriever')
121
+ breed2 = Breed.new(:name => 'pitbull terrier')
122
+ daisy = Dog.create(:name => 'Daisy', :color => color1, :breed => breed1)
123
+ sammy = Dog.create(:name => 'Sammy', :color => color1, :breed => breed2)
124
+
125
+ Dog.find(daisy.id, sammy.id)
126
+ Dog.find_all_by_color_id(color1.id)
127
+ Dog.find_all_by_breed_id([breed1.id, breed2.id])
128
+
129
+ RecordCache::Index.disable_db
130
+
131
+ assert_equal [daisy, sammy].to_set, Dog.find(daisy.id, sammy.id).to_set
132
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_color_id(color1.id).to_set
133
+ assert_equal [daisy, sammy].to_set, Dog.find_all_by_breed_id([breed1.id, breed2.id]).to_set
134
+ assert_equal [sammy, daisy].to_set, Dog.find_all_by_breed_id([breed2.id, breed1.id]).to_set
135
+ assert_equal [daisy].to_set, Dog.find_all_by_breed_id(breed1.id).to_set
136
+
137
+ # Alternate find methods.
138
+ #assert_equal [sammy.id, daisy.id], Dog.find_set_by_breed_id([breed2.id, breed1.id]).ids
139
+ assert_equal [sammy.id, daisy.id].to_set, Dog.find_ids_by_breed_id([breed2.id, breed1.id]).to_set
140
+
141
+ assert_equal daisy, Dog.find_by_color_id(color1.id)
142
+ assert_equal daisy, Dog.find_by_breed_id([breed1.id, breed2.id])
143
+ assert_equal sammy, Dog.find_by_breed_id([breed2.id, breed1.id])
144
+
145
+ baseball = Dog.create(:name => 'Baseball', :color => color2, :breed => breed1)
146
+
147
+ RecordCache::Index.enable_db
148
+
149
+ assert_equal [daisy, baseball], Dog.find_all_by_breed_id(breed1.id)
150
+ end
151
+
152
+ should 'create raw find methods' do
153
+ daisy = Dog.create(:name => 'Daisy')
154
+ sammy = Dog.create(:name => 'Sammy')
155
+
156
+ Dog.find(daisy.id, sammy.id)
157
+ RecordCache::Index.disable_db
158
+
159
+ raw_records = Dog.find_raw_by_id([sammy.id, daisy.id])
160
+ assert_equal ['Sammy', 'Daisy'], raw_records.collect {|r| r['name']}
161
+ end
162
+
163
+ should 'cache indexes using scope' do
164
+ color = Color.new(:name => 'black & white')
165
+ breed1 = Breed.new(:name => 'pitbull retriever')
166
+ breed2 = Breed.new(:name => 'pitbull terrier')
167
+ daisy = Dog.create(:name => 'Daisy', :color => color, :breed => breed1, :sex => 'f')
168
+ sammy = Dog.create(:name => 'Sammy', :color => color, :breed => breed2, :sex => 'm')
169
+
170
+ assert_equal [sammy], Dog.find_all_male_by_color_id(color.id)
171
+ assert_equal [daisy], Dog.find_all_female_by_color_id(color.id)
172
+ assert_equal [daisy, sammy], Dog.find_all_colors(color.id)
173
+
174
+ cousin = Dog.create(:name => 'Cousin', :color => color, :breed => breed2, :sex => 'm')
175
+
176
+ assert_equal [sammy, cousin], Dog.find_all_male_by_color_id(color.id)
177
+ assert_equal [daisy, sammy, cousin], Dog.find_all_colors(color.id)
178
+ end
179
+
180
+ should 'yield cached indexes' do
181
+ count = 0
182
+ Dog.each_cached_index do |index|
183
+ count += 1
184
+ end
185
+ assert_equal 6, count
186
+ end
187
+
188
+ should 'invalidate indexes on save' do
189
+ b_w = Color.new(:name => 'black & white')
190
+ brown = Color.new(:name => 'brown')
191
+ breed = Breed.new(:name => 'mutt')
192
+ daisy = Dog.create(:name => 'Daisy', :color => b_w, :breed => breed, :sex => 'f')
193
+
194
+ assert_equal daisy, Dog.find_by_color_id(b_w.id)
195
+
196
+ daisy.name = 'Molly'
197
+ daisy.color = brown
198
+ daisy.save
199
+
200
+ assert_equal 'Molly', daisy.name
201
+ assert_equal brown.id, daisy.color_id
202
+
203
+ assert_equal daisy, Dog.find_by_color_id(brown.id)
204
+ assert_equal nil, Dog.find_by_color_id(b_w.id)
205
+ end
206
+
207
+ end
208
+ end
@@ -0,0 +1,29 @@
1
+ require 'test/unit'
2
+ require 'rubygems'
3
+ require 'shoulda'
4
+ require 'mocha'
5
+
6
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../lib"
7
+ ['deep_clonable', 'ordered_set', 'cache_version', 'model_set', 'memcache', 'deferrable'].each do |dir|
8
+ $LOAD_PATH.unshift File.dirname(__FILE__) + "/../../#{dir}/lib"
9
+ end
10
+
11
+ require 'record_cache'
12
+
13
+ ['lib/after_commit', 'lib/after_commit/active_record', 'lib/after_commit/connection_adapters', 'init'].each do |file|
14
+ require File.dirname(__FILE__) + "/../../after_commit/#{file}"
15
+ end
16
+
17
+ class Test::Unit::TestCase
18
+ end
19
+
20
+ CACHE = Memcache.new(:servers => 'localhost')
21
+ ActiveRecord::Base.establish_connection(
22
+ :adapter => "postgresql",
23
+ :host => "localhost",
24
+ :username => "postgres",
25
+ :password => "",
26
+ :database => "test"
27
+ )
28
+ ActiveRecord::Migration.verbose = false
29
+ ActiveRecord::Base.connection.client_min_messages = 'panic'
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: record_cache
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.6
5
+ platform: ruby
6
+ authors:
7
+ - Justin Balthrop
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-11-20 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: after_commit
17
+ type: :runtime
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">"
22
+ - !ruby/object:Gem::Version
23
+ version: 1.0.0
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: deferrable
27
+ type: :runtime
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.1.0
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: memcache
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">"
42
+ - !ruby/object:Gem::Version
43
+ version: 1.0.0
44
+ version:
45
+ - !ruby/object:Gem::Dependency
46
+ name: cache_version
47
+ type: :runtime
48
+ version_requirement:
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">"
52
+ - !ruby/object:Gem::Version
53
+ version: 0.9.4
54
+ version:
55
+ description: Active Record caching and indexing in memcache
56
+ email: code@justinbalthrop.com
57
+ executables: []
58
+
59
+ extensions: []
60
+
61
+ extra_rdoc_files:
62
+ - LICENSE
63
+ - README.rdoc
64
+ files:
65
+ - .gitignore
66
+ - LICENSE
67
+ - README.rdoc
68
+ - Rakefile
69
+ - VERSION
70
+ - lib/record_cache.rb
71
+ - lib/record_cache/index.rb
72
+ - lib/record_cache/scope.rb
73
+ - lib/record_cache/set.rb
74
+ - test/record_cache_test.rb
75
+ - test/test_helper.rb
76
+ has_rdoc: true
77
+ homepage: http://github.com/ninjudd/record_cache
78
+ licenses: []
79
+
80
+ post_install_message:
81
+ rdoc_options:
82
+ - --charset=UTF-8
83
+ require_paths:
84
+ - lib
85
+ required_ruby_version: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: "0"
90
+ version:
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: "0"
96
+ version:
97
+ requirements: []
98
+
99
+ rubyforge_project:
100
+ rubygems_version: 1.3.5
101
+ signing_key:
102
+ specification_version: 3
103
+ summary: Active Record caching and indexing in memcache. An alternative to cache_fu
104
+ test_files:
105
+ - test/record_cache_test.rb
106
+ - test/test_helper.rb