Files
synapse/rust/src/runtime.rs
T
Erik JohnstonandClaude Opus 5.5 728a965b54 Carry the active opentracing span into Rust tasks
Rust work spawned from Python needs the caller's span as the parent of
the spans it will report. `LogContextHandle::capture` now reads the
active span's context (trace ID, span ID, flags, baggage) off the
logcontext's scope, but only once a homeserver has enabled tracing.
Otherwise a capture costs one relaxed atomic load. A capture inside a
task copies the task's handle, so the span stays the one the task was
spawned under.

The `_blocking` entry points ran their futures on the reactor thread
with no handle at all. They now go through
`RustRuntime.block_on_scoped`, which spawns the future with a captured
handle and blocks on the join, the same as `create_deferred`.

`init_tracer` hands the Jaeger tracer's service name, process tags,
agent address and sampler to `RustRuntime.enable_tracing`, which stores
them for the reporter that follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4zxygfJC93SsugxskhoJG
2026-10-19 08:43:50 +00:00

414 lines
15 KiB
Rust

/*
* 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>.
*
*/
//! The per-homeserver state for the Rust side of Synapse.
//!
//! A [`RustRuntime`] is created once per homeserver (`hs.get_rust_runtime()`)
//! and holds everything the Rust side keeps for the lifetime of that
//! homeserver. Rust consumers (e.g. the HTTP client) clone the inner
//! [`Arc<RustRuntimeInner>`] at construction time and don't need the GIL (or
//! the Python-facing object) to reach it afterwards.
//!
//! The tokio runtime is shut down with the homeserver, via a handler
//! registered with `HomeServer.register_sync_shutdown_handler`.
use std::future::Future;
use std::ops::Deref;
use std::sync::{Arc, Mutex, OnceLock, Weak};
use std::time::{Duration, UNIX_EPOCH};
use anyhow::Context;
use pyo3::{
exceptions::{PyRuntimeError, PyValueError},
prelude::*,
};
use tokio::runtime::{Handle, Runtime};
use crate::caches::registry::CacheRegistry;
use crate::clock::{set_virtual_time_msec, Clock};
use crate::homeserver::HomeServer;
use crate::logging::context::LogContextHandle;
use crate::logging::opentracing::{set_tracing_enabled, SamplerConfig, TracerConfig};
use crate::reactor::Reactor;
use crate::twisted_dispatch::{self, TwistedDispatchReader, TwistedDispatcher};
/// How long to wait for in-flight tokio tasks to be cancelled when shutting
/// down with the reactor.
///
/// Note that any [`Runtime::spawn_blocking`] work that is still running when
/// the timeout expires is leaked, along with the worker thread running it.
///
/// See [`tokio::runtime`] for details.
const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
/// State of the lazily-started tokio runtime.
enum TokioState {
/// Not started yet; the runtime is built on first use.
NotStarted,
Running(Runtime),
/// Shut down with the homeserver. Cannot be restarted.
Shutdown,
}
/// The state shared between the Python-facing [`RustRuntime`] handle and any
/// Rust-side consumers holding an `Arc` of this.
pub struct RustRuntimeInner {
reactor: Reactor,
tokio: Mutex<TokioState>,
worker_threads: usize,
clock: Clock,
/// This homeserver's Rust caches. See [`crate::caches`].
caches: CacheRegistry,
/// Runs closures on the Twisted reactor thread without taking the GIL on
/// the calling thread. See [`crate::twisted_dispatch`].
dispatcher: Arc<TwistedDispatcher>,
/// The reactor-facing half of `dispatcher`, registered with the Twisted
/// reactor via `addReader` until `shutdown`.
dispatch_reader: Py<TwistedDispatchReader>,
/// Set by `enable_tracing` when opentracing is enabled for this
/// homeserver. See [`crate::logging::opentracing`].
tracing: OnceLock<Arc<TracerConfig>>,
}
impl RustRuntimeInner {
/// This homeserver's clock. See [`crate::clock`].
pub fn clock(&self) -> &Clock {
&self.clock
}
/// This homeserver's Rust caches, for building one or expiring them all.
/// See [`crate::caches`].
pub fn caches(&self) -> &CacheRegistry {
&self.caches
}
/// Queue `f` to run on the Twisted reactor thread with the GIL held, and
/// wake the reactor. Never takes the GIL itself, so a tokio task can call
/// it to hand a result back to Twisted. See [`crate::twisted_dispatch`].
///
/// This is the equivalent of calling `reactor.callFromThread` in Python and
/// should be used by Rust code instead of `callFromThread`.
///
/// Returns an error once the homeserver has shut down.
pub fn dispatch_to_twisted<F>(&self, f: F) -> anyhow::Result<()>
where
F: FnOnce(Python<'_>) + Send + 'static,
{
self.dispatcher.dispatch(f)
}
/// Get a handle to the tokio runtime, starting the runtime if it hasn't
/// been started yet.
pub fn tokio_handle(&self) -> PyResult<Handle> {
let mut state = self
.tokio
.lock()
.map_err(|_| PyRuntimeError::new_err("tokio runtime lock poisoned"))?;
match &*state {
TokioState::Running(runtime) => Ok(runtime.handle().clone()),
TokioState::NotStarted => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(self.worker_threads)
.enable_all()
.build()
.context("building tokio runtime")?;
let handle = runtime.handle().clone();
*state = TokioState::Running(runtime);
Ok(handle)
}
TokioState::Shutdown => Err(PyRuntimeError::new_err(
"the tokio runtime has been shut down",
)),
}
}
/// Run `fut` to completion as a task on the runtime, blocking the calling
/// thread with the GIL released.
///
/// The task runs in the caller's logcontext, with its active span as the
/// parent, the same as a task spawned by [`crate::deferred::create_deferred`].
/// It runs on a worker thread rather than the calling one, so the calling
/// thread never has a task-local logcontext in scope.
///
/// For the `_blocking` entry points that tests call in place of the
/// deferred ones.
pub fn block_on_scoped<F>(&self, py: Python<'_>, fut: F) -> PyResult<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
let logcontext = LogContextHandle::capture(py);
let handle = self.tokio_handle()?;
let task = handle.spawn(logcontext.scope(fut));
match py.detach(|| handle.block_on(task)) {
Ok(output) => Ok(output),
Err(err) if err.is_panic() => std::panic::resume_unwind(err.into_panic()),
Err(err) => Err(PyRuntimeError::new_err(format!(
"blocking task did not finish: {err}"
))),
}
}
/// The tracing settings, if opentracing is enabled for this homeserver.
pub fn tracer_config(&self) -> Option<&Arc<TracerConfig>> {
self.tracing.get()
}
/// Shut the tokio runtime down, cancelling all in-flight tasks and waiting
/// for up to [`SHUTDOWN_TIMEOUT`] for them to finish. Called via
/// [`ShutdownHook`] when the reactor shuts down.
///
/// Note that any [`Runtime::spawn_blocking`] work is leaked until it is
/// finished, along with the worker thread running it.
fn shutdown(&self, py: Python<'_>) -> PyResult<()> {
let mut state = self
.tokio
.lock()
.map_err(|_| PyRuntimeError::new_err("tokio runtime lock poisoned"))?;
let previous_state = std::mem::replace(&mut *state, TokioState::Shutdown);
// Don't hold the lock while blocking on the shutdown below.
drop(state);
if let TokioState::Running(runtime) = previous_state {
// Shutdown the runtime, waiting for a small grace period for
// in-flight tasks to be cancelled.
//
// See [`tokio::runtime`] for details.
py.detach(|| runtime.shutdown_timeout(SHUTDOWN_TIMEOUT));
}
// Unregister the wakeup socket from the Twisted reactor, then close
// the dispatcher and run the closures still queued so that their
// deferreds fire.
//
// All tokio tasks should be stopped by now, so only long running
// blocking threads may still be active. If they try to dispatch new
// work they get an error and should stop.
self.reactor
.remove_reader(py, self.dispatch_reader.bind(py).as_any())?;
self.dispatch_reader.get().close_and_drain(py);
Ok(())
}
}
impl Drop for RustRuntimeInner {
fn drop(&mut self) {
// Backstop for homeservers whose shutdown trigger never fires (e.g. in
// tests). We use `shutdown_background` rather than a blocking shutdown,
// because the last `Arc` may be dropped from a task running on this
// very runtime, where blocking would panic.
if let Ok(state) = self.tokio.get_mut() {
if let TokioState::Running(runtime) = std::mem::replace(state, TokioState::Shutdown) {
runtime.shutdown_background();
}
}
}
}
/// A cheaply-clonable handle to the per-homeserver Rust state, and the
/// Python-facing class for it.
///
/// One instance is constructed per homeserver by
/// `HomeServer.get_rust_runtime()`. Rust classes that need it take it as a
/// constructor argument and store their own clone, which is just an `Arc`
/// refcount bump. Derefs to [`RustRuntimeInner`].
#[pyclass(frozen, weakref, skip_from_py_object)]
#[derive(Clone)]
pub struct RustRuntime {
inner: Arc<RustRuntimeInner>,
}
impl Deref for RustRuntime {
type Target = RustRuntimeInner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
#[pymethods]
impl RustRuntime {
#[new]
#[pyo3(signature = (hs, worker_threads = 4))]
fn py_new(py: Python<'_>, hs: HomeServer, worker_threads: usize) -> PyResult<Self> {
let reactor = hs.get_reactor(py)?;
// Register the read end of the dispatcher's wakeup socket with the
// Twisted reactor, so that closures dispatched by tokio tasks run on
// the reactor thread. `shutdown` removes it again.
let (dispatcher, dispatch_reader) = twisted_dispatch::new_pair()?;
let dispatch_reader = Py::new(py, dispatch_reader)?;
reactor.add_reader(py, dispatch_reader.bind(py).as_any())?;
let inner = Arc::new(RustRuntimeInner {
reactor,
tokio: Mutex::new(TokioState::NotStarted),
worker_threads,
clock: Clock::new(),
caches: CacheRegistry::new(),
dispatcher,
dispatch_reader,
tracing: OnceLock::new(),
});
// Shut the tokio runtime down when the homeserver is shut down. The
// trigger holds only a `Weak` reference, as otherwise we risk a
// reference cycle passing through a Rust field that Python's GC cannot
// see into.
let hook = Py::new(
py,
ShutdownHook {
inner: Arc::downgrade(&inner),
},
)?;
hs.register_sync_shutdown_handler(py, hook.bind(py).as_any())?;
Ok(RustRuntime { inner })
}
/// The current time, in milliseconds since the Unix epoch.
fn time_msec(&self) -> u64 {
self.inner.clock.now_millis()
}
/// Report spans from Rust to the Jaeger agent alongside Python's. Called
/// by `synapse.logging.opentracing.init_tracer` once the Python tracer
/// exists, with the settings it computed, so that both report as the
/// same service.
///
/// `sampler_type` and `sampler_param` decide whether a trace that starts
/// in Rust is sampled: `const` and `probabilistic` are implemented, any
/// other type never samples such a trace. Rust spans with a Python parent
/// follow the parent's decision.
///
/// A second call is ignored.
#[pyo3(signature = (service_name, process_tags, reporting_host, reporting_port, sampler_type, sampler_param))]
fn enable_tracing(
&self,
service_name: String,
process_tags: Vec<(String, String)>,
reporting_host: String,
reporting_port: u16,
sampler_type: &str,
sampler_param: f64,
) {
let config = TracerConfig {
service_name,
process_tags,
reporting_host,
reporting_port,
sampler: SamplerConfig::from_type_and_param(sampler_type, sampler_param),
};
if self.inner.tracing.set(Arc::new(config)).is_err() {
log::warn!("Rust tracing was already enabled for this homeserver");
return;
}
set_tracing_enabled(true);
}
/// The settings `enable_tracing` stored, as `(service_name,
/// process_tags, reporting_host, reporting_port, sampler)` with the
/// sampler in its debug form, or `None`. For tests.
#[allow(clippy::type_complexity)]
fn tracing_config_for_test(
&self,
) -> Option<(String, Vec<(String, String)>, String, u16, String)> {
self.inner.tracing.get().map(|config| {
(
config.service_name.clone(),
config.process_tags.clone(),
config.reporting_host.clone(),
config.reporting_port,
format!("{:?}", config.sampler),
)
})
}
/// Evict entries not read for `expiry_secs` from every Rust cache of this
/// homeserver. Returns how many went. See [`crate::caches::registry`].
fn expire_caches(&self, expiry_secs: f64) -> PyResult<usize> {
let max_age = Duration::try_from_secs_f64(expiry_secs).map_err(|err| {
PyValueError::new_err(format!("bad cache expiry {expiry_secs}: {err}"))
})?;
Ok(self
.inner
.caches
.expire_caches(self.inner.clock.now(), max_age))
}
/// Evict up to `max` entries last read before `before_secs` (seconds
/// since the Unix epoch), coldest first across every Rust cache of this
/// homeserver. For memory-pressure eviction, which Python drives.
///
/// Returns how many went, and when the coldest entry left was last read,
/// in seconds since the epoch, or `None` if the caches are empty. See
/// [`crate::caches::registry::CacheRegistry::evict_coldest`].
fn evict_coldest(&self, before_secs: f64, max: usize) -> PyResult<(usize, Option<f64>)> {
let before = Duration::try_from_secs_f64(before_secs.max(0.0))
.map(|since_epoch| UNIX_EPOCH + since_epoch)
.map_err(|err| PyValueError::new_err(format!("bad time {before_secs}: {err}")))?;
let (evicted, coldest) = self.inner.caches.evict_coldest(before, max);
let coldest = coldest.map(|time| {
time.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs_f64()
});
Ok((evicted, coldest))
}
}
/// The callable registered with `HomeServer.register_sync_shutdown_handler`,
/// which runs it on `HomeServer.shutdown()` or when the reactor stops.
#[pyclass(frozen)]
struct ShutdownHook {
inner: Weak<RustRuntimeInner>,
}
#[pymethods]
impl ShutdownHook {
fn __call__(&self, py: Python<'_>) -> PyResult<()> {
if let Some(inner) = self.inner.upgrade() {
inner.shutdown(py)?;
}
Ok(())
}
}
/// Called when registering modules with python.
pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
let child_module = PyModule::new(py, "runtime")?;
child_module.add_class::<RustRuntime>()?;
child_module.add_function(wrap_pyfunction!(set_virtual_time_msec, m)?)?;
m.add_submodule(&child_module)?;
py.import("sys")?
.getattr("modules")?
.set_item("synapse.synapse_rust.runtime", child_module)?;
Ok(())
}