mirror of
https://github.com/element-hq/synapse.git
synced 2026-08-14 18:01:13 +00:00
Give tokio tasks a captured logcontext, resolving the module-doc TODO: - LogContextHandle is a cheap, clone-able, GIL-free handle in the same Option<Py<LoggingContext>> representation the storage slots use. create_deferred captures the caller's context at the FFI boundary and scopes it onto the spawned task via a tokio task-local, which rides with the task across .await points. current_context() gives the task-local read precedence, so log records emitted while a task is polled — via LoggingContextFilter and pyo3-log — are attributed to the captured context with no per-record stamping. - The switch primitive is only ever driven on reactor/threadpool threads, never during a tokio-scoped poll (where the write would be invisible to reads); swap_current_context enforces that invariant with an error log rather than trusting it. - run_python_awaitable restores the captured context on the reactor thread before driving the awaitable, so Python called back from Rust (e.g. DatabasePool.runInteraction from the Rust /versions handler) runs in — and accounts its DB usage against — the right request. The restore protocol lives in a new with_logcontext helper (the Rust equivalent of `with PreserveLoggingContext(...)`): an error cannot skip the restore (which would leak the context onto the reactor thread permanently), and a context that has already finished is not re-started (create_deferred does not propagate cancellation, so a task can outlive its request; see the TODO) — such work runs in the sentinel instead. - tests/synapse_rust/test_logcontext.py exercises both guarantees through real production code paths: reqwest's log records carry the caller's request id, and the /versions handler's DB transaction lands on the caller's usage accounting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFbRtswu7rsHrttJFauUUb
293 lines
11 KiB
Python
293 lines
11 KiB
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>.
|
|
|
|
"""Cross-language logcontext attribution for Rust.
|
|
|
|
The current logcontext lives in the Rust slot (`synapse.synapse_rust.logcontext`
|
|
/ `rust/src/logging/context.rs`), visible from both Python (reactor/threadpool
|
|
threads) and Rust (tokio tasks). These tests exercise the two guarantees that
|
|
gives us, through real production code paths:
|
|
|
|
1. Log records emitted from Rust while a task is being polled (e.g. reqwest
|
|
connecting) are attributed to the logcontext that was current when Python
|
|
called into Rust — not the sentinel.
|
|
2. When Rust calls back into Python (`run_python_awaitable`, as the Rust
|
|
`/versions` handler does for its per-user feature DB lookup), the Python code
|
|
runs in that same logcontext, so its DB-transaction accounting lands on the
|
|
right request.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from typing import Callable
|
|
|
|
from twisted.internet.testing import MemoryReactor
|
|
|
|
from synapse.logging.context import (
|
|
LoggingContext,
|
|
LoggingContextFilter,
|
|
PreserveLoggingContext,
|
|
_Sentinel,
|
|
current_context,
|
|
run_in_background,
|
|
)
|
|
from synapse.rest import admin
|
|
from synapse.rest.client import login
|
|
from synapse.server import HomeServer
|
|
from synapse.synapse_rust import reset_logging_config
|
|
from synapse.synapse_rust.http_client import HttpClient
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests.unittest import HomeserverTestCase
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Log-target roots that Rust code emits under while running on tokio worker
|
|
# threads: the reqwest dependency stack, plus "synapse"/"synapse_rust" because
|
|
# the Rust crate is itself named `synapse` (see rust/Cargo.toml). Anything
|
|
# emitted under these while a task is being polled should be attributed to the
|
|
# caller's logcontext, never the sentinel.
|
|
#
|
|
# NB: bare "synapse" also matches every *Python* `synapse.*` record, so the
|
|
# attribution assertion below implicitly relies on nothing else logging during
|
|
# the pump (MemoryReactor with `advance(0)`, so no timed background work fires).
|
|
# If this test starts flaking on unrelated records, tighten this filter rather
|
|
# than weakening the assertion.
|
|
_RUST_LOGGER_ROOTS = frozenset(
|
|
{"reqwest", "hyper", "hyper_util", "h2", "rustls", "synapse_rust", "synapse"}
|
|
)
|
|
|
|
|
|
class RustLogContextTestCase(HomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
|
|
hs = self.setup_test_homeserver()
|
|
|
|
# XXX: We must create the Rust HTTP client before we call `reactor.run()`
|
|
# below. Twisted's `MemoryReactor` doesn't invoke `callWhenRunning`
|
|
# callbacks if it's already running and we rely on that to start the Tokio
|
|
# thread pool in Rust.
|
|
self._http_client = hs.get_proxied_http_client()
|
|
self._rust_http_client = HttpClient(
|
|
reactor=hs.get_reactor(),
|
|
user_agent=self._http_client.user_agent.decode("utf8"),
|
|
)
|
|
|
|
# This triggers the server startup hooks, which starts the Tokio thread pool
|
|
reactor.run()
|
|
|
|
return hs
|
|
|
|
def tearDown(self) -> None:
|
|
# MemoryReactor doesn't trigger the shutdown phases, and we want the Tokio
|
|
# thread pool to be stopped.
|
|
shutdown_triggers = self.reactor.triggers.get("shutdown", {})
|
|
for phase in ["before", "during", "after"]:
|
|
triggers = shutdown_triggers.get(phase, [])
|
|
for callbable, args, kwargs in triggers:
|
|
callbable(*args, **kwargs)
|
|
|
|
def prepare(
|
|
self, reactor: MemoryReactor, clock: Clock, homeserver: HomeServer
|
|
) -> None:
|
|
self.user_id = self.register_user("user1", "pass")
|
|
|
|
def _check_current_logcontext(self, expected: str) -> None:
|
|
context = current_context()
|
|
assert isinstance(context, (LoggingContext, _Sentinel)), context
|
|
self.assertEqual(str(context), expected, f"expected {expected}, saw {context}")
|
|
|
|
def _run_in_logcontext_and_pump(
|
|
self, name: str, body: Callable[[dict[str, object]], None]
|
|
) -> dict[str, object]:
|
|
"""Run `body` fired off inside a fresh `LoggingContext(name)`, pumping the
|
|
reactor (and yielding to the Tokio pool) until it sets `result["done"]`.
|
|
|
|
Returns the `result` dict `body` populated. Asserts the caller logcontext
|
|
is intact afterwards and that we end back in the sentinel.
|
|
"""
|
|
self._check_current_logcontext("sentinel")
|
|
result: dict[str, object] = {}
|
|
|
|
with LoggingContext(name=name, server_name="test_server"):
|
|
body(result)
|
|
|
|
with PreserveLoggingContext():
|
|
# Generous upper bound (the work is a real HTTP round-trip or DB
|
|
# hop on a possibly-loaded CI box); the loop exits early via
|
|
# `result["done"]`, and we fail below if it never gets set.
|
|
for _ in range(50000):
|
|
if result.get("done"):
|
|
break
|
|
# Let the Tokio worker threads make progress...
|
|
time.sleep(0)
|
|
# ...and run anything they scheduled back on the reactor.
|
|
self.reactor.advance(0)
|
|
|
|
# The caller's logcontext must be intact after firing off the work.
|
|
self._check_current_logcontext(name)
|
|
|
|
# ...and we must not have leaked it into the reactor.
|
|
self._check_current_logcontext("sentinel")
|
|
|
|
self.assertTrue(
|
|
result.get("done"),
|
|
"work never finished; the test probably didn't pump long enough",
|
|
)
|
|
return result
|
|
|
|
def test_rust_log_records_attributed_to_caller_logcontext(self) -> None:
|
|
"""A log record emitted from Rust on a tokio thread (reqwest connecting)
|
|
is attributed to the caller's logcontext via `LoggingContextFilter`, not
|
|
the sentinel."""
|
|
records: list[tuple[str, object]] = []
|
|
|
|
class CapturingHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
records.append((record.name, getattr(record, "request", "<unset>")))
|
|
|
|
handler = CapturingHandler()
|
|
# The global filter is what copies `str(current_context())` onto the
|
|
# record as `record.request`; attach it so we observe what Synapse would.
|
|
handler.addFilter(LoggingContextFilter())
|
|
|
|
root = logging.getLogger()
|
|
root.addHandler(handler)
|
|
|
|
# Turn up the Rust-side loggers so reqwest actually emits, and refresh
|
|
# pyo3-log's cached levels so it forwards them.
|
|
saved_levels = {
|
|
name: logging.getLogger(name).level for name in _RUST_LOGGER_ROOTS
|
|
}
|
|
for name in _RUST_LOGGER_ROOTS:
|
|
logging.getLogger(name).setLevel(logging.DEBUG)
|
|
reset_logging_config()
|
|
|
|
try:
|
|
server = _StubServer()
|
|
self.addCleanup(server.shutdown)
|
|
|
|
def body(result: dict[str, object]) -> None:
|
|
async def do() -> None:
|
|
try:
|
|
await self._rust_http_client.get(
|
|
url=server.endpoint,
|
|
response_limit=1 * 1024 * 1024,
|
|
)
|
|
finally:
|
|
result["done"] = True
|
|
|
|
run_in_background(do)
|
|
|
|
self._run_in_logcontext_and_pump("http-caller", body)
|
|
finally:
|
|
root.removeHandler(handler)
|
|
for name, level in saved_levels.items():
|
|
logging.getLogger(name).setLevel(level)
|
|
reset_logging_config()
|
|
|
|
rust_records = [
|
|
(name, req)
|
|
for (name, req) in records
|
|
if name.split(".", 1)[0] in _RUST_LOGGER_ROOTS
|
|
]
|
|
self.assertTrue(
|
|
rust_records,
|
|
"expected at least one Rust-origin log record (e.g. reqwest connecting); "
|
|
f"captured loggers: {sorted({name for name, _ in records})}",
|
|
)
|
|
for name, req in rust_records:
|
|
self.assertEqual(
|
|
req,
|
|
"http-caller",
|
|
f"Rust log record from {name!r} was attributed to {req!r}, "
|
|
"not the caller's logcontext",
|
|
)
|
|
|
|
def test_db_callback_runs_in_caller_logcontext(self) -> None:
|
|
"""The Rust `/versions` handler's per-user feature lookup calls back into
|
|
Python via `run_python_awaitable`; the DB transaction it runs must be
|
|
accounted against the caller's logcontext. The failure mode is the
|
|
awaitable running in the sentinel instead, silently losing the
|
|
accounting."""
|
|
versions_handler = self.hs.get_rust_handlers().versions
|
|
|
|
def body(result: dict[str, object]) -> None:
|
|
async def do() -> None:
|
|
try:
|
|
context = current_context()
|
|
assert isinstance(context, LoggingContext)
|
|
before = context.get_resource_usage().db_txn_count
|
|
|
|
# Passing a user id makes the Rust handler do a per-user
|
|
# feature DB lookup (msc3881/msc3575 default to off), which
|
|
# goes Rust -> run_python_awaitable -> runInteraction.
|
|
await versions_handler.get_versions(self.user_id)
|
|
|
|
after = context.get_resource_usage().db_txn_count
|
|
result["db_txn_delta"] = after - before
|
|
finally:
|
|
result["done"] = True
|
|
|
|
run_in_background(do)
|
|
|
|
result = self._run_in_logcontext_and_pump("db-caller", body)
|
|
|
|
db_txn_delta = result.get("db_txn_delta", 0)
|
|
assert isinstance(db_txn_delta, int)
|
|
self.assertGreaterEqual(
|
|
db_txn_delta,
|
|
1,
|
|
"the Rust handler's DB work was not accounted against the caller's "
|
|
"logcontext — run_python_awaitable is not restoring it (ran in the "
|
|
"sentinel instead)",
|
|
)
|
|
|
|
|
|
class _StubServer:
|
|
"""A real HTTP server on a random port, served from a background thread."""
|
|
|
|
def __init__(self) -> None:
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self) -> None:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.end_headers()
|
|
self.wfile.write(b'{"ok": true}')
|
|
|
|
def log_message(self, format: str, *args: object) -> None:
|
|
pass
|
|
|
|
self._server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
self._thread = threading.Thread(
|
|
target=self._server.serve_forever,
|
|
name="StubServer",
|
|
kwargs={"poll_interval": 0.01},
|
|
daemon=True,
|
|
)
|
|
self._thread.start()
|
|
|
|
@property
|
|
def endpoint(self) -> str:
|
|
return f"http://127.0.0.1:{self._server.server_port}/"
|
|
|
|
def shutdown(self) -> None:
|
|
self._server.shutdown()
|
|
self._thread.join()
|