MSC4140: delayed event content as text, not bytes (#19360)

Store the JSON content of scheduled delayed events as text instead of a
byte array. 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.

Fixes #19242
This commit is contained in:
Andrew Ferrazzutti
2026-01-15 11:05:19 -05:00
committed by GitHub
parent a1e9abc7df
commit 079c52e16b
4 changed files with 85 additions and 7 deletions

1
changelog.d/19360.bugfix Normal file
View File

@@ -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.

View File

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

View File

@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
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")

View File

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