Skip to content

Commit

Permalink
Add simple etag based cache
Browse files Browse the repository at this point in the history
The cache only handles get requests (so in particular RelaxDB.load %w(lots of ids) is not cached), but it does handle all get requests (so views are cached).
The cache uses the etags that couchdb provides to add a If-None-Match header, if the document or view has not changed then couchdb return a 304 response and the data from the cache is returned. This means that there is not a huge gain when loading a single document (other than shifting some work off couchdb) - the cache is most useful for views. This mechanism means that there is no expiry to worry about. Couchdb knows from the etag whether the cached data is up to date or not.

The cache is off by default. To enable it pass a cache store object to RelaxDB.configure eg

RelaxDB.configure ..., :cache_store => RelaxDB::MemoryStore.new(:size => 200)

will tell RelaxDB to use a cache store that stores up to 200 entries in memory.

There is also a simple memcache based store, eg

RelaxDB.configure ..., :cache_store => RelaxDB::MemcacheStore.new('localhost:11211')

which is more useful for a web app environment. All options passed to MemcacheStore.new are passed straight through to memcache-client
  • Loading branch information
fcheung committed Apr 14, 2009
1 parent bf79e22 commit 1d9acfd
Show file tree
Hide file tree
Showing 4 changed files with 235 additions and 19 deletions.
36 changes: 36 additions & 0 deletions lib/relaxdb/memcache_store.rb
@@ -0,0 +1,36 @@
gem 'memcache-client'
require 'memcache'
require 'zlib'
module RelaxDB
class MemcacheStore
def initialize(*args_for_memcache)
@cache = MemCache.new *args_for_memcache
end

def get_etag(cache_key)
@cache.get "etag:#{cache_key}"
end

def get_data(cache_key)
entry = @cache.get "data:#{cache_key}"
if entry[:deflated]
Zlib::Inflate.inflate(entry[:data])
else
entry[:data]
end
end

def store(cache_key, data, etag)
deflated = false
if data.length >= 1024 * 1024
data = Zlib::Deflate.deflate(data, 1)
deflated = true
end

if data.size < 1024*1024 #memcache cannot store values over 1mb
@cache.set( "data:#{cache_key}", {:data => data, :deflated => deflated})
@cache.set( "etag:#{cache_key}", etag)
end
end
end
end
130 changes: 112 additions & 18 deletions lib/relaxdb/server.rb
Expand Up @@ -4,24 +4,103 @@ class HTTP_404 < StandardError; end
class HTTP_409 < StandardError; end
class HTTP_412 < StandardError; end

class CacheEntry
attr_accessor :data, :etag
def initialize(data, etag)
self.data = data
self.etag = etag
end
end

class NilStore
def get_etag(cache_key)
end
def get_data(cache_key)
end
def store(cache_key, data,etag)
end
end

class MemoryStore
def initialize(options={})
@cache = {}
@maximum_entry_size = options[:maximum_entry_size]
@size = options[:size] || 100
@cache_order = []
end

def get(cache_key)
value = @cache[cache_key]
push_key_to_top cache_key if value
value
end

def get_etag(cache_key)
entry = get(cache_key)
entry && entry.etag
end

def get_data(cache_key)
entry = get(cache_key)
entry && entry.data
end

def store(cache_key, data, etag)
if @maximum_entry_size.nil? || data.length <= @maximum_entry_size
if @cache.size >= @size && !@cache.has_key?(cache_key)
first_key = @cache_order.shift
@cache.delete first_key
end
push_key_to_top cache_key
@cache[cache_key] = CacheEntry.new(data, etag)
end
end

private
def push_key_to_top key
@cache_order.delete_if {|k| k == key} if @cache.has_key? key
@cache_order << key
end
end

class Server
attr_reader :cache_store
class Response
attr_reader :body
def initialize body
@body = body
attr_reader :status, :body, :etag
def initialize status, body, etag
@status, @body, @etag = status, body, etag
end
end

def initialize(host, port)
def initialize(host, port, cache_store)
@host, @port = host, port
@cache_store = cache_store || NilStore.new
end

def delete(uri)
request(uri, 'delete'){ |c| c.http_delete}
end

def get(uri)
request(uri, 'get'){ |c| c.http_get}
etag = cache_store.get_etag uri

response = request(uri, 'get') do |c|
c.headers['If-None-Match'] = etag
c.http_get
end

if etag && response.status == 304
data = cache_store.get_data uri
if data
return Response.new( 200, data, etag)
else
#we don't have the data in our cache anymore boohoo
response = request(uri, 'get') {|c| c.http_get }
end
end

cache_store.store uri, response.body, response.etag
response
end

def put(uri, json)
Expand All @@ -40,27 +119,42 @@ def post(uri, json)

def request(uri, method)
c = Curl::Easy.new "http://#{@host}:#{@port}#{uri}"
etag = status_line = nil

c.on_header do |header_string|
if !status_line
status_line = header_string
elsif header_string.strip =~ /etag:\s*(.*)/i
etag = $1
end
header_string.length
end

yield c

if c.response_code < 200 || c.response_code >= 300
status_line = c.header_str.split('\r\n').first
msg = "#{c.response_code}:#{status_line}\nMETHOD:#{method}\nURI:#{uri}\n#{c.body_str}"
begin
klass = RelaxDB.const_get("HTTP_#{c.response_code}")
e = klass.new(msg)
rescue
e = RuntimeError.new(msg)
end

raise e
if (c.response_code < 200 || c.response_code >= 300) && c.response_code != 304
handle_error c.response_code, status_line, method, uri, c.body_str
end
Response.new c.body_str
Response.new c.response_code, c.body_str, etag
end

def to_s
"http://#{@host}:#{@port}/"
end

protected

def handle_error status_code, status_line, method, uri, body
msg = "#{status_code}:#{status_line}\nMETHOD:#{method}\nURI:#{uri}\n#{body}"
begin
klass = RelaxDB.const_get("HTTP_#{status_code}")
e = klass.new(msg)
rescue
e = RuntimeError.new(msg)
end

raise e
end
end


Expand All @@ -74,7 +168,7 @@ class CouchDB

def initialize(config)
@get_count, @post_count, @put_count = 0, 0, 0
@server = RelaxDB::Server.new(config[:host], config[:port])
@server = RelaxDB::Server.new(config[:host], config[:port], config[:cache_store])
@logger = config[:logger] ? config[:logger] : Logger.new(Tempfile.new('couchdb.log'))
end

Expand Down
60 changes: 60 additions & 0 deletions spec/memory_store_spec.rb
@@ -0,0 +1,60 @@
require File.dirname(__FILE__) + '/spec_helper.rb'

describe RelaxDB::MemoryStore do

describe "cache" do
it "should not store objects bigger than the maximum size" do
@store = RelaxDB::MemoryStore.new :maximum_entry_size => 30
@store.store 'some_key', 'x' * 30, 'tag'

@store.get('some_key').should_not be_nil

@store.store 'some_other_key', 'x' * 31, 'tag'
@store.get('some_other_key').should be_nil
end

it "should store and retrieve objects" do
@store = RelaxDB::MemoryStore.new
@store.store 'some_key', 'x' * 30, 'tag'
entry = @store.get 'some_key'
entry.data.should == 'x' * 30
entry.etag.should == 'tag'
end

it "should remove old items from the cache" do
@store = RelaxDB::MemoryStore.new :size => 2

@store.store 'first_key', 'data', 'tag'
@store.store 'second_key', 'data', 'tag'
@store.store 'third_key', 'data', 'tag'

@store.get('second_key').should_not be_nil
@store.get('third_key').should_not be_nil
@store.get('first_key').should be_nil
end

it "should not remove items from cache when storing an existing key" do
@store = RelaxDB::MemoryStore.new :size => 2
@store.store 'first_key', 'data', 'tag'
@store.store 'second_key', 'data', 'tag'
@store.store 'second_key', 'data2', 'tag'
@store.store 'second_key', 'data3', 'tag'

@store.get('second_key').should_not be_nil
@store.get('first_key').should_not be_nil
end

it "should push items to the front of the queue" do
@store = RelaxDB::MemoryStore.new :size => 2
@store.store 'first_key', 'data', 'tag'
@store.store 'second_key', 'data', 'tag'

@store.get('first_key')
@store.store 'third_key', 'data', 'tag'

@store.get('second_key').should be_nil
@store.get('third_key').should_not be_nil
@store.get('first_key').should_not be_nil
end
end
end
28 changes: 27 additions & 1 deletion spec/server_spec.rb
Expand Up @@ -5,7 +5,7 @@

before(:all) do
RelaxDB.configure :host => "localhost", :port => 5984, :design_doc => "spec_doc"
@server = RelaxDB::Server.new("localhost", 5984)
@server = RelaxDB::Server.new("localhost", 5984, RelaxDB::MemoryStore.new)
end

before(:each) do
Expand All @@ -27,6 +27,32 @@
end.should raise_error(RuntimeError)
end

it "should store results in the cache" do
a = Atom.new.save

response = @server.get "/relaxdb_spec/#{a._id}"

entry = @server.cache_store.get "/relaxdb_spec/#{a._id}"
entry.should_not be_nil
entry.etag.should == "\"#{a._rev}\""
entry.data.should == response.body
end

it "should fetch results from the cache" do
a = Atom.new.save
entry = @server.cache_store.store "/relaxdb_spec/#{a._id}", "dummy data", "\"#{a._rev}\""

response = @server.get "/relaxdb_spec/#{a._id}"
response.body.should == "dummy data"
end

it "should not get stale data from the cache" do
a = Atom.new.save
entry = @server.cache_store.store "/relaxdb_spec/#{a._id}", "dummy data", "\"#{a._rev}\""
a.save
response = @server.get "/relaxdb_spec/#{a._id}"
response.body.should_not == "dummy data"
end
end

end

0 comments on commit 1d9acfd

Please sign in to comment.