viximo-cache-money 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (51) hide show
  1. data/LICENSE +201 -0
  2. data/README +204 -0
  3. data/README.markdown +204 -0
  4. data/TODO +17 -0
  5. data/UNSUPPORTED_FEATURES +13 -0
  6. data/config/environment.rb +8 -0
  7. data/config/memcached.yml +4 -0
  8. data/db/schema.rb +18 -0
  9. data/init.rb +1 -0
  10. data/lib/cache_money.rb +105 -0
  11. data/lib/cash/accessor.rb +83 -0
  12. data/lib/cash/adapter/memcache_client.rb +36 -0
  13. data/lib/cash/adapter/memcached.rb +127 -0
  14. data/lib/cash/adapter/redis.rb +144 -0
  15. data/lib/cash/buffered.rb +137 -0
  16. data/lib/cash/config.rb +78 -0
  17. data/lib/cash/fake.rb +83 -0
  18. data/lib/cash/finders.rb +50 -0
  19. data/lib/cash/index.rb +211 -0
  20. data/lib/cash/local.rb +105 -0
  21. data/lib/cash/lock.rb +63 -0
  22. data/lib/cash/mock.rb +158 -0
  23. data/lib/cash/query/abstract.rb +219 -0
  24. data/lib/cash/query/calculation.rb +45 -0
  25. data/lib/cash/query/primary_key.rb +50 -0
  26. data/lib/cash/query/select.rb +16 -0
  27. data/lib/cash/request.rb +3 -0
  28. data/lib/cash/transactional.rb +43 -0
  29. data/lib/cash/util/array.rb +9 -0
  30. data/lib/cash/util/marshal.rb +19 -0
  31. data/lib/cash/version.rb +3 -0
  32. data/lib/cash/write_through.rb +71 -0
  33. data/lib/mem_cached_session_store.rb +49 -0
  34. data/lib/mem_cached_support_store.rb +143 -0
  35. data/rails/init.rb +1 -0
  36. data/spec/cash/accessor_spec.rb +186 -0
  37. data/spec/cash/active_record_spec.rb +224 -0
  38. data/spec/cash/buffered_spec.rb +9 -0
  39. data/spec/cash/calculations_spec.rb +78 -0
  40. data/spec/cash/finders_spec.rb +455 -0
  41. data/spec/cash/local_buffer_spec.rb +9 -0
  42. data/spec/cash/local_spec.rb +9 -0
  43. data/spec/cash/lock_spec.rb +110 -0
  44. data/spec/cash/marshal_spec.rb +60 -0
  45. data/spec/cash/order_spec.rb +172 -0
  46. data/spec/cash/transactional_spec.rb +602 -0
  47. data/spec/cash/window_spec.rb +195 -0
  48. data/spec/cash/without_caching_spec.rb +32 -0
  49. data/spec/cash/write_through_spec.rb +252 -0
  50. data/spec/spec_helper.rb +87 -0
  51. metadata +300 -0
data/TODO ADDED
@@ -0,0 +1,17 @@
1
+ TOP PRIORITY
2
+
3
+ REFACTOR
4
+ * Clarify terminology around cache/key/index, etc.
5
+
6
+ INFRASTRUCTURE
7
+
8
+ NEW FEATURES
9
+ * transactional get multi isn't really multi
10
+
11
+ BUGS
12
+ * Handle append strategy (using add rather than set?) to avoid race condition
13
+
14
+ MISSING TESTS:
15
+ * missing tests for Klass.transaction do ... end
16
+ * non "id" pks work but lack test coverage
17
+ * expire_cache
@@ -0,0 +1,13 @@
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
+ * Named bind variables :conditions => ["name = :name", { :name => "37signals!" }] - not hard to support
10
+ * printf style binds: :conditions => ["name = '%s'", "37signals!"] - not too hard to support
11
+ * objects as attributes that are serialized. story.title = {:foo => :bar}; customer.balance = Money.new(...) - these could be coerced using Column#type_cast?
12
+
13
+ With a lot of these features the issue is not technical but performance. Every special case costs some overhead.
@@ -0,0 +1,8 @@
1
+ require 'action_controller'
2
+ require 'active_record'
3
+ require 'active_record/session_store'
4
+
5
+ ActiveRecord::Base.establish_connection(
6
+ :adapter => 'sqlite3',
7
+ :database => ':memory:'
8
+ )
@@ -0,0 +1,4 @@
1
+ test:
2
+ ttl: 604800
3
+ namespace: cache
4
+ servers: localhost:11211
@@ -0,0 +1,18 @@
1
+ ActiveRecord::Schema.define(:version => 2) do
2
+ create_table "stories", :force => true do |t|
3
+ t.string "title", "subtitle"
4
+ t.string "type"
5
+ t.boolean "published"
6
+ end
7
+
8
+ create_table "characters", :force => true do |t|
9
+ t.integer "story_id"
10
+ t.string "name"
11
+ end
12
+
13
+ create_table :sessions, :force => true do |t|
14
+ t.string :session_id
15
+ t.text :data
16
+ t.timestamps
17
+ end
18
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'rails/init'
@@ -0,0 +1,105 @@
1
+ require 'active_support'
2
+ require 'active_record'
3
+
4
+ require 'cash/version'
5
+ require 'cash/lock'
6
+ require 'cash/transactional'
7
+ require 'cash/write_through'
8
+ require 'cash/finders'
9
+ require 'cash/buffered'
10
+ require 'cash/index'
11
+ require 'cash/config'
12
+ require 'cash/accessor'
13
+
14
+ require 'cash/request'
15
+ require 'cash/fake'
16
+ require 'cash/local'
17
+
18
+ require 'cash/query/abstract'
19
+ require 'cash/query/select'
20
+ require 'cash/query/primary_key'
21
+ require 'cash/query/calculation'
22
+
23
+ require 'cash/util/array'
24
+ require 'cash/util/marshal'
25
+
26
+ module Cash
27
+ mattr_accessor :enabled
28
+ self.enabled = true
29
+
30
+ mattr_accessor :repository
31
+
32
+ def self.configure(options = {})
33
+ options.assert_valid_keys(:repository, :local, :transactional, :adapter, :default_ttl)
34
+ cache = options[:repository] || raise(":repository is a required option")
35
+
36
+ adapter = options.fetch(:adapter, :memcached)
37
+
38
+ if adapter
39
+ require "cash/adapter/#{adapter.to_s}"
40
+ klass = "Cash::Adapter::#{adapter.to_s.camelize}".constantize
41
+ cache = klass.new(cache, :logger => Rails.logger, :default_ttl => options.fetch(:default_ttl, 1.day.to_i))
42
+ end
43
+
44
+ lock = Cash::Lock.new(cache)
45
+ cache = Cash::Local.new(cache) if options.fetch(:local, true)
46
+ cache = Cash::Transactional.new(cache, lock) if options.fetch(:transactional, true)
47
+
48
+ self.repository = cache
49
+ end
50
+
51
+ def self.included(active_record_class)
52
+ active_record_class.class_eval do
53
+ include Config, Accessor, WriteThrough, Finders
54
+ extend ClassMethods
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def self.repository
61
+ @@repository || raise("Cash.configure must be called when Cash.enabled is true")
62
+ end
63
+
64
+ module ClassMethods
65
+ def self.extended(active_record_class)
66
+ class << active_record_class
67
+ alias_method_chain :transaction, :cache_transaction
68
+ end
69
+ end
70
+
71
+ def transaction_with_cache_transaction(*args, &block)
72
+ if Cash.enabled
73
+ # Wrap both the db and cache transaction in another cache transaction so that the cache
74
+ # gets written only after the database commit but can still flush the inner cache
75
+ # transaction if an AR::Rollback is issued.
76
+ Cash.repository.transaction do
77
+ transaction_without_cache_transaction(*args) do
78
+ Cash.repository.transaction { block.call }
79
+ end
80
+ end
81
+ else
82
+ transaction_without_cache_transaction(*args, &block)
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ class ActiveRecord::Base
89
+ include Cash
90
+
91
+ def self.is_cached(options = {})
92
+ options.assert_valid_keys(:ttl, :repository, :version)
93
+ opts = options.dup
94
+ opts[:repository] = Cash.repository unless opts.has_key?(:repository)
95
+ Cash::Config.create(self, opts)
96
+ end
97
+
98
+ def <=>(other)
99
+ if self.id == other.id then
100
+ 0
101
+ else
102
+ self.id < other.id ? -1 : 1
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,83 @@
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
+ return {} if keys.empty?
15
+
16
+ keys = keys.collect { |key| cache_key(key) }
17
+ hits = repository.get_multi(*keys)
18
+ if (missed_keys = keys - hits.keys).any?
19
+ missed_values = block.call(missed_keys)
20
+ hits.merge!(missed_keys.zip(Array(missed_values)).to_hash_without_nils)
21
+ end
22
+ hits
23
+ else
24
+ repository.get(cache_key(keys), options[:raw]) || (block ? block.call : nil)
25
+ end
26
+ end
27
+
28
+ def get(keys, options = {}, &block)
29
+ case keys
30
+ when Array
31
+ fetch(keys, options, &block)
32
+ else
33
+ fetch(keys, options) do
34
+ if block_given?
35
+ add(keys, result = yield(keys), options)
36
+ result
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def add(key, value, options = {})
43
+ if repository.add(cache_key(key), value, options[:ttl] || cache_config.ttl, options[:raw]) == "NOT_STORED\r\n"
44
+ yield if block_given?
45
+ end
46
+ end
47
+
48
+ def set(key, value, options = {})
49
+ repository.set(cache_key(key), value, options[:ttl] || cache_config.ttl, options[:raw])
50
+ end
51
+
52
+ def incr(key, delta = 1, ttl = nil)
53
+ ttl ||= cache_config.ttl
54
+ repository.incr(cache_key = cache_key(key), delta) || begin
55
+ repository.add(cache_key, (result = yield).to_s, ttl, true) { repository.incr(cache_key) }
56
+ result
57
+ end
58
+ end
59
+
60
+ def decr(key, delta = 1, ttl = nil)
61
+ ttl ||= cache_config.ttl
62
+ repository.decr(cache_key = cache_key(key), delta) || begin
63
+ repository.add(cache_key, (result = yield).to_s, ttl, true) { repository.decr(cache_key) }
64
+ result
65
+ end
66
+ end
67
+
68
+ def expire(key)
69
+ repository.delete(cache_key(key))
70
+ end
71
+
72
+ def cache_key(key)
73
+ "#{name}:#{cache_config.version}/#{key.to_s.gsub(' ', '+')}"
74
+ end
75
+ end
76
+
77
+ module InstanceMethods
78
+ def expire
79
+ self.class.expire(id)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,36 @@
1
+ require 'memcache'
2
+
3
+ module Cash
4
+ module Adapter
5
+ class MemcacheClient
6
+ def initialize(repository, options = {})
7
+ @repository = repository
8
+ @logger = options[:logger]
9
+ @default_ttl = options[:default_ttl] || raise(":default_ttl is a required option")
10
+ end
11
+
12
+ def add(key, value, ttl=nil, raw=false)
13
+ @repository.add(key, value || @default_ttl, ttl, raw)
14
+ end
15
+
16
+ def set(key, value, ttl=nil, raw=false)
17
+ @repository.set(key, value || @default_ttl, ttl, raw)
18
+ end
19
+
20
+ def exception_classes
21
+ MemCache::MemCacheError
22
+ end
23
+
24
+ def respond_to?(method)
25
+ super || @repository.respond_to?(method)
26
+ end
27
+
28
+ private
29
+
30
+ def method_missing(*args, &block)
31
+ @repository.send(*args, &block)
32
+ end
33
+
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,127 @@
1
+ require 'memcached'
2
+
3
+ # Maps memcached methods and semantics to those of memcache-client
4
+ module Cash
5
+ module Adapter
6
+ class Memcached
7
+ def initialize(repository, options = {})
8
+ @repository = repository
9
+ @logger = options[:logger]
10
+ @default_ttl = options[:default_ttl] || raise(":default_ttl is a required option")
11
+ end
12
+
13
+ def add(key, value, ttl=nil, raw=false)
14
+ wrap(key, not_stored) do
15
+ logger.debug("Memcached add: #{key.inspect}") if debug_logger?
16
+ @repository.add(key, raw ? value.to_s : value, ttl || @default_ttl, !raw)
17
+ logger.debug("Memcached hit: #{key.inspect}") if debug_logger?
18
+ stored
19
+ end
20
+ end
21
+
22
+ # Wraps Memcached#get so that it doesn't raise. This has the side-effect of preventing you from
23
+ # storing <tt>nil</tt> values.
24
+ def get(key, raw=false)
25
+ wrap(key) do
26
+ logger.debug("Memcached get: #{key.inspect}") if debug_logger?
27
+ value = wrap(key) { @repository.get(key, !raw) }
28
+ logger.debug("Memcached hit: #{key.inspect}") if debug_logger?
29
+ value
30
+ end
31
+ end
32
+
33
+ def get_multi(*keys)
34
+ wrap(keys, {}) do
35
+ begin
36
+ keys.flatten!
37
+ logger.debug("Memcached get_multi: #{keys.inspect}") if debug_logger?
38
+ values = @repository.get(keys, true)
39
+ logger.debug("Memcached hit: #{keys.inspect}") if debug_logger?
40
+ values
41
+ rescue TypeError
42
+ log_error($!) if logger
43
+ keys.each { |key| delete(key) }
44
+ logger.debug("Memcached deleted: #{keys.inspect}") if debug_logger?
45
+ {}
46
+ end
47
+ end
48
+ end
49
+
50
+ def set(key, value, ttl=nil, raw=false)
51
+ wrap(key, not_stored) do
52
+ logger.debug("Memcached set: #{key.inspect}") if debug_logger?
53
+ @repository.set(key, raw ? value.to_s : value, ttl || @default_ttl, !raw)
54
+ logger.debug("Memcached hit: #{key.inspect}") if debug_logger?
55
+ stored
56
+ end
57
+ end
58
+
59
+ def delete(key)
60
+ wrap(key, not_found) do
61
+ logger.debug("Memcached delete: #{key.inspect}") if debug_logger?
62
+ @repository.delete(key)
63
+ logger.debug("Memcached hit: #{key.inspect}") if debug_logger?
64
+ deleted
65
+ end
66
+ end
67
+
68
+ def incr(key, value = 1)
69
+ wrap(key) { @repository.incr(key, value) }
70
+ end
71
+
72
+ def decr(key, value = 1)
73
+ wrap(key) { @repository.decr(key, value) }
74
+ end
75
+
76
+ def flush_all
77
+ @repository.flush
78
+ end
79
+
80
+ def exception_classes
81
+ ::Memcached::Error
82
+ end
83
+
84
+ private
85
+
86
+ def logger
87
+ @logger
88
+ end
89
+
90
+ def debug_logger?
91
+ logger && logger.respond_to?(:debug?) && logger.debug?
92
+ end
93
+
94
+ def wrap(key, error_value = nil, options = {})
95
+ yield
96
+ rescue ::Memcached::NotStored, ::Memcached::NotFound
97
+ logger.debug("Memcached miss: #{key.inspect}") if debug_logger?
98
+ error_value
99
+ rescue ::Memcached::Error
100
+ log_error($!) if logger
101
+ raise if options[:reraise_error]
102
+ error_value
103
+ end
104
+
105
+ def stored
106
+ "STORED\r\n"
107
+ end
108
+
109
+ def deleted
110
+ "DELETED\r\n"
111
+ end
112
+
113
+ def not_stored
114
+ "NOT_STORED\r\n"
115
+ end
116
+
117
+ def not_found
118
+ "NOT_FOUND\r\n"
119
+ end
120
+
121
+ def log_error(err)
122
+ #logger.error("#{err}: \n\t#{err.backtrace.join("\n\t")}") if logger
123
+ logger.error("Memcached ERROR, #{err.class}: #{err}") if logger
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,144 @@
1
+ # Maps Redis methods and semantics to those of memcache-client
2
+ module Cash
3
+ module Adapter
4
+ class Redis
5
+ def initialize(repository, options = {})
6
+ @repository = repository
7
+ @logger = options[:logger]
8
+ @default_ttl = options[:default_ttl] || raise(":default_ttl is a required option")
9
+ end
10
+
11
+ def add(key, value, ttl=nil, raw = false)
12
+ wrap(key, not_stored) do
13
+ logger.debug("Redis add: #{key.inspect}") if debug_logger?
14
+ value = dump(value) unless raw
15
+ # TODO: make transactional
16
+ result = @repository.setnx(key, value)
17
+ @repository.expires(key, ttl || @default_ttl) if 1 == result
18
+ logger.debug("Redis hit: #{key.inspect}") if debug_logger?
19
+ 1 == result ? stored : not_stored
20
+ end
21
+ end
22
+
23
+ def get(key, raw = false)
24
+ wrap(key) do
25
+ logger.debug("Redis get: #{key.inspect}") if debug_logger?
26
+ value = wrap(key) { @repository.get(key) }
27
+ if value
28
+ logger.debug("Redis hit: #{key.inspect}") if debug_logger?
29
+ value = load(value) unless raw
30
+ else
31
+ logger.debug("Redis miss: #{key.inspect}") if debug_logger?
32
+ end
33
+ value
34
+ end
35
+ end
36
+
37
+ def get_multi(*keys)
38
+ wrap(keys, {}) do
39
+ keys.flatten!
40
+ logger.debug("Redis get_multi: #{keys.inspect}") if debug_logger?
41
+
42
+ # Values are returned as an array. Convert them to a hash of matches, dropping anything
43
+ # that doesn't have a match.
44
+ values = @repository.mget(*keys)
45
+ result = {}
46
+ keys.each_with_index{ |key, i| result[key] = load(values[i]) if values[i] }
47
+
48
+ if result.any?
49
+ logger.debug("Redis hit: #{keys.inspect}") if debug_logger?
50
+ else
51
+ logger.debug("Redis miss: #{keys.inspect}") if debug_logger?
52
+ end
53
+ result
54
+ end
55
+ end
56
+
57
+ def set(key, value, ttl=nil, raw = false)
58
+ wrap(key, not_stored) do
59
+ logger.debug("Redis set: #{key.inspect}") if debug_logger?
60
+ value = dump(value) unless raw
61
+ @repository.setex(key, ttl || @default_ttl, value)
62
+ logger.debug("Redis hit: #{key.inspect}") if debug_logger?
63
+ stored
64
+ end
65
+ end
66
+
67
+ def delete(key)
68
+ wrap(key, not_found) do
69
+ logger.debug("Redis delete: #{key.inspect}") if debug_logger?
70
+ @repository.del(key)
71
+ logger.debug("Redis hit: #{key.inspect}") if debug_logger?
72
+ deleted
73
+ end
74
+ end
75
+
76
+ def incr(key, value = 1)
77
+ # Redis always answeres positively to incr/decr but memcache does not and waits for the key
78
+ # to be added in a separate operation.
79
+ if wrap(nil) { @repository.exists(key) }
80
+ wrap(key) { @repository.incrby(key, value).to_i }
81
+ end
82
+ end
83
+
84
+ def decr(key, value = 1)
85
+ if wrap(nil) { @repository.exists(key) }
86
+ wrap(key) { @repository.decrby(key, value).to_i }
87
+ end
88
+ end
89
+
90
+ def flush_all
91
+ @repository.flushall
92
+ end
93
+
94
+ def exception_classes
95
+ [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EBADF, Errno::EINVAL]
96
+ end
97
+
98
+ private
99
+
100
+ def logger
101
+ @logger
102
+ end
103
+
104
+ def debug_logger?
105
+ logger && logger.respond_to?(:debug?) && logger.debug?
106
+ end
107
+
108
+ def wrap(key, error_value = nil)
109
+ yield
110
+ rescue *exception_classes
111
+ log_error($!) if logger
112
+ error_value
113
+ end
114
+
115
+ def dump(value)
116
+ Marshal.dump(value)
117
+ end
118
+
119
+ def load(value)
120
+ Marshal.load(value)
121
+ end
122
+
123
+ def stored
124
+ "STORED\r\n"
125
+ end
126
+
127
+ def deleted
128
+ "DELETED\r\n"
129
+ end
130
+
131
+ def not_stored
132
+ "NOT_STORED\r\n"
133
+ end
134
+
135
+ def not_found
136
+ "NOT_FOUND\r\n"
137
+ end
138
+
139
+ def log_error(err)
140
+ logger.error("Redis ERROR, #{err.class}: #{err}") if logger
141
+ end
142
+ end
143
+ end
144
+ end