RPC-вызовы в MTProto

Повышенной безопасности

Александр Рулёв
Programming world
4 min readAug 30, 2013

--

Когда я столкнулся с реалиями андроида (отголоски о последствиях можно почитать в предыдущем посте), мне стало не по себе. Пользователь может в любой момент свернуть приложение. А оно в любой момент может быть терминировано. Я могу где-нибудь ошибиться и оно зависнет и пользователь его принудительно убъёт. Или оно само упадёт от чего-нибудь.

Причём упасть может в каком-нибудь другом потоке и обрушить все остальные.

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

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

Собственно, в этом посте я расскажу, куда я потратил уйму времени «впустую».

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

Мы имеем два хранилища, не связанных между собой:

  1. Первое — наше основное хранилище данных приложения, в котором хранятся сообщения, контакты, всякие настройки и прочее. Так же имеет таблицу «незаконченные действия», хранящую rpc_id (о нём далее), время создания, тип действия и rowid строки, ответственной за это действие.
  2. Второе — хранилище, имеющее счётчик, при получении значения которого следующий раз вернёт большее значение, а так же таблицу пар id-состояние.

Каждый RPC-вызов имеет четыре компоненты:

  1. rpc_id (значение, которое возвращает счётчик).
  2. dc_id (идентификатор датацентра, куда мы посылали запрос).
  3. msg_id (идентификатор сообщения, сгенерированное непосредственно перед отправкой и которое является частью протокола).
  4. статус (один из: создан, приготовлен, отправлен, доставлен, отвечен, обработан, провален).

Рассмотрим пример совершения запроса на отправку сообщения, хотя до туда я не дошёл и вероятно некоторые детали будут иными.

Пользователь ввёл сообщение и нажал кнопку отправить

  1. Получаем rpc_id.
  2. Сохраняем в хранилище данных это сообщение вместе с rpc_id, а так же добавляем в таблицу незаконченных действий этот rpc_id и rowid сообщения.

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

Инициируем совершение RPC-вызова

Будем звать того, кто ответственен за совершение RPC-вызова StelClient’ом.

  1. В таблице состояний RPC-вызовов создаём строку с состоянием «создан».
  2. Передаём данные для RPC-вызова нужному клиенту с нужным датацентром.
  3. Клиент сообщает сетевому бэкэнду, что готов отправлять сообщение.
  4. Сетевой бэкэнд уведомляет клиента, что можно писать в сокет и требует дать ему буффер.
  5. Клиент берёт все сообщения из очереди (которая могла образоваться, если было совершено несколько вызовов, а сетевой бэкэнд какое-то время нас не уведомлял), если нужно создаёт контейнер, сериализует сообщения, присваивает каждому из них msg_id. Уведомляет StelClient’а, что для какого-то сообщения (которое содержит RPC-вызов) был создан msg_id. Это изменяет состояние RPC-вызова в «приготовлен» и сохраняет dc_id и msg_id. А затем отправляет по сети весь этот пакет.
  6. Когда сетевой бэкэнд полностью записал буффер в сокет, он уведомляет об этом клиента. Клиент уведомляет об этом StelClient’а, что переводит состояние каждого RPC-вызова, который входил в контейнер в состояние «отправлен». Если же бэкэнд сообщает, что сообщение отправить не удалось из-за проблем с сетью — статус становится «провален» и приложение уведомляется об этом.
  7. Когда сервер присылает уведомление о доставке, состояние изменяется на «доставлен». Если сервер говорит, что нужно отправить это сообщение другому датацентру — начинаем с пункта #2.
  8. Когда приходит ответ на RPC-вызов, сначала состояние изменяется на «отвечен», но отчёт о доставке серверу не отправляется. Содержимое ответа передаётся приложению.

Рассмотрим ситуации, когда система падает на каком-либо из шагов:

  1. До того, как было установлено состояние «создан», либо «приготовлен». После рестарта приложение запросит статус RPC-вызова, а состояние его либо будет отсутствовать, либо мы даже не начинали его отправлять. Следовательно, вызов не был совершён, изменяем его состояние в «провален» и уведомляем приложение об этом. Что повторит отправку соощения, если нужно (например, прошло меньше 5 минут и нужно перепослать сообщение).
  2. До того, как вызов был доставлен. Это значит, что упало или до отправки, или во время отправки, или после отправки. Упасть могло в любой момент, но мы знаем msg_id сообщения. Поэтому запрашиваем сервер о судьбе сообщения. Если оно было доставлено — изменяем статус на актуальный и уведомляем приложение, что всё в порядке, ожидайте ответа.
  3. До того, как вызов был обработан. Состояние «обработан» устанавливается только после полной обработки ответа на RPC-вызов. Так же, уведомление о получении ответа отправляется только после того, как мы поменяли состояние на это. Поэтому если сервер всё ещё не перепослал нам RPC-ответ, узнаём статус сообщения/запрашиваем повторную доставку. В любом случае говорим приложению, что всё ок, ответ скоро будет.

Обработка ответа приложением

Приложение получает ответ. Транзакционно совершаются следующие действия:

  1. Сообщение помечается доставленным.
  2. Удаляется строка из таблицы незавершённых действий.

Затем приложение уведомляет StelClient’а о том, что ответ был обработан.

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

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

Вот и всё. Мне кажется, приложение может падать сколько хочет и где хочет, но это не может привести к неким неожиданностям.

Но приложения не должны падать. Это — лишь страховка, чтобы даже после падения все пассажиры остались живы и довольны.

Спасибо.

--

--

Александр Рулёв
Programming world

Самопровозглашённый самопровозгласитель