Skip to content

Commit e994cb7

Browse files
authored
Merge pull request #181 from splitio/impression_observer
add impression observer
2 parents f2beb3c + dabd6bf commit e994cb7

File tree

6 files changed

+305
-1
lines changed

6 files changed

+305
-1
lines changed

splitio/engine/cache/__init__.py

Whitespace-only changes.

splitio/engine/cache/lru.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Simple test-and-set LRU Cache."""
2+
import threading
3+
4+
5+
DEFAULT_MAX_SIZE = 5000
6+
7+
8+
class SimpleLruCache(object): #pylint: disable=too-many-instance-attributes
9+
"""
10+
Key/Value local memory cache. with expiration & LRU eviction.
11+
12+
LRU double-linked-list format:
13+
14+
{
15+
'key1'---------------------------------------------------------------
16+
'key2'------------------------------------ |
17+
'key3'------------ | |
18+
} | | |
19+
V V V
20+
|| MRU || -previous-> || X || ... -previous-> || LRU || -previous-> None
21+
None <---next--- || node || <---next--- || node || ... <---next--- || node ||
22+
"""
23+
24+
class _Node(object): #pylint: disable=too-few-public-methods
25+
"""Links to previous an next items in the circular list."""
26+
27+
def __init__(self, key, value, previous_element, next_element):
28+
"""Class constructor."""
29+
self.key = key # we also keep the key for O(1) access when removing the LRU.
30+
self.value = value
31+
self.previous = previous_element
32+
self.next = next_element
33+
34+
def __str__(self):
35+
"""Return string representation."""
36+
return '(%s, %s)' % (self.key, self.value)
37+
38+
def __init__(self, max_size=DEFAULT_MAX_SIZE):
39+
"""Class constructor."""
40+
self._data = {}
41+
self._lock = threading.Lock()
42+
self._max_size = max_size
43+
self._lru = None
44+
self._mru = None
45+
46+
def test_and_set(self, key, value):
47+
"""
48+
Set an item in the cache and return the previous value.
49+
50+
:param key: object key
51+
:type args: object
52+
:param value: object value
53+
:type kwargs: object
54+
55+
:return: previous value if any. None otherwise
56+
:rtype: object
57+
"""
58+
with self._lock:
59+
node = self._data.get(key)
60+
to_return = node.value if node else None
61+
if node is None:
62+
node = SimpleLruCache._Node(key, value, None, None)
63+
node = self._bubble_up(node)
64+
self._data[key] = node
65+
self._rollover()
66+
return to_return
67+
68+
def clear(self):
69+
"""Clear the cache."""
70+
self._data = {}
71+
self._lru = None
72+
self._mru = None
73+
74+
def _bubble_up(self, node):
75+
"""Send node to the top of the list (mark it as the MRU)."""
76+
if node is None:
77+
return None
78+
79+
# First item, just set lru & mru
80+
if not self._data:
81+
self._lru = node
82+
self._mru = node
83+
return node
84+
85+
# MRU, just return it
86+
if node is self._mru:
87+
return node
88+
89+
# LRU, update pointer and end-of-list
90+
if node is self._lru:
91+
self._lru = node.next
92+
self._lru.previous = None
93+
94+
if node.previous is not None:
95+
node.previous.next = node.next
96+
if node.next is not None:
97+
node.next.previous = node.previous
98+
99+
node.previous = self._mru
100+
node.previous.next = node
101+
node.next = None
102+
self._mru = node
103+
104+
return node
105+
106+
def _rollover(self):
107+
"""Check we're within the size limit. Otherwise drop the LRU."""
108+
if len(self._data) > self._max_size:
109+
next_item = self._lru.next
110+
del self._data[self._lru.key]
111+
self._lru = next_item
112+
self._lru.previous = None
113+
114+
def __str__(self):
115+
"""User friendly representation of cache."""
116+
nodes = []
117+
node = self._mru
118+
while node is not None:
119+
nodes.append('\t<%s: %s> -->' % (node.key, node.value))
120+
node = node.previous
121+
return '<MRU>\n' + '\n'.join(nodes) + '\n<LRU>'

splitio/engine/impmanager.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Split evaluator module."""
2+
import logging
3+
4+
from splitio.models.impressions import Impression
5+
from splitio.engine.hashfns import murmur_128
6+
from splitio.engine.cache.lru import SimpleLruCache
7+
8+
9+
class Hasher(object):
10+
"""Impression hasher."""
11+
12+
_PATTERN = "%s:%s:%s:%s:%d"
13+
14+
def __init__(self, hash_fn=murmur_128, seed=0):
15+
"""
16+
Class constructor.
17+
18+
:param hash_fn: Hash function to apply (str, int) -> int
19+
:type hash_fn: callable
20+
21+
:param seed: seed to be provided when hashing
22+
:type seed: int
23+
"""
24+
self._hash_fn = hash_fn
25+
self._seed = seed
26+
27+
def _stringify(self, impression):
28+
"""
29+
Stringify an impression.
30+
31+
:param impression: Impression to stringify using _PATTERN
32+
:type impression: splitio.models.impressions.Impression
33+
34+
:returns: a string representation of the impression
35+
:rtype: str
36+
"""
37+
return self._PATTERN % (impression.matching_key,
38+
impression.feature_name,
39+
impression.treatment,
40+
impression.label,
41+
impression.change_number)
42+
43+
def process(self, impression):
44+
"""
45+
Hash an impression.
46+
47+
:param impression: Impression to hash.
48+
:type impression: splitio.models.impressions.Impression
49+
50+
:returns: a hash of the supplied impression's relevant fields.
51+
:rtype: int
52+
"""
53+
return self._hash_fn(self._stringify(impression), self._seed)
54+
55+
56+
class Observer(object):
57+
"""Observe impression and add a previous time if applicable."""
58+
59+
def __init__(self, size):
60+
"""Class constructor."""
61+
self._hasher = Hasher()
62+
self._cache = SimpleLruCache(size)
63+
64+
def test_and_set(self, impression):
65+
"""
66+
Examine an impression to determine and set it's previous time accordingly.
67+
68+
:param impression: Impression to track
69+
:type impression: splitio.models.impressions.Impression
70+
71+
:returns: Impression with populated previous time
72+
:rtype: splitio.models.impressions.Impression
73+
"""
74+
previous_time = self._cache.test_and_set(self._hasher.process(impression), impression.time)
75+
return Impression(impression.matching_key,
76+
impression.feature_name,
77+
impression.treatment,
78+
impression.label,
79+
impression.change_number,
80+
impression.bucketing_key,
81+
impression.time,
82+
previous_time)
83+
84+
85+
class Manager(object):
86+
"""Impression manager."""
87+
88+
#TODO: implement
89+
pass

splitio/models/impressions.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@
1414
'label',
1515
'change_number',
1616
'bucketing_key',
17-
'time'
17+
'time',
18+
'previous_time'
1819
]
1920
)
2021

22+
# pre-python3.7 hack to make previous_time optional
23+
Impression.__new__.__defaults__ = (None,)
24+
2125

2226
class Label(object): # pylint: disable=too-few-public-methods
2327
"""Impressions labels."""

tests/engine/cache/test_lru.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""LRU Cache unit tests."""
2+
3+
from splitio.engine.cache.lru import SimpleLruCache
4+
5+
class SimpleLruCacheTests(object):
6+
"""Test SimpleLruCache."""
7+
8+
def test_basic_usage(self, mocker):
9+
"""Test that a missing split logs and returns CONTROL."""
10+
cache = SimpleLruCache(5)
11+
assert cache.test_and_set('a', 1) is None
12+
assert cache.test_and_set('b', 2) is None
13+
assert cache.test_and_set('c', 3) is None
14+
assert cache.test_and_set('d', 4) is None
15+
assert cache.test_and_set('e', 5) is None
16+
17+
assert cache.test_and_set('a', 10) is 1
18+
assert cache.test_and_set('b', 20) is 2
19+
assert cache.test_and_set('c', 30) is 3
20+
assert cache.test_and_set('d', 40) is 4
21+
assert cache.test_and_set('e', 50) is 5
22+
assert len(cache._data) is 5
23+
24+
def test_lru_eviction(self, mocker):
25+
"""Test that a missing split logs and returns CONTROL."""
26+
cache = SimpleLruCache(5)
27+
assert cache.test_and_set('a', 1) is None
28+
assert cache.test_and_set('b', 2) is None
29+
assert cache.test_and_set('c', 3) is None
30+
assert cache.test_and_set('d', 4) is None
31+
assert cache.test_and_set('e', 5) is None
32+
assert cache.test_and_set('f', 6) is None
33+
assert cache.test_and_set('g', 7) is None
34+
assert cache.test_and_set('h', 8) is None
35+
assert cache.test_and_set('i', 9) is None
36+
assert cache.test_and_set('j', 0) is None
37+
assert len(cache._data) is 5
38+
assert set(cache._data.keys()) == set(['f', 'g', 'h', 'i', 'j'])

tests/engine/test_impmanager.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Impression manager, observer & hasher tests."""
2+
3+
from splitio.engine.impmanager import Hasher, Observer, Manager
4+
from splitio.models.impressions import Impression
5+
6+
7+
class ImpressionHasherTests(object):
8+
"""Test ImpressionHasher behavior."""
9+
10+
def test_changes_are_reflected(self):
11+
"""Test that change in any field changes the resulting hash."""
12+
total = set()
13+
hasher = Hasher()
14+
total.add(hasher.process(Impression('key1', 'feature1', 'on', 'killed', 123, None, 456)))
15+
total.add(hasher.process(Impression('key2', 'feature1', 'on', 'killed', 123, None, 456)))
16+
total.add(hasher.process(Impression('key1', 'feature2', 'on', 'killed', 123, None, 456)))
17+
total.add(hasher.process(Impression('key1', 'feature1', 'off', 'killed', 123, None, 456)))
18+
total.add(hasher.process(Impression('key1', 'feature1', 'on', 'not killed', 123, None, 456)))
19+
total.add(hasher.process(Impression('key1', 'feature1', 'on', 'killed', 321, None, 456)))
20+
assert len(total) == 6
21+
22+
# Re-adding the first-one should not increase the number of different hashes
23+
total.add(hasher.process(Impression('key1', 'feature1', 'on', 'killed', 123, None, 456)))
24+
assert len(total) == 6
25+
26+
27+
class ImpressionObserverTests(object):
28+
"""Test impression observer behaviour."""
29+
30+
def test_previous_time_properly_calculated(self):
31+
"""Test that the previous time is properly set."""
32+
observer = Observer(5)
33+
assert (observer.test_and_set(Impression('key1', 'f1', 'on', 'killed', 123, None, 456))
34+
== Impression('key1', 'f1', 'on', 'killed', 123, None, 456))
35+
assert (observer.test_and_set(Impression('key1', 'f1', 'on', 'killed', 123, None, 457))
36+
== Impression('key1', 'f1', 'on', 'killed', 123, None, 457, 456))
37+
38+
# Add 5 new impressions to evict the first one and check that previous time is None again
39+
assert (observer.test_and_set(Impression('key2', 'f1', 'on', 'killed', 123, None, 456))
40+
== Impression('key2', 'f1', 'on', 'killed', 123, None, 456))
41+
assert (observer.test_and_set(Impression('key3', 'f1', 'on', 'killed', 123, None, 456))
42+
== Impression('key3', 'f1', 'on', 'killed', 123, None, 456))
43+
assert (observer.test_and_set(Impression('key4', 'f1', 'on', 'killed', 123, None, 456))
44+
== Impression('key4', 'f1', 'on', 'killed', 123, None, 456))
45+
assert (observer.test_and_set(Impression('key5', 'f1', 'on', 'killed', 123, None, 456))
46+
== Impression('key5', 'f1', 'on', 'killed', 123, None, 456))
47+
assert (observer.test_and_set(Impression('key6', 'f1', 'on', 'killed', 123, None, 456))
48+
== Impression('key6', 'f1', 'on', 'killed', 123, None, 456))
49+
50+
# Re-process the first-one
51+
assert (observer.test_and_set(Impression('key1', 'f1', 'on', 'killed', 123, None, 456))
52+
== Impression('key1', 'f1', 'on', 'killed', 123, None, 456))

0 commit comments

Comments
 (0)