├── spec ├── spec.opts └── spec_helper.rb ├── Gemfile ├── Rakefile ├── lib └── ngcache.rb └── README.markdown /spec/spec.opts: -------------------------------------------------------------------------------- 1 | --color --backtrace -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gemspec 4 | 5 | gem 'memcache-lock', '=0.1.0' 6 | gem 'rails', '~>3.0.0' 7 | -------------------------------------------------------------------------------- /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 | 39 | desc "Push a new version to Gemcutter" 40 | task :publish => [ :spec, :build ] do 41 | system "git tag v#{NGCache::VERSION}" 42 | system "git push origin v#{NGCache::VERSION}" 43 | system "git push origin master" 44 | system "gem push pkg/ngcache-#{NGCache::VERSION}.gem" 45 | system "git clean -fd" 46 | end 47 | -------------------------------------------------------------------------------- /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 'ngcache' 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 | 33 | else 34 | require 'cash/adapter/memcached' 35 | # Test with memcached client 36 | $memcache = Cash::Adapter::Memcached.new(Memcached.new(config["servers"], config), 37 | :default_ttl => 1.minute.to_i) 38 | end 39 | end 40 | 41 | config.before :each do 42 | $memcache.flush_all 43 | Story.delete_all 44 | Character.delete_all 45 | end 46 | 47 | config.before :suite do 48 | Cash.configure :repository => $memcache, :adapter => false 49 | 50 | ActiveRecord::Base.class_eval do 51 | is_cached 52 | end 53 | 54 | Character = Class.new(ActiveRecord::Base) 55 | Story = Class.new(ActiveRecord::Base) 56 | Story.has_many :characters 57 | 58 | Story.class_eval do 59 | index :title 60 | index [:id, :title] 61 | index :published 62 | end 63 | 64 | Short = Class.new(Story) 65 | Short.class_eval do 66 | index :subtitle, :order_column => 'title' 67 | end 68 | 69 | Epic = Class.new(Story) 70 | Oral = Class.new(Epic) 71 | 72 | Character.class_eval do 73 | index [:name, :story_id] 74 | index [:id, :story_id] 75 | index [:id, :name, :story_id] 76 | end 77 | 78 | Oral.class_eval do 79 | index :subtitle 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/ngcache.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_record' 3 | 4 | require 'ngcache/version' 5 | 6 | module NGCache 7 | mattr_accessor :enabled 8 | self.enabled = true 9 | 10 | mattr_accessor :repository 11 | 12 | def self.configure(options = {}) 13 | options.assert_valid_keys(:repository, :local, :transactional, :adapter, :default_ttl) 14 | cache = options[:repository] || raise(":repository is a required option") 15 | 16 | adapter = options.fetch(:adapter, :memcached) 17 | 18 | if adapter 19 | require "ngcache/adapter/#{adapter.to_s}" 20 | klass = "NGCache::Adapter::#{adapter.to_s.camelize}".constantize 21 | cache = klass.new(cache, :logger => Rails.logger, :default_ttl => options.fetch(:default_ttl, 1.day.to_i)) 22 | end 23 | 24 | lock = NGCache::Lock.new(cache) 25 | cache = NGCache::Local.new(cache) if options.fetch(:local, true) 26 | cache = NGCache::Transactional.new(cache, lock) if options.fetch(:transactional, true) 27 | 28 | self.repository = cache 29 | end 30 | 31 | def self.included(active_record_class) 32 | active_record_class.class_eval do 33 | include Config, Accessor, WriteThrough, Finders 34 | extend ClassMethods 35 | end 36 | end 37 | 38 | private 39 | 40 | def self.repository 41 | @@repository || raise("NGCache.configure must be called when NGCache.enabled is true") 42 | end 43 | 44 | module ClassMethods 45 | def self.extended(active_record_class) 46 | class << active_record_class 47 | alias_method_chain :transaction, :cache_transaction 48 | end 49 | end 50 | 51 | def transaction_with_cache_transaction(*args, &block) 52 | if NGCache.enabled 53 | # Wrap both the db and cache transaction in another cache transaction so that the cache 54 | # gets written only after the database commit but can still flush the inner cache 55 | # transaction if an AR::Rollback is issued. 56 | NGCache.repository.transaction do 57 | transaction_without_cache_transaction(*args) do 58 | NGCache.repository.transaction { block.call } 59 | end 60 | end 61 | else 62 | transaction_without_cache_transaction(*args, &block) 63 | end 64 | end 65 | end 66 | end 67 | 68 | class ActiveRecord::Base 69 | include NGCache 70 | 71 | def self.is_cached(options = {}) 72 | options.assert_valid_keys(:ttl, :repository, :version) 73 | opts = options.dup 74 | opts[:repository] = Cash.repository unless opts.has_key?(:repository) 75 | NGCache::Config.create(self, opts) 76 | end 77 | 78 | def <=>(other) 79 | if self.id == other.id then 80 | 0 81 | else 82 | self.id < other.id ? -1 : 1 83 | end 84 | end 85 | end -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | ## What is NGCache ## 2 | 3 | NGCache 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 | For all those interested this GEM is a simplification of cache-money for use in Rails 3.x. By simplification I mean that not all the caching cases covered by cache-money are supported and will never be. For instance: NGCache will NOT support random indexes, that was a bad idea. It caused huge cache thrash. However, NGCache will support unique indexes, which makes sense. 10 | 11 | What we are aiming for is to solve the 80% problem, find by ID or find by unique index and provide class methods to cache and clear whatever instance method you want. 12 | 13 | ## Howto ## 14 | ### What kinds of queries are supported? ### 15 | 16 | Many styles of ActiveRecord usage are supported: 17 | 18 | * `User.find` 19 | * `User.find_by_id` 20 | * `User.find(:conditions => {:id => ...})` 21 | * `User.find(:conditions => ['id = ?', ...])` 22 | * `User.find(:conditions => 'id = ...')` 23 | * `User.find(:conditions => 'users.id = ...')` 24 | 25 | As you can see, the `find_by_`, `find_all_by`, hash, array, and string forms are all supported. 26 | 27 | Queries with joins/includes are unsupported at this time. In general, any query involving just equality (=) and conjunction (AND) is supported by `NGCache`. Disjunction (OR) and inequality (!=, <=, etc.) are not typically materialized in a hash table style index and are unsupported at this time. 28 | 29 | 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 `NGCache` index like so: 30 | 31 | DirectMessage.index :user_id, :limit => 1000 32 | 33 | In this example, only queries whose limit and offset are less than 1000 will use the cache. 34 | 35 | ### Multiple unique indices are supported ### 36 | 37 | class User < ActiveRecord::Base 38 | index :screen_name 39 | index :email 40 | end 41 | 42 | #### `with_scope` support #### 43 | 44 | `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: 45 | 46 | class Device < ActiveRecord::Base 47 | index [:user_id, :id] 48 | end 49 | 50 | ### Transactions ### 51 | 52 | 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. 53 | 54 | The memcache client library has been enhanced to simulate transactions. 55 | 56 | $cache.transaction do 57 | $cache.set(key1, value1) 58 | $cache.set(key2, value2) 59 | end 60 | 61 | 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. 62 | 63 | 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". 64 | 65 | #### Rollbacks #### 66 | 67 | $cache.transaction do 68 | $cache.set(k, v) 69 | raise 70 | end 71 | 72 | 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. 73 | 74 | Nested transactions are fully supported, with partial rollback and (apparent) partial commitment (this is simulated with nested buffers). 75 | 76 | ### Locks ### 77 | 78 | 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. 79 | 80 | $lock.synchronize('lock_name') do 81 | $memcache.set("key", "value") 82 | end 83 | 84 | ## Installation ## 85 | 86 | #### Step 1: Get the GEM #### 87 | 88 | % sudo gem install ngcache 89 | 90 | Add the gem you your Gemfile: 91 | gem 'ngcache' 92 | 93 | #### Step 2: Configure cache client 94 | 95 | In your environment, create a cache client instance configured for your cache servers. 96 | 97 | $memcached = Memcached.new( ...servers..., ...options...) 98 | 99 | Currently supported cache clients are: memcached, memcache-client 100 | 101 | #### Step 3: Configure Caching 102 | 103 | Add the following to an initializer: 104 | 105 | NGCache.configure :repository => $memcached, :adapter => :memcached 106 | 107 | Supported adapters are :memcache_client, :memcached. :memcached is assumed and is only compatible with Memcached clients. 108 | Local or transactional semantics may be disabled by setting :local => false or :transactional => false. 109 | 110 | Caching can be disabled on a per-environment basis in the environment's initializer: 111 | 112 | NGCache.enabled = false 113 | 114 | #### Step 4: Add indices to your ActiveRecord models #### 115 | 116 | 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: 117 | 118 | class User < ActiveRecord::Base 119 | index :name 120 | end 121 | 122 | For queries on multiple attributes, combination indexes are necessary. For example, `User.find(:all, :conditions => {:name => 'bob', :age => 26})` 123 | 124 | class User < ActiveRecord::Base 125 | index [:name, :age] 126 | end 127 | 128 | #### Optional: Selectively cache specific models 129 | 130 | There may be times where you only want to cache some of your models instead of everything. 131 | 132 | In that case, you can omit the following from your `config/initializers/cache_money.rb` 133 | 134 | class ActiveRecord::Base 135 | is_cached 136 | end 137 | 138 | After that is removed, you can simple put this at the top of your models you wish to cache: 139 | 140 | is_cached 141 | 142 | 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. 143 | 144 | ## Acknowledgments ## 145 | 146 | Thanks to 147 | 148 | --------------------------------------------------------------------------------