Skip to content

Limit to-device EDU sizes#19617

Merged
erikjohnston merged 14 commits into
developfrom
erikj/limit_to_device
Jun 2, 2026
Merged

Limit to-device EDU sizes#19617
erikjohnston merged 14 commits into
developfrom
erikj/limit_to_device

Conversation

@erikjohnston

@erikjohnston erikjohnston commented Mar 27, 2026

Copy link
Copy Markdown
Member

This is based on #18416, which got reverted (#19614) due to it incorrectly rejecting to-device messages to users with many devices (and thus breaking message sending).

Fix #17035

A to-device message content looks like:

{
  "@user:domain": {"device1": {...}, "device2": {...}},
  ...
}

The previous PR would split up into multiple EDUs, each with a subset of the users. However, if one user's entry was too large it would not further split it up and then error out.

The main change in this PR is to allow splitting up a single user into multiple EDUs.

Other changes:

  1. Rename to SOFT_MAX_EDU_SIZE to indicate that we sometimes send EDUs with larger size than that, and its more a target than a hard limit.
  2. Check early if any to-device message (to a specific device) is too large to send, even if we're not going to send it over federation. This ensures that we catch issues where clients try to send too large to-device.

This still means that if a client send a large individual to-device message it will fail, but I don't believe we ever send such large to-device messages (normally they're in the range of a few KB).


I ended up changing the implementation a bunch to make it easy to reuse the code to split up dictionaries. Instead of repeatedly splitting up the EDU until each bit fits into the size, we instead record the size of each entry in the dict and instead split up based on cumulative size. This means we call encode_canonical_json on each entry rather than once on the entire struct, but its not significantly slower to do so.

--

cc @MatMaul @MadLittleMods

MatMaul and others added 3 commits March 27, 2026 12:01
If a set of messages exceeds this limit, the messages are split
across several EDUs.

Fix #17035 (should)

There is currently [no official specced limit for
EDUs](matrix-org/matrix-spec#807), but the
consensus seems to be that it would be useful to have one to avoid this
bug by bounding the transaction size.

As a side effect it also limits the size of a single to-device message
to a bit less than 65536.

This should probably be added to the spec similarly to the [message size
limit.](https://spec.matrix.org/v1.14/client-server-api/#size-limits)

Spec PR: matrix-org/matrix-spec#2340

---------

Co-authored-by: mcalinghee <mcalinghee.dev@gmail.com>
Co-authored-by: Eric Eastwood <madlittlemods@gmail.com>
This is based on #18416, which got reverted due to it rejecting
to-device messages to users with many devices.

The main change here is that if a to-device EDU for a single user is too
large, then we split it up into multiple EDUs.
Comment thread synapse/handlers/devicemessage.py Outdated
Comment thread changelog.d/19617.bugfix
It's not that much more costly to just keep re-encoding the values.
Comment thread synapse/handlers/devicemessage.py Outdated
Comment thread synapse/handlers/devicemessage.py
@erikjohnston erikjohnston marked this pull request as ready for review April 1, 2026 10:55
@erikjohnston erikjohnston requested a review from a team as a code owner April 1, 2026 10:55
@anoadragon453 anoadragon453 requested a review from Copilot April 29, 2026 12:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates Synapse’s handling of outbound to-device messages so that outgoing m.direct_to_device EDUs are kept to a target size by splitting payloads more granularly (including splitting a single recipient’s devices across multiple EDUs), while also rejecting individual to-device messages that are too large.

Changes:

  • Introduce a generic dict-splitting helper (split_dict_to_fit_to_size) and unit tests for its behavior.
  • Refactor to-device sending to split remote to-device payloads across multiple EDUs (including per-user device splitting) and queue them separately for federation.
  • Add constants for EDU size targets / transaction EDU budgeting and expand tests to cover splitting and size-limit failures.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/util/test_split_dict.py Adds unit tests for the new dict-splitting utility.
tests/rest/client/test_sendtodevice.py Adds tests for to-device size rejection and EDU/transaction splitting behavior.
tests/handlers/test_appservice.py Updates test setup to use the renamed datastore method for local to-device inbox seeding.
synapse/util/init.py Adds split_dict_to_fit_to_size and helper state/size-calculation logic.
synapse/storage/databases/main/deviceinbox.py Splits the old “add to-device inbox” method into separate local vs remote queueing APIs.
synapse/handlers/devicemessage.py Enforces per-message size checking and implements splitting remote to-device messages into multiple EDUs.
synapse/federation/sender/per_destination_queue.py Uses constants for EDU transaction limits and reserved EDU slots.
synapse/api/constants.py Introduces SOFT_MAX_EDU_SIZE and transaction EDU constants.
changelog.d/19617.bugfix Documents the bugfix around large to-device queues blocking outbound federation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread synapse/handlers/devicemessage.py Outdated
Comment thread synapse/util/__init__.py
Comment on lines +187 to +193
def split_dict_to_fit_to_size(
original_dict: dict[str, Any],
*,
soft_max_size: int,
wrapping_object_size: int = 2,
) -> Iterator[tuple[dict[str, JsonDict], int]]:
"""Splits a dict up into a list of dicts, each of which is small enough to

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type of split_dict_to_fit_to_size is too specific: it currently declares Iterator[tuple[dict[str, JsonDict], int]], but this helper is used (and tested) with non-dict values (e.g. strings) and should preserve the original value type. Consider making it generic over the value type (TypeVar) and returning dict[str, V] (or dict[str, Any]) to avoid mypy errors and misleading annotations.

Copilot uses AI. Check for mistakes.
Comment thread synapse/util/__init__.py
Comment on lines +233 to +237
for key, payload in original_dict.items():
current_payload.subset[key] = payload
current_size = _len_with_wrapping_object(
current_payload.subset, wrapping_object_size
)

Copilot AI Apr 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

split_dict_to_fit_to_size recomputes encode_canonical_json(current_payload.subset) on every iteration as the subset grows, making the algorithm O(n^2) in the number of entries. For large to-device payloads (many devices/users), this can become a noticeable CPU cost; consider precomputing per-entry sizes once and tracking cumulative size (including commas/quotes/keys) to split without repeatedly re-encoding the whole subset.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions calling encode_canonical_json on each entry. But Copilot is correct; we are technically calling encode_canonical_json multiple times on each entry in current_payload.subset as we build it up.

I presume you'd have to store the length for each entry in subset to avoid this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was the original implementation, but you have to be careful of handling extra chars like commas, etc.

The common case should be that the to-device content is smaller than max, and therefore we take the fast path of only serializing once.

Comment thread tests/util/test_split_dict.py Outdated
Comment thread synapse/handlers/devicemessage.py Outdated

@anoadragon453 anoadragon453 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks great. Just some minor things below.

Comment thread synapse/handlers/devicemessage.py Outdated
Comment thread synapse/util/__init__.py Outdated
Comment thread synapse/util/__init__.py
Comment on lines +187 to +192
def split_dict_to_fit_to_size(
original_dict: dict[str, Any],
*,
soft_max_size: int,
wrapping_object_size: int = 2,
) -> Iterator[tuple[dict[str, JsonDict], int]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should note that this is a generator in the docstring.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could annotate that it's a generator, but the fact its a generator is an implementation detail? A generator like this is an Iterator

Comment thread synapse/util/__init__.py
Comment on lines +233 to +237
for key, payload in original_dict.items():
current_payload.subset[key] = payload
current_size = _len_with_wrapping_object(
current_payload.subset, wrapping_object_size
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions calling encode_canonical_json on each entry. But Copilot is correct; we are technically calling encode_canonical_json multiple times on each entry in current_payload.subset as we build it up.

I presume you'd have to store the length for each entry in subset to avoid this.

Comment thread synapse/handlers/devicemessage.py Outdated
Comment thread tests/util/test_split_dict.py Outdated
@erikjohnston erikjohnston enabled auto-merge (squash) June 2, 2026 15:42
@erikjohnston erikjohnston merged commit 5bf825c into develop Jun 2, 2026
79 of 81 checks passed
@erikjohnston erikjohnston deleted the erikj/limit_to_device branch June 2, 2026 15:57
@MatMaul

MatMaul commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Thank you all for getting this through ❤️


self.assertEqual(number_of_edus_sent, 2)

def test_edu_large_messages_not_splitting_one_user(self) -> None:

@MadLittleMods MadLittleMods Jun 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just saw this test flake in another PR,

https://github.com/element-hq/synapse/actions/runs/26910432685/job/79387059438?pr=19648

Error: 
Traceback (most recent call last):
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/logger/_observer.py", line 81, in __call__
    observer(event)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/logger/_legacy.py", line 90, in __call__
    self.legacyObserver(event)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/trial/_dist/workertrial.py", line 44, in emit
    self.protocol.callRemote(managercommands.TestWrite, out=text)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 951, in callRemote
    return co._doCommand(self)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 1973, in _doCommand
    d = proto._sendBoxCommand(
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 884, in _sendBoxCommand
    box._sendTo(self.boxSender)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 713, in _sendTo
    proto.sendBox(self)
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 2358, in sendBox
    self.transport.write(box.serialize())
  File "/home/runner/.cache/pypoetry/virtualenvs/matrix-synapse-pswDeSvb-py3.14/lib/python3.14/site-packages/twisted/protocols/amp.py", line 692, in serialize
    raise TooLong(False, True, v, k)
twisted.protocols.amp.TooLong: 

tests.rest.client.test_sendtodevice.SendToDeviceTestCase.test_edu_large_messages_not_splitting_one_user

Previous discussions in the original PR:

How we worked around this before:

FrenchGithubUser pushed a commit to famedly/synapse-upstreaming that referenced this pull request Jun 12, 2026
This is based on element-hq#18416, which
got reverted (element-hq#19614) due to it incorrectly rejecting to-device messages
to users with many devices (and thus breaking message sending).

Fix element-hq#17035

A to-device message content looks like:

```jsonc
{
  "@user:domain": {"device1": {...}, "device2": {...}},
  ...
}
```

The previous PR would split up into multiple EDUs, each with a subset of
the users. However, if one user's entry was too large it would not
further split it up and then error out.

The main change in this PR is to allow splitting up a single user into
multiple EDUs.

Other changes:
1. Rename to `SOFT_MAX_EDU_SIZE` to indicate that we sometimes send EDUs
with larger size than that, and its more a target than a hard limit.
2. Check early if any to-device message (to a specific device) is too
large to send, even if we're not going to send it over federation. This
ensures that we catch issues where clients try to send too large
to-device.

This still means that if a client send a large individual to-device
message it will fail, but I don't believe we ever send such large
to-device messages (normally they're in the range of a few KB).

---

I ended up changing the implementation a bunch to make it easy to reuse
the code to split up dictionaries. Instead of repeatedly splitting up
the EDU until each bit fits into the size, we instead record the size of
each entry in the dict and instead split up based on cumulative size.
This means we call `encode_canonical_json` on each entry rather than
once on the entire struct, but its not significantly slower to do so.

--

cc @MatMaul @MadLittleMods

---------

Co-authored-by: Mathieu Velten <matmaul@gmail.com>
Co-authored-by: mcalinghee <mcalinghee.dev@gmail.com>
Co-authored-by: Eric Eastwood <madlittlemods@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A long queue of to-device messages can prevent outgoing federation working

5 participants