Files
synapse/synapse/util/__init__.py
T
5bf825c016 Limit to-device EDU sizes (#19617)
This is based on https://github.com/element-hq/synapse/pull/18416, which
got reverted (#19614) due to it incorrectly rejecting to-device messages
to users with many devices (and thus breaking message sending).

Fix https://github.com/element-hq/synapse/issues/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>
2026-06-02 15:57:55 +00:00

276 lines
9.0 KiB
Python

#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright 2014-2016 OpenMarket Ltd
# Copyright (C) 2023 New Vector, Ltd
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#
import collections.abc
import logging
import os
import typing
from typing import (
Any,
Iterator,
Mapping,
Sequence,
TypeVar,
)
import attr
from canonicaljson import encode_canonical_json
from matrix_common.versionstring import get_distribution_version_string
from twisted.internet import defer
from twisted.python.failure import Failure
from synapse.types import JsonDict
if typing.TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
def unwrapFirstError(failure: Failure) -> Failure:
# Deprecated: you probably just want to catch defer.FirstError and reraise
# the subFailure's value, which will do a better job of preserving stacktraces.
# (actually, you probably want to use yieldable_gather_results anyway)
failure.trap(defer.FirstError)
return failure.value.subFailure
def log_failure(
failure: Failure, msg: str, consumeErrors: bool = True
) -> Failure | None:
"""Creates a function suitable for passing to `Deferred.addErrback` that
logs any failures that occur.
Args:
failure: The Failure to log
msg: Message to log
consumeErrors: If true consumes the failure, otherwise passes on down
the callback chain
Returns:
The Failure if consumeErrors is false. None, otherwise.
"""
logger.error(
msg, exc_info=(failure.type, failure.value, failure.getTracebackObject())
)
if not consumeErrors:
return failure
return None
SYNAPSE_VERSION = os.getenv(
"SYNAPSE_VERSION_STRING"
) or get_distribution_version_string("matrix-synapse", __file__)
"""
Version string with git info.
This can be overridden via the `SYNAPSE_VERSION_STRING` environment variable or is
computed here once so that we don't invoke git multiple times.
"""
class ExceptionBundle(Exception):
# A poor stand-in for something like Python 3.11's ExceptionGroup.
# (A backport called `exceptiongroup` exists but seems overkill: we just want a
# container type here.)
def __init__(self, message: str, exceptions: Sequence[Exception]):
parts = [message]
for e in exceptions:
parts.append(str(e))
super().__init__("\n - ".join(parts))
self.exceptions = exceptions
K = TypeVar("K")
V = TypeVar("V")
@attr.s(slots=True, auto_attribs=True)
class MutableOverlayMapping(collections.abc.MutableMapping[K, V]):
"""A mutable mapping that allows changes to a read-only underlying
mapping. Supports deletions.
This is useful for cases where you want to allow modifications to a mapping
without changing or copying the original mapping.
Note: the underlying mapping must not change while this proxy is in use.
"""
_underlying_map: Mapping[K, V]
_mutable_map: dict[K, V] = attr.ib(factory=dict)
_deletions: set[K] = attr.ib(factory=set)
def __getitem__(self, key: K) -> V:
if key in self._deletions:
raise KeyError(key)
if key in self._mutable_map:
return self._mutable_map[key]
return self._underlying_map[key]
def __setitem__(self, key: K, value: V) -> None:
self._deletions.discard(key)
self._mutable_map[key] = value
def __delitem__(self, key: K) -> None:
if key not in self:
raise KeyError(key)
self._deletions.add(key)
self._mutable_map.pop(key, None)
def __iter__(self) -> Iterator[K]:
for key in self._mutable_map:
if key not in self._deletions:
yield key
for key in self._underlying_map:
if key not in self._deletions and key not in self._mutable_map:
# `key` should not be in both _mutable_map and _deletions
assert key not in self._mutable_map
yield key
def __len__(self) -> int:
count = len(self._underlying_map)
for key in self._deletions:
if key in self._underlying_map:
count -= 1
for key in self._mutable_map:
# `key` should not be in both _mutable_map and _deletions
assert key not in self._deletions
if key not in self._underlying_map:
count += 1
return count
def clear(self) -> None:
self._underlying_map = {}
self._mutable_map.clear()
self._deletions.clear()
@attr.s(slots=True, auto_attribs=True)
class _DictSplitterState:
"""State for splitting a dict into multiple dicts, c.f.
`split_dict_to_fit_to_size`."""
subset: dict[str, Any]
"""A subset of the original dict."""
estimated_size: int
"""Estimated size of the JSON encoding of the current payload, including any
wrapping structure."""
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
fit into the given size when encoded as JSON. Every entry in the original
dict is in exactly one of the resulting dicts.
The `wrapping_object_size` can be used if the resulting dicts are going to
be wrapped in some additional JSON structure, to account for the additional
size of that structure. The default assumes no wrapping, and just accounts
for the two curly braces of the dict itself.
Note that if an individual entry in the original dict is larger than
`soft_max_size` then this will emit a dict containing just that entry, which
will be larger than `soft_max_size` when encoded as JSON.
Args:
original_dict: The dict to split.
soft_max_size: The maximum size of each dict when encoded as JSON.
wrapping_object_size: The estimated size of the JSON encoding of the
payload when empty.
Returns:
An iterator of (dict, size) pairs, where dict is a subset of the
original dict and size is the estimated size of the JSON encoding of
that dict, including any wrapping structure.
"""
if not original_dict:
return
# Check if the whole dict fits within the size limit. If it does, we can
# skip the splitting logic and just return the original dict.
full_size = _len_with_wrapping_object(original_dict, wrapping_object_size)
if full_size <= soft_max_size:
yield (original_dict, full_size)
return
# The current payload being built up. We keep track of the estimated size of
# the JSON encoding of this payload so that we can decide when to start a
# new one.
current_payload = _DictSplitterState(subset={}, estimated_size=wrapping_object_size)
for key, payload in original_dict.items():
current_payload.subset[key] = payload
current_size = _len_with_wrapping_object(
current_payload.subset, wrapping_object_size
)
if current_size > soft_max_size:
# We've exceeded the size limit, so we need to start a new payload. We pop
# the current entry from the payload and yield the previous payload, then
# start a new payload with just the current entry.
if len(current_payload.subset) > 1:
current_payload.subset.pop(key)
yield current_payload.subset, current_payload.estimated_size
current_payload = _DictSplitterState(
subset={},
estimated_size=wrapping_object_size,
)
# Recalculate the current size with just the current entry.
current_size = _len_with_wrapping_object(
{key: payload}, wrapping_object_size
)
current_payload.subset[key] = payload
current_payload.estimated_size = current_size
if current_payload.subset:
# yield the final payload if it's non-empty
yield current_payload.subset, current_payload.estimated_size
def _len_with_wrapping_object(payload: Any, wrapping_object_size: int) -> int:
"""Helper function to calculate the size of a payload when encoded as JSON,
including any wrapping structure."""
return (
len(encode_canonical_json(payload))
+ wrapping_object_size
# account for the curly braces of the dict itself, which are
# included in the size of the subset but not in the size of the
# payload
- 2
)