mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-06 15:18:33 +00:00
Loading and parsing events was most of a Rust resolution's time: against a warm Python event cache, Rust was level with Python. The handler now keeps the events it loads, keyed by event ID, so a resolution in a room it has seen reads them from memory. The conflict cache's LRU becomes a generic SizedLru shared by both caches. The Postgres store takes the event cache, reads it before the database and inserts what it loads. The stats report event cache hits and the time spent converting state to and from Python. Invalidation follows the Python event cache rather than having its own sources: EventsWorkerStore._invalidate_local_get_event_cache and the room-wide variant drop the same events from the Rust cache. Those two methods are where every event cache invalidation ends up, including a partial-state event's rejection status changing, which is the one change state resolution can observe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
211 lines
8.3 KiB
Python
211 lines
8.3 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 resolving state with the Rust state resolution
|
|
(`experimental_features.rust_state_res`). These need the native Rust Postgres
|
|
driver:
|
|
|
|
SYNAPSE_TEST_RUST_DRIVER=1 POSTGRES=1 run-trial.sh tests.state.test_rust_handler
|
|
"""
|
|
|
|
from unittest import SkipTest
|
|
from unittest.mock import patch
|
|
|
|
from twisted.internet.testing import MemoryReactor
|
|
|
|
from synapse.api.room_versions import RoomVersions
|
|
from synapse.rest import admin
|
|
from synapse.rest.client import login, room
|
|
from synapse.server import HomeServer
|
|
from synapse.state import _rust_shadow_counter
|
|
from synapse.storage.rust_pool import RustConnectionPool
|
|
from synapse.types import create_requester
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests import unittest
|
|
|
|
|
|
class RustStateResHandlerTestCase(unittest.HomeserverTestCase):
|
|
servlets = [
|
|
admin.register_servlets,
|
|
room.register_servlets,
|
|
login.register_servlets,
|
|
]
|
|
|
|
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 prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
self.store = hs.get_datastores().main
|
|
self.user_id = self.register_user("user", "pass")
|
|
self.tok = self.login("user", "pass")
|
|
self.handler = hs.get_state_resolution_handler()
|
|
|
|
def _fork(self, room_id: str) -> tuple[str, str]:
|
|
"""Send two topics with the same prev events, so persisting the second
|
|
resolves state across the fork. Returns their event IDs."""
|
|
event_handler = self.hs.get_event_creation_handler()
|
|
requester = create_requester(self.user_id)
|
|
prev_event_ids = self.get_success(self.store.get_prev_events_for_room(room_id))
|
|
|
|
event_ids = []
|
|
for topic in ("A", "B"):
|
|
event, unpersisted_context = self.get_success(
|
|
event_handler.create_event(
|
|
requester,
|
|
{
|
|
"type": "m.room.topic",
|
|
"state_key": "",
|
|
"content": {"topic": topic},
|
|
"room_id": room_id,
|
|
"sender": self.user_id,
|
|
},
|
|
prev_event_ids=prev_event_ids,
|
|
)
|
|
)
|
|
context = self.get_success(unpersisted_context.persist(event))
|
|
self.get_success(
|
|
event_handler.handle_new_client_event(
|
|
requester, events_and_context=[(event, context)]
|
|
)
|
|
)
|
|
event_ids.append(event.event_id)
|
|
return event_ids[0], event_ids[1]
|
|
|
|
def _shadow_count(self, result: str) -> float:
|
|
return _rust_shadow_counter.labels(
|
|
result=result, server_name=self.hs.hostname
|
|
)._value.get()
|
|
|
|
def _current_topic(self, room_id: str) -> str | None:
|
|
state = self.get_success(self.store.get_partial_current_state_ids(room_id))
|
|
return state.get(("m.room.topic", ""))
|
|
|
|
def test_shadow_mode_compares(self) -> None:
|
|
"""In shadow mode, resolutions run in both and agree."""
|
|
self.handler._rust_state_res_mode = "shadow"
|
|
self.handler._rust_state_res = self.hs.get_rust_handlers().state_resolution
|
|
assert self.handler._rust_state_res is not None
|
|
|
|
matches = self._shadow_count("match")
|
|
mismatches = self._shadow_count("mismatch")
|
|
errors = self._shadow_count("error")
|
|
|
|
room_id = self.helper.create_room_as(self.user_id, tok=self.tok)
|
|
topics = self._fork(room_id)
|
|
|
|
self.assertGreater(self._shadow_count("match"), matches)
|
|
self.assertEqual(self._shadow_count("mismatch"), mismatches)
|
|
self.assertEqual(self._shadow_count("error"), errors)
|
|
self.assertIn(self._current_topic(room_id), topics)
|
|
|
|
def test_on_mode_uses_rust(self) -> None:
|
|
"""In "on" mode, v2 and v2.1 resolutions run in Rust and the metrics
|
|
come from its stats."""
|
|
self.handler._rust_state_res_mode = "on"
|
|
self.handler._rust_state_res = self.hs.get_rust_handlers().state_resolution
|
|
assert self.handler._rust_state_res is not None
|
|
|
|
for room_version in (RoomVersions.V10, RoomVersions.V12):
|
|
room_id = self.helper.create_room_as(
|
|
self.user_id, room_version=room_version.identifier, tok=self.tok
|
|
)
|
|
|
|
with patch(
|
|
"synapse.state.v2.resolve_events_with_store",
|
|
side_effect=AssertionError("Python state res should not run"),
|
|
):
|
|
topics = self._fork(room_id)
|
|
|
|
self.assertIn(self._current_topic(room_id), topics)
|
|
self.assertIn(room_id, self.handler._state_res_metrics)
|
|
|
|
def test_on_mode_falls_back_without_chain_cover_index(self) -> None:
|
|
"""A room without a chain cover index is resolved in Python."""
|
|
self.handler._rust_state_res_mode = "on"
|
|
self.handler._rust_state_res = self.hs.get_rust_handlers().state_resolution
|
|
|
|
room_id = self.helper.create_room_as(
|
|
self.user_id, room_version=RoomVersions.V10.identifier, tok=self.tok
|
|
)
|
|
self.get_success(
|
|
self.store.db_pool.simple_update(
|
|
table="rooms",
|
|
keyvalues={"room_id": room_id},
|
|
updatevalues={"has_auth_chain_index": False},
|
|
desc="test",
|
|
)
|
|
)
|
|
|
|
with self.assertLogs("synapse.state", level="INFO") as logs:
|
|
topics = self._fork(room_id)
|
|
self.assertTrue(
|
|
any("Falling back to Python state res" in line for line in logs.output)
|
|
)
|
|
self.assertIn(self._current_topic(room_id), topics)
|
|
|
|
def test_event_cache(self) -> None:
|
|
"""A second resolution reads its events from the handler's event
|
|
cache, which the store's event cache invalidation empties."""
|
|
rust = self.hs.get_rust_handlers().state_resolution
|
|
assert rust is not None
|
|
|
|
room_id = self.helper.create_room_as(
|
|
self.user_id, room_version=RoomVersions.V10.identifier, tok=self.tok
|
|
)
|
|
topic_a, topic_b = self._fork(room_id)
|
|
groups = self.get_success(
|
|
self.store._get_state_group_for_events([topic_a, topic_b])
|
|
)
|
|
state_controller = self.hs.get_storage_controllers().state
|
|
state_sets = [
|
|
self.get_success(state_controller.get_state_ids_for_group(group))
|
|
for group in groups.values()
|
|
]
|
|
|
|
# Persisting the fork may already have resolved it here, depending on
|
|
# the test's `rust_state_res` mode.
|
|
rust.clear_event_cache()
|
|
rust.clear_conflict_cache_for_test()
|
|
|
|
first, first_stats = self.get_success(
|
|
rust.resolve(room_id, RoomVersions.V10.identifier, state_sets)
|
|
)
|
|
self.assertGreater(first_stats.db_events, 0)
|
|
self.assertEqual(first_stats.event_cache_hits, 0)
|
|
cached = rust.event_cache_len()
|
|
self.assertEqual(cached, first_stats.db_events)
|
|
|
|
# Clear the conflict cache, so the resolution runs again rather than
|
|
# being answered from it.
|
|
rust.clear_conflict_cache_for_test()
|
|
second, second_stats = self.get_success(
|
|
rust.resolve(room_id, RoomVersions.V10.identifier, state_sets)
|
|
)
|
|
self.assertEqual(second, first)
|
|
self.assertEqual(second_stats.db_events, 0)
|
|
self.assertEqual(second_stats.event_cache_hits, first_stats.db_events)
|
|
|
|
self.store._invalidate_local_get_event_cache(topic_a)
|
|
self.assertEqual(rust.event_cache_len(), cached - 1)
|
|
|
|
self.store._invalidate_local_get_event_cache_room_id(room_id)
|
|
self.assertEqual(rust.event_cache_len(), 0)
|