nkallen-cash 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README +132 -0
- data/TODO +19 -0
- data/UNSUPPORTED_FEATURES +14 -0
- data/config/environment.rb +13 -0
- data/config/memcache.yml +6 -0
- data/db/schema.rb +11 -0
- data/lib/cash.rb +49 -0
- data/lib/cash/accessor.rb +78 -0
- data/lib/cash/buffered.rb +129 -0
- data/lib/cash/config.rb +64 -0
- data/lib/cash/finders.rb +36 -0
- data/lib/cash/index.rb +200 -0
- data/lib/cash/local.rb +59 -0
- data/lib/cash/lock.rb +52 -0
- data/lib/cash/mock.rb +82 -0
- data/lib/cash/query/abstract.rb +157 -0
- data/lib/cash/query/calculation.rb +45 -0
- data/lib/cash/query/primary_key.rb +51 -0
- data/lib/cash/query/select.rb +16 -0
- data/lib/cash/transactional.rb +39 -0
- data/lib/cash/util/array.rb +3 -0
- data/lib/cash/write_through.rb +72 -0
- data/spec/cash/accessor_spec.rb +133 -0
- data/spec/cash/active_record_spec.rb +190 -0
- data/spec/cash/calculations_spec.rb +65 -0
- data/spec/cash/finders_spec.rb +336 -0
- data/spec/cash/lock_spec.rb +87 -0
- data/spec/cash/order_spec.rb +166 -0
- data/spec/cash/transactional_spec.rb +573 -0
- data/spec/cash/window_spec.rb +195 -0
- data/spec/cash/write_through_spec.rb +223 -0
- data/spec/spec_helper.rb +53 -0
- metadata +100 -0
data/README
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
=== A single index ===
|
2
|
+
|
3
|
+
{{{
|
4
|
+
$memcache = MemCache.new(config)
|
5
|
+
$memcache.servers = config['servers']
|
6
|
+
$lock = Cash::Lock.new($memcache)
|
7
|
+
$cache = Cash::Transactional.new($memcache, $lock)
|
8
|
+
|
9
|
+
class User
|
10
|
+
is_cached :repository => $cache
|
11
|
+
index :id
|
12
|
+
end
|
13
|
+
}}}
|
14
|
+
|
15
|
+
The `index :id` declaration:
|
16
|
+
|
17
|
+
* Adds a number of annotation to populate the cache when a `user` is created, updated, or deleted.
|
18
|
+
* Overrides some finders to first look in the cache, then the database. For example: `User.find`, `User.find_by_id`, `User.find(:conditions => {:id => ...})`, `User.find(:conditions => ['id = ?', ...])`, `User.find(:conditions => 'id = ...')`, `User.find(:conditions => 'users.id = ...')`, and `User.find(:conditions => '`users`.id = ...')`.
|
19
|
+
* Ensures that queries that must not hit the cache (e.g., if they include `:joins`) will hit the database instead.
|
20
|
+
|
21
|
+
=== Multiple indices ===
|
22
|
+
|
23
|
+
{{{
|
24
|
+
index :id
|
25
|
+
index :screen_name
|
26
|
+
index :email
|
27
|
+
}}}
|
28
|
+
|
29
|
+
This creates three indices, populates them as side effects of CUD operations, and overrides their corresponding finders (e.g., `User.find_by_screen_name` and all of the variations).
|
30
|
+
|
31
|
+
=== Multi-key indices ===
|
32
|
+
|
33
|
+
{{{
|
34
|
+
class Device
|
35
|
+
index [:user_id, :id]
|
36
|
+
end
|
37
|
+
}}}
|
38
|
+
|
39
|
+
This creates an index whose key is the concatenation of the two attributes. Finders will first look in the cache for these keys if they resemble `Device.find(:conditions => {:id => ..., :user_id => ...})`, `User.find(:conditions => "id = ... AND user_id = ...")`, etc.
|
40
|
+
|
41
|
+
==== with_scope support ====
|
42
|
+
|
43
|
+
`with_scope` and the like (`named_scope`, `has_many`, `belongs_to`, etc.) are fully supported. For example, `user.devices.find(1)` will first look in the `Device` cache for the index `[:user_id, :id]`.
|
44
|
+
|
45
|
+
=== Collections ===
|
46
|
+
|
47
|
+
{{{
|
48
|
+
class Devices
|
49
|
+
index [:user_id]
|
50
|
+
end
|
51
|
+
}}}
|
52
|
+
|
53
|
+
Collection indices are supported. For example, many devices may belong to the same user. Indexing devices on `user_id` will append new devices to that key on create, remove on delete, etc. `find` queries with `user_id` in the conditions will hit the cache before the database.
|
54
|
+
|
55
|
+
=== Deletions ===
|
56
|
+
|
57
|
+
In fact, all indices are treated as collection indices, even ones where the key is guaranteed unique (i.e., the cardinality of the index is 1). This allows us to support deletions correctly. `User.find(1)` will first hit the cache; if the cache value is `[]` (the empty array), then nil is returned--the database is never queried.
|
58
|
+
|
59
|
+
=== Complex Queries ===
|
60
|
+
|
61
|
+
{{{
|
62
|
+
class FriendRequest
|
63
|
+
index :requestor_id
|
64
|
+
end
|
65
|
+
}}}
|
66
|
+
|
67
|
+
In this example, `requestor_id` is a collection index. Limit and offset are supported. If the cache is populated, `FriendRequest.find(:all, :conditions => {:requestor_id => ...}, :limit => ..., :offset => ...)` will perform the limits and offsets with array arithmetic rather than a database query.
|
68
|
+
|
69
|
+
=== Ordered indices ===
|
70
|
+
|
71
|
+
{{{
|
72
|
+
class Message
|
73
|
+
index :sender_id, :order => :desc
|
74
|
+
end
|
75
|
+
}}}
|
76
|
+
|
77
|
+
The order declaration will ensure that the index is kept in the correctly sorted order. Only queries with order clauses compatible with the ordering in the index will use the cache: `Message.find(:all, :conditions => {:sender_id => ...})`, `Message.find(:all, :conditions => {:sender_id => ...}, :order => 'id DESC')`. Order clauses can be specified in many formats ("`messages`.id DESC", "`messages`.`id` DESC", and so forth), but ordering MUST be on the primary key column. Note that `Message.find(:all, :conditions => {:sender_id => ...}, :order => 'id ASC')` will NOT use the cache because the order in the query does not match the index.
|
78
|
+
|
79
|
+
=== Window indices ===
|
80
|
+
|
81
|
+
{{{
|
82
|
+
class Message
|
83
|
+
index :sender_id, :limit => 500, :buffer => 100
|
84
|
+
end
|
85
|
+
}}}
|
86
|
+
|
87
|
+
With a limit attribute, indices will only store limit + buffer in the cache. As new objects are created the index will be truncated, and as objects are destroyed, the cache will be refreshed if it has fewer than the limit of items. The buffer is how many "extra" items to keep around in case of deletes.
|
88
|
+
|
89
|
+
=== Calculations ===
|
90
|
+
|
91
|
+
`Message.count(:all, :conditions => {:sender_id => ...})` will use the cache rather than the database. This happens for "free" -- no additional declarations are necessary.
|
92
|
+
|
93
|
+
=== Transactions ===
|
94
|
+
|
95
|
+
Because of the parallel requests writing to the same indices, race conditions are possible. We have created a pessimistic "transactional" memcache client to handle the locking issues.
|
96
|
+
|
97
|
+
The memcache client library has been enhanced to simulate transactions.
|
98
|
+
|
99
|
+
{{{
|
100
|
+
CACHE.transaction do
|
101
|
+
CACHE.set(key1, value1)
|
102
|
+
CACHE.set(key2, value2)
|
103
|
+
end
|
104
|
+
}}}
|
105
|
+
|
106
|
+
The writes to the cache are buffered until the transaction is committed. Reads within the transaction read from the buffer. The writes are performed as if atomically, by acquiring locks, performing writes, and finally releasing locks. Special attention has been paid to ensure that deadlocks cannot occur and that the critical region (the duration of lock ownership) is as small as possible.
|
107
|
+
|
108
|
+
Writes are not truly atomic as reads do not pay attention to locks. Therefore, it is possible to peak inside a partially committed transaction. This is a performance compromise, since acquiring a lock for a read was deemed too expensive. Again, the critical region is as small as possible, reducing the frequency of such "peeks".
|
109
|
+
|
110
|
+
==== Rollbacks ====
|
111
|
+
|
112
|
+
{{{
|
113
|
+
CACHE.transaction do
|
114
|
+
CACHE.set(k, v)
|
115
|
+
raise
|
116
|
+
end
|
117
|
+
}}}
|
118
|
+
|
119
|
+
Because transactions buffer writes, an exception in a transaction ensures that the writes are cleanly rolled-back (i.e., never committed to memcache). Database transactions are wrapped in memcache transactions, ensuring a database rollback also rolls back cache transactions.
|
120
|
+
|
121
|
+
==== Nested transactions ====
|
122
|
+
|
123
|
+
{{{
|
124
|
+
CACHE.transaction do
|
125
|
+
CACHE.set(k, v)
|
126
|
+
CACHE.transaction do
|
127
|
+
CACHE.get(k)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
}}}
|
131
|
+
|
132
|
+
Nested transactions are fully supported, with partial rollback and (apparent) partial commitment (this is simulated with nested buffers).
|
data/TODO
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
TOP PRIORITY
|
2
|
+
* cache_fu adapter
|
3
|
+
|
4
|
+
REFACTOR
|
5
|
+
* Reorganize transactional spec
|
6
|
+
* Clarify terminology around cache/key/index, etc.
|
7
|
+
|
8
|
+
INFRASTRUCTURE
|
9
|
+
|
10
|
+
NEW FEATURES
|
11
|
+
* transactional get multi isn't really multi
|
12
|
+
|
13
|
+
BUGS
|
14
|
+
* Handle append strategy (using add rather than set?) to avoid race condition
|
15
|
+
|
16
|
+
MISSING TESTS:
|
17
|
+
* missing tests for Klass.transaction do ... end
|
18
|
+
* non "id" pks work but lack test coverage
|
19
|
+
* expire_cache
|
@@ -0,0 +1,14 @@
|
|
1
|
+
* does not work with :dependent => nullify because
|
2
|
+
def nullify_has_many_dependencies(record, reflection_name, association_class, primary_key_name, dependent_conditions)
|
3
|
+
association_class.update_all("#{primary_key_name} = NULL", dependent_conditions)
|
4
|
+
end
|
5
|
+
This does not trigger callbacks
|
6
|
+
* update_all, delete, update_counter, increment_counter, decrement_counter, counter_caches in general - counter caches are replaced by this gem, bear that in mind.
|
7
|
+
* attr_readonly - no technical obstacle, just not yet supported
|
8
|
+
* attributes before typecast behave unpredictably - hard to support
|
9
|
+
* ActiveRecord::Rollback is unsupported - the exception gets swallowed so there isn't an opportunity to rollback the cache transaction - not hard to support
|
10
|
+
* Named bind variables :conditions => ["name = :name", { :name => "37signals!" }] - not hard to support
|
11
|
+
* printf style binds: :conditions => ["name = '%s'", "37signals!"] - not too hard to support
|
12
|
+
* objects as attributes that are serialized. story.title = {:foo => :bar}; customer.balance = Money.new(...) - these could be coerced using Column#type_cast?
|
13
|
+
|
14
|
+
With a lot of these features the issue is not technical but performance. Every special case costs some overhead.
|
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'activerecord'
|
2
|
+
|
3
|
+
ActiveRecord::Base.establish_connection(
|
4
|
+
:adapter => 'mysql',
|
5
|
+
:socket => '/tmp/mysql.sock',
|
6
|
+
:host => 'web030',
|
7
|
+
:port => 3306,
|
8
|
+
:username => 'root',
|
9
|
+
:password => '',
|
10
|
+
:encoding => 'UTF8',
|
11
|
+
:host => 'localhost',
|
12
|
+
:database => 'cash_test'
|
13
|
+
)
|
data/config/memcache.yml
ADDED
data/db/schema.rb
ADDED
data/lib/cash.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
2
|
+
|
3
|
+
require 'rubygems'
|
4
|
+
require 'activesupport'
|
5
|
+
require 'activerecord'
|
6
|
+
|
7
|
+
require 'cash/lock'
|
8
|
+
require 'cash/transactional'
|
9
|
+
require 'cash/write_through'
|
10
|
+
require 'cash/finders'
|
11
|
+
require 'cash/buffered'
|
12
|
+
require 'cash/index'
|
13
|
+
require 'cash/config'
|
14
|
+
require 'cash/accessor'
|
15
|
+
|
16
|
+
require 'cash/query/abstract'
|
17
|
+
require 'cash/query/select'
|
18
|
+
require 'cash/query/primary_key'
|
19
|
+
require 'cash/query/calculation'
|
20
|
+
|
21
|
+
require 'cash/util/array'
|
22
|
+
|
23
|
+
class ActiveRecord::Base
|
24
|
+
def self.is_cached(options = {})
|
25
|
+
include Cash
|
26
|
+
Config.create(self, options)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
module Cash
|
31
|
+
def self.included(active_record_class)
|
32
|
+
active_record_class.class_eval do
|
33
|
+
include Config, Accessor, WriteThrough, Finders
|
34
|
+
extend ClassMethods
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
module ClassMethods
|
39
|
+
def self.extended(active_record_class)
|
40
|
+
class << active_record_class
|
41
|
+
alias_method_chain :transaction, :cache_transaction
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def transaction_with_cache_transaction(&block)
|
46
|
+
repository.transaction { transaction_without_cache_transaction(&block) }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module Cash
|
2
|
+
module Accessor
|
3
|
+
def self.included(a_module)
|
4
|
+
a_module.module_eval do
|
5
|
+
extend ClassMethods
|
6
|
+
include InstanceMethods
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def fetch(keys, options = {}, &block)
|
12
|
+
case keys
|
13
|
+
when Array
|
14
|
+
keys = keys.collect { |key| cache_key(key) }
|
15
|
+
hits = repository.get_multi(keys)
|
16
|
+
if (missed_keys = keys - hits.keys).any?
|
17
|
+
missed_values = block.call(missed_keys)
|
18
|
+
hits.merge!(Hash[*missed_keys.zip(Array(missed_values)).flatten])
|
19
|
+
end
|
20
|
+
hits
|
21
|
+
else
|
22
|
+
repository.get(cache_key(keys), options[:raw]) || (block ? block.call : nil)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def get(keys, options = {}, &block)
|
27
|
+
case keys
|
28
|
+
when Array
|
29
|
+
fetch(keys, options, &block)
|
30
|
+
else
|
31
|
+
fetch(keys, options) do
|
32
|
+
if block_given?
|
33
|
+
add(keys, result = yield(keys))
|
34
|
+
result
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def add(key, value, options = {})
|
41
|
+
repository.add(cache_key(key), value, options[:ttl] || 0, options[:raw])
|
42
|
+
end
|
43
|
+
|
44
|
+
def set(key, value, options = {})
|
45
|
+
repository.set(cache_key(key), value, options[:ttl] || 0, options[:raw])
|
46
|
+
end
|
47
|
+
|
48
|
+
def incr(key, delta = 1, ttl = 0)
|
49
|
+
repository.incr(cache_key(key), delta) || begin
|
50
|
+
repository.set(cache_key(key), (result = yield).to_s, ttl, true)
|
51
|
+
result
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def decr(key, delta = 1, ttl = 0)
|
56
|
+
repository.decr(cache_key(key), delta) || begin
|
57
|
+
repository.set(cache_key(key), (result = yield).to_s, ttl, true)
|
58
|
+
result
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def expire(key)
|
63
|
+
repository.delete(cache_key(key))
|
64
|
+
end
|
65
|
+
|
66
|
+
def cache_key(key)
|
67
|
+
"#{name}/#{key.to_s.gsub(' ', '+')}"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
module InstanceMethods
|
72
|
+
def expire
|
73
|
+
self.class.expire(id)
|
74
|
+
end
|
75
|
+
alias_method :expire_cache, :expire
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,129 @@
|
|
1
|
+
module Cash
|
2
|
+
class Buffered
|
3
|
+
def self.push(cache, lock)
|
4
|
+
if cache.is_a?(Buffered)
|
5
|
+
cache.push
|
6
|
+
else
|
7
|
+
Buffered.new(cache, lock)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def initialize(memcache, lock)
|
12
|
+
@buffer = {}
|
13
|
+
@commands = []
|
14
|
+
@cache = memcache
|
15
|
+
@lock = lock
|
16
|
+
end
|
17
|
+
|
18
|
+
def pop
|
19
|
+
@cache
|
20
|
+
end
|
21
|
+
|
22
|
+
def push
|
23
|
+
NestedBuffered.new(self, @lock)
|
24
|
+
end
|
25
|
+
|
26
|
+
def get(key, *options)
|
27
|
+
if @buffer.has_key?(key)
|
28
|
+
@buffer[key]
|
29
|
+
else
|
30
|
+
@buffer[key] = @cache.get(key, *options)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def set(key, value, *options)
|
35
|
+
@buffer[key] = value
|
36
|
+
buffer_command Command.new(:set, key, value, *options)
|
37
|
+
end
|
38
|
+
|
39
|
+
def incr(key, amount = 1)
|
40
|
+
return unless value = get(key, true)
|
41
|
+
|
42
|
+
@buffer[key] = value.to_i + amount
|
43
|
+
buffer_command Command.new(:incr, key, amount)
|
44
|
+
@buffer[key]
|
45
|
+
end
|
46
|
+
|
47
|
+
def decr(key, amount = 1)
|
48
|
+
return unless value = get(key, true)
|
49
|
+
|
50
|
+
@buffer[key] = [value.to_i - amount, 0].max
|
51
|
+
buffer_command Command.new(:decr, key, amount)
|
52
|
+
@buffer[key]
|
53
|
+
end
|
54
|
+
|
55
|
+
def add(key, value, *options)
|
56
|
+
@buffer[key] = value
|
57
|
+
buffer_command Command.new(:add, key, value, *options)
|
58
|
+
end
|
59
|
+
|
60
|
+
def delete(key, *options)
|
61
|
+
@buffer[key] = nil
|
62
|
+
buffer_command Command.new(:delete, key, *options)
|
63
|
+
end
|
64
|
+
|
65
|
+
def get_multi(args)
|
66
|
+
values = args.collect { |arg| get(arg) }
|
67
|
+
keys_and_values = args.zip(values)
|
68
|
+
keys_and_values_without_nils = keys_and_values.reject { |key, value| value.nil? }
|
69
|
+
shallow_flattened_keys_and_values_without_nils = keys_and_values_without_nils.inject([]) { |result, pair| result += pair }
|
70
|
+
Hash[*shallow_flattened_keys_and_values_without_nils]
|
71
|
+
end
|
72
|
+
|
73
|
+
def flush
|
74
|
+
sorted_keys = @commands.select(&:requires_lock?).collect(&:key).uniq.sort
|
75
|
+
sorted_keys.each do |key|
|
76
|
+
@lock.acquire_lock(key)
|
77
|
+
end
|
78
|
+
perform_commands
|
79
|
+
ensure
|
80
|
+
@buffer = {}
|
81
|
+
sorted_keys.each do |key|
|
82
|
+
@lock.release_lock(key)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def method_missing(method, *args, &block)
|
87
|
+
@cache.send(method, *args, &block)
|
88
|
+
end
|
89
|
+
|
90
|
+
def respond_to?(method)
|
91
|
+
@cache.respond_to?(method)
|
92
|
+
end
|
93
|
+
|
94
|
+
protected
|
95
|
+
def perform_commands
|
96
|
+
@commands.each do |command|
|
97
|
+
command.call(@cache)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def buffer_command(command)
|
102
|
+
@commands << command
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
class NestedBuffered < Buffered
|
107
|
+
def flush
|
108
|
+
perform_commands
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
class Command
|
113
|
+
attr_accessor :key
|
114
|
+
|
115
|
+
def initialize(name, key, *args)
|
116
|
+
@name = name
|
117
|
+
@key = key
|
118
|
+
@args = args
|
119
|
+
end
|
120
|
+
|
121
|
+
def requires_lock?
|
122
|
+
@name == :set
|
123
|
+
end
|
124
|
+
|
125
|
+
def call(cache)
|
126
|
+
cache.send @name, @key, *@args
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|