PageRenderTime 586ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 0ms

/src/sentry/utils/redis.py

https://github.com/pombredanne/django-sentry
Python | 242 lines | 162 code | 48 blank | 32 comment | 29 complexity | 2ed1023832a9f5a50ee7c925f994387c MD5 | raw file
  1. from __future__ import absolute_import
  2. import functools
  3. import logging
  4. import posixpath
  5. import six
  6. from threading import Lock
  7. import rb
  8. from django.utils.functional import SimpleLazyObject
  9. from pkg_resources import resource_string
  10. from redis.client import Script, StrictRedis
  11. from redis.connection import ConnectionPool
  12. from redis.exceptions import ConnectionError, BusyLoadingError
  13. from rediscluster import StrictRedisCluster
  14. from sentry import options
  15. from sentry.exceptions import InvalidConfiguration
  16. from sentry.utils import warnings
  17. from sentry.utils.warnings import DeprecatedSettingWarning
  18. from sentry.utils.versioning import Version, check_versions
  19. logger = logging.getLogger(__name__)
  20. _pool_cache = {}
  21. _pool_lock = Lock()
  22. def _shared_pool(**opts):
  23. if "host" in opts:
  24. key = "%s:%s/%s" % (opts["host"], opts["port"], opts["db"])
  25. else:
  26. key = "%s/%s" % (opts["path"], opts["db"])
  27. pool = _pool_cache.get(key)
  28. if pool is not None:
  29. return pool
  30. with _pool_lock:
  31. pool = _pool_cache.get(key)
  32. if pool is not None:
  33. return pool
  34. pool = ConnectionPool(**opts)
  35. _pool_cache[key] = pool
  36. return pool
  37. _make_rb_cluster = functools.partial(rb.Cluster, pool_cls=_shared_pool)
  38. def make_rb_cluster(*args, **kwargs):
  39. # This uses the standard library `warnings`, since this is provided for
  40. # plugin compatibility but isn't actionable by the system administrator.
  41. import warnings
  42. warnings.warn(
  43. "Direct Redis cluster construction is deprecated, please use named clusters. "
  44. "Direct cluster construction will be removed in Sentry 8.5.",
  45. DeprecationWarning,
  46. )
  47. return _make_rb_cluster(*args, **kwargs)
  48. class _RBCluster(object):
  49. def supports(self, config):
  50. return not config.get("is_redis_cluster", False)
  51. def factory(self, **config):
  52. # rb expects a dict of { host, port } dicts where the key is the host
  53. # ID. Coerce the configuration into the correct format if necessary.
  54. hosts = config["hosts"]
  55. hosts = {k: v for k, v in enumerate(hosts)} if isinstance(hosts, list) else hosts
  56. config["hosts"] = hosts
  57. return _make_rb_cluster(**config)
  58. def __str__(self):
  59. return "Redis Blaster Cluster"
  60. class RetryingStrictRedisCluster(StrictRedisCluster):
  61. """
  62. Execute a command with cluster reinitialization retry logic.
  63. Should a cluster respond with a ConnectionError or BusyLoadingError the
  64. cluster nodes list will be reinitialized and the command will be executed
  65. again with the most up to date view of the world.
  66. """
  67. def execute_command(self, *args, **kwargs):
  68. try:
  69. return super(self.__class__, self).execute_command(*args, **kwargs)
  70. except (
  71. ConnectionError,
  72. BusyLoadingError,
  73. KeyError, # see: https://github.com/Grokzen/redis-py-cluster/issues/287
  74. ):
  75. self.connection_pool.nodes.reset()
  76. return super(self.__class__, self).execute_command(*args, **kwargs)
  77. class _RedisCluster(object):
  78. def supports(self, config):
  79. # _RedisCluster supports two configurations:
  80. # * Explicitly configured with is_redis_cluster. This mode is for real redis-cluster.
  81. # * No is_redis_cluster, but only 1 host. This represents a singular node Redis running
  82. # in non-cluster mode.
  83. return config.get("is_redis_cluster", False) or len(config.get("hosts")) == 1
  84. def factory(self, **config):
  85. # StrictRedisCluster expects a list of { host, port } dicts. Coerce the
  86. # configuration into the correct format if necessary.
  87. hosts = config.get("hosts")
  88. hosts = hosts.values() if isinstance(hosts, dict) else hosts
  89. # Redis cluster does not wait to attempt to connect. We'd prefer to not
  90. # make TCP connections on boot. Wrap the client in a lazy proxy object.
  91. def cluster_factory():
  92. if config.get("is_redis_cluster", False):
  93. return RetryingStrictRedisCluster(
  94. startup_nodes=hosts, decode_responses=True, skip_full_coverage_check=True
  95. )
  96. else:
  97. host = hosts[0].copy()
  98. host["decode_responses"] = True
  99. return StrictRedis(**host)
  100. return SimpleLazyObject(cluster_factory)
  101. def __str__(self):
  102. return "Redis Cluster"
  103. class ClusterManager(object):
  104. def __init__(self, options_manager, cluster_type=_RBCluster):
  105. self.__clusters = {}
  106. self.__options_manager = options_manager
  107. self.__cluster_type = cluster_type()
  108. def get(self, key):
  109. cluster = self.__clusters.get(key)
  110. if cluster:
  111. return cluster
  112. # TODO: This would probably be safer with a lock, but I'm not sure
  113. # that it's necessary.
  114. configuration = self.__options_manager.get("redis.clusters").get(key)
  115. if configuration is None:
  116. raise KeyError(u"Invalid cluster name: {}".format(key))
  117. if not self.__cluster_type.supports(configuration):
  118. raise KeyError(u"Invalid cluster type, expected: {}".format(self.__cluster_type))
  119. cluster = self.__clusters[key] = self.__cluster_type.factory(**configuration)
  120. return cluster
  121. # TODO(epurkhiser): When migration of all rb cluster to true redis clusters has
  122. # completed, remove the rb ``clusters`` module variable and rename
  123. # redis_clusters to clusters.
  124. clusters = ClusterManager(options.default_manager)
  125. redis_clusters = ClusterManager(options.default_manager, _RedisCluster)
  126. def get_cluster_from_options(setting, options, cluster_manager=clusters):
  127. cluster_option_name = "cluster"
  128. default_cluster_name = "default"
  129. cluster_constructor_option_names = frozenset(("hosts",))
  130. options = options.copy()
  131. cluster_options = {
  132. key: options.pop(key)
  133. for key in set(options.keys()).intersection(cluster_constructor_option_names)
  134. }
  135. if cluster_options:
  136. if cluster_option_name in options:
  137. raise InvalidConfiguration(
  138. u"Cannot provide both named cluster ({!r}) and cluster configuration ({}) options.".format(
  139. cluster_option_name, ", ".join(map(repr, cluster_constructor_option_names))
  140. )
  141. )
  142. else:
  143. warnings.warn(
  144. DeprecatedSettingWarning(
  145. u"{} parameter of {}".format(
  146. ", ".join(map(repr, cluster_constructor_option_names)), setting
  147. ),
  148. u'{}["{}"]'.format(setting, cluster_option_name),
  149. removed_in_version="8.5",
  150. ),
  151. stacklevel=2,
  152. )
  153. cluster = rb.Cluster(pool_cls=_shared_pool, **cluster_options)
  154. else:
  155. cluster = cluster_manager.get(options.pop(cluster_option_name, default_cluster_name))
  156. return cluster, options
  157. def check_cluster_versions(cluster, required, recommended=None, label=None):
  158. try:
  159. with cluster.all() as client:
  160. results = client.info()
  161. except Exception as e:
  162. # Any connection issues should be caught here.
  163. raise InvalidConfiguration(six.text_type(e))
  164. versions = {}
  165. for id, info in results.value.items():
  166. host = cluster.hosts[id]
  167. # NOTE: This assumes there is no routing magic going on here, and
  168. # all requests to this host are being served by the same database.
  169. key = u"{host}:{port}".format(host=host.host, port=host.port)
  170. versions[key] = Version(map(int, info["redis_version"].split(".", 3)))
  171. check_versions(
  172. "Redis" if label is None else "Redis (%s)" % (label,), versions, required, recommended
  173. )
  174. def load_script(path):
  175. script = Script(None, resource_string("sentry", posixpath.join("scripts", path)))
  176. # This changes the argument order of the ``Script.__call__`` method to
  177. # encourage using the script with a specific Redis client, rather
  178. # than implicitly using the first client that the script was registered
  179. # with. (This can prevent lots of bizarre behavior when dealing with
  180. # clusters of Redis servers.)
  181. def call_script(client, keys, args):
  182. u"""
  183. Executes {!r} as a Lua script on a Redis server.
  184. Takes the client to execute the script on as the first argument,
  185. followed by the values that will be provided as ``KEYS`` and ``ARGV``
  186. to the script as two sequence arguments.
  187. """.format(
  188. path
  189. )
  190. return script(keys, args, client)
  191. return call_script