Run the federation queues in Rust at rust_federation_sending: queues

`FederationSender` makes a `RustPerDestinationQueue` for each
destination at the new `queues` level. It forwards the queue methods to
Rust and provides the hooks: catch-up, the sticky backlog, the device
EDUs, and recording a sent transaction (stream positions, the module
callback, the metrics).

The catch-up loop moves into `_CatchUpLoop`, which both queues share.
`StickyEventBacklogTracker.load_backlog` splits the database part out of
`prepare_transaction`.

The sender, catch-up and sticky event tests also run at this level, with
every reactor advance waiting for the Rust loops to settle.
`test_send_receipts_thread` is skipped there: it depends on coroutine
order, which the Rust loop changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
This commit is contained in:
Erik JohnstonandClaude Opus 5.5 committed 2026-10-15 14:54:44 +00:00
1 parent 9e90814d9b
commit 0ebe577640
9 files changed
+1022 -223

No files matched your search

+1
View File
@@ -0,0 +1 @@
Run the per-destination federation queues and their transmission loops in Rust at `experimental_features.rust_federation_sending: queues`.
+5 -1
View File
@@ -50,7 +50,7 @@ class MSC3866Config:
require_approval_for_new_accounts: bool = False
RustFederationSendingLevel = Literal["off", "client", "transactions"]
RustFederationSendingLevel = Literal["off", "client", "transactions", "queues"]
# The levels of `rust_federation_sending`, in order. Each implies the ones
# before it.
@@ -58,6 +58,7 @@ RUST_FEDERATION_SENDING_LEVELS: tuple[RustFederationSendingLevel, ...] = (
"off",
"client",
"transactions",
"queues",
)
@@ -377,6 +378,9 @@ class ExperimentalConfig(Config):
# federation traffic goes through a proxy worker, stay Python.
# - "transactions": the `TransactionManager` builds and sends `/send`
# transactions in Rust, PDU JSON included.
# - "queues": each destination's queue and transmission loop run in
# Rust. Catch-up, the sticky event backlog and the database work
# of the loop stay Python, called from Rust.
rust_federation_sending = experimental.get("rust_federation_sending", "off")
if rust_federation_sending not in RUST_FEDERATION_SENDING_LEVELS:
raise ConfigError(
+27 -3
View File
@@ -154,6 +154,7 @@ from synapse.federation.sender.per_destination_queue import (
CATCHUP_RETRY_INTERVAL,
PerDestinationQueue,
)
from synapse.federation.sender.rust_queue import RustPerDestinationQueue
from synapse.federation.sender.transaction_manager import TransactionManager
from synapse.federation.units import Edu
from synapse.logging.context import make_deferred_yieldable, run_in_background
@@ -167,6 +168,7 @@ from synapse.metrics import (
from synapse.metrics.background_process_metrics import (
wrap_as_background_process,
)
from synapse.synapse_rust.federation import RustDestinationQueues
from synapse.types import (
JsonDict,
ReadReceipt,
@@ -418,7 +420,22 @@ class FederationSender(AbstractFederationSender):
self._federation_shard_config = hs.config.worker.federation_shard_config
# map from destination to PerDestinationQueue
self._per_destination_queues: dict[str, PerDestinationQueue] = {}
self._per_destination_queues: dict[
str, PerDestinationQueue | RustPerDestinationQueue
] = {}
# At `rust_federation_sending: queues`, the queues and their loops
# are Rust's. They send through the Rust transaction sender, so there
# are none where that is missing.
self._rust_queues: RustDestinationQueues | None = None
rust_transactions = self._transaction_manager._rust
if (
hs.config.experimental.rust_federation_sending_at_least("queues")
and rust_transactions is not None
):
self._rust_queues = RustDestinationQueues(
rust_transactions, hs.config.experimental.msc4354_enabled
)
transaction_queue_pending_destinations_gauge.register_hook(
homeserver_instance_id=hs.get_instance_id(),
@@ -482,7 +499,7 @@ class FederationSender(AbstractFederationSender):
def _get_per_destination_queue(
self, destination: str
) -> PerDestinationQueue | None:
) -> PerDestinationQueue | RustPerDestinationQueue | None:
"""Get or create a PerDestinationQueue for the given destination
Args:
@@ -499,7 +516,14 @@ class FederationSender(AbstractFederationSender):
queue = self._per_destination_queues.get(destination)
if not queue:
queue = PerDestinationQueue(self.hs, self._transaction_manager, destination)
if self._rust_queues is not None:
queue = RustPerDestinationQueue(
self.hs, self._transaction_manager, destination, self._rust_queues
)
else:
queue = PerDestinationQueue(
self.hs, self._transaction_manager, destination
)
self._per_destination_queues[destination] = queue
return queue
+257 -213
View File
@@ -23,7 +23,7 @@ import datetime
import itertools
import logging
from collections import OrderedDict
from typing import TYPE_CHECKING, Hashable, Iterable
from typing import TYPE_CHECKING, Hashable, Iterable, Protocol
import attr
from prometheus_client import Counter
@@ -55,6 +55,8 @@ from synapse.visibility import filter_events_for_server
if TYPE_CHECKING:
import synapse.server
from synapse.storage.controllers import StorageControllers
from synapse.storage.databases.main import DataStore
logger = logging.getLogger(__name__)
@@ -158,7 +160,222 @@ class _PreparedTransaction:
return bool(self.pdus or self.edus)
class PerDestinationQueue:
class _StickyBacklogNotifier(Protocol):
def notify_potential_new_backlog(self) -> None: ...
class _CatchUpLoop:
"""The catch-up loop. Shared by `PerDestinationQueue` and the facade of the
Rust queue, `RustPerDestinationQueue`, whose loop calls it."""
server_name: str
_destination: str
_store: "DataStore"
_storage_controllers: "StorageControllers"
_transaction_manager: "synapse.federation.sender.TransactionManager"
_sticky_event_backlog_tracker: _StickyBacklogNotifier
_catching_up: bool
_catchup_last_skipped: int
_last_successful_stream_ordering: int | None
_transmission_loop_enabled: bool
def _start_catching_up(self) -> None:
raise NotImplementedError()
async def _catch_up_transmission_loop(self) -> None:
first_catch_up_check = self._last_successful_stream_ordering is None
if first_catch_up_check:
# first catchup so get last_successful_stream_ordering from database
self._last_successful_stream_ordering = (
await self._store.get_destination_last_successful_stream_ordering(
self._destination
)
)
if self._last_successful_stream_ordering is None:
# if it's still None, then this means we don't have the information
# in our database ­ we haven't successfully sent a PDU to this server
# (at least since the introduction of the feature tracking
# last_successful_stream_ordering).
# Sadly, this means we can't do anything here as we don't know what
# needs catching up — so catching up is futile; let's stop.
self._catching_up = False
return
# get at most 50 catchup room/PDUs
while self._transmission_loop_enabled:
event_ids = await self._store.get_catch_up_room_event_ids(
self._destination, self._last_successful_stream_ordering
)
if not event_ids:
# No more events to catch up on, but we can't ignore the chance
# of a race condition, so we check that no new events have been
# skipped due to us being in catch-up mode
if (
self._catchup_last_skipped != 0
and self._catchup_last_skipped
> self._last_successful_stream_ordering
):
# another event has been skipped because we were in catch-up mode
# As an exception to this case: we can hit this branch if the
# room has been purged whilst we have been looping.
# In that case we avoid hot-looping by resetting the 'catch-up skipped
# PDU' flag.
# Then if there is still no progress to be made at the next iteration,
# we can exit catch-up mode.
self._catchup_last_skipped = 0
continue
# we are done catching up!
self._catching_up = False
break
if first_catch_up_check:
# as this is our check for needing catch-up, we may have PDUs in
# the queue from before we *knew* we had to do catch-up, so
# clear those out now.
self._start_catching_up()
# fetch the relevant events from the event store
# - redacted behaviour of REDACT is fine, since we only send metadata
# of redacted events to the destination.
# - don't need to worry about rejected events as we do not actively
# forward received events over federation.
catchup_pdus = await self._store.get_events_as_list(event_ids)
if not catchup_pdus:
raise AssertionError(
"No events retrieved when we asked for %r. "
"This should not happen." % event_ids
)
logger.info(
"Catching up destination %s with %d PDUs",
self._destination,
len(catchup_pdus),
)
# We send transactions with events from one room only, as its likely
# that the remote will have to do additional processing, which may
# take some time. It's better to give it small amounts of work
# rather than risk the request timing out and repeatedly being
# retried, and not making any progress.
#
# Note: `catchup_pdus` will have exactly one PDU per room.
for pdu in catchup_pdus:
# The PDU from the DB will be the newest PDU in the room from
# *this server* that we tried---but were unable---to send to the remote.
# servers may have sent lots of events since then, and we want
# to try and tell the remote only about the *latest* events in
# the room. This is so that it doesn't get inundated by events
# from various parts of the DAG, which all need to be processed.
#
# Note: this does mean that in large rooms a server coming back
# online will get sent the same events from all the different
# servers, but the remote will correctly deduplicate them and
# handle it only once.
# Step 1, fetch the current extremities
extrems = await self._store.get_prev_events_for_room(pdu.room_id)
if pdu.event_id in extrems:
# If the event is in the extremities, then great! We can just
# use that without having to do further checks.
room_catchup_pdus = [pdu]
elif await self._store.is_partial_state_room(pdu.room_id):
# We can't be sure which events the destination should
# see using only partial state. Avoid doing so, and just retry
# sending our the newest PDU the remote is missing from us.
room_catchup_pdus = [pdu]
else:
# If not, fetch the extremities and figure out which we can
# send.
extrem_events = await self._store.get_events_as_list(extrems)
new_pdus = []
for p in extrem_events:
# We pulled this from the DB, so it'll be non-null
assert p.internal_metadata.stream_ordering
# Filter out events that happened before the remote went
# offline
if (
p.internal_metadata.stream_ordering
< self._last_successful_stream_ordering
):
continue
new_pdus.append(p)
# Filter out events where the server is not in the room,
# e.g. it may have left/been kicked. *Ideally* we'd pull
# out the kick and send that, but it's a rare edge case
# so we don't bother for now (the server that sent the
# kick should send it out if its online).
new_pdus = await filter_events_for_server(
self._storage_controllers,
self._destination,
self.server_name,
new_pdus,
redact=False,
filter_out_erased_senders=True,
filter_out_remote_partial_state_events=True,
)
# If we've filtered out all the extremities, fall back to
# sending the original event. This should ensure that the
# server gets at least some of missed events (especially if
# the other sending servers are up).
if new_pdus:
room_catchup_pdus = new_pdus
else:
room_catchup_pdus = [pdu]
logger.info(
"Catching up rooms to %s: %r", self._destination, pdu.room_id
)
await self._transaction_manager.send_new_transaction(
self._destination, room_catchup_pdus, []
)
sent_transactions_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()
# We pulled this from the DB, so it'll be non-null
assert pdu.internal_metadata.stream_ordering
# When advancing our `last_successful_stream_ordering` position,
# there may be unsent sticky events 'in the gap'; note that down as a backlog.
await self._store.mark_backlogged_sticky_events_after_catchup_transaction(
self._destination,
old_last_successfully_sent_stream_ordering=self._last_successful_stream_ordering,
new_last_successfully_sent_stream_ordering=pdu.internal_metadata.stream_ordering,
# These are the events we actually sent in this successful catch-up transaction
event_stream_orderings_sent_in_transaction={
# unwrap: these events have been persisted so `stream_ordering` is not None
unwrap(room_catchup_pdu.internal_metadata.stream_ordering)
for room_catchup_pdu in room_catchup_pdus
},
)
self._sticky_event_backlog_tracker.notify_potential_new_backlog()
# Note that we mark the last successful stream ordering as that
# from the *original* PDU, rather than the PDU(s) we actually
# send. This is because we use it to mark our position in the
# queue of missed PDUs to process.
self._last_successful_stream_ordering = (
pdu.internal_metadata.stream_ordering
)
await self._store.set_destination_last_successful_stream_ordering(
self._destination, self._last_successful_stream_ordering
)
class PerDestinationQueue(_CatchUpLoop):
"""
Manages the per-destination transmission queues.
Runs until `shutdown()` is called on the queue.
@@ -185,8 +402,10 @@ class PerDestinationQueue:
self._instance_name = hs.get_instance_name()
self._federation_shard_config = hs.config.worker.federation_shard_config
self._state = hs.get_state_handler()
self._sticky_event_backlog_tracker = StickyEventBacklogTracker(
destination, self._hs, self._hs.config.experimental.msc4354_enabled
self._sticky_event_backlog_tracker: StickyEventBacklogTracker = (
StickyEventBacklogTracker(
destination, self._hs, self._hs.config.experimental.msc4354_enabled
)
)
self._sticky_backlog_turn = False
"""
@@ -575,198 +794,6 @@ class PerDestinationQueue:
self.active_transmission_loop = None
self.transmission_loop_running = False
async def _catch_up_transmission_loop(self) -> None:
first_catch_up_check = self._last_successful_stream_ordering is None
if first_catch_up_check:
# first catchup so get last_successful_stream_ordering from database
self._last_successful_stream_ordering = (
await self._store.get_destination_last_successful_stream_ordering(
self._destination
)
)
if self._last_successful_stream_ordering is None:
# if it's still None, then this means we don't have the information
# in our database ­ we haven't successfully sent a PDU to this server
# (at least since the introduction of the feature tracking
# last_successful_stream_ordering).
# Sadly, this means we can't do anything here as we don't know what
# needs catching up — so catching up is futile; let's stop.
self._catching_up = False
return
# get at most 50 catchup room/PDUs
while self._transmission_loop_enabled:
event_ids = await self._store.get_catch_up_room_event_ids(
self._destination, self._last_successful_stream_ordering
)
if not event_ids:
# No more events to catch up on, but we can't ignore the chance
# of a race condition, so we check that no new events have been
# skipped due to us being in catch-up mode
if (
self._catchup_last_skipped != 0
and self._catchup_last_skipped
> self._last_successful_stream_ordering
):
# another event has been skipped because we were in catch-up mode
# As an exception to this case: we can hit this branch if the
# room has been purged whilst we have been looping.
# In that case we avoid hot-looping by resetting the 'catch-up skipped
# PDU' flag.
# Then if there is still no progress to be made at the next iteration,
# we can exit catch-up mode.
self._catchup_last_skipped = 0
continue
# we are done catching up!
self._catching_up = False
break
if first_catch_up_check:
# as this is our check for needing catch-up, we may have PDUs in
# the queue from before we *knew* we had to do catch-up, so
# clear those out now.
self._start_catching_up()
# fetch the relevant events from the event store
# - redacted behaviour of REDACT is fine, since we only send metadata
# of redacted events to the destination.
# - don't need to worry about rejected events as we do not actively
# forward received events over federation.
catchup_pdus = await self._store.get_events_as_list(event_ids)
if not catchup_pdus:
raise AssertionError(
"No events retrieved when we asked for %r. "
"This should not happen." % event_ids
)
logger.info(
"Catching up destination %s with %d PDUs",
self._destination,
len(catchup_pdus),
)
# We send transactions with events from one room only, as its likely
# that the remote will have to do additional processing, which may
# take some time. It's better to give it small amounts of work
# rather than risk the request timing out and repeatedly being
# retried, and not making any progress.
#
# Note: `catchup_pdus` will have exactly one PDU per room.
for pdu in catchup_pdus:
# The PDU from the DB will be the newest PDU in the room from
# *this server* that we tried---but were unable---to send to the remote.
# servers may have sent lots of events since then, and we want
# to try and tell the remote only about the *latest* events in
# the room. This is so that it doesn't get inundated by events
# from various parts of the DAG, which all need to be processed.
#
# Note: this does mean that in large rooms a server coming back
# online will get sent the same events from all the different
# servers, but the remote will correctly deduplicate them and
# handle it only once.
# Step 1, fetch the current extremities
extrems = await self._store.get_prev_events_for_room(pdu.room_id)
if pdu.event_id in extrems:
# If the event is in the extremities, then great! We can just
# use that without having to do further checks.
room_catchup_pdus = [pdu]
elif await self._store.is_partial_state_room(pdu.room_id):
# We can't be sure which events the destination should
# see using only partial state. Avoid doing so, and just retry
# sending our the newest PDU the remote is missing from us.
room_catchup_pdus = [pdu]
else:
# If not, fetch the extremities and figure out which we can
# send.
extrem_events = await self._store.get_events_as_list(extrems)
new_pdus = []
for p in extrem_events:
# We pulled this from the DB, so it'll be non-null
assert p.internal_metadata.stream_ordering
# Filter out events that happened before the remote went
# offline
if (
p.internal_metadata.stream_ordering
< self._last_successful_stream_ordering
):
continue
new_pdus.append(p)
# Filter out events where the server is not in the room,
# e.g. it may have left/been kicked. *Ideally* we'd pull
# out the kick and send that, but it's a rare edge case
# so we don't bother for now (the server that sent the
# kick should send it out if its online).
new_pdus = await filter_events_for_server(
self._storage_controllers,
self._destination,
self.server_name,
new_pdus,
redact=False,
filter_out_erased_senders=True,
filter_out_remote_partial_state_events=True,
)
# If we've filtered out all the extremities, fall back to
# sending the original event. This should ensure that the
# server gets at least some of missed events (especially if
# the other sending servers are up).
if new_pdus:
room_catchup_pdus = new_pdus
else:
room_catchup_pdus = [pdu]
logger.info(
"Catching up rooms to %s: %r", self._destination, pdu.room_id
)
await self._transaction_manager.send_new_transaction(
self._destination, room_catchup_pdus, []
)
sent_transactions_counter.labels(
**{SERVER_NAME_LABEL: self.server_name}
).inc()
# We pulled this from the DB, so it'll be non-null
assert pdu.internal_metadata.stream_ordering
# When advancing our `last_successful_stream_ordering` position,
# there may be unsent sticky events 'in the gap'; note that down as a backlog.
await self._store.mark_backlogged_sticky_events_after_catchup_transaction(
self._destination,
old_last_successfully_sent_stream_ordering=self._last_successful_stream_ordering,
new_last_successfully_sent_stream_ordering=pdu.internal_metadata.stream_ordering,
# These are the events we actually sent in this successful catch-up transaction
event_stream_orderings_sent_in_transaction={
# unwrap: these events have been persisted so `stream_ordering` is not None
unwrap(room_catchup_pdu.internal_metadata.stream_ordering)
for room_catchup_pdu in room_catchup_pdus
},
)
self._sticky_event_backlog_tracker.notify_potential_new_backlog()
# Note that we mark the last successful stream ordering as that
# from the *original* PDU, rather than the PDU(s) we actually
# send. This is because we use it to mark our position in the
# queue of missed PDUs to process.
self._last_successful_stream_ordering = (
pdu.internal_metadata.stream_ordering
)
await self._store.set_destination_last_successful_stream_ordering(
self._destination, self._last_successful_stream_ordering
)
def _get_receipt_edus(self, limit: int) -> Iterable[Edu]:
if not self._pending_receipt_edus:
return
@@ -1137,17 +1164,42 @@ class StickyEventBacklogTracker:
if not self._backlogged:
return None
loaded = await self.load_backlog()
if loaded is None:
logger.info(
"Caught up on federation sticky event backlog for destination %r",
self._destination,
)
self._backlogged = False
return None
info, sticky_events = loaded
return _PreparedTransaction(
pdus=sticky_events,
# No EDUs are sent alongside backlogged sticky events.
edus=[],
device_list_stream_id=None,
to_device_message_stream_id=None,
last_stream_ordering=None,
# Upon completion, advance in the sticky backlog stream
sticky_events=info,
)
async def load_backlog(
self,
) -> tuple[_StickyEventsTransactionInfo, list[EventBase]] | None:
"""
Load up to 50 backlogged sticky events from one room, filtered for the
destination, and the position to record once they are sent.
Returns None if the backlog is empty.
"""
# Select a room and get up to 50 backlogged sticky events (to fill a transaction)
backlog = await self._store.get_backlogged_sticky_events_for_destination(
self._destination, limit=MAX_PDUS_PER_TRANSACTION
)
if backlog is None:
logger.info(
"Caught up on federation sticky event backlog for destination %r",
self._destination,
)
self._backlogged = False
return None
room_id, sticky_event_stream_position, event_ids = backlog
@@ -1186,19 +1238,11 @@ class StickyEventBacklogTracker:
filter_out_remote_partial_state_events=False,
)
return _PreparedTransaction(
pdus=sticky_events,
# No EDUs are sent alongside backlogged sticky events.
edus=[],
device_list_stream_id=None,
to_device_message_stream_id=None,
last_stream_ordering=None,
# Upon completion, advance in the sticky backlog stream
sticky_events=_StickyEventsTransactionInfo(
room_id=room_id,
max_sent_sticky_events_stream_position=sticky_event_stream_position,
),
info = _StickyEventsTransactionInfo(
room_id=room_id,
max_sent_sticky_events_stream_position=sticky_event_stream_position,
)
return info, sticky_events
async def complete_transaction(self, info: _StickyEventsTransactionInfo) -> None:
"""
+317
View File
@@ -0,0 +1,317 @@
#
# 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-destination queue at `rust_federation_sending: queues`.
The queue and its transmission loop are Rust's. `RustPerDestinationQueue` has
the methods of `PerDestinationQueue` that `FederationSender` calls, and
forwards them. The loop calls back into it for the parts still in Python:
catch-up, the sticky event backlog, and the database work around each
transaction. Those are the `_rust_*` coroutines.
"""
import logging
from typing import TYPE_CHECKING, Hashable, Iterable
from synapse.api.presence import UserPresenceState
from synapse.events import EventBase
from synapse.federation.sender.per_destination_queue import (
StickyEventBacklogTracker,
_CatchUpLoop,
_StickyEventsTransactionInfo,
sent_edus_by_type,
sent_edus_counter,
)
from synapse.federation.sender.transaction_manager import last_pdu_ts_metric
from synapse.federation.units import Edu
from synapse.logging import issue9533_logger
from synapse.logging.opentracing import whitelisted_homeserver
from synapse.metrics import SERVER_NAME_LABEL, sent_transactions_counter
from synapse.metrics.background_process_metrics import BackgroundProcessLoggingContext
from synapse.module_api.callbacks.federation import FederatedEventDeliveryMethod
from synapse.synapse_rust.federation import (
RustDestinationQueue,
RustDestinationQueues,
)
from synapse.types import JsonDict, ReadReceipt
if TYPE_CHECKING:
import synapse.server
from synapse.federation.sender import TransactionManager
logger = logging.getLogger(__name__)
class _RustStickyBacklog:
"""`StickyEventBacklogTracker`'s flag, which the Rust queue holds."""
def __init__(self, rust: RustDestinationQueue) -> None:
self._rust = rust
@property
def is_backlogged(self) -> bool:
return self._rust.sticky_backlogged
@property
def _backlogged(self) -> bool:
return self._rust.sticky_backlogged
@_backlogged.setter
def _backlogged(self, backlogged: bool) -> None:
self._rust.sticky_backlogged = backlogged
def notify_potential_new_backlog(self) -> None:
self._rust.sticky_backlogged = True
class RustPerDestinationQueue(_CatchUpLoop):
"""`PerDestinationQueue`, with the queue and the loop in Rust."""
def __init__(
self,
hs: "synapse.server.HomeServer",
transaction_manager: "TransactionManager",
destination: str,
rust_queues: RustDestinationQueues,
):
self.server_name = hs.hostname
self._hs = hs
self._storage_controllers = hs.get_storage_controllers()
self._store = hs.get_datastores().main
self._transaction_manager = transaction_manager
self._destination = destination
self._federation_callbacks = hs.get_module_api_callbacks().federation
self._federation_metrics_domains = (
hs.config.federation.federation_metrics_domains
)
self._record_request_metrics = (
hs.get_federation_http_client()._record_request_metrics
)
# Its database half. The Rust queue holds its flag.
self._sticky_backlog_store = StickyEventBacklogTracker(
destination, hs, hs.config.experimental.msc4354_enabled
)
should_send_on_this_instance = (
hs.config.worker.federation_shard_config.should_handle(
hs.get_instance_name(), destination
)
)
self._rust = rust_queues.queue(destination, self, should_send_on_this_instance)
self._sticky_event_backlog_tracker = _RustStickyBacklog(self._rust)
def __str__(self) -> str:
return "RustPerDestinationQueue[%s]" % self._destination
def shutdown(self) -> None:
"""Instruct the queue to stop processing any further requests"""
self._transaction_manager.shutdown()
self._rust.shutdown()
def pending_pdu_count(self) -> int:
return self._rust.pending_pdu_count()
def pending_edu_count(self) -> int:
return self._rust.pending_edu_count()
@property
def transmission_loop_running(self) -> bool:
return self._rust.transmission_loop_running
@property
def _new_data_to_send(self) -> bool:
return self._rust.new_data_to_send
def send_pdu(self, pdu: EventBase) -> None:
self._rust.send_pdu(pdu)
def send_presence(
self, states: Iterable[UserPresenceState], start_loop: bool = True
) -> None:
self._rust.send_presence(list(states), start_loop)
def queue_read_receipt(self, receipt: ReadReceipt) -> None:
self._rust.queue_read_receipt(receipt)
def send_keyed_edu(self, edu: Edu, key: Hashable) -> None:
self._strip_context(edu)
self._rust.send_keyed_edu(edu, repr(key))
def send_edu(self, edu: Edu) -> None:
self._strip_context(edu)
self._rust.send_edu(edu)
def mark_new_data(self) -> None:
self._rust.mark_new_data()
def attempt_new_transaction(self) -> None:
self._rust.attempt_new_transaction()
def _strip_context(self, edu: Edu) -> None:
# As `send_new_transaction` does. The `send_transaction` span the
# contexts would have followed from is not made at this level.
if whitelisted_homeserver(self._destination):
edu.strip_context()
# The catch-up state, which `_catch_up_transmission_loop` reads and
# writes.
@property
def _catching_up(self) -> bool:
return self._rust.catching_up
@_catching_up.setter
def _catching_up(self, catching_up: bool) -> None:
self._rust.catching_up = catching_up
@property
def _catchup_last_skipped(self) -> int:
return self._rust.catchup_last_skipped
@_catchup_last_skipped.setter
def _catchup_last_skipped(self, stream_ordering: int) -> None:
self._rust.catchup_last_skipped = stream_ordering
@property
def _last_successful_stream_ordering(self) -> int | None:
return self._rust.last_successful_stream_ordering
@_last_successful_stream_ordering.setter
def _last_successful_stream_ordering(self, stream_ordering: int | None) -> None:
self._rust.last_successful_stream_ordering = stream_ordering
@property
def _transmission_loop_enabled(self) -> bool: # type: ignore[override]
return self._rust.transmission_loop_enabled
def _start_catching_up(self) -> None:
self._rust.start_catching_up()
# The hooks the Rust loop calls. Each runs in a logcontext of its own, as
# the Python loop runs as a background process.
def _logcontext(self, name: str) -> BackgroundProcessLoggingContext:
return BackgroundProcessLoggingContext(name=name, server_name=self.server_name)
async def _rust_catch_up(self) -> None:
with self._logcontext("federation_catch_up_transmission_loop"):
await self._catch_up_transmission_loop()
async def _rust_device_edus(
self,
last_device_stream_id: int,
last_device_list_stream_id: int,
to_device_limit: int,
edu_limit: int,
) -> tuple[list[JsonDict], int, list[tuple[str, JsonDict]], int]:
"""The to-device messages and device list updates for the next
transaction, and how far through each stream they go."""
with self._logcontext("federation_queue_device_edus"):
to_device_stream_id = self._store.get_to_device_stream_token()
contents, stream_id = await self._store.get_new_device_msgs_for_remote(
self._destination,
last_device_stream_id,
to_device_stream_id,
to_device_limit,
)
if contents:
issue9533_logger.debug(
"Sending %i to-device messages to %s, up to stream id %i",
len(contents),
self._destination,
stream_id,
)
device_list_limit = edu_limit - len(contents)
(
device_list_stream_id,
results,
) = await self._store.get_device_updates_by_remote(
self._destination, last_device_list_stream_id, limit=device_list_limit
)
assert len(results) <= device_list_limit, (
"get_device_updates_by_remote returned too many EDUs"
)
return contents, stream_id, list(results), device_list_stream_id
async def _rust_sticky_backlog(
self,
) -> tuple[str, _StickyEventsTransactionInfo, list[EventBase]] | None:
with self._logcontext("federation_queue_sticky_backlog"):
loaded = await self._sticky_backlog_store.load_backlog()
if loaded is None:
return None
info, events = loaded
return info.room_id.to_string(), info, events
async def _rust_complete(
self,
to_device_stream_id: int | None,
device_list_stream_id: int | None,
last_stream_ordering: int | None,
sticky: tuple[str, _StickyEventsTransactionInfo] | None,
sent: tuple[list[EventBase], list[str], list[str], int, list[int]] | None,
) -> None:
"""Record a finished transaction: what `send_new_transaction` does
after the response, the counters, and `_complete_transaction`."""
with self._logcontext("federation_queue_complete"):
if sent is not None:
await self._record_sent(*sent)
if to_device_stream_id:
await self._store.delete_device_msgs_for_remote(
self._destination, to_device_stream_id
)
if device_list_stream_id:
await self._store.mark_as_sent_devices_by_remote(
self._destination, device_list_stream_id
)
if last_stream_ordering:
await self._store.set_destination_last_successful_stream_ordering(
self._destination, last_stream_ordering
)
if sticky is not None:
await self._sticky_backlog_store.complete_transaction(sticky[1])
async def _record_sent(
self,
pdus: list[EventBase],
edu_types: list[str],
acknowledged: list[str],
attempts: int,
responses: list[int],
) -> None:
self._record_request_metrics("PUT", attempts, responses)
if self._federation_callbacks.interested_in_events_delivered_over_federation():
acknowledged_ids = set(acknowledged)
await self._federation_callbacks.notify_on_event_delivered_over_federation(
self._destination,
[p for p in pdus if p.event_id in acknowledged_ids],
FederatedEventDeliveryMethod.SEND,
)
if pdus and self._destination in self._federation_metrics_domains:
last_pdu_ts_metric.labels(
destination_server_name=self._destination,
**{SERVER_NAME_LABEL: self.server_name},
).set(pdus[-1].origin_server_ts / 1000)
sent_transactions_counter.labels(**{SERVER_NAME_LABEL: self.server_name}).inc()
sent_edus_counter.labels(**{SERVER_NAME_LABEL: self.server_name}).inc(
len(edu_types)
)
for edu_type in edu_types:
sent_edus_by_type.labels(
type=edu_type, **{SERVER_NAME_LABEL: self.server_name}
).inc()
+89 -4
View File
@@ -11,7 +11,8 @@
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
"""Run federation sender tests with Rust building the transactions.
"""Run federation sender tests with Rust building the transactions, or at
`queues`, running the queues too.
The tests mock `TransportLayerClient.send_transaction`, which the Rust sender
does not call. `RustTransactionsMixin` puts a stand-in for the Rust sender in
@@ -20,14 +21,19 @@ would be with a Python `Transaction`, and Rust reads the mock's response. The
HTTP half of the Rust sender is tested in `tests.http.test_rust_transactions`.
"""
from typing import TYPE_CHECKING, Callable, Sequence
import time
from typing import TYPE_CHECKING, Any, Callable, Sequence
from twisted.internet.testing import MemoryReactor
from twisted.internet.testing import MemoryReactor, MemoryReactorClock
from synapse.events import EventBase
from synapse.federation.sender import FederationSender
from synapse.server import HomeServer
from synapse.synapse_rust.federation import RustTransaction, RustTransactionManager
from synapse.synapse_rust.federation import (
RustDestinationQueues,
RustTransaction,
RustTransactionManager,
)
from synapse.types import JsonDict
from synapse.util.clock import Clock
@@ -100,3 +106,82 @@ class RustTransactionsMixin(_Base):
manager._rust = _MockedRustTransactions( # type: ignore[assignment]
manager._rust, self.federation_transport_client
)
def wait_for_rust_queues(
reactor: MemoryReactorClock, queues: RustDestinationQueues
) -> None:
"""Advance the reactor by nothing until every Rust transmission loop has
finished or waits on something that needs time to pass.
The loops run on tokio, and the reactor does not wait for them. A loop is
done for now when it is idle and the reactor has nothing due.
"""
for i in range(5000):
reactor.advance(0)
due = any(
call.getTime() <= reactor.seconds() for call in reactor.getDelayedCalls()
)
if queues.idle() and not due:
return
time.sleep(0 if i < 10 else 0.001)
raise AssertionError("the Rust federation queues did not settle")
class RustQueuesMixin(_Base):
"""Run a test case at `rust_federation_sending: queues`. The test case
must mock the federation transport client as
`self.federation_transport_client`.
The Rust loops send through the mocked `send_transaction`, as the Python
loop does. Every `reactor.advance` waits for the loops to settle, so the
test's checks after one see what the loops did.
"""
federation_transport_client: "Mock"
def default_config(self) -> JsonDict:
config = super().default_config()
config.setdefault("experimental_features", {})["rust_federation_sending"] = (
"queues"
)
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
super().prepare(reactor, clock, hs)
sender = hs.get_federation_sender()
assert isinstance(sender, FederationSender)
queues = sender._rust_queues
assert queues is not None
transport = self.federation_transport_client
async def send(transaction: RustTransaction) -> Any:
return await transport.send_transaction(transaction, transaction.get_dict)
queues.set_send_for_test(send)
# Catch-up still sends through the transaction manager.
manager = sender._transaction_manager
assert manager._rust is not None
manager._rust_strict = RUST_FED_STRICT_FOR_TESTS
manager._rust = _MockedRustTransactions( # type: ignore[assignment]
manager._rust, transport
)
reactor = self.reactor
advance = reactor.advance
waiting = False
def advance_and_wait(amount: float) -> None:
nonlocal waiting
advance(amount)
if waiting:
return
waiting = True
try:
wait_for_rust_queues(reactor, queues)
finally:
waiting = False
reactor.advance = advance_and_wait # type: ignore[method-assign]
+11 -1
View File
@@ -22,7 +22,7 @@ from synapse.util.clock import Clock
from synapse.util.duration import Duration
from synapse.util.retryutils import NotRetryingDestination
from tests.federation._rust_transactions import RustTransactionsMixin
from tests.federation._rust_transactions import RustQueuesMixin, RustTransactionsMixin
from tests.test_utils import event_injection
from tests.unittest import FederatingHomeserverTestCase
from tests.utils import USE_POSTGRES_FOR_TESTS
@@ -958,3 +958,13 @@ class FederationStickyEventCatchUpRustTestCase(
):
"""As `FederationStickyEventCatchUpTestCase`, with Rust building the
transactions."""
class FederationCatchUpQueuesTestCases(RustQueuesMixin, FederationCatchUpTestCases):
"""As `FederationCatchUpTestCases`, with the queues in Rust."""
class FederationStickyEventCatchUpQueuesTestCase(
RustQueuesMixin, FederationStickyEventCatchUpTestCase
):
"""As `FederationStickyEventCatchUpTestCase`, with the queues in Rust."""
+33 -1
View File
@@ -39,7 +39,7 @@ from synapse.types import JsonDict, ReadReceipt
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests.federation._rust_transactions import RustTransactionsMixin
from tests.federation._rust_transactions import RustQueuesMixin, RustTransactionsMixin
from tests.unittest import HomeserverTestCase
@@ -988,3 +988,35 @@ class FederationSenderDevicesRustTestCases(
):
"""As `FederationSenderDevicesTestCases`, with Rust building the
transactions."""
class FederationSenderReceiptsQueuesTestCases(
RustQueuesMixin, FederationSenderReceiptsTestCases
):
"""As `FederationSenderReceiptsTestCases`, with the queues in Rust."""
def test_send_receipts_thread(self) -> None:
super().test_send_receipts_thread()
# The test depends on the order the four receipts reach the queue. That
# order comes from how their retry timing lookups resolve. The Python loop
# starts the first lookup itself, before the receipts do. The Rust loop
# starts its own later, from tokio, which changes the order. The queue
# then merges as Python's does, which loses a threaded receipt that is
# followed by an unthreaded one from the same user: it looks for
# `thread_id` at the top of the queued receipt rather than under `data`.
# The Rust test `receipts_with_threads_share_an_edu` checks the merge in
# the order the Python loop sees.
test_send_receipts_thread.skip = "depends on coroutine order, see above" # type: ignore[attr-defined]
class FederationSenderPresenceQueuesTestCases(
RustQueuesMixin, FederationSenderPresenceTestCases
):
"""As `FederationSenderPresenceTestCases`, with the queues in Rust."""
class FederationSenderDevicesQueuesTestCases(
RustQueuesMixin, FederationSenderDevicesTestCases
):
"""As `FederationSenderDevicesTestCases`, with the queues in Rust."""
+282
View File
@@ -0,0 +1,282 @@
#
# 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>.
#
"""Tests for the per-destination queues at `rust_federation_sending: queues`.
The sender, catch-up and sticky event tests also run at this level, through
`RustQueuesMixin`, with their mocked `send_transaction`. These tests send
through the Rust request layer instead, with a Python callable in place of the
network, as `tests.http.test_rust_federation_client` does.
"""
import json
from typing import Any
from unittest.mock import AsyncMock
from twisted.internet.testing import MemoryReactor
from synapse.api.presence import UserPresenceState
from synapse.federation.sender import FederationSender
from synapse.federation.sender.per_destination_queue import PerDestinationQueue
from synapse.federation.sender.rust_queue import RustPerDestinationQueue
from synapse.federation.units import Edu
from synapse.metrics import SERVER_NAME_LABEL, sent_transactions_counter
from synapse.module_api.callbacks.federation import (
FederatedEventDeliveryMethod,
FederationEventDeliveryEvent,
)
from synapse.rest import admin
from synapse.rest.client import login, room
from synapse.server import HomeServer
from synapse.synapse_rust.federation import (
RustDestinationQueues,
RustFederationClient,
RustTransactionManager,
federation_gil_stats,
)
from synapse.util.clock import Clock
from tests import unittest
from tests.federation._rust_transactions import wait_for_rust_queues
from tests.http.test_rust_federation_client import (
DEST,
FakeTransport,
json_response,
)
class RustQueuesTestCase(unittest.HomeserverTestCase):
servlets = [
admin.register_servlets,
login.register_servlets,
room.register_servlets,
]
def default_config(self) -> dict[str, Any]:
config = super().default_config()
config.setdefault("experimental_features", {})["rust_federation_sending"] = (
"queues"
)
config["federation_sender_instances"] = None
config["federation_metrics_domains"] = [DEST]
return config
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
cl = hs.get_federation_http_client()
self.transport = FakeTransport()
rust_client = RustFederationClient(
hs,
hs.version_string,
hs.version_string,
cl._write_retry_timings,
cl._record_request_metrics,
self.transport,
)
sender = hs.get_federation_sender()
assert isinstance(sender, FederationSender)
self.sender = sender
self.queues = RustDestinationQueues(RustTransactionManager(rust_client), False)
sender._rust_queues = self.queues
user = self.register_user("user", "pass")
tok = self.login("user", "pass")
room_id = self.helper.create_room_as(user, tok=tok)
self.helper.send(room_id, "hello", tok=tok)
self.store = hs.get_datastores().main
event_ids = self.get_success(self.store.get_latest_event_ids_in_room(room_id))
self.event = self.get_success(self.store.get_event(next(iter(event_ids))))
queue = sender._get_per_destination_queue(DEST)
assert isinstance(queue, RustPerDestinationQueue)
self.queue = queue
def _settle(self) -> None:
wait_for_rust_queues(self.reactor, self.queues)
def _sent_transactions(self) -> float:
return sent_transactions_counter.labels(
**{SERVER_NAME_LABEL: self.hs.hostname}
)._value.get()
def test_send_pdu(self) -> None:
"""The queue sends the PDU through the Rust client and records it."""
callback = AsyncMock(return_value=None)
self.hs.get_module_api_callbacks().federation.register_callbacks(
on_event_delivered_over_federation=callback
)
self.transport.responses.append(
json_response(200, {"pdus": {self.event.event_id: {}}})
)
sent_before = self._sent_transactions()
self.queue.send_pdu(self.event)
self._settle()
[request] = self.transport.requests
self.assertEqual(request.method, "PUT")
self.assertRegex(request.path, r"^/_matrix/federation/v1/send/\d+$")
body = json.loads(request.body)
self.assertEqual(body["pdus"], [self.event.get_pdu_json()])
self.assertNotIn("edus", body)
self.assertEqual(self.queue.pending_pdu_count(), 0)
self.assertFalse(self.queue.transmission_loop_running)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(DEST)
),
self.event.internal_metadata.stream_ordering,
)
callback.assert_awaited_once_with(
FederationEventDeliveryEvent(
server_name=DEST,
events=[self.event],
method=FederatedEventDeliveryMethod.SEND,
)
)
self.assertEqual(self._sent_transactions(), sent_before + 1)
def test_edus(self) -> None:
"""Keyed EDUs replace each other, and go with plain ones."""
self.transport.responses.append(json_response(200, {}))
def typing(room_id: str, typing: bool) -> Edu:
return Edu(
edu_type="m.typing",
content={"room_id": room_id, "user_id": "@u:test", "typing": typing},
origin=self.hs.hostname,
destination=DEST,
)
other = Edu(
edu_type="m.other", content={}, origin=self.hs.hostname, destination=DEST
)
# The loop cannot get past its backoff check until the reactor runs,
# so these all go in the first transaction.
self.queue.send_edu(other)
self.queue.send_keyed_edu(typing("!a", True), ("!a",))
self.queue.send_keyed_edu(typing("!a", False), ("!a",))
self._settle()
[request] = self.transport.requests
body = json.loads(request.body)
self.assertEqual(
body["edus"], [other.get_dict(), typing("!a", False).get_dict()]
)
def test_failed_send_keeps_pdu(self) -> None:
"""An error response leaves the PDU queued and backs off."""
self.transport.responses.append(
json_response(403, {"errcode": "M_FORBIDDEN", "error": "no"})
)
self.queue.send_pdu(self.event)
self._settle()
self.assertEqual(len(self.transport.requests), 1)
self.assertEqual(self.queue._rust.pending_pdu_ids(), [self.event.event_id])
timings = self.get_success(self.store.get_destination_retry_timings(DEST))
self.assertIsNotNone(timings)
def test_long_backoff_catches_up(self) -> None:
"""Backing off for over an hour drops the volatile EDUs and the PDUs,
and leaves catch-up to send the PDUs later."""
now = self.clock.time_msec()
self.get_success(
self.store.set_destination_retry_timings(DEST, now, now, 2 * 60 * 60 * 1000)
)
self.queue.send_presence(
[UserPresenceState.default("@u:test")], start_loop=False
)
self.assertEqual(self.queue.pending_edu_count(), 1)
self.queue.send_pdu(self.event)
self._settle()
self.assertEqual(self.transport.requests, [])
self.assertTrue(self.queue._catching_up)
self.assertEqual(self.queue.pending_pdu_count(), 0)
self.assertEqual(self.queue.pending_edu_count(), 0)
def test_gil_crossings(self) -> None:
"""Once caught up, a transaction takes the GIL twice in the loop: for
its device EDUs and to record it. The loop then looks for more to
send, which asks for device EDUs again, as Python's does."""
self.transport.responses.extend([json_response(200, {})] * 2)
self.queue.send_pdu(self.event)
self._settle()
before = federation_gil_stats()
self.queue.send_pdu(self.event)
self._settle()
after = federation_gil_stats()
calls = {
site: after[site][0] - before.get(site, (0, 0.0))[0]
for site in after
if after[site][0] != before.get(site, (0, 0.0))[0]
}
queue_calls = {
site: count for site, count in calls.items() if site.startswith("queue_")
}
self.assertEqual(queue_calls, {"queue_device_edus": 2, "queue_complete": 1})
def test_shutdown(self) -> None:
self.sender.shutdown()
self.queue.send_pdu(self.event)
self._settle()
self.assertEqual(self.transport.requests, [])
self.assertFalse(self.queue.transmission_loop_running)
class RustQueuesLevelTestCase(unittest.HomeserverTestCase):
"""Which queue each level gets."""
def default_config(self) -> dict[str, Any]:
config = super().default_config()
config["federation_sender_instances"] = None
return config
def _queue(self) -> object:
sender = self.hs.get_federation_sender()
assert isinstance(sender, FederationSender)
return sender._get_per_destination_queue(DEST)
@unittest.override_config(
{"experimental_features": {"rust_federation_sending": "transactions"}}
)
def test_transactions_level(self) -> None:
self.assertIsInstance(self._queue(), PerDestinationQueue)
@unittest.override_config(
{"experimental_features": {"rust_federation_sending": "queues"}}
)
def test_queues_level(self) -> None:
self.assertIsInstance(self._queue(), RustPerDestinationQueue)
@unittest.override_config(
{
"experimental_features": {"rust_federation_sending": "queues"},
"instance_map": {
"main": {"host": "testserv", "port": 8765},
"federation_sender": {"host": "testserv", "port": 1001},
},
"outbound_federation_restricted_to": ["federation_sender"],
"worker_replication_secret": "secret",
}
)
def test_proxy_worker(self) -> None:
"""Without the Rust transaction sender, the queues are Python's."""
self.assertIsInstance(self._queue(), PerDestinationQueue)