Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 75dff3d

Browse files
authored
Include bundled aggregations for the latest event in a thread. (#12273)
The `latest_event` field of the bundled aggregations for `m.thread` relations did not include bundled aggregations itself. This resulted in clients needing to immediately request the event from the server (and thus making it useless that the latest event itself was serialized instead of just including an event ID).
1 parent 01e6255 commit 75dff3d

File tree

5 files changed

+198
-51
lines changed

5 files changed

+198
-51
lines changed

changelog.d/12273.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a bug introduced in Synapse v1.48.0 where latest thread reply provided failed to include the proper bundled aggregations.

synapse/events/utils.py

Lines changed: 33 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -426,13 +426,12 @@ def serialize_event(
426426

427427
# Check if there are any bundled aggregations to include with the event.
428428
if bundle_aggregations:
429-
event_aggregations = bundle_aggregations.get(event.event_id)
430-
if event_aggregations:
429+
if event.event_id in bundle_aggregations:
431430
self._inject_bundled_aggregations(
432431
event,
433432
time_now,
434433
config,
435-
event_aggregations,
434+
bundle_aggregations,
436435
serialized_event,
437436
apply_edits=apply_edits,
438437
)
@@ -471,7 +470,7 @@ def _inject_bundled_aggregations(
471470
event: EventBase,
472471
time_now: int,
473472
config: SerializeEventConfig,
474-
aggregations: "BundledAggregations",
473+
bundled_aggregations: Dict[str, "BundledAggregations"],
475474
serialized_event: JsonDict,
476475
apply_edits: bool,
477476
) -> None:
@@ -481,22 +480,37 @@ def _inject_bundled_aggregations(
481480
event: The event being serialized.
482481
time_now: The current time in milliseconds
483482
config: Event serialization config
484-
aggregations: The bundled aggregation to serialize.
483+
bundled_aggregations: Bundled aggregations to be injected.
484+
A map from event_id to aggregation data. Must contain at least an
485+
entry for `event`.
486+
487+
While serializing the bundled aggregations this map may be searched
488+
again for additional events in a recursive manner.
485489
serialized_event: The serialized event which may be modified.
486490
apply_edits: Whether the content of the event should be modified to reflect
487491
any replacement in `aggregations.replace`.
488492
"""
493+
494+
# We have already checked that aggregations exist for this event.
495+
event_aggregations = bundled_aggregations[event.event_id]
496+
497+
# The JSON dictionary to be added under the unsigned property of the event
498+
# being serialized.
489499
serialized_aggregations = {}
490500

491-
if aggregations.annotations:
492-
serialized_aggregations[RelationTypes.ANNOTATION] = aggregations.annotations
501+
if event_aggregations.annotations:
502+
serialized_aggregations[
503+
RelationTypes.ANNOTATION
504+
] = event_aggregations.annotations
493505

494-
if aggregations.references:
495-
serialized_aggregations[RelationTypes.REFERENCE] = aggregations.references
506+
if event_aggregations.references:
507+
serialized_aggregations[
508+
RelationTypes.REFERENCE
509+
] = event_aggregations.references
496510

497-
if aggregations.replace:
511+
if event_aggregations.replace:
498512
# If there is an edit, optionally apply it to the event.
499-
edit = aggregations.replace
513+
edit = event_aggregations.replace
500514
if apply_edits:
501515
self._apply_edit(event, serialized_event, edit)
502516

@@ -507,19 +521,16 @@ def _inject_bundled_aggregations(
507521
"sender": edit.sender,
508522
}
509523

510-
# If this event is the start of a thread, include a summary of the replies.
511-
if aggregations.thread:
512-
thread = aggregations.thread
524+
# Include any threaded replies to this event.
525+
if event_aggregations.thread:
526+
thread = event_aggregations.thread
513527

514-
# Don't bundle aggregations as this could recurse forever.
515-
serialized_latest_event = serialize_event(
516-
thread.latest_event, time_now, config=config
528+
serialized_latest_event = self.serialize_event(
529+
thread.latest_event,
530+
time_now,
531+
config=config,
532+
bundle_aggregations=bundled_aggregations,
517533
)
518-
# Manually apply an edit, if one exists.
519-
if thread.latest_edit:
520-
self._apply_edit(
521-
thread.latest_event, serialized_latest_event, thread.latest_edit
522-
)
523534

524535
thread_summary = {
525536
"latest_event": serialized_latest_event,

synapse/handlers/relations.py

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,6 @@
4444
class _ThreadAggregation:
4545
# The latest event in the thread.
4646
latest_event: EventBase
47-
# The latest edit to the latest event in the thread.
48-
latest_edit: Optional[EventBase]
4947
# The total number of events in the thread.
5048
count: int
5149
# True if the current user has sent an event to the thread.
@@ -295,7 +293,7 @@ async def get_threads_for_events(
295293

296294
for event_id, summary in summaries.items():
297295
if summary:
298-
thread_count, latest_thread_event, edit = summary
296+
thread_count, latest_thread_event = summary
299297

300298
# Subtract off the count of any ignored users.
301299
for ignored_user in ignored_users:
@@ -340,7 +338,6 @@ async def get_threads_for_events(
340338

341339
results[event_id] = _ThreadAggregation(
342340
latest_event=latest_thread_event,
343-
latest_edit=edit,
344341
count=thread_count,
345342
# If there's a thread summary it must also exist in the
346343
# participated dictionary.
@@ -359,8 +356,13 @@ async def get_bundled_aggregations(
359356
user_id: The user requesting the bundled aggregations.
360357
361358
Returns:
362-
A map of event ID to the bundled aggregation for the event. Not all
363-
events may have bundled aggregations in the results.
359+
A map of event ID to the bundled aggregations for the event.
360+
361+
Not all requested events may exist in the results (if they don't have
362+
bundled aggregations).
363+
364+
The results may include additional events which are related to the
365+
requested events.
364366
"""
365367
# De-duplicate events by ID to handle the same event requested multiple times.
366368
#
@@ -369,22 +371,59 @@ async def get_bundled_aggregations(
369371
event.event_id: event for event in events if not event.is_state()
370372
}
371373

374+
# A map of event ID to the relation in that event, if there is one.
375+
relations_by_id: Dict[str, str] = {}
376+
for event_id, event in events_by_id.items():
377+
relates_to = event.content.get("m.relates_to")
378+
if isinstance(relates_to, collections.abc.Mapping):
379+
relation_type = relates_to.get("rel_type")
380+
if isinstance(relation_type, str):
381+
relations_by_id[event_id] = relation_type
382+
372383
# event ID -> bundled aggregation in non-serialized form.
373384
results: Dict[str, BundledAggregations] = {}
374385

375386
# Fetch any ignored users of the requesting user.
376387
ignored_users = await self._main_store.ignored_users(user_id)
377388

389+
# Threads are special as the latest event of a thread might cause additional
390+
# events to be fetched. Thus, we check those first!
391+
392+
# Fetch thread summaries (but only for the directly requested events).
393+
threads = await self.get_threads_for_events(
394+
# It is not valid to start a thread on an event which itself relates to another event.
395+
[eid for eid in events_by_id.keys() if eid not in relations_by_id],
396+
user_id,
397+
ignored_users,
398+
)
399+
for event_id, thread in threads.items():
400+
results.setdefault(event_id, BundledAggregations()).thread = thread
401+
402+
# If the latest event in a thread is not already being fetched,
403+
# add it. This ensures that the bundled aggregations for the
404+
# latest thread event is correct.
405+
latest_thread_event = thread.latest_event
406+
if latest_thread_event and latest_thread_event.event_id not in events_by_id:
407+
events_by_id[latest_thread_event.event_id] = latest_thread_event
408+
# Keep relations_by_id in sync with events_by_id:
409+
#
410+
# We know that the latest event in a thread has a thread relation
411+
# (as that is what makes it part of the thread).
412+
relations_by_id[latest_thread_event.event_id] = RelationTypes.THREAD
413+
378414
# Fetch other relations per event.
379415
for event in events_by_id.values():
380-
# Do not bundle aggregations for an event which represents an edit or an
381-
# annotation. It does not make sense for them to have related events.
382-
relates_to = event.content.get("m.relates_to")
383-
if isinstance(relates_to, collections.abc.Mapping):
384-
relation_type = relates_to.get("rel_type")
385-
if relation_type in (RelationTypes.ANNOTATION, RelationTypes.REPLACE):
386-
continue
387-
416+
# An event which is a replacement (ie edit) or annotation (ie, reaction)
417+
# may not have any other event related to it.
418+
#
419+
# XXX This is buggy, see https://github.com/matrix-org/synapse/issues/12566
420+
if relations_by_id.get(event.event_id) in (
421+
RelationTypes.ANNOTATION,
422+
RelationTypes.REPLACE,
423+
):
424+
continue
425+
426+
# Fetch any annotations (ie, reactions) to bundle with this event.
388427
annotations = await self.get_annotations_for_event(
389428
event.event_id, event.room_id, ignored_users=ignored_users
390429
)
@@ -393,6 +432,7 @@ async def get_bundled_aggregations(
393432
event.event_id, BundledAggregations()
394433
).annotations = {"chunk": annotations}
395434

435+
# Fetch any references to bundle with this event.
396436
references, next_token = await self.get_relations_for_event(
397437
event.event_id,
398438
event,
@@ -425,10 +465,4 @@ async def get_bundled_aggregations(
425465
for event_id, edit in edits.items():
426466
results.setdefault(event_id, BundledAggregations()).replace = edit
427467

428-
threads = await self.get_threads_for_events(
429-
events_by_id.keys(), user_id, ignored_users
430-
)
431-
for event_id, thread in threads.items():
432-
results.setdefault(event_id, BundledAggregations()).thread = thread
433-
434468
return results

synapse/storage/databases/main/relations.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,8 @@ def get_thread_summary(self, event_id: str) -> Optional[Tuple[int, EventBase]]:
445445
@cachedList(cached_method_name="get_thread_summary", list_name="event_ids")
446446
async def get_thread_summaries(
447447
self, event_ids: Collection[str]
448-
) -> Dict[str, Optional[Tuple[int, EventBase, Optional[EventBase]]]]:
449-
"""Get the number of threaded replies, the latest reply (if any), and the latest edit for that reply for the given event.
448+
) -> Dict[str, Optional[Tuple[int, EventBase]]]:
449+
"""Get the number of threaded replies and the latest reply (if any) for the given events.
450450
451451
Args:
452452
event_ids: Summarize the thread related to this event ID.
@@ -458,7 +458,6 @@ async def get_thread_summaries(
458458
Each summary is a tuple of:
459459
The number of events in the thread.
460460
The most recent event in the thread.
461-
The most recent edit to the most recent event in the thread, if applicable.
462461
"""
463462

464463
def _get_thread_summaries_txn(
@@ -544,9 +543,6 @@ def _get_thread_summaries_txn(
544543

545544
latest_events = await self.get_events(latest_event_ids.values()) # type: ignore[attr-defined]
546545

547-
# Check to see if any of those events are edited.
548-
latest_edits = await self.get_applicable_edits(latest_event_ids.values())
549-
550546
# Map to the event IDs to the thread summary.
551547
#
552548
# There might not be a summary due to there not being a thread or
@@ -557,8 +553,7 @@ def _get_thread_summaries_txn(
557553

558554
summary = None
559555
if latest_event:
560-
latest_edit = latest_edits.get(latest_event_id)
561-
summary = (counts[parent_event_id], latest_event, latest_edit)
556+
summary = (counts[parent_event_id], latest_event)
562557
summaries[parent_event_id] = summary
563558

564559
return summaries

tests/rest/client/test_relations.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1029,7 +1029,106 @@ def assert_thread(bundled_aggregations: JsonDict) -> None:
10291029
bundled_aggregations.get("latest_event"),
10301030
)
10311031

1032-
self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 9)
1032+
self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 10)
1033+
1034+
def test_thread_with_bundled_aggregations_for_latest(self) -> None:
1035+
"""
1036+
Bundled aggregations should get applied to the latest thread event.
1037+
"""
1038+
self._send_relation(RelationTypes.THREAD, "m.room.test")
1039+
channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
1040+
thread_2 = channel.json_body["event_id"]
1041+
1042+
self._send_relation(
1043+
RelationTypes.ANNOTATION, "m.reaction", "a", parent_id=thread_2
1044+
)
1045+
1046+
def assert_thread(bundled_aggregations: JsonDict) -> None:
1047+
self.assertEqual(2, bundled_aggregations.get("count"))
1048+
self.assertTrue(bundled_aggregations.get("current_user_participated"))
1049+
# The latest thread event has some fields that don't matter.
1050+
self.assert_dict(
1051+
{
1052+
"content": {
1053+
"m.relates_to": {
1054+
"event_id": self.parent_id,
1055+
"rel_type": RelationTypes.THREAD,
1056+
}
1057+
},
1058+
"event_id": thread_2,
1059+
"sender": self.user_id,
1060+
"type": "m.room.test",
1061+
},
1062+
bundled_aggregations.get("latest_event"),
1063+
)
1064+
# Check the unsigned field on the latest event.
1065+
self.assert_dict(
1066+
{
1067+
"m.relations": {
1068+
RelationTypes.ANNOTATION: {
1069+
"chunk": [
1070+
{"type": "m.reaction", "key": "a", "count": 1},
1071+
]
1072+
},
1073+
}
1074+
},
1075+
bundled_aggregations["latest_event"].get("unsigned"),
1076+
)
1077+
1078+
self._test_bundled_aggregations(RelationTypes.THREAD, assert_thread, 10)
1079+
1080+
def test_nested_thread(self) -> None:
1081+
"""
1082+
Ensure that a nested thread gets ignored by bundled aggregations, as
1083+
those are forbidden.
1084+
"""
1085+
1086+
# Start a thread.
1087+
channel = self._send_relation(RelationTypes.THREAD, "m.room.test")
1088+
reply_event_id = channel.json_body["event_id"]
1089+
1090+
# Disable the validation to pretend this came over federation, since it is
1091+
# not an event the Client-Server API will allow..
1092+
with patch(
1093+
"synapse.handlers.message.EventCreationHandler._validate_event_relation",
1094+
new=lambda self, event: make_awaitable(None),
1095+
):
1096+
# Create a sub-thread off the thread, which is not allowed.
1097+
self._send_relation(
1098+
RelationTypes.THREAD, "m.room.test", parent_id=reply_event_id
1099+
)
1100+
1101+
# Fetch the thread root, to get the bundled aggregation for the thread.
1102+
relations_from_event = self._get_bundled_aggregations()
1103+
1104+
# Ensure that requesting the room messages also does not return the sub-thread.
1105+
channel = self.make_request(
1106+
"GET",
1107+
f"/rooms/{self.room}/messages?dir=b",
1108+
access_token=self.user_token,
1109+
)
1110+
self.assertEqual(200, channel.code, channel.json_body)
1111+
event = self._find_event_in_chunk(channel.json_body["chunk"])
1112+
relations_from_messages = event["unsigned"]["m.relations"]
1113+
1114+
# Check the bundled aggregations from each point.
1115+
for aggregations, desc in (
1116+
(relations_from_event, "/event"),
1117+
(relations_from_messages, "/messages"),
1118+
):
1119+
# The latest event should have bundled aggregations.
1120+
self.assertIn(RelationTypes.THREAD, aggregations, desc)
1121+
thread_summary = aggregations[RelationTypes.THREAD]
1122+
self.assertIn("latest_event", thread_summary, desc)
1123+
self.assertEqual(
1124+
thread_summary["latest_event"]["event_id"], reply_event_id, desc
1125+
)
1126+
1127+
# The latest event should not have any bundled aggregations (since the
1128+
# only relation to it is another thread, which is invalid).
1129+
self.assertNotIn(
1130+
"m.relations", thread_summary["latest_event"]["unsigned"], desc
1131+
)
10331132

10341133
def test_thread_edit_latest_event(self) -> None:
10351134
"""Test that editing the latest event in a thread works."""
@@ -1049,6 +1148,7 @@ def test_thread_edit_latest_event(self) -> None:
10491148
content={"msgtype": "m.text", "body": "foo", "m.new_content": new_body},
10501149
parent_id=threaded_event_id,
10511150
)
1151+
edit_event_id = channel.json_body["event_id"]
10521152

10531153
# Fetch the thread root, to get the bundled aggregation for the thread.
10541154
relations_dict = self._get_bundled_aggregations()
@@ -1061,6 +1161,12 @@ def test_thread_edit_latest_event(self) -> None:
10611161
self.assertIn("latest_event", thread_summary)
10621162
latest_event_in_thread = thread_summary["latest_event"]
10631163
self.assertEqual(latest_event_in_thread["content"]["body"], "I've been edited!")
1164+
# The latest event in the thread should have the edit appear under the
1165+
# bundled aggregations.
1166+
self.assertDictContainsSubset(
1167+
{"event_id": edit_event_id, "sender": "@alice:test"},
1168+
latest_event_in_thread["unsigned"]["m.relations"][RelationTypes.REPLACE],
1169+
)
10641170

10651171
def test_aggregation_get_event_for_annotation(self) -> None:
10661172
"""Test that annotations do not get bundled aggregations included

0 commit comments

Comments
 (0)