diff --git a/changelog.d/19360.bugfix b/changelog.d/19360.bugfix new file mode 100644 index 0000000000..b4d5fb0892 --- /dev/null +++ b/changelog.d/19360.bugfix @@ -0,0 +1 @@ +MSC4140: Store the JSON content of scheduled delayed events as text instead of a byte array. This fixes the inability to schedule a delayed event with non-ASCII characters in its content. diff --git a/scripts-dev/check_schema_delta.py b/scripts-dev/check_schema_delta.py index ba8aff3628..12ed5d258c 100755 --- a/scripts-dev/check_schema_delta.py +++ b/scripts-dev/check_schema_delta.py @@ -187,6 +187,14 @@ def check_schema_delta(delta_files: list[str], force_colors: bool) -> bool: sql_lang = "postgres" if delta_file.endswith(".sqlite"): sql_lang = "sqlite" + elif delta_file.endswith(".py"): + click.secho( + f"Skipping Python delta file: '{delta_file}'", + fg="yellow", + bold=True, + color=force_colors, + ) + return True statements = sqlglot.parse(delta_contents, read=sql_lang) diff --git a/synapse/storage/schema/main/delta/93/04_make_delayed_event_content_text.py b/synapse/storage/schema/main/delta/93/04_make_delayed_event_content_text.py new file mode 100644 index 0000000000..0e48a40ff3 --- /dev/null +++ b/synapse/storage/schema/main/delta/93/04_make_delayed_event_content_text.py @@ -0,0 +1,62 @@ +# +# 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: +# . +# + +import logging + +from synapse.storage.database import LoggingTransaction +from synapse.storage.engines import BaseDatabaseEngine, PostgresEngine + +logger = logging.getLogger(__name__) + + +def run_create(cur: LoggingTransaction, database_engine: BaseDatabaseEngine) -> None: + """ + Change the type / affinity of the `delayed_events` table's `content` column from bytes to text. + This brings it in line with the `event_json` table's `json` column, and fixes the inability to + schedule a delayed event with non-ASCII characters in its content. + """ + + if isinstance(database_engine, PostgresEngine): + cur.execute( + "ALTER TABLE delayed_events " + "ALTER COLUMN content SET DATA TYPE TEXT " + "USING convert_from(content, 'utf8')" + ) + return + + # For sqlite3, change the type affinity by fiddling with the table schema directly. + # This strategy is also used by ../50/make_event_content_nullable.py. + + cur.execute( + "SELECT sql FROM sqlite_master WHERE tbl_name='delayed_events' AND type='table'" + ) + row = cur.fetchone() + assert row is not None + (oldsql,) = row + + sql = oldsql.replace("content bytea", "content TEXT") + if sql == oldsql: + raise Exception("Couldn't find content bytes column in %s" % oldsql) + + cur.execute("PRAGMA schema_version") + row = cur.fetchone() + assert row is not None + (oldver,) = row + cur.execute("PRAGMA writable_schema=ON") + cur.execute( + "UPDATE sqlite_master SET sql=? WHERE tbl_name='delayed_events' AND type='table'", + (sql,), + ) + cur.execute("PRAGMA schema_version=%i" % (oldver + 1,)) + cur.execute("PRAGMA writable_schema=OFF") diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py index cc983ea101..efa69a393a 100644 --- a/tests/rest/client/test_delayed_events.py +++ b/tests/rest/client/test_delayed_events.py @@ -278,17 +278,24 @@ class DelayedEventsTestCase(HomeserverTestCase): channel = self._update_delayed_event(delay_ids.pop(0), "cancel", action_in_path) self.assertEqual(HTTPStatus.TOO_MANY_REQUESTS, channel.code, channel.result) - @parameterized.expand((True, False)) - def test_send_delayed_state_event(self, action_in_path: bool) -> None: + @parameterized.expand( + ( + (content_property_value, action_in_path) + for content_property_value in ("test", "tест") + for action_in_path in (True, False) + ) + ) + def test_send_delayed_state_event( + self, content_value: str, action_in_path: bool + ) -> None: state_key = "to_send_on_request" - setter_key = "setter" - setter_expected = "on_send" + content_property_name = "key" channel = self.make_request( "PUT", _get_path_for_delayed_state(self.room_id, _EVENT_TYPE, state_key, 100000), { - setter_key: setter_expected, + content_property_name: content_value, }, self.user1_access_token, ) @@ -300,7 +307,7 @@ class DelayedEventsTestCase(HomeserverTestCase): events = self._get_delayed_events() self.assertEqual(1, len(events), events) content = self._get_delayed_event_content(events[0]) - self.assertEqual(setter_expected, content.get(setter_key), content) + self.assertEqual(content_value, content.get(content_property_name), content) self.helper.get_state( self.room_id, _EVENT_TYPE, @@ -318,7 +325,7 @@ class DelayedEventsTestCase(HomeserverTestCase): self.user1_access_token, state_key=state_key, ) - self.assertEqual(setter_expected, content.get(setter_key), content) + self.assertEqual(content_value, content.get(content_property_name), content) @parameterized.expand((True, False)) @unittest.override_config({"rc_message": {"per_second": 2.5, "burst_count": 3}})