mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 15:37:49 +00:00
`scripts-dev/pg_roundtrip_proxy.py` sits between Synapse and Postgres. It counts round trips, a client message after a server message on the same connection, can add latency to each, and records the protocol messages and SQL of each round trip. SIGUSR2 resets it and SIGUSR1 writes the counts and log to a file. `createroom_bench.py --proxy-delay-ms N` runs the server through it and reports the round trips per request beside the timings. With a delay, the extra wall time over a run without one shows how many of those round trips the request waits for in turn. A new `e2ee_invite` scenario is the room the E2EE monitor creates on jki.re, and `--scenarios` picks a subset. `persist_bench.Server` honours `PGPORT`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PVLsNh6akt4ifEizY4D1ta
279 lines
9.3 KiB
Python
Executable File
279 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python
|
|
#
|
|
# This file is licensed under the Affero General Public License (AGPL) version 3.
|
|
#
|
|
# Copyright (C) 2026 Element Creations Ltd
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as
|
|
# published by the Free Software Foundation, either version 3 of the
|
|
# License, or (at your option) any later version.
|
|
#
|
|
# See the GNU Affero General Public License for more details:
|
|
# <https://www.gnu.org/licenses/agpl-3.0.html>.
|
|
#
|
|
|
|
"""Time `/createRoom` against a real Synapse on Postgres.
|
|
|
|
Starts the server the way `persist_bench.py` does, once per
|
|
`rust_event_persistence` level. For each scenario it creates five warm-up
|
|
rooms, then `--rooms` rooms one after another, and reports the mean and median
|
|
request time and the CPU of the reactor thread and of the process per room.
|
|
|
|
poetry run python scripts-dev/createroom_bench.py --levels off,full,full+rooms
|
|
|
|
A level with `+rooms` also turns on `rust_room_creation`. `--psycopg2` runs
|
|
the server on psycopg2 instead of the native driver.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import signal
|
|
import statistics
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from typing import Any
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
import persist_bench as pb # noqa: E402
|
|
|
|
SCENARIOS: dict[str, dict[str, Any]] = {
|
|
"plain": {},
|
|
# The room the E2EE monitor on jki.re creates every minute.
|
|
"e2ee_invite": {
|
|
"preset": "trusted_private_chat",
|
|
"is_direct": True,
|
|
"invite": ["@invitee:bench"],
|
|
"initial_state": [
|
|
{
|
|
"type": "m.room.encryption",
|
|
"state_key": "",
|
|
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
|
|
}
|
|
],
|
|
},
|
|
"private_invite": {"preset": "private_chat", "invite": ["@invitee:bench"]},
|
|
"public_named": {"preset": "public_chat", "name": "Bench room", "topic": "A topic"},
|
|
"initial_state": {
|
|
"initial_state": [
|
|
{
|
|
"type": "m.room.encryption",
|
|
"state_key": "",
|
|
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
|
|
},
|
|
{
|
|
"type": "m.room.guest_access",
|
|
"state_key": "",
|
|
"content": {"guest_access": "can_join"},
|
|
},
|
|
{
|
|
"type": "m.space.child",
|
|
"state_key": "!a:bench",
|
|
"content": {"via": ["bench"]},
|
|
},
|
|
{
|
|
"type": "m.space.child",
|
|
"state_key": "!b:bench",
|
|
"content": {"via": ["bench"]},
|
|
},
|
|
]
|
|
},
|
|
}
|
|
|
|
CREATE_ROOM = "/_matrix/client/v3/createRoom"
|
|
|
|
# The local port `pg_roundtrip_proxy.py` listens on, with `--proxy-delay-ms`.
|
|
PROXY_PORT = 15432
|
|
|
|
|
|
class RoundTripProxy:
|
|
"""`pg_roundtrip_proxy.py` between the server and Postgres, counting the
|
|
round trips of each request."""
|
|
|
|
def __init__(self, delay_ms: float) -> None:
|
|
self.stats_dir = tempfile.mkdtemp(prefix="pg-roundtrips-")
|
|
self.stats = os.path.join(self.stats_dir, "stats.json")
|
|
target = (
|
|
f"{os.environ.get('PGHOST', 'localhost')}:"
|
|
f"{os.environ.get('PGPORT', '5432')}"
|
|
)
|
|
self.process = subprocess.Popen(
|
|
[
|
|
sys.executable,
|
|
os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "pg_roundtrip_proxy.py"
|
|
),
|
|
f"--listen={PROXY_PORT}",
|
|
f"--target={target}",
|
|
f"--delay-ms={delay_ms}",
|
|
f"--stats={self.stats}",
|
|
]
|
|
)
|
|
time.sleep(1)
|
|
self.check_running()
|
|
os.environ["PGHOST"] = "127.0.0.1"
|
|
os.environ["PGPORT"] = str(PROXY_PORT)
|
|
|
|
def check_running(self) -> None:
|
|
if self.process.poll() is not None:
|
|
raise RuntimeError(
|
|
f"pg_roundtrip_proxy.py exited with {self.process.returncode}"
|
|
)
|
|
|
|
def reset(self) -> None:
|
|
self.process.send_signal(signal.SIGUSR2)
|
|
time.sleep(0.05)
|
|
|
|
def turns(self) -> int:
|
|
"""The round trips since the last `reset`."""
|
|
# The proxy replaces the file atomically, so it is complete once it
|
|
# is gone and back.
|
|
if os.path.exists(self.stats):
|
|
os.unlink(self.stats)
|
|
self.process.send_signal(signal.SIGUSR1)
|
|
deadline = time.monotonic() + 10
|
|
while not os.path.exists(self.stats):
|
|
self.check_running()
|
|
if time.monotonic() > deadline:
|
|
raise RuntimeError("pg_roundtrip_proxy.py did not write its stats")
|
|
time.sleep(0.01)
|
|
with open(self.stats) as f:
|
|
return json.load(f)["turns"]
|
|
|
|
def stop(self) -> None:
|
|
self.process.terminate()
|
|
self.process.wait()
|
|
shutil.rmtree(self.stats_dir, ignore_errors=True)
|
|
|
|
|
|
def bench_level(
|
|
level: str,
|
|
rooms: int,
|
|
rust_driver: bool,
|
|
scenarios: list[str],
|
|
proxy: RoundTripProxy | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Benchmark a server at `level`: a `rust_event_persistence` level,
|
|
optionally followed by `+rooms` for `rust_room_creation: on`."""
|
|
results = []
|
|
persist_level, _, extra = level.partition("+")
|
|
if extra not in ("", "rooms"):
|
|
raise ValueError(f"unknown level suffix {extra!r} in {level!r}")
|
|
experimental = {"rust_room_creation": "on"} if extra == "rooms" else {}
|
|
workdir = tempfile.mkdtemp(prefix=f"createroom-bench-{persist_level}-")
|
|
server = pb.Server(
|
|
persist_level, workdir, rust_driver=rust_driver, experimental=experimental
|
|
)
|
|
try:
|
|
server.start()
|
|
token = pb.register("creator")
|
|
pb.register("invitee")
|
|
for name in scenarios:
|
|
body = SCENARIOS[name]
|
|
for _ in range(5):
|
|
pb.ok("POST", CREATE_ROOM, body, token)
|
|
before = server.sample()
|
|
times = []
|
|
turns = []
|
|
for _ in range(rooms):
|
|
if proxy is not None:
|
|
# Let the last room's background work finish first.
|
|
time.sleep(0.5)
|
|
proxy.reset()
|
|
t0 = time.perf_counter()
|
|
pb.ok("POST", CREATE_ROOM, body, token)
|
|
times.append(time.perf_counter() - t0)
|
|
if proxy is not None:
|
|
turns.append(proxy.turns())
|
|
after = server.sample()
|
|
row = {
|
|
"level": level,
|
|
"scenario": name,
|
|
"mean_ms": 1000 * statistics.mean(times),
|
|
"p50_ms": 1000 * statistics.median(times),
|
|
"reactor_cpu_ms": 1000
|
|
* (after.reactor_cpu - before.reactor_cpu)
|
|
/ rooms,
|
|
"process_cpu_ms": 1000
|
|
* (after.process_cpu - before.process_cpu)
|
|
/ rooms,
|
|
}
|
|
if turns:
|
|
# Background work from the request counts too, if it
|
|
# overlaps.
|
|
row["round_trips"] = statistics.median(turns)
|
|
results.append(row)
|
|
print(row, flush=True)
|
|
finally:
|
|
server.stop()
|
|
return results
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--levels",
|
|
default="off,full,full+rooms",
|
|
help="comma-separated `rust_event_persistence` levels; append `+rooms`"
|
|
" to a level for `rust_room_creation: on`",
|
|
)
|
|
parser.add_argument("--rooms", type=int, default=40)
|
|
parser.add_argument("--psycopg2", action="store_true")
|
|
parser.add_argument(
|
|
"--scenarios",
|
|
default=",".join(SCENARIOS),
|
|
help="comma-separated scenarios to run, from: " + ", ".join(SCENARIOS),
|
|
)
|
|
parser.add_argument(
|
|
"--proxy-delay-ms",
|
|
type=float,
|
|
help="run Postgres connections through pg_roundtrip_proxy.py, adding this"
|
|
" much latency to each round trip (0 to only count), and report the round"
|
|
" trips per request. The extra wall time over a run without the proxy,"
|
|
" divided by the delay, is the number of round trips the request waits"
|
|
" for in turn.",
|
|
)
|
|
parser.add_argument("--json", help="also write the results to this file")
|
|
args = parser.parse_args()
|
|
|
|
proxy = None
|
|
if args.proxy_delay_ms is not None:
|
|
proxy = RoundTripProxy(args.proxy_delay_ms)
|
|
|
|
results = []
|
|
try:
|
|
for level in args.levels.split(","):
|
|
results += bench_level(
|
|
level,
|
|
args.rooms,
|
|
rust_driver=not args.psycopg2,
|
|
scenarios=args.scenarios.split(","),
|
|
proxy=proxy,
|
|
)
|
|
finally:
|
|
if proxy is not None:
|
|
proxy.stop()
|
|
|
|
print(
|
|
f"\n{'scenario':<16} {'level':<12} {'mean ms':>8} {'p50 ms':>8}"
|
|
f" {'reactor ms':>11} {'process ms':>11} {'round trips':>12}"
|
|
)
|
|
for r in results:
|
|
print(
|
|
f"{r['scenario']:<16} {r['level']:<12} {r['mean_ms']:>8.1f}"
|
|
f" {r['p50_ms']:>8.1f} {r['reactor_cpu_ms']:>11.1f}"
|
|
f" {r['process_cpu_ms']:>11.1f} {r.get('round_trips', ''):>12}"
|
|
)
|
|
if args.json:
|
|
with open(args.json, "w") as f:
|
|
json.dump(results, f, indent=2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|