├── README ├── init.rb ├── spec ├── spec.opts ├── cash │ ├── local_spec.rb │ ├── buffered_spec.rb │ ├── local_buffer_spec.rb │ ├── without_caching_spec.rb │ ├── marshal_spec.rb │ ├── calculations_spec.rb │ ├── lock_spec.rb │ ├── accessor_spec.rb │ ├── order_spec.rb │ ├── active_record_spec.rb │ ├── window_spec.rb │ ├── write_through_spec.rb │ ├── finders_spec.rb │ └── transactional_spec.rb └── spec_helper.rb ├── rails └── init.rb ├── lib ├── cash │ ├── request.rb │ ├── version.rb │ ├── util │ │ ├── array.rb │ │ └── marshal.rb │ ├── query │ │ ├── select.rb │ │ ├── calculation.rb │ │ ├── primary_key.rb │ │ └── abstract.rb │ ├── transactional.rb │ ├── adapter │ │ ├── memcache_client.rb │ │ ├── memcached.rb │ │ └── redis.rb │ ├── finders.rb │ ├── fake.rb │ ├── lock.rb │ ├── local.rb │ ├── write_through.rb │ ├── config.rb │ ├── accessor.rb │ ├── buffered.rb │ ├── mock.rb │ └── index.rb ├── mem_cached_session_store.rb ├── cache_money.rb └── mem_cached_support_store.rb ├── .gitignore ├── config ├── memcached.yml └── environment.rb ├── Gemfile ├── TODO ├── db └── schema.rb ├── UNSUPPORTED_FEATURES ├── Rakefile ├── ngmoco-cache-money.gemspec ├── README.markdown └── LICENSE /README: -------------------------------------------------------------------------------- 1 | README.markdown -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | require 'rails/init' -------------------------------------------------------------------------------- /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --color --backtrace -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | require 'cache_money' -------------------------------------------------------------------------------- /lib/cash/request.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | Request = {} 3 | end 4 | -------------------------------------------------------------------------------- /lib/cash/version.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | VERSION = '0.2.24.2' 3 | end 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | .bundle/* 4 | Gemfile.lock 5 | data 6 | coverage 7 | -------------------------------------------------------------------------------- /config/memcached.yml: -------------------------------------------------------------------------------- 1 | test: 2 | ttl: 604800 3 | namespace: cache 4 | servers: localhost:11211 -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 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 | ) 9 | -------------------------------------------------------------------------------- /spec/cash/local_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Local do 5 | it "should have method missing as a private method" do 6 | Local.private_instance_methods.should include("method_missing") 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /spec/cash/buffered_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Buffered do 5 | it "should have method missing as a private method" do 6 | Buffered.private_instance_methods.should include("method_missing") 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /spec/cash/local_buffer_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe LocalBuffer do 5 | it "should have method missing as a private method" do 6 | LocalBuffer.private_instance_methods.should include("method_missing") 7 | end 8 | end 9 | end -------------------------------------------------------------------------------- /lib/cash/util/array.rb: -------------------------------------------------------------------------------- 1 | class Array 2 | alias_method :count, :size 3 | 4 | def to_hash_without_nils 5 | keys_and_values_without_nils = reject { |key, value| value.nil? } 6 | shallow_flattened_keys_and_values_without_nils = keys_and_values_without_nils.inject([]) { |result, pair| result += pair } 7 | Hash[*shallow_flattened_keys_and_values_without_nils] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem 'activerecord', '>=2.2.0', '<3.0' 4 | gem 'activesupport', '>=2.2.0', '<3.0' 5 | 6 | group :development do 7 | gem 'actionpack', '>=2.2.0', '<3.0' 8 | gem 'jeweler', '~>1.5.0' 9 | gem 'ruby-debug', '0.10.3' 10 | gem 'rspec', '~>1.3.0' 11 | gem 'sqlite3-ruby' 12 | gem 'rr' 13 | gem 'memcached' 14 | gem 'memcache-client' 15 | gem 'fakeredis' 16 | end 17 | -------------------------------------------------------------------------------- /lib/cash/query/select.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Query 3 | class Select < Abstract 4 | delegate :find_every_without_cache, :to => :@active_record 5 | 6 | protected 7 | def miss(_, miss_options) 8 | find_every_without_cache(miss_options) 9 | end 10 | 11 | def uncacheable 12 | find_every_without_cache(@options1) 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 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 19 | -------------------------------------------------------------------------------- /lib/cash/util/marshal.rb: -------------------------------------------------------------------------------- 1 | module Marshal 2 | class << self 3 | def constantize(name) 4 | name.constantize 5 | end 6 | 7 | def load_with_constantize(value) 8 | begin 9 | Marshal.load_without_constantize value 10 | rescue ArgumentError => e 11 | _, class_name = *(/undefined class\/module ([\w:]*\w)/.match(e.message)) 12 | raise if !class_name 13 | constantize(class_name) 14 | Marshal.load value 15 | end 16 | end 17 | alias_method_chain :load, :constantize 18 | end 19 | end -------------------------------------------------------------------------------- /spec/cash/without_caching_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Cash do 4 | describe 'when disabled' do 5 | before(:each) do 6 | Cash.enabled = false 7 | 8 | mock($memcache).get.never 9 | mock($memcache).add.never 10 | mock($memcache).set.never 11 | end 12 | 13 | after(:each) do 14 | Cash.enabled = true 15 | end 16 | 17 | it 'creates and looks up objects without using cache' do 18 | story = Story.create! 19 | Story.find(story.id).should == story 20 | end 21 | 22 | it 'updates objects without using cache' do 23 | story = Story.create! 24 | story.title = 'test' 25 | story.save! 26 | end 27 | 28 | it 'should find using indexed condition without using cache' do 29 | Story.find(:all, :conditions => {:title => 'x'}) 30 | end 31 | end 32 | end -------------------------------------------------------------------------------- /lib/cash/transactional.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | class Transactional 3 | attr_reader :memcache 4 | 5 | def initialize(memcache, lock) 6 | @memcache, @cache = [memcache, memcache] 7 | @lock = lock 8 | end 9 | 10 | def transaction 11 | exception_was_raised = false 12 | begin_transaction 13 | result = yield 14 | rescue Object => e 15 | exception_was_raised = true 16 | raise 17 | ensure 18 | begin 19 | @cache.flush unless exception_was_raised 20 | ensure 21 | end_transaction 22 | end 23 | end 24 | 25 | def respond_to?(method) 26 | @cache.respond_to?(method) 27 | end 28 | 29 | private 30 | 31 | def method_missing(method, *args, &block) 32 | @cache.send(method, *args, &block) 33 | end 34 | 35 | def begin_transaction 36 | @cache = Buffered.push(@cache, @lock) 37 | end 38 | 39 | def end_transaction 40 | @cache = @cache.pop 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/cash/adapter/memcache_client.rb: -------------------------------------------------------------------------------- 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, ttl || @default_ttl, raw) 14 | end 15 | 16 | def set(key, value, ttl=nil, raw=false) 17 | @repository.set(key, value, ttl || @default_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 -------------------------------------------------------------------------------- /UNSUPPORTED_FEATURES: -------------------------------------------------------------------------------- 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. -------------------------------------------------------------------------------- /lib/cash/query/calculation.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Query 3 | class Calculation < Abstract 4 | delegate :calculate_without_cache, :incr, :to => :@active_record 5 | 6 | def initialize(active_record, operation, column, options1, options2) 7 | super(active_record, options1, options2) 8 | @operation, @column = operation, column 9 | end 10 | 11 | def perform 12 | super({}, :raw => true) 13 | end 14 | 15 | def calculation? 16 | true 17 | end 18 | 19 | protected 20 | def miss(_, __) 21 | calculate_without_cache(@operation, @column, @options1) 22 | end 23 | 24 | def uncacheable 25 | calculate_without_cache(@operation, @column, @options1) 26 | end 27 | 28 | def format_results(_, objects) 29 | objects.to_i 30 | end 31 | 32 | def serialize_objects(_, objects) 33 | objects.to_s 34 | end 35 | 36 | def cacheable?(*optionss) 37 | @column == :all && super(*optionss) 38 | end 39 | 40 | def cache_keys(attribute_value_pairs) 41 | "#{super(attribute_value_pairs)}/#{@operation}" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler/setup' 3 | Bundler.require :default, 'development' 4 | require File.expand_path('../config/environment', __FILE__) 5 | 6 | begin 7 | require 'rake/testtask' 8 | require 'rake/rdoctask' 9 | require 'spec/rake/spectask' 10 | rescue MissingSourceFile 11 | STDERR.puts "Error, could not load rake/rspec tasks! (#{$!})\n\nDid you run `bundle install`?\n\n" 12 | exit 1 13 | end 14 | 15 | require 'lib/cash/version' 16 | 17 | Spec::Rake::SpecTask.new do |t| 18 | t.spec_files = FileList['spec/**/*_spec.rb'] 19 | t.spec_opts = ['--format', 'profile', '--color'] 20 | end 21 | 22 | Spec::Rake::SpecTask.new(:coverage) do |t| 23 | t.spec_files = FileList['spec/**/*_spec.rb'] 24 | t.rcov = true 25 | t.rcov_opts = ['-x', 'spec,gems'] 26 | end 27 | 28 | desc "Default task is to run specs" 29 | task :default => :spec 30 | 31 | namespace :britt do 32 | desc 'Removes trailing whitespace' 33 | task :space do 34 | sh %{find . -name '*.rb' -exec sed -i '' 's/ *$//g' {} \\;} 35 | end 36 | end 37 | 38 | task :build do 39 | system "gem build ngmoco-cache-money.gemspec" 40 | end 41 | 42 | desc "Push a new version to Gemcutter" 43 | task :publish => [ :spec, :build ] do 44 | system "git tag v#{Cash::VERSION}" 45 | system "git push origin v#{Cash::VERSION}" 46 | system "git push origin master" 47 | system "gem push ngmoco-cache-money-#{Cash::VERSION}.gem" 48 | system "git clean -fd" 49 | end 50 | -------------------------------------------------------------------------------- /lib/cash/query/primary_key.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Query 3 | class PrimaryKey < Abstract 4 | def initialize(active_record, ids, options1, options2) 5 | super(active_record, options1, options2) 6 | @expects_array = ids.first.kind_of?(Array) 7 | @original_ids = ids 8 | @ids = ids.flatten.compact.uniq.collect do |object| 9 | object.respond_to?(:quoted_id) ? object.quoted_id : object.to_i 10 | end 11 | end 12 | 13 | def perform 14 | return [] if @expects_array && @ids.empty? 15 | raise ActiveRecord::RecordNotFound if @ids.empty? 16 | 17 | super(:conditions => { :id => @ids.first }) 18 | end 19 | 20 | protected 21 | def deserialize_objects(objects) 22 | convert_to_active_record_collection(super(objects)) 23 | end 24 | 25 | def cache_keys(attribute_value_pairs) 26 | @ids.collect { |id| "id/#{id}" } 27 | end 28 | 29 | def miss(missing_keys, options) 30 | find_from_keys(*missing_keys) 31 | end 32 | 33 | def uncacheable 34 | find_from_ids_without_cache(@original_ids, @options1) 35 | end 36 | 37 | private 38 | def convert_to_active_record_collection(objects) 39 | case objects.size 40 | when 0 41 | raise ActiveRecord::RecordNotFound 42 | when 1 43 | @expects_array ? objects : objects.first 44 | else 45 | objects 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /ngmoco-cache-money.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | require File.join(File.dirname(__FILE__), 'lib/cash/version') 3 | 4 | Gem::Specification.new do |s| 5 | s.name = %q{ngmoco-cache-money} 6 | s.version = Cash::VERSION 7 | 8 | s.required_rubygems_version = '>= 1.3.7' 9 | s.authors = ["Nick Kallen", "Ashley Martens", "Scott Mace", "John O'Neill"] 10 | s.date = Date.today.to_s 11 | s.description = %q{Write-through and Read-through Cacheing for ActiveRecord} 12 | s.email = %q{teamplatform@ngmoco.com} 13 | s.extra_rdoc_files = [ 14 | "LICENSE", 15 | "README", 16 | "README.markdown", 17 | "TODO" 18 | ] 19 | s.files = Dir[ 20 | "README", 21 | "TODO", 22 | "UNSUPPORTED_FEATURES", 23 | "lib/**/*.rb", 24 | "rails/init.rb", 25 | "init.rb" 26 | ] 27 | s.homepage = %q{http://github.com/ngmoco/cache-money} 28 | s.license = "Apache 2.0" 29 | s.require_paths = ["lib"] 30 | s.summary = %q{Write-through and Read-through Cacheing for ActiveRecord} 31 | s.test_files = Dir[ 32 | "config/*", 33 | "db/schema.rb", 34 | "spec/**/*.rb" 35 | ] 36 | 37 | s.add_runtime_dependency(%q, [">= 2.2.0", "< 3.0"]) 38 | s.add_runtime_dependency(%q, [">= 2.2.0", "< 3.0"]) 39 | 40 | s.add_development_dependency(%q) 41 | s.add_development_dependency(%q, ["~> 0.10.0"]) 42 | s.add_development_dependency(%q, ["~> 1.3.0"]) 43 | s.add_development_dependency(%q) 44 | s.add_development_dependency(%q) 45 | s.add_development_dependency(%q) 46 | s.add_development_dependency(%q) 47 | s.add_development_dependency(%q) 48 | end 49 | 50 | -------------------------------------------------------------------------------- /lib/cash/finders.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Finders 3 | def self.included(active_record_class) 4 | active_record_class.class_eval do 5 | extend ClassMethods 6 | end 7 | end 8 | 9 | module ClassMethods 10 | def self.extended(active_record_class) 11 | class << active_record_class 12 | alias_method_chain :find_every, :cache 13 | alias_method_chain :find_from_ids, :cache 14 | alias_method_chain :calculate, :cache 15 | end 16 | end 17 | 18 | def without_cache(&block) 19 | with_scope(:find => {:readonly => true}, &block) 20 | end 21 | 22 | # User.find(:first, ...), User.find_by_foo(...), User.find(:all, ...), User.find_all_by_foo(...) 23 | def find_every_with_cache(options) 24 | if cacheable? 25 | Query::Select.perform(self, options, scope(:find)) 26 | else 27 | find_every_without_cache(options) 28 | end 29 | end 30 | 31 | # User.find(1), User.find(1, 2, 3), User.find([1, 2, 3]), User.find([]) 32 | def find_from_ids_with_cache(ids, options) 33 | if cacheable? 34 | Query::PrimaryKey.perform(self, ids, options, scope(:find)) 35 | else 36 | find_from_ids_without_cache(ids, options) 37 | end 38 | end 39 | 40 | # User.count(:all), User.count, User.sum(...) 41 | def calculate_with_cache(operation, column_name, options = {}) 42 | if cacheable? 43 | Query::Calculation.perform(self, operation, column_name, options, scope(:find)) 44 | else 45 | calculate_without_cache(operation, column_name, options) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/mem_cached_session_store.rb: -------------------------------------------------------------------------------- 1 | # begin 2 | require 'memcached' 3 | 4 | class MemCachedSessionStore < ActionController::Session::AbstractStore 5 | def initialize(app, options = {}) 6 | # Support old :expires option 7 | options[:expire_after] ||= options[:expires] 8 | 9 | super 10 | 11 | @default_options = { 12 | :namespace => 'rack:session', 13 | :servers => 'localhost:11211' 14 | }.merge(@default_options) 15 | 16 | @default_options[:prefix_key] ||= @default_options[:namespace] 17 | 18 | @pool = options[:cache] || Memcached.new(@default_options[:servers], @default_options) 19 | # unless @pool.servers.any? { |s| s.alive? } 20 | # raise "#{self} unable to find server during initialization." 21 | # end 22 | @mutex = Mutex.new 23 | 24 | super 25 | end 26 | 27 | private 28 | def get_session(env, sid) 29 | sid ||= generate_sid 30 | begin 31 | session = @pool.get(sid) || {} 32 | rescue Memcached::NotFound, MemCache::MemCacheError, Errno::ECONNREFUSED 33 | session = {} 34 | end 35 | [sid, session] 36 | end 37 | 38 | def set_session(env, sid, session_data) 39 | options = env['rack.session.options'] 40 | expiry = options[:expire_after] || 0 41 | @pool.set(sid, session_data, expiry) 42 | return true 43 | rescue Memcached::NotStored, MemCache::MemCacheError, Errno::ECONNREFUSED 44 | return false 45 | end 46 | end 47 | # rescue LoadError 48 | # # Memcached wasn't available so neither can the store be 49 | # end 50 | -------------------------------------------------------------------------------- /lib/cash/fake.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | class Fake < HashWithIndifferentAccess 3 | attr_accessor :servers 4 | 5 | def get_multi(*keys) 6 | slice(*keys).collect { |k,v| [k, Marshal.load(v)] }.to_hash 7 | end 8 | 9 | def set(key, value, ttl = 0, raw = false) 10 | self[key] = marshal(value, raw) 11 | end 12 | 13 | def get(key, raw = false) 14 | if raw 15 | self[key] 16 | else 17 | if self.has_key?(key) 18 | Marshal.load(self[key]) 19 | else 20 | nil 21 | end 22 | end 23 | end 24 | 25 | def incr(key, amount = 1) 26 | if self.has_key?(key) 27 | self[key] = (self[key].to_i + amount).to_s 28 | self[key].to_i 29 | end 30 | end 31 | 32 | def decr(key, amount = 1) 33 | if self.has_key?(key) 34 | self[key] = (self[key].to_i - amount).to_s 35 | self[key].to_i 36 | end 37 | end 38 | 39 | def add(key, value, ttl = 0, raw = false) 40 | return false if self.has_key?(key) 41 | 42 | self[key] = marshal(value, raw) 43 | true 44 | end 45 | 46 | def append(key, value) 47 | set(key, get(key, true).to_s + value.to_s, nil, true) 48 | end 49 | 50 | def namespace 51 | nil 52 | end 53 | 54 | def flush_all 55 | clear 56 | end 57 | 58 | def stats 59 | {} 60 | end 61 | 62 | def reset_runtime 63 | [0, Hash.new(0)] 64 | end 65 | 66 | private 67 | def marshal(value, raw) 68 | if raw 69 | value.to_s 70 | else 71 | Marshal.dump(value) 72 | end 73 | end 74 | 75 | def unmarshal(marshaled_obj) 76 | Marshal.load(marshaled_obj) 77 | end 78 | 79 | def deep_clone(obj) 80 | unmarshal(marshal(obj)) 81 | end 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/cash/lock.rb: -------------------------------------------------------------------------------- 1 | require 'socket' 2 | 3 | module Cash 4 | class Lock 5 | class Error < RuntimeError; end 6 | 7 | INITIAL_WAIT = 2 8 | DEFAULT_RETRY = 8 9 | DEFAULT_EXPIRY = 30 10 | 11 | def initialize(cache) 12 | @cache = cache 13 | end 14 | 15 | def synchronize(key, lock_expiry = DEFAULT_EXPIRY, retries = DEFAULT_RETRY, initial_wait = INITIAL_WAIT) 16 | if recursive_lock?(key) 17 | yield 18 | else 19 | acquire_lock(key, lock_expiry, retries, initial_wait) 20 | begin 21 | yield 22 | ensure 23 | release_lock(key) 24 | end 25 | end 26 | end 27 | 28 | def acquire_lock(key, lock_expiry = DEFAULT_EXPIRY, retries = DEFAULT_RETRY, initial_wait = INITIAL_WAIT) 29 | retries.times do |count| 30 | response = @cache.add("lock/#{key}", host_pid, lock_expiry) 31 | return if response == "STORED\r\n" 32 | return if recursive_lock?(key) 33 | exponential_sleep(count, initial_wait) unless count == retries - 1 34 | end 35 | debug_lock(key) 36 | raise Error, "Couldn't acquire memcache lock on 'lock/#{key}'" 37 | end 38 | 39 | def release_lock(key) 40 | @cache.delete("lock/#{key}") 41 | end 42 | 43 | def exponential_sleep(count, initial_wait) 44 | sleep((2**count) * initial_wait) 45 | end 46 | 47 | private 48 | 49 | def recursive_lock?(key) 50 | @cache.get("lock/#{key}") == host_pid 51 | end 52 | 53 | def debug_lock(key) 54 | @cache.logger.warn("Cash::Lock[#{key}]: #{@cache.get("lock/#{key}")}") if @cache.respond_to?(:logger) && @cache.logger.respond_to?(:warn) 55 | rescue 56 | @cache.logger.warn("#{$!}") if @cache.respond_to?(:logger) && @cache.logger.respond_to?(:warn) 57 | end 58 | 59 | def host_pid 60 | "#{Socket.gethostname} #{Process.pid}" 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/cash/local.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | class Local 3 | delegate :respond_to?, :to => :@remote_cache 4 | 5 | def initialize(remote_cache) 6 | @remote_cache = remote_cache 7 | end 8 | 9 | def cache_locally 10 | @remote_cache = LocalBuffer.new(original_cache = @remote_cache) 11 | yield if block_given? 12 | ensure 13 | @remote_cache = original_cache 14 | end 15 | 16 | def autoload_missing_constants 17 | yield if block_given? 18 | rescue ArgumentError, *@remote_cache.exception_classes => error 19 | lazy_load ||= Hash.new { |hash, hash_key| hash[hash_key] = true; false } 20 | if error.to_s[/undefined class|referred/] && !lazy_load[error.to_s.split.last.constantize] 21 | retry 22 | else 23 | raise error 24 | end 25 | end 26 | 27 | private 28 | 29 | def method_missing(method, *args, &block) 30 | autoload_missing_constants do 31 | @remote_cache.send(method, *args, &block) 32 | end 33 | end 34 | end 35 | 36 | class LocalBuffer 37 | delegate :respond_to?, :to => :@remote_cache 38 | 39 | def initialize(remote_cache) 40 | @local_cache = {} 41 | @remote_cache = remote_cache 42 | end 43 | 44 | def get(key, *options) 45 | if @local_cache.has_key?(key) 46 | @local_cache[key] 47 | else 48 | @local_cache[key] = @remote_cache.get(key, *options) 49 | end 50 | end 51 | 52 | def set(key, value, *options) 53 | @remote_cache.set(key, value, *options) 54 | @local_cache[key] = value 55 | end 56 | 57 | def add(key, value, *options) 58 | result = @remote_cache.add(key, value, *options) 59 | if result == "STORED\r\n" 60 | @local_cache[key] = value 61 | end 62 | result 63 | end 64 | 65 | def delete(key, *options) 66 | @remote_cache.delete(key, *options) 67 | @local_cache.delete(key) 68 | end 69 | 70 | private 71 | 72 | def method_missing(method, *args, &block) 73 | @remote_cache.send(method, *args, &block) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/cash/write_through.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module WriteThrough 3 | DEFAULT_TTL = 12.hours.to_i 4 | 5 | def self.included(active_record_class) 6 | active_record_class.class_eval do 7 | include InstanceMethods 8 | extend ClassMethods 9 | end 10 | end 11 | 12 | module InstanceMethods 13 | def self.included(active_record_class) 14 | active_record_class.class_eval do 15 | after_create :add_to_caches 16 | after_update :update_caches 17 | after_destroy :remove_from_caches 18 | end 19 | end 20 | 21 | def add_to_caches 22 | InstanceMethods.unfold(self.class, :add_to_caches, self) 23 | end 24 | 25 | def update_caches 26 | InstanceMethods.unfold(self.class, :update_caches, self) 27 | end 28 | 29 | def remove_from_caches 30 | return if new_record? 31 | InstanceMethods.unfold(self.class, :remove_from_caches, self) 32 | end 33 | 34 | def expire_caches 35 | InstanceMethods.unfold(self.class, :expire_caches, self) 36 | end 37 | 38 | def shallow_clone 39 | self.class.send(:instantiate, instance_variable_get(:@attributes)) 40 | end 41 | 42 | private 43 | def self.unfold(klass, operation, object) 44 | while klass < ActiveRecord::Base && klass.ancestors.include?(WriteThrough) 45 | klass.send(operation, object) 46 | klass = klass.superclass 47 | end 48 | end 49 | end 50 | 51 | module ClassMethods 52 | def add_to_caches(object) 53 | indices.each { |index| index.add(object) } if cacheable? 54 | true 55 | end 56 | 57 | def update_caches(object) 58 | indices.each { |index| index.update(object) } if cacheable? 59 | true 60 | end 61 | 62 | def remove_from_caches(object) 63 | indices.each { |index| index.remove(object) } if cacheable? 64 | true 65 | end 66 | 67 | def expire_caches(object) 68 | indices.each { |index| index.delete(object) } if cacheable? 69 | true 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/cash/config.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Config 3 | def self.create(active_record, options, indices = []) 4 | active_record.cache_config = Cash::Config::Config.new(active_record, options) 5 | indices.each { |i| active_record.index i.attributes, i.options } 6 | end 7 | 8 | def self.included(a_module) 9 | a_module.module_eval do 10 | extend ClassMethods 11 | delegate :repository, :to => "self.class" 12 | end 13 | end 14 | 15 | module ClassMethods 16 | def self.extended(a_class) 17 | class << a_class 18 | def cache_config 19 | @cache_config 20 | end 21 | 22 | delegate :repository, :indices, :to => :cache_config 23 | alias_method_chain :inherited, :cache_config 24 | end 25 | end 26 | 27 | def inherited_with_cache_config(subclass) 28 | inherited_without_cache_config(subclass) 29 | @cache_config.inherit(subclass) if @cache_config 30 | end 31 | 32 | def index(attributes, options = {}) 33 | options.assert_valid_keys(:ttl, :order, :limit, :buffer, :order_column) 34 | (@cache_config.indices.unshift(Index.new(@cache_config, self, attributes, options))).uniq! 35 | end 36 | 37 | def version(number) 38 | @cache_config.options[:version] = number 39 | end 40 | 41 | def cache_config=(config) 42 | @cache_config = config 43 | end 44 | 45 | def cacheable?(*args) 46 | Cash.enabled && cache_config 47 | end 48 | end 49 | 50 | class Config 51 | attr_reader :active_record, :options 52 | 53 | def initialize(active_record, options = {}) 54 | @active_record, @options = active_record, options 55 | end 56 | 57 | def repository 58 | @options[:repository] 59 | end 60 | 61 | def ttl 62 | @ttl ||= (repository.respond_to?(:default_ttl) && repository.default_ttl) || @options[:ttl] 63 | end 64 | 65 | def version 66 | @options[:version] || 1 67 | end 68 | 69 | def indices 70 | @indices ||= active_record == ActiveRecord::Base ? [] : [Index.new(self, active_record, active_record.primary_key)] 71 | end 72 | 73 | def inherit(active_record) 74 | Cash::Config.create(active_record, @options, indices) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | dir = File.dirname(__FILE__) 2 | $LOAD_PATH.unshift "#{dir}/../lib" 3 | 4 | require 'rubygems' 5 | require 'bundler' 6 | 7 | require File.join(dir, '../config/environment') 8 | require 'spec' 9 | require 'pp' 10 | require 'cache_money' 11 | 12 | Spec::Runner.configure do |config| 13 | config.mock_with :rr 14 | config.before :suite do 15 | load File.join(dir, "../db/schema.rb") 16 | 17 | config = YAML.load(IO.read((File.expand_path(File.dirname(__FILE__) + "/../config/memcached.yml"))))['test'] 18 | 19 | case ENV['ADAPTER'] 20 | when 'memcache_client' 21 | # Test with MemCache client 22 | require 'cash/adapter/memcache_client' 23 | $memcache = Cash::Adapter::MemcacheClient.new(MemCache.new(config['servers']), 24 | :default_ttl => 1.minute.to_i) 25 | 26 | when 'redis' 27 | # Test with Redis client 28 | require 'cash/adapter/redis' 29 | require 'fakeredis' 30 | $memcache = Cash::Adapter::Redis.new(FakeRedis::Redis.new(), 31 | :default_ttl => 1.minute.to_i) 32 | when 'mock' 33 | # Test with Mock client 34 | require 'cash/adapter/memcache_client' 35 | require 'cash/mock' 36 | $memcache = Cash::Adapter::MemcacheClient.new(Cash::Mock.new(), 37 | :default_ttl => 1.minute.to_i) 38 | else 39 | require 'cash/adapter/memcached' 40 | # Test with memcached client 41 | $memcache = Cash::Adapter::Memcached.new(Memcached.new(config["servers"], config), 42 | :default_ttl => 1.minute.to_i) 43 | end 44 | end 45 | 46 | config.before :each do 47 | $memcache.flush_all 48 | Story.delete_all 49 | Character.delete_all 50 | end 51 | 52 | config.before :suite do 53 | Cash.configure :repository => $memcache, :adapter => false 54 | 55 | ActiveRecord::Base.class_eval do 56 | is_cached 57 | end 58 | 59 | Character = Class.new(ActiveRecord::Base) 60 | Story = Class.new(ActiveRecord::Base) 61 | Story.has_many :characters 62 | 63 | Story.class_eval do 64 | index :title 65 | index [:id, :title] 66 | index :published 67 | end 68 | 69 | Short = Class.new(Story) 70 | Short.class_eval do 71 | index :subtitle, :order_column => 'title' 72 | end 73 | 74 | Epic = Class.new(Story) 75 | Oral = Class.new(Epic) 76 | 77 | Character.class_eval do 78 | index [:name, :story_id] 79 | index [:id, :story_id] 80 | index [:id, :name, :story_id] 81 | end 82 | 83 | Oral.class_eval do 84 | index :subtitle 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/cash/marshal_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Marshal do 4 | describe '#load' do 5 | before do 6 | class Constant; end 7 | @reference_to_constant = Constant 8 | @object = @reference_to_constant.new 9 | @marshaled_object = Marshal.dump(@object) 10 | end 11 | 12 | describe 'when the constant is not yet loaded' do 13 | it 'loads the constant' do 14 | Object.send(:remove_const, :Constant) 15 | stub(Marshal).constantize(@reference_to_constant.name) { Object.send(:const_set, :Constant, @reference_to_constant) } 16 | Marshal.load(@marshaled_object).class.should == @object.class 17 | end 18 | 19 | it 'loads the constant with the scope operator' do 20 | module Foo; class Bar; end; end 21 | 22 | reference_to_module = Foo 23 | reference_to_constant = Foo::Bar 24 | object = reference_to_constant.new 25 | marshaled_object = Marshal.dump(object) 26 | 27 | Foo.send(:remove_const, :Bar) 28 | Object.send(:remove_const, :Foo) 29 | stub(Marshal).constantize(reference_to_module.name) { Object.send(:const_set, :Foo, reference_to_module) } 30 | stub(Marshal).constantize(reference_to_constant.name) { Foo.send(:const_set, :Bar, reference_to_constant) } 31 | 32 | Marshal.load(marshaled_object).class.should == object.class 33 | end 34 | end 35 | 36 | describe 'when the constant does not exist' do 37 | it 'raises a LoadError' do 38 | Object.send(:remove_const, :Constant) 39 | stub(Marshal).constantize { raise NameError } 40 | lambda { Marshal.load(@marshaled_object) }.should raise_error(NameError) 41 | end 42 | end 43 | 44 | describe 'when there are recursive constants to load' do 45 | it 'loads all constants recursively' do 46 | class Constant1; end 47 | class Constant2; end 48 | reference_to_constant1 = Constant1 49 | reference_to_constant2 = Constant2 50 | object = [reference_to_constant1.new, reference_to_constant2.new] 51 | marshaled_object = Marshal.dump(object) 52 | Object.send(:remove_const, :Constant1) 53 | Object.send(:remove_const, :Constant2) 54 | stub(Marshal).constantize(reference_to_constant1.name) { Object.send(:const_set, :Constant1, reference_to_constant1) } 55 | stub(Marshal).constantize(reference_to_constant2.name) { Object.send(:const_set, :Constant2, reference_to_constant2) } 56 | Marshal.load(marshaled_object).collect(&:class).should == object.collect(&:class) 57 | end 58 | end 59 | end 60 | end -------------------------------------------------------------------------------- /lib/cash/accessor.rb: -------------------------------------------------------------------------------- 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 84 | -------------------------------------------------------------------------------- /lib/cash/buffered.rb: -------------------------------------------------------------------------------- 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(*keys) 66 | values = keys.collect { |key| get(key) } 67 | keys.zip(values).to_hash_without_nils 68 | end 69 | 70 | def flush 71 | sorted_keys = @commands.select(&:requires_lock?).collect(&:key).uniq.sort 72 | sorted_keys.each do |key| 73 | @lock.acquire_lock(key) 74 | end 75 | perform_commands 76 | ensure 77 | @buffer = {} 78 | sorted_keys.each do |key| 79 | @lock.release_lock(key) 80 | end 81 | end 82 | 83 | def respond_to?(method) 84 | @cache.respond_to?(method) 85 | end 86 | 87 | protected 88 | 89 | def perform_commands 90 | @commands.each do |command| 91 | command.call(@cache) 92 | end 93 | end 94 | 95 | def buffer_command(command) 96 | @commands << command 97 | end 98 | 99 | private 100 | 101 | def method_missing(method, *args, &block) 102 | @cache.send(method, *args, &block) 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 130 | -------------------------------------------------------------------------------- /spec/cash/calculations_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Finders do 5 | describe 'Calculations' do 6 | describe 'when the cache is populated' do 7 | before do 8 | @stories = [Story.create!(:title => @title = 'asdf'), Story.create!(:title => @title)] 9 | end 10 | 11 | describe '#count(:all, :conditions => ...)' do 12 | it "does not use the database" do 13 | Story.count(:all, :conditions => { :title => @title }).should == @stories.size 14 | end 15 | end 16 | 17 | describe '#count(:column, :conditions => ...)' do 18 | it "uses the database, not the cache" do 19 | mock(Story).get.never 20 | Story.count(:title, :conditions => { :title => @title }).should == @stories.size 21 | end 22 | end 23 | 24 | describe '#count(:all, :distinct => ..., :select => ...)' do 25 | it 'uses the database, not the cache' do 26 | mock(Story).get.never 27 | Story.count(:all, :distinct => true, :select => :title, :conditions => { :title => @title }).should == @stories.collect(&:title).uniq.size 28 | end 29 | end 30 | 31 | describe 'association proxies' do 32 | describe '#count(:all, :conditions => ...)' do 33 | it 'does not use the database' do 34 | story = Story.create! 35 | characters = [story.characters.create!(:name => name = 'name'), story.characters.create!(:name => name)] 36 | mock(Story.connection).execute.never 37 | story.characters.count(:all, :conditions => { :name => name }).should == characters.size 38 | end 39 | 40 | it 'has correct counter cache' do 41 | story = Story.create! 42 | characters = [story.characters.create!(:name => name = 'name'), story.characters.create!(:name => name)] 43 | $memcache.flush_all 44 | story.characters.find(:all, :conditions => { :name => name }) == characters 45 | story.characters.count(:all, :conditions => { :name => name }).should == characters.size 46 | story.characters.find(:all, :conditions => { :name => name }) == characters 47 | mock(Story.connection).execute.never 48 | # story.characters.count(:all, :conditions => { :name => name }).should == characters.size 49 | end 50 | end 51 | end 52 | end 53 | 54 | describe 'when the cache is not populated' do 55 | describe '#count(:all, ...)' do 56 | describe '#count(:all)' do 57 | it 'uses the database, not the cache' do 58 | mock(Story).get.never 59 | Story.count 60 | end 61 | end 62 | 63 | describe '#count(:all, :conditions => ...)' do 64 | before do 65 | Story.create!(:title => @title = 'title') 66 | $memcache.flush_all 67 | end 68 | 69 | it "populates the count correctly" do 70 | Story.count(:all, :conditions => { :title => @title }).should == 1 71 | Story.fetch("title/#{@title}/count", :raw => true).should =~ /\s*1\s*/ 72 | end 73 | end 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/cache_money.rb: -------------------------------------------------------------------------------- 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 -------------------------------------------------------------------------------- /lib/cash/mock.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | class Mock < HashWithIndifferentAccess 3 | attr_accessor :servers 4 | 5 | class CacheEntry 6 | attr_reader :value 7 | 8 | def self.default_ttl 9 | 1_000_000 10 | end 11 | 12 | def self.now 13 | Time.now 14 | end 15 | 16 | def initialize(value, raw, ttl) 17 | if raw 18 | @value = value.to_s 19 | else 20 | @value = Marshal.dump(value) 21 | end 22 | 23 | if ttl.nil? || ttl.zero? 24 | @ttl = self.class.default_ttl 25 | else 26 | @ttl = ttl 27 | end 28 | 29 | @expires_at = self.class.now + @ttl 30 | end 31 | 32 | 33 | def expired? 34 | self.class.now > @expires_at 35 | end 36 | 37 | def increment(amount = 1) 38 | @value = (@value.to_i + amount).to_s 39 | end 40 | 41 | def decrement(amount = 1) 42 | @value = (@value.to_i - amount).to_s 43 | end 44 | 45 | def unmarshal 46 | Marshal.load(@value) 47 | end 48 | 49 | def to_i 50 | @value.to_i 51 | end 52 | end 53 | 54 | attr_accessor :logging 55 | 56 | def initialize 57 | @logging = false 58 | end 59 | 60 | def get_multi(*keys) 61 | slice(*keys).collect { |k,v| [k, v.unmarshal] }.to_hash_without_nils 62 | end 63 | 64 | def set(key, value, ttl = CacheEntry.default_ttl, raw = false) 65 | log "< set #{key} #{ttl}" 66 | self[key] = CacheEntry.new(value, raw, ttl) 67 | log('> STORED') 68 | end 69 | 70 | def get(key, raw = false) 71 | if key.is_a?(Array) 72 | get_multi(*key) 73 | else 74 | log "< get #{key}" 75 | unless self.has_unexpired_key?(key) 76 | log('> END') 77 | return nil 78 | end 79 | 80 | log("> sending key #{key}") 81 | log('> END') 82 | if raw 83 | self[key].value 84 | else 85 | self[key].unmarshal 86 | end 87 | end 88 | end 89 | 90 | def delete(key, options = {}) 91 | log "< delete #{key}" 92 | if self.has_unexpired_key?(key) 93 | log "> DELETED" 94 | super(key) 95 | else 96 | log "> NOT FOUND" 97 | end 98 | end 99 | 100 | def incr(key, amount = 1) 101 | if self.has_unexpired_key?(key) 102 | self[key].increment(amount) 103 | self[key].to_i 104 | end 105 | end 106 | 107 | def decr(key, amount = 1) 108 | if self.has_unexpired_key?(key) 109 | self[key].decrement(amount) 110 | self[key].to_i 111 | end 112 | end 113 | 114 | def add(key, value, ttl = CacheEntry.default_ttl, raw = false) 115 | if self.has_unexpired_key?(key) 116 | "NOT_STORED\r\n" 117 | else 118 | set(key, value, ttl, raw) 119 | "STORED\r\n" 120 | end 121 | end 122 | 123 | def append(key, value) 124 | set(key, get(key, true).to_s + value.to_s, nil, true) 125 | end 126 | 127 | def namespace 128 | nil 129 | end 130 | 131 | def flush_all 132 | log('< flush_all') 133 | clear 134 | end 135 | 136 | def stats 137 | {} 138 | end 139 | 140 | def reset_runtime 141 | [0, Hash.new(0)] 142 | end 143 | 144 | def has_unexpired_key?(key) 145 | self.has_key?(key) && !self[key].expired? 146 | end 147 | 148 | def log(message) 149 | return unless logging 150 | logger.debug(message) 151 | end 152 | 153 | def logger 154 | @logger ||= ActiveSupport::BufferedLogger.new(Rails.root.join('log/cash_mock.log')) 155 | end 156 | 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/cash/adapter/memcached.rb: -------------------------------------------------------------------------------- 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 nil 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 get_server_for_key(key) 69 | wrap(key) { @repository.server_by_key(key) } 70 | end 71 | 72 | def incr(key, value = 1) 73 | wrap(key) { @repository.incr(key, value) } 74 | end 75 | 76 | def decr(key, value = 1) 77 | wrap(key) { @repository.decr(key, value) } 78 | end 79 | 80 | def flush_all 81 | @repository.flush 82 | end 83 | 84 | def exception_classes 85 | ::Memcached::Error 86 | end 87 | 88 | private 89 | 90 | def logger 91 | @logger 92 | end 93 | 94 | def debug_logger? 95 | logger && logger.respond_to?(:debug?) && logger.debug? 96 | end 97 | 98 | def wrap(key, error_value = nil, options = {}) 99 | yield 100 | rescue ::Memcached::NotStored 101 | logger.debug("Memcached miss: #{key.inspect}") if debug_logger? 102 | error_value 103 | rescue ::Memcached::Error 104 | log_error($!) if logger 105 | raise if options[:reraise_error] 106 | error_value 107 | end 108 | 109 | def stored 110 | "STORED\r\n" 111 | end 112 | 113 | def deleted 114 | "DELETED\r\n" 115 | end 116 | 117 | def not_stored 118 | "NOT_STORED\r\n" 119 | end 120 | 121 | def not_found 122 | "NOT_FOUND\r\n" 123 | end 124 | 125 | def log_error(err) 126 | #logger.error("#{err}: \n\t#{err.backtrace.join("\n\t")}") if logger 127 | logger.error("Memcached ERROR, #{err.class}: #{err}") if logger 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/cash/lock_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Lock do 5 | let(:lock) { Cash::Lock.new($memcache) } 6 | 7 | describe '#synchronize' do 8 | it "yields the block" do 9 | block_was_called = false 10 | lock.synchronize('lock_key') do 11 | block_was_called = true 12 | end 13 | block_was_called.should == true 14 | end 15 | 16 | it "acquires the specified lock before the block is run" do 17 | $memcache.get("lock/lock_key").should == nil 18 | lock.synchronize('lock_key') do 19 | $memcache.get("lock/lock_key").should_not == nil 20 | end 21 | end 22 | 23 | it "releases the lock after the block is run" do 24 | $memcache.get("lock/lock_key").should == nil 25 | lock.synchronize('lock_key') {} 26 | $memcache.get("lock/lock_key").should == nil 27 | end 28 | 29 | it "releases the lock even if the block raises" do 30 | $memcache.get("lock/lock_key").should == nil 31 | lock.synchronize('lock_key') { raise } rescue nil 32 | $memcache.get("lock/lock_key").should == nil 33 | end 34 | 35 | it "does not block on recursive lock acquisition" do 36 | lock.synchronize('lock_key') do 37 | lambda { lock.synchronize('lock_key') {} }.should_not raise_error 38 | end 39 | end 40 | end 41 | 42 | describe '#acquire_lock' do 43 | it "creates a lock at a given cache key" do 44 | $memcache.get("lock/lock_key").should == nil 45 | lock.acquire_lock("lock_key") 46 | $memcache.get("lock/lock_key").should_not == nil 47 | end 48 | 49 | describe 'when given a timeout for the lock' do 50 | it "correctly sets timeout on memcache entries" do 51 | mock($memcache).add('lock/lock_key', "#{Socket.gethostname} #{Process.pid}", timeout = 10) { true } 52 | # lock.acquire_lock('lock_key', timeout) 53 | lambda { lock.acquire_lock('lock_key', timeout, 1) }.should raise_error(Cash::Lock::Error) 54 | end 55 | end 56 | 57 | describe 'when two processes contend for a lock' do 58 | it "prevents two processes from acquiring the same lock at the same time" do 59 | lock.acquire_lock('lock_key') 60 | as_another_process do 61 | stub(lock).exponential_sleep 62 | lambda { lock.acquire_lock('lock_key') }.should raise_error(Cash::Lock::Error) 63 | end 64 | end 65 | 66 | describe 'when given a number of times to retry' do 67 | it "retries specified number of times" do 68 | lock.acquire_lock('lock_key') 69 | as_another_process do 70 | mock($memcache).add("lock/lock_key", "#{Socket.gethostname} #{Process.pid}", timeout = 10) { false }.times(retries = 3) 71 | stub(lock).exponential_sleep 72 | lambda { lock.acquire_lock('lock_key', timeout, retries) }.should raise_error(Cash::Lock::Error) 73 | end 74 | end 75 | end 76 | 77 | describe 'when given an initial wait' do 78 | it 'sleeps exponentially starting with the initial wait' do 79 | stub(lock).sleep(initial_wait = 0.123) 80 | stub(lock).sleep(2 * initial_wait) 81 | stub(lock).sleep(4 * initial_wait) 82 | stub(lock).sleep(8 * initial_wait) 83 | stub(lock).sleep(16 * initial_wait) 84 | stub(lock).sleep(32 * initial_wait) 85 | stub(lock).sleep(64 * initial_wait) 86 | stub(lock).sleep(128 * initial_wait) 87 | lock.acquire_lock('lock_key') 88 | as_another_process do 89 | lambda { lock.acquire_lock('lock_key', Lock::DEFAULT_EXPIRY, Lock::DEFAULT_RETRY, initial_wait) }.should raise_error(Cash::Lock::Error) 90 | end 91 | end 92 | end 93 | 94 | def as_another_process 95 | current_pid = Process.pid 96 | stub(Process).pid { current_pid + 1 } 97 | yield 98 | end 99 | 100 | end 101 | 102 | end 103 | 104 | describe '#release_lock' do 105 | it "deletes the lock for a given cache key" do 106 | $memcache.get("lock/lock_key").should == nil 107 | lock.acquire_lock("lock_key") 108 | $memcache.get("lock/lock_key").should_not == nil 109 | lock.release_lock("lock_key") 110 | $memcache.get("lock/lock_key").should == nil 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/mem_cached_support_store.rb: -------------------------------------------------------------------------------- 1 | require 'memcached' 2 | 3 | # A cache store implementation which stores data in Memcached: 4 | # http://www.danga.com/memcached/ 5 | # 6 | # This is currently the most popular cache store for production websites. 7 | # 8 | # Special features: 9 | # - Clustering and load balancing. One can specify multiple memcached servers, 10 | # and MemCacheStore will load balance between all available servers. If a 11 | # server goes down, then MemCacheStore will ignore it until it goes back 12 | # online. 13 | # - Time-based expiry support. See #write and the +:expires_in+ option. 14 | # - Per-request in memory cache for all communication with the MemCache server(s). 15 | class MemCachedSupportStore < ActiveSupport::Cache::Store 16 | 17 | attr_reader :addresses 18 | 19 | # Creates a new MemCacheStore object, with the given memcached server 20 | # addresses. Each address is either a host name, or a host-with-port string 21 | # in the form of "host_name:port". For example: 22 | # 23 | # ActiveSupport::Cache::MemCacheStore.new("localhost", "server-downstairs.localnetwork:8229") 24 | # 25 | # If no addresses are specified, then MemCacheStore will connect to 26 | # localhost port 11211 (the default memcached port). 27 | def initialize(*addresses) 28 | addresses = addresses.flatten 29 | options = addresses.extract_options! 30 | options[:prefix_key] ||= options[:namespace] 31 | addresses = ["localhost"] if addresses.empty? 32 | @addresses = addresses 33 | @data = Memcached.new(addresses, options) 34 | 35 | extend ActiveSupport::Cache::Strategy::LocalCache 36 | end 37 | 38 | def read(key, options = nil) # :nodoc: 39 | super 40 | @data.get(key, marshal?(options)) 41 | rescue Memcached::NotFound 42 | nil 43 | rescue Memcached::Error => e 44 | logger.error("MemcachedError (#{e}): #{e.message}") 45 | nil 46 | end 47 | 48 | # Writes a value to the cache. 49 | # 50 | # Possible options: 51 | # - +:unless_exist+ - set to true if you don't want to update the cache 52 | # if the key is already set. 53 | # - +:expires_in+ - the number of seconds that this value may stay in 54 | # the cache. See ActiveSupport::Cache::Store#write for an example. 55 | def write(key, value, options = nil) 56 | super 57 | method = options && options[:unless_exist] ? :add : :set 58 | # memcache-client will break the connection if you send it an integer 59 | # in raw mode, so we convert it to a string to be sure it continues working. 60 | @data.send(method, key, value, expires_in(options), marshal?(options)) 61 | true 62 | rescue Memcached::NotStored 63 | false 64 | rescue Memcached::NotFound 65 | false 66 | rescue Memcached::Error => e 67 | logger.error("MemcachedError (#{e}): #{e.message}") 68 | false 69 | end 70 | 71 | def delete(key, options = nil) # :nodoc: 72 | super 73 | @data.delete(key) 74 | true 75 | rescue Memcached::NotFound 76 | false 77 | rescue Memcached::Error => e 78 | logger.error("MemcachedError (#{e}): #{e.message}") 79 | false 80 | end 81 | 82 | def exist?(key, options = nil) # :nodoc: 83 | # Doesn't call super, cause exist? in memcache is in fact a read 84 | # But who cares? Reading is very fast anyway 85 | # Local cache is checked first, if it doesn't know then memcache itself is read from 86 | !read(key, options).nil? 87 | end 88 | 89 | def increment(key, amount = 1) # :nodoc: 90 | log("incrementing", key, amount) 91 | 92 | @data.incr(key, amount) 93 | response 94 | rescue Memcached::NotFound 95 | nil 96 | rescue Memcached::Error 97 | nil 98 | end 99 | 100 | def decrement(key, amount = 1) # :nodoc: 101 | log("decrement", key, amount) 102 | @data.decr(key, amount) 103 | response 104 | rescue Memcached::NotFound 105 | nil 106 | rescue Memcached::Error 107 | nil 108 | end 109 | 110 | def delete_matched(matcher, options = nil) # :nodoc: 111 | # don't do any local caching at present, just pass 112 | # through and let the error happen 113 | super 114 | raise "Not supported by Memcache" 115 | end 116 | 117 | def clear 118 | @data.flush 119 | rescue Memcached::NotFound 120 | end 121 | 122 | def stats 123 | @data.stats 124 | rescue Memcached::NotFound 125 | end 126 | 127 | private 128 | def expires_in(options) 129 | (options && options[:expires_in] && options[:expires_in].to_i) || 0 130 | end 131 | 132 | def marshal?(options) 133 | !(options && options[:raw]) 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/cash/adapter/redis.rb: -------------------------------------------------------------------------------- 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 get_server_for_key(key) 77 | wrap(key) do 78 | # Redis::Distributed has a node_for method. 79 | client = @repository.respond_to?(:node_for) ? @repository.node_for(key) : @repository.client 80 | client.id 81 | end 82 | end 83 | 84 | def incr(key, value = 1) 85 | # Redis always answeres positively to incr/decr but memcache does not and waits for the key 86 | # to be added in a separate operation. 87 | if wrap(nil) { @repository.exists(key) } 88 | wrap(key) { @repository.incrby(key, value).to_i } 89 | end 90 | end 91 | 92 | def decr(key, value = 1) 93 | if wrap(nil) { @repository.exists(key) } 94 | wrap(key) { @repository.decrby(key, value).to_i } 95 | end 96 | end 97 | 98 | def flush_all 99 | @repository.flushall 100 | end 101 | 102 | def exception_classes 103 | [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED, Errno::EBADF, Errno::EINVAL] 104 | end 105 | 106 | private 107 | 108 | def logger 109 | @logger 110 | end 111 | 112 | def debug_logger? 113 | logger && logger.respond_to?(:debug?) && logger.debug? 114 | end 115 | 116 | def wrap(key, error_value = nil) 117 | yield 118 | rescue *exception_classes 119 | log_error($!) if logger 120 | error_value 121 | end 122 | 123 | def dump(value) 124 | Marshal.dump(value) 125 | end 126 | 127 | def load(value) 128 | Marshal.load(value) 129 | end 130 | 131 | def stored 132 | "STORED\r\n" 133 | end 134 | 135 | def deleted 136 | "DELETED\r\n" 137 | end 138 | 139 | def not_stored 140 | "NOT_STORED\r\n" 141 | end 142 | 143 | def not_found 144 | "NOT_FOUND\r\n" 145 | end 146 | 147 | def log_error(err) 148 | logger.error("Redis ERROR, #{err.class}: #{err}") if logger 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/cash/accessor_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'ruby-debug' 3 | 4 | module Cash 5 | describe Accessor do 6 | describe '#fetch' do 7 | describe '#fetch("...")' do 8 | describe 'when there is a cache miss' do 9 | it 'returns nil' do 10 | Story.fetch("yabba").should be_nil 11 | end 12 | end 13 | 14 | describe 'when there is a cache hit' do 15 | it 'returns the value of the cache' do 16 | Story.set("yabba", "dabba") 17 | Story.fetch("yabba").should == "dabba" 18 | end 19 | end 20 | end 21 | 22 | describe '#fetch([...])', :shared => true do 23 | describe '#fetch([])' do 24 | it 'returns the empty hash' do 25 | Story.fetch([]).should == {} 26 | end 27 | end 28 | 29 | describe 'when there is a total cache miss' do 30 | it 'yields the keys to the block' do 31 | Story.fetch(["yabba", "dabba"]) { |*missing_ids| ["doo", "doo"] }.should == { 32 | "Story:1/yabba" => "doo", 33 | "Story:1/dabba" => "doo" 34 | } 35 | end 36 | end 37 | 38 | describe 'when there is a partial cache miss' do 39 | it 'yields just the missing ids to the block' do 40 | Story.set("yabba", "dabba") 41 | Story.fetch(["yabba", "dabba"]) { |*missing_ids| "doo" }.should == { 42 | "Story:1/yabba" => "dabba", 43 | "Story:1/dabba" => "doo" 44 | } 45 | end 46 | end 47 | end 48 | end 49 | 50 | describe '#get' do 51 | describe '#get("...")' do 52 | describe 'when there is a cache miss' do 53 | it 'returns the value of the block' do 54 | Story.get("yabba") { "dabba" }.should == "dabba" 55 | end 56 | 57 | it 'adds to the cache' do 58 | Story.get("yabba") { "dabba" } 59 | Story.get("yabba").should == "dabba" 60 | end 61 | end 62 | 63 | describe 'when there is a cache hit' do 64 | before do 65 | Story.set("yabba", "dabba") 66 | end 67 | 68 | it 'returns the value of the cache' do 69 | Story.get("yabba") { "doo" }.should == "dabba" 70 | end 71 | 72 | it 'does nothing to the cache' do 73 | Story.get("yabba") { "doo" } 74 | Story.get("yabba").should == "dabba" 75 | end 76 | end 77 | end 78 | 79 | describe '#get([...])' do 80 | it_should_behave_like "#fetch([...])" 81 | end 82 | end 83 | 84 | describe '#add' do 85 | describe 'when the value already exists' do 86 | describe 'when a block is given' do 87 | it 'yields to the block' do 88 | Story.set("count", 1) 89 | Story.add("count", 1) { "yield me" }.should == "yield me" 90 | end 91 | end 92 | 93 | describe 'when no block is given' do 94 | it 'does not error' do 95 | Story.set("count", 1) 96 | lambda { Story.add("count", 1) }.should_not raise_error 97 | end 98 | end 99 | end 100 | 101 | describe 'when the value does not already exist' do 102 | it 'adds the key to the cache' do 103 | Story.add("count", 1) 104 | Story.get("count").should == 1 105 | end 106 | end 107 | end 108 | 109 | describe '#set' do 110 | end 111 | 112 | describe '#incr' do 113 | describe 'when there is a cache hit' do 114 | before do 115 | Story.set("count", 0, :raw => true) 116 | end 117 | 118 | it 'increments the value of the cache' do 119 | Story.incr("count", 2) 120 | Story.get("count", :raw => true).should =~ /2/ 121 | end 122 | 123 | it 'returns the new cache value' do 124 | Story.incr("count", 2).should == 2 125 | end 126 | end 127 | 128 | describe 'when there is a cache miss' do 129 | it 'initializes the value of the cache to the value of the block' do 130 | Story.incr("count", 1) { 5 } 131 | Story.get("count", :raw => true).should =~ /5/ 132 | end 133 | 134 | it 'returns the new cache value' do 135 | Story.incr("count", 1) { 2 }.should == 2 136 | end 137 | end 138 | end 139 | 140 | describe '#decr' do 141 | describe 'when there is a cache hit' do 142 | before do 143 | Story.incr("count", 1) { 10 } 144 | end 145 | 146 | it 'decrements the value of the cache' do 147 | Story.decr("count", 2) 148 | Story.get("count", :raw => true).should =~ /8/ 149 | end 150 | 151 | it 'returns the new cache value' do 152 | Story.decr("count", 2).should == 8 153 | end 154 | end 155 | 156 | describe 'when there is a cache miss' do 157 | it 'initializes the value of the cache to the value of the block' do 158 | Story.decr("count", 1) { 5 } 159 | Story.get("count", :raw => true).should =~ /5/ 160 | end 161 | 162 | it 'returns the new cache value' do 163 | Story.decr("count", 1) { 2 }.should == 2 164 | end 165 | end 166 | end 167 | 168 | describe '#expire' do 169 | it 'deletes the key' do 170 | Story.set("bobo", 1) 171 | Story.expire("bobo") 172 | Story.get("bobo").should == nil 173 | end 174 | end 175 | 176 | describe '#cache_key' do 177 | it 'uses the version number' do 178 | Story.version 1 179 | Story.cache_key("foo").should == "Story:1/foo" 180 | 181 | Story.version 2 182 | Story.cache_key("foo").should == "Story:2/foo" 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/cash/order_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe 'Ordering' do 5 | before :suite do 6 | FairyTale = Class.new(Story) 7 | end 8 | 9 | describe '#create!' do 10 | describe 'the records are written-through in sorted order', :shared => true do 11 | describe 'when there are not already records matching the index' do 12 | it 'initializes the index' do 13 | fairy_tale = FairyTale.create!(:title => 'title') 14 | FairyTale.get("title/#{fairy_tale.title}").should == [fairy_tale.id] 15 | end 16 | end 17 | 18 | describe 'when there are already records matching the index' do 19 | before do 20 | @fairy_tale1 = FairyTale.create!(:title => 'title') 21 | FairyTale.get("title/#{@fairy_tale1.title}").should == sorted_and_serialized_records(@fairy_tale1) 22 | end 23 | 24 | describe 'when the index is populated' do 25 | it 'appends to the index' do 26 | fairy_tale2 = FairyTale.create!(:title => @fairy_tale1.title) 27 | FairyTale.get("title/#{@fairy_tale1.title}").should == sorted_and_serialized_records(@fairy_tale1, fairy_tale2) 28 | end 29 | end 30 | 31 | describe 'when the index is not populated' do 32 | before do 33 | $memcache.flush_all 34 | end 35 | 36 | it 'initializes the index' do 37 | fairy_tale2 = FairyTale.create!(:title => @fairy_tale1.title) 38 | FairyTale.get("title/#{@fairy_tale1.title}").should == sorted_and_serialized_records(@fairy_tale1, fairy_tale2) 39 | end 40 | end 41 | end 42 | end 43 | 44 | describe 'when the order is ascending' do 45 | it_should_behave_like 'the records are written-through in sorted order' 46 | 47 | before :all do 48 | FairyTale.index :title, :order => :asc 49 | end 50 | 51 | def sorted_and_serialized_records(*records) 52 | records.collect(&:id).sort 53 | end 54 | end 55 | 56 | describe 'when the order is descending' do 57 | it_should_behave_like 'the records are written-through in sorted order' 58 | 59 | before :all do 60 | FairyTale.index :title, :order => :desc 61 | end 62 | 63 | def sorted_and_serialized_records(*records) 64 | records.collect(&:id).sort.reverse 65 | end 66 | end 67 | end 68 | 69 | describe "#find(..., :order => ...)" do 70 | before :each do 71 | @fairy_tales = [FairyTale.create!(:title => @title = 'title'), FairyTale.create!(:title => @title)] 72 | end 73 | 74 | describe 'when the order is ascending' do 75 | before :all do 76 | FairyTale.index :title, :order => :asc 77 | end 78 | 79 | describe "#find(..., :order => 'id ASC')" do 80 | describe 'when the cache is populated' do 81 | it 'does not use the database' do 82 | mock(FairyTale.connection).execute.never 83 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id ASC').should == @fairy_tales 84 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id').should == @fairy_tales 85 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`id`').should == @fairy_tales 86 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'stories.id').should == @fairy_tales 87 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`stories`.id').should == @fairy_tales 88 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`stories`.`id`').should == @fairy_tales 89 | end 90 | 91 | describe 'when the order is passed as a symbol' do 92 | it 'works' do 93 | FairyTale.find(:all, :conditions => { :title => @title }, :order => :id) 94 | end 95 | end 96 | end 97 | 98 | describe 'when the cache is not populated' do 99 | it 'populates the cache' do 100 | $memcache.flush_all 101 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id ASC').should == @fairy_tales 102 | FairyTale.get("title/#{@title}").should == @fairy_tales.collect(&:id) 103 | end 104 | end 105 | end 106 | 107 | describe "#find(..., :order => 'id DESC')" do 108 | describe 'when the cache is populated' do 109 | it 'uses the database, not the cache' do 110 | mock(FairyTale).get.never 111 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id DESC').should == @fairy_tales.reverse 112 | end 113 | end 114 | 115 | describe 'when the cache is not populated' do 116 | it 'does not populate the cache' do 117 | $memcache.flush_all 118 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id DESC').should == @fairy_tales.reverse 119 | FairyTale.get("title/#{@title}").should be_nil 120 | end 121 | end 122 | end 123 | end 124 | 125 | describe 'when the order is descending' do 126 | before :all do 127 | FairyTale.index :title, :order => :desc 128 | end 129 | 130 | describe "#find(..., :order => 'id DESC')" do 131 | describe 'when the cache is populated' do 132 | it 'does not use the database' do 133 | mock(FairyTale.connection).execute.never 134 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id DESC').should == @fairy_tales.reverse 135 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id DESC').should == @fairy_tales.reverse 136 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`id` DESC').should == @fairy_tales.reverse 137 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'stories.id DESC').should == @fairy_tales.reverse 138 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`stories`.id DESC').should == @fairy_tales.reverse 139 | FairyTale.find(:all, :conditions => { :title => @title }, :order => '`stories`.`id` DESC').should == @fairy_tales.reverse 140 | end 141 | end 142 | 143 | describe 'when the cache is not populated' do 144 | it 'populates the cache' do 145 | $memcache.flush_all 146 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id DESC') 147 | FairyTale.get("title/#{@title}").should == @fairy_tales.collect(&:id).reverse 148 | end 149 | end 150 | end 151 | 152 | describe "#find(..., :order => 'id ASC')" do 153 | describe 'when the cache is populated' do 154 | it 'uses the database, not the cache' do 155 | mock(FairyTale).get.never 156 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id ASC').should == @fairy_tales 157 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id').should == @fairy_tales 158 | end 159 | end 160 | 161 | describe 'when the cache is not populated' do 162 | it 'does not populate the cache' do 163 | $memcache.flush_all 164 | FairyTale.find(:all, :conditions => { :title => @title }, :order => 'id ASC').should == @fairy_tales 165 | FairyTale.get("title/#{@title}").should be_nil 166 | end 167 | end 168 | end 169 | end 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /lib/cash/index.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | class Index 3 | attr_reader :attributes, :options 4 | delegate :each, :hash, :to => :@attributes 5 | delegate :get, :set, :expire, :find_every_without_cache, :calculate_without_cache, :calculate_with_cache, :incr, :decr, :primary_key, :logger, :to => :@active_record 6 | 7 | def initialize(config, active_record, attributes, options = {}) 8 | @config, @active_record, @attributes, @options = config, active_record, Array(attributes).collect(&:to_s).sort, options 9 | end 10 | 11 | def ==(other) 12 | case other 13 | when Index 14 | attributes == other.attributes 15 | else 16 | attributes == Array(other) 17 | end 18 | end 19 | alias_method :eql?, :== 20 | 21 | module Commands 22 | def add(object) 23 | _, new_attribute_value_pairs = old_and_new_attribute_value_pairs(object) 24 | add_to_index_with_minimal_network_operations(new_attribute_value_pairs, object) 25 | end 26 | 27 | def update(object) 28 | old_attribute_value_pairs, new_attribute_value_pairs = old_and_new_attribute_value_pairs(object) 29 | update_index_with_minimal_network_operations(old_attribute_value_pairs, new_attribute_value_pairs, object) 30 | end 31 | 32 | def remove(object) 33 | old_attribute_value_pairs, _ = old_and_new_attribute_value_pairs(object) 34 | remove_from_index_with_minimal_network_operations(old_attribute_value_pairs, object) 35 | end 36 | 37 | def delete(object) 38 | old_attribute_value_pairs, _ = old_and_new_attribute_value_pairs(object) 39 | key = cache_key(old_attribute_value_pairs) 40 | expire(key) 41 | end 42 | end 43 | include Commands 44 | 45 | module Attributes 46 | def ttl 47 | @ttl ||= options[:ttl] || @config.ttl 48 | end 49 | 50 | def order 51 | @order ||= options[:order] || :asc 52 | end 53 | 54 | def limit 55 | options[:limit] 56 | end 57 | 58 | def buffer 59 | options[:buffer] 60 | end 61 | 62 | def window 63 | limit && limit + buffer 64 | end 65 | 66 | def order_column 67 | options[:order_column] || 'id' 68 | end 69 | end 70 | include Attributes 71 | 72 | def serialize_object(object) 73 | primary_key? ? object.shallow_clone : object.id 74 | end 75 | 76 | def matches?(query) 77 | query.calculation? || 78 | (query.order == [order_column, order] && 79 | (!limit || (query.limit && query.limit + query.offset <= limit))) 80 | end 81 | 82 | private 83 | def old_and_new_attribute_value_pairs(object) 84 | old_attribute_value_pairs = [] 85 | new_attribute_value_pairs = [] 86 | @attributes.each do |name| 87 | new_value = object.attributes[name] 88 | if object.changed.include? name 89 | original_value = object.send("#{name}_was") 90 | else 91 | original_value = new_value 92 | end 93 | old_attribute_value_pairs << [name, original_value] 94 | new_attribute_value_pairs << [name, new_value] 95 | end 96 | [old_attribute_value_pairs, new_attribute_value_pairs] 97 | end 98 | 99 | def add_to_index_with_minimal_network_operations(attribute_value_pairs, object) 100 | if primary_key? 101 | add_object_to_primary_key_cache(attribute_value_pairs, object) 102 | else 103 | add_object_to_cache(attribute_value_pairs, object) 104 | end 105 | end 106 | 107 | def primary_key? 108 | @attributes.size == 1 && @attributes.first == primary_key 109 | end 110 | 111 | def add_object_to_primary_key_cache(attribute_value_pairs, object) 112 | set(cache_key(attribute_value_pairs), [serialize_object(object)], :ttl => ttl) 113 | end 114 | 115 | def cache_key(attribute_value_pairs) 116 | attribute_value_pairs.flatten.join('/') 117 | end 118 | 119 | def add_object_to_cache(attribute_value_pairs, object, overwrite = true) 120 | return if invalid_cache_key?(attribute_value_pairs) 121 | 122 | key, cache_value, cache_hit = get_key_and_value_at_index(attribute_value_pairs) 123 | if !cache_hit || overwrite 124 | object_to_add = serialize_object(object) 125 | objects = (cache_value + [object_to_add]).sort do |a, b| 126 | (a <=> b) * (order == :asc ? 1 : -1) 127 | end.uniq 128 | objects = truncate_if_necessary(objects) 129 | set(key, objects, :ttl => ttl) 130 | incr("#{key}/count") { calculate_at_index(:count, attribute_value_pairs) } 131 | end 132 | end 133 | 134 | def invalid_cache_key?(attribute_value_pairs) 135 | attribute_value_pairs.collect { |_,value| value }.any? { |x| x.nil? } 136 | end 137 | 138 | def get_key_and_value_at_index(attribute_value_pairs) 139 | key = cache_key(attribute_value_pairs) 140 | cache_hit = true 141 | cache_value = get(key) do 142 | cache_hit = false 143 | conditions = attribute_value_pairs.to_hash_without_nils 144 | find_every_without_cache(:conditions => conditions, :limit => window).collect do |object| 145 | serialize_object(object) 146 | end 147 | end 148 | [key, cache_value, cache_hit] 149 | end 150 | 151 | def truncate_if_necessary(objects) 152 | objects.slice(0, window || objects.size) 153 | end 154 | 155 | def calculate_at_index(operation, attribute_value_pairs) 156 | conditions = attribute_value_pairs.to_hash_without_nils 157 | calculate_without_cache(operation, :all, :conditions => conditions) 158 | end 159 | 160 | def update_index_with_minimal_network_operations(old_attribute_value_pairs, new_attribute_value_pairs, object) 161 | if index_is_stale?(old_attribute_value_pairs, new_attribute_value_pairs) 162 | remove_object_from_cache(old_attribute_value_pairs, object) 163 | add_object_to_cache(new_attribute_value_pairs, object) 164 | elsif primary_key? 165 | add_object_to_primary_key_cache(new_attribute_value_pairs, object) 166 | else 167 | add_object_to_cache(new_attribute_value_pairs, object, false) 168 | end 169 | end 170 | 171 | def index_is_stale?(old_attribute_value_pairs, new_attribute_value_pairs) 172 | old_attribute_value_pairs != new_attribute_value_pairs 173 | end 174 | 175 | def remove_from_index_with_minimal_network_operations(attribute_value_pairs, object) 176 | if primary_key? 177 | remove_object_from_primary_key_cache(attribute_value_pairs, object) 178 | else 179 | remove_object_from_cache(attribute_value_pairs, object) 180 | end 181 | end 182 | 183 | def remove_object_from_primary_key_cache(attribute_value_pairs, object) 184 | set(cache_key(attribute_value_pairs), [], :ttl => ttl) 185 | end 186 | 187 | def remove_object_from_cache(attribute_value_pairs, object) 188 | return if invalid_cache_key?(attribute_value_pairs) 189 | 190 | key, cache_value, _ = get_key_and_value_at_index(attribute_value_pairs) 191 | object_to_remove = serialize_object(object) 192 | objects = cache_value - [object_to_remove] 193 | objects = resize_if_necessary(attribute_value_pairs, objects) 194 | set(key, objects, :ttl => ttl) 195 | end 196 | 197 | def resize_if_necessary(attribute_value_pairs, objects) 198 | conditions = attribute_value_pairs.to_hash_without_nils 199 | key = cache_key(attribute_value_pairs) 200 | count = decr("#{key}/count") { calculate_at_index(:count, attribute_value_pairs) } 201 | 202 | if limit && objects.size < limit && objects.size < count 203 | find_every_without_cache(:select => :id, :conditions => conditions).collect do |object| 204 | serialize_object(object) 205 | end 206 | else 207 | objects 208 | end 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /spec/cash/active_record_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Finders do 5 | describe 'when the cache is populated' do 6 | describe "#find" do 7 | describe '#find(id...)' do 8 | describe '#find(id)' do 9 | it "returns an active record" do 10 | story = Story.create!(:title => 'a story') 11 | Story.find(story.id).should == story 12 | end 13 | end 14 | 15 | describe 'when the object is destroyed' do 16 | describe '#find(id)' do 17 | it "raises an error" do 18 | story = Story.create!(:title => "I am delicious") 19 | story.destroy 20 | lambda { Story.find(story.id) }.should raise_error(ActiveRecord::RecordNotFound) 21 | end 22 | end 23 | end 24 | 25 | describe '#find(id1, id2, ...)' do 26 | it "returns an array" do 27 | story1, story2 = Story.create!, Story.create! 28 | Story.find(story1.id, story2.id).should == [story1, story2] 29 | end 30 | 31 | describe "#find(id, nil)" do 32 | it "ignores the nils" do 33 | story = Story.create! 34 | Story.find(story.id, nil).should == story 35 | end 36 | end 37 | end 38 | 39 | describe 'when given nonexistent ids' do 40 | describe 'when given one nonexistent id' do 41 | it 'raises an error' do 42 | lambda { Story.find(1) }.should raise_error(ActiveRecord::RecordNotFound) 43 | end 44 | end 45 | 46 | describe 'when given multiple nonexistent ids' do 47 | it "raises an error" do 48 | lambda { Story.find(1, 2, 3) }.should raise_error(ActiveRecord::RecordNotFound) 49 | end 50 | end 51 | 52 | 53 | describe '#find(nil)' do 54 | it 'raises an error' do 55 | lambda { Story.find(nil) }.should raise_error(ActiveRecord::RecordNotFound) 56 | end 57 | end 58 | end 59 | end 60 | 61 | describe '#find(object)' do 62 | it "coerces arguments to integers" do 63 | story = Story.create! 64 | Story.find(story.id.to_s).should == story 65 | end 66 | end 67 | 68 | describe '#find([...])' do 69 | describe 'when given an array with valid ids' do 70 | it "#finds the object with that id" do 71 | story = Story.create! 72 | Story.find([story.id]).should == [story] 73 | end 74 | end 75 | 76 | describe '#find([])' do 77 | it 'returns the empty array' do 78 | Story.find([]).should == [] 79 | end 80 | end 81 | 82 | describe 'when given nonexistent ids' do 83 | it 'raises an error' do 84 | lambda { Story.find([1, 2, 3]) }.should raise_error(ActiveRecord::RecordNotFound) 85 | end 86 | end 87 | 88 | describe 'when given limits and offsets' do 89 | describe '#find([1, 2, ...], :limit => ..., :offset => ...)' do 90 | it "returns the correct slice of objects" do 91 | character1 = Character.create!(:name => "Sam", :story_id => 1) 92 | character2 = Character.create!(:name => "Sam", :story_id => 1) 93 | character3 = Character.create!(:name => "Sam", :story_id => 1) 94 | Character.find( 95 | [character1.id, character2.id, character3.id], 96 | :conditions => { :name => "Sam", :story_id => 1 }, :limit => 2 97 | ).should == [character1, character2] 98 | end 99 | end 100 | 101 | describe '#find([1], :limit => 0)' do 102 | it "raises an error" do 103 | character = Character.create!(:name => "Sam", :story_id => 1) 104 | lambda do 105 | Character.find([character.id], :conditions => { :name => "Sam", :story_id => 1 }, :limit => 0) 106 | end.should raise_error(ActiveRecord::RecordNotFound) 107 | end 108 | end 109 | end 110 | end 111 | 112 | describe '#find(:first, ...)' do 113 | describe '#find(:first, ..., :offset => ...)' do 114 | it "#finds the object in the correct order" do 115 | story1 = Story.create!(:title => 'title1') 116 | story2 = Story.create!(:title => story1.title) 117 | Story.find(:first, :conditions => { :title => story1.title }, :offset => 1).should == story2 118 | end 119 | end 120 | 121 | describe '#find(:first, :conditions => [])' do 122 | it 'finds the object in the correct order' do 123 | story = Story.create! 124 | Story.find(:first, :conditions => []).should == story 125 | end 126 | end 127 | 128 | describe "#find(:first, :conditions => '...')" do 129 | it "coerces ruby values to the appropriate database values" do 130 | story1 = Story.create! :title => 'a story', :published => true 131 | story2 = Story.create! :title => 'another story', :published => false 132 | Story.find(:first, :conditions => 'published = 0').should == story2 133 | end 134 | end 135 | end 136 | end 137 | 138 | describe '#find_by_attr' do 139 | describe '#find_by_attr(nil)' do 140 | it 'returns nil' do 141 | Story.find_by_id(nil).should == nil 142 | end 143 | end 144 | 145 | describe 'when given non-existent ids' do 146 | it 'returns nil' do 147 | Story.find_by_id(-1).should == nil 148 | end 149 | end 150 | end 151 | 152 | describe '#find_all_by_attr' do 153 | describe 'when given non-existent ids' do 154 | it "does not raise an error" do 155 | lambda { Story.find_all_by_id([-1, -2, -3]) }.should_not raise_error 156 | end 157 | end 158 | end 159 | end 160 | 161 | describe 'when the cache is partially populated' do 162 | describe '#find(:all, :conditions => ...)' do 163 | it "returns the correct records" do 164 | story1 = Story.create!(:title => title = 'once upon a time...') 165 | $memcache.flush_all 166 | story2 = Story.create!(:title => title) 167 | Story.find(:all, :conditions => { :title => story1.title }).should == [story1, story2] 168 | end 169 | end 170 | 171 | describe '#find(id1, id2, ...)' do 172 | it "returns the correct records" do 173 | story1 = Story.create!(:title => 'story 1') 174 | $memcache.flush_all 175 | story2 = Story.create!(:title => 'story 2') 176 | Story.find(story1.id, story2.id).should == [story1, story2] 177 | end 178 | end 179 | end 180 | 181 | describe 'when the cache is not populated' do 182 | describe '#find(id)' do 183 | it "returns the correct records" do 184 | story = Story.create!(:title => 'a story') 185 | $memcache.flush_all 186 | Story.find(story.id).should == story 187 | end 188 | 189 | it "handles after_find on model" do 190 | class AfterFindStory < Story 191 | def after_find 192 | self.title 193 | end 194 | end 195 | lambda do 196 | AfterFindStory.create!(:title => 'a story') 197 | end.should_not raise_error(ActiveRecord::MissingAttributeError) 198 | end 199 | end 200 | 201 | describe '#find(id1, id2, ...)' do 202 | it "handles finds with multiple ids correctly" do 203 | story1 = Story.create! 204 | story2 = Story.create! 205 | $memcache.flush_all 206 | Story.find(story1.id, story2.id).should == [story1, story2] 207 | end 208 | end 209 | end 210 | 211 | describe 'loading' do 212 | it "should be able to create a record for an ar subclass that was loaded before cache money" do 213 | $debug = true 214 | session = ActiveRecord::SessionStore::Session.new 215 | session.session_id = "12345" 216 | session.data = "foobarbaz" 217 | 218 | lambda { 219 | session.save! 220 | }.should_not raise_error 221 | end 222 | end 223 | end 224 | end 225 | -------------------------------------------------------------------------------- /spec/cash/window_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe 'Windows' do 5 | LIMIT, BUFFER = 5, 2 6 | 7 | before :suite do 8 | Fable = Class.new(Story) 9 | Fable.index :title, :limit => LIMIT, :buffer => BUFFER 10 | end 11 | 12 | describe '#find(...)' do 13 | before do 14 | @fables = [] 15 | 10.times { @fables << Fable.create!(:title => @title = 'title') } 16 | end 17 | 18 | describe 'when the cache is populated' do 19 | describe "#find(:all, :conditions => ...)" do 20 | it "uses the database, not the cache" do 21 | mock(Fable).get.never 22 | Fable.find(:all, :conditions => { :title => @title }).should == @fables 23 | end 24 | end 25 | 26 | describe "#find(:all, :conditions => ..., :limit => ...) and query limit > index limit" do 27 | it "uses the database, not the cache" do 28 | mock(Fable).get.never 29 | Fable.find(:all, :conditions => { :title => @title }, :limit => LIMIT + 1).should == @fables[0, LIMIT + 1] 30 | end 31 | end 32 | 33 | describe "#find(:all, :conditions => ..., :limit => ..., :offset => ...) and query limit + offset > index limit" do 34 | it "uses the database, not the cache" do 35 | mock(Fable).get.never 36 | Fable.find(:all, :conditions => { :title => @title }, :limit => 1, :offset => LIMIT).should == @fables[LIMIT, 1] 37 | end 38 | end 39 | 40 | describe "#find(:all, :conditions => ..., :limit => ...) and query limit <= index limit" do 41 | it "does not use the database" do 42 | mock(Fable.connection).execute.never 43 | Fable.find(:all, :conditions => { :title => @title }, :limit => LIMIT - 1).should == @fables[0, LIMIT - 1] 44 | end 45 | end 46 | end 47 | 48 | describe 'when the cache is not populated' do 49 | describe "#find(:all, :conditions => ..., :limit => ...) and query limit <= index limit" do 50 | describe 'when there are fewer than limit + buffer items' do 51 | it "populates the cache with all items" do 52 | Fable.find(:all, :limit => deleted = @fables.size - LIMIT - BUFFER + 1).collect(&:destroy) 53 | $memcache.flush_all 54 | Fable.find(:all, :conditions => { :title => @title }, :limit => LIMIT).should == @fables[deleted, LIMIT] 55 | Fable.get("title/#{@title}").should == @fables[deleted, @fables.size - deleted].collect(&:id) 56 | end 57 | end 58 | 59 | describe 'when there are more than limit + buffer items' do 60 | it "populates the cache with limit + buffer items" do 61 | $memcache.flush_all 62 | Fable.find(:all, :conditions => { :title => @title }, :limit => 5).should == @fables[0, 5] 63 | Fable.get("title/#{@title}").should == @fables[0, LIMIT + BUFFER].collect(&:id) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | 70 | describe '#create!' do 71 | describe 'when the cache is populated' do 72 | describe 'when the count of records in the database is > limit + buffer items' do 73 | it 'truncates' do 74 | fables, title = [], 'title' 75 | (LIMIT + BUFFER).times { fables << Fable.create!(:title => title) } 76 | Fable.get("title/#{title}").should == fables.collect(&:id) 77 | Fable.create!(:title => title) 78 | Fable.get("title/#{title}").should == fables.collect(&:id) 79 | end 80 | end 81 | 82 | describe 'when the count of records in the database is < limit + buffer items' do 83 | it 'appends to the list' do 84 | fables, title = [], 'title' 85 | (LIMIT + BUFFER - 1).times { fables << Fable.create!(:title => title) } 86 | Fable.get("title/#{title}").should == fables.collect(&:id) 87 | fable = Fable.create!(:title => title) 88 | Fable.get("title/#{title}").should == (fables << fable).collect(&:id) 89 | end 90 | end 91 | end 92 | 93 | describe 'when the cache is not populated' do 94 | describe 'when the count of records in the database is > limit + buffer items' do 95 | it 'truncates the index' do 96 | fables, title = [], 'title' 97 | (LIMIT + BUFFER).times { fables << Fable.create!(:title => title) } 98 | $memcache.flush_all 99 | Fable.create!(:title => title) 100 | Fable.get("title/#{title}").should == fables.collect(&:id) 101 | end 102 | end 103 | 104 | describe 'when the count of records in the database is < limit + buffer items' do 105 | it 'appends to the list' do 106 | fables, title = [], 'title' 107 | (LIMIT + BUFFER - 1).times { fables << Fable.create!(:title => title) } 108 | $memcache.flush_all 109 | fable = Fable.create!(:title => title) 110 | Fable.get("title/#{title}").should == (fables << fable).collect(&:id) 111 | end 112 | end 113 | end 114 | end 115 | 116 | describe '#destroy' do 117 | describe 'when the cache is populated' do 118 | describe 'when the index size is <= limit of items' do 119 | describe 'when the count of records in the database is <= limit of items' do 120 | it 'deletes from the list without refreshing from the database' do 121 | fables, title = [], 'title' 122 | LIMIT.times { fables << Fable.create!(:title => title) } 123 | Fable.get("title/#{title}").size.should <= LIMIT 124 | 125 | mock(Fable.connection).select.never 126 | fables.shift.destroy 127 | Fable.get("title/#{title}").should == fables.collect(&:id) 128 | end 129 | end 130 | 131 | describe 'when the count of records in the database is >= limit of items' do 132 | it 'refreshes the list (from the database)' do 133 | fables, title = [], 'title' 134 | (LIMIT + BUFFER + 1).times { fables << Fable.create!(:title => title) } 135 | BUFFER.times { fables.shift.destroy } 136 | Fable.get("title/#{title}").size.should == LIMIT 137 | 138 | fables.shift.destroy 139 | Fable.get("title/#{title}").should == fables.collect(&:id) 140 | 141 | end 142 | end 143 | end 144 | 145 | describe 'when the index size is > limit of items' do 146 | it 'deletes from the list' do 147 | fables, title = [], 'title' 148 | (LIMIT + 1).times { fables << Fable.create!(:title => title) } 149 | Fable.get("title/#{title}").size.should > LIMIT 150 | 151 | fables.shift.destroy 152 | Fable.get("title/#{title}").should == fables.collect(&:id) 153 | end 154 | end 155 | end 156 | 157 | describe 'when the cache is not populated' do 158 | describe 'when count of records in the database is <= limit of items' do 159 | it 'deletes from the index' do 160 | fables, title = [], 'title' 161 | LIMIT.times { fables << Fable.create!(:title => title) } 162 | $memcache.flush_all 163 | 164 | fables.shift.destroy 165 | Fable.get("title/#{title}").should == fables.collect(&:id) 166 | end 167 | 168 | describe 'when the count of records in the database is between limit and limit + buffer items' do 169 | it 'populates the index' do 170 | fables, title = [], 'title' 171 | (LIMIT + BUFFER + 1).times { fables << Fable.create!(:title => title) } 172 | BUFFER.times { fables.shift.destroy } 173 | $memcache.flush_all 174 | 175 | fables.shift.destroy 176 | Fable.get("title/#{title}").should == fables.collect(&:id) 177 | 178 | end 179 | end 180 | 181 | describe 'when the count of records in the database is > limit + buffer items' do 182 | it 'populates the index with limit + buffer items' do 183 | fables, title = [], 'title' 184 | (LIMIT + BUFFER + 2).times { fables << Fable.create!(:title => title) } 185 | $memcache.flush_all 186 | 187 | fables.shift.destroy 188 | Fable.get("title/#{title}").should == fables[0, LIMIT + BUFFER].collect(&:id) 189 | end 190 | end 191 | end 192 | end 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/cash/query/abstract.rb: -------------------------------------------------------------------------------- 1 | module Cash 2 | module Query 3 | class Abstract 4 | delegate :with_exclusive_scope, :get, :table_name, :indices, :find_from_ids_without_cache, :cache_key, :columns_hash, :logger, :to => :@active_record 5 | 6 | def self.perform(*args) 7 | new(*args).perform 8 | end 9 | 10 | def initialize(active_record, options1, options2) 11 | @active_record, @options1, @options2 = active_record, options1, options2 || {} 12 | 13 | # if @options2.empty? and active_record.base_class != active_record 14 | # @options2 = { :conditions => { active_record.inheritance_column => active_record.to_s }} 15 | # end 16 | # if active_record.base_class != active_record 17 | # @options2[:conditions] = active_record.merge_conditions( 18 | # @options2[:conditions], { active_record.inheritance_column => active_record.to_s } 19 | # ) 20 | # end 21 | end 22 | 23 | def perform(find_options = {}, get_options = {}) 24 | if cache_config = cacheable?(@options1, @options2, find_options) 25 | cache_keys, index = cache_keys(cache_config[0]), cache_config[1] 26 | 27 | misses, missed_keys, objects = hit_or_miss(cache_keys, index, get_options) 28 | format_results(cache_keys, choose_deserialized_objects_if_possible(missed_keys, cache_keys, misses, objects)) 29 | else 30 | logger.debug(" \e[1;4;31mUNCACHEABLE\e[0m #{table_name} - #{find_options.inspect} - #{get_options.inspect} - #{@options1.inspect} - #{@options2.inspect}") if logger 31 | uncacheable 32 | end 33 | end 34 | 35 | DESC = /DESC/i 36 | 37 | def order 38 | @order ||= begin 39 | if order_sql = @options1[:order] || @options2[:order] 40 | matched, table_name, column_name, direction = *(ORDER.match(order_sql.to_s)) 41 | [column_name, direction =~ DESC ? :desc : :asc] 42 | else 43 | ['id', :asc] 44 | end 45 | end 46 | rescue TypeError 47 | ['id', :asc] 48 | end 49 | 50 | def limit 51 | @limit ||= @options1[:limit] || @options2[:limit] 52 | end 53 | 54 | def offset 55 | @offset ||= @options1[:offset] || @options2[:offset] || 0 56 | end 57 | 58 | def calculation? 59 | false 60 | end 61 | 62 | private 63 | def cacheable?(*optionss) 64 | if @active_record.respond_to?(:cacheable?) && ! @active_record.cacheable?(*optionss) 65 | if logger 66 | if @active_record.respond_to?(:cacheable?) 67 | logger.debug(" \e[1;4;31mUNCACHEABLE CLASS\e[0m #{table_name}") 68 | else 69 | logger.debug(" \e[1;4;31mUNCACHEABLE INSTANCE\e[0m #{table_name} - #{optionss.inspect}") 70 | end 71 | end 72 | return false 73 | end 74 | optionss.each do |options| 75 | unless safe_options_for_cache?(options) 76 | logger.debug(" \e[1;4;31mUNCACHEABLE UNSAFE\e[0m #{table_name} - #{options.inspect}") if logger 77 | return false 78 | end 79 | end 80 | partial_indices = optionss.collect { |options| attribute_value_pairs_for_conditions(options[:conditions]) } 81 | return if partial_indices.include?(nil) 82 | attribute_value_pairs = partial_indices.sum.sort { |x, y| x[0] <=> y[0] } 83 | 84 | # attribute_value_pairs.each do |attribute_value_pair| 85 | # return false if attribute_value_pair.last.is_a?(Array) 86 | # end 87 | 88 | if index = indexed_on?(attribute_value_pairs.collect { |pair| pair[0] }) 89 | if index.matches?(self) 90 | [attribute_value_pairs, index] 91 | else 92 | logger.debug(" \e[1;4;31mUNCACHEABLE NO MATCHING INDEX\e[0m #{table_name} - #{index.order_column.inspect} #{index.order.inspect} #{index.limit.inspect}") if logger 93 | false 94 | end 95 | else 96 | logger.debug(" \e[1;4;31mUNCACHEABLE NOT INDEXED\e[0m #{table_name} - #{attribute_value_pairs.collect { |pair| pair[0] }.inspect}") if logger 97 | false 98 | end 99 | end 100 | 101 | def hit_or_miss(cache_keys, index, options) 102 | misses, missed_keys = nil, nil 103 | objects = @active_record.get(cache_keys, options.merge(:ttl => index.ttl)) do |missed_keys| 104 | misses = miss(missed_keys, @options1.merge(:limit => index.window)) 105 | serialize_objects(index, misses) 106 | end 107 | [misses, missed_keys, objects] 108 | end 109 | 110 | def cache_keys(attribute_value_pairs) 111 | attribute_value_pairs.flatten.join('/') 112 | end 113 | 114 | def safe_options_for_cache?(options) 115 | return false unless options.kind_of?(Hash) 116 | options.except(:conditions, :readonly, :limit, :offset, :order).values.compact.empty? && !options[:readonly] 117 | end 118 | 119 | def attribute_value_pairs_for_conditions(conditions) 120 | case conditions 121 | when Hash 122 | conditions.to_a.collect { |key, value| [key.to_s, value] } 123 | when String 124 | parse_indices_from_condition(conditions.gsub('1 = 1 AND ', '')) #ignore unnecessary conditions 125 | when Array 126 | parse_indices_from_condition(*conditions) 127 | when NilClass 128 | [] 129 | end 130 | end 131 | 132 | AND = /\s+AND\s+/i 133 | TABLE_AND_COLUMN = /(?:(?:`|")?(\w+)(?:`|")?\.)?(?:`|")?(\w+)(?:`|")?/ # Matches: `users`.id, `users`.`id`, users.id, id 134 | VALUE = /'?(\d+|\?|(?:(?:[^']|'')*))'?/ # Matches: 123, ?, '123', '12''3' 135 | KEY_EQ_VALUE = /^\(?#{TABLE_AND_COLUMN}\s+=\s+#{VALUE}\)?$/ # Matches: KEY = VALUE, (KEY = VALUE) 136 | ORDER = /^#{TABLE_AND_COLUMN}\s*(ASC|DESC)?$/i # Matches: COLUMN ASC, COLUMN DESC, COLUMN 137 | 138 | def parse_indices_from_condition(conditions = '', *values) 139 | values = values.dup 140 | conditions.split(AND).inject([]) do |indices, condition| 141 | matched, table_name, column_name, sql_value = *(KEY_EQ_VALUE.match(condition)) 142 | if matched 143 | # value = sql_value == '?' ? values.shift : columns_hash[column_name].type_cast(sql_value) 144 | if sql_value == '?' 145 | value = values.shift 146 | else 147 | column = columns_hash[column_name] 148 | raise "could not find column #{column_name} in columns #{columns_hash.keys.join(',')}" if column.nil? 149 | if sql_value[0..0] == ':' && values && values.count > 0 && values[0].is_a?(Hash) 150 | symb = sql_value[1..-1].to_sym 151 | value = column.type_cast(values[0][symb]) 152 | else 153 | value = column.type_cast(sql_value) 154 | end 155 | end 156 | indices << [column_name, value] 157 | else 158 | return nil 159 | end 160 | end 161 | end 162 | 163 | def indexed_on?(attributes) 164 | indices.detect { |index| index == attributes } 165 | rescue NoMethodError 166 | nil 167 | end 168 | alias_method :index_for, :indexed_on? 169 | 170 | def format_results(cache_keys, objects) 171 | return objects if objects.blank? 172 | 173 | objects = convert_to_array(cache_keys, objects) 174 | objects = apply_limits_and_offsets(objects, @options1) 175 | deserialize_objects(objects) 176 | end 177 | 178 | def choose_deserialized_objects_if_possible(missed_keys, cache_keys, misses, objects) 179 | missed_keys == cache_keys ? misses : objects 180 | end 181 | 182 | def serialize_objects(index, objects) 183 | Array(objects).collect { |missed| index.serialize_object(missed) } 184 | end 185 | 186 | def convert_to_array(cache_keys, object) 187 | if object.kind_of?(Hash) 188 | cache_keys.collect { |key| object[cache_key(key)] }.flatten.compact 189 | else 190 | Array(object) 191 | end 192 | end 193 | 194 | def apply_limits_and_offsets(results, options) 195 | results.slice((options[:offset] || 0), (options[:limit] || results.length)) 196 | end 197 | 198 | def deserialize_objects(objects) 199 | if objects.first.kind_of?(ActiveRecord::Base) 200 | objects 201 | else 202 | cache_keys = objects.collect { |id| "id/#{id}" } 203 | with_exclusive_scope(:find => {}) { objects = get(cache_keys, &method(:find_from_keys)) } 204 | convert_to_array(cache_keys, objects) 205 | end 206 | end 207 | 208 | def find_from_keys(*missing_keys) 209 | missing_ids = Array(missing_keys).flatten.collect { |key| key.split('/')[2].to_i } 210 | options = {} 211 | order_sql = @options1[:order] || @options2[:order] 212 | options[:order] = order_sql if order_sql 213 | results = find_from_ids_without_cache(missing_ids, options) 214 | if results 215 | if results.is_a?(Array) 216 | results.each {|o| @active_record.add_to_caches(o) } 217 | else 218 | @active_record.add_to_caches(results) 219 | end 220 | end 221 | results 222 | end 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | ## What is Cache Money ## 2 | 3 | Cache Money is a write-through and read-through caching library for ActiveRecord. 4 | 5 | Read-Through: Queries like `User.find(:all, :conditions => ...)` will first look in Memcached and then look in the database for the results of that query. If there is a cache miss, it will populate the cache. 6 | 7 | Write-Through: As objects are created, updated, and deleted, all of the caches are *automatically* kept up-to-date and coherent. 8 | 9 | ## Howto ## 10 | ### What kinds of queries are supported? ### 11 | 12 | Many styles of ActiveRecord usage are supported: 13 | 14 | * `User.find` 15 | * `User.find_by_id` 16 | * `User.find(:conditions => {:id => ...})` 17 | * `User.find(:conditions => ['id = ?', ...])` 18 | * `User.find(:conditions => 'id = ...')` 19 | * `User.find(:conditions => 'users.id = ...')` 20 | 21 | As you can see, the `find_by_`, `find_all_by`, hash, array, and string forms are all supported. 22 | 23 | Queries with joins/includes are unsupported at this time. In general, any query involving just equality (=) and conjunction (AND) is supported by `Cache Money`. Disjunction (OR) and inequality (!=, <=, etc.) are not typically materialized in a hash table style index and are unsupported at this time. 24 | 25 | Queries with limits and offsets are supported. In general, however, if you are running queries with limits and offsets you are dealing with large datasets. It's more performant to place a limit on the size of the `Cache Money` index like so: 26 | 27 | DirectMessage.index :user_id, :limit => 1000 28 | 29 | In this example, only queries whose limit and offset are less than 1000 will use the cache. 30 | 31 | ### Multiple indices are supported ### 32 | 33 | class User < ActiveRecord::Base 34 | index :screen_name 35 | index :email 36 | end 37 | 38 | #### `with_scope` support #### 39 | 40 | `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 cache if there is an index like this: 41 | 42 | class Device < ActiveRecord::Base 43 | index [:user_id, :id] 44 | end 45 | 46 | ### Ordered indices ### 47 | 48 | class Message < ActiveRecord::Base 49 | index :sender_id, :order => :desc 50 | end 51 | 52 | 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: 53 | 54 | * `Message.find(:all, :conditions => {:sender_id => ...}, :order => 'id DESC')`. 55 | 56 | 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. 57 | 58 | class Message < ActiveRecord::Base 59 | index :sender_id, :order => :asc 60 | end 61 | 62 | will support queries like: 63 | 64 | * `Message.find(:all, :conditions => {:sender_id => ...}, :order => 'id ASC')` 65 | * `Message.find(:all, :conditions => {:sender_id => ...})` 66 | 67 | Note that ascending order is implicit in index declarations (i.e., not specifying an order is the same as ascending). This is also true of queries (order is not nondeterministic as in MySQL). 68 | 69 | ### Window indices ### 70 | 71 | class Message < ActiveRecord::Base 72 | index :sender_id, :limit => 500, :buffer => 100 73 | end 74 | 75 | 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. 76 | 77 | It is particularly in conjunction with window indices that the `:order` attribute is useful. 78 | 79 | ### Calculations ### 80 | 81 | `Message.count(:all, :conditions => {:sender_id => ...})` will use the cache rather than the database. This happens for "free" -- no additional declarations are necessary. 82 | 83 | ### Version Numbers ### 84 | 85 | class User < ActiveRecord::Base 86 | version 7 87 | index ... 88 | end 89 | 90 | You can increment the version number as you migrate your schema. Be careful how you deploy changes like this as during deployment independent mongrels may be using different versions of your code. Indices can be corrupted if you do not plan accordingly. 91 | 92 | ### Transactions ### 93 | 94 | 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. 95 | 96 | The memcache client library has been enhanced to simulate transactions. 97 | 98 | $cache.transaction do 99 | $cache.set(key1, value1) 100 | $cache.set(key2, value2) 101 | end 102 | 103 | 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. 104 | 105 | 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". 106 | 107 | #### Rollbacks #### 108 | 109 | $cache.transaction do 110 | $cache.set(k, v) 111 | raise 112 | end 113 | 114 | 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. 115 | 116 | Nested transactions are fully supported, with partial rollback and (apparent) partial commitment (this is simulated with nested buffers). 117 | 118 | ### Mocks ### 119 | 120 | For your unit tests, it is faster to use a Memcached mock than the real deal. In your test environment, initialize the repository with an instance of Cash::Mock. 121 | 122 | ### Locks ### 123 | 124 | In most cases locks are unnecessary; the transactional Memcached client will take care locks for you automatically and guarantees that no deadlocks can occur. But for very complex distributed transactions, shared locks are necessary. 125 | 126 | $lock.synchronize('lock_name') do 127 | $memcache.set("key", "value") 128 | end 129 | 130 | ### Local Cache ### 131 | 132 | Sometimes your code will request the same cache key twice in one request. You can avoid a round trip to the Memcached server by using a local, per-request cache. Add this to your initializer: 133 | 134 | $memcache = MemcachedWrapper.new(config[:servers].gsub(' ', '').split(','), config) 135 | $local = Cash::Local.new($memcache) 136 | $lock = Cash::Lock.new($memcache) 137 | $cache = Cash::Transactional.new($local, $lock) 138 | 139 | ## Installation ## 140 | 141 | #### Step 1: Get the GEM #### 142 | 143 | % sudo gem install ngmoco-cache-money 144 | 145 | Add the gem you your Gemfile: 146 | gem 'ngmoco-cache-money', :lib => 'cache_money' 147 | 148 | #### Step 2: Configure cache client 149 | 150 | In your environment, create a cache client instance configured for your cache servers. 151 | 152 | $memcached = Memcached.new( ...servers..., ...options...) 153 | 154 | Currently supported cache clients are: memcached, memcache-client 155 | 156 | #### Step 3: Configure Caching 157 | 158 | Add the following to an initializer: 159 | 160 | Cash.configure :repository => $memcached, :adapter => :memcached 161 | 162 | Supported adapters are :memcache_client, :memcached. :memcached is assumed and is only compatible with Memcached clients. 163 | Local or transactional semantics may be disabled by setting :local => false or :transactional => false. 164 | 165 | Caching can be disabled on a per-environment basis in the environment's initializer: 166 | 167 | Cash.enabled = false 168 | 169 | #### Step 4: Add indices to your ActiveRecord models #### 170 | 171 | Queries like `User.find(1)` will use the cache automatically. For more complex queries you must add indices on the attributes that you will query on. For example, a query like `User.find(:all, :conditions => {:name => 'bob'})` will require an index like: 172 | 173 | class User < ActiveRecord::Base 174 | index :name 175 | end 176 | 177 | For queries on multiple attributes, combination indexes are necessary. For example, `User.find(:all, :conditions => {:name => 'bob', :age => 26})` 178 | 179 | class User < ActiveRecord::Base 180 | index [:name, :age] 181 | end 182 | 183 | #### Optional: Selectively cache specific models 184 | 185 | There may be times where you only want to cache some of your models instead of everything. 186 | 187 | In that case, you can omit the following from your `config/initializers/cache_money.rb` 188 | 189 | class ActiveRecord::Base 190 | is_cached 191 | end 192 | 193 | After that is removed, you can simple put this at the top of your models you wish to cache: 194 | 195 | is_cached 196 | 197 | Just make sure that you put that line before any of your index directives. Note that all subclasses of a cached model are also cached. 198 | 199 | ## Acknowledgments ## 200 | 201 | Thanks to 202 | 203 | * Twitter for commissioning the development of this library and supporting the effort to open-source it. 204 | * Sam Luckenbill for pairing with Nick on most of the hard stuff. 205 | * Matthew and Chris for pairing a few days, offering useful feedback on the readability of the code, and the initial implementation of the Memcached mock. 206 | * Evan Weaver for helping to reason-through software and testing strategies to deal with replication lag, and the initial implementation of the Memcached lock. 207 | -------------------------------------------------------------------------------- /spec/cash/write_through_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe WriteThrough do 5 | describe 'ClassMethods' do 6 | describe 'after create' do 7 | it "inserts all indexed attributes into the cache" do 8 | story = Story.create!(:title => "I am delicious") 9 | Story.get("title/#{story.title}").should == [story.id] 10 | Story.get("id/#{story.id}").should == [story] 11 | end 12 | 13 | describe 'multiple objects' do 14 | it "inserts multiple objects into the same cache key" do 15 | story1 = Story.create!(:title => "I am delicious") 16 | story2 = Story.create!(:title => "I am delicious") 17 | Story.get("title/#{story1.title}").should == [story1.id, story2.id] 18 | end 19 | 20 | describe 'when the cache has been cleared after some objects were created' do 21 | before do 22 | @story1 = Story.create!(:title => "I am delicious") 23 | $memcache.flush_all 24 | @story2 = Story.create!(:title => "I am delicious") 25 | end 26 | 27 | it 'inserts legacy objects into the cache' do 28 | Story.get("title/#{@story1.title}").should == [@story1.id, @story2.id] 29 | end 30 | 31 | it 'initializes the count to account for the legacy objects' do 32 | Story.get("title/#{@story1.title}/count", :raw => true).should =~ /2/ 33 | end 34 | end 35 | end 36 | 37 | it "does not write through the cache on non-indexed attributes" do 38 | story = Story.create!(:title => "Story 1", :subtitle => "Subtitle") 39 | Story.get("subtitle/#{story.subtitle}").should == nil 40 | end 41 | 42 | it "indexes on combinations of attributes" do 43 | story = Story.create!(:title => "Sam") 44 | Story.get("id/#{story.id}/title/#{story.title}").should == [story.id] 45 | end 46 | 47 | it "does not cache associations" do 48 | story = Story.new(:title => 'I am lugubrious') 49 | story.characters.build(:name => 'How am I holy?') 50 | story.save! 51 | Story.get("id/#{story.id}").first.characters.loaded?.should_not be 52 | end 53 | 54 | it 'increments the count' do 55 | story = Story.create!(:title => "Sam") 56 | Story.get("title/#{story.title}/count", :raw => true).should =~ /1/ 57 | story = Story.create!(:title => "Sam") 58 | Story.get("title/#{story.title}/count", :raw => true).should =~ /2/ 59 | end 60 | 61 | describe 'when the value is nil' do 62 | it "does not write through the cache on indexed attributes" do 63 | story = Story.create!(:title => nil) 64 | Story.get("title/").should == nil 65 | end 66 | end 67 | 68 | it 'should not remember instance variables' do 69 | story = Story.new(:title => 'story') 70 | story.instance_eval { @forgetme = "value" } 71 | story.save! 72 | Story.find(story.id).instance_variables.should_not include("@forgetme") 73 | end 74 | end 75 | 76 | describe 'after update' do 77 | it "overwrites the primary cache" do 78 | story = Story.create!(:title => "I am delicious") 79 | Story.get(cache_key = "id/#{story.id}").first.title.should == "I am delicious" 80 | story.update_attributes(:title => "I am fabulous") 81 | Story.get(cache_key).first.title.should == "I am fabulous" 82 | end 83 | 84 | it "populates empty caches" do 85 | story = Story.create!(:title => "I am delicious") 86 | $memcache.flush_all 87 | story.update_attributes(:title => "I am fabulous") 88 | Story.get("title/#{story.title}").should == [story.id] 89 | end 90 | 91 | it "removes from the affected index caches on update" do 92 | story = Story.create!(:title => "I am delicious") 93 | Story.get(cache_key = "title/#{story.title}").should == [story.id] 94 | story.update_attributes(:title => "I am fabulous") 95 | Story.get(cache_key).should == [] 96 | end 97 | 98 | it 'increments/decrements the counts of affected indices' do 99 | story = Story.create!(:title => original_title = "I am delicious") 100 | story.update_attributes(:title => new_title = "I am fabulous") 101 | Story.get("title/#{original_title}/count", :raw => true).should =~ /0/ 102 | Story.get("title/#{new_title}/count", :raw => true).should =~ /1/ 103 | end 104 | end 105 | 106 | describe 'after destroy' do 107 | it "removes from the primary cache" do 108 | story = Story.create!(:title => "I am delicious") 109 | Story.get(cache_key = "id/#{story.id}").should == [story] 110 | story.destroy 111 | Story.get(cache_key).should == [] 112 | end 113 | 114 | it "removes from the the cache on keys matching the original values of attributes" do 115 | story = Story.create!(:title => "I am delicious") 116 | Story.get(cache_key = "title/#{story.title}").should == [story.id] 117 | story.title = "I am not delicious" 118 | story.destroy 119 | Story.get(cache_key).should == [] 120 | end 121 | 122 | it 'decrements the count' do 123 | story = Story.create!(:title => "I am delicious") 124 | story.destroy 125 | Story.get("title/#{story.title}/count", :raw => true).should =~ /0/ 126 | end 127 | 128 | describe 'when there are multiple items in the index' do 129 | it "only removes one item from the affected indices, not all of them" do 130 | story1 = Story.create!(:title => "I am delicious") 131 | story2 = Story.create!(:title => "I am delicious") 132 | Story.get(cache_key = "title/#{story1.title}").should == [story1.id, story2.id] 133 | story1.destroy 134 | Story.get(cache_key).should == [story2.id] 135 | end 136 | end 137 | 138 | describe 'when the object is a new record' do 139 | it 'does nothing' do 140 | story1 = Story.new 141 | mock(Story).set.never 142 | story1.destroy 143 | end 144 | end 145 | 146 | describe 'when the cache is not yet populated' do 147 | it "populates the cache with data" do 148 | story1 = Story.create!(:title => "I am delicious") 149 | story2 = Story.create!(:title => "I am delicious") 150 | $memcache.flush_all 151 | Story.get(cache_key = "title/#{story1.title}").should == nil 152 | story1.destroy 153 | Story.get(cache_key).should == [story2.id] 154 | end 155 | end 156 | 157 | describe 'when the value is nil' do 158 | it "does not delete through the cache on indexed attributes when the value is nil" do 159 | story = Story.create!(:title => nil) 160 | story.destroy 161 | Story.get("title/").should == nil 162 | end 163 | end 164 | end 165 | 166 | describe 'InstanceMethods' do 167 | describe '#expire_caches' do 168 | it 'deletes the index' do 169 | story = Story.create!(:title => "I am delicious") 170 | Story.get(cache_key = "id/#{story.id}").should == [story] 171 | story.expire_caches 172 | Story.get(cache_key).should be_nil 173 | end 174 | end 175 | end 176 | end 177 | 178 | describe "Locking" do 179 | it "acquires and releases locks, in order, for all indices to be written" do 180 | pending 181 | 182 | story = Story.create!(:title => original_title = "original title") 183 | story.title = tentative_title = "tentative title" 184 | keys = ["id/#{story.id}", "title/#{original_title}", "title/#{story.title}", "id/#{story.id}/title/#{original_title}", "id/#{story.id}/title/#{tentative_title}"] 185 | 186 | locks_should_be_acquired_and_released_in_order($lock, keys) 187 | story.save! 188 | end 189 | 190 | it "acquires and releases locks on destroy" do 191 | pending 192 | 193 | story = Story.create!(:title => "title") 194 | keys = ["id/#{story.id}", "title/#{story.title}", "id/#{story.id}/title/#{story.title}"] 195 | 196 | locks_should_be_acquired_and_released_in_order($lock, keys) 197 | story.destroy 198 | end 199 | 200 | def locks_should_be_acquired_and_released_in_order(lock, keys) 201 | mock = keys.sort!.inject(mock = mock($lock)) do |mock, key| 202 | mock.acquire_lock.with(Story.cache_key(key)).then 203 | end 204 | keys.inject(mock) do |mock, key| 205 | mock.release_lock.with(Story.cache_key(key)).then 206 | end 207 | end 208 | end 209 | 210 | describe "Single Table Inheritence" do 211 | describe 'A subclass' do 212 | it "writes to indices of all superclasses" do 213 | oral = Oral.create!(:title => 'title') 214 | Story.get("title/#{oral.title}").should == [oral.id] 215 | Epic.get("title/#{oral.title}").should == [oral.id] 216 | Oral.get("title/#{oral.title}").should == [oral.id] 217 | end 218 | 219 | describe 'when one ancestor has its own indices' do 220 | it "it only populates those indices for that ancestor" do 221 | oral = Oral.create!(:subtitle => 'subtitle') 222 | Story.get("subtitle/#{oral.subtitle}").should be_nil 223 | Epic.get("subtitle/#{oral.subtitle}").should be_nil 224 | Oral.get("subtitle/#{oral.subtitle}").should == [oral.id] 225 | end 226 | end 227 | end 228 | end 229 | 230 | describe 'Transactions' do 231 | def create_story_and_update 232 | @story = Story.create!(:title => original_title = "original title") 233 | 234 | Story.transaction do 235 | @story.title = "new title" 236 | @story.save 237 | yield if block_given? 238 | end 239 | end 240 | 241 | it 'should commit on success' do 242 | create_story_and_update 243 | @story.reload.title.should == "new title" 244 | end 245 | 246 | it 'should roll back transactions when ActiveRecord::Rollback is raised' do 247 | create_story_and_update { raise ActiveRecord::Rollback } 248 | @story.reload.title.should == "original title" 249 | end 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2009, Twitter Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /spec/cash/finders_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Finders do 5 | describe 'Cache Usage' do 6 | describe 'when the cache is populated' do 7 | describe '#find' do 8 | describe '#find(1)' do 9 | it 'does not use the database' do 10 | story = Story.create! 11 | mock(Story.connection).execute.never 12 | Story.find(story.id).should == story 13 | end 14 | end 15 | 16 | describe '#find(object)' do 17 | it 'uses the objects quoted id' do 18 | story = Story.create! 19 | mock(Story.connection).execute.never 20 | Story.find(story).should == story 21 | end 22 | end 23 | 24 | describe '#find(:first, ...)' do 25 | describe '#find(:first, :conditions => { :id => ? })' do 26 | it "does not use the database" do 27 | story = Story.create! 28 | mock(Story.connection).execute.never 29 | Story.find(:first, :conditions => { :id => story.id }).should == story 30 | end 31 | end 32 | 33 | describe '#find(:first, :conditions => [ "id = :id", { :id => story.id } ])' do 34 | it "does not use the database" do 35 | story = Story.create! 36 | mock(Story.connection).execute.never 37 | Story.find(:first, :conditions => [ "id = :id", { :id => story.id } ]).should == story 38 | end 39 | end 40 | 41 | describe "#find(:first, :conditions => 'id = ?')" do 42 | it "does not use the database" do 43 | story = Story.create! 44 | mock(Story.connection).execute.never 45 | Story.find(:first, :conditions => "id = #{story.id}").should == story 46 | Story.find(:first, :conditions => "`stories`.id = #{story.id}").should == story 47 | Story.find(:first, :conditions => "`stories`.`id` = #{story.id}").should == story 48 | end 49 | end 50 | 51 | describe '#find(:first, :readonly => false) and any other options other than conditions are nil' do 52 | it "does not use the database" do 53 | story = Story.create! 54 | mock(Story.connection).execute.never 55 | Story.find(:first, :conditions => { :id => story.id }, :readonly => false, :limit => nil, :offset => nil, :joins => nil, :include => nil).should == story 56 | end 57 | end 58 | 59 | describe '#find(:first, :readonly => true)' do 60 | it "uses the database, not the cache" do 61 | story = Story.create! 62 | mock(Story).get.never 63 | Story.find(:first, :conditions => { :id => story.id }, :readonly => true).should == story 64 | end 65 | end 66 | 67 | describe '#find(:first, :join => ...) or find(..., :include => ...)' do 68 | it "uses the database, not the cache" do 69 | story = Story.create! 70 | mock(Story).get.never 71 | Story.find(:first, :conditions => { :id => story.id }, :joins => 'AS stories').should == story 72 | Story.find(:first, :conditions => { :id => story.id }, :include => :characters).should == story 73 | end 74 | end 75 | 76 | describe '#find(:first)' do 77 | it 'uses the database, not the cache' do 78 | mock(Story).get.never 79 | Story.find(:first) 80 | end 81 | end 82 | 83 | describe '#find(:first, :conditions => "...")' do 84 | describe 'on unindexed attributes' do 85 | it 'uses the database, not the cache' do 86 | story = Story.create! 87 | mock(Story).get.never 88 | Story.find(:first, :conditions => "type IS NULL") 89 | end 90 | end 91 | 92 | describe 'on indexed attributes' do 93 | describe 'when the attributes are integers' do 94 | it 'does not use the database' do 95 | story = Story.create! 96 | mock(Story.connection).execute.never 97 | Story.find(:first, :conditions => "`stories`.id = #{story.id}") \ 98 | .should == story 99 | end 100 | end 101 | 102 | describe 'when the attributes are non-integers' do 103 | it 'uses the database, not the cache' do 104 | story = Story.create!(:title => "title") 105 | mock(Story.connection).execute.never 106 | Story.find(:first, :conditions => "`stories`.title = '#{story.title }'") \ 107 | .should == story 108 | end 109 | end 110 | 111 | describe 'when the attributes must be coerced to sql values' do 112 | it 'does not use the database' do 113 | story1 = Story.create!(:published => true) 114 | story2 = Story.create!(:published => false) 115 | mock(Story.connection).execute.never 116 | Story.find(:first, :conditions => 'published = 0').should == story2 117 | end 118 | end 119 | end 120 | 121 | describe '#find(:first, :conditions => [...])' do 122 | describe 'with one indexed attribute' do 123 | it 'does not use the database' do 124 | story = Story.create! 125 | mock(Story.connection).execute.never 126 | Story.find(:first, :conditions => ['id = ?', story.id]).should == story 127 | end 128 | end 129 | 130 | describe 'with two attributes that match a combo-index' do 131 | it 'does not use the database' do 132 | story = Story.create!(:title => 'title') 133 | mock(Story.connection).execute.never 134 | Story.find(:first, :conditions => ['id = ? AND title = ?', story.id, story.title]).should == story 135 | end 136 | end 137 | end 138 | end 139 | 140 | describe '#find(:first, :conditions => {...})' do 141 | it "does not use the database" do 142 | story = Story.create!(:title => "Sam") 143 | mock(Story.connection).execute.never 144 | Story.find(:first, :conditions => { :id => story.id, :title => story.title }).should == story 145 | end 146 | 147 | describe 'regardless of hash order' do 148 | it 'does not use the database' do 149 | story = Story.create!(:title => "Sam") 150 | mock(Story.connection).execute.never 151 | Story.find(:first, :conditions => { :id => story.id, :title => story.title }).should == story 152 | Story.find(:first, :conditions => { :title => story.title, :id => story.id }).should == story 153 | end 154 | end 155 | 156 | describe 'on unindexed attribtes' do 157 | it 'uses the database, not the cache' do 158 | story = Story.create! 159 | mock(Story).get.never 160 | Story.find(:first, :conditions => { :id => story.id, :type => story.type }).should == story 161 | end 162 | end 163 | end 164 | end 165 | 166 | describe 'when there is a with_scope' do 167 | describe 'when the with_scope has conditions' do 168 | describe 'when the scope conditions is a string' do 169 | it "uses the database, not the cache" do 170 | story = Story.create!(:title => title = 'title') 171 | mock(Story.connection).execute.never 172 | Story.send :with_scope, :find => { :conditions => "title = '#{title}'"} do 173 | Story.find(:first, :conditions => { :id => story.id }).should == story 174 | end 175 | end 176 | end 177 | 178 | describe 'when the find conditions is a string' do 179 | it "does not use the database" do 180 | story = Story.create!(:title => title = 'title') 181 | mock(Story.connection).execute.never 182 | Story.send :with_scope, :find => { :conditions => { :id => story.id }} do 183 | Story.find(:first, :conditions => "title = '#{title}'").should == story 184 | end 185 | end 186 | end 187 | 188 | describe '#find(1, :conditions => ...)' do 189 | it "does not use the database" do 190 | story = Story.create! 191 | character = Character.create!(:name => name = 'barbara', :story_id => story) 192 | mock(Character.connection).execute.never 193 | Character.send :with_scope, :find => { :conditions => { :story_id => story.id } } do 194 | Character.find(character.id, :conditions => { :name => name }).should == character 195 | end 196 | end 197 | end 198 | end 199 | 200 | describe 'has_many associations' do 201 | describe '#find(1)' do 202 | it "does not use the database" do 203 | story = Story.create! 204 | character = story.characters.create! 205 | mock(Character.connection).execute.never 206 | story.characters.find(character.id).should == character 207 | end 208 | end 209 | 210 | describe '#find(1, 2, ...)' do 211 | it "does not use the database" do 212 | story = Story.create! 213 | character1 = story.characters.create! 214 | character2 = story.characters.create! 215 | mock(Character.connection).execute.never 216 | story.characters.find(character1.id, character2.id).should == [character1, character2] 217 | end 218 | end 219 | 220 | describe '#find_by_attr' do 221 | it "does not use the database" do 222 | story = Story.create! 223 | character = story.characters.create! 224 | mock(Character.connection).execute.never 225 | story.characters.find_by_id(character.id).should == character 226 | end 227 | end 228 | end 229 | end 230 | 231 | describe '#find(:all)' do 232 | it "uses the database, not the cache" do 233 | character = Character.create! 234 | mock(Character).get.never 235 | Character.find(:all).should == [character] 236 | end 237 | 238 | describe '#find(:all, :conditions => {...})' do 239 | describe 'when the index is not empty' do 240 | it 'does not use the database' do 241 | story1 = Story.create!(:title => title = "title") 242 | story2 = Story.create!(:title => title) 243 | mock(Story.connection).execute.never 244 | Story.find(:all, :conditions => { :title => story1.title }).should == [story1, story2] 245 | end 246 | end 247 | end 248 | 249 | describe '#find(:all, :limit => ..., :offset => ...)' do 250 | it "cached attributes should support limits and offsets" do 251 | character1 = Character.create!(:name => "Sam", :story_id => 1) 252 | character2 = Character.create!(:name => "Sam", :story_id => 1) 253 | character3 = Character.create!(:name => "Sam", :story_id => 1) 254 | mock(Character.connection).execute.never 255 | 256 | Character.find(:all, :conditions => { :name => character1.name, :story_id => character1.story_id }, :limit => 1).should == [character1] 257 | Character.find(:all, :conditions => { :name => character1.name, :story_id => character1.story_id }, :offset => 1).should == [character2, character3] 258 | Character.find(:all, :conditions => { :name => character1.name, :story_id => character1.story_id }, :limit => 1, :offset => 1).should == [character2] 259 | end 260 | end 261 | end 262 | 263 | describe '#find([...])' do 264 | describe '#find([1, 2, ...], :conditions => ...)' do 265 | it "uses the database, not the cache" do 266 | story1, story2 = Story.create!, Story.create! 267 | mock(Story).get.never 268 | Story.find([story1.id, story2.id], :conditions => "type IS NULL").should == [story1, story2] 269 | end 270 | end 271 | 272 | describe '#find([1], :conditions => ...)' do 273 | it "uses the database, not the cache" do 274 | story1, story2 = Story.create!, Story.create! 275 | mock(Story).get.never 276 | Story.find([story1.id], :conditions => "type IS NULL").should == [story1] 277 | end 278 | end 279 | end 280 | 281 | describe '#find_by_attr' do 282 | describe 'on indexed attributes' do 283 | describe '#find_by_id(id)' do 284 | it "does not use the database" do 285 | story = Story.create! 286 | mock(Story.connection).execute.never 287 | Story.find_by_id(story.id).should == story 288 | end 289 | end 290 | 291 | describe '#find_by_title(title)' do 292 | it "does not use the database" do 293 | story1 = Story.create!(:title => 'title1') 294 | story2 = Story.create!(:title => 'title2') 295 | mock(Story.connection).execute.never 296 | Story.find_by_title('title1').should == story1 297 | end 298 | end 299 | end 300 | end 301 | 302 | describe "Single Table Inheritence" do 303 | describe '#find(:all, ...)' do 304 | it "does not use the database" do 305 | story, epic, oral = Story.create!(:title => title = 'foo'), Epic.create!(:title => title), Oral.create!(:title => title) 306 | mock(Story.connection).execute.never 307 | Story.find(:all, :conditions => { :title => title }).should == [story, epic, oral] 308 | Epic.find(:all, :conditions => { :title => title }).should == [epic, oral] 309 | Oral.find(:all, :conditions => { :title => title }).should == [oral] 310 | end 311 | end 312 | end 313 | end 314 | 315 | describe '#without_cache' do 316 | describe 'when finders are called within the provided block' do 317 | it 'uses the database not the cache' do 318 | story = Story.create! 319 | mock(Story).get.never 320 | Story.without_cache do 321 | Story.find(story.id).should == story 322 | end 323 | end 324 | end 325 | end 326 | end 327 | 328 | describe 'when the cache is not populated' do 329 | before do 330 | @story = Story.create!(:title => 'title') 331 | $memcache.flush_all 332 | end 333 | 334 | describe '#find(:first, ...)' do 335 | it 'populates the cache' do 336 | Story.find(:first, :conditions => { :title => @story.title }) 337 | Story.fetch("title/#{@story.title}").should == [@story.id] 338 | end 339 | end 340 | 341 | describe '#find_by_attr' do 342 | before(:each) do 343 | Story.find_by_title(@story.title) # populates cache for title with [@story.id] 344 | end 345 | 346 | it 'populates the cache' do 347 | Story.fetch("title/#{@story.title}").should == [@story.id] 348 | end 349 | 350 | it 'populates the cache when finding by non-primary-key attribute' do 351 | Story.find_by_title(@story.title) # populates cache for id with record 352 | 353 | mock(Story.connection).execute.never # should hit cache only 354 | Story.find_by_title(@story.title).id.should == @story.id 355 | end 356 | end 357 | 358 | describe '#find(:all, :conditions => ...)' do 359 | it 'populates the cache' do 360 | Story.find(:all, :conditions => { :title => @story.title }) 361 | Story.fetch("title/#{@story.title}").should == [@story.id] 362 | end 363 | end 364 | 365 | describe '#find(:all, :conditions => ..., :order => ...)' do 366 | before(:each) do 367 | @short1 = Short.create(:title => 'title', 368 | :subtitle => 'subtitle') 369 | @short2 = Short.create(:title => 'another title', 370 | :subtitle => 'subtitle') 371 | $memcache.flush_all 372 | # debugger 373 | Short.find(:all, :conditions => { :subtitle => @short1.subtitle }, 374 | :order => 'title') 375 | end 376 | 377 | it 'populates the cache' do 378 | Short.fetch("subtitle/subtitle").should_not be_blank 379 | end 380 | 381 | it 'returns objects in the correct order' do 382 | Short.fetch("subtitle/subtitle").should == 383 | [@short2.id, @short1.id] 384 | end 385 | 386 | it 'finds objects in the correct order' do 387 | Short.find(:all, :conditions => { :subtitle => @short1.subtitle }, 388 | :order => 'title').map(&:id).should == [@short2.id, @short1.id] 389 | end 390 | 391 | # it 'populates cache for each object' do 392 | # Short.fetch("id/#{@short1.id}").should == [@short1] 393 | # Short.fetch("id/#{@short2.id}").should == [@short2] 394 | # end 395 | end 396 | 397 | describe '#find(1)' do 398 | it 'populates the cache' do 399 | Story.find(@story.id) 400 | Story.fetch("id/#{@story.id}").should == [@story] 401 | end 402 | end 403 | 404 | describe '#find([1, 2, ...])' do 405 | before do 406 | @short1 = Short.create(:title => 'title1', :subtitle => 'subtitle') 407 | @short2 = Short.create(:title => 'title2', :subtitle => 'subtitle') 408 | @short3 = Short.create(:title => 'title3', :subtitle => 'subtitle') 409 | $memcache.flush_all 410 | end 411 | 412 | it 'populates the cache' do 413 | Short.find(@short1.id, @short2.id, @short3.id) == [@short1, @short2, @short3] 414 | Short.fetch("id/#{@short1.id}").should == [@short1] 415 | Short.fetch("id/#{@short2.id}").should == [@short2] 416 | Short.fetch("id/#{@short3.id}").should == [@short3] 417 | end 418 | end 419 | 420 | describe 'when there is a with_scope' do 421 | it "uses the database, not the cache" do 422 | Story.send :with_scope, :find => { :conditions => { :title => @story.title }} do 423 | Story.find(:first, :conditions => { :id => @story.id }).should == @story 424 | end 425 | end 426 | end 427 | end 428 | end 429 | end 430 | end 431 | -------------------------------------------------------------------------------- /spec/cash/transactional_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Cash 4 | describe Transactional do 5 | let(:lock) { Cash::Lock.new($memcache) } 6 | 7 | before do 8 | @cache = Transactional.new($memcache, lock) 9 | @value = "stuff to be cached" 10 | @key = "key" 11 | end 12 | 13 | describe 'Basic Operations' do 14 | it "gets through the real cache" do 15 | $memcache.set(@key, @value) 16 | @cache.get(@key).should == @value 17 | end 18 | 19 | it "sets through the real cache" do 20 | mock($memcache).set(@key, @value, :option1, :option2) 21 | @cache.set(@key, @value, :option1, :option2) 22 | end 23 | 24 | it "increments through the real cache" do 25 | @cache.set(@key, 0, 0, true) 26 | @cache.incr(@key, 3) 27 | 28 | @cache.get(@key, true).to_i.should == 3 29 | $memcache.get(@key, true).to_i.should == 3 30 | end 31 | 32 | it "decrements through the real cache" do 33 | @cache.set(@key, 0, 0, true) 34 | @cache.incr(@key, 3) 35 | @cache.decr(@key, 2) 36 | 37 | @cache.get(@key, true).to_i.should == 1 38 | $memcache.get(@key, true).to_i.should == 1 39 | end 40 | 41 | it "adds through the real cache" do 42 | @cache.add(@key, @value) 43 | $memcache.get(@key).should == @value 44 | @cache.get(@key).should == @value 45 | 46 | @cache.add(@key, "another value") 47 | $memcache.get(@key).should == @value 48 | @cache.get(@key).should == @value 49 | end 50 | 51 | it "deletes through the real cache" do 52 | $memcache.add(@key, @value) 53 | $memcache.get(@key).should == @value 54 | 55 | @cache.delete(@key) 56 | $memcache.get(@key).should be_nil 57 | end 58 | 59 | it "returns true for respond_to? with what it responds to" do 60 | @cache.respond_to?(:get).should be_true 61 | @cache.respond_to?(:set).should be_true 62 | @cache.respond_to?(:get_multi).should be_true 63 | @cache.respond_to?(:incr).should be_true 64 | @cache.respond_to?(:decr).should be_true 65 | @cache.respond_to?(:add).should be_true 66 | end 67 | 68 | it "delegates unsupported messages back to the real cache" do 69 | mock($memcache).foo(:bar) 70 | @cache.foo(:bar) 71 | end 72 | 73 | describe '#get_multi' do 74 | describe 'when everything is a hit' do 75 | it 'returns a hash' do 76 | @cache.set('key1', @value) 77 | @cache.set('key2', @value) 78 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value, 'key2' => @value } 79 | end 80 | end 81 | 82 | describe 'when there are misses' do 83 | it 'only returns results for hits' do 84 | @cache.set('key1', @value) 85 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value } 86 | end 87 | end 88 | end 89 | end 90 | 91 | describe 'In a Transaction' do 92 | it "commits to the real cache" do 93 | $memcache.get(@key).should == nil 94 | @cache.transaction do 95 | @cache.set(@key, @value) 96 | end 97 | $memcache.get(@key).should == @value 98 | end 99 | 100 | describe 'when there is a return/next/break in the transaction' do 101 | it 'commits to the real cache' do 102 | $memcache.get(@key).should == nil 103 | @cache.transaction do 104 | @cache.set(@key, @value) 105 | next 106 | end 107 | $memcache.get(@key).should == @value 108 | end 109 | end 110 | 111 | it "reads through the real cache if key has not been written to" do 112 | $memcache.set(@key, @value) 113 | @cache.transaction do 114 | @cache.get(@key).should == @value 115 | end 116 | @cache.get(@key).should == @value 117 | end 118 | 119 | it "delegates unsupported messages back to the real cache" do 120 | @cache.transaction do 121 | mock($memcache).foo(:bar) 122 | @cache.foo(:bar) 123 | end 124 | end 125 | 126 | it "returns the result of the block passed to the transaction" do 127 | @cache.transaction do 128 | :result 129 | end.should == :result 130 | end 131 | 132 | describe 'Increment and Decrement' do 133 | describe '#incr' do 134 | it "works" do 135 | @cache.set(@key, 0, 0, true) 136 | @cache.incr(@key) 137 | @cache.transaction do 138 | @cache.incr(@key).should == 2 139 | end 140 | end 141 | 142 | it "is buffered" do 143 | @cache.transaction do 144 | @cache.set(@key, 0, 0, true) 145 | @cache.incr(@key, 2).should == 2 146 | @cache.get(@key).should == 2 147 | $memcache.get(@key).should == nil 148 | end 149 | @cache.get(@key, true).to_i.should == 2 150 | $memcache.get(@key, true).to_i.should == 2 151 | end 152 | 153 | it "returns nil if there is no key already at that value" do 154 | @cache.transaction do 155 | @cache.incr(@key).should == nil 156 | end 157 | end 158 | 159 | end 160 | 161 | describe '#decr' do 162 | it "works" do 163 | @cache.set(@key, 0, 0, true) 164 | @cache.incr(@key) 165 | @cache.transaction do 166 | @cache.decr(@key).should == 0 167 | end 168 | end 169 | 170 | it "is buffered" do 171 | @cache.transaction do 172 | @cache.set(@key, 0, 0, true) 173 | @cache.incr(@key, 3) 174 | @cache.decr(@key, 2).should == 1 175 | @cache.get(@key, true).to_i.should == 1 176 | $memcache.get(@key).should == nil 177 | end 178 | @cache.get(@key, true).to_i.should == 1 179 | $memcache.get(@key, true).to_i.should == 1 180 | end 181 | 182 | it "returns nil if there is no key already at that value" do 183 | @cache.transaction do 184 | @cache.decr(@key).should == nil 185 | end 186 | end 187 | 188 | it "bottoms out at zero" do 189 | @cache.transaction do 190 | @cache.set(@key, 0, 0, true) 191 | @cache.incr(@key, 1) 192 | @cache.get(@key, true).should == 1 193 | @cache.decr(@key) 194 | @cache.get(@key, true).should == 0 195 | @cache.decr(@key) 196 | @cache.get(@key, true).should == 0 197 | end 198 | end 199 | end 200 | end 201 | 202 | describe '#get_multi' do 203 | describe 'when a hit value is the empty array' do 204 | it 'returns a hash' do 205 | @cache.transaction do 206 | @cache.set('key1', @value) 207 | @cache.set('key2', []) 208 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value, 'key2' => [] } 209 | end 210 | end 211 | end 212 | 213 | describe 'when everything is a hit' do 214 | it 'returns a hash' do 215 | @cache.transaction do 216 | @cache.set('key1', @value) 217 | @cache.set('key2', @value) 218 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value, 'key2' => @value } 219 | end 220 | end 221 | end 222 | 223 | describe 'when there are misses' do 224 | it 'only returns results for hits' do 225 | @cache.transaction do 226 | @cache.set('key1', @value) 227 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value } 228 | end 229 | end 230 | end 231 | end 232 | 233 | describe 'Lock Acquisition' do 234 | it "locks @keys to be written before writing to memcache and release them after" do 235 | mock(lock).acquire_lock(@key) 236 | mock($memcache).set(@key, @value) 237 | mock(lock).release_lock(@key) 238 | 239 | @cache.transaction do 240 | @cache.set(@key, @value) 241 | end 242 | end 243 | 244 | it "does not acquire locks on reads" do 245 | mock(lock).acquire_lock.never 246 | mock(lock).release_lock.never 247 | 248 | @cache.transaction do 249 | @cache.get(@key) 250 | end 251 | end 252 | 253 | it "locks @keys in lexically sorted order" do 254 | keys = ['c', 'a', 'b'] 255 | keys.sort.inject(mock(lock)) do |mock, key| 256 | mock.acquire_lock(key).then 257 | end 258 | keys.each { |key| mock($memcache).set(key, @value) } 259 | keys.each { |key| mock(lock).release_lock(key) } 260 | @cache.transaction do 261 | @cache.set(keys[0], @value) 262 | @cache.set(keys[1], @value) 263 | @cache.set(keys[2], @value) 264 | end 265 | end 266 | 267 | it "releases locks even if memcache blows up" do 268 | mock(lock).acquire_lock.with(@key) 269 | mock(lock).release_lock.with(@key) 270 | stub($memcache).set(anything, anything) { raise } 271 | @cache.transaction do 272 | @cache.set(@key, @value) 273 | end rescue nil 274 | end 275 | 276 | end 277 | 278 | describe 'Buffering' do 279 | it "reading from the cache show uncommitted writes" do 280 | @cache.get(@key).should == nil 281 | @cache.transaction do 282 | @cache.set(@key, @value) 283 | @cache.get(@key).should == @value 284 | end 285 | end 286 | 287 | it "get_multi is buffered" do 288 | @cache.transaction do 289 | @cache.set('key1', @value) 290 | @cache.set('key2', @value) 291 | @cache.get_multi('key1', 'key2').should == { 'key1' => @value, 'key2' => @value } 292 | $memcache.get_multi('key1', 'key2').should == {} 293 | end 294 | end 295 | 296 | it "get is memoized" do 297 | @cache.set(@key, @value) 298 | @cache.transaction do 299 | @cache.get(@key).should == @value 300 | $memcache.set(@key, "new value") 301 | @cache.get(@key).should == @value 302 | end 303 | end 304 | 305 | it "add is buffered" do 306 | @cache.transaction do 307 | @cache.add(@key, @value) 308 | $memcache.get(@key).should == nil 309 | @cache.get(@key).should == @value 310 | end 311 | @cache.get(@key).should == @value 312 | $memcache.get(@key).should == @value 313 | end 314 | 315 | describe '#delete' do 316 | it "within a transaction, delete is isolated" do 317 | @cache.add(@key, @value) 318 | @cache.transaction do 319 | @cache.delete(@key) 320 | $memcache.add(@key, "another value") 321 | end 322 | @cache.get(@key).should == nil 323 | $memcache.get(@key).should == nil 324 | end 325 | 326 | it "within a transaction, delete is buffered" do 327 | @cache.set(@key, @value) 328 | @cache.transaction do 329 | @cache.delete(@key) 330 | $memcache.get(@key).should == @value 331 | @cache.get(@key).should == nil 332 | end 333 | @cache.get(@key).should == nil 334 | $memcache.get(@key).should == nil 335 | end 336 | end 337 | end 338 | 339 | describe '#incr' do 340 | it "increment be atomic" do 341 | @cache.set(@key, 0, 0, true) 342 | @cache.transaction do 343 | @cache.incr(@key) 344 | $memcache.incr(@key) 345 | end 346 | @cache.get(@key, true).to_i.should == 2 347 | $memcache.get(@key, true).to_i.should == 2 348 | end 349 | 350 | it "interleaved, etc. increments and sets be ordered" do 351 | @cache.set(@key, 0, 0, true) 352 | @cache.transaction do 353 | @cache.incr(@key) 354 | @cache.incr(@key) 355 | @cache.set(@key, 0, 0, true) 356 | @cache.incr(@key) 357 | @cache.incr(@key) 358 | end 359 | @cache.get(@key, true).to_i.should == 2 360 | $memcache.get(@key, true).to_i.should == 2 361 | end 362 | end 363 | 364 | describe '#decr' do 365 | it "decrement be atomic" do 366 | @cache.set(@key, 0, 0, true) 367 | @cache.incr(@key, 3) 368 | @cache.transaction do 369 | @cache.decr(@key) 370 | $memcache.decr(@key) 371 | end 372 | @cache.get(@key, true).to_i.should == 1 373 | $memcache.get(@key, true).to_i.should == 1 374 | end 375 | end 376 | 377 | it "retains the value in the transactional cache after committing the transaction" do 378 | @cache.get(@key).should == nil 379 | @cache.transaction do 380 | @cache.set(@key, @value) 381 | end 382 | @cache.get(@key).should == @value 383 | end 384 | 385 | describe 'when reading from the memcache' do 386 | it "does NOT show uncommitted writes" do 387 | @cache.transaction do 388 | $memcache.get(@key).should == nil 389 | @cache.set(@key, @value) 390 | $memcache.get(@key).should == nil 391 | end 392 | end 393 | end 394 | end 395 | 396 | describe 'Exception Handling' do 397 | 398 | it "re-raises exceptions thrown by memcache" do 399 | stub($memcache).set(anything, anything) { raise } 400 | lambda do 401 | @cache.transaction do 402 | @cache.set(@key, @value) 403 | end 404 | end.should raise_error 405 | end 406 | 407 | it "rolls back transaction cleanly if an exception is raised" do 408 | $memcache.get(@key).should == nil 409 | @cache.get(@key).should == nil 410 | @cache.transaction do 411 | @cache.set(@key, @value) 412 | raise 413 | end rescue nil 414 | @cache.get(@key).should == nil 415 | $memcache.get(@key).should == nil 416 | end 417 | 418 | it "does not acquire locks if transaction is rolled back" do 419 | mock(lock).acquire_lock.never 420 | mock(lock).release_lock.never 421 | 422 | @cache.transaction do 423 | @cache.set(@key, value) 424 | raise 425 | end rescue nil 426 | end 427 | end 428 | 429 | describe 'Nested Transactions' do 430 | it "delegate unsupported messages back to the real cache" do 431 | @cache.transaction do 432 | @cache.transaction do 433 | @cache.transaction do 434 | mock($memcache).foo(:bar) 435 | @cache.foo(:bar) 436 | end 437 | end 438 | end 439 | end 440 | 441 | it "makes newly set keys only be visible within the transaction in which they were set" do 442 | @cache.transaction do 443 | @cache.set('key1', @value) 444 | @cache.transaction do 445 | @cache.get('key1').should == @value 446 | @cache.set('key2', @value) 447 | @cache.transaction do 448 | @cache.get('key1').should == @value 449 | @cache.get('key2').should == @value 450 | @cache.set('key3', @value) 451 | end 452 | end 453 | @cache.get('key1').should == @value 454 | @cache.get('key2').should == @value 455 | @cache.get('key3').should == @value 456 | end 457 | @cache.get('key1').should == @value 458 | @cache.get('key2').should == @value 459 | @cache.get('key3').should == @value 460 | end 461 | 462 | it "not write any values to memcache until the outermost transaction commits" do 463 | @cache.transaction do 464 | @cache.set('key1', @value) 465 | @cache.transaction do 466 | @cache.set('key2', @value) 467 | $memcache.get('key1').should == nil 468 | $memcache.get('key2').should == nil 469 | end 470 | $memcache.get('key1').should == nil 471 | $memcache.get('key2').should == nil 472 | end 473 | $memcache.get('key1').should == @value 474 | $memcache.get('key2').should == @value 475 | end 476 | 477 | it "acquire locks in lexical order for all keys" do 478 | keys = ['c', 'a', 'b'] 479 | keys.sort.inject(mock(lock)) do |mock, key| 480 | mock.acquire_lock(key).then 481 | end 482 | keys.each { |key| mock($memcache).set(key, @value) } 483 | keys.each { |key| mock(lock).release_lock(key) } 484 | @cache.transaction do 485 | @cache.set(keys[0], @value) 486 | @cache.transaction do 487 | @cache.set(keys[1], @value) 488 | @cache.transaction do 489 | @cache.set(keys[2], @value) 490 | end 491 | end 492 | end 493 | end 494 | 495 | it "reads through the real memcache if key has not been written to in a transaction" do 496 | $memcache.set(@key, @value) 497 | @cache.transaction do 498 | @cache.transaction do 499 | @cache.transaction do 500 | @cache.get(@key).should == @value 501 | end 502 | end 503 | end 504 | @cache.get(@key).should == @value 505 | end 506 | 507 | describe 'Error Handling' do 508 | it "releases locks even if memcache blows up" do 509 | mock(lock).acquire_lock(@key) 510 | mock(lock).release_lock(@key) 511 | stub($memcache).set(anything, anything) { raise } 512 | @cache.transaction do 513 | @cache.transaction do 514 | @cache.transaction do 515 | @cache.set(@key, @value) 516 | end 517 | end 518 | end rescue nil 519 | end 520 | 521 | it "re-raise exceptions thrown by memcache" do 522 | stub($memcache).set(anything, anything) { raise } 523 | lambda do 524 | @cache.transaction do 525 | @cache.transaction do 526 | @cache.transaction do 527 | @cache.set(@key, @value) 528 | end 529 | end 530 | end 531 | end.should raise_error 532 | end 533 | 534 | it "rollback transaction cleanly if an exception is raised" do 535 | $memcache.get(@key).should == nil 536 | @cache.get(@key).should == nil 537 | @cache.transaction do 538 | @cache.transaction do 539 | @cache.set(@key, @value) 540 | raise 541 | end 542 | end rescue nil 543 | @cache.get(@key).should == nil 544 | $memcache.get(@key).should == nil 545 | end 546 | 547 | it "not acquire locks if transaction is rolled back" do 548 | mock(lock).acquire_lock.never 549 | mock(lock).release_lock.never 550 | 551 | @cache.transaction do 552 | @cache.transaction do 553 | @cache.set(@key, @value) 554 | raise 555 | end 556 | end rescue nil 557 | end 558 | 559 | it "support rollbacks" do 560 | @cache.transaction do 561 | @cache.set('key1', @value) 562 | @cache.transaction do 563 | @cache.get('key1').should == @value 564 | @cache.set('key2', @value) 565 | raise 566 | end rescue nil 567 | @cache.get('key1').should == @value 568 | @cache.get('key2').should == nil 569 | end 570 | $memcache.get('key1').should == @value 571 | $memcache.get('key2').should == nil 572 | end 573 | end 574 | end 575 | 576 | it "should have method_missing as a private method" do 577 | Transactional.private_instance_methods.should include("method_missing") 578 | end 579 | end 580 | end 581 | --------------------------------------------------------------------------------