Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Add soft_ttl and retry_delay options to fetch.

  • Loading branch information...
commit 71e73a95d4f5d2fbb4c129a81527163e3928682a 1 parent 6cb6db6
@kindkid authored
View
31 README.md
@@ -14,8 +14,8 @@ Example
puts cache[2] # b
puts cache[1] # a
puts cache[3] # c
- puts cache[:does_not_exist] # 42, has no effect on LRU.
- cache[4] = 'd' # Out of space! Throws out the least-recently used (2 => 'b').
+ puts cache[:does_not_exist] # 42, has no effect on LRU
+ cache[4] = 'd' # Out of space! Throws out the least-recently used (2 => 'b')
puts cache.keys # [1,3,4]
@@ -37,4 +37,29 @@ TTL (time-to-live)
# Three days later ...
cache.fetch("banana") # nil
- cache.fetch("monkey") # nil
+ cache.fetch("monkey") # nil
+
+SOFT_TTL
+========
+Allows you to have two TTLs when calling fetch with a block.
+After the soft-ttl expires, the block is called to refresh the value.
+If the block completes normally, the value is replaced and expirations reset.
+If the block raises a fatal (non-RuntimeError) exception, it bubbles up. But,
+if the block raises a RuntimeError, the cached value is kept and used a little
+longer, and the soft-ttl is postponed retry_delay into the future. If the block
+is not called successfully before the normal TTL expires, then the cached value
+expires and the block is called for a new value, but exceptions are not handled.
+
+ cache = LRUCache.new(:ttl => 1.hour,
+ :soft_ttl => 30.minutes,
+ :retry_delay => 1.minute)
+ cache.fetch("banana") { "yellow" } # "yellow"
+ cache.fetch("banana") { "george" } # "yellow"
+
+ # 30 minutes later ...
+ cache.fetch("banana") { raise "ruckus" } # "yellow"
+ cache.fetch("banana") { "george" } # "yellow"
+
+ # 1 more minute later ...
+ cache.fetch("banana") { "george" } # "george"
+ cache.fetch("banana") { "barney" } # "george"
View
115 lib/lrucache.rb
@@ -4,14 +4,19 @@
# Not thread-safe!
class LRUCache
- attr_reader :default, :max_size, :ttl
+ attr_reader :default, :max_size, :ttl, :soft_ttl, :retry_delay
def initialize(opts={})
@max_size = Integer(opts[:max_size] || 100)
@default = opts[:default]
@ttl = Float(opts[:ttl] || 0)
+ @soft_ttl = Float(opts[:soft_ttl] || 0)
+ @retry_delay = Float(opts[:retry_delay] || 0)
raise "max_size must not be negative" if @max_size < 0
- raise "ttl must be positive or zero" unless @ttl >= 0
+ raise "ttl must not be negative" if @ttl < 0
+ raise "soft_ttl must not be negative" if @soft_ttl < 0
+ raise "retry_delay must not be negative" if @retry_delay < 0
+
@pqueue = PriorityQueue.new
@data = {}
@counter = 0
@@ -26,49 +31,59 @@ def clear
def include?(key)
datum = @data[key]
return false if datum.nil?
- value, expires = datum
- if expires.nil? || expires > Time.now # no expiration, or not expired
- access(key)
- true
- else # expired
+ if datum.expired?
delete(key)
false
+ else
+ access(key)
+ true
end
end
- def store(key, value, ttl=nil)
+ def store(key, value, args={})
evict_lru! unless @data.include?(key) || @data.size < max_size
- ttl ||= @ttl
- expires =
- if ttl.is_a?(Time)
- ttl
- else
- ttl = Float(ttl)
- (ttl > 0) ? (Time.now + ttl) : nil
- end
- @data[key] = [value, expires]
+ ttl, soft_ttl, retry_delay = extract_arguments(args)
+ expiration = expiration_date(ttl)
+ soft_expiration = expiration_date(soft_ttl)
+ @data[key] = Datum.new(value, expiration, soft_expiration)
access(key)
+ value
end
alias :[]= :store
- def fetch(key, ttl=nil)
+ def fetch(key, args={})
datum = @data[key]
- unless datum.nil?
- value, expires = datum
- if expires.nil? || expires > Time.now # no expiration, or not expired
+ if datum.nil?
+ if block_given?
+ store(key, value = yield, args)
+ else
+ @default
+ end
+ elsif datum.expired?
+ delete(key)
+ if block_given?
+ store(key, value = yield, args)
+ else
+ @default
+ end
+ elsif datum.soft_expired?
+ if block_given?
+ begin
+ store(key, value = yield, args)
+ rescue RuntimeError => e
+ access(key)
+ ttl, soft_ttl, retry_delay = extract_arguments(args)
+ datum.soft_expiration = (Time.now + retry_delay) if retry_delay > 0
+ datum.value
+ end
+ else
access(key)
- return value
- else # expired
- delete(key)
+ datum.value
end
- end
- if block_given?
- value = yield
- store(key, value, ttl)
- value
else
- @default
+ access(key)
+ datum.value
end
end
@@ -93,6 +108,46 @@ def delete(key)
private
+ class Datum
+ attr_reader :value, :expiration, :soft_expiration
+ attr_writer :soft_expiration
+ def initialize(value, expiration, soft_expiration)
+ @value = value
+ @expiration = expiration
+ @soft_expiration = soft_expiration
+ end
+
+ def expired?
+ !@expiration.nil? && @expiration <= Time.now
+ end
+
+ def soft_expired?
+ !@soft_expiration.nil? && @soft_expiration <= Time.now
+ end
+ end
+
+ def expiration_date(ttl)
+ if ttl.is_a?(Time)
+ ttl
+ else
+ ttl = Float(ttl)
+ (ttl > 0) ? (Time.now + ttl) : nil
+ end
+ end
+
+ def extract_arguments(args)
+ if args.is_a?(Hash)
+ ttl = args[:ttl] || @ttl
+ soft_ttl = args[:soft_ttl] || @soft_ttl
+ retry_delay = args[:retry_delay] || @retry_delay
+ [ttl, soft_ttl, retry_delay]
+ else
+ # legacy arg
+ ttl = args || @ttl
+ [ttl, @soft_ttl, @retry_delay]
+ end
+ end
+
def evict_lru!
key, priority = @pqueue.delete_min
@data.delete(key) unless priority.nil?
View
2  lib/lrucache/version.rb
@@ -1,3 +1,3 @@
class LRUCache
- VERSION = "0.1.1"
+ VERSION = "0.1.2"
end
View
63 spec/lrucache_spec.rb
@@ -197,7 +197,8 @@
c = LRUCache.new(:ttl => 0)
c.store(:a,'a')
stored = c.instance_variable_get(:@data)[:a]
- stored.should == ['a', nil]
+ stored.value.should == 'a'
+ stored.expiration.should == nil
end
end
context "when ttl is not given and the cache's default ttl is greater than zero" do
@@ -206,7 +207,8 @@
now = Time.now
Timecop.freeze(now) { c.store(:a,'a') }
stored = c.instance_variable_get(:@data)[:a]
- stored.last.should == now + 1
+ stored.value.should == 'a'
+ stored.expiration.should == now + 1
end
end
context "when ttl is a Time" do
@@ -215,7 +217,8 @@
ttl = Time.now + 246
c.store(:a, 'a', ttl)
stored = c.instance_variable_get(:@data)[:a]
- stored.last.should == ttl
+ stored.value.should == 'a'
+ stored.expiration.should == ttl
end
end
context "when ttl can be parsed as a float" do
@@ -224,7 +227,8 @@
now = Time.now
Timecop.freeze(now) { c.store(:a, 'a', "98.6") }
stored = c.instance_variable_get(:@data)[:a]
- stored.last.should == now + 98.6
+ stored.value.should == 'a'
+ stored.expiration.should == now + 98.6
end
end
context "when ttl cannot be parsed as a float" do
@@ -302,27 +306,27 @@
end
end
context "when a block is given" do
- context "when the key does not exist" do
+ context "and the key does not exist" do
it "should call the block and store and return the result" do
c = LRUCache.new
ttl = double(:ttl)
result = double(:result)
- c.should_receive(:store).with(:a, result, ttl)
+ c.should_receive(:store).with(:a, result, ttl).and_return(result)
c.fetch(:a, ttl){ result }.should == result
end
end
- context "when the key has been evicted" do
+ context "and the key has been evicted" do
it "should call the block and store and return the result" do
c = LRUCache.new
c[:a] = 'a'
c.send(:evict_lru!)
ttl = double(:ttl)
result = double(:result)
- c.should_receive(:store).with(:a, result, ttl)
+ c.should_receive(:store).with(:a, result, ttl).and_return(result)
c.fetch(:a, ttl){ result }.should == result
end
end
- context "when the key has expired" do
+ context "and the key has expired" do
it "should call the block and store and return the result" do
c = LRUCache.new
now = Time.now
@@ -330,12 +334,49 @@
Timecop.freeze(now + 20) do
ttl = double(:ttl)
result = double(:result)
- c.should_receive(:store).with(:a, result, ttl)
+ c.should_receive(:store).with(:a, result, ttl).and_return(result)
c.fetch(:a, ttl){ result }.should == result
end
end
end
- context "when the key is present and un-expired" do
+ context 'and the key has "soft"-expired' do
+ before(:each) do
+ @c = LRUCache.new
+ @c.store(:a, 'a', :ttl => 10_000, :soft_ttl => Time.now - 60, :retry_delay => 10)
+ @args = {:ttl => 10_000, :soft_ttl => 60, :retry_delay => 10}
+ end
+ context "and the block raises a runtime exception" do
+ it "should continue to return the old value" do
+ @c.should_not_receive(:store)
+ @c.fetch(:a, @args) { raise "no!" }.should == 'a'
+ end
+ it "should extend the soft-expiration by retry_delay" do
+ Timecop.freeze(Time.now) do
+ data = @c.instance_variable_get(:@data)
+ original_soft_expiration = data[:a].soft_expiration
+ @c.should_not_receive(:store)
+ @c.fetch(:a, @args) { raise "no!" }
+ data = @c.instance_variable_get(:@data)
+ data[:a].soft_expiration.should == Time.now + @args[:retry_delay]
+ end
+ end
+ end
+ context "and the block raises a fatal exception" do
+ it "should allow the exception through" do
+ expect {
+ @c.fetch(:a, @args) { raise(NoMemoryError,"panic!") }
+ }.to raise_exception(NoMemoryError)
+ end
+ end
+ context "and the block does not raise an exception" do
+ it "should call the block and store and return the result" do
+ result = double(:result)
+ @c.should_receive(:store).with(:a, result, @args).and_return(result)
+ @c.fetch(:a, @args) { result }.should == result
+ end
+ end
+ end
+ context "and the key is present and un-expired" do
it "should return the cached value without calling the block" do
c = LRUCache.new(:ttl => nil)
c[:a] = 'a'
Please sign in to comment.
Something went wrong with that request. Please try again.