viximo-cache-money 0.3.0

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.
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