BaseConsumer: тело сообщения в логе ошибки ограничено по размеру - #243
Merged
Merged
Conversation
added 2 commits
August 27, 2026 09:29
При падении консьюмера BaseConsumer.LogError писал тело целиком и с деструктуризацией
({@MessageData}). Structured-синк разворачивает коллекции скаляров в отдельные поля, поэтому
сообщение со справочником на десятки тысяч записей давало нечитаемую запись с массивами во весь
набор, и так на каждый ретрай.
Запись об ошибке сохранена — она нужна для разбора инцидентов, — но ограничена:
- тело уходит одним скаляром: усечённый JSON строкой, а не последовательностью;
- лимит MessageDataLimit по умолчанию 4000 байт UTF-8, переопределяется наследником. Байты, а не
символы: ограничение на длину терма в хранилище логов считается в байтах;
- сериализация идёт в буфер фиксированного размера, а на переполнении прекращает обход графа:
размер тела ничем не ограничен сверху, а сериализация в строку на большом сообщении сама даёт
OutOfMemory в обработчике ошибки;
- сбой сериализации отдаётся текстом: своё исключение подменило бы исходное, ради которого запись
и пишется;
- незакрытый хвост UTF-8 после обреза по байтам отбрасывается, иначе на его месте символ замены;
- нелатинский текст не экранируется в \uXXXX: иначе запись нечитаема, и на символ уходит втрое
больше отведённого объёма;
- в запись добавлены MessageType, MessageId, ConversationId, RetryAttempt — в теле их нет, от
лимита они не зависят, и по ним запись сопоставляется с сообщением в error-очереди;
- LogError стал virtual, форма записи вынесена в ILogger.LogConsumeError: консьюмер на несколько
типов сообщений базовый класс наследовать не может, а запись ему нужна такая же.
Новый тест-проект Dex.MassTransit.Rabbit.Tests, 12 тестов, проверены мутациями.
IdempotentConsumer переопределяет LogError и добавляет к записи IdempotentKey областью логирования, не дублируя шаблон базового класса. По этому ключу запись связывается с записью once-executor и с повторной доставкой того же сообщения; раньше он попадал в лог только как поле развёрнутого тела, то есть после ограничения тела пропал бы. Вычисление ключа обёрнуто в перехват: ConsumeContextExtensions.GetIdempotentKey бросает ArgumentNullException, если у сообщения нет ни IIdempotentKey, ни MessageId, а в обработчике ошибки это подменило бы исходное исключение и записи не стало бы вовсе. Одноаргументный IdempotentConsumer<TDbContext> не тронут: он не наследует BaseConsumer, у него нет ни Consume, ни обработки исключений, ни логгера. В remarks класса указано, что запись об ошибке за наследником и что готовую форму даёт ILogger.LogConsumeError. Новый тест-проект Dex.Cap.OnceExecutor.MassTransit.Test, 3 теста, проверены мутациями.
juk-777
approved these changes
Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Проблема
При падении консьюмера
BaseConsumer.LogErrorписал тело сообщения целиком и с деструктуризацией(
{@MessageData}). Structured-синк разворачивает каждую коллекцию скаляров в отдельное поле, поэтомусообщение со справочником на десятки тысяч записей давало нечитаемую запись с массивами во весь набор —
и повторялось на каждый ретрай.
Источник: GorodPay-group/GorodPay.Backend#59
Решение
Запись об ошибке сохранена — она нужна для разбора инцидентов, — но ограничена по размеру.
Форма записи (
ConsumerLoggerExtensions.LogConsumeError):(в том числе
IEnumerable<char>) structured-синк разворачивает поэлементно, то есть возвращаетисходную проблему в другой форме;
MessageType,MessageId,ConversationId,RetryAttempt— в теле их нет, от лимита они независят.
MessageIdсопоставляет запись с сообщением в_error-очереди,RetryAttemptотличаетповтор одной ошибки от нескольких разных сообщений;
TraceId/SpanIdнамеренно не добавлены: их пишет сам ECS-слой изActivity.Current, которуюставит
ActivityTracingConsumeFilter. Дублировать значит развести поиск по двум полям.Лимит —
protected virtual int MessageDataLimit, по умолчанию 4000 байт UTF-8, переопределяетсянаследником. Байты, а не символы: ограничение на длину терма в хранилище логов считается в байтах.
Как считается тело (
MessageDataFormatter):сверху, и сериализация в строку на большом сообщении сама даёт
OutOfMemoryв обработчике ошибки;<not serialized: ИмяИсключения>— своё исключение подменило быисходное, ради которого запись и пишется;
\uXXXX: иначе запись нечитаема, и на символ уходит втрое большеотведённого объёма. Экранирование кавычек и управляющих символов сохранено, JSON разбираемый.
Ключ идемпотентности.
IdempotentConsumerпереопределяетLogErrorи добавляетIdempotentKeyобластью логирования, не дублируя шаблон базового класса. Раньше ключ попадал в лог только как поле
развёрнутого тела, то есть после ограничения тела пропал бы. Вычисление ключа обёрнуто в перехват:
GetIdempotentKeyбросает, если у сообщения нет ниIIdempotentKey, ниMessageId.LogErrorсталvirtual, форма записи вынесена в расширение надILogger. Консьюмер на несколькотипов сообщений не может наследовать
BaseConsumer<TMessage>(одноаргументныйIdempotentConsumer<TDbContext>— ровно этот случай), а запись ему нужна такая же: он зовётlogger.LogConsumeError(context, e)из своегоcatch.Замеры
Release, прогрев,
GC.GetAllocatedBytesForCurrentThread, лимит 4000 байт. Сообщение — массив записейсправочника (
Id,Name,MunicipalityId).Остаточные 45 мкс — буфер
Utf8JsonWriter(16 КБ): в поток он флашится не сразу, поэтому до остановкисериализатор проходит первые ~16 КБ тела, а не ровно лимит.
Тесты
Два новых тест-проекта, у обоих пакетов тестов не было.
Dex.MassTransit/Tests/Dex.MassTransit.Rabbit.TestsDex.Cap/Tests/Dex.Cap.OnceExecutor.MassTransit.TestНаборы проверены мутациями:
.Take().Concat())RetryAttemptубран из шаблонаcatchвокруг сериализации снятFullName→Nameв расширенииBaseConsumerне передаёт свой лимитLogErrorвIdempotentConsumerубранtry/catchвокруг вычисления ключа снятПрогон:
dotnet test src/Dex.MassTransit/Dex.MassTransit.slnиdotnet test src/Dex.Cap/Tests/Dex.Cap.OnceExecutor.MassTransit.Test/Dex.Cap.OnceExecutor.MassTransit.Test.csproj.Влияние на потребителей
Поведение меняется у всех наследников
BaseConsumer, включаяIdempotentConsumerиTransactionalConsumerизDex.Cap.Тело перестаёт быть набором полей и становится одной строкой. Выборки и дашборды, построенные на
подполях развёрнутого тела, на новых записях искать перестанут — это надо сказать поддержке до выката.
Кому нужен прежний объём, поднимает
MessageDataLimitу своего консьюмера.Публичный API вырос на
ConsumerLoggerExtensions(LogConsumeError,DefaultMessageDataLimit) иBaseConsumer.MessageDataLimit;LogErrorизprotected voidсталprotected virtual void.InternalsVisibleTo("Dex.MassTransit.Rabbit.Tests")вDex.MassTransit.Rabbit.Вне этого PR
src/Directory.Build.targets(коммиты
7f79a3e7,6594fe32,0202648e).Activity. Пока это так, брокерныеидентификаторы в записи и нужны.
GorodPay.Backend, здесь их нет.