Files
synapse/tests/storage/test_background_update.py
T
Eric Eastwood 7da21a715d Update HomeserverTestCase.get_success(...) and friends to drive async Rust (Tokio runtime/thread pool) (#19871)
This means you can use `get_success(...)` anywhere regardless
of what kind of work needs to be done.

Spawning from adding some more async Rust things in
https://github.com/element-hq/synapse/pull/19846 and wanting something
more standard instead of the custom `till_deferred_has_result(...)` that
has crept in to a few files.

Alternative to https://github.com/element-hq/synapse/pull/19867 spurred
on by [this
comment](https://github.com/element-hq/synapse/pull/19867#discussion_r3441774685)
from @erikjohnston


### How does this work?

Previously, `get_success(...)` just ran in a hot-loop advancing the
Twisted reactor clock which didn't give any time for other threads to do
some work or acquire the GIL if necessary (whenever there is a hand-off
from Rust to Python, we need the GIL).

Now, `get_success(...)` loops until we see a result (until we hit the
~0.1s real-time timeout). In the loop, we call
[`time.sleep(0)`](https://docs.python.org/3/library/time.html#time.sleep)
which will "Suspend execution of the calling thread [...]" (CPU and GIL)
to allow other threads to do some work. Then like before, we advance the
Twisted reactor clock to run any scheduled callbacks which includes
anything the other threads may have scheduled.


### Does this slow down the entire test suite?

Seems just as fast as before. There is minutes variance in what we had
before and after but both are within the same range of each other.

(see PR for actual before/after timings)
2026-07-02 15:20:38 -05:00

690 lines
25 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2021 The Matrix.org Foundation C.I.C.
# Copyright (C) 2023 New Vector, 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>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#
import logging
from typing import cast
from unittest.mock import AsyncMock, Mock
import yaml
from twisted.internet.defer import Deferred, ensureDeferred
from twisted.internet.testing import MemoryReactor
from synapse.server import HomeServer
from synapse.storage.background_updates import (
BackgroundUpdater,
ForeignKeyConstraint,
NotNullConstraint,
run_validate_constraint_and_delete_rows_schema_delta,
)
from synapse.storage.database import LoggingTransaction
from synapse.storage.engines import PostgresEngine, Sqlite3Engine
from synapse.types import JsonDict
from synapse.util.clock import Clock
from synapse.util.duration import Duration
from tests import unittest
from tests.unittest import override_config
class BackgroundUpdateTestCase(unittest.HomeserverTestCase):
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.updates: BackgroundUpdater = self.hs.get_datastores().main.db_pool.updates
# the base test class should have run the real bg updates for us
self.assertTrue(
self.get_success(self.updates.has_completed_background_updates())
)
self.update_handler = Mock()
self.updates.register_background_update_handler(
"test_update", self.update_handler
)
self.store = self.hs.get_datastores().main
async def update(self, progress: JsonDict, count: int) -> int:
fake_work_duration = Duration(seconds=1)
await self.clock.sleep(fake_work_duration)
progress = {"my_key": progress["my_key"] + 1}
await self.store.db_pool.runInteraction(
"update_progress",
self.updates._background_update_progress_txn,
"test_update",
progress,
)
return count
def test_do_background_update(self) -> None:
# the time we claim it takes to update one item when running the update
duration_ms = 10
# the target runtime for each bg update
target_background_update_duration_ms = 100
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
background_update_d = ensureDeferred(
self.updates.do_next_background_update(False)
)
# Wait for database queries to run in `do_next_background_update(...)` so the
# background update actually gets scheduled
self.reactor.advance(0)
# Wait for the actual background update `fake_work_duration`
self.reactor.advance(Duration(seconds=1).as_secs())
res = self.get_success(background_update_d)
self.assertFalse(res)
# on the first call, we should get run with the default background update size
self.update_handler.assert_called_once_with(
{"my_key": 1}, self.updates.default_background_batch_size
)
# second step: complete the update
# we should now get run with a much bigger number of items to update
async def update(progress: JsonDict, count: int) -> int:
self.assertEqual(progress, {"my_key": 2})
self.assertAlmostEqual(
count,
target_background_update_duration_ms / duration_ms,
places=0,
)
await self.updates._end_background_update("test_update")
return count
self.update_handler.side_effect = update
self.update_handler.reset_mock()
result = self.get_success(self.updates.do_next_background_update(False))
self.assertFalse(result)
self.update_handler.assert_called_once()
# third step: we don't expect to be called any more
self.update_handler.reset_mock()
result = self.get_success(self.updates.do_next_background_update(False))
self.assertTrue(result)
self.assertFalse(self.update_handler.called)
@override_config(
yaml.safe_load(
"""
background_updates:
default_batch_size: 20
"""
)
)
def test_background_update_default_batch_set_by_config(self) -> None:
"""
Test that the background update is run with the default_batch_size set by the config
"""
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
background_update_d = ensureDeferred(
self.updates.do_next_background_update(False)
)
# Wait for database queries to run in `do_next_background_update(...)` so the
# background update actually gets scheduled
self.reactor.advance(0)
# Wait for the actual background update `fake_work_duration`
self.reactor.advance(Duration(seconds=1).as_secs())
res = self.get_success(background_update_d)
self.assertFalse(res)
# on the first call, we should get run with the default background update size specified in the config
self.update_handler.assert_called_once_with({"my_key": 1}, 20)
def test_background_update_default_sleep_behavior(self) -> None:
"""
Test default background update behavior, which is to sleep
"""
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
self.updates.start_doing_background_updates()
# 2: advance the reactor less than the default sleep duration (1000ms)
self.reactor.pump([0.5])
# check that an update has not been run
self.update_handler.assert_not_called()
# advance reactor past default sleep duration
self.reactor.pump([1])
# check that update has been run
self.update_handler.assert_called()
@override_config(
yaml.safe_load(
"""
background_updates:
sleep_duration_ms: 500
"""
)
)
def test_background_update_sleep_set_in_config(self) -> None:
"""
Test that changing the sleep time in the config changes how long it sleeps
"""
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
self.updates.start_doing_background_updates()
# 2: advance the reactor less than the configured sleep duration (500ms)
self.reactor.pump([0.45])
# check that an update has not been run
self.update_handler.assert_not_called()
# advance reactor past config sleep duration but less than default duration
self.reactor.pump([0.75])
# check that update has been run
self.update_handler.assert_called()
@override_config(
yaml.safe_load(
"""
background_updates:
sleep_enabled: false
"""
)
)
def test_disabling_background_update_sleep(self) -> None:
"""
Test that disabling sleep in the config results in bg update not sleeping
"""
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
self.updates.start_doing_background_updates()
# 2: advance the reactor very little
self.reactor.pump([0.025])
# check that an update has run
self.update_handler.assert_called()
@override_config(
yaml.safe_load(
"""
background_updates:
background_update_duration_ms: 500
"""
)
)
def test_background_update_duration_set_in_config(self) -> None:
"""
Test that the desired duration set in the config is used in determining batch size
"""
# Duration of one background update item
duration_ms = 10
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
self.update_handler.side_effect = self.update
self.update_handler.reset_mock()
background_update_d = ensureDeferred(
self.updates.do_next_background_update(False)
)
# Wait for database queries to run in `do_next_background_update(...)` so the
# background update actually gets scheduled
self.reactor.advance(0)
# Wait for the actual background update `fake_work_duration`
self.reactor.advance(Duration(seconds=1).as_secs())
res = self.get_success(background_update_d)
self.assertFalse(res)
# the first update was run with the default batch size, this should be run with 500ms as the
# desired duration
async def update(progress: JsonDict, count: int) -> int:
self.assertEqual(progress, {"my_key": 2})
self.assertAlmostEqual(
count,
500 / duration_ms,
places=0,
)
await self.updates._end_background_update("test_update")
return count
self.update_handler.side_effect = update
self.get_success(self.updates.do_next_background_update(False))
@override_config(
yaml.safe_load(
"""
background_updates:
min_batch_size: 5
"""
)
)
def test_background_update_min_batch_set_in_config(self) -> None:
"""
Test that the minimum batch size set in the config is used
"""
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": '{"my_key": 1}'},
)
)
# Run the update with the long-running update item
async def update_long(progress: JsonDict, count: int) -> int:
very_long_fake_work_duration = Duration(seconds=5)
await self.clock.sleep(very_long_fake_work_duration)
progress = {"my_key": progress["my_key"] + 1}
await self.store.db_pool.runInteraction(
"update_progress",
self.updates._background_update_progress_txn,
"test_update",
progress,
)
return count
self.update_handler.side_effect = update_long
self.update_handler.reset_mock()
background_update_d = ensureDeferred(
self.updates.do_next_background_update(False)
)
# Wait for database queries to run in `do_next_background_update(...)` so the
# background update actually gets scheduled
self.reactor.advance(0)
# Wait for the actual background update `very_long_fake_work_duration`
self.reactor.advance(Duration(seconds=5).as_secs())
res = self.get_success(background_update_d)
self.assertFalse(res)
# the first update was run with the default batch size, this should be run with minimum batch size
# as the first items took a very long time
async def update_short(progress: JsonDict, count: int) -> int:
self.assertEqual(progress, {"my_key": 2})
self.assertEqual(count, 5)
await self.updates._end_background_update("test_update")
return count
self.update_handler.side_effect = update_short
self.get_success(self.updates.do_next_background_update(False))
def test_failed_update_logs_exception_details(self) -> None:
needle = "RUH ROH RAGGY"
def failing_update(progress: JsonDict, count: int) -> int:
raise Exception(needle)
self.update_handler.side_effect = failing_update
self.update_handler.reset_mock()
self.get_success(
self.store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": "{}"},
)
)
with self.assertLogs(level=logging.ERROR) as logs:
# Expect a back-to-back RuntimeError to be raised
self.get_failure(self.updates.run_background_updates(False), RuntimeError)
self.assertTrue(any(needle in log for log in logs.output), logs.output)
class BackgroundUpdateControllerTestCase(unittest.HomeserverTestCase):
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.updates: BackgroundUpdater = self.hs.get_datastores().main.db_pool.updates
# the base test class should have run the real bg updates for us
self.assertTrue(
self.get_success(self.updates.has_completed_background_updates())
)
self.update_deferred: Deferred[int] = Deferred()
self.update_handler = Mock(return_value=self.update_deferred)
self.updates.register_background_update_handler(
"test_update", self.update_handler
)
# Mock out the AsyncContextManager
class MockCM:
__aenter__ = AsyncMock(return_value=None)
__aexit__ = AsyncMock(return_value=None)
self._update_ctx_manager = MockCM
# Mock out the `update_handler` callback
self._on_update = Mock(return_value=self._update_ctx_manager())
# Define a default batch size value that's not the same as the internal default
# value (100).
self._default_batch_size = 500
# Register the callbacks with more mocks
self.hs.get_module_api().register_background_update_controller_callbacks(
on_update=self._on_update,
min_batch_size=AsyncMock(return_value=self._default_batch_size),
default_batch_size=AsyncMock(
return_value=self._default_batch_size,
),
)
def test_controller(self) -> None:
store = self.hs.get_datastores().main
self.get_success(
store.db_pool.simple_insert(
"background_updates",
values={"update_name": "test_update", "progress_json": "{}"},
)
)
# Set the return value for the context manager.
enter_defer: Deferred[int] = Deferred()
self._update_ctx_manager.__aenter__ = Mock(return_value=enter_defer)
# Start the background update.
do_update_d = ensureDeferred(self.updates.do_next_background_update(True))
self.pump()
# `run_update` should have been called, but the update handler won't be
# called until the `enter_defer` (returned by `__aenter__`) is resolved.
self._on_update.assert_called_once_with(
"test_update",
"master",
False,
)
self.assertFalse(do_update_d.called)
self.assertFalse(self.update_deferred.called)
# Resolving the `enter_defer` should call the update handler, which then
# blocks.
enter_defer.callback(100)
self.pump()
self.update_handler.assert_called_once_with({}, self._default_batch_size)
self.assertFalse(self.update_deferred.called)
self._update_ctx_manager.__aexit__.assert_not_called()
# Resolving the update handler deferred should cause the
# `do_next_background_update` to finish and return
self.update_deferred.callback(100)
self.pump()
self._update_ctx_manager.__aexit__.assert_called()
self.get_success(do_update_d)
class BackgroundUpdateValidateConstraintTestCase(unittest.HomeserverTestCase):
"""Tests the validate contraint and delete background handlers."""
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.updates: BackgroundUpdater = self.hs.get_datastores().main.db_pool.updates
# the base test class should have run the real bg updates for us
self.assertTrue(
self.get_success(self.updates.has_completed_background_updates())
)
self.store = self.hs.get_datastores().main
def test_not_null_constraint(self) -> None:
# Create the initial tables, where we have some invalid data.
"""Tests adding a not null constraint."""
table_sql = """
CREATE TABLE test_constraint(
a INT PRIMARY KEY,
b INT
);
"""
self.get_success(
self.store.db_pool.runInteraction(
"test_not_null_constraint", lambda txn: txn.execute(table_sql)
)
)
# We add an index so that we can check that its correctly recreated when
# using SQLite.
index_sql = "CREATE INDEX test_index ON test_constraint(a)"
self.get_success(
self.store.db_pool.runInteraction(
"test_not_null_constraint", lambda txn: txn.execute(index_sql)
)
)
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 1, "b": 1})
)
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 2, "b": None})
)
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 3, "b": 3})
)
# Now lets do the migration
table2_sqlite = """
CREATE TABLE test_constraint2(
a INT PRIMARY KEY,
b INT,
CONSTRAINT test_constraint_name CHECK (b is NOT NULL)
);
"""
def delta(txn: LoggingTransaction) -> None:
run_validate_constraint_and_delete_rows_schema_delta(
txn,
ordering=1000,
update_name="test_bg_update",
table="test_constraint",
constraint_name="test_constraint_name",
constraint=NotNullConstraint("b"),
sqlite_table_name="test_constraint2",
sqlite_table_schema=table2_sqlite,
)
self.get_success(
self.store.db_pool.runInteraction(
"test_not_null_constraint",
delta,
)
)
if isinstance(self.store.database_engine, PostgresEngine):
# Postgres uses a background update
self.updates.register_background_validate_constraint_and_delete_rows(
"test_bg_update",
table="test_constraint",
constraint_name="test_constraint_name",
constraint=NotNullConstraint("b"),
unique_columns=["a"],
)
# Tell the DataStore that it hasn't finished all updates yet
self.store.db_pool.updates._all_done = False
# Now let's actually drive the updates to completion
self.wait_for_background_updates()
# Check the correct values are in the new table.
rows = cast(
list[tuple[int, int]],
self.get_success(
self.store.db_pool.simple_select_list(
table="test_constraint",
keyvalues={},
retcols=("a", "b"),
)
),
)
self.assertCountEqual(rows, [(1, 1), (3, 3)])
# And check that invalid rows get correctly rejected.
self.get_failure(
self.store.db_pool.simple_insert("test_constraint", {"a": 2, "b": None}),
exc=self.store.database_engine.module.IntegrityError,
)
# Check the index is still there for SQLite.
if isinstance(self.store.database_engine, Sqlite3Engine):
# Ensure the index exists in the schema.
self.get_success(
self.store.db_pool.simple_select_one_onecol(
table="sqlite_master",
keyvalues={"tbl_name": "test_constraint"},
retcol="name",
)
)
def test_foreign_constraint(self) -> None:
"""Tests adding a not foreign key constraint."""
# Create the initial tables, where we have some invalid data.
base_sql = """
CREATE TABLE base_table(
b INT PRIMARY KEY
);
"""
table_sql = """
CREATE TABLE test_constraint(
a INT PRIMARY KEY,
b INT NOT NULL
);
"""
self.get_success(
self.store.db_pool.runInteraction(
"test_foreign_key_constraint", lambda txn: txn.execute(base_sql)
)
)
self.get_success(
self.store.db_pool.runInteraction(
"test_foreign_key_constraint", lambda txn: txn.execute(table_sql)
)
)
self.get_success(self.store.db_pool.simple_insert("base_table", {"b": 1}))
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 1, "b": 1})
)
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 2, "b": 2})
)
self.get_success(self.store.db_pool.simple_insert("base_table", {"b": 3}))
self.get_success(
self.store.db_pool.simple_insert("test_constraint", {"a": 3, "b": 3})
)
table2_sqlite = """
CREATE TABLE test_constraint2(
a INT PRIMARY KEY,
b INT NOT NULL,
CONSTRAINT test_constraint_name FOREIGN KEY (b) REFERENCES base_table (b)
);
"""
def delta(txn: LoggingTransaction) -> None:
run_validate_constraint_and_delete_rows_schema_delta(
txn,
ordering=1000,
update_name="test_bg_update",
table="test_constraint",
constraint_name="test_constraint_name",
constraint=ForeignKeyConstraint(
"base_table", [("b", "b")], deferred=False
),
sqlite_table_name="test_constraint2",
sqlite_table_schema=table2_sqlite,
)
self.get_success(
self.store.db_pool.runInteraction(
"test_foreign_key_constraint",
delta,
)
)
if isinstance(self.store.database_engine, PostgresEngine):
# Postgres uses a background update
self.updates.register_background_validate_constraint_and_delete_rows(
"test_bg_update",
table="test_constraint",
constraint_name="test_constraint_name",
constraint=ForeignKeyConstraint(
"base_table", [("b", "b")], deferred=False
),
unique_columns=["a"],
)
# Tell the DataStore that it hasn't finished all updates yet
self.store.db_pool.updates._all_done = False
# Now let's actually drive the updates to completion
self.wait_for_background_updates()
# Check the correct values are in the new table.
rows = cast(
list[tuple[int, int]],
self.get_success(
self.store.db_pool.simple_select_list(
table="test_constraint",
keyvalues={},
retcols=("a", "b"),
)
),
)
self.assertCountEqual(rows, [(1, 1), (3, 3)])
# And check that invalid rows get correctly rejected.
self.get_failure(
self.store.db_pool.simple_insert("test_constraint", {"a": 2, "b": 2}),
exc=self.store.database_engine.module.IntegrityError,
)