mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 19:57:45 +00:00
`EventsWorkerStore._get_event_cache` is now the Rust `EventCache` that the sync handler and state resolution read, so each event is held once. The cache keeps Rust `CachedEvent` values. Python gets them through `RustEventCache`, a `RustCache` subclass with the `AsyncLruCache` names the rest of Synapse uses, and each hit wraps the cached event in a new `Event` object. The wrapper shares the cached event's internal metadata, unsigned data and signatures, so a change Python makes, such as adding `prev_content` or flipping `outlier`, lands in the one copy the Rust readers see. Python's `_event_ref` weak map is checked before the cache, so while anything holds an event, a lookup returns that same object, as before. A hit there marks the cache entry as read, and puts the event back if it was evicted. - The cache is sized from `caches.event_cache_size` and named `*getEvent*`, so its metrics and `per_cache_factors` keep their names. The Rust loader now counts its misses too. - Invalidating an event drops its chain position as well, and a room purge drops the room's events through the room index, plus the chain cover and state group caches, as `RustStore.invalidate_event` and `clear_event_cache` did. Those two calls are gone. - `EventsWorkerStore` and `RustWorkerStore` run their `__init__` bodies in a different order in `DataStore` and `GenericWorkerStore`, as they sit differently in each MRO. So whichever runs first builds the `RustStore`. A datastore without `RustWorkerStore`, such as the port script's, gets a standalone `RustEventCache`. - The external cache stubs (`get_external`, `set_external`, `_invalidate_async_get_event_cache` and its room variant) are dropped. They were documented no-ops. - The sync shadow diff still ignores `prev_content` on state events. Python keeps the objects it holds after the cache evicts them, so a Rust reader can still load a second copy without it. Trial's cache of one event makes that routine. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VpBQxvqZTUvHCMm3u6CR7J
302 lines
12 KiB
Python
302 lines
12 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 _make_state_cache_entry, _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 of state groups run in Rust,
|
|
through its state group entry point, 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"),
|
|
),
|
|
patch.object(
|
|
self.handler,
|
|
"_resolve_with_rust",
|
|
side_effect=AssertionError("state groups resolve as groups"),
|
|
),
|
|
):
|
|
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_resolve_state_groups(self) -> None:
|
|
"""Resolving state groups picks the cache entry's base as
|
|
`_make_state_cache_entry` does, and a second resolution does not
|
|
convert the groups' state again."""
|
|
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_groups = self.get_success(
|
|
state_controller.get_state_for_groups(set(groups.values()))
|
|
)
|
|
state_sets = list(state_groups.values())
|
|
|
|
rust.clear_event_cache()
|
|
rust.clear_conflict_cache_for_test()
|
|
self.assertEqual(rust.state_map_cache_len(), 0)
|
|
|
|
# What the Python picks from the resolved state.
|
|
resolved, _ = self.get_success(
|
|
rust.resolve(room_id, RoomVersions.V10.identifier, state_sets)
|
|
)
|
|
expected = _make_state_cache_entry(resolved, state_groups)
|
|
|
|
for attempt in range(2):
|
|
rust.clear_conflict_cache_for_test()
|
|
state_group, prev_group, delta_ids, state, stats = self.get_success(
|
|
rust.resolve_state_groups(
|
|
room_id, RoomVersions.V10.identifier, state_groups
|
|
)
|
|
)
|
|
self.assertEqual(
|
|
(state_group, prev_group, delta_ids, state),
|
|
(
|
|
expected.state_group,
|
|
expected.prev_group,
|
|
expected.delta_ids,
|
|
expected._state,
|
|
),
|
|
f"attempt {attempt}",
|
|
)
|
|
# The fork's two groups differ only in the topic, so the resolved
|
|
# state is one of them.
|
|
self.assertIn(state_group, state_groups)
|
|
self.assertIsNone(prev_group)
|
|
self.assertIsNone(delta_ids)
|
|
self.assertIsNone(state)
|
|
self.assertEqual(
|
|
stats.state_map_cache_hits, len(state_groups) if attempt else 0
|
|
)
|
|
self.assertEqual(rust.state_map_cache_len(), len(state_groups))
|
|
|
|
# A group passed with partial state is converted afresh, not served
|
|
# the cached full state.
|
|
(group, full_state), *_ = state_groups.items()
|
|
partial = {group: {("m.room.create", ""): full_state[("m.room.create", "")]}}
|
|
_, _, _, _, stats = self.get_success(
|
|
rust.resolve_state_groups(room_id, RoomVersions.V10.identifier, partial)
|
|
)
|
|
self.assertEqual(stats.state_map_cache_hits, 0)
|
|
|
|
rust.clear_event_cache()
|
|
self.assertEqual(rust.state_map_cache_len(), 0)
|
|
|
|
# Big enough for every event the resolution reads. Trial's default is one.
|
|
@unittest.override_config({"event_cache_size": 1000})
|
|
def test_event_cache(self) -> None:
|
|
"""A second resolution reads its events, chain positions and chain
|
|
links from the handler's caches, 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)
|
|
self.assertEqual(first_stats.chain_position_cache_hits, 0)
|
|
self.assertEqual(first_stats.chain_link_cache_hits, 0)
|
|
cached = rust.event_cache_len()
|
|
self.assertEqual(cached, first_stats.db_events)
|
|
self.assertGreater(rust.chain_link_cache_len(), 0)
|
|
|
|
# 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)
|
|
# Every event in the state sets has its position cached, and every
|
|
# chain its links, so the only chain query left is for the gap
|
|
# events the auth difference needs.
|
|
self.assertGreater(second_stats.chain_position_cache_hits, 0)
|
|
self.assertGreater(second_stats.chain_link_cache_hits, 0)
|
|
self.assertLess(second_stats.db_queries, first_stats.db_queries)
|
|
|
|
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)
|
|
self.assertEqual(rust.chain_link_cache_len(), 0)
|