219 lines
7.7 KiB
Python
219 lines
7.7 KiB
Python
# Copyright 2022 The Matrix.org Foundation C.I.C.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
#
|
|
# ## What this script does
|
|
#
|
|
# This script spawns multiple workers, whilst only going through the code loading
|
|
# process once. The net effect is that start-up time for a swarm of workers is
|
|
# reduced, particularly in CPU-constrained environments.
|
|
#
|
|
# Before the workers are spawned, the database is prepared in order to avoid the
|
|
# workers racing.
|
|
#
|
|
# ## Stability
|
|
#
|
|
# This script is only intended for use within the Synapse images for the
|
|
# Complement test suite.
|
|
# There are currently no stability guarantees whatsoever; especially not about:
|
|
# - whether it will continue to exist in future versions;
|
|
# - the format of its command-line arguments; or
|
|
# - any details about its behaviour or principles of operation.
|
|
#
|
|
# ## Usage
|
|
#
|
|
# The first argument should be the path to the database configuration, used to
|
|
# set up the database. The rest of the arguments are used as follows:
|
|
# Each worker is specified as an argument group (each argument group is
|
|
# separated by '--').
|
|
# The first argument in each argument group is the Python module name of the application
|
|
# to start. Further arguments are then passed to that module as-is.
|
|
#
|
|
# ## Example
|
|
#
|
|
# python -m synapse.app.complement_fork_starter path_to_db_config.yaml \
|
|
# synapse.app.homeserver [args..] -- \
|
|
# synapse.app.generic_worker [args..] -- \
|
|
# ...
|
|
# synapse.app.generic_worker [args..]
|
|
#
|
|
import argparse
|
|
import importlib
|
|
import itertools
|
|
import multiprocessing
|
|
import os
|
|
import signal
|
|
import sys
|
|
from types import FrameType
|
|
from typing import Any, Callable, List, Optional
|
|
|
|
from twisted.internet.main import installReactor
|
|
|
|
# a list of the original signal handlers, before we installed our custom ones.
|
|
# We restore these in our child processes.
|
|
_original_signal_handlers: dict[int, Any] = {}
|
|
|
|
|
|
class ProxiedReactor:
|
|
"""
|
|
Twisted tracks the 'installed' reactor as a global variable.
|
|
(Actually, it does some module trickery, but the effect is similar.)
|
|
|
|
The default EpollReactor is buggy if it's created before a process is
|
|
forked, then used in the child.
|
|
See https://twistedmatrix.com/trac/ticket/4759#comment:17.
|
|
|
|
However, importing certain Twisted modules will automatically create and
|
|
install a reactor if one hasn't already been installed.
|
|
It's not normally possible to re-install a reactor.
|
|
|
|
Given the goal of launching workers with fork() to only import the code once,
|
|
this presents a conflict.
|
|
Our work around is to 'install' this ProxiedReactor which prevents Twisted
|
|
from creating and installing one, but which lets us replace the actual reactor
|
|
in use later on.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.___reactor_target: Any = None
|
|
|
|
def _install_real_reactor(self, new_reactor: Any) -> None:
|
|
"""
|
|
Install a real reactor for this ProxiedReactor to forward lookups onto.
|
|
|
|
This method is specific to our ProxiedReactor and should not clash with
|
|
any names used on an actual Twisted reactor.
|
|
"""
|
|
self.___reactor_target = new_reactor
|
|
|
|
def __getattr__(self, attr_name: str) -> Any:
|
|
return getattr(self.___reactor_target, attr_name)
|
|
|
|
|
|
def _worker_entrypoint(
|
|
func: Callable[[], None], proxy_reactor: ProxiedReactor, args: List[str]
|
|
) -> None:
|
|
"""
|
|
Entrypoint for a forked worker process.
|
|
|
|
We just need to set up the command-line arguments, create our real reactor
|
|
and then kick off the worker's main() function.
|
|
"""
|
|
|
|
sys.argv = args
|
|
|
|
# reset the custom signal handlers that we installed, so that the children start
|
|
# from a clean slate.
|
|
for sig, handler in _original_signal_handlers.items():
|
|
signal.signal(sig, handler)
|
|
|
|
from twisted.internet.epollreactor import EPollReactor
|
|
|
|
proxy_reactor._install_real_reactor(EPollReactor())
|
|
func()
|
|
|
|
|
|
def main() -> None:
|
|
"""
|
|
Entrypoint for the forking launcher.
|
|
"""
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("db_config", help="Path to database config file")
|
|
parser.add_argument(
|
|
"args",
|
|
nargs="...",
|
|
help="Argument groups separated by `--`. "
|
|
"The first argument of each group is a Synapse app name. "
|
|
"Subsequent arguments are passed through.",
|
|
)
|
|
ns = parser.parse_args()
|
|
|
|
# Split up the subsequent arguments into each workers' arguments;
|
|
# `--` is our delimiter of choice.
|
|
args_by_worker: List[List[str]] = [
|
|
list(args)
|
|
for cond, args in itertools.groupby(ns.args, lambda ele: ele != "--")
|
|
if cond and args
|
|
]
|
|
|
|
# Prevent Twisted from installing a shared reactor that all the workers will
|
|
# inherit when we fork(), by installing our own beforehand.
|
|
proxy_reactor = ProxiedReactor()
|
|
installReactor(proxy_reactor)
|
|
|
|
# Import the entrypoints for all the workers.
|
|
worker_functions = []
|
|
for worker_args in args_by_worker:
|
|
worker_module = importlib.import_module(worker_args[0])
|
|
worker_functions.append(worker_module.main)
|
|
|
|
# We need to prepare the database first as otherwise all the workers will
|
|
# try to create a schema version table and some will crash out.
|
|
from synapse._scripts import update_synapse_database
|
|
|
|
update_proc = multiprocessing.Process(
|
|
target=_worker_entrypoint,
|
|
args=(
|
|
update_synapse_database.main,
|
|
proxy_reactor,
|
|
[
|
|
"update_synapse_database",
|
|
"--database-config",
|
|
ns.db_config,
|
|
"--run-background-updates",
|
|
],
|
|
),
|
|
)
|
|
print("===== PREPARING DATABASE =====", file=sys.stderr)
|
|
update_proc.start()
|
|
update_proc.join()
|
|
print("===== PREPARED DATABASE =====", file=sys.stderr)
|
|
|
|
processes: List[multiprocessing.Process] = []
|
|
|
|
# Install signal handlers to propagate signals to all our children, so that they
|
|
# shut down cleanly. This also inhibits our own exit, but that's good: we want to
|
|
# wait until the children have exited.
|
|
def handle_signal(signum: int, frame: Optional[FrameType]) -> None:
|
|
print(
|
|
f"complement_fork_starter: Caught signal {signum}. Stopping children.",
|
|
file=sys.stderr,
|
|
)
|
|
for p in processes:
|
|
if p.pid:
|
|
os.kill(p.pid, signum)
|
|
|
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
_original_signal_handlers[sig] = signal.signal(sig, handle_signal)
|
|
|
|
# At this point, we've imported all the main entrypoints for all the workers.
|
|
# Now we basically just fork() out to create the workers we need.
|
|
# Because we're using fork(), all the workers get a clone of this launcher's
|
|
# memory space and don't need to repeat the work of loading the code!
|
|
# Instead of using fork() directly, we use the multiprocessing library,
|
|
# which uses fork() on Unix platforms.
|
|
for (func, worker_args) in zip(worker_functions, args_by_worker):
|
|
process = multiprocessing.Process(
|
|
target=_worker_entrypoint, args=(func, proxy_reactor, worker_args)
|
|
)
|
|
process.start()
|
|
processes.append(process)
|
|
|
|
# Be a good parent and wait for our children to die before exiting.
|
|
for process in processes:
|
|
process.join()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|