Skip to content

Commit f41ecd3

Browse files
Implement support for redis clusters
1 parent 591416f commit f41ecd3

File tree

5 files changed

+109
-9
lines changed

5 files changed

+109
-9
lines changed

CHANGES

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ Version 8.20 (Unreleased)
33
- Make BitBucket repositories enabled by default
44
- Add raw data toggle for Additional Data
55

6+
- Add initial support for Redis Cluster.
7+
- Support a list of hosts in the ``redis.clusters`` configuration along side
8+
the traditional dictionary style configuration.
9+
610
Schema Changes
711
~~~~~~~~~~~~~~
812
- Added index on ``ProjectPlatform(last_seen)`` column

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
'rb>=1.7.0,<2.0.0',
147147
'qrcode>=5.2.2,<6.0.0',
148148
'python-u2flib-server>=4.0.1,<4.1.0',
149+
'redis-py-cluster>=1.3.4,<1.4.0',
149150
]
150151

151152
saml_requires = [

src/sentry/data/config/config.yml.default

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,18 @@ system.secret-key: '%(secret_key)s'
3838
# The ``redis.clusters`` setting is used, unsurprisingly, to configure Redis
3939
# clusters. These clusters can be then referred to by name when configuring
4040
# backends such as the cache, digests, or TSDB backend.
41+
#
42+
# Two types of clusters are currently supported:
43+
#
44+
# rb.Cluster
45+
# A redis blaster cluster is the traditional cluster used by most services
46+
# within sentry. This is the default type cluster type.
47+
#
48+
# rediscluster.StrictRedisCluster
49+
# An official Redis Cluster can be configured by marking the named group with
50+
# the ``is_redis_cluster: True`` flag. In future versions of Sentry more
51+
# services will require this type of cluster.
52+
#
4153
redis.clusters:
4254
default:
4355
hosts:

src/sentry/utils/redis.py

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
from __future__ import absolute_import
22

33
import functools
4+
import logging
45
import posixpath
56
import six
67

78
from threading import Lock
89

910
import rb
11+
import rediscluster
1012
from pkg_resources import resource_string
1113
from redis.client import Script
1214
from redis.connection import ConnectionPool
@@ -17,6 +19,8 @@
1719
from sentry.utils.warnings import DeprecatedSettingWarning
1820
from sentry.utils.versioning import Version, check_versions
1921

22+
logger = logging.getLogger(__name__)
23+
2024
_pool_cache = {}
2125
_pool_lock = Lock()
2226

@@ -53,27 +57,76 @@ def make_rb_cluster(*args, **kwargs):
5357
return _make_rb_cluster(*args, **kwargs)
5458

5559

60+
class _RBCluster(object):
61+
def supports(self, config):
62+
return not config.get('is_redis_cluster', False)
63+
64+
def factory(self, **config):
65+
# rb expects a dict of { host, port } dicts where the key is the host
66+
# ID. Coerce the configuration into the correct format if necessary.
67+
hosts = config['hosts']
68+
hosts = {k: v for k, v in enumerate(hosts)} if isinstance(hosts, list) else hosts
69+
config['hosts'] = hosts
70+
71+
return _make_rb_cluster(**config)
72+
73+
def __str__(self):
74+
return 'Redis Blaster Cluster'
75+
76+
77+
class _RedisCluster(object):
78+
def supports(self, config):
79+
return config.get('is_redis_cluster', False)
80+
81+
def factory(self, **config):
82+
# StrictRedisCluster expects a list of { host, port } dicts. Coerce the
83+
# configuration into the correct format if necessary.
84+
hosts = config.get('hosts')
85+
hosts = hosts.values() if isinstance(hosts, dict) else hosts
86+
87+
# Redis cluster does not wait to attempt to connect, we don't want the
88+
# application to fail to boot because of this, raise a KeyError
89+
try:
90+
return rediscluster.StrictRedisCluster(startup_nodes=hosts, decode_responses=True)
91+
except rediscluster.exceptions.RedisClusterException:
92+
logger.warning('Failed to connect to Redis Cluster', exc_info=True)
93+
raise KeyError('Redis Cluster could not be initalized')
94+
95+
def __str__(self):
96+
return 'Redis Cluster'
97+
98+
5699
class ClusterManager(object):
57-
def __init__(self, options_manager):
100+
def __init__(self, options_manager, cluster_type=_RBCluster):
58101
self.__clusters = {}
59102
self.__options_manager = options_manager
103+
self.__cluster_type = cluster_type()
60104

61105
def get(self, key):
62106
cluster = self.__clusters.get(key)
63107

64-
if cluster is None:
65-
# TODO: This would probably be safer with a lock, but I'm not sure
66-
# that it's necessary.
67-
configuration = self.__options_manager.get('redis.clusters').get(key)
68-
if configuration is None:
69-
raise KeyError('Invalid cluster name: {}'.format(key))
108+
if cluster:
109+
return cluster
110+
111+
# TODO: This would probably be safer with a lock, but I'm not sure
112+
# that it's necessary.
113+
configuration = self.__options_manager.get('redis.clusters').get(key)
114+
if configuration is None:
115+
raise KeyError('Invalid cluster name: {}'.format(key))
116+
117+
if not self.__cluster_type.supports(configuration):
118+
raise KeyError('Invalid cluster type, expected: {}'.format(self.__cluster_type))
70119

71-
cluster = self.__clusters[key] = _make_rb_cluster(**configuration)
120+
cluster = self.__clusters[key] = self.__cluster_type.factory(**configuration)
72121

73122
return cluster
74123

75124

125+
# TODO(epurkhiser): When migration of all rb cluster to true redis clusters has
126+
# completed, remove the rb ``clusters`` module variable and rename
127+
# redis_clusters to clusters.
76128
clusters = ClusterManager(options.default_manager)
129+
redis_clusters = ClusterManager(options.default_manager, _RedisCluster)
77130

78131

79132
def get_cluster_from_options(setting, options, cluster_manager=clusters):

tests/sentry/utils/test_redis.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
from __future__ import absolute_import
22

33
import functools
4+
import logging
5+
import mock
46
import pytest
57

8+
from rediscluster.exceptions import RedisClusterException
9+
610
from sentry.exceptions import InvalidConfiguration
711
from sentry.testutils.cases import TestCase
8-
from sentry.utils.redis import (ClusterManager, _shared_pool, get_cluster_from_options)
12+
from sentry.utils.redis import (
13+
ClusterManager, _shared_pool, get_cluster_from_options, _RedisCluster, logger
14+
)
15+
16+
# Silence connection warnings
17+
logger.setLevel(logging.ERROR)
918

1019
make_manager = functools.partial(
1120
ClusterManager,
@@ -28,10 +37,18 @@
2837
},
2938
}
3039
},
40+
'baz': {
41+
'is_redis_cluster': True,
42+
'hosts': {
43+
0: {},
44+
},
45+
},
3146
},
3247
},
3348
)
3449

50+
redis_cluster_exception = RedisClusterException('Failed to connect')
51+
3552

3653
class ClusterManagerTestCase(TestCase):
3754
def test_get(self):
@@ -42,6 +59,19 @@ def test_get(self):
4259
with pytest.raises(KeyError):
4360
manager.get('invalid')
4461

62+
@mock.patch('rediscluster.StrictRedisCluster')
63+
def test_specific_cluster(self, cluster):
64+
manager = make_manager(cluster_type=_RedisCluster)
65+
assert manager.get('baz') is cluster.return_value
66+
with pytest.raises(KeyError):
67+
manager.get('foo')
68+
69+
@mock.patch('rediscluster.StrictRedisCluster', side_effect=redis_cluster_exception)
70+
def test_failed_redis_cluster(self, cluster):
71+
manager = make_manager(cluster_type=_RedisCluster)
72+
with pytest.raises(KeyError):
73+
manager.get('baz')
74+
4575

4676
def test_get_cluster_from_options():
4777
backend = object()

0 commit comments

Comments
 (0)