Files
synapse/tests/storage/databases/main/test_experimental_features.py
T
Erik JohnstonandClaude Fable 5.1 4d002f82eb Move is_feature_enabled onto a cache that lives in Rust
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
2026-10-14 12:42:31 +00:00

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))