Skip to content

Commit 6c12bfd

Browse files
feat: add lru cache (optimizely#306)
* add lru cache * add min ruby version to readme
1 parent 92898cd commit 6c12bfd

File tree

3 files changed

+269
-0
lines changed

3 files changed

+269
-0
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Optimizely Rollouts is free feature flags for development teams. Easily roll out
1111

1212
## Getting Started
1313

14+
### Requirements
15+
* Ruby 2.7+
16+
1417
### Installing the SDK
1518

1619
The SDK is available through [RubyGems](https://rubygems.org/gems/optimizely-sdk). To install:

lib/optimizely/odp/lru_cache.rb

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
#
4+
# Copyright 2022, Optimizely and contributors
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
19+
module Optimizely
20+
class LRUCache
21+
# Least Recently Used cache that invalidates entries older than the timeout.
22+
23+
attr_reader :capacity, :timeout
24+
25+
def initialize(capacity, timeout_in_secs)
26+
# @param capacity - The max size of the cache. If set <= 0, caching is disabled.
27+
# @param timeout_in_secs - Seconds until a cache item is considered stale.
28+
# If set <= 0, items never expire.
29+
@cache_mutex = Mutex.new
30+
@map = {}
31+
@capacity = capacity
32+
@timeout = timeout_in_secs
33+
end
34+
35+
# Retrieve the non stale value from the cache corresponding to the provided key
36+
# or nil if key is not found
37+
# Moves the key/value pair to the end of the cache
38+
#
39+
# @param key - The key to retrieve
40+
41+
def lookup(key)
42+
return nil if @capacity <= 0
43+
44+
@cache_mutex.synchronize do
45+
return nil unless @map.include?(key)
46+
47+
element = @map.delete(key)
48+
return nil if element.stale?(@timeout)
49+
50+
@map[key] = element
51+
52+
element.value
53+
end
54+
end
55+
56+
# Save a key/value pair into the cache
57+
# Moves the key/value pair to the end of the cache
58+
#
59+
# @param key - A user key
60+
# @param value - A user value
61+
62+
def save(key, value)
63+
return if @capacity <= 0
64+
65+
@cache_mutex.synchronize do
66+
@map.delete(key) if @map.key?(key)
67+
68+
@map[key] = CacheElement.new(value)
69+
70+
@map.delete(@map.first[0]) if @map.size > @capacity
71+
nil
72+
end
73+
end
74+
75+
# Clears the cache
76+
77+
def reset
78+
return if @capacity <= 0
79+
80+
@cache_mutex.synchronize { @map.clear }
81+
nil
82+
end
83+
84+
# Retrieve a value from the cache for a given key or nil if key is not found
85+
# Doesn't update the cache
86+
#
87+
# @param key - The key to retrieve
88+
89+
def peek(key)
90+
return nil if @capacity <= 0
91+
92+
@cache_mutex.synchronize { @map[key]&.value }
93+
end
94+
end
95+
96+
class CacheElement
97+
# Individual element for the LRUCache.
98+
attr_reader :value, :timestamp
99+
100+
def initialize(value)
101+
@value = value
102+
@timestamp = Time.new
103+
end
104+
105+
def stale?(timeout)
106+
# Returns true if the provided timeout has passed since the element's timestamp.
107+
#
108+
# @param timeout - The duration to check against
109+
return false if timeout <= 0
110+
111+
Time.new - @timestamp >= timeout
112+
end
113+
end
114+
end

spec/odp/lru_cache_spec.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# frozen_string_literal: true
2+
3+
#
4+
# Copyright 2022, Optimizely and contributors
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
require 'optimizely/odp/lru_cache'
19+
20+
describe Optimizely::LRUCache do
21+
it 'should create a cache with min config' do
22+
cache = Optimizely::LRUCache.new(1000, 2000)
23+
expect(cache.capacity).to eq 1000
24+
expect(cache.timeout).to eq 2000
25+
26+
cache = Optimizely::LRUCache.new(0, 0)
27+
expect(cache.capacity).to eq 0
28+
expect(cache.timeout).to eq 0
29+
end
30+
31+
it 'should save and lookup correctly' do
32+
max_size = 2
33+
cache = Optimizely::LRUCache.new(max_size, 1000)
34+
35+
expect(cache.peek(1)).to be_nil
36+
cache.save(1, 100) # [1]
37+
cache.save(2, 200) # [1, 2]
38+
cache.save(3, 300) # [2, 3]
39+
expect(cache.peek(1)).to be_nil
40+
expect(cache.peek(2)).to be 200
41+
expect(cache.peek(3)).to be 300
42+
43+
cache.save(2, 201) # [3, 2]
44+
cache.save(1, 101) # [2, 1]
45+
expect(cache.peek(1)).to eq 101
46+
expect(cache.peek(2)).to eq 201
47+
expect(cache.peek(3)).to be_nil
48+
49+
expect(cache.lookup(3)).to be_nil # [2, 1]
50+
expect(cache.lookup(2)).to eq 201 # [1, 2]
51+
cache.save(3, 302) # [2, 3]
52+
expect(cache.peek(1)).to be_nil
53+
expect(cache.peek(2)).to eq 201
54+
expect(cache.peek(3)).to eq 302
55+
56+
expect(cache.lookup(3)).to eq 302 # [2, 3]
57+
cache.save(1, 103) # [3, 1]
58+
expect(cache.peek(1)).to eq 103
59+
expect(cache.peek(2)).to be_nil
60+
expect(cache.peek(3)).to eq 302
61+
62+
expect(cache.instance_variable_get('@map').size).to be max_size
63+
expect(cache.instance_variable_get('@map').size).to be cache.capacity
64+
end
65+
66+
it 'should disable cache with size zero' do
67+
cache = Optimizely::LRUCache.new(0, 1000)
68+
69+
expect(cache.lookup(1)).to be_nil
70+
cache.save(1, 100) # [1]
71+
expect(cache.lookup(1)).to be_nil
72+
end
73+
74+
it 'should disable with cache size less than zero' do
75+
cache = Optimizely::LRUCache.new(-2, 1000)
76+
77+
expect(cache.lookup(1)).to be_nil
78+
cache.save(1, 100) # [1]
79+
expect(cache.lookup(1)).to be_nil
80+
end
81+
82+
it 'should make elements stale after timeout' do
83+
max_timeout = 0.5
84+
85+
cache = Optimizely::LRUCache.new(1000, max_timeout)
86+
87+
cache.save(1, 100) # [1]
88+
cache.save(2, 200) # [1, 2]
89+
cache.save(3, 300) # [1, 2, 3]
90+
sleep(1.1) # wait to expire
91+
cache.save(4, 400) # [1, 2, 3, 4]
92+
cache.save(1, 101) # [2, 3, 4, 1]
93+
94+
expect(cache.lookup(1)).to eq 101 # [4, 1]
95+
expect(cache.lookup(2)).to be_nil
96+
expect(cache.lookup(3)).to be_nil
97+
expect(cache.lookup(4)).to eq 400
98+
end
99+
100+
it 'should make element stale after timeout even with lookup' do
101+
max_timeout = 1
102+
103+
cache = Optimizely::LRUCache.new(1000, max_timeout)
104+
105+
cache.save(1, 100)
106+
sleep(0.5)
107+
cache.lookup(1)
108+
sleep(0.5)
109+
expect(cache.lookup(1)).to be_nil
110+
end
111+
112+
it 'should not make elements stale when timeout is zero' do
113+
max_timeout = 0
114+
cache = Optimizely::LRUCache.new(1000, max_timeout)
115+
116+
cache.save(1, 100) # [1]
117+
cache.save(2, 200) # [1, 2]
118+
sleep(1) # wait to expire
119+
120+
expect(cache.lookup(1)).to eq 100
121+
expect(cache.lookup(2)).to eq 200
122+
end
123+
124+
it 'should not expire when timeout is less than zero' do
125+
max_timeout = -2
126+
cache = Optimizely::LRUCache.new(1000, max_timeout)
127+
128+
cache.save(1, 100) # [1]
129+
cache.save(2, 200) # [1, 2]
130+
sleep(1) # wait to expire
131+
132+
expect(cache.lookup(1)).to eq 100
133+
expect(cache.lookup(2)).to eq 200
134+
end
135+
136+
it 'should clear cache when reset is called' do
137+
cache = Optimizely::LRUCache.new(1000, 600)
138+
cache.save('wow', 'great')
139+
cache.save('tow', 'freight')
140+
141+
expect(cache.lookup('wow')).to eq 'great'
142+
expect(cache.instance_variable_get('@map').size).to eq 2
143+
144+
cache.reset
145+
146+
expect(cache.lookup('wow')).to be_nil
147+
expect(cache.instance_variable_get('@map').size).to eq 0
148+
149+
cache.save('cow', 'crate')
150+
expect(cache.lookup('cow')).to eq 'crate'
151+
end
152+
end

0 commit comments

Comments
 (0)