identity_cache 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ module IdentityCache
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,71 @@
1
+ require 'monitor'
2
+
3
+ module IdentityCache
4
+ class MemoizedCacheProxy
5
+ attr_writer :memcache
6
+
7
+ def initialize(memcache = nil)
8
+ @memcache = memcache || Rails.cache
9
+ @key_value_maps = Hash.new {|h, k| h[k] = {} }
10
+ end
11
+
12
+ def memoized_key_values
13
+ @key_value_maps[Thread.current.object_id]
14
+ end
15
+
16
+ def with_memoization(&block)
17
+ Thread.current[:memoizing_idc] = true
18
+ yield
19
+ ensure
20
+ clear_memoization
21
+ Thread.current[:memoizing_idc] = false
22
+ end
23
+
24
+ def write(key, value)
25
+ memoized_key_values[key] = value if memoizing?
26
+ @memcache.write(key, value)
27
+ end
28
+
29
+ def read(key)
30
+ if memoizing?
31
+ memoized_key_values[key] ||= @memcache.read(key)
32
+ else
33
+ @memcache.read(key)
34
+ end
35
+ end
36
+
37
+ def delete(key)
38
+ memoized_key_values.delete(key) if memoizing?
39
+ @memcache.delete(key)
40
+ end
41
+
42
+ def read_multi(*keys)
43
+ hash = {}
44
+
45
+ if memoizing?
46
+ keys.reduce({}) do |hash, key|
47
+ hash[key] = memoized_key_values[key] if memoized_key_values[key].present?
48
+ hash
49
+ end
50
+ end
51
+
52
+ missing_keys = keys - hash.keys
53
+ hash.merge(@memcache.read_multi(*missing_keys))
54
+ end
55
+
56
+ def clear
57
+ clear_memoization
58
+ @memcache.clear
59
+ end
60
+
61
+ private
62
+
63
+ def clear_memoization
64
+ @key_value_maps.delete(Thread.current.object_id)
65
+ end
66
+
67
+ def memoizing?
68
+ Thread.current[:memoizing_idc]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,73 @@
1
+ require "test_helper"
2
+
3
+ class AttributeCacheTest < IdentityCache::TestCase
4
+ def setup
5
+ super
6
+ AssociatedRecord.cache_attribute :name
7
+ AssociatedRecord.cache_attribute :record, :by => [:id, :name]
8
+
9
+ @parent = Record.create!(:title => 'bob')
10
+ @record = @parent.associated_records.create!(:name => 'foo')
11
+ @name_attribute_key = "IDC:attribute:AssociatedRecord:name:id:#{cache_hash(@record.id.to_s)}"
12
+ @blob_key = "IDC:blob:AssociatedRecord:#{cache_hash("id:integer,name:string,record_id:integer")}:1"
13
+ end
14
+
15
+ def test_attribute_values_are_returned_on_cache_hits
16
+ IdentityCache.cache.expects(:read).with(@name_attribute_key).returns('foo')
17
+ assert_equal 'foo', AssociatedRecord.fetch_name_by_id(1)
18
+ end
19
+
20
+ def test_attribute_values_are_fetched_and_returned_on_cache_misses
21
+ IdentityCache.cache.expects(:read).with(@name_attribute_key).returns(nil)
22
+ Record.connection.expects(:select_value).with("SELECT name FROM associated_records WHERE id = 1 LIMIT 1").returns('foo')
23
+ assert_equal 'foo', AssociatedRecord.fetch_name_by_id(1)
24
+ end
25
+
26
+ def test_attribute_values_are_stored_in_the_cache_on_cache_misses
27
+
28
+ # Cache miss, so
29
+ IdentityCache.cache.expects(:read).with(@name_attribute_key).returns(nil)
30
+
31
+ # Grab the value of the attribute from the DB
32
+ Record.connection.expects(:select_value).with("SELECT name FROM associated_records WHERE id = 1 LIMIT 1").returns('foo')
33
+
34
+ # And write it back to the cache
35
+ IdentityCache.cache.expects(:write).with(@name_attribute_key, 'foo').returns(nil)
36
+
37
+ assert_equal 'foo', AssociatedRecord.fetch_name_by_id(1)
38
+ end
39
+
40
+ def test_cached_attribute_values_are_expired_from_the_cache_when_an_existing_record_is_saved
41
+ IdentityCache.cache.expects(:delete).with(@name_attribute_key)
42
+ IdentityCache.cache.expects(:delete).with(@blob_key)
43
+ @record.save!
44
+ end
45
+
46
+ def test_cached_attribute_values_are_expired_from_the_cache_when_an_existing_record_with_changed_attributes_is_saved
47
+ IdentityCache.cache.expects(:delete).with(@name_attribute_key)
48
+ IdentityCache.cache.expects(:delete).with(@blob_key)
49
+ @record.name = 'bar'
50
+ @record.save!
51
+ end
52
+
53
+ def test_cached_attribute_values_are_expired_from_the_cache_when_an_existing_record_is_destroyed
54
+ IdentityCache.cache.expects(:delete).with(@name_attribute_key)
55
+ IdentityCache.cache.expects(:delete).with(@blob_key)
56
+ @record.destroy
57
+ end
58
+
59
+ def test_cached_attribute_values_are_expired_from_the_cache_when_a_new_record_is_saved
60
+ IdentityCache.cache.expects(:delete).with("IDC:blob:AssociatedRecord:#{cache_hash("id:integer,name:string,record_id:integer")}:2")
61
+ @parent.associated_records.create(:name => 'bar')
62
+ end
63
+
64
+ def test_fetching_by_attribute_delegates_to_block_if_transactions_are_open
65
+ IdentityCache.cache.expects(:read).with(@name_attribute_key).never
66
+
67
+ Record.connection.expects(:select_value).with("SELECT name FROM associated_records WHERE id = 1 LIMIT 1").returns('foo')
68
+
69
+ @record.transaction do
70
+ assert_equal 'foo', AssociatedRecord.fetch_name_by_id(1)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,89 @@
1
+ require "test_helper"
2
+
3
+ class DenormalizedHasManyTest < IdentityCache::TestCase
4
+ def setup
5
+ super
6
+ Record.cache_has_many :associated_records, :embed => true
7
+
8
+ @record = Record.new(:title => 'foo')
9
+ @record.associated_records << AssociatedRecord.new(:name => 'bar')
10
+ @record.associated_records << AssociatedRecord.new(:name => 'baz')
11
+ @record.save
12
+ @record.reload
13
+ end
14
+
15
+ def test_uncached_record_from_the_db_will_use_normal_association
16
+ expected = @record.associated_records
17
+ record_from_db = Record.find(@record.id)
18
+
19
+ Record.any_instance.expects(:associated_records).returns(expected)
20
+
21
+ assert_equal @record, record_from_db
22
+ assert_equal expected, record_from_db.fetch_associated_records
23
+ end
24
+
25
+ def test_on_cache_hit_record_should_come_back_with_cached_association
26
+ Record.fetch(@record.id) # warm cache
27
+
28
+ record_from_cache_hit = Record.fetch(@record.id)
29
+ assert_equal @record, record_from_cache_hit
30
+
31
+ expected = @record.associated_records
32
+ Record.any_instance.expects(:associated_records).never
33
+ assert_equal expected, record_from_cache_hit.fetch_associated_records
34
+ end
35
+
36
+ def test_on_cache_miss_record_should_embed_associated_objects_and_return
37
+ record_from_cache_miss = Record.fetch(@record.id)
38
+ expected = @record.associated_records
39
+
40
+ assert_equal @record, record_from_cache_miss
41
+ assert_equal expected, record_from_cache_miss.fetch_associated_records
42
+ end
43
+
44
+ def test_changes_in_associated_records_should_expire_the_parents_cache
45
+ Record.fetch(@record.id)
46
+ key = @record.primary_cache_index_key
47
+ assert_not_nil IdentityCache.cache.read(key)
48
+
49
+ IdentityCache.cache.expects(:delete).with(key)
50
+ @record.associated_records.first.save
51
+ end
52
+
53
+ def test_changes_in_associated_records_foreign_keys_should_expire_new_parent_and_old_parents_cache
54
+ @associatated_record = @record.associated_records.first
55
+ old_key = @record.primary_cache_index_key
56
+ @new_record = Record.create
57
+ new_key = @new_record.primary_cache_index_key
58
+
59
+ IdentityCache.cache.expects(:delete).with(old_key)
60
+ IdentityCache.cache.expects(:delete).with(new_key)
61
+ @associatated_record.record_id = @new_record.id
62
+ @associatated_record.save!
63
+ end
64
+
65
+ def test_cached_associations_after_commit_hook_will_not_fail_on_undefined_parent_association
66
+ ar = AssociatedRecord.new
67
+ ar.save
68
+ assert_nothing_raised { ar.expire_parent_cache }
69
+ end
70
+
71
+ def test_cache_without_guessable_inverse_name_raises
72
+ assert_raises IdentityCache::InverseAssociationError do
73
+ Record.cache_has_many :polymorphic_records, :embed => true
74
+ end
75
+ end
76
+
77
+ def test_cache_without_guessable_inverse_name_does_not_raise_when_inverse_name_specified
78
+ assert_nothing_raised do
79
+ Record.cache_has_many :polymorphic_records, :inverse_name => :owner, :embed => true
80
+ end
81
+ end
82
+
83
+ def test_touching_associated_records_should_expire_itself_and_the_parents_cache
84
+ child = @record.associated_records.first
85
+ IdentityCache.cache.expects(:delete).with(child.primary_cache_index_key).once
86
+ IdentityCache.cache.expects(:delete).with(@record.primary_cache_index_key)
87
+ child.touch
88
+ end
89
+ end
@@ -0,0 +1,99 @@
1
+ require "test_helper"
2
+
3
+ class DenormalizedHasOneTest < IdentityCache::TestCase
4
+ def setup
5
+ super
6
+ Record.cache_has_one :associated
7
+ Record.cache_index :title, :unique => true
8
+ @record = Record.new(:title => 'foo')
9
+ @record.associated = AssociatedRecord.new(:name => 'bar')
10
+ @record.save
11
+
12
+ @record.reload
13
+ end
14
+
15
+ def test_on_cache_miss_record_should_embed_associated_object
16
+ IdentityCache.cache.expects(:read).with(@record.secondary_cache_index_key_for_current_values([:title]))
17
+ IdentityCache.cache.expects(:read).with(@record.primary_cache_index_key)
18
+
19
+ record_from_cache_miss = Record.fetch_by_title('foo')
20
+
21
+ assert_equal @record, record_from_cache_miss
22
+ assert_not_nil @record.fetch_associated
23
+ assert_equal @record.associated, record_from_cache_miss.fetch_associated
24
+ end
25
+
26
+ def test_on_cache_miss_record_should_embed_nil_object
27
+ @record.expects(:associated => nil)
28
+ Record.expects(:find_by_id).with(@record.id, :include => Record.cache_fetch_includes).returns(@record)
29
+ IdentityCache.cache.expects(:read).with(@record.secondary_cache_index_key_for_current_values([:title]))
30
+ IdentityCache.cache.expects(:read).with(@record.primary_cache_index_key)
31
+
32
+ record_from_cache_miss = Record.fetch_by_title('foo')
33
+ record_from_cache_miss.expects(:associated).never
34
+
35
+ assert_equal @record, record_from_cache_miss
36
+ 5.times do
37
+ assert_nil record_from_cache_miss.fetch_associated
38
+ end
39
+ end
40
+
41
+ def test_on_record_from_the_db_will_use_normal_association
42
+ record_from_db = Record.find_by_title('foo')
43
+
44
+ assert_equal @record, record_from_db
45
+ assert_not_nil record_from_db.fetch_associated
46
+ end
47
+
48
+ def test_on_cache_hit_record_should_come_back_with_cached_association
49
+ Record.expects(:find_by_id).with(1, :include => Record.cache_fetch_includes).once.returns(@record)
50
+ Record.fetch_by_title('foo')
51
+
52
+ record_from_cache_hit = Record.fetch_by_title('foo')
53
+ expected = @record.associated
54
+
55
+ assert_equal @record, record_from_cache_hit
56
+ assert_equal expected, record_from_cache_hit.fetch_associated
57
+ end
58
+
59
+ def test_on_cache_hit_record_should_come_back_with_cached_nil_association
60
+ @record.expects(:associated => nil)
61
+ Record.expects(:find_by_id).with(1, :include => Record.cache_fetch_includes).once.returns(@record)
62
+ Record.fetch_by_title('foo')
63
+
64
+ record_from_cache_hit = Record.fetch_by_title('foo')
65
+ record_from_cache_hit.expects(:associated).never
66
+
67
+ assert_equal @record, record_from_cache_hit
68
+ 5.times do
69
+ assert_nil record_from_cache_hit.fetch_associated
70
+ end
71
+ end
72
+
73
+ def test_changes_in_associated_record_should_expire_the_parents_cache
74
+ Record.fetch_by_title('foo')
75
+ key = @record.primary_cache_index_key
76
+ assert_not_nil IdentityCache.cache.read(key)
77
+
78
+ IdentityCache.cache.expects(:delete).at_least(1).with(key)
79
+ @record.associated.save
80
+ end
81
+
82
+ def test_cached_associations_after_commit_hook_will_not_fail_on_undefined_parent_association
83
+ ar = AssociatedRecord.new
84
+ ar.save
85
+ assert_nothing_raised { ar.expire_parent_cache }
86
+ end
87
+
88
+ def test_cache_without_guessable_inverse_name_raises
89
+ assert_raises IdentityCache::InverseAssociationError do
90
+ Record.cache_has_one :polymorphic_record, :embed => true
91
+ end
92
+ end
93
+
94
+ def test_cache_without_guessable_inverse_name_does_not_raise_when_inverse_name_specified
95
+ assert_nothing_raised do
96
+ Record.cache_has_one :polymorphic_record, :inverse_name => :owner, :embed => true
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,144 @@
1
+ require "test_helper"
2
+
3
+ class FetchMultiTest < IdentityCache::TestCase
4
+ def setup
5
+ super
6
+ @bob = Record.create!(:title => 'bob')
7
+ @joe = Record.create!(:title => 'joe')
8
+ @fred = Record.create!(:title => 'fred')
9
+ @bob_blob_key = "IDC:blob:Record:#{cache_hash("created_at:datetime,id:integer,title:string,updated_at:datetime")}:1"
10
+ @joe_blob_key = "IDC:blob:Record:#{cache_hash("created_at:datetime,id:integer,title:string,updated_at:datetime")}:2"
11
+ @fred_blob_key = "IDC:blob:Record:#{cache_hash("created_at:datetime,id:integer,title:string,updated_at:datetime")}:3"
12
+ @tenth_blob_key = "IDC:blob:Record:#{cache_hash("created_at:datetime,id:integer,title:string,updated_at:datetime")}:10"
13
+ end
14
+
15
+ def test_fetch_multi_with_no_records
16
+ assert_equal [], Record.fetch_multi
17
+ end
18
+
19
+ def test_fetch_multi_with_all_hits
20
+ cache_response = {}
21
+ cache_response[@bob_blob_key] = @bob
22
+ cache_response[@joe_blob_key] = @joe
23
+ cache_response[@fred_blob_key] = @fred
24
+ IdentityCache.cache.expects(:read_multi).with(@bob_blob_key, @joe_blob_key, @fred_blob_key).returns(cache_response)
25
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(@bob.id, @joe.id, @fred.id)
26
+ end
27
+
28
+ def test_fetch_multi_with_all_misses
29
+ cache_response = {}
30
+ cache_response[@bob_blob_key] = nil
31
+ cache_response[@joe_blob_key] = nil
32
+ cache_response[@fred_blob_key] = nil
33
+ IdentityCache.cache.expects(:read_multi).with(@bob_blob_key, @joe_blob_key, @fred_blob_key).returns(cache_response)
34
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(@bob.id, @joe.id, @fred.id)
35
+ end
36
+
37
+ def test_fetch_multi_with_mixed_hits_and_misses
38
+ cache_response = {}
39
+ cache_response[@bob_blob_key] = @bob
40
+ cache_response[@joe_blob_key] = nil
41
+ cache_response[@fred_blob_key] = @fred
42
+ IdentityCache.cache.expects(:read_multi).with(@bob_blob_key, @joe_blob_key, @fred_blob_key).returns(cache_response)
43
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(@bob.id, @joe.id, @fred.id)
44
+ end
45
+
46
+ def test_fetch_multi_with_mixed_hits_and_misses_and_responses_in_the_wrong_order
47
+ cache_response = {}
48
+ cache_response[@bob_blob_key] = nil
49
+ cache_response[@joe_blob_key] = nil
50
+ cache_response[@fred_blob_key] = @fred
51
+ IdentityCache.cache.expects(:read_multi).with(@bob_blob_key, @fred_blob_key, @joe_blob_key).returns(cache_response)
52
+ assert_equal [@bob, @fred, @joe], Record.fetch_multi(@bob.id, @fred.id, @joe.id)
53
+ end
54
+
55
+ def test_fetch_multi_with_mixed_hits_and_misses_and_non_existant_keys_1
56
+ populate_only_fred
57
+
58
+ IdentityCache.cache.expects(:read_multi).with(@tenth_blob_key, @bob_blob_key, @joe_blob_key, @fred_blob_key).returns(@cache_response)
59
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(10, @bob.id, @joe.id, @fred.id)
60
+ end
61
+
62
+ def test_fetch_multi_with_mixed_hits_and_misses_and_non_existant_keys_2
63
+ populate_only_fred
64
+
65
+ IdentityCache.cache.expects(:read_multi).with(@fred_blob_key, @bob_blob_key, @tenth_blob_key, @joe_blob_key).returns(@cache_response)
66
+ assert_equal [@fred, @bob, @joe], Record.fetch_multi(@fred.id, @bob.id, 10, @joe.id)
67
+ end
68
+
69
+
70
+ def test_fetch_multi_works_with_nils
71
+ cache_result = {1 => IdentityCache::CACHED_NIL, 2 => IdentityCache::CACHED_NIL}
72
+ fetch_result = {1 => nil, 2 => nil}
73
+
74
+ IdentityCache.cache.expects(:read_multi).with(1,2).times(2).returns({1 => nil, 2 => nil}, cache_result)
75
+ IdentityCache.cache.expects(:write).with(1, IdentityCache::CACHED_NIL).once
76
+ IdentityCache.cache.expects(:write).with(2, IdentityCache::CACHED_NIL).once
77
+
78
+ results = IdentityCache.fetch_multi(1,2) do |keys|
79
+ [nil, nil]
80
+ end
81
+ assert_equal fetch_result, results
82
+
83
+ results = IdentityCache.fetch_multi(1,2) do |keys|
84
+ flunk "Contents should have been fetched from cache successfully"
85
+ end
86
+
87
+ assert_equal fetch_result, results
88
+ end
89
+
90
+ def test_fetch_multi_duplicate_ids
91
+ assert_equal [@joe, @bob, @joe], Record.fetch_multi(@joe.id, @bob.id, @joe.id)
92
+ end
93
+
94
+ def test_fetch_multi_with_open_transactions_hits_the_database
95
+ Record.connection.expects(:open_transactions).at_least_once.returns(1)
96
+ IdentityCache.cache.expects(:read_multi).never
97
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(@bob.id, @joe.id, @fred.id)
98
+ end
99
+
100
+ def test_fetch_multi_with_open_transactions_returns_results_in_the_order_of_the_passed_ids
101
+ Record.connection.expects(:open_transactions).at_least_once.returns(1)
102
+ assert_equal [@joe, @bob, @fred], Record.fetch_multi(@joe.id, @bob.id, @fred.id)
103
+ end
104
+
105
+ def test_fetch_multi_with_duplicate_ids_in_transaction_returns_results_in_the_order_of_the_passed_ids
106
+ Record.connection.expects(:open_transactions).at_least_once.returns(1)
107
+ assert_equal [@joe, @bob, @joe], Record.fetch_multi(@joe.id, @bob.id, @joe.id)
108
+ end
109
+
110
+ def test_fetch_multi_includes_cached_associations
111
+ Record.send(:cache_has_many, :associated_records, :embed => true)
112
+ Record.send(:cache_has_one, :associated)
113
+
114
+ cache_response = {}
115
+ cache_response[@bob_blob_key] = nil
116
+ cache_response[@joe_blob_key] = nil
117
+ cache_response[@fred_blob_key] = nil
118
+
119
+ IdentityCache.cache.expects(:read_multi).with(@bob_blob_key, @joe_blob_key, @fred_blob_key).returns(cache_response)
120
+
121
+ mock_relation = mock("ActiveRecord::Relation")
122
+ Record.expects(:where).returns(mock_relation)
123
+ mock_relation.expects(:includes).with([:associated_records, :associated]).returns(stub(:all => [@bob, @joe, @fred]))
124
+ assert_equal [@bob, @joe, @fred], Record.fetch_multi(@bob.id, @joe.id, @fred.id)
125
+ end
126
+
127
+ def test_find_batch_coerces_ids_to_primary_key_type
128
+ mock_relation = mock("ActiveRecord::Relation")
129
+ Record.expects(:where).returns(mock_relation)
130
+ mock_relation.expects(:includes).returns(stub(:all => [@bob, @joe, @fred]))
131
+
132
+ Record.find_batch([@bob, @joe, @fred].map(&:id).map(&:to_s))
133
+ end
134
+
135
+ private
136
+
137
+ def populate_only_fred
138
+ @cache_response = {}
139
+ @cache_response[@bob_blob_key] = nil
140
+ @cache_response[@joe_blob_key] = nil
141
+ @cache_response[@tenth_blob_key] = nil
142
+ @cache_response[@fred_blob_key] = @fred
143
+ end
144
+ end