nkallen-cash 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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
+ )
@@ -0,0 +1,6 @@
1
+ test:
2
+ ttl: 604800
3
+ namespace: cache
4
+ sessions: false
5
+ debug: false
6
+ servers: localhost:11211
data/db/schema.rb ADDED
@@ -0,0 +1,11 @@
1
+ ActiveRecord::Schema.define(:version => 1) do
2
+ create_table "stories", :force => true do |t|
3
+ t.string "title", "subtitle"
4
+ t.string "type"
5
+ end
6
+
7
+ create_table "characters", :force => true do |t|
8
+ t.integer "story_id"
9
+ t.string "name"
10
+ end
11
+ end
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