No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 
 
 

457 líneas
15 KiB

  1. # Copyright 2020 The Matrix.org Foundation C.I.C.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import logging
  15. from inspect import isawaitable
  16. from typing import TYPE_CHECKING, Any, Generic, List, Optional, Type, TypeVar, cast
  17. import attr
  18. from txredisapi import (
  19. ConnectionHandler,
  20. RedisFactory,
  21. SubscriberProtocol,
  22. UnixConnectionHandler,
  23. )
  24. from zope.interface import implementer
  25. from twisted.internet.address import IPv4Address, IPv6Address
  26. from twisted.internet.interfaces import IAddress, IConnector
  27. from twisted.python.failure import Failure
  28. from synapse.logging.context import PreserveLoggingContext, make_deferred_yieldable
  29. from synapse.metrics.background_process_metrics import (
  30. BackgroundProcessLoggingContext,
  31. run_as_background_process,
  32. wrap_as_background_process,
  33. )
  34. from synapse.replication.tcp.commands import (
  35. Command,
  36. ReplicateCommand,
  37. parse_command_from_line,
  38. )
  39. from synapse.replication.tcp.context import ClientContextFactory
  40. from synapse.replication.tcp.protocol import (
  41. IReplicationConnection,
  42. tcp_inbound_commands_counter,
  43. tcp_outbound_commands_counter,
  44. )
  45. if TYPE_CHECKING:
  46. from synapse.replication.tcp.handler import ReplicationCommandHandler
  47. from synapse.server import HomeServer
  48. logger = logging.getLogger(__name__)
  49. T = TypeVar("T")
  50. V = TypeVar("V")
  51. @attr.s
  52. class ConstantProperty(Generic[T, V]):
  53. """A descriptor that returns the given constant, ignoring attempts to set
  54. it.
  55. """
  56. constant: V = attr.ib()
  57. def __get__(self, obj: Optional[T], objtype: Optional[Type[T]] = None) -> V:
  58. return self.constant
  59. def __set__(self, obj: Optional[T], value: V) -> None:
  60. pass
  61. @implementer(IReplicationConnection)
  62. class RedisSubscriber(SubscriberProtocol):
  63. """Connection to redis subscribed to replication stream.
  64. This class fulfils two functions:
  65. (a) it implements the twisted Protocol API, where it handles the SUBSCRIBEd redis
  66. connection, parsing *incoming* messages into replication commands, and passing them
  67. to `ReplicationCommandHandler`
  68. (b) it implements the IReplicationConnection API, where it sends *outgoing* commands
  69. onto outbound_redis_connection.
  70. Due to the vagaries of `txredisapi` we don't want to have a custom
  71. constructor, so instead we expect the defined attributes below to be set
  72. immediately after initialisation.
  73. Attributes:
  74. synapse_handler: The command handler to handle incoming commands.
  75. synapse_stream_prefix: The *redis* stream name to subscribe to and publish
  76. from (not anything to do with Synapse replication streams).
  77. synapse_outbound_redis_connection: The connection to redis to use to send
  78. commands.
  79. """
  80. synapse_handler: "ReplicationCommandHandler"
  81. synapse_stream_prefix: str
  82. synapse_channel_names: List[str]
  83. synapse_outbound_redis_connection: ConnectionHandler
  84. def __init__(self, *args: Any, **kwargs: Any):
  85. super().__init__(*args, **kwargs)
  86. # a logcontext which we use for processing incoming commands. We declare it as a
  87. # background process so that the CPU stats get reported to prometheus.
  88. with PreserveLoggingContext():
  89. # thanks to `PreserveLoggingContext()`, the new logcontext is guaranteed to
  90. # capture the sentinel context as its containing context and won't prevent
  91. # GC of / unintentionally reactivate what would be the current context.
  92. self._logging_context = BackgroundProcessLoggingContext(
  93. "replication_command_handler"
  94. )
  95. def connectionMade(self) -> None:
  96. logger.info("Connected to redis")
  97. super().connectionMade()
  98. run_as_background_process("subscribe-replication", self._send_subscribe)
  99. async def _send_subscribe(self) -> None:
  100. # it's important to make sure that we only send the REPLICATE command once we
  101. # have successfully subscribed to the stream - otherwise we might miss the
  102. # POSITION response sent back by the other end.
  103. fully_qualified_stream_names = [
  104. f"{self.synapse_stream_prefix}/{stream_suffix}"
  105. for stream_suffix in self.synapse_channel_names
  106. ] + [self.synapse_stream_prefix]
  107. logger.info("Sending redis SUBSCRIBE for %r", fully_qualified_stream_names)
  108. await make_deferred_yieldable(self.subscribe(fully_qualified_stream_names))
  109. logger.info(
  110. "Successfully subscribed to redis stream, sending REPLICATE command"
  111. )
  112. self.synapse_handler.new_connection(self)
  113. await self._async_send_command(ReplicateCommand())
  114. logger.info("REPLICATE successfully sent")
  115. # We send out our positions when there is a new connection in case the
  116. # other side missed updates. We do this for Redis connections as the
  117. # otherside won't know we've connected and so won't issue a REPLICATE.
  118. self.synapse_handler.send_positions_to_connection(self)
  119. def messageReceived(self, pattern: str, channel: str, message: str) -> None:
  120. """Received a message from redis."""
  121. with PreserveLoggingContext(self._logging_context):
  122. self._parse_and_dispatch_message(message)
  123. def _parse_and_dispatch_message(self, message: str) -> None:
  124. if message.strip() == "":
  125. # Ignore blank lines
  126. return
  127. try:
  128. cmd = parse_command_from_line(message)
  129. except Exception:
  130. logger.exception(
  131. "Failed to parse replication line: %r",
  132. message,
  133. )
  134. return
  135. # We use "redis" as the name here as we don't have 1:1 connections to
  136. # remote instances.
  137. tcp_inbound_commands_counter.labels(cmd.NAME, "redis").inc()
  138. self.handle_command(cmd)
  139. def handle_command(self, cmd: Command) -> None:
  140. """Handle a command we have received over the replication stream.
  141. Delegates to `self.handler.on_<COMMAND>` (which can optionally return an
  142. Awaitable).
  143. Args:
  144. cmd: received command
  145. """
  146. cmd_func = getattr(self.synapse_handler, "on_%s" % (cmd.NAME,), None)
  147. if not cmd_func:
  148. logger.warning("Unhandled command: %r", cmd)
  149. return
  150. res = cmd_func(self, cmd)
  151. # the handler might be a coroutine: fire it off as a background process
  152. # if so.
  153. if isawaitable(res):
  154. run_as_background_process(
  155. "replication-" + cmd.get_logcontext_id(), lambda: res
  156. )
  157. def connectionLost(self, reason: Failure) -> None: # type: ignore[override]
  158. logger.info("Lost connection to redis")
  159. super().connectionLost(reason)
  160. self.synapse_handler.lost_connection(self)
  161. # mark the logging context as finished by triggering `__exit__()`
  162. with PreserveLoggingContext():
  163. with self._logging_context:
  164. pass
  165. # the sentinel context is now active, which may not be correct.
  166. # PreserveLoggingContext() will restore the correct logging context.
  167. def send_command(self, cmd: Command) -> None:
  168. """Send a command if connection has been established.
  169. Args:
  170. cmd: The command to send
  171. """
  172. run_as_background_process(
  173. "send-cmd", self._async_send_command, cmd, bg_start_span=False
  174. )
  175. async def _async_send_command(self, cmd: Command) -> None:
  176. """Encode a replication command and send it over our outbound connection"""
  177. string = "%s %s" % (cmd.NAME, cmd.to_line())
  178. if "\n" in string:
  179. raise Exception("Unexpected newline in command: %r", string)
  180. encoded_string = string.encode("utf-8")
  181. # We use "redis" as the name here as we don't have 1:1 connections to
  182. # remote instances.
  183. tcp_outbound_commands_counter.labels(cmd.NAME, "redis").inc()
  184. channel_name = cmd.redis_channel_name(self.synapse_stream_prefix)
  185. await make_deferred_yieldable(
  186. self.synapse_outbound_redis_connection.publish(channel_name, encoded_string)
  187. )
  188. class SynapseRedisFactory(RedisFactory):
  189. """A subclass of RedisFactory that periodically sends pings to ensure that
  190. we detect dead connections.
  191. """
  192. # We want to *always* retry connecting, txredisapi will stop if there is a
  193. # failure during certain operations, e.g. during AUTH.
  194. continueTrying = cast(bool, ConstantProperty(True))
  195. def __init__(
  196. self,
  197. hs: "HomeServer",
  198. uuid: str,
  199. dbid: Optional[int],
  200. poolsize: int,
  201. isLazy: bool = False,
  202. handler: Type = ConnectionHandler,
  203. charset: str = "utf-8",
  204. password: Optional[str] = None,
  205. replyTimeout: int = 30,
  206. convertNumbers: Optional[int] = True,
  207. ):
  208. super().__init__(
  209. uuid=uuid,
  210. dbid=dbid,
  211. poolsize=poolsize,
  212. isLazy=isLazy,
  213. handler=handler,
  214. charset=charset,
  215. password=password,
  216. replyTimeout=replyTimeout,
  217. convertNumbers=convertNumbers,
  218. )
  219. hs.get_clock().looping_call(self._send_ping, 30 * 1000)
  220. @wrap_as_background_process("redis_ping")
  221. async def _send_ping(self) -> None:
  222. for connection in self.pool:
  223. try:
  224. await make_deferred_yieldable(connection.ping())
  225. except Exception:
  226. logger.warning("Failed to send ping to a redis connection")
  227. # ReconnectingClientFactory has some logging (if you enable `self.noisy`), but
  228. # it's rubbish. We add our own here.
  229. def startedConnecting(self, connector: IConnector) -> None:
  230. logger.info(
  231. "Connecting to redis server %s", format_address(connector.getDestination())
  232. )
  233. super().startedConnecting(connector)
  234. def clientConnectionFailed(self, connector: IConnector, reason: Failure) -> None:
  235. logger.info(
  236. "Connection to redis server %s failed: %s",
  237. format_address(connector.getDestination()),
  238. reason.value,
  239. )
  240. super().clientConnectionFailed(connector, reason)
  241. def clientConnectionLost(self, connector: IConnector, reason: Failure) -> None:
  242. logger.info(
  243. "Connection to redis server %s lost: %s",
  244. format_address(connector.getDestination()),
  245. reason.value,
  246. )
  247. super().clientConnectionLost(connector, reason)
  248. def format_address(address: IAddress) -> str:
  249. if isinstance(address, (IPv4Address, IPv6Address)):
  250. return "%s:%i" % (address.host, address.port)
  251. return str(address)
  252. class RedisDirectTcpReplicationClientFactory(SynapseRedisFactory):
  253. """This is a reconnecting factory that connects to redis and immediately
  254. subscribes to some streams.
  255. Args:
  256. hs
  257. outbound_redis_connection: A connection to redis that will be used to
  258. send outbound commands (this is separate to the redis connection
  259. used to subscribe).
  260. channel_names: A list of channel names to append to the base channel name
  261. to additionally subscribe to.
  262. e.g. if ['ABC', 'DEF'] is specified then we'll listen to:
  263. example.com; example.com/ABC; and example.com/DEF.
  264. """
  265. maxDelay = 5
  266. protocol = RedisSubscriber
  267. def __init__(
  268. self,
  269. hs: "HomeServer",
  270. outbound_redis_connection: ConnectionHandler,
  271. channel_names: List[str],
  272. ):
  273. super().__init__(
  274. hs,
  275. uuid="subscriber",
  276. dbid=None,
  277. poolsize=1,
  278. replyTimeout=30,
  279. password=hs.config.redis.redis_password,
  280. )
  281. self.synapse_handler = hs.get_replication_command_handler()
  282. self.synapse_stream_prefix = hs.hostname
  283. self.synapse_channel_names = channel_names
  284. self.synapse_outbound_redis_connection = outbound_redis_connection
  285. def buildProtocol(self, addr: IAddress) -> RedisSubscriber:
  286. p = super().buildProtocol(addr)
  287. p = cast(RedisSubscriber, p)
  288. # We do this here rather than add to the constructor of `RedisSubcriber`
  289. # as to do so would involve overriding `buildProtocol` entirely, however
  290. # the base method does some other things than just instantiating the
  291. # protocol.
  292. p.synapse_handler = self.synapse_handler
  293. p.synapse_outbound_redis_connection = self.synapse_outbound_redis_connection
  294. p.synapse_stream_prefix = self.synapse_stream_prefix
  295. p.synapse_channel_names = self.synapse_channel_names
  296. return p
  297. def lazyConnection(
  298. hs: "HomeServer",
  299. host: str = "localhost",
  300. port: int = 6379,
  301. dbid: Optional[int] = None,
  302. reconnect: bool = True,
  303. password: Optional[str] = None,
  304. replyTimeout: int = 30,
  305. ) -> ConnectionHandler:
  306. """Creates a connection to Redis that is lazily set up and reconnects if the
  307. connections is lost.
  308. """
  309. uuid = "%s:%d" % (host, port)
  310. factory = SynapseRedisFactory(
  311. hs,
  312. uuid=uuid,
  313. dbid=dbid,
  314. poolsize=1,
  315. isLazy=True,
  316. handler=ConnectionHandler,
  317. password=password,
  318. replyTimeout=replyTimeout,
  319. )
  320. factory.continueTrying = reconnect
  321. reactor = hs.get_reactor()
  322. if hs.config.redis.redis_use_tls:
  323. ssl_context_factory = ClientContextFactory(hs.config.redis)
  324. reactor.connectSSL(
  325. host,
  326. port,
  327. factory,
  328. ssl_context_factory,
  329. timeout=30,
  330. bindAddress=None,
  331. )
  332. else:
  333. reactor.connectTCP(
  334. host,
  335. port,
  336. factory,
  337. timeout=30,
  338. bindAddress=None,
  339. )
  340. return factory.handler
  341. def lazyUnixConnection(
  342. hs: "HomeServer",
  343. path: str = "/tmp/redis.sock",
  344. dbid: Optional[int] = None,
  345. reconnect: bool = True,
  346. password: Optional[str] = None,
  347. replyTimeout: int = 30,
  348. ) -> ConnectionHandler:
  349. """Creates a connection to Redis that is lazily set up and reconnects if the
  350. connection is lost.
  351. Returns:
  352. A subclass of ConnectionHandler, which is a UnixConnectionHandler in this case.
  353. """
  354. uuid = path
  355. factory = SynapseRedisFactory(
  356. hs,
  357. uuid=uuid,
  358. dbid=dbid,
  359. poolsize=1,
  360. isLazy=True,
  361. handler=UnixConnectionHandler,
  362. password=password,
  363. replyTimeout=replyTimeout,
  364. )
  365. factory.continueTrying = reconnect
  366. reactor = hs.get_reactor()
  367. reactor.connectUNIX(
  368. path,
  369. factory,
  370. timeout=30,
  371. checkPID=False,
  372. )
  373. return factory.handler