Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
61fa1b9
Limit to_device EDU size to 65536
MatMaul May 8, 2025
c80f24d
Increment to_device stream for each EDU otherwise we loose some
MatMaul May 11, 2025
eda00e1
Simplify
MatMaul May 12, 2025
6627bed
Add comment
MatMaul May 12, 2025
c6bc691
Cosmetic
MatMaul May 12, 2025
57ab541
Cosmetic
MatMaul May 12, 2025
a0e6dc3
Add logs
MatMaul May 12, 2025
9ce1488
Improve logs
MatMaul May 12, 2025
5e86c59
fix bug
MatMaul May 12, 2025
35d98b6
Improve logs
MatMaul May 12, 2025
6be7bcc
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul May 13, 2025
a15a585
Apply suggestion from @MadLittleMods
mcalinghee Aug 20, 2025
e81e737
Apply suggestion from @MadLittleMods
mcalinghee Aug 20, 2025
1f9d243
Apply suggestion from @MadLittleMods
mcalinghee Aug 20, 2025
b503f97
Apply suggestion from @MadLittleMods
mcalinghee Aug 20, 2025
eef8132
add comment and add helper create_new_to_device_edu_content()
mcalinghee Aug 20, 2025
0e74f3e
Merge branch 'develop' into edu-limit-size
mcalinghee Aug 20, 2025
cf7441a
add helper method and refactor EDUs constant
mcalinghee Aug 20, 2025
71a26a4
fix trial tests due to twisted lib issue
mcalinghee Aug 20, 2025
55afc88
try to fix test flaky issue
mcalinghee Aug 21, 2025
f234226
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul Sep 2, 2025
cb3bcb5
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul Sep 9, 2025
fe51d27
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul Dec 23, 2025
7536ac0
Adress comments
MatMaul Dec 23, 2025
206b0b0
Fix mypy
MatMaul Dec 23, 2025
d1700d1
Adress comment
MatMaul Dec 23, 2025
4bb5f26
Adress comment
MatMaul Dec 24, 2025
9a1e646
Adress comments
MatMaul Dec 24, 2025
fd615d3
Adress comments
MatMaul Dec 24, 2025
5c115b9
Adress comments
MatMaul Dec 24, 2025
ca9af7e
Address comments
MatMaul Jan 5, 2026
e59be78
Remove useless whitespace change
MatMaul Jan 5, 2026
b5777d3
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul Jan 5, 2026
6b57cd3
Update tests/rest/client/test_sendtodevice.py
MatMaul Jan 6, 2026
55afe9a
Update tests/rest/client/test_sendtodevice.py
MatMaul Jan 6, 2026
67e7b50
Apply suggestion from @MadLittleMods
MatMaul Jan 7, 2026
384ce4d
Apply suggestion from @MadLittleMods
MatMaul Jan 7, 2026
f38f853
Apply suggestions from code review
MatMaul Jan 7, 2026
71a2c23
Update tests/rest/client/test_sendtodevice.py
MatMaul Jan 7, 2026
cf5ca16
Update tests/rest/client/test_sendtodevice.py
MatMaul Jan 7, 2026
c340373
typos
MatMaul Jan 7, 2026
741abb2
Use split in 2 alg
MatMaul Jan 7, 2026
e2d7eeb
Merge remote-tracking branch 'origin/develop' into edu-limit-size
MatMaul Mar 17, 2026
6c1606c
improve tests
MatMaul Mar 17, 2026
eb05042
Rename split method
MatMaul Mar 17, 2026
045c0bf
split db method in 2
MatMaul Mar 17, 2026
6406415
Naming and comments
MatMaul Mar 17, 2026
5347b78
Add comment
MatMaul Mar 17, 2026
4aa1bab
Add comment
MatMaul Mar 17, 2026
ab2951b
Address comments
MatMaul Mar 20, 2026
31f0029
remove useless pump
MatMaul Mar 20, 2026
33ff986
Remove try except
MatMaul Mar 20, 2026
ff046f7
remove workaround for test failures
MatMaul Mar 20, 2026
14a7515
remove random from tests
MatMaul Mar 20, 2026
a55fc07
Adress comments
MatMaul Mar 23, 2026
6c93d88
Add spec link
MatMaul Mar 23, 2026
4b7da00
Update synapse/handlers/devicemessage.py
MatMaul Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/18416.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A long queue of to-device messages could prevent outgoing federation because of the size of the transaction, let's limit the to-device EDU size to a reasonable value.
16 changes: 16 additions & 0 deletions synapse/api/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@

# the max size of a (canonical-json-encoded) event
MAX_PDU_SIZE = 65536
# This isn't spec'ed but is our own reasonable default to play nice with Synapse's
# `max_request_size`/`max_request_body_size`. We chose the same as `MAX_PDU_SIZE` as our
# `max_request_body_size` math is currently limited by 200 `MAX_PDU_SIZE` things. The
# spec for a `/federation/v1/send` request sets the limit at 100 EDU's and 50 PDU's
# which is below that 200 `MAX_PDU_SIZE` limit (`max_request_body_size`).
#
# Allowing oversized EDU's results in failed `/federation/v1/send` transactions (because
# the request overall can overrun the `max_request_body_size`) which are retried over
# and over and prevent other outbound federation traffic from happening.
MAX_EDU_SIZE = 65536
Comment thread
mcalinghee marked this conversation as resolved.

# This is defined in the Matrix spec and enforced by the receiver.
MAX_EDUS_PER_TRANSACTION = 100
# A transaction can contain up to 100 EDUs but synapse reserves 10 EDUs for other purposes
Comment thread
MatMaul marked this conversation as resolved.
# like trickling out some device list updates.
NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION = 10

# The maximum allowed size of an HTTP request.
# Other than media uploads, the biggest request we expect to see is a fully-loaded
Expand Down
13 changes: 8 additions & 5 deletions synapse/federation/sender/per_destination_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@

from twisted.internet import defer

from synapse.api.constants import EduTypes
from synapse.api.constants import (
MAX_EDUS_PER_TRANSACTION,
NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION,
EduTypes,
)
from synapse.api.errors import (
FederationDeniedError,
HttpResponseException,
Expand All @@ -51,9 +55,6 @@
if TYPE_CHECKING:
import synapse.server

# This is defined in the Matrix spec and enforced by the receiver.
MAX_EDUS_PER_TRANSACTION = 100

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -798,7 +799,9 @@ async def __aenter__(self) -> tuple[list[EventBase], list[Edu]]:
(
to_device_edus,
device_stream_id,
) = await self.queue._get_to_device_message_edus(edu_limit - 10)
) = await self.queue._get_to_device_message_edus(
edu_limit - NUMBER_OF_RESERVED_EDUS_PER_TRANSACTION
)

if to_device_edus:
self._device_stream_id = device_stream_id
Expand Down
158 changes: 135 additions & 23 deletions synapse/handlers/devicemessage.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,15 @@
from http import HTTPStatus
from typing import TYPE_CHECKING, Any

from synapse.api.constants import EduTypes, EventContentFields, ToDeviceEventTypes
from synapse.api.errors import Codes, SynapseError
from canonicaljson import encode_canonical_json

from synapse.api.constants import (
MAX_EDU_SIZE,
EduTypes,
EventContentFields,
ToDeviceEventTypes,
)
from synapse.api.errors import Codes, EventSizeError, SynapseError
from synapse.api.ratelimiting import Ratelimiter
from synapse.logging.context import run_in_background
from synapse.logging.opentracing import (
Expand All @@ -35,7 +42,7 @@
)
from synapse.types import JsonDict, Requester, StreamKeyType, UserID, get_domain_from_id
from synapse.util.json import json_encoder
from synapse.util.stringutils import random_string
from synapse.util.stringutils import random_string_insecure_fast

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand Down Expand Up @@ -222,6 +229,7 @@ async def send_device_message(
set_tag(SynapseTags.TO_DEVICE_TYPE, message_type)
set_tag(SynapseTags.TO_DEVICE_SENDER, sender_user_id)
local_messages = {}
# Map from destination (server) -> recipient (user ID) -> device_id -> JSON message content
remote_messages: dict[str, dict[str, dict[str, JsonDict]]] = {}
for user_id, by_device in messages.items():
if not UserID.is_valid(user_id):
Expand Down Expand Up @@ -277,28 +285,33 @@ async def send_device_message(
destination = get_domain_from_id(user_id)
remote_messages.setdefault(destination, {})[user_id] = by_device

context = get_active_span_text_map()

remote_edu_contents = {}
for destination, messages in remote_messages.items():
# The EDU contains a "message_id" property which is used for
# idempotence. Make up a random one.
Comment thread
MatMaul marked this conversation as resolved.
message_id = random_string(16)
log_kv({"destination": destination, "message_id": message_id})

remote_edu_contents[destination] = {
"messages": messages,
"sender": sender_user_id,
"type": message_type,
"message_id": message_id,
"org.matrix.opentracing_context": json_encoder.encode(context),
}

# Add messages to the database.
# Add local messages to the database.
# Retrieve the stream id of the last-processed to-device message.
last_stream_id = await self.store.add_messages_to_device_inbox(
local_messages, remote_edu_contents
last_stream_id = (
await self.store.add_local_messages_from_client_to_device_inbox(
local_messages
)
)
for destination, messages in remote_messages.items():
split_edus = split_device_messages_into_edus(
sender_user_id, message_type, messages
)
for edu in split_edus:
edu["org.matrix.opentracing_context"] = json_encoder.encode(
get_active_span_text_map()
)
Comment thread
MatMaul marked this conversation as resolved.
# Add remote messages to the database.
last_stream_id = (
await self.store.add_remote_messages_from_client_to_device_inbox(
{destination: edu}
)
)
log_kv(
{
"destination": destination,
"message_id": edu["message_id"],
}
)

# Notify listeners that there are new to-device messages to process,
# handing them the latest stream id.
Expand Down Expand Up @@ -397,3 +410,102 @@ async def get_events_for_dehydrated_device(
"events": messages,
"next_batch": f"d{stream_id}",
}


def split_device_messages_into_edus(
sender_user_id: str,
message_type: str,
messages_by_user_then_device: dict[str, dict[str, JsonDict]],
) -> list[JsonDict]:
"""
This function takes many to-device messages and fits/splits them into several EDUs
as necessary. We split the messages up as the overall request can overrun the
`max_request_body_size` and prevent outbound federation traffic because of the size
of the transaction (cf. `MAX_EDU_SIZE`).

Args:
sender_user_id: The user that is sending the to-device messages.
message_type: The type of to-device messages that are being sent.
messages_by_user_then_device: Dictionary of recipient user_id to recipient device_id to message.

Returns:
Bin-packed list of EDU JSON content for the given to_device messages

Raises:
EventSizeError: If a single to-device message is too large to fit into an EDU.
"""
split_edus_content: list[JsonDict] = []

# Convert messages dict to a list of (recipient, messages_by_device) pairs
message_items = list(messages_by_user_then_device.items())

while message_items:
edu_messages = {}
# Start by trying to fit all remaining messages
target_count = len(message_items)

while target_count > 0:
# Take the first target_count messages
edu_messages = dict(message_items[:target_count])
edu_content = create_new_to_device_edu_content(
sender_user_id, message_type, edu_messages
)
# Let's add the whole EDU structure before testing the size
edu = {
"content": edu_content,
"edu_type": EduTypes.DIRECT_TO_DEVICE,
}

if len(encode_canonical_json(edu)) <= MAX_EDU_SIZE:
# It fits! Add this EDU and remove these messages from the list
split_edus_content.append(edu_content)
message_items = message_items[target_count:]

logger.debug(
"Created EDU with %d recipients from %s (message_id=%s), (total EDUs so far: %d)",
target_count,
sender_user_id,
edu_content["message_id"],
len(split_edus_content),
)
break
else:
if target_count == 1:
# Single recipient's messages are too large, let's reject the client
# call with 413/`M_TOO_LARGE`, we expect this error to reach the
# client in the case of the /sendToDevice endpoint.
#
# 413 is currently an unspecced response for `/sendToDevice` but is
# probably the best thing we can do.
# https://github.com/matrix-org/matrix-spec/pull/2340 tracks adding
# this to the spec
recipient = message_items[0][0]
raise EventSizeError(
f"device message to {recipient} too large to fit in a single EDU",
unpersistable=True,
)

# Halve the number of messages and try again
target_count = target_count // 2

return split_edus_content


def create_new_to_device_edu_content(
sender_user_id: str,
message_type: str,
messages_by_user_then_device: dict[str, dict[str, JsonDict]],
) -> JsonDict:
"""
Create a new `m.direct_to_device` EDU `content` object with a unique message ID.
"""
# The EDU contains a "message_id" property which is used for
# idempotence. Make up a random one.
message_id = random_string_insecure_fast(16)
content = {
"sender": sender_user_id,
"type": message_type,
"message_id": message_id,
"messages": messages_by_user_then_device,
}
return content
47 changes: 39 additions & 8 deletions synapse/storage/databases/main/deviceinbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -739,18 +739,15 @@ def get_all_new_device_messages_txn(
)

@trace
async def add_messages_to_device_inbox(
async def add_local_messages_from_client_to_device_inbox(
self,
local_messages_by_user_then_device: dict[str, dict[str, JsonDict]],
remote_messages_by_destination: dict[str, JsonDict],
) -> int:
"""Used to send messages from this server.
"""Queue local device messages that will be sent to devices of local users.

Args:
local_messages_by_user_then_device:
Dictionary of recipient user_id to recipient device_id to message.
remote_messages_by_destination:
Dictionary of destination server_name to the EDU JSON to send.

Returns:
The new stream_id.
Expand All @@ -766,6 +763,39 @@ def add_messages_txn(
txn, stream_id, local_messages_by_user_then_device
)

async with self._to_device_msg_id_gen.get_next() as stream_id:
now_ms = self.clock.time_msec()
await self.db_pool.runInteraction(
"add_local_messages_from_client_to_device_inbox",
add_messages_txn,
now_ms,
stream_id,
)
for user_id in local_messages_by_user_then_device.keys():
self._device_inbox_stream_cache.entity_has_changed(user_id, stream_id)

return self._to_device_msg_id_gen.get_current_token()

@trace
async def add_remote_messages_from_client_to_device_inbox(
self,
remote_messages_by_destination: dict[str, JsonDict],
) -> int:
"""Queue device messages that will be sent to remote servers.

Args:
remote_messages_by_destination:
Dictionary of destination server_name to the EDU JSON to send.

Returns:
The new stream_id.
"""

assert self._can_write_to_device

def add_messages_txn(
txn: LoggingTransaction, now_ms: int, stream_id: int
) -> None:
# Add the remote messages to the federation outbox.
# We'll send them to a remote server when we next send a
# federation transaction to that destination.
Expand Down Expand Up @@ -824,10 +854,11 @@ def add_messages_txn(
async with self._to_device_msg_id_gen.get_next() as stream_id:
now_ms = self.clock.time_msec()
await self.db_pool.runInteraction(
"add_messages_to_device_inbox", add_messages_txn, now_ms, stream_id
"add_remote_messages_from_client_to_device_inbox",
add_messages_txn,
now_ms,
stream_id,
)
for user_id in local_messages_by_user_then_device.keys():
self._device_inbox_stream_cache.entity_has_changed(user_id, stream_id)
for destination in remote_messages_by_destination.keys():
self._device_federation_outbox_stream_cache.entity_has_changed(
destination, stream_id
Expand Down
4 changes: 3 additions & 1 deletion tests/handlers/test_appservice.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,7 +856,9 @@ def test_application_services_receive_bursts_of_to_device(self) -> None:

# Seed the device_inbox table with our fake messages
self.get_success(
self.hs.get_datastores().main.add_messages_to_device_inbox(messages, {})
self.hs.get_datastores().main.add_local_messages_from_client_to_device_inbox(
messages
)
)

# Now have local_user send a final to-device message to exclusive_as_user. All unsent
Expand Down
Loading
Loading