Skip to content

MSC4140: merge develop into the finalised delayed events branch - #2

Draft
barodeur wants to merge 688 commits into
AndrewFerr:msc4140-finalised-and-filtersfrom
element-hq:barodeur/msc4140-finalised-delayed-events-merge-base
Draft

barodeur wants to merge 688 commits into
AndrewFerr:msc4140-finalised-and-filtersfrom
element-hq:barodeur/msc4140-finalised-delayed-events-merge-base

Conversation

@barodeur

Copy link
Copy Markdown

Brings #19038 up to date with develop. This is a single merge commit on top of the PR's head, nothing else.

I recommend to use git show --remerge-diff 58ee03480b to see only the relevant changes.

git show --remerge-diff 58ee03480b
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 012025a299..dcb9db7f2b 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -247,11 +247,6 @@ class ExperimentalConfig(Config):
         # MSC4133: Custom profile fields
         self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False)
 
-        # MSC4140: How many delayed events a user is allowed to have scheduled at a time.
-        self.msc4140_max_delayed_events_per_user = experimental.get(
-            "msc4140_max_delayed_events_per_user", 100
-        )
-
         # MSC4140: How long to keep finalised delayed events in the database before deleting them.
         self.msc4140_finalised_retention_period = self.parse_duration(
             config.get("msc4140_finalised_retention_period", "7d")
diff --git a/synapse/handlers/delayed_events.py b/synapse/handlers/delayed_events.py
remerge CONFLICT (content): Merge conflict in synapse/handlers/delayed_events.py
index c0261301c1..49b719a2d5 100644
--- a/synapse/handlers/delayed_events.py
+++ b/synapse/handlers/delayed_events.py
@@ -18,13 +18,8 @@ from typing import TYPE_CHECKING, Optional
 
 from twisted.internet.interfaces import IDelayedCall
 
-<<<<<<< ded80f4b43 (Merge with 'develop')
-from synapse.api.constants import EventTypes
-from synapse.api.errors import ShadowBanError, SynapseError, cs_error
-=======
 from synapse.api.constants import EventTypes, StickyEvent, StickyEventField
-from synapse.api.errors import Codes, ShadowBanError, SynapseError
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
+from synapse.api.errors import Codes, ShadowBanError, SynapseError, cs_error
 from synapse.api.ratelimiting import Ratelimiter
 from synapse.config.workers import MAIN_PROCESS_INSTANCE_NAME
 from synapse.http.site import SynapseRequest
@@ -38,7 +33,6 @@ from synapse.replication.http.delayed_events import (
 from synapse.storage.databases.main.delayed_events import (
     DelayedEventDetails,
     DelayedEventResponse,
-    DelayedEventResponseLegacyCompat,
     EventType,
     StateKey,
     Timestamp,
@@ -52,11 +46,7 @@ from synapse.types import (
     UserID,
     create_requester,
 )
-<<<<<<< ded80f4b43 (Merge with 'develop')
-from synapse.util.constants import MILLISECONDS_PER_SECOND, ONE_MINUTE_SECONDS
-=======
 from synapse.util.duration import Duration
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
 from synapse.util.events import generate_fake_event_id
 from synapse.util.metrics import Measure
 
@@ -143,7 +133,7 @@ class DelayedEventsHandler:
         if hs.config.worker.run_background_tasks:
             self._clock.looping_call(
                 self._prune_finalised_events,
-                5 * ONE_MINUTE_SECONDS * MILLISECONDS_PER_SECOND,
+                Duration(minutes=5),
             )
 
     @property
@@ -427,12 +417,8 @@ class DelayedEventsHandler:
             origin_server_ts=origin_server_ts,
             content=content,
             delay=delay,
-<<<<<<< ded80f4b43 (Merge with 'develop')
-            limit=self.hs.config.experimental.msc4140_max_delayed_events_per_user,
-=======
             sticky_duration_ms=sticky_duration_ms,
             limit=self._config.server.max_delayed_events_per_user,
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         )
 
         if self._repl_client is not None:
@@ -584,74 +570,55 @@ class DelayedEventsHandler:
         else:
             self._next_delayed_event_call.reset(delay_duration.as_secs())
 
-<<<<<<< ded80f4b43 (Merge with 'develop')
+    async def get_for_user(
+        self, requester: Requester, delay_id: str
+    ) -> DelayedEventResponse:
+        """
+        Return the specified pending delayed event requested by the given user.
+
+        Raises:
+            NotFoundError: if no matching delayed event could be found.
+        """
+        await self._delayed_event_mgmt_ratelimiter.ratelimit(requester)
+        return await self._store.get_delayed_event_for_user(
+            delay_id,
+            requester.user.localpart,
+        )
+
     async def get_delayed_events_for_user(
         self,
         requester: Requester,
-        delay_ids: list[str] | None,
         get_scheduled: bool,
         get_finalised: bool,
     ) -> dict[str, list[JsonDict]]:
         """
-        Return all scheduled delayed events for the given user.
+        Return the delayed events owned by the given user.
+        Scheduled delayed events include fields from earlier revisions of MSC4140
+        for compatibility with clients that still expect them.
 
         Args:
             requester: The user whose delayed events to get.
-            delay_ids: The IDs of the delayed events to get, or None to get all of them.
             get_scheduled: Whether to look up scheduled delayed events.
             get_finalised: Whether to look up finalised delayed events.
         """
-        await self._delayed_event_mgmt_ratelimiter.ratelimit(
-            requester,
-            (requester.user.to_string(), requester.device_id),
-        )
+        # TODO: Remove legacy fields once stable
+        await self._delayed_event_mgmt_ratelimiter.ratelimit(requester)
 
         # TODO: Support Pagination stream API
-        ret = {}
+        ret: dict[str, list[JsonDict]] = {}
         if get_scheduled:
-            ret["scheduled"] = await self._store.get_scheduled_delayed_events_for_user(
-                requester.user.localpart,
-                delay_ids,
+            scheduled = await self._store.get_all_delayed_events_for_user(
+                requester.user.localpart
             )
+            ret["scheduled"] = [delayed_event.asdict() for delayed_event in scheduled]
         if get_finalised:
             ret["finalised"] = await self._store.get_finalised_delayed_events_for_user(
                 requester.user.localpart,
-                delay_ids,
                 self._get_current_ts(),
                 self.hs.config.experimental.msc4140_finalised_retention_period,
                 self.hs.config.experimental.msc4140_finalised_per_user_retention_limit,
             )
         return ret
-=======
-    async def get_for_user(
-        self, requester: Requester, delay_id: str
-    ) -> DelayedEventResponse:
-        """
-        Return the specified pending delayed event requested by the given user.
-
-        Raises:
-            NotFoundError: if no matching delayed event could be found.
-        """
-        await self._delayed_event_mgmt_ratelimiter.ratelimit(requester)
-        return await self._store.get_delayed_event_for_user(
-            delay_id,
-            requester.user.localpart,
-        )
-
-    async def get_all_for_user(
-        self, requester: Requester
-    ) -> list[DelayedEventResponseLegacyCompat]:
-        """
-        Return all pending delayed events owned by the given user.
-        Includes fields from earlier revisions of MSC4140 for
-        compatibility with clients that still expect them.
-        """
-        # TODO: Remove legacy fields once stable
-        await self._delayed_event_mgmt_ratelimiter.ratelimit(requester)
-        return await self._store.get_all_delayed_events_for_user(
-            requester.user.localpart
-        )
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
 
     async def _send_event(
         self,
@@ -704,11 +671,7 @@ class DelayedEventsHandler:
                 ) = await self._event_creation_handler.create_and_send_nonmember_event(
                     requester,
                     event_dict,
-<<<<<<< ded80f4b43 (Merge with 'develop')
-=======
-                    txn_id=txn_id,
                     delay_id=event.delay_id,
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
                 )
                 event_id = sent_event.event_id
                 if event.origin_server_ts is None:
diff --git a/synapse/rest/client/delayed_events.py b/synapse/rest/client/delayed_events.py
remerge CONFLICT (content): Merge conflict in synapse/rest/client/delayed_events.py
index 45b6e3c52b..7200f7bf6c 100644
--- a/synapse/rest/client/delayed_events.py
+++ b/synapse/rest/client/delayed_events.py
@@ -180,7 +180,6 @@ class DelayedEventsServlet(RestServlet):
 
     async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]:
         requester = await self.auth.get_user_by_req(request)
-<<<<<<< ded80f4b43 (Merge with 'develop')
 
         # twisted.web.server.Request.args is incorrectly defined as Optional[Any]
         args: dict[bytes, list[bytes]] = request.args  # type: ignore
@@ -189,13 +188,11 @@ class DelayedEventsServlet(RestServlet):
             "status",
             allowed_values=tuple(s.value for s in _DelayedEventStatus),
         )
-        delay_ids = parse_strings_from_args(args, "delay_id")
         # TODO: Support Pagination stream API
         _from_token = parse_string_from_args(args, "from")
 
         ret = await self.delayed_events_handler.get_delayed_events_for_user(
             requester,
-            delay_ids,
             statuses is None or _DelayedEventStatus.SCHEDULED.value in statuses,
             statuses is None or _DelayedEventStatus.FINALISED.value in statuses,
         )
@@ -203,15 +200,6 @@ class DelayedEventsServlet(RestServlet):
         if statuses is None:
             ret["delayed_events"] = ret[_DelayedEventStatus.SCHEDULED.value]
         return 200, ret
-=======
-        # TODO: Support Pagination stream API ("from" query parameter)
-        delayed_events = await self.delayed_events_handler.get_all_for_user(requester)
-        return 200, {
-            "delayed_events": [
-                delayed_event.asdict() for delayed_event in delayed_events
-            ]
-        }
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
 
 
 def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
diff --git a/synapse/storage/databases/main/delayed_events.py b/synapse/storage/databases/main/delayed_events.py
remerge CONFLICT (content): Merge conflict in synapse/storage/databases/main/delayed_events.py
index d3defac5a0..5907b3f881 100644
--- a/synapse/storage/databases/main/delayed_events.py
+++ b/synapse/storage/databases/main/delayed_events.py
@@ -18,17 +18,18 @@ from typing import TYPE_CHECKING, NewType
 
 import attr
 
-<<<<<<< ded80f4b43 (Merge with 'develop')
-from synapse.api.errors import NotFoundError, StoreError, SynapseError, cs_error
-=======
-from synapse.api.errors import LimitExceededError, NotFoundError
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
+from synapse.api.errors import (
+    LimitExceededError,
+    NotFoundError,
+    SynapseError,
+    cs_error,
+)
 from synapse.storage._base import SQLBaseStore, db_to_json
 from synapse.storage.database import (
     DatabasePool,
     LoggingDatabaseConnection,
     LoggingTransaction,
-    make_in_list_sql_clause,
+    StoreError,
 )
 from synapse.storage.engines import PostgresEngine
 from synapse.types import JsonDict, RoomID
@@ -162,12 +163,8 @@ class DelayedEventsStore(SQLBaseStore):
         state_key: str | None,
         origin_server_ts: int | None,
         content: JsonDict,
-<<<<<<< ded80f4b43 (Merge with 'develop')
-        delay: int,
-=======
         delay: Duration,
         sticky_duration_ms: int | None,
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         limit: int,
     ) -> tuple[DelayID, Timestamp]:
         """
@@ -194,14 +191,9 @@ class DelayedEventsStore(SQLBaseStore):
             which is either the event just added or one added earlier.
 
         Raises:
-<<<<<<< ded80f4b43 (Merge with 'develop')
-            SynapseError: if the user has reached the limit of how many
-                delayed events they may have scheduled at a time.
-=======
             LimitExceededError: if the DB has reached the limit of
                 how many delayed events it may store for the given requester.
             AssertionError: if the limit is not greater than 0.
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         """
         assert limit > 0, "limit must be greater than 0"
 
@@ -210,7 +202,6 @@ class DelayedEventsStore(SQLBaseStore):
         send_ts = creation_ts + delay_ms
 
         def add_delayed_event_txn(txn: LoggingTransaction) -> Timestamp:
-<<<<<<< ded80f4b43 (Merge with 'develop')
             txn.execute(
                 """
                 SELECT COUNT(*) FROM delayed_events
@@ -220,21 +211,6 @@ class DelayedEventsStore(SQLBaseStore):
                 (user_localpart,),
             )
             num_existing: int = txn.fetchall()[0][0]
-            if num_existing >= limit:
-                raise SynapseError(
-                    HTTPStatus.BAD_REQUEST,
-                    "The maximum number of delayed events has been reached.",
-                    additional_fields={
-                        "org.matrix.msc4140.errcode": "M_MAX_DELAYED_EVENTS_EXCEEDED",
-                    },
-                )
-=======
-            num_existing: int = self.db_pool.simple_select_one_onecol_txn(
-                txn,
-                table="delayed_events",
-                keyvalues={"user_localpart": user_localpart},
-                retcol="COUNT(*)",
-            )
             if num_existing >= limit:
                 # Find the send_ts threshold that will bring the queue back under the limit.
                 # When the amount of existing delayed events has reached the limit,
@@ -249,6 +225,7 @@ class DelayedEventsStore(SQLBaseStore):
                     SELECT MAX(send_ts) FROM (
                         SELECT * FROM delayed_events
                         WHERE user_localpart = ?
+                            AND finalised_ts IS NULL
                         ORDER BY send_ts ASC
                         LIMIT ?
                     ) AS subquery
@@ -267,7 +244,6 @@ class DelayedEventsStore(SQLBaseStore):
                 )
                 err.msg = "The maximum number of delayed events has been reached."
                 raise err
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
 
             self.db_pool.simple_insert_txn(
                 txn,
@@ -402,7 +378,6 @@ class DelayedEventsStore(SQLBaseStore):
             "prune_finalised_delayed_events", prune_finalised_delayed_events
         )
 
-<<<<<<< ded80f4b43 (Merge with 'develop')
     def _prune_expired_finalised_delayed_events(
         self, txn: LoggingTransaction, current_ts: Timestamp, retention_period: int
     ) -> None:
@@ -452,13 +427,6 @@ class DelayedEventsStore(SQLBaseStore):
                 ),
             )
 
-    async def get_scheduled_delayed_events_for_user(
-        self,
-        user_localpart: str,
-        delay_ids: list[str] | None,
-    ) -> list[JsonDict]:
-        """Returns all scheduled delayed events for the given user."""
-=======
     async def get_delayed_event_for_user(
         self,
         delay_id: str,
@@ -470,27 +438,27 @@ class DelayedEventsStore(SQLBaseStore):
         Raises:
             NotFoundError: if there is no matching delayed event.
         """
-        row = await self.db_pool.simple_select_one(
-            table="delayed_events",
-            keyvalues={
-                "delay_id": delay_id,
-                "user_localpart": user_localpart,
-                "is_processed": False,
-            },
-            retcols=(
-                "room_id",
-                "event_type",
-                "state_key",
-                "delay",
-                "send_ts - delay",
-                "content",
-            ),
-            allow_none=True,
-            desc="get_delayed_event_for_user",
+        rows = await self.db_pool.execute(
+            "get_delayed_event_for_user",
+            """
+            SELECT
+                room_id,
+                event_type,
+                state_key,
+                delay,
+                send_ts - delay,
+                content
+            FROM delayed_events
+            WHERE delay_id = ? AND user_localpart = ?
+                AND NOT is_processed
+                AND finalised_ts IS NULL
+            """,
+            delay_id,
+            user_localpart,
         )
-        if row is None:
+        if not rows:
             raise NotFoundError("Delayed event not found")
-        return DelayedEventResponse(delay_id, *row)
+        return DelayedEventResponse(delay_id, *rows[0])
 
     async def get_all_delayed_events_for_user(
         self,
@@ -502,19 +470,10 @@ class DelayedEventsStore(SQLBaseStore):
         compatibility with clients that still expect them.
         """
         # TODO: Remove legacy fields once stable
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         # TODO: Support Pagination stream API ("next_batch" field)
-        sql_where = "WHERE user_localpart = ? AND finalised_ts IS NULL"
-        sql_args = [user_localpart]
-        if delay_ids:
-            delay_id_clause_sql, delay_id_clause_args = make_in_list_sql_clause(
-                self.database_engine, "delay_id", delay_ids
-            )
-            sql_where += f" AND {delay_id_clause_sql}"
-            sql_args.extend(delay_id_clause_args)
         rows = await self.db_pool.execute(
-            "get_scheduled_delayed_events_for_user",
-            f"""
+            "get_all_delayed_events_for_user",
+            """
             SELECT
                 delay_id,
                 room_id,
@@ -524,17 +483,18 @@ class DelayedEventsStore(SQLBaseStore):
                 send_ts - delay,
                 content
             FROM delayed_events
-            {sql_where}
+            WHERE user_localpart = ?
+                AND NOT is_processed
+                AND finalised_ts IS NULL
             ORDER BY send_ts
             """,
-            *sql_args,
+            user_localpart,
         )
         return [DelayedEventResponseLegacyCompat(*row) for row in rows]
 
     async def get_finalised_delayed_events_for_user(
         self,
         user_localpart: str,
-        delay_ids: list[str] | None,
         current_ts: Timestamp,
         retention_period: int,
         retention_limit: int,
@@ -553,16 +513,8 @@ class DelayedEventsStore(SQLBaseStore):
                 txn, user_localpart, retention_limit
             )
 
-            sql_where = "WHERE user_localpart = ? AND finalised_ts IS NOT NULL"
-            sql_args = [user_localpart]
-            if delay_ids:
-                delay_id_clause_sql, delay_id_clause_args = make_in_list_sql_clause(
-                    self.database_engine, "delay_id", delay_ids
-                )
-                sql_where += f" AND {delay_id_clause_sql}"
-                sql_args.extend(delay_id_clause_args)
             txn.execute(
-                f"""
+                """
                 SELECT
                     delay_id,
                     room_id,
@@ -575,26 +527,22 @@ class DelayedEventsStore(SQLBaseStore):
                     finalised_event_id,
                     finalised_ts
                 FROM delayed_events
-                {sql_where}
+                WHERE user_localpart = ? AND finalised_ts IS NOT NULL
                 ORDER BY finalised_ts DESC
                 """,
-                sql_args,
+                (user_localpart,),
             )
             return [
                 {
-                    "delayed_event": {
-                        "delay_id": DelayID(row[0]),
-                        "room_id": str(RoomID.from_string(row[1])),
-                        "type": EventType(row[2]),
-                        **(
-                            {"state_key": StateKey(row[3])}
-                            if row[3] is not None
-                            else {}
-                        ),
-                        "delay": Delay(row[4]),
-                        "running_since": Timestamp(row[5] - row[4]),
-                        "content": db_to_json(row[6]),
-                    },
+                    "delayed_event": DelayedEventResponseLegacyCompat(
+                        row[0],
+                        row[1],
+                        row[2],
+                        row[3],
+                        row[4],
+                        row[5] - row[4],
+                        row[6],
+                    ).asdict(),
                     "outcome": "cancel" if row[8] is None else "send",
                     "reason": (
                         "error"
@@ -660,20 +608,12 @@ class DelayedEventsStore(SQLBaseStore):
                 )
             )
             sql_update = "UPDATE delayed_events SET is_processed = TRUE"
-<<<<<<< ded80f4b43 (Merge with 'develop')
-            sql_where = """
-                WHERE send_ts <= ?
-                    AND NOT is_processed
-                    AND finalised_ts IS NULL
-                """
-=======
-            sql_where = "WHERE send_ts <= ?"
+            sql_where = "WHERE send_ts <= ? AND finalised_ts IS NULL"
 
             if not reprocess_events:
                 # Skip already-processed events.
                 sql_where += " AND NOT is_processed"
 
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
             sql_args = (current_ts,)
             sql_order = "ORDER BY send_ts"
             if isinstance(self.database_engine, PostgresEngine):
diff --git a/synapse/storage/schema/__init__.py b/synapse/storage/schema/__init__.py
remerge CONFLICT (content): Merge conflict in synapse/storage/schema/__init__.py
index 9800732346..76c74a12ff 100644
--- a/synapse/storage/schema/__init__.py
+++ b/synapse/storage/schema/__init__.py
@@ -171,17 +171,14 @@ Changes in SCHEMA_VERSION = 92
 
 Changes in SCHEMA_VERSION = 93
     - MSC4140: Set delayed events to be uniquely identifiable by their delay ID.
-<<<<<<< ded80f4b43 (Merge with 'develop')
-    - MSC4140: Add columns to the `delayed_events` table to keep track of delayed events
-      that have been sent, cancelled, or failed to be sent due to an error.
-=======
 
 Changes in SCHEMA_VERSION = 94
     - Add `recheck` column (boolean, default true) to the `redactions` table.
     - MSC4242: Add state DAG tables.
     - MSC4429/MSC4262: Track updates to user profile fields via a new stream.
     - Add an `inserted_ts` column to the `state_groups_persisting` table.
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
+    - MSC4140: Add columns to the `delayed_events` table to keep track of delayed events
+      that have been sent, cancelled, or failed to be sent due to an error.
 """
 
 
diff --git a/synapse/storage/schema/main/delta/93/02_add_finalised_delayed_events.sql b/synapse/storage/schema/main/delta/94/11_add_finalised_delayed_events.sql
similarity index 94%
rename from synapse/storage/schema/main/delta/93/02_add_finalised_delayed_events.sql
rename to synapse/storage/schema/main/delta/94/11_add_finalised_delayed_events.sql
index c7941277bd..242e5c2b12 100644
--- a/synapse/storage/schema/main/delta/93/02_add_finalised_delayed_events.sql
+++ b/synapse/storage/schema/main/delta/94/11_add_finalised_delayed_events.sql
@@ -17,4 +17,4 @@ ALTER TABLE delayed_events ADD COLUMN finalised_event_id TEXT;
 ALTER TABLE delayed_events ADD COLUMN finalised_ts BIGINT;
 
 INSERT INTO background_updates (ordering, update_name, progress_json) VALUES
-  (9302, 'delayed_events_finalised_ts', '{}');
+  (9411, 'delayed_events_finalised_ts', '{}');
diff --git a/tests/rest/client/test_delayed_events.py b/tests/rest/client/test_delayed_events.py
remerge CONFLICT (content): Merge conflict in tests/rest/client/test_delayed_events.py
index 808c5f2e15..8799b3243a 100644
--- a/tests/rest/client/test_delayed_events.py
+++ b/tests/rest/client/test_delayed_events.py
@@ -228,7 +228,7 @@ class DelayedEventsTestCase(HomeserverTestCase):
 
         # Test that the list lookup retrieves the same items (with legacy fields included)
         self.assertEqual(
-            self._get_delayed_events(),
+            self._get_scheduled_delayed_events(),
             [
                 event
                 | {
@@ -257,7 +257,9 @@ class DelayedEventsTestCase(HomeserverTestCase):
             self.user1_access_token,
         )
         self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
-<<<<<<< ded80f4b43 (Merge with 'develop')
+        delay_id = channel.json_body.get("delay_id")
+        assert delay_id is not None
+
         scheduled, finalised = self._get_delayed_events()
         self.assertEqual(1, len(scheduled), scheduled)
         self.assertListEqual([], finalised)
@@ -265,14 +267,6 @@ class DelayedEventsTestCase(HomeserverTestCase):
         scheduled_event = scheduled[0]
         content = self._get_delayed_event_content(scheduled_event)
 
-=======
-        delay_id = channel.json_body.get("delay_id")
-        assert delay_id is not None
-
-        events = self._get_delayed_events()
-        self.assertEqual(1, len(events), events)
-        content = self._get_delayed_event_content(events[0])
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         self.assertEqual(setter_expected, content.get(setter_key), content)
         self.helper.get_state(
             self.room_id,
@@ -324,9 +318,9 @@ class DelayedEventsTestCase(HomeserverTestCase):
         delay_id = channel.json_body.get("delay_id")
         assert delay_id is not None
 
-        events = self._get_delayed_events()
-        self.assertEqual(1, len(events), events)
-        content = self._get_delayed_event_content(events[0])
+        scheduled = self._get_scheduled_delayed_events()
+        self.assertEqual(1, len(scheduled), scheduled)
+        content = self._get_delayed_event_content(scheduled[0])
         self.assertEqual("leave", content.get("membership"), content)
         self.assertEqual("Delayed kick", content.get("reason"), content)
 
@@ -339,7 +333,7 @@ class DelayedEventsTestCase(HomeserverTestCase):
         self.assertEqual("join", content.get("membership"), content)
 
         self.reactor.advance(1)
-        self.assertListEqual([], self._get_delayed_events())
+        self.assertListEqual([], self._get_scheduled_delayed_events())
         content = self.helper.get_state(
             self.room_id,
             "m.room.member",
@@ -570,19 +564,12 @@ class DelayedEventsTestCase(HomeserverTestCase):
         assert delay_id is not None
 
         self.reactor.advance(1)
-<<<<<<< ded80f4b43 (Merge with 'develop')
         scheduled = self._get_scheduled_delayed_events()
         self.assertEqual(1, len(scheduled), scheduled)
 
         scheduled_event = scheduled[0]
         content = self._get_delayed_event_content(scheduled_event)
-        self.assertEqual(setter_expected, content.get(setter_key), content)
-=======
-        events = self._get_delayed_events()
-        self.assertEqual(1, len(events), events)
-        content = self._get_delayed_event_content(events[0])
         self.assertEqual(content_value, content.get(content_property_name), content)
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
         self.helper.get_state(
             self.room_id,
             _EVENT_TYPE,
@@ -779,15 +766,10 @@ class DelayedEventsTestCase(HomeserverTestCase):
             self.user1_access_token,
         )
         self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
-<<<<<<< ded80f4b43 (Merge with 'develop')
-        scheduled = self._get_scheduled_delayed_events()
-        self.assertEqual(1, len(scheduled), scheduled)
-=======
         delay_id = channel.json_body.get("delay_id")
         assert delay_id is not None
-        events = self._get_delayed_events()
-        self.assertEqual(1, len(events), events)
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
+        scheduled = self._get_scheduled_delayed_events()
+        self.assertEqual(1, len(scheduled), scheduled)
 
         self.helper.send_state(
             self.room_id,
@@ -828,16 +810,11 @@ class DelayedEventsTestCase(HomeserverTestCase):
             self.user1_access_token,
         )
         self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
-<<<<<<< ded80f4b43 (Merge with 'develop')
+        delay_id = channel.json_body.get("delay_id")
+        assert delay_id is not None
         scheduled = self._get_scheduled_delayed_events()
         self.assertEqual(1, len(scheduled), scheduled)
         scheduled_event = scheduled[0]
-=======
-        delay_id = channel.json_body.get("delay_id")
-        assert delay_id is not None
-        events = self._get_delayed_events()
-        self.assertEqual(1, len(events), events)
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
 
         setter_expected = "other_user"
         self.helper.send_state(
@@ -876,14 +853,10 @@ class DelayedEventsTestCase(HomeserverTestCase):
         )
         self.assertEqual(setter_expected, content.get(setter_key), content)
 
-<<<<<<< ded80f4b43 (Merge with 'develop')
-    def _get_delayed_events(self) -> tuple[list[JsonDict], list[JsonDict]]:
-=======
         self._find_sent_delayed_event(self.user1_access_token, delay_id, False)
         self._find_sent_delayed_event(self.user2_access_token, delay_id, False)
 
-    def _get_delayed_events(self) -> list[JsonDict]:
->>>>>>> 84ac9ffe37 ( Drop federation device list updates from non-compliant user IDs (#20115))
+    def _get_delayed_events(self) -> tuple[list[JsonDict], list[JsonDict]]:
         channel = self.make_request(
             "GET",
             PATH_PREFIX,
diff --git a/tests/rest/client/test_rooms.py b/tests/rest/client/test_rooms.py
index 4ee15a6bf2..d0df8809c3 100644
--- a/tests/rest/client/test_rooms.py
+++ b/tests/rest/client/test_rooms.py
@@ -2494,8 +2494,6 @@ class RoomMessageFilterTestCase(RoomBase):
 class RoomDelayedEventTestCase(RoomBase):
     """Tests delayed events."""
 
-    servlets = RoomBase.servlets + [admin.register_servlets]
-
     user_id = "@sid1:red"
 
     def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
@@ -2822,54 +2820,6 @@ class RoomDelayedEventTestCase(RoomBase):
         channel = make_delayed_event_request()
         self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
 
-    @unittest.override_config(
-        {
-            "max_event_delay_duration": "24h",
-            "experimental_features": {
-                "msc4140_max_delayed_events_per_user": 1,
-            },
-        }
-    )
-    def test_add_delayed_event_num_limit(self) -> None:
-        """Test that users may not have too many scheduled delayed events at once."""
-        user2_user_id = self.register_user("user2", "pass")
-
-        room_id = self.helper.create_room_as(self.user_id, is_public=True)
-        self.helper.join(room_id, user2_user_id)
-
-        txn_id = 0
-
-        def add_delayed_event(
-            expect_success: bool,
-            user_id: str = self.user_id,
-        ) -> None:
-            nonlocal room_id, txn_id
-            self.helper.auth_user_id = user_id
-            txn_id += 1
-            channel = self.make_request(
-                "PUT",
-                (
-                    "rooms/%s/send/m.room.message/%s?org.matrix.msc4140.delay=2000"
-                    % (room_id, txn_id)
-                ).encode("ascii"),
-                {"body": "test", "msgtype": "m.text"},
-            )
-            if expect_success:
-                self.assertEqual(HTTPStatus.OK, channel.code, channel.result)
-            else:
-                self.assertEqual(HTTPStatus.BAD_REQUEST, channel.code, channel.result)
-                self.assertEqual(
-                    "M_MAX_DELAYED_EVENTS_EXCEEDED",
-                    channel.json_body.get("org.matrix.msc4140.errcode"),
-                    channel.json_body,
-                )
-
-        add_delayed_event(True)
-        add_delayed_event(False)
-        add_delayed_event(True, user2_user_id)
-        self.reactor.advance(2)
-        add_delayed_event(True)
-
 
 class RoomSearchTestCase(unittest.HomeserverTestCase):
     servlets = [

Resolution policy: keep the PR's behaviour wherever the MSC backs it or is silent; take develop's behaviour where develop already moved to what MSC4140 (merged on 2026-09-08) now says. Each change to the PR's code is listed below with the develop PR that caused it.

Changes to the PR's code

Per-user limit on scheduled delayed events

  • The PR capped the delayed events a user may have scheduled with experimental_features.msc4140_max_delayed_events_per_user (default 100), checked in the store, answering 400 M_UNKNOWN with org.matrix.msc4140.errcode: M_MAX_DELAYED_EVENTS_EXCEEDED.
  • develop has the same cap as msc4140_max_delayed_events_per_user in the server config, answering 429 M_LIMIT_EXCEEDED with a Retry-After header, which is what the MSC specifies (M_MAX_DELAYED_EVENTS_EXCEEDED is listed under its rejected alternatives).
  • Introduced by #19539.
  • Conflict: add_delayed_event in the store and the limit= argument in the handler. Took develop's; dropped the PR's config key, now unused, and its test_add_delayed_event_num_limit, which asserted the PR's error. As the PR keeps finalised rows in delayed_events, develop's COUNT(*) and its Retry-After subquery filter on finalised_ts IS NULL; without that, develop's cap tests fail once their events have been sent.

Lookup by delay ID

  • The PR filtered the list endpoint with ?delay_id= (repeatable), alongside ?status= and ?from=.
  • develop added GET /delayed_events/{delay_id}, the MSC's single-event lookup.
  • Introduced by #19926.
  • Conflict: the list servlet, the handler and the store's list queries. Took develop's endpoint and dropped the ?delay_id= filter; the PR's ?status= filter and its from parsing are kept. develop's single lookup and list select on is_processed = FALSE, which does not exclude the rows the PR finalises as cancelled, so both queries also filter on finalised_ts IS NULL; the single lookup becomes raw SQL for that, as simple_select_one cannot express IS NULL.

Finalised list entries carry develop's field names

  • The PR built the delayed_event object of each finalised entry by hand, with delay and running_since.
  • develop renamed those fields to delay_ms and delayed_since_ts, keeping the old names on the list through DelayedEventResponseLegacyCompat.
  • Introduced by #19926.
  • Not a textual conflict, but the PR's state-cancellation test compares a scheduled entry with the same event's finalised delayed_event, and the scheduled entry now carries both sets of names. The finalised entry is built through DelayedEventResponseLegacyCompat.asdict() so that it does too.

No txn_id when sending a delayed event

  • The PR stopped passing the transaction ID to create_and_send_nonmember_event, so a sent delayed event carries no transaction_id in unsigned, as the MSC says it should not.
  • develop added a delay_id= argument right next to the txn_id= one.
  • Introduced by #19479.
  • Conflict: kept the PR's removal and develop's delay_id=.

API changes on develop, no behaviour change

  • add takes delay: Duration and a required sticky_duration_ms (#19539, #19365).
  • Clock.looping_call takes a Duration, so the prune interval becomes Duration(minutes=5) (#19229).
  • process_timeout_delayed_events gained reprocess_events (#19207); its is_processed clause is combined with the PR's finalised_ts IS NULL.
  • The management ratelimiter is keyed on the requester (#19794).
  • StoreError is imported from synapse.storage.database, as on develop.

Schema delta

  • SCHEMA_VERSION is 94 on develop, so the PR's delta moves from delta/93/02_ to delta/94/11_, its background update ordering from 9302 to 9411, and its note to the version 94 list.

Changes to the PR's tests

develop's new tests in test_delayed_events.py routed through the PR's helpers

  • The PR's _get_delayed_events() returns a (scheduled, finalised) pair and adds _get_scheduled_delayed_events(); develop's returns a plain list.
  • develop added call sites in test_delayed_event_lookup (#19926), in test_delayed_member_events_are_sent_on_timeout and the _find_sent_delayed_event checks (#19479), and renamed the content variables of the delayed-state tests (#19360).
  • Conflict: rewrote those call sites onto the PR's helpers; assertions unchanged.

test_rooms.py

  • Removed test_add_delayed_event_num_limit (see the cap above) and the servlets override that only it needed. develop's own cap tests from #19539 run unchanged.

MadLittleMods and others added 30 commits June 19, 2026 22:21
…19868)

Spawning from #19824 /
#19846 and wanting to use
`create_deferred` in more than just the `http_client.rs`
Signed-off-by: dependabot[bot] <support@github.com>
…19591)

Follows: #19487
Part of: MSC4354 whose experimental feature tracking issue is #19409

This PR implements the Sliding Sync (MSC4186) extension described in
MSC4354, allowing sliding sync clients
to receive sticky events in a reliable way.

The logic is much the same as for oldschool sync (implementation in
#19487),
although in the sliding sync extension, the client can choose their own
limit
and must control their own pagination through an extra token in the
extension request/response bodies.

Note this does not yet send down existing sticky events in the
room when the room has been newly-joined.
This newly-discovered gap is tracked at #19662 and will be addressed for
both current sync and MSC4186 SSS soon.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Co-authored-by: Eric Eastwood <erice@element.io>
After looking into it, just a couple of things to pick a bone at in the
old wording,
which I thought could be clarified for when I next come to look at this
again.


- the claim that there's a fundamental difference; I'd argue there isn't
really, it's just by convention
  on some mainstream distros. So I have changed this to 'typically'
- statements that some distros fetch dependencies at build time
(probably does happen, but
traditional distros make a point of not doing this for the reasons you'd
expect).
- This was probably meant to be talking about Debian, but my observation
based on sample size of 3 is that some crates are packaged natively,
others are vendored in the respective application's source package (like
they do for us) and sometime they patch the bounds a bit

There could probably be room to talk about how distros vendoring
packages is a maintenance burden on them,
but I guess it's a bit moot as we would struggle to conform to wide
enough bounds to make everyone
happy (and anyway; I expect the distros that vendor packages have the
tooling to make this easy to
update and we do keep on top of security updates and release
frequently...)

---

Spawning from discussion in
[`#element-backend-internal:matrix.org`](https://matrix.to/#/!SGNQGPGUwtcPBUotTL:matrix.org/$VttYPPUevn2S_W_rrzg2ZOXWI6aKebk2ganTgrLEWUc?via=jki.re&via=element.io&via=matrix.org)

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…19863)

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: dependabot[bot] <support@github.com>
This reports the total count of users (split by appservice) which is
meant to be the monthless counterpart to the MAU metric.

Context:

> So this is largely for billing purposes and wanting to know the change
in the number of users. If a user is deactivated then we no longer want
to count them. Consumers *might* want to count appservice users, and
maybe count them based on the service (perhaps you change more for users
under bridge X or bridge Y).
>
> *-- #19848 (comment)
Signed-off-by: timedout <git@nexy7574.co.uk>
…ures` (#19646)

Closes #19580

---------

Co-authored-by: toonbr1me <toonbr1me@users.noreply.github.com>
…9892)

`_synapse/mas/sync_devices` checks the device list against the set of
devices MAS knows about, but the dehydrated device (MSC3814) is
invisible to MAS, so it gets automatically deleted on each sync, which
prevents dehydrated devices from working.

This change excludes the dehydrated device from the check.

There is similar special case code in the admin devices API (which gives
it a special flag) and MAS's own legacy sync path (which filters it
out).

This code was initially written by @ara4n and Claude, but both he and I
have read it and think it makes sense. I am far from a Synapse expert,
so feel free to tell me it's all wrong and point me in the right
direction.

A system-level test for this bug is being written here:
element-hq/element-web#34034 but if you think
we should have one somewhere else, please let me know.

Closes #19889

### Pull Request Checklist

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))

Co-authored-by: Matthew <matthew@element.io>
…19896)

Change `/org.matrix.msc3814.v1/dehydrated_device/[device_id]/events` to
accept GET requests instead of POST.

The original version of
[MSC3814](matrix-org/matrix-spec-proposals#3814)
said we should delete keys after returning them from this endpoint, but
it is being updated to say we should not delete them, and therefore the
appropriate verb is GET.

Synapse already doesn't delete anything, so we just need to change to a
GET with a `next_batch` query param. (Currently it is a POST with
`next_batch` in the JSON content.)

This code was initially written by @ara4n and Claude, but both he and I
have read it and think it makes sense. I am far from a Synapse expert,
so feel free to tell me it's all wrong and point me in the right
direction.

I don't know what system tests will be affected by this, but I guess we
will see when the CI runs (right?).

This is a change to an unstable endpoint so no need for notifications
about breaking changes or similar.

Part of element-hq/element-meta#2704

### Pull Request Checklist

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))

---------

Co-authored-by: Matthew Hodgson <matthew@matrix.org>
Fixes: #19857

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
… repeated deadlocks. (#19826)

Got paged today for this. The sliding sync worker in question had loads
of deadlocks in the logs.
I restarted it and it got unwedged, but we should have a more robust
defence, which this PR proposes.

```
psycopg2.errors.DeadlockDetected: deadlock detected
DETAIL:  Process 257324 waits for ShareLock on transaction 688227036; blocked by process 254908.
Process 254908 waits for ShareLock on transaction 688222971; blocked by process 256179.
Process 256179 waits for ExclusiveLock on tuple (302352,92) of relation 2962200779 of database 16403; blocked by process 257213.
Process 257213 waits for ShareLock on transaction 688225005; blocked by process 254905.
Process 254905 waits for ShareLock on transaction 688228814; blocked by process 257324.
HINT:  See server log for query details.
CONTEXT:  while inserting index tuple (183070,103) in relation "sliding_sync_connection_lazy_members"
```

I wonder if an unfortunate side effect is that these repeated attempts
leave a lot of dead tuples on the table,
which would then harm the performance of the next attempt to insert the
tuples,
I suspect making it more likely that they will deadlock again (?).

---

By acquring a `FOR NO KEY UPDATE` lock upfront before beginning work, we
can ensure that one
of the transactions gets queued behind the other one, meaning the first
one can succeed unimpeded.

`FOR NO KEY UPDATE` blocks other `FOR NO KEY UPDATE` locks and is the
weakest lock level that blocks itself.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…19890)

Introduced in: #17847

This 10-second wall-clock timeout was troublesome as it fails flakily on
slow/struggling CI runners, like the
default ones for private GitHub repositories.

The loop also silently relied on the reactor advance in `make_request`,
whereas we could just deterministically advance the reactor the known
amount of times
instead.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…/user/$user_id/redact) (#19802)

Closes #19441 

---------

Co-authored-by: Olivier 'reivilibre <oliverw@matrix.org>
Signed-off-by: dependabot[bot] <support@github.com>
…te (#19901)

The `flag_existing_quarantined_media` background update (added in
#19558, shipped in v1.152.0) back-populates the
`quarantined_media_changes` table with media that was already
quarantined. It has two bugs.

  ### 1. Some quarantined remote media is silently skipped

  The remote-media query paged through `remote_media_cache` with:

  ```sql
  WHERE quarantined_by IS NOT NULL
      AND media_origin >= ? AND media_id > ?
  ```

This ANDs the two key columns independently rather than comparing them
as a tuple. Once an origin has been fully processed (e.g. `media_id`
reaches `zzz` for origin `a.example`), rows in a *later* origin whose
`media_id` is `<=` the last processed `media_id` (e.g. `b.example` /
`aaa`) fail the `media_id > ?` test and are never flagged.

  Fixed by using a proper row-value tuple comparison:

  ```sql
  WHERE quarantined_by IS NOT NULL
      AND (media_origin, media_id) > (?, ?)
  ```

Both the minimum supported SQLite (3.37.2) and PostgreSQL support
row-value comparisons.

  ### 2. Exhausted queries keep re-running every iteration

`flag_quarantined` ran *both* the local and remote queries on every
iteration. When one table was exhausted but the other still had rows,
the update kept returning a positive count, so the finished table's (now
empty) query needlessly re-ran on every subsequent iteration until the
whole update completed.

This could add significant time to the transaction, as since the rows
were deleted it could scan a significant portion of the table each time.

Fixed by tracking per-table completion (`local_done` / `remote_done`) in
the background-update progress and skipping a table's query once it has
returned an empty batch. The progress dict was also restructured into an
incremental build for readability.
Modern 'MAS integration' (as we call it now) is, of course, preserved.

Fixes: #19549

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…nc 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
#19846 and wanting something
more standard instead of the custom `till_deferred_has_result(...)` that
has crept in to a few files.

Alternative to #19867 spurred
on by [this
comment](#19867 (comment))
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)
MadLittleMods and others added 30 commits September 8, 2026 15:47
…he CTE (#20182)

This solves the root cause of a user opening the thread panel in Element
Web DoSing synapse with recursive relation requests, starving out
delayed events and causing MatrixRTC calls to drop - see
matrix-org/matrix-js-sdk#5519 for papering over
it clientside.

Fixes #18788

Claude rationale:

Postgres cannot estimate the size of a recursive CTE. When it guesses
large
it stops probing events by event_id and instead hashes every event in
the
room, so a single recursive `/relations` request in a busy room takes
seconds
and a Threads-panel fan-out of 30 of them can pin a client-reader's DB
pool
for a minute. Joining events per recursion step keeps the lookups as
index
probes regardless of the estimate.

The recursion also moves from `UNION` to `UNION ALL`. An event carries a
single
`m.relates_to` and is stored as exactly one `event_relations` row
(unique index
on `event_id`), so the relation graph is a tree: every node is reached
along
one path and `UNION` never had duplicates to remove. `UNION ALL` drops
the
sort-and-dedupe pass over the working table on every iteration, which
matters more now that each row also carries the joined events columns. A
cycle from bogus events is still terminated by the depth bound, as
before,
and `UNION` gave no protection there anyway since such rows differ in
depth.

Measured on Postgres 16 against a synthetic corpus modelled on a large
homeserver: 4 rooms x 300k events; one 40-reply thread rooted 280k
events
back in the timeline, with 2 reactions per reply; every 5th event
elsewhere
a reaction, plus 200 popular roots with 2000 reactions each so that the
`relates_to_id statistics` are skewed the way they are in production.
Default
limit (6 rows), warm cache, JIT off:

```
                           before     after
  single request           77 ms      1.4 ms
  20 concurrent requests   1.21 s     0.14 s
```

Before: Hash Join with a Hash over all 300k events of the room (4
batches).
After: Nested Loop with an Index Scan on `events_event_id_key` per row.

(found/solved by fable)

---------

Co-authored-by: Eric Eastwood <erice@element.io>
…tEqual` in the tests. (#20193)

This also changes the rendering of our custom helper `assertIncludes`.

Spawns from
#20019 (comment)

This PR hijacks `assertEqual` in order to substitute in our own error
rendering logic for set inequality.
(The motivation to do this is that we should just render sets in our
preferred style by default, without having to think about
`assertIncludes` with the `exact` flag or risk forgetting it.)

The error rendering for `assertIncludes` is adapted to make it reusable
in our `assertEqual` and to make it clearer _to me_ (I found it a bit
jarring that `+` was used more like a tick, when in other test
frameworks
I expect to see that as a diff marker).
I have tried to make it as clear as I could without being cryptic.

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Signed-off-by: dependabot[bot] <support@github.com>
Bumps [gitpython](https://github.com/gitpython-developers/GitPython)
from 3.1.58 to 3.1.59.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/gitpython-developers/GitPython/releases">gitpython's
releases</a>.</em></p>
<blockquote>
<h2>3.1.59 - Security</h2>
<h2>What's Changed</h2>
<ul>
<li>prepare changelog for upcoming release by <a
href="https://github.com/Byron"><code>@​Byron</code></a> in <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2207">gitpython-developers/GitPython#2207</a></li>
<li>Block file-reading Git options by <a
href="https://github.com/Byron"><code>@​Byron</code></a> in <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2208">gitpython-developers/GitPython#2208</a></li>
<li>index: write blobs via git hash-object, not gitdb's odb.store by <a
href="https://github.com/caroescm"><code>@​caroescm</code></a> in <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2209">gitpython-developers/GitPython#2209</a></li>
<li>Block separate git directories during clone by <a
href="https://github.com/Byron"><code>@​Byron</code></a> in <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2210">gitpython-developers/GitPython#2210</a></li>
<li>fix: harden config parsing boundaries by <a
href="https://github.com/Byron"><code>@​Byron</code></a> in <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2211">gitpython-developers/GitPython#2211</a></li>
<li><code>repo.index.add()</code> now respects worktree filters <a
href="https://redirect.github.com/gitpython-developers/GitPython/pull/2209">gitpython-developers/GitPython#2209</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59">https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/66340d77aab9a7468f4aed3681d4ef1e3c0ec931"><code>66340d7</code></a>
prepare changelog prior to release</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/a5e047d0db7047c4249c0de335585470b14d50c4"><code>a5e047d</code></a>
Merge pull request <a
href="https://redirect.github.com/gitpython-developers/GitPython/issues/2211">#2211</a>
from gitpython-developers/config-sanitize-more</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/ef7568e3b317ce617eacda39b8b54dcdff8c3b5c"><code>ef7568e</code></a>
fix: ignore includes in submodule configuration</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/4b4e47fc1224e23b0c8ee7220a7192818f2e4abb"><code>4b4e47f</code></a>
fix: preserve multiline config values when writing</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/b473abb0f7de754392e1ec923f2fe296509013ab"><code>b473abb</code></a>
Merge pull request <a
href="https://redirect.github.com/gitpython-developers/GitPython/issues/2210">#2210</a>
from gitpython-developers/fix-clone-unsafe-option</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/5ff52cccca770fd69c6caf0b8f281d3e45d599be"><code>5ff52cc</code></a>
Merge pull request <a
href="https://redirect.github.com/gitpython-developers/GitPython/issues/2209">#2209</a>
from caroescm/fix-index-add-chmod</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"><code>b68afff</code></a>
Block separate git directories during clone</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/93677a00ab9dcb06cc08595fd1f88a4b4a0fa23b"><code>93677a0</code></a>
fix: <code>index.add()</code> now supports filters (<a
href="https://redirect.github.com/gitpython-developers/GitPython/issues/2021">#2021</a>)</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/9729ed3b948f2bde09f1f188c5311e172212b67e"><code>9729ed3</code></a>
Merge pull request <a
href="https://redirect.github.com/gitpython-developers/GitPython/issues/2208">#2208</a>
from gitpython-developers/security-fixes</li>
<li><a
href="https://github.com/gitpython-developers/GitPython/commit/ce9d8e8d150e06ae2e2cc2efa229071cd3048a93"><code>ce9d8e8</code></a>
prepare next release</li>
<li>Additional commits viewable in <a
href="https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=gitpython&package-manager=pip&previous-version=3.1.58&new-version=3.1.59)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/element-hq/synapse/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
### Pull Request Checklist

Replacement for #19752 as that
has bit-rotted with #19895
being merged.

`/_synapse/mas` is mounted on a worker on matrix.org and can be seen to
be workerisable via
*
https://github.com/element-hq/synapse/blob/v1.160.0/synapse/app/generic_worker.py#L202
*
https://github.com/element-hq/synapse/blob/v1.160.0/synapse/rest/synapse/client/__init__.py#L71
*
https://github.com/element-hq/synapse/blob/v1.160.0/synapse/rest/synapse/mas/__init__.py#L46-L71

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
…le (#20210)

### Pull Request Checklist

Missed off of https://github.com/element-hq/synapse/pull/19926/changes
IMO. My reading of
https://github.com/element-hq/synapse/blob/v1.161.0rc1/synapse/rest/client/delayed_events.py
is that the new endpoint (pattern
`r"/org\.matrix\.msc4140/delayed_events/(?P<delay_id>[^/]+)$"` is
workerisable) given how `register_servlets` flows. However given
`UpdateDelayedEventServlet` covers the same pattern but for `POST`
requests, this should go in the `GET` only part of the documentation.

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
Adds the serving functions needed for MSC4242: State DAGs. This PR adds
MSC4242 support to /make_join, /send_join and /get_missing_events, as
well as calculates the destinations for /send events correctly using
`prev_state_events`.

Built on top of #19718 for the
storage functions it makes.

Split out from #19425

Part of a series of 5x PRs to land the federation part of
[MSC4242](matrix-org/matrix-spec-proposals#4242)
([storage](#19718),
[fedclient](#20127), serving
(this PR), inbound-joins, inbound-pulls).

Whilst this is mostly a port of the code in #19425 there are a few
changes:
- `/get_missing_events` accepts message events when walking the state
DAG, in which case it resolves the first hop to be that event's
`prev_state_events`. The original PR made the client `/event` the
message event and then set `latest=[prev_state_events]` on its own. This
is not very efficient (extra round trip to fetch the event) and there's
no reason why the server can't do the message->prev_state_events lookup,
so we do so. This matches the MSC examples.
- We cap the amount of events fetched via `/get_missing_events`. The MSC
allows it, so it's a good safety check.
- We sort the returned state DAG in `/send_join` by depth then event ID
so it's "mostly" sorted. This is more a formality than anything else,
the MSC does not mandate this, but it makes `/send_join` responses
deterministic.
- `notify_on_event_delivered_over_federation` is a new thing since
#19425, so we include state DAG events in it like we do with
state/auth_chain.

This PR does remove the forced `m.federate: false` setting for MSC4242
rooms, so it makes it possible for federated MSC4242 rooms to be made.
This is mostly so we can test via the endpoints. Given you must opt-in
to MSC4242 via the experimental features config option, it seems
reasonable to loosen this setting. The forced no-federation flag existed
prior to review saying that the MSC4242 room version could itself be
gated behind an experimental feature.

Reviewable commit-by-commit.

### Pull Request Checklist

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))

---------

Co-authored-by: Eric Eastwood <erice@element.io>
Fixes: #20167

The `Schema Diff` workflow posts a PR comment showing the effective
schema diff. For PRs from forks, `GITHUB_TOKEN` is downgraded to
read-only, so the comment-posting step was silently failing.

### Changes
- `schema_diff.yml`: only post the comment directly when the PR is from
the same repository. For forked PRs, upload the diff (and PR number) as
a short-lived artifact instead of trying to comment.
- `schema_diff_comment.yml` (new): triggered by `workflow_run` after
`Schema Diff` completes, with `pull-requests: write` permission (granted
because this workflow always runs in the context of the base
repository). It downloads the artifact, if present, and posts the
comment on behalf of the forked PR.

This avoids `pull_request_target`, per the security concerns raised in
the issue (zizmor flags it as dangerous). The new workflow only ever
treats the downloaded artifact as inert comment text -- it is never
executed.

---------

Co-authored-by: Olivier 'reivilibre <oliverw@element.io>
Part of: #19415

TLDR: return the `M_UNKNOWN_DEVICE` error code instead of the unstable
`ORG.MATRIX.MSC4326.M_UNKNOWN_DEVICE` identifier.

> **[Added in `v1.17`]** Application services MAY similarly masquerade
as a specific device ID belonging the user ID through use of the
`device_id` query string parameter on the request. If the given device
ID is not known to belong to the user, the server will return a 400
`M_UNKNOWN_DEVICE` error.
>
> — [Matrix v1.19, Application Service API — Identity
assertion](https://spec.matrix.org/v1.19/application-service-api/#identity-assertion)

Synapse returns the correct 400, but with the unstable identifier.
MSC4326 was stabilized in Matrix 1.17 and its experimental flag was
already removed in #19033; only the error code identifier was left
behind.

Before:

```
GET /_matrix/client/v3/sync?user_id=@alice:test&device_id=NOT_A_REAL_DEVICE_ID  # appservice token
  400 {"errcode": "ORG.MATRIX.MSC4326.M_UNKNOWN_DEVICE"}
```

After:

```
GET /_matrix/client/v3/sync?user_id=@alice:test&device_id=NOT_A_REAL_DEVICE_ID  # appservice token
  400 {"errcode": "M_UNKNOWN_DEVICE"}
```

I verified that no implementation was currently handling the prefixed
error code.
Part of: #19414

When
[MSC4133](matrix-org/matrix-spec-proposals#4133)
(custom profile fields) was implemented, the returned value for an unset
display name changed from `200 {}` to `200 { displayname: null }`. This
happened first on the unstable `uk.tcpip.msc4133` path in #17488
(1.123.0), then on the stable path when #18635 (1.135.0) unified the
`displayname`, `avatar_url` and custom field servlets. Neither PR
discussed the change in review, so it looks like an unintended side
effect of the refactor rather than a deliberate decision.

The v1.16 spec mandated to change from returning `200 {}` to `404` but
change was not identified as breaking and was eventually not implemented
in other clients and server. This PR has a sister MSC that proposes to
return to the pre-1.16 error codes:
[MSC4537](matrix-org/matrix-spec-proposals#4537).

Before:

```
GET /_matrix/client/v3/profile/@alice:test/displayname
  200 {"displayname": null}
```

After:

```
GET /_matrix/client/v3/profile/@alice:test/displayname
  200 {}
```

### Pull Request Checklist

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
… changes, making federation support more reliable. (#20204)

Part of: MSC4354

Experimental feature tracking issue:
#19409

Related Complement tests currently in
https://github.com/matrix-org/complement/pull/806/files#diff-6c9d6d169485d0848c6b20dd9b43f6fe669a8a710e42f953d08fa25a99cc8f4cR509

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…omplement suite fails. (#20161)

Supersedes: element-hq/synapse-private#155

It would be useful to have the in-repo Complement suite give a status,
even when the normal suite fails (e.g. flakes).

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
Co-authored-by: Andrew Morgan <andrew@amorgan.xyz>
Signed-off-by: Skye Elliot <actuallyori@gmail.com>
…eparation and completion. (#20166)

A key refactoring for, and split out of,
#20165

Would be easier to land first to isolate the diff.

Should be a standalone change with no behavioural change.

Motivation is that #20165 will round-robin between 'main queue'
transactions and 'sticky event' transactions.
To keep the data flow clear, I wanted to insert a typed struct (well,
`attrs` dataclass) as an interface between the 'preparation' of a
transaction and its 'completion'.
Doing this whilst keeping the asynchronous context manager style did not
lead to a readable result in my opinion.
(I would also say the async context manager is a touch 'magic' /
obscures control flow, but I suspect this is largely down to opinion.)

Replace _TransactionQueueManager with prepare/complete transaction
methods

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
…ith a `null` value. (#20145)

Instead, treat them as absent fields as they feel like they should be.

The database implementation detail that these fields have a dedicated
column with `NULL`
when unset is kept to the storage layer.

The goal here is to reduce the amount of special casing needed for these
two original profile fields and treat them a little bit more like
regular profile fields.

Follows: #20003

Follows: #20147 (needed as a bugfix to continue sending them down
oldschool sync when they get deleted. Without #20147, this PR would
break that — which matches how custom profile fields were broken too.)

---------

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Adds a `redis.username` config option.

Details:
A `username` without a `password` (or `password_path`) is refused at
startup. Redis has no wire form for a username without a password, and
txredisapi only sends `AUTH` when a password is set, so the username
would otherwise be silently ignored. An explicitly empty password is
accepted, since that is how a `nopass` ACL user is configured.

This relies on txredisapi 1.4.12, the first release to accept a
`username` kwarg. That upstream support was contributed by @karolyi
specifically to unblock this.

Fixes #19238.


### Pull Request Checklist

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [X] Pull request is based on the develop branch
* [X] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [X] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ulating the room summary (#20205)

Fix #19905

Directly retrieve the `m.room.join_rules` event so the room summary data
is correct and matches `allowed_room_ids`. Otherwise there could be a
mis-match delay or the `join_rule` could be missing altogether.
…token falls inside a persist batch (#20171)

This PR fixes the issue described as comment here:
#18793 (comment)

In Element Call, this shows up as ghost participants: someone who left
the call keeps being displayed until a later state change refreshes the
room.

The bug is not specific to Element Call: any state event can be
affected, RTC membership just changes often enough to make it visible.

## What happens

Alice has a client syncing against a homeserver where events are
persisted by one worker (the event persister) and `/sync` is served by
another (the sync worker). Her client is parked in a long-poll: `GET
/sync?since=s99&timeout=30000`.

Bob joins a call at the same moment Carol sends a message. Carol's
message reaches the persister first; Bob's `m.call.member` arrives while
that write is still in flight, so the per-room persist queue groups them
into one transaction:

```
events (each gets its own stream ordering):
    stream_ordering 100:  m.room.message   Carol
    stream_ordering 101:  m.call.member    Bob        (state)

current_state_delta_stream (how state_after finds state changes):
    stream_id 100 ────►  (m.call.member, @bob) -> $bob_join_call
          ▲
          └─ stamped with the batch MINIMUM (100), not the event's own 101
              (see `_update_current_state_txn`)
```

The transaction commits: both events and the delta row are now in the
database, atomically.

The persister then announces the new events over replication, one RDATA
token per stream ordering — rows are only merged into one token when
they share a position, and 100 and 101 don't. So the sync worker's
events-stream position steps 99 → 100 → 101, and on reaching 100 it
pokes the notifier.

Alice's long-poll wakes at exactly that moment. Her response is built at
the worker's *current* position — `end = 100` — with RDATA 101 still in
the queue:

```
Sync A  (since=99, end=100):
  timeline:     events   99 < ordering ≤ 100  →  [Carol's message]
  state_after:  deltas   99 < stream_id ≤ 100 →  [$bob_join_call]  ← delivered EARLY
  next_batch:   s100                                               ← mid-batch token
```

No race on the client's side is needed: the server *hands out* the
mid-batch token as `next_batch`. Alice's client re-polls with it, as
every sync client does. The worker has meanwhile processed RDATA 101:

```
Sync B  (since=100, end=101):
  timeline:     events   100 < ordering ≤ 101  →  [Bob's m.call.member @101]  ✓
  state_after:  deltas   100 < stream_id ≤ 101 →  []     row is stamped 100   ✗
```

A state event in the timeline with an empty `state_after`. An MSC4222
client trusts `state_after` over timeline state events, so Alice's copy
of Bob's call membership never updates from this response.

On a single process this cannot happen: the batch's stream IDs are
released as a whole, so the position visible to `/sync` jumps 99 → 101
and `s100` is never handed out. Only a process that learns its position
from replication — any sync worker — ticks through the middle of a
batch.


### Pull Request Checklist

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
)

This partially fixes the bug
#20116

Device list update EDUs from non-compliant (grandfathered historical)
user IDs are currently accepted over federation, stored, and surfaced to
clients in `/sync`'s `device_lists.changed` array.

> For current room versions, servers must still accept events using such
user IDs over federation; however they SHOULD NOT forward such user IDs
to clients when referenced outside the context of an event. For example,
device list updates from non-compliant user IDs would be dropped by the
receiving server.
>
> -- [Matrix
spec](https://spec.matrix.org/v1.14/appendices/#historical-user-ids),
clarified in Matrix v1.14 by
[matrix-spec#1506](matrix-org/matrix-spec#1506)


### Problem Example

A remote server sends an `m.device_list_update` EDU for
`@héllo:remote.example` (non-ASCII localpart, outside the compliant
U+0021–U+007E range). Synapse:
- accepts and processes the update (resyncing the user's device list if
needed)
- stores it in the remote device list cache
- forwards `@héllo:remote.example` to local clients via
`device_lists.changed` in `/sync` (**the leak** — a non-compliant user
ID referenced outside event context)

---

### Pull Request Checklist

<!-- Please read
https://element-hq.github.io/synapse/latest/development/contributing_guide.html
before submitting your pull request -->

* [x] Pull request is based on the develop branch
* [x] Pull request includes a [changelog
file](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#changelog).
The entry should:
- Be a short description of your change which makes sense to users.
"Fixed a bug that prevented receiving messages from other servers."
instead of "Moved X method from `EventStore` to `EventWorkerStore`.".
  - Use markdown where necessary, mostly for `code blocks`.
  - End with either a period (.) or an exclamation mark (!).
  - Start with a capital letter.
- Feel free to credit yourself, by adding a sentence "Contributed by
@github_username." or "Contributed by [Your Name]." to the end of the
entry.
* [x] [Code
style](https://element-hq.github.io/synapse/latest/code_style.html) is
correct (run the
[linters](https://element-hq.github.io/synapse/latest/development/contributing_guide.html#run-the-linters))
Resolve the conflicts in the delayed events handler, servlet, store,
schema notes and tests:

- Per-user cap on scheduled delayed events: take develop's (#19539),
  which answers 429 M_LIMIT_EXCEEDED as the MSC specifies, and drop the
  PR's experimental config key and its test. The cap's COUNT and its
  Retry-After subquery exclude finalised rows, as those now stay in the
  table.
- Lookup by delay ID: take develop's GET /delayed_events/{delay_id}
  (#19926) and drop the PR's ?delay_id= filter on the list. The PR's
  ?status= filter is kept. The single lookup and the list exclude
  finalised rows, as on develop they only returned pending events.
- Store: keep the PR's finalisation columns and methods on top of
  develop's Duration delays, sticky_duration_ms and reprocess_events.
  The finalised list builds its delayed_event objects through develop's
  DelayedEventResponseLegacyCompat (#19926), so that they carry the
  same fields as the scheduled list.
- Handler: keep the PR's _send_event(event, finalise_error) and prune
  looping call; keep develop's delay_id plumbing (#19479), management
  ratelimiter (#19794) and sticky handling (#19365).
- Schema: SCHEMA_VERSION is 94 on develop, so the PR's delta moves from
  delta/93/02 to delta/94/11 and its background update ordering to
  9411.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.