Как функция сохранения удалённых сообщений едва не положила весь мессенджер

от автора

Технический детектив о последовательной очереди, бесконечной валидации и баге, который оказался сразу несколькими.

Первый симптом выглядел почти комично.

Пользователь один раз нажимал кнопку отправки — и в чате появлялось несколько одинаковых сообщений. Иногда два-три. Иногда уже такой залп, после которого проще было закрыть приложение и подождать, пока оно успокоится.

Почти одновременно начинал мерцать интерфейс. Контекстное меню над сообщением открывалось и закрывалось само. Голосовые уходили с огромной задержкой. При удалении клиент мог зависнуть ровно между первой кнопкой «Удалить» и вторым выбором — удалить у себя или у всех.

Через несколько минут телефон заметно нагревался.

Самое неприятное было не в количестве симптомов, а в их бессвязности. Отправка, меню, удаление, голосовые, нагрев — слишком много разных систем ломались одновременно. Такие баги заставляют подозревать всё подряд: жесты, сеть, базу данных, анимации, старую iOS и, ближе к утру, уже фазу Луны.

Но у нас было важное преимущество. На том же iPhone официальный Telegram работал нормально. Чистый Swiftgram, от которого произошёл наш форк, — тоже. На устройствах с более новой iOS проблема не проявлялась.

Значит, виновник находился где-то среди наших собственных изменений.

Как выяснилось позже, виновник был не один.

Невинная функция

В DarkGram есть локальное сохранение удалённых сообщений. Идея простая: если собеседник удаляет уже доставленное сообщение, клиент не выбрасывает локальную копию, а помечает её удалённой и продолжает показывать пользователю.

Первые варианты реализации были хрупкими. Сообщение удалялось из основного хранилища Telegram, после чего возвращалось в историю искусственно. Из-за этого мерцала лента, ломалась анимация удаления, терялось медиа, а иногда из нескольких сохранённых записей оставалась только последняя.

Поэтому архитектуру развернули в другую сторону:

  1. не удалять сообщение из Postbox — основного локального хранилища клиента;

  2. добавить к нему специальный атрибут;

  3. отрисовать его как удалённое, сохранив исходный текст и медиа;

  4. параллельно держать архивные данные для отдельного браузера истории.

С точки зрения интерфейса это было намного надёжнее. Сообщение оставалось там, где родилось, и не требовало сложной повторной инъекции в историю.

Но у решения появилась цена, которую поначалу никто не видел.

Функция сохранения удалённых сообщений оказалась встроена сразу в несколько горячих путей клиента. И часть работы выполнялась там, где любая задержка распространялась почти на всё приложение.

Место преступления: Postbox

Postbox в Telegram-iOS — не просто файл локальной базы. Через него проходит огромная часть состояния клиента: сообщения, история, представления чатов, транзакции, локальные изменения и часть жизненного цикла отправки.

Для этой истории важнее всего одно: значительная часть работы идёт через последовательную очередь.

Это удобно для консистентности. Две операции не начинают одновременно менять одни и те же данные, а порядок событий остаётся предсказуемым.

Но последовательная очередь похожа на однополосный тоннель. Пока внутри стоит грузовик, за ним ждут все — даже те, кому нужно быстро проехать налегке.

В нашем случае в пробке оказались:

  • открытие чата;

  • построение контекстного меню;

  • чтение локального состояния сообщения;

  • обработка изменений исходящей отправки;

  • обновление истории;

  • часть сценария удаления.

Поэтому внешне разные функции начали ломаться одновременно. Они не были связаны напрямую. Они просто стояли в одной очереди.

Оставалось понять, кто поставил грузовик поперёк тоннеля.

Первый подозреваемый: быстрый доступ

Самым заметным кандидатом был Quick Access — наш слой быстрых жестов и оверлеев.

Он устанавливал глобальный жест, слушал изменения настроек, создавал дополнительные окна для статусных элементов и мог обновлять интерфейс поверх основного клиента. На старой iOS такой набор выглядел достаточно подозрительно.

Мы собрали версию, в которой Quick Access полностью отключался ещё на этапе установки.

Цирк продолжился.

После дополнительного ограничения шумных observer-вызовов телефон стал вести себя чуть спокойнее, но сообщения всё ещё размножались, меню мерцало, а удаление зависало.

Самый зрелищный модуль оказался почти ни при чём.

Второй подозреваемый: SQLite

Следующая гипотеза выглядела гораздо убедительнее.

Помимо сохранения сообщения внутри Postbox, архив записывал снимки в SQLite. Вызов находился рядом с транзакцией обработки сообщений. Если запись на диск выполнялась синхронно, она могла удерживать последовательную очередь.

Картина складывалась почти идеально:

архив пишет SQLite        ↓очередь Postbox задерживается        ↓исходящее сообщение дольше остаётся pending        ↓интерфейс ждёт, телефон греется,растёт окно для повторной обработки

Запись в архив перенесли на отдельную последовательную очередь.

Устройство действительно стало холоднее.

Но основной баг остался.

Это был худший вид ложной победы: гипотеза оказалась не выдумкой, изменение реально помогло, однако корень проблемы находился глубже. Мы сняли один тяжёлый слой и на несколько часов получили ощущение, что почти добрались до причины.

На деле расследование только началось.

Баг №1: сообщение, которое невозможно провалидировать

Первый настоящий механизм обнаружился в HistoryViewStateValidation.

Сохранённое удалённое сообщение продолжало жить локально, но сервер уже не возвращал его как обычную актуальную запись. Для системы валидации оно выглядело устаревшим или неподтверждённым.

В штатном клиенте такая запись в конце концов была бы удалена или заменена серверными данными. Но наш защитный код специально не позволял удалить её: иначе функция сохранения потеряла бы смысл.

Получалась замкнутая система:

обновилось представление истории        ↓удалённое сообщение попало в validation batch        ↓клиент запросил состояние у сервера        ↓сервер не вернул удалённое сообщение        ↓наш guard сохранил локальную копию        ↓следующее обновление истории        ↓тот же validation batch

Сообщение не могло ни успешно провалидироваться, ни исчезнуть. Оно снова и снова запускало одну и ту же работу.

Исправление оказалось почти оскорбительно маленьким: если сообщение помечено как локально сохранённое удалённое, для этого механизма считать его валидным сразу.

По размеру diff — несколько строк.

По последствиям — остановка бесконечного цикла запросов, обновлений представления и транзакций.

Количество изменённого кода в очередной раз ничего не сказало о сложности бага.

Баг №2: архив, который снимал вообще всё

Следующая находка была менее тонкой, но гораздо более прожорливой.

Вызов darkgramArchiveMessage(state: .active) стоял после добавления каждого сообщения.

Не каждого удалённого сообщения.

Каждого.

Он срабатывал при:

  • получении нового сообщения;

  • собственной отправке;

  • загрузке истории;

  • синхронизации;

  • добавлении сообщений в любом чате.

Для каждой записи архив мог обходить медиаграф, подготавливать ссылки на ресурсы, запускать сохранение и создавать снимок состояния.

Во время аудита выяснилось, что снимки .active практически не имели потребителей. История редакций использовала .edited, браузер удалённых сообщений — .deleted, а рабочего сценария, оправдывающего поток активных снимков, не нашлось.

То есть клиент выполнял тяжёлую архивную работу на каждом сообщении ради данных, которые потом почти никто не читал.

Вызов удалили.

Иногда оптимизация — это не более быстрый алгоритм. Иногда лучший алгоритм — перестать делать работу, которая вообще не нужна.

Баг №3: слишком заботливое сохранение медиа

Сам SQLite к этому моменту уже работал в фоне. Но рядом оставалась ещё одна операция: preserveResources.

Она обходила медиа сообщения, собирала ссылки на ресурсы и могла запускать их загрузку. С точки зрения архива это логично: бесполезно сохранить запись об удалённой фотографии, если сам файл позднее исчезнет из локального кэша.

Проблема была во времени и месте.

Обход медиаграфа и eager fetch происходили рядом с критическим путём обработки сообщений. На аккаунте с тяжёлой историей или большим количеством медиа такая забота превращалась в серьёзную нагрузку.

Работу перенесли на отдельную serial resource queue.

Семантика сохранилась: ресурсы всё ещё можно было удерживать для архива. Но Postbox больше не обязан был ждать, пока архив разберётся со всеми фотографиями, видео и файлами.

Баг №4: полный скан истории ради одного системного события

Самая дорогая находка скрывалась в обработке .UpdateMinAvailableMessage.

Это обновление связано с продвижением минимально доступного сообщения — например, когда сервер обрезает старую часть истории канала.

Наша модификация пыталась быть максимально бережной. Перед штатным удалением старых сообщений она проходила по всей локальной истории пира, архивировала записи и сохраняла ресурсы.

Выглядело благородно: даже то, что сервер решил убрать, останется у пользователя.

Практически это означало полный withAllMessages(peerId:) внутри одной транзакции.

На крупном канале клиент мог сканировать огромный массив сообщений, создавать архивные записи и запускать сохранение ресурсов, пока остальное приложение ждало освобождения той же последовательной очереди.

Здесь пришлось принять неприятное, но взрослое решение: вернуть upstream-поведение.

Старая server-trimmed история больше не сохранялась этим способом. Зато клиент перестал замерзать ради редкого граничного сценария.

Это один из тех компромиссов, которые плохо выглядят в списке возможностей, но хорошо — в работающем продукте.

Полнота архива не имеет смысла, если из-за неё невозможно нормально отправить сообщение.

Четыре бага под одним костюмом

После разгрузки тестер повторил сценарии на проблемном устройстве.

Исчезли общие зависания, мерцание контекстного меню, пауза внутри удаления, сильный нагрев и ощущение, что любое действие клиента ждёт несколько секунд.

Пользователь видел один большой хаотичный баг. Внутри это были четыре независимых механизма, складывавшие нагрузку в одну очередь:

  1. бесконечная валидация сохранённого сообщения;

  2. архивирование каждого активного сообщения;

  3. тяжёлая работа с медиа в горячем пути;

  4. полный скан истории при редком серверном обновлении.

Поэтому каждое раннее исправление помогало — и ни одно не завершало расследование.

Последний фокус: редкие дубли остались

После основной операции клиент снова стал отзывчивым. Но иногда один тап всё ещё приводил к нескольким копиям сообщения.

Это была важная улика: тормоза и сетевые дубли оказались связаны, но не были одной и той же ошибкой.

Долгая блокировка Postbox растягивала время, в течение которого исходящее сообщение оставалось в промежуточном состоянии. Чем дольше живёт pending-операция, тем выше шанс пересечения с перезапуском приложения, восстановлением соединения или повторной обработкой очереди отправки.

У дублей, по данным расследования, совпадал random_id. Поэтому версия «мы просто добавили одно сообщение в очередь несколько раз» объясняла картину плохо.

Аудит указал на более узкую границу: сообщение могло уже получить признак серверного подтверждения, но после восстановления pending-очереди снова рассматриваться как кандидат на отправку.

Для проверки добавили экспериментальный guard: не повторять отправку обычного облачного сообщения, если его OutgoingMessageInfoAttribute уже содержит acknowledged == true.

И здесь мы намеренно не объявили победу.

Защита от дубля, способная хотя бы в одном редком сценарии потерять сообщение, опаснее самого дубля. Поэтому guard оставили выключенным по умолчанию и вынесли в отдельную настройку для проверки на проблемном устройстве.

Эта часть расследования осталась рабочей гипотезой, а не таким же хорошо подтверждённым корнем, как перегрузка Postbox. Честное «здесь мы ещё не уверены» полезнее красивой причинности, придуманной задним числом.

Почему всё проявлялось на iOS 16

Самый соблазнительный ответ — найти один старый API, одну системную регрессию или один if #available, объясняющий всё.

Мы такого ответа не нашли.

Подтверждено только наблюдение:

  • на iPhone 14 Pro Max с iOS 16.0.2 проблема воспроизводилась стабильно;

  • на устройствах с iOS 18 тот же форк работал нормально;

  • официальный Telegram и чистый Swiftgram на проблемном устройстве не ломались.

Значит, старая iOS не была первопричиной. Наш код создавал неправильную нагрузку сам, а конкретная среда делала её заметнее — вероятно, из-за отличий в таймингах или производительности отдельных путей.

Точный механизм различия расследование не установило.

Самая честная формулировка: iOS 16 стала усилителем архитектурной ошибки, но не её автором.

Три вывода, которые остались после расследования

«Работает в фоне» ничего не значит без названия очереди

SQLite можно перенести на background queue и всё равно оставить половину тяжёлой работы в критическом пути. Смотреть нужно не на имя функции, а на весь граф действий до первого настоящего асинхронного разрыва.

Два разумных защитных механизма могут собрать бесконечный цикл

Мы не позволяли удалить сохранённое сообщение — и были правы в рамках своей функции. Соседняя система считала, что невосстановимую запись нужно валидировать снова. Локально правильные решения образовали глобально бессмысленную петлю.

Редкий edge case не должен брать основной продукт в заложники

Мы отказались от сохранения server-trimmed истории через полный скан. Это ухудшило один редкий сценарий и улучшило каждое обычное действие пользователя.

Функции пришлось стать менее жадной

Сохранение удалённых сообщений в DarkGram после этого не исчезло. Мы не вырезали его и не заменили заглушкой. Удалённые сообщения по-прежнему остаются локально и отображаются внутри истории.

Но функции пришлось стать менее жадной.

Она перестала архивировать всё подряд, перестала удерживать критическую очередь ради медиа и отказалась от попытки спасти абсолютно каждую серверно обрезанную запись.

Пожалуй, это и было настоящим исправлением: не заставить тяжёлую функцию работать любой ценой, а провести границу, после которой её амбиции начинают ломать основной продукт.

Иногда лучший способ сохранить историю — перестать мешать мессенджеру жить в настоящем.

и вот вам вопрос: насколько далеко должна заходить локальная функция сохранения данных, если полная сохранность начинает конкурировать со стабильностью основного приложения?

Наши соц сети:

ссылка на оригинал статьи https://habr.com/ru/articles/1062948/