Files
synapse/tests/federation/test_rust_queues.py
T
Erik JohnstonandClaude Opus 5.5 2d76b50a9c Send the sticky event backlog in Rust on the native driver
`NativeHooks::sticky_backlog` loads a backlogged batch, cleaning up rooms
whose sticky events expired, reads the events through the shared event
cache and filters them for the destination. Its completion records the
batch natively. Where the filter would wait for full state, the batch is
left to Python, as before.

`FederationStore` also gains `get_catch_up_outstanding_destinations` for
phase 6. Each query has a differential test against Python's.

The native queue tests now run with MSC4354 on, so the loops look at the
sticky event backlog, and pin that it takes no GIL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb
2026-10-15 17:03:57 +00:00

589 lines
22 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>.
#
"""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 import SkipTest
from unittest.mock import AsyncMock
from twisted.internet.testing import MemoryReactor
from synapse.api.presence import UserPresenceState
from synapse.events import EventBase
from synapse.federation.sender import FederationSender
from synapse.federation.sender.per_destination_queue import PerDestinationQueue
from synapse.federation.sender.rust_queue import (
RustPerDestinationQueue,
use_native_store,
)
from synapse.federation.units import Edu
from synapse.metrics import REGISTRY, 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.storage.rust_pool import RustConnectionPool
from synapse.synapse_rust.federation import (
RustDestinationQueues,
RustFederationClient,
RustTransactionManager,
federation_gil_stats,
)
from synapse.util.clock import Clock
from synapse.util.duration import Duration
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,
)
# Modules register their callbacks before the queues are made, which
# read whether there are any.
self.delivered = AsyncMock(return_value=None)
hs.get_module_api_callbacks().federation.register_callbacks(
on_event_delivered_over_federation=self.delivered
)
sender = hs.get_federation_sender()
assert isinstance(sender, FederationSender)
self.sender = sender
self.queues = self._make_queues(RustTransactionManager(rust_client))
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.room_id = room_id
self.tok = tok
self.store = hs.get_datastores().main
self.event = self._send_event("hello")
queue = sender._get_per_destination_queue(DEST)
assert isinstance(queue, RustPerDestinationQueue)
self.queue = queue
def _make_queues(
self, transactions: RustTransactionManager
) -> RustDestinationQueues:
return RustDestinationQueues(
transactions, self.hs.config.experimental.msc4354_enabled, {DEST}
)
def _send_event(self, body: str) -> EventBase:
event_id = self.helper.send(self.room_id, body, tok=self.tok)["event_id"]
return self.get_success(self.store.get_event(event_id))
def _settle(self) -> None:
wait_for_rust_queues(self.reactor, self.queues)
def _queue_calls(self, before: dict[str, tuple[int, float]]) -> dict[str, int]:
"""The GIL crossings of the queues since `before`."""
after = federation_gil_stats()
return {
site: after[site][0] - before.get(site, (0, 0.0))[0]
for site in after
if site.startswith("queue_")
and after[site][0] != before.get(site, (0, 0.0))[0]
}
def _sent_transactions(self) -> float:
self.sender._flush_rust_queue_metrics()
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."""
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,
)
self.delivered.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()
self.assertEqual(
self._queue_calls(before), {"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 RustQueuesNativeTestCase(RustQueuesTestCase):
"""The same, with the database work done through the native driver, and
sticky events on, so the loops look at the sticky event backlog."""
def default_config(self) -> dict[str, Any]:
config = super().default_config()
config["experimental_features"]["msc4354_enabled"] = True
return config
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
hs = self.setup_test_homeserver()
if not isinstance(
hs.get_datastores().main.db_pool._db_pool, RustConnectionPool
):
raise SkipTest("needs the native Rust Postgres driver")
return hs
def _make_queues(
self, transactions: RustTransactionManager
) -> RustDestinationQueues:
queues = super()._make_queues(transactions)
self.assertTrue(use_native_store(self.hs, queues))
return queues
def _catch_up(self) -> None:
"""Send a first transaction, which leaves catch-up and reads the
device streams for the first time."""
self.transport.responses.append(json_response(200, {}))
self.queue.send_pdu(self.event)
self._settle()
self.transport.requests.clear()
def test_gil_crossings(self) -> None:
"""Once caught up, a transaction with no device updates takes the GIL
only to tell the module about the delivered event."""
self._catch_up()
self.transport.responses.append(
json_response(200, {"pdus": {self.event.event_id: {}}})
)
before = federation_gil_stats()
self.queue.send_pdu(self.event)
self._settle()
self.assertEqual(len(self.transport.requests), 1)
self.assertEqual(self._queue_calls(before), {"queue_complete": 1})
def test_gil_crossings_without_modules(self) -> None:
"""With no module to tell, it takes the GIL nowhere in the loop."""
self.hs.get_module_api_callbacks().federation._on_event_delivered_over_federation_callbacks.clear()
queue = self.sender._get_per_destination_queue("other.example.com")
assert isinstance(queue, RustPerDestinationQueue)
self.transport.responses.extend([json_response(200, {})] * 2)
queue.send_pdu(self.event)
self._settle()
self.transport.requests.clear()
before = federation_gil_stats()
queue.send_pdu(self.event)
self._settle()
self.assertEqual(len(self.transport.requests), 1)
self.assertEqual(self._queue_calls(before), {})
def _catch_up_destination(self, destination: str, missed: EventBase) -> None:
"""Make `destination` one we sent nothing to since before `missed`."""
stream_ordering = missed.internal_metadata.stream_ordering
assert stream_ordering is not None
self.get_success(
self.store.set_destination_last_successful_stream_ordering(
destination, stream_ordering - 1
)
)
self.get_success(
self.store.store_destination_rooms_entries(
[destination], missed.room_id, stream_ordering
)
)
def test_catch_up(self) -> None:
"""Catch-up runs in Rust: it sends the room's latest event in place of
the one the destination missed, and records how far it got, without
taking the GIL."""
self.hs.get_module_api_callbacks().federation._on_event_delivered_over_federation_callbacks.clear()
destination = "catchup.example.com"
self._catch_up_destination(destination, self.event)
latest = self._send_event("newer")
queue = self.sender._get_per_destination_queue(destination)
assert isinstance(queue, RustPerDestinationQueue)
self.transport.responses.append(json_response(200, {}))
before = federation_gil_stats()
queue.attempt_new_transaction()
self._settle()
[request] = self.transport.requests
body = json.loads(request.body)
self.assertEqual(
[pdu["content"].get("body") for pdu in body["pdus"]],
[latest.content["body"]],
)
self.assertEqual(
self.get_success(
self.store.get_destination_last_successful_stream_ordering(destination)
),
self.event.internal_metadata.stream_ordering,
)
self.assertFalse(queue._catching_up)
self.assertNotIn("queue_catch_up", self._queue_calls(before))
def test_catch_up_falls_back(self) -> None:
"""With history visible to members only, the destination, which has
no members, may not see the room's latest events, so catch-up sends
the event it missed."""
destination = "catchup.example.com"
self._catch_up_destination(destination, self.event)
self.helper.send_state(
self.room_id,
"m.room.history_visibility",
{"history_visibility": "joined"},
tok=self.tok,
)
self._send_event("newer")
queue = self.sender._get_per_destination_queue(destination)
assert isinstance(queue, RustPerDestinationQueue)
self.transport.responses.append(json_response(200, {}))
queue.attempt_new_transaction()
self._settle()
[request] = self.transport.requests
body = json.loads(request.body)
self.assertEqual(
[pdu["content"].get("body") for pdu in body["pdus"]],
[self.event.content["body"]],
)
def test_sticky_backlog(self) -> None:
"""Catching up past a sticky event backlogs it, and the loop then
sends it, all without taking the GIL."""
self.hs.get_module_api_callbacks().federation._on_event_delivered_over_federation_callbacks.clear()
destination = "sticky.example.com"
self._catch_up_destination(destination, self.event)
self.helper.send_sticky_event(
self.room_id,
"m.room.message",
duration=Duration(minutes=10),
content={"body": "sticky", "msgtype": "m.text"},
tok=self.tok,
)
latest = self._send_event("latest")
stream_ordering = latest.internal_metadata.stream_ordering
assert stream_ordering is not None
self.get_success(
self.store.store_destination_rooms_entries(
[destination], self.room_id, stream_ordering
)
)
queue = self.sender._get_per_destination_queue(destination)
assert isinstance(queue, RustPerDestinationQueue)
self.transport.responses.extend([json_response(200, {})] * 2)
before = federation_gil_stats()
queue.attempt_new_transaction()
self._settle()
bodies = [
[pdu["content"].get("body") for pdu in json.loads(request.body)["pdus"]]
for request in self.transport.requests
]
self.assertEqual(bodies, [["latest"], ["sticky"]])
# The loop looked again, found the backlog sent, and cleared it.
self.assertEqual(
self.get_success(
self.store.db_pool.simple_select_list(
"destination_rooms_sticky_events_backlog",
{"destination": destination},
["room_id"],
)
),
[],
)
self.assertFalse(queue._rust.sticky_backlogged)
calls = self._queue_calls(before)
self.assertNotIn("queue_catch_up", calls)
self.assertNotIn("queue_sticky_backlog", calls)
self.assertNotIn("queue_complete", calls)
def test_to_device(self) -> None:
"""A to-device message is sent, and deleted once sent. Reading the
stream position is the one GIL crossing."""
self._catch_up()
self.transport.responses.append(json_response(200, {}))
message = {
"sender": "@user:test",
"type": "m.test",
"message_id": "m1",
"messages": {"@u:remote": {"D": {"body": "hi"}}},
}
before = federation_gil_stats()
self.get_success(
self.store.add_remote_messages_from_client_to_device_inbox({DEST: message})
)
self.queue.attempt_new_transaction()
self._settle()
[request] = self.transport.requests
body = json.loads(request.body)
self.assertEqual(
body["edus"], [{"edu_type": "m.direct_to_device", "content": message}]
)
self.assertEqual(self._queue_calls(before), {"queue_stream_tokens": 1})
current = self.store.get_to_device_stream_token()
self.assertEqual(
self.get_success(
self.store.get_new_device_msgs_for_remote(DEST, 0, current, 10)
),
([], current),
)
def test_device_list_update(self) -> None:
"""A device list update is sent, and marked as sent."""
self._catch_up()
self.transport.responses.append(json_response(200, {}))
user = "@user:test"
self.get_success(self.store.store_device(user, "NEW", None))
self.get_success(self.store.add_device_change_to_streams(user, ["NEW"], []))
self.get_success(
self.store.add_device_list_outbound_pokes(
user_id=user, device_id="NEW", room_id="!r", hosts=[DEST], context={}
)
)
self.queue.attempt_new_transaction()
self._settle()
[request] = self.transport.requests
[edu] = json.loads(request.body)["edus"]
self.assertEqual(edu["edu_type"], "m.device_list_update")
self.assertEqual(edu["content"]["device_id"], "NEW")
pokes = self.get_success(
self.store.db_pool.simple_select_list(
"device_lists_outbound_pokes", {"destination": DEST}, ["stream_id"]
)
)
self.assertEqual(pokes, [])
def test_metrics(self) -> None:
"""The loops' counts reach the Python metrics in a scrape."""
self._catch_up()
def scrape(name: str, **labels: str) -> float | None:
# Collecting the registry runs the pre-collect hooks first.
return REGISTRY.get_sample_value(
name, {SERVER_NAME_LABEL: self.hs.hostname, **labels}
)
sent_before = scrape("synapse_federation_client_sent_transactions_total")
assert sent_before is not None
# A new event, so the gauge can only hold its time if this send set it.
self.reactor.advance(10)
event = self._send_event("again")
self.assertNotEqual(event.origin_server_ts, self.event.origin_server_ts)
self.transport.responses.append(json_response(200, {}))
self.queue.send_pdu(event)
self._settle()
self.assertEqual(
scrape("synapse_federation_client_sent_transactions_total"),
sent_before + 1,
)
self.assertEqual(
scrape(
"synapse_federation_last_sent_pdu_time", destination_server_name=DEST
),
event.origin_server_ts / 1000,
)
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)