Skip to content

Commit d343a88

Browse files
authored
Merge pull request #62 from launchdarkly/eb/ch16609/remove-moneta-2
use our own lightweight expiring cache instead of moneta
2 parents 38d63fe + 072217d commit d343a88

File tree

5 files changed

+167
-18
lines changed

5 files changed

+167
-18
lines changed

ldclient-rb.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ Gem::Specification.new do |spec|
2626
spec.add_development_dependency "codeclimate-test-reporter", "~> 0"
2727
spec.add_development_dependency "redis", "~> 3.3.5"
2828
spec.add_development_dependency "connection_pool", ">= 2.1.2"
29-
spec.add_development_dependency "moneta", "~> 1.0.0"
3029
if RUBY_VERSION >= "2.0.0"
3130
spec.add_development_dependency "rake", "~> 10.0"
3231
spec.add_development_dependency "rspec_junit_formatter", "~> 0.3.0"
3332
else
3433
spec.add_development_dependency "rake", "12.1.0"
3534
# higher versions of rake fail to install in JRuby 1.7
3635
end
36+
spec.add_development_dependency "timecop", "~> 0.9.1"
3737

3838
spec.add_runtime_dependency "json", [">= 1.8", "< 3"]
3939
if RUBY_VERSION >= "2.1.0"

lib/ldclient-rb.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
require "ldclient-rb/evaluation"
33
require "ldclient-rb/ldclient"
44
require "ldclient-rb/cache_store"
5+
require "ldclient-rb/expiring_cache"
56
require "ldclient-rb/memoized_value"
67
require "ldclient-rb/in_memory_store"
78
require "ldclient-rb/config"

lib/ldclient-rb/expiring_cache.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
2+
module LaunchDarkly
3+
# A thread-safe cache with maximum number of entries and TTL.
4+
# Adapted from https://github.com/SamSaffron/lru_redux/blob/master/lib/lru_redux/ttl/cache.rb
5+
# under MIT license with the following changes:
6+
# * made thread-safe
7+
# * removed many unused methods
8+
# * reading a key does not reset its expiration time, only writing
9+
class ExpiringCache
10+
def initialize(max_size, ttl)
11+
@max_size = max_size
12+
@ttl = ttl
13+
@data_lru = {}
14+
@data_ttl = {}
15+
@lock = Mutex.new
16+
end
17+
18+
def [](key)
19+
@lock.synchronize do
20+
ttl_evict
21+
@data_lru[key]
22+
end
23+
end
24+
25+
def []=(key, val)
26+
@lock.synchronize do
27+
ttl_evict
28+
29+
@data_lru.delete(key)
30+
@data_ttl.delete(key)
31+
32+
@data_lru[key] = val
33+
@data_ttl[key] = Time.now.to_f
34+
35+
if @data_lru.size > @max_size
36+
key, _ = @data_lru.first # hashes have a FIFO ordering in Ruby
37+
38+
@data_ttl.delete(key)
39+
@data_lru.delete(key)
40+
end
41+
42+
val
43+
end
44+
end
45+
46+
def delete(key)
47+
@lock.synchronize do
48+
ttl_evict
49+
50+
@data_lru.delete(key)
51+
@data_ttl.delete(key)
52+
end
53+
end
54+
55+
def clear
56+
@lock.synchronize do
57+
@data_lru.clear
58+
@data_ttl.clear
59+
end
60+
end
61+
62+
private
63+
64+
def ttl_evict
65+
ttl_horizon = Time.now.to_f - @ttl
66+
key, time = @data_ttl.first
67+
68+
until time.nil? || time > ttl_horizon
69+
@data_ttl.delete(key)
70+
@data_lru.delete(key)
71+
72+
key, time = @data_ttl.first
73+
end
74+
end
75+
end
76+
end

lib/ldclient-rb/redis_store.rb

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ module LaunchDarkly
99
# streaming API. Feature data can also be further cached in memory to reduce overhead
1010
# of calls to Redis.
1111
#
12-
# To use this class, you must first have the `redis`, `connection-pool`, and `moneta`
13-
# gems installed. Then, create an instance and store it in the `feature_store`
14-
# property of your client configuration.
12+
# To use this class, you must first have the `redis` and `connection-pool` gems
13+
# installed. Then, create an instance and store it in the `feature_store` property
14+
# of your client configuration.
1515
#
1616
class RedisFeatureStore
1717
begin
1818
require "redis"
1919
require "connection_pool"
20-
require "moneta"
2120
REDIS_ENABLED = true
2221
rescue ScriptError, StandardError
2322
REDIS_ENABLED = false
@@ -38,7 +37,7 @@ class RedisFeatureStore
3837
#
3938
def initialize(opts = {})
4039
if !REDIS_ENABLED
41-
raise RuntimeError.new("can't use RedisFeatureStore because one of these gems is missing: redis, connection_pool, moneta")
40+
raise RuntimeError.new("can't use RedisFeatureStore because one of these gems is missing: redis, connection_pool")
4241
end
4342
@redis_opts = opts[:redis_opts] || Hash.new
4443
if opts[:redis_url]
@@ -54,15 +53,12 @@ def initialize(opts = {})
5453
@prefix = opts[:prefix] || RedisFeatureStore.default_prefix
5554
@logger = opts[:logger] || Config.default_logger
5655

57-
@expiration_seconds = opts[:expiration] || 15
58-
@capacity = opts[:capacity] || 1000
59-
# We're using Moneta only to provide expiration behavior for the in-memory cache.
60-
# Moneta can also be used as a wrapper for Redis, but it doesn't support the Redis
61-
# hash operations that we use.
62-
if @expiration_seconds > 0
63-
@cache = Moneta.new(:LRUHash, expires: true, threadsafe: true, max_count: @capacity)
56+
expiration_seconds = opts[:expiration] || 15
57+
capacity = opts[:capacity] || 1000
58+
if expiration_seconds > 0
59+
@cache = ExpiringCache.new(capacity, expiration_seconds)
6460
else
65-
@cache = Moneta.new(:Null) # a stub that caches nothing
61+
@cache = nil
6662
end
6763

6864
@stopped = Concurrent::AtomicBoolean.new(false)
@@ -92,7 +88,7 @@ def self.default_prefix
9288
end
9389

9490
def get(kind, key)
95-
f = @cache[cache_key(kind, key)]
91+
f = @cache.nil? ? nil : @cache[cache_key(kind, key)]
9692
if f.nil?
9793
@logger.debug { "RedisFeatureStore: no cache hit for #{key} in '#{kind[:namespace]}', requesting from Redis" }
9894
f = with_connection do |redis|
@@ -139,7 +135,7 @@ def delete(kind, key, version)
139135
end
140136

141137
def init(all_data)
142-
@cache.clear
138+
@cache.clear if !@cache.nil?
143139
count = 0
144140
with_connection do |redis|
145141
all_data.each do |kind, items|
@@ -174,7 +170,7 @@ def initialized?
174170
def stop
175171
if @stopped.make_true
176172
@pool.shutdown { |redis| redis.close }
177-
@cache.clear
173+
@cache.clear if !@cache.nil?
178174
end
179175
end
180176

@@ -213,7 +209,7 @@ def get_redis(kind, redis, key)
213209
end
214210

215211
def put_cache(kind, key, value)
216-
@cache.store(cache_key(kind, key), value, expires: @expiration_seconds)
212+
@cache[cache_key(kind, key)] = value if !@cache.nil?
217213
end
218214

219215
def update_with_versioning(kind, new_item)

spec/expiring_cache_spec.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
require 'timecop'
2+
3+
describe LaunchDarkly::ExpiringCache do
4+
subject { LaunchDarkly::ExpiringCache }
5+
6+
before(:each) do
7+
Timecop.freeze(Time.now)
8+
end
9+
10+
after(:each) do
11+
Timecop.return
12+
end
13+
14+
it "evicts entries based on TTL" do
15+
c = subject.new(3, 300)
16+
c[:a] = 1
17+
c[:b] = 2
18+
19+
Timecop.freeze(Time.now + 330)
20+
21+
c[:c] = 3
22+
23+
expect(c[:a]).to be nil
24+
expect(c[:b]).to be nil
25+
expect(c[:c]).to eq 3
26+
end
27+
28+
it "evicts entries based on max size" do
29+
c = subject.new(2, 300)
30+
c[:a] = 1
31+
c[:b] = 2
32+
c[:c] = 3
33+
34+
expect(c[:a]).to be nil
35+
expect(c[:b]).to eq 2
36+
expect(c[:c]).to eq 3
37+
end
38+
39+
it "does not reset LRU on get" do
40+
c = subject.new(2, 300)
41+
c[:a] = 1
42+
c[:b] = 2
43+
c[:a]
44+
c[:c] = 3
45+
46+
expect(c[:a]).to be nil
47+
expect(c[:b]).to eq 2
48+
expect(c[:c]).to eq 3
49+
end
50+
51+
it "resets LRU on put" do
52+
c = subject.new(2, 300)
53+
c[:a] = 1
54+
c[:b] = 2
55+
c[:a] = 1
56+
c[:c] = 3
57+
58+
expect(c[:a]).to eq 1
59+
expect(c[:b]).to be nil
60+
expect(c[:c]).to eq 3
61+
end
62+
63+
it "resets TTL on put" do
64+
c = subject.new(3, 300)
65+
c[:a] = 1
66+
c[:b] = 2
67+
68+
Timecop.freeze(Time.now + 330)
69+
c[:a] = 1
70+
c[:c] = 3
71+
72+
expect(c[:a]).to eq 1
73+
expect(c[:b]).to be nil
74+
expect(c[:c]).to eq 3
75+
end
76+
end

0 commit comments

Comments
 (0)