Skip to content

Commit 5c8350e

Browse files
committed
update cache trait to have both LRU and TTL logic
1 parent 436dd55 commit 5c8350e

File tree

2 files changed

+271
-30
lines changed

2 files changed

+271
-30
lines changed

splitio/storage/adapters/cache_trait.py

Lines changed: 158 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,57 +2,185 @@
22

33
import threading
44
import time
5+
from functools import update_wrapper
56

7+
import six
68

7-
class ElementExpiredException(Exception):
8-
"""Exception to be thrown when an element requested is present but expired."""
99

10-
pass
10+
DEFAULT_MAX_AGE = 5
11+
DEFAULT_MAX_SIZE = 100
1112

1213

13-
class ElementNotPresentException(Exception):
14-
"""Exception to be thrown when an element requested is not present."""
14+
class LocalMemoryCache(object): #pylint: disable=too-many-instance-attributes
15+
"""
16+
Key/Value local memory cache. with expiration & LRU eviction.
1517
16-
pass
18+
LRU double-linked-list format:
1719
20+
{
21+
'key1'---------------------------------------------------------------
22+
'key2'------------------------------------ |
23+
'key3'------------ | |
24+
} | | |
25+
V V V
26+
|| MRU || -previous-> || X || ... -previous-> || LRU || -previous-> None
27+
None <---next--- || node || <---next--- || node || ... <---next--- || node ||
28+
"""
1829

19-
class LocalMemoryCache(object):
20-
"""Key/Value local memory cache. with deprecation."""
30+
class _Node(object): #pylint: disable=too-few-public-methods
31+
"""Links to previous an next items in the circular list."""
2132

22-
def __init__(self, max_age_seconds=5):
33+
def __init__(self, key, value, last_update, previous_element, next_element): #pylint: disable=too-many-arguments
34+
"""Class constructor."""
35+
self.key = key # we also keep the key for O(1) access when removing the LRU.
36+
self.value = value
37+
self.last_update = last_update
38+
self.previous = previous_element
39+
self.next = next_element
40+
41+
def __str__(self):
42+
"""Return string representation."""
43+
return '(%s, %s)' % (self.key, self.value)
44+
45+
def __init__(
46+
self,
47+
key_func,
48+
user_func,
49+
max_age_seconds=DEFAULT_MAX_AGE,
50+
max_size=DEFAULT_MAX_SIZE
51+
):
2352
"""Class constructor."""
2453
self._data = {}
2554
self._lock = threading.RLock()
2655
self._max_age_seconds = max_age_seconds
56+
self._max_size = max_size
57+
self._lru = None
58+
self._mru = None
59+
self._key_func = key_func
60+
self._user_func = user_func
2761

28-
def set(self, key, value):
62+
def get(self, *args, **kwargs):
2963
"""
30-
Set a key/value pair.
64+
Fetch an item from the cache. If it's a miss, call user function to refill.
3165
32-
:param key: Key used to reference the value.
33-
:type key: str
34-
:param value: Value to store.
35-
:type value: object
66+
:param args: User supplied positional arguments
67+
:type args: list
68+
:param kwargs: User supplied keyword arguments
69+
:type kwargs: dict
70+
71+
:return: Cached/Fetched object
72+
:rtype: object
3673
"""
3774
with self._lock:
38-
self._data[key] = (value, time.time())
75+
key = self._key_func(*args, **kwargs)
76+
node = self._data.get(key)
77+
if node is not None:
78+
if self._is_expired(node):
79+
node.value = self._user_func(*args, **kwargs)
80+
node.last_update = time.time()
81+
else:
82+
value = self._user_func(*args, **kwargs)
83+
node = LocalMemoryCache._Node(key, value, time.time(), None, None)
84+
node = self._bubble_up(node)
85+
self._data[key] = node
86+
self._rollover()
87+
return node.value
88+
89+
def remove_expired(self):
90+
"""Remove expired elements."""
91+
with self._lock:
92+
self._data = {
93+
key: value for (key, value) in six.iteritems(self._data)
94+
if not self._is_expired(value)
95+
}
3996

40-
def get(self, key):
97+
def clear(self):
98+
"""Clear the cache."""
99+
self._data = {}
100+
self._lru = None
101+
self._mru = None
102+
103+
def _is_expired(self, node):
104+
"""Return whether the data held by the node is expired."""
105+
return time.time() - self._max_age_seconds > node.last_update
106+
107+
def _bubble_up(self, node):
108+
"""Send node to the top of the list (mark it as the MRU)."""
109+
if node is None:
110+
return None
111+
112+
if node.previous is not None:
113+
node.previous.next = node.next
114+
115+
if node.next is not None:
116+
node.next.previous = node.previous
117+
118+
if self._lru == node:
119+
if node.next is not None: #only update lru pointer if there are more than 1 elements.
120+
self._lru = node.next
121+
122+
if not self._data:
123+
# if there are no items, set the LRU to this node
124+
self._lru = node
125+
else:
126+
# if there is at least one item, update the MRU chain
127+
self._mru.next = node
128+
129+
node.next = None
130+
node.previous = self._mru
131+
self._mru = node
132+
return node
133+
134+
def _rollover(self):
135+
"""Check we're within the size limit. Otherwise drop the LRU."""
136+
if len(self._data) > self._max_size:
137+
next_item = self._lru.next
138+
del self._data[self._lru.key]
139+
self._lru = next_item
140+
141+
def __str__(self):
142+
"""User friendly representation of cache."""
143+
nodes = []
144+
node = self._mru
145+
while node is not None:
146+
nodes.append('<%s: %s> -->' % (node.key, node.value))
147+
node = node.previous
148+
return '<MRU>\n' + '\n'.join(nodes) + '\n<LRU>'
149+
150+
151+
def decorate(key_func, max_age_seconds=DEFAULT_MAX_AGE, max_size=DEFAULT_MAX_SIZE):
152+
"""
153+
Decorate a function or method to cache results up to `max_age_seconds`.
154+
155+
:param key_func: user specified function to execute over the arguments to determine the key.
156+
:type key_func: callable
157+
:param max_age_seconds: Maximum number of seconds during which the cached value is valid.
158+
:type max_age_seconds: int
159+
160+
:return: Decorating function wrapper.
161+
:rtype: callable
162+
"""
163+
if max_age_seconds < 0:
164+
raise TypeError('Max cache age cannot be a negative number.')
165+
166+
if max_size < 0:
167+
raise TypeError('Max cache size cannot be a negative number.')
168+
169+
if max_age_seconds == 0 or max_size == 0:
170+
return lambda function: function # bypass cache overlay.
171+
172+
def _decorator(user_function):
41173
"""
42-
Attempt to get a value based on a key.
174+
Decorate function to be used with `@` syntax.
43175
44-
:param key: Key associated with the value.
45-
:type key: str
176+
:param user_function: Function to decorate with cacheable results
177+
:type user_function: callable
46178
47-
:return: The value associated with the key. None if it doesn't exist.
48-
:rtype: object
179+
:return: A function that looks exactly the same but with cacheable results.
180+
:rtype: callable
49181
"""
50-
try:
51-
value, set_time = self._data[key]
52-
except KeyError:
53-
raise ElementNotPresentException('Element %s not present in local storage' % key)
54-
55-
if (time.time() - set_time) > self._max_age_seconds:
56-
raise ElementExpiredException('Element %s present but expired' % key)
182+
_cache = LocalMemoryCache(key_func, user_function, max_age_seconds, max_size)
183+
wrapper = _cache.get
184+
return update_wrapper(wrapper, user_function)
57185

58-
return value
186+
return _decorator
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Cache testing module."""
2+
#pylint: disable=protected-access,no-self-use,line-too-long
3+
import time
4+
5+
import pytest
6+
7+
from splitio.storage.adapters import cache_trait
8+
9+
class CacheTraitTests(object):
10+
"""Cache trait test cases."""
11+
12+
def test_lru_behaviour(self, mocker):
13+
"""Test LRU cache functionality."""
14+
user_func = mocker.Mock()
15+
user_func.side_effect = lambda *p, **kw: len(p[0])
16+
key_func = mocker.Mock()
17+
key_func.side_effect = lambda *p, **kw: p[0]
18+
cache = cache_trait.LocalMemoryCache(key_func, user_func, 1, 5)
19+
20+
# add one element & validate basic function calls
21+
assert cache.get('some') == 4
22+
assert key_func.mock_calls == [mocker.call('some')]
23+
assert user_func.mock_calls == [mocker.call('some')]
24+
25+
# validate _lru & _mru references are updated correctly
26+
assert cache._lru.key == 'some'
27+
assert cache._lru.value == 4
28+
assert cache._lru.next is None
29+
assert cache._lru.previous is None
30+
assert cache._mru.key == 'some'
31+
assert cache._mru.value == 4
32+
assert cache._mru.next is None
33+
assert cache._mru.previous is None
34+
35+
# add another element & validate references.
36+
assert cache.get('some_other') == 10
37+
assert cache._lru.key == 'some'
38+
assert cache._lru.value == 4
39+
assert cache._lru.next is cache._mru
40+
assert cache._lru.previous is None
41+
assert cache._mru.key == 'some_other'
42+
assert cache._mru.value == 10
43+
assert cache._mru.next is None
44+
assert cache._mru.previous is cache._lru
45+
46+
# add 3 more elements to reach max_size
47+
assert cache.get('another') == 7
48+
assert cache.get('some_another') == 12
49+
assert cache.get('yet_another') == 11
50+
assert len(cache._data) == 5
51+
assert cache._lru.key == 'some'
52+
53+
# add one more element to force LRU eviction and check structure integrity.
54+
assert cache.get('overrun') == 7
55+
assert 'some' not in cache._data
56+
assert len(cache._data) == 5
57+
assert cache._lru.key == 'some_other'
58+
59+
# `some_other` should be the next key to be evicted.
60+
# if we issue a get call for it, it should be marked as the MRU,
61+
# and `another` should become the new LRU.
62+
assert cache.get('some_other') == 10
63+
assert len(cache._data) == 5
64+
assert cache._mru.key == 'some_other'
65+
assert cache._lru.key == 'another'
66+
67+
def test_expiration_behaviour(self, mocker):
68+
"""Test time expiration works as expected."""
69+
user_func = mocker.Mock()
70+
k = 0
71+
user_func.side_effect = lambda *p, **kw: len(p[0]) + k
72+
key_func = mocker.Mock()
73+
key_func.side_effect = lambda *p, **kw: p[0]
74+
cache = cache_trait.LocalMemoryCache(key_func, user_func, 1, 5)
75+
76+
assert cache.get('key') == 3
77+
assert cache.get('other') == 5
78+
79+
k = 1
80+
assert cache.get('key') == 3 # cached key retains value until it's expired
81+
assert cache.get('other') == 5 # cached key retains value until it's expired
82+
assert cache.get('kkk') == 4 # non-cached key calls function anyway and gets new result.
83+
84+
time.sleep(1)
85+
assert cache.get('key') == 4
86+
assert cache.get('other') == 6
87+
88+
def test_decorate(self, mocker):
89+
"""Test decorator maker function."""
90+
local_memory_cache_mock = mocker.Mock(spec=cache_trait.LocalMemoryCache)
91+
returned_instance_mock = mocker.Mock(spec=cache_trait.LocalMemoryCache)
92+
local_memory_cache_mock.return_value = returned_instance_mock
93+
update_wrapper_mock = mocker.Mock()
94+
mocker.patch('splitio.storage.adapters.cache_trait.update_wrapper', new=update_wrapper_mock)
95+
mocker.patch('splitio.storage.adapters.cache_trait.LocalMemoryCache', new=local_memory_cache_mock)
96+
key_func = mocker.Mock()
97+
user_func = mocker.Mock()
98+
99+
cache_trait.decorate(key_func)(user_func)
100+
assert update_wrapper_mock.mock_calls == [mocker.call(returned_instance_mock.get, user_func)]
101+
assert local_memory_cache_mock.mock_calls == [
102+
mocker.call(key_func, user_func, cache_trait.DEFAULT_MAX_AGE, cache_trait.DEFAULT_MAX_SIZE)
103+
]
104+
105+
with pytest.raises(TypeError):
106+
cache_trait.decorate(key_func, -1)
107+
108+
with pytest.raises(TypeError):
109+
cache_trait.decorate(key_func, 10, -1)
110+
111+
assert cache_trait.decorate(key_func, 0, 10)(user_func) is user_func
112+
assert cache_trait.decorate(key_func, 10, 0)(user_func) is user_func
113+
assert cache_trait.decorate(key_func, 0, 0)(user_func) is user_func

0 commit comments

Comments
 (0)