mirror of
https://github.com/element-hq/synapse.git
synced 2026-11-04 17:47:28 +00:00
The first Rust database function to get a cache, and the first Python `@cached` function to be replaced by one. The Rust `/versions` handler calls `Store::is_feature_enabled_for_user` on every authenticated request, and until now each call ran a query. The Python `is_feature_enabled` had a cache of the same rows under another name, which every writer would have had to invalidate alongside. Now there is one cache, keyed on `(user_id, feature)`, and both sides read through it. The main datastore builds the Rust `Store` in its `__init__`, over its own `db_pool`, and `hs.get_rust_store()` hands it to the Rust handlers: the datastore owns the store, the handlers borrow it. The store's `is_feature_enabled_for_user` reaches Python as a callable cache object, bound on the datastore under that name. Calling it answers a hit with an already-fired Deferred, without leaving the reactor thread, and only a miss goes through tokio. Being a `RustCache`, it is also what `_invalidate_cache_and_stream`, replication and `register_cache` take, so `set_features_for_user` invalidates it with the one call it already made, and the name is spelled once in Rust, where the cache is built. `is_feature_enabled` keeps its global-config short cut in Python and delegates the per-user lookup. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULdieJyQoHNzeHq42H2M1p
104 lines
4.3 KiB
Python
104 lines
4.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>.
|
|
#
|
|
#
|
|
|
|
from twisted.internet.testing import MemoryReactor
|
|
|
|
from synapse.rest.admin.experimental_features import ExperimentalFeature
|
|
from synapse.server import HomeServer
|
|
from synapse.util.clock import Clock
|
|
|
|
from tests import unittest
|
|
|
|
|
|
class ExperimentalFeaturesStoreTestCase(unittest.HomeserverTestCase):
|
|
"""`is_feature_enabled` reads through a cache that lives in Rust."""
|
|
|
|
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
|
|
self.store = hs.get_datastores().main
|
|
self.user_id = self.get_success(
|
|
hs.get_registration_handler().register_user(localpart="user")
|
|
)
|
|
|
|
def _is_enabled(self, feature: ExperimentalFeature) -> bool:
|
|
return self.get_success(self.store.is_feature_enabled(self.user_id, feature))
|
|
|
|
def test_reads_through_the_rust_cache(self) -> None:
|
|
cache = self.store.is_feature_enabled_for_user
|
|
|
|
# No row means disabled, and that answer is cached too.
|
|
self.assertFalse(self._is_enabled(ExperimentalFeature.MSC3881))
|
|
self.assertEqual((cache.hits, cache.misses), (0, 1))
|
|
self.assertFalse(self._is_enabled(ExperimentalFeature.MSC3881))
|
|
self.assertEqual((cache.hits, cache.misses), (1, 1))
|
|
|
|
# Writing invalidates, so the next read sees the new row...
|
|
self.get_success(
|
|
self.store.set_features_for_user(
|
|
self.user_id, {ExperimentalFeature.MSC3881: True}
|
|
)
|
|
)
|
|
self.assertTrue(self._is_enabled(ExperimentalFeature.MSC3881))
|
|
self.assertEqual((cache.hits, cache.misses), (1, 2))
|
|
|
|
# ...in both directions.
|
|
self.get_success(
|
|
self.store.set_features_for_user(
|
|
self.user_id, {ExperimentalFeature.MSC3881: False}
|
|
)
|
|
)
|
|
self.assertFalse(self._is_enabled(ExperimentalFeature.MSC3881))
|
|
|
|
def test_invalidation_with_plain_string_keys(self) -> None:
|
|
"""Invalidations from other workers arrive as strings.
|
|
|
|
The cache invalidation stream's column is `keys text[]`, so whatever a
|
|
key started life as, a worker receiving it sees a string, and the Rust
|
|
side has to decode that back into its own key type.
|
|
"""
|
|
cache = self.store.is_feature_enabled_for_user
|
|
self._is_enabled(ExperimentalFeature.MSC3881)
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
# A key that matches nothing is a no-op rather than an error: a worker
|
|
# may well see feature names this version doesn't know about.
|
|
cache.invalidate((self.user_id, "not_a_feature"))
|
|
cache.invalidate(("@nobody:test", "msc3881"))
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
# So is a key of the wrong shape, which a newer worker might stream. A
|
|
# Python cache shrugs those off, and raising here would fail the whole
|
|
# batch of invalidations the row arrived in.
|
|
cache.invalidate((self.user_id,))
|
|
cache.invalidate((self.user_id, "msc3881", "extra"))
|
|
cache.invalidate((self.user_id, 42))
|
|
self.assertEqual(len(cache), 1)
|
|
|
|
cache.invalidate((self.user_id, "msc3881"))
|
|
self.assertEqual(len(cache), 0)
|
|
|
|
def test_an_unknown_feature_is_an_error(self) -> None:
|
|
"""The Rust side keys the cache by its own enum of features, which
|
|
mirrors `ExperimentalFeature`. A name it doesn't know means the two
|
|
have drifted apart, and should fail rather than quietly read as off."""
|
|
with self.assertRaises(ValueError):
|
|
self.store.is_feature_enabled_for_user(self.user_id, "not_a_feature")
|
|
|
|
@unittest.override_config({"experimental_features": {"msc3881_enabled": True}})
|
|
def test_a_globally_enabled_feature_skips_the_lookup(self) -> None:
|
|
cache = self.store.is_feature_enabled_for_user
|
|
|
|
self.assertTrue(self._is_enabled(ExperimentalFeature.MSC3881))
|
|
self.assertEqual((cache.hits, cache.misses), (0, 0))
|