You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

236 lines
8.3 KiB

  1. # Copyright 2022 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. #
  15. # ## What this script does
  16. #
  17. # This script spawns multiple workers, whilst only going through the code loading
  18. # process once. The net effect is that start-up time for a swarm of workers is
  19. # reduced, particularly in CPU-constrained environments.
  20. #
  21. # Before the workers are spawned, the database is prepared in order to avoid the
  22. # workers racing.
  23. #
  24. # ## Stability
  25. #
  26. # This script is only intended for use within the Synapse images for the
  27. # Complement test suite.
  28. # There are currently no stability guarantees whatsoever; especially not about:
  29. # - whether it will continue to exist in future versions;
  30. # - the format of its command-line arguments; or
  31. # - any details about its behaviour or principles of operation.
  32. #
  33. # ## Usage
  34. #
  35. # The first argument should be the path to the database configuration, used to
  36. # set up the database. The rest of the arguments are used as follows:
  37. # Each worker is specified as an argument group (each argument group is
  38. # separated by '--').
  39. # The first argument in each argument group is the Python module name of the application
  40. # to start. Further arguments are then passed to that module as-is.
  41. #
  42. # ## Example
  43. #
  44. # python -m synapse.app.complement_fork_starter path_to_db_config.yaml \
  45. # synapse.app.homeserver [args..] -- \
  46. # synapse.app.generic_worker [args..] -- \
  47. # ...
  48. # synapse.app.generic_worker [args..]
  49. #
  50. import argparse
  51. import importlib
  52. import itertools
  53. import multiprocessing
  54. import os
  55. import signal
  56. import sys
  57. from types import FrameType
  58. from typing import Any, Callable, Dict, List, Optional
  59. from twisted.internet.main import installReactor
  60. # a list of the original signal handlers, before we installed our custom ones.
  61. # We restore these in our child processes.
  62. _original_signal_handlers: Dict[int, Any] = {}
  63. class ProxiedReactor:
  64. """
  65. Twisted tracks the 'installed' reactor as a global variable.
  66. (Actually, it does some module trickery, but the effect is similar.)
  67. The default EpollReactor is buggy if it's created before a process is
  68. forked, then used in the child.
  69. See https://twistedmatrix.com/trac/ticket/4759#comment:17.
  70. However, importing certain Twisted modules will automatically create and
  71. install a reactor if one hasn't already been installed.
  72. It's not normally possible to re-install a reactor.
  73. Given the goal of launching workers with fork() to only import the code once,
  74. this presents a conflict.
  75. Our work around is to 'install' this ProxiedReactor which prevents Twisted
  76. from creating and installing one, but which lets us replace the actual reactor
  77. in use later on.
  78. """
  79. def __init__(self) -> None:
  80. self.___reactor_target: Any = None
  81. def _install_real_reactor(self, new_reactor: Any) -> None:
  82. """
  83. Install a real reactor for this ProxiedReactor to forward lookups onto.
  84. This method is specific to our ProxiedReactor and should not clash with
  85. any names used on an actual Twisted reactor.
  86. """
  87. self.___reactor_target = new_reactor
  88. def __getattr__(self, attr_name: str) -> Any:
  89. return getattr(self.___reactor_target, attr_name)
  90. def _worker_entrypoint(
  91. func: Callable[[], None], proxy_reactor: ProxiedReactor, args: List[str]
  92. ) -> None:
  93. """
  94. Entrypoint for a forked worker process.
  95. We just need to set up the command-line arguments, create our real reactor
  96. and then kick off the worker's main() function.
  97. """
  98. from synapse.util.stringutils import strtobool
  99. sys.argv = args
  100. # reset the custom signal handlers that we installed, so that the children start
  101. # from a clean slate.
  102. for sig, handler in _original_signal_handlers.items():
  103. signal.signal(sig, handler)
  104. # Install the asyncio reactor if the
  105. # SYNAPSE_COMPLEMENT_FORKING_LAUNCHER_ASYNC_IO_REACTOR is set to 1. The
  106. # SYNAPSE_ASYNC_IO_REACTOR variable would be used, but then causes
  107. # synapse/__init__.py to also try to install an asyncio reactor.
  108. if strtobool(
  109. os.environ.get("SYNAPSE_COMPLEMENT_FORKING_LAUNCHER_ASYNC_IO_REACTOR", "0")
  110. ):
  111. import asyncio
  112. from twisted.internet.asyncioreactor import AsyncioSelectorReactor
  113. reactor = AsyncioSelectorReactor(asyncio.get_event_loop())
  114. proxy_reactor._install_real_reactor(reactor)
  115. else:
  116. from twisted.internet.epollreactor import EPollReactor
  117. proxy_reactor._install_real_reactor(EPollReactor())
  118. func()
  119. def main() -> None:
  120. """
  121. Entrypoint for the forking launcher.
  122. """
  123. parser = argparse.ArgumentParser()
  124. parser.add_argument("db_config", help="Path to database config file")
  125. parser.add_argument(
  126. "args",
  127. nargs="...",
  128. help="Argument groups separated by `--`. "
  129. "The first argument of each group is a Synapse app name. "
  130. "Subsequent arguments are passed through.",
  131. )
  132. ns = parser.parse_args()
  133. # Split up the subsequent arguments into each workers' arguments;
  134. # `--` is our delimiter of choice.
  135. args_by_worker: List[List[str]] = [
  136. list(args)
  137. for cond, args in itertools.groupby(ns.args, lambda ele: ele != "--")
  138. if cond and args
  139. ]
  140. # Prevent Twisted from installing a shared reactor that all the workers will
  141. # inherit when we fork(), by installing our own beforehand.
  142. proxy_reactor = ProxiedReactor()
  143. installReactor(proxy_reactor)
  144. # Import the entrypoints for all the workers.
  145. worker_functions = []
  146. for worker_args in args_by_worker:
  147. worker_module = importlib.import_module(worker_args[0])
  148. worker_functions.append(worker_module.main)
  149. # We need to prepare the database first as otherwise all the workers will
  150. # try to create a schema version table and some will crash out.
  151. from synapse._scripts import update_synapse_database
  152. update_proc = multiprocessing.Process(
  153. target=_worker_entrypoint,
  154. args=(
  155. update_synapse_database.main,
  156. proxy_reactor,
  157. [
  158. "update_synapse_database",
  159. "--database-config",
  160. ns.db_config,
  161. "--run-background-updates",
  162. ],
  163. ),
  164. )
  165. print("===== PREPARING DATABASE =====", file=sys.stderr)
  166. update_proc.start()
  167. update_proc.join()
  168. print("===== PREPARED DATABASE =====", file=sys.stderr)
  169. processes: List[multiprocessing.Process] = []
  170. # Install signal handlers to propagate signals to all our children, so that they
  171. # shut down cleanly. This also inhibits our own exit, but that's good: we want to
  172. # wait until the children have exited.
  173. def handle_signal(signum: int, frame: Optional[FrameType]) -> None:
  174. print(
  175. f"complement_fork_starter: Caught signal {signum}. Stopping children.",
  176. file=sys.stderr,
  177. )
  178. for p in processes:
  179. if p.pid:
  180. os.kill(p.pid, signum)
  181. for sig in (signal.SIGINT, signal.SIGTERM):
  182. _original_signal_handlers[sig] = signal.signal(sig, handle_signal)
  183. # At this point, we've imported all the main entrypoints for all the workers.
  184. # Now we basically just fork() out to create the workers we need.
  185. # Because we're using fork(), all the workers get a clone of this launcher's
  186. # memory space and don't need to repeat the work of loading the code!
  187. # Instead of using fork() directly, we use the multiprocessing library,
  188. # which uses fork() on Unix platforms.
  189. for func, worker_args in zip(worker_functions, args_by_worker):
  190. process = multiprocessing.Process(
  191. target=_worker_entrypoint, args=(func, proxy_reactor, worker_args)
  192. )
  193. process.start()
  194. processes.append(process)
  195. # Be a good parent and wait for our children to die before exiting.
  196. for process in processes:
  197. process.join()
  198. if __name__ == "__main__":
  199. main()