Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера. API.. API. highload.. API. highload. telegram.. API. highload. telegram. Мессенджеры.. API. highload. telegram. Мессенджеры. Проектирование API.. API. highload. telegram. Мессенджеры. Проектирование API. проектирование систем.

В прошлой статье я притащил на Хабр Plumb — свой самописный мессенджер, цифровой бункер, гаражную игрушку и личный способ не зависеть от чужой кнопки «сегодня мы вас немножко ограничим».

Я тогда честно сказал: это не убийца Telegram, не pitch deck для инвестора и не стартап с парнишей в худи, который произносит слово «экосистема» так важно, будто сейчас вызовет дождь.

Это моя штука.

Мой велосипед.

Мой бункер.

Мой маленький Франкенштейн, который сначала лежал на столе, потом резко подпрыгнул, потом сел, посмотрел на меня и как будто сказал: «Ну что, папаша, теперь у нас real-time».

Хабр отреагировал как Хабр.

Кто-то пришел смотреть скриншоты. Кто-то начал вспоминать ICQ и красивый UIN как первую любовь с модемным звуком. Кто-то сразу достал nmap, тяжелый взгляд и внутреннего сеньора, который уже не верит словам «вот новый чатик».

И вот ради этого внутреннего сеньора я сегодня открываю вторую дверцу.

Поговорим про бэкенд.

Если вы уже тыкали корневой домен API, то могли увидеть там не Hello, world, а привет из машинного подвала:

{ "message": "SCAN_PROCESS... TITANIUM IS WATCHING" }

Да, это не ошибка. Это я так здороваюсь.

Plumb — это то, что видит пользователь: чаты, UIN, кнопки, сообщения, вся социальная оболочка, где человек думает «о, прикольно, можно написать другу».

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 1

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 2

Если совсем коротко: Titanium — это кодовое имя серверной части и моя внутренняя инженерная доктрина для Plumb. Не аналог MTProto, не «сейчас я придумал протокол лучше Telegram, держите мой белый плащ». Скорее, это набор правил: где живет истина, как двигать состояние, что делать с дырками в сети, как не верить клиенту, как не раздавать медиа вечными ссылками и как не превратить мессенджер в суп из случайных событий.

У больших систем такие штуки имеют имя и характер. У Telegram есть MTProto. У WhatsApp — своя взрослая история доставки, устройств и E2E. У Discord — real-time, voice и CDN-зверинец. У Netflix — дисциплина доставки контента, где никто не раздает movie.mp4 как листовку у метро.

У меня, без дата-центров и армии инженеров с бейджиками, есть Titanium.

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

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 3

Присаживайтесь поудобнее. Началось все не с Titanium

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

Я человек мирный. Первые пять минут.

Поэтому сначала я сделал нормальную взрослую вещь: пошел смотреть готовые self-hosted решения.

Список был классический:

  • XMPP / ejabberd;

  • Matrix / Synapse;

  • Nextcloud;

  • Mattermost;

  • Rocket.Chat;

  • еще пачка решений, которые на скриншотах выглядят как «ну почти».

И вот тут началась первая маленькая драма.

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 4

Я не говорю, что эти проекты плохие. У многих из них огромная история, сильные команды, комьюнити и реальные продакшн-инсталляции.

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

Тогда родилась первая наивная мысль: ладно, сервер возьму готовый, а клиент напишу свой. Красивый. Быстрый. Без ощущения, что кнопки верстали по мотивам древней фрески.

На этом этапе в финалисты вышли Matrix и XMPP.

Matrix интересный, мощный, современный по амбициям и довольно серьезный по идеологии. Но у него есть плотная интеграция с E2E, куча слоев, исторический багаж, клиент-серверная модель, federation, спецификации, легаси и вся эта взрослая мебель, которую нельзя просто обойти с фразой «да я тут чуть-чуть допишу».

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

Плюс флагманская реализация Synapse написана на Python. Без обид питонистам: Python прекрасен, но если ты строишь real-time-систему и начинаешь думать про ресурсы, соединения, задержки и сервер, который не должен просить новый VPS после каждого чиха, то где-то внутри просыпается маленький бухгалтер и начинает кашлять.

XMPP выглядел старше, скучнее, но понятнее.

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

Я решил: окей, пишу кастомный клиент поверх XMPP.

План был смешной своей невинностью:

  • сделать симпатичный интерфейс;

  • добавить OTP-аутентификацию;

  • прикрутить реакции на сообщения;

  • нормально оформить файлы;

  • чуть причесать UX;

  • жить счастливо.

На бумаге выглядело как проект на пару вечеров и один приступ уверенности.

Я начал писать модули для ejabberd на Erlang.

И вот здесь XMPP открыл дверь в подвал.

Сначала все даже шло бодро. Erlang приятен, когда начинаешь понимать, почему его вообще придумали. ejabberd структурирован лучше, чем многие современные «микросервисные платформы», где из десяти сервисов семь существуют потому, что кто-то хотел попробовать новый фреймворк.

Но потом полезла реальность.

Файлы? Нормальная отправка медиа не такая красивая, как хочется в мессенджере 2026 года.

MUC-комнаты? Групповые чаты в XMPP умеют быть очень шумными. С ростом участников сетевая и процессорная нагрузка начинают вести себя не как аккуратная линейная функция, а как студент на первой зарплате: весело, быстро и без уважения к бюджету. Не просто так MUC-комнаты обычно держат небольшими.

XML-станзы? Да, стандарт красиво звучит. Но парсинг строк в XML — это дополнительный налог на CPU. Налог маленький, пока у тебя игрушка. Налог неприятный, когда ты начинаешь думать про нагрузку. XML вообще похож на человека, который пришел на вечеринку в костюме из 2000-х и громко объясняет, что зато все стандартизировано.

XEP-расширения? На бумаге они выглядят как цивилизация: вот спецификация, вот стандарт, вот как реализовать реакции, файлы, карбоны, архивы, уведомления, вот это все. В реальности часть XEP живет как археология: где-то реализовано не полностью, где-то работает только в одном клиенте/библиотеке, где-то красиво написано в документе, но open-source-реализацию как будто унесло ветром.

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

Чтобы менять бизнес-логику глубоко и уверенно, нужно быть прожженным Erlang-овцем и хорошо понимать внутренности ejabberd. Если ты работаешь только на уровне модулей и пытаешься поверх старого ядра построить современную продуктовую логику, ejabberd быстро превращается в почти идеального поставщика race conditions. Такой заботливый. Сам приносит. Иногда даже с доставкой.

Я тогда еще думал: ладно, может, не надо ломать все сразу. Сделаем гибрид.

Пусть XMPP/ejabberd отвечает за транспорт сообщений. А продвинутую бизнес-логику — API, OTP, файлы, реакции, REST, служебные сценарии — вынесем в что-то более гибкое. Например, FastAPI.

Свяжем мир ejabberd и мир FastAPI через event-sourcing, брокер или что-то, что оба мира смогут понять без дипломатического скандала.

И сначала оно даже поехало.

Прототип бодро передавал сообщения. OTP-аутентификация появилась. Файлы уже можно было отправлять. Клиент начал выглядеть не как наказание для пользователя. Внутренний оптимист снова достал флажок и начал махать.

А потом гибрид показал зубы.

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

Race conditions стали хитрее. Split-brain в базе начал выглядывать из-за угла с лицом человека, который знает, где ты живешь. Каждое событие требовало ответа на вопрос: кто здесь источник истины? ejabberd? FastAPI? База? Очередь? Клиент, который очень убедительно моргает спиннером?

FastAPI приходилось заставлять понимать XMPP-станзы. Это означало парсинг, преобразования, слабую типизацию на границе, кучу промежуточных DTO и нагрузку на тот самый Python-путь с GIL, которому и без этого было не скучно.

Групповые чаты это не решало. Полноценной замены MUC в такой схеме не получалось: старое ядро все равно диктовало часть правил, а новая бизнес-логика пыталась сбоку изображать современность.

И вот в какой-то момент я поймал себя на мысли: я уже не строю мессенджер. Я занимаюсь семейной терапией между ejabberd и FastAPI.

Один говорит на XML.

Второй говорит на JSON.

Где-то между ними сидит брокер, который делает вид, что у него все под контролем.

А пользователь просто хочет отправить картинку и получить две галочки.

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 5

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

Вот там, собственно, и начался путь Titanium.

Первая собственная версия: FastAPI, надежда и первые 500 RPS

Первая уже по-настоящему собственная версия Titanium была на Python/FastAPI.

И это было правильно.

FastAPI — отличный способ быстро оживить идею. Поднял ручки, описал модели, прикрутил авторизацию, накинул WebSocket, клиент зашевелился. Для прототипа — подарок. Почти как скотч, только со встроенным OpenAPI.

Пока у тебя три пользователя, один чат и «мама, смотри, сообщение пришло», все выглядит солидно. Ты такой сидишь, пьешь кофе, думаешь: «А может, я недооцененный архитектор?»

Потом начинается нагрузка. Например, следующий сценарий:

scenario: "evening chaos"
users: 1_000
active_sessions: 2_700
messages_per_sec: 120
media_uploads_per_min: 40
disconnect_rate: 7%
duplicate_send_rate: 3%
packet_reorder: enabled
mobile_sleep_wakeup: enabled
bot_webhook_5xx_rate: 2%

И система такая: «Архитектор, говоришь? Ну держи…».

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

Когда я говорю RPS в этом месте, я не имею в виду «сколько раз можно дернуть ручку /health и получить бодрое 200 OK». С таким спортом можно и на обычном API, без особо продвинутого кеша, выжать пару тысяч запросов в секунду. А если прикрутить нормальный кеш, очереди, правильно разложить воркеры, взять условный gunicorn/uvicorn в удачной оптимизированной конфигурации, обрезать лишнее, прогреть пулы и поставить рядом человека с бубном, цифры станут еще веселее. Бенчмарки вообще любят, когда им не мешает реальная жизнь.

У меня под этим числом была другая, более противная единица измерения: отправленное сообщение должно пройти весь бизнес-путь от одного клиента до другого по real-time-каналу, с подтверждением доставки/прочтения и синхронизацией состояния.

То есть не просто «записали строку в таблицу и хлопнули дверью», а:

  • проверить, имеет ли отправитель право писать в этот чат;

  • понять тип сообщения, вложения, reply, forward, служебные флаги;

  • выдать message_id, pts, не соврать в порядке событий;

  • обновить участникам счетчики, unread, last message, read state;

  • положить событие в журнал синхронизации;

  • раздать апдейт живым сессиям;

  • не потерять тех, кто спит в метро, в VPN, за NAT или просто решил быть мобильным приложением;

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

Это уже скорее не RPS из бенчмарков, а маленькая проверка на честность. Такой тест не спрашивает: «Сколько ты умеешь отвечать?» Он спрашивает: «Сколько раз подряд ты можешь не наврать?»

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

Потом я вычистил очевидную глупость, подкрутил пулы, убрал лишние походы в базу, чуть аккуратнее разложил фоновые задачи, перестал делать сериализацию там, где можно было не делать сериализацию. В каких-то прогонах получалось увидеть 600-700 RPS на этом сценарии. Но среднее честное ощущение для той версии было около 400-500 RPS: не рекорд для футболки, а рабочая цифра.

И вот на этих примерно 500 RPS стало ясно: дальше можно еще поджимать, оптимизировать транзакции, обмазываться очередями, подключить Kafka на каждый чих и сделать вид, что это все еще план. Но по факту я уже не оптимизировал архитектуру, а уговаривал табуретку стать мостом.

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 6

Не потому что Python плохой.

Повторю для свидетелей холиваров: не потому что Python плохой.

А потому что прототипная архитектура начала цементировать неправильные привычки:

  • HTTP-мышление пыталось обслуживать real-time;

  • клиенту местами верили больше, чем положено;

  • доставка сообщения была размазана между API, WebSocket и базой;

  • медиа жили по принципу «загрузили файл, потом придумаем правила»;

  • фоновые задачи выглядели как обслуживающий персонал, а на деле держали несущую стену;

  • ошибки сети воспринимались как исключения, хотя это просто нормальная погода.

В этот момент проект сказал мне человеческим голосом: «Хватит играть в чатик. Давай уже как взрослые». И примерно в этом момент я понял что тут одного Python/FastAPI будет явно маловато.

Лаборатория, где happy path приходит только как подозреваемый

Обычный тест мессенджера:

  1. Отправил сообщение.

  2. Получил сообщение.

  3. Зеленая галочка.

  4. Все улыбаются.

  5. PM получает бонус.

Это не тест. Это просто открытка.

Настоящий мессенджер начинается, когда ты включаешь режим «жизнь решила помочь»:

case 01: клиент отправил сообщение, но не получил ack
case 02: клиент повторил отправку тем же client_msg_id
case 03: сервер сохранил сообщение, но WebSocket умер до fanout
case 04: десктоп получил pts=845, телефон еще на pts=841
case 05: клиент проснулся после 40 минут сна
case 06: два устройства одновременно прочитали один чат
case 07: медиа загрузилось в S3, но finalize не пришел
case 08: finalize пришел дважды, потому что клиент испугался
case 09: webhook бота вернул 503 и сделал вид, что он занят
case 10: typing улетел, но сообщение не улетело
case 11: пользователь вышел из группы в момент доставки
case 12: звонок начался, но один участник умер за NAT
case 13: бот шлет слишком быстро и ловит 429
case 14: старая вкладка пытается откатить read state назад
case 15: пользователь нажал кнопку три раза, потому что «а вдруг не отправилось»

Вот это уже похоже на настоящий сервис.

Я реально и намеренно прогоняю систему через мерзкие сценарии: дубли, дырки, реконнекты, отложенные ack, повторные finalize, зависшие загрузки, старые вкладки, конкурирующие устройства. Это не QA, это маленькая школа боевых искусств для бэкенда. Только вместо татами — логи, а вместо тренера — мобильная сеть в лифте.

Мессенджер, который работает только на хорошей сети, — это не мессенджер. Это демо для конференции, где Wi-Fi почему-то порой тоже не работает.

Большие дяди как учебник, а не иконостас

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 7

Telegram — учебник по ощущению синхронизации. Пользователь не думает про pts, difference, порядок апдейтов и восстановление дыр. Он просто открывает телефон, десктоп и веб, а реальность везде одна. Магия? Нет. Бухгалтерия, просто очень хорошо одетая.

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

Discord — урок про удобство и цену удобства. Файлы, CDN, шаринг, голос, сообщества. Удобно до неприличия. Но когда ты думаешь про приватное медиа в личном мессенджере, возникает вопрос: хочу ли я, чтобы ссылка на файл жила слишком долго и чувствовала себя слишком уверенно?

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

Netflix и Spotify — урок про доставку контента как дисциплину. Токены, CDN, кеши, деградация, контроль доступа. У меня не стриминговая империя, но привычка не раздавать контент как бесхозный media.mp4 очень полезна.

OnlyFans и Patreon — да, сейчас кто-то поперхнулся чаем. Но инженерно это хороший пример: если платформа живет на чувствительном приватном контенте, она архитектурно не может относиться к ссылкам как к бесплатным листовкам у метро. Доступ там должен быть временным, проверяемым, отзывным. Документы и личные фото в мессенджере заслуживают не меньшего уважения, чем платный пикантный JPEG. Вот такая внезапная мораль от бэкендера.

Я не делаю свой Discord, свой Telegram или Netflix для переписок.

Я делаю свой Plumb. А ядро зову Titanium.

Публично это мессенджер. Внутри — моя маленькая школа выживания на опыте больших систем.

Сначала схема. Потом кровь

Верхний уровень до неприличия простой:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 8

Или оно же, но в чуть менее пафосной иллюстрации:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 9

И оно же, но разрез более с точки зрения DevOps:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 10

Никакой наномагии (почти). Nginx принимает удар. API занимается бюрократией. Realtime gateway держит сессии. Postgres хранит факты. Redis/KV держит горячую эфемерщину. S3 держит медиа. SFU занимается звонками, потому что я еще не настолько поехал, чтобы писать свой WebRTC SFU между ужином и чисткой зубов.

Но горизонтальная схема — это вид сверху. Сверху даже бардак на столе выглядит как «рабочее пространство».

Настоящая польза в слоях ответственности:

┌────────────────────────────────────────────────────┐
│ Client contract                                    │
│ API schema, envelopes, compatibility, versioning   │
├────────────────────────────────────────────────────┤
│ Session layer                                      │
│ auth, devices, ws lifecycle, reconnects            │
├────────────────────────────────────────────────────┤
│ Domain layer                                       │
│ chats, messages, UIN, media, calls, permissions    │
├────────────────────────────────────────────────────┤
│ State sync layer                                   │
│ pts, updates_log, GetDifference, gap recovery      │
├────────────────────────────────────────────────────┤
│ Delivery layer                                     │
│ fanout, outbox, queues, retries, backpressure      │
├────────────────────────────────────────────────────┤
│ Storage layer                                      │
│ Postgres, indexes, partitions, S3, Redis/KV        │
├────────────────────────────────────────────────────┤
│ Ops layer                                          │
│ logs, metrics, migrations, deploys, темные ритуалы │
└────────────────────────────────────────────────────┘

Главная ошибка — смешать все это в один send_message(), который делает все.

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

Я через это проходил. Не советую. Метод-муниципалитет всегда заканчивается тем, что ты боишься менять одну строку, потому что где-то внизу может перестать работать аватарка.

Транспорт без священных войн

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

«Надо только gRPC».

«Нет, только WebSocket».

«Нет, нормальные мужики вообще на чистом TCP».

«А вот XMPP в 2004-м…»

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

В моем случае:

  • где нужен запрос-ответ — там RPC/HTTP;

  • где нужен стрим событий — там WebSocket;

  • где web диктует компромисс — там Proto3 JSON Mapping;

  • где хочется компактнее — там бинарный payload;

  • где нужны медиа-потоки — туда вообще не надо тащить обычную доставку сообщений.

Базовый сокетный конверт выглядит так:

syntax = "proto3";

message SocketPacket {
  string topic = 1;
  string event = 2;
  int64 seq = 3;
  int64 pts = 4;

  oneof payload {
    bytes raw_data = 10;
    string json_data = 11;
  }
}

Это не откровение с горы Синай. Это просто пакет, внутри которого летит полезная нагрузка.

И это хорошо.

Чем меньше театр вокруг транспорта, тем легче жить, когда начнутся reconnect’ы, retransmit’ы, backpressure и бессмертная пользовательская фраза: «А у меня в метро не доставилось».

Клиент у меня не царь. Царь у меня сервер

Клиент в real-time-системе всегда немного лжет.

Он показывает сообщение до подтверждения сервера. Он рисует «печатает…». Он оптимистично считает, что локальная база похожа на правду. Он хочет быть быстрым и приятным. Это нормально.

Но верить клиенту нельзя.

Клиент может:

  • отправить один и тот же запрос дважды;

  • потерять ack;

  • проснуться со старым токеном;

  • открыть две вкладки;

  • применить апдейты не в том порядке;

  • прислать chat_id, куда он уже не имеет права писать;

  • попытаться скачать медиа, которое видел вчера, но сегодня уже не должен видеть;

  • нажать кнопку пять раз, потому что «оно как-то не моргнуло».

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

pts: маленький счетчик, который держит лавку

В нормальном мессенджере нельзя жить так: «когда клиент переподключился, давайте просто отдадим ему последние 100 сообщений».

Это работает до первого пользователя, у которого телефон, десктоп, веб-вкладка, планшет, две сети, плохой Wi-Fi и привычка не закрывать приложения неделями, потому что «можно, а зачем?».

Мне нужен был способ синхронизации, где клиент не спрашивает «дай мне все», а говорит: «я видел реальность до версии N, что изменилось после этого?»

Вот тут появляется pts.

client state:
  user_id = 42
  pts = 841

server state:
  user_id = 42
  pts = 845

client asks:
  GetDifference(841)

server returns:
  842 UpdateNewMessage
  843 UpdateReadHistory
  844 UpdateChatPinned
  845 UpdateNewMessage

Да, пахнет Telegram. Не вижу причины стесняться. Если у больших дядь есть рабочая инженерная ДНК, ее надо изучать, а не воротить нос как студент после первой статьи про «чистую архитектуру».

И тут уже можно начинать спорить: pts per user или per chat?

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

pts отвечает на вопрос: «какую реальность видел пользователь?»

message_id/chat order отвечает на вопрос: «в каком порядке сообщения лежат внутри чата?»

Если смешать эти две вещи, потом будешь ночью сидеть над логами и объяснять себе, почему read state выглядит правдоподобно, но пользователь все равно видит дырку. Это такой особый вид медитации, только без пользы для нервной системы.

Снаружи difference выглядит скучно:

SELECT *
FROM updates_log
WHERE user_id = :user_id
  AND pts > :from_pts
ORDER BY pts ASC
LIMIT :limit;

Но это витрина.

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

Плохой путь:

insert message
commit
publish websocket event
somewhere increment pts
pray!

Это не архитектура. Это свечка в храме распределенных систем.

Нормальный путь скучнее и полезнее:

BEGIN;

SELECT pts
FROM user_states
WHERE user_id = :recipient_id
FOR UPDATE;

UPDATE user_states
SET pts = pts + 1
WHERE user_id = :recipient_id
RETURNING pts;

INSERT INTO messages (
  id,
  chat_id,
  sender_id,
  client_msg_id,
  content,
  inserted_at
) VALUES (
  :message_id,
  :chat_id,
  :sender_id,
  :client_msg_id,
  :content,
  now()
);

INSERT INTO updates_log (
  user_id,
  pts,
  update_type,
  payload
) VALUES (
  :recipient_id,
  :next_pts,
  'new_message',
  :payload
);

COMMIT;

Любители микросервисов спросят: а почему всё в одной транзакции? Отвечаю на схеме. Посмотрите на этот танец с блокировкой строк. Пока база не сказала COMMIT, ни один сокет не смеет пикнуть

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 11

Да, FOR UPDATE может стать местом боли.

Если один пользователь получает миллион событий в секунду, вы живете не в статье, а в отдельном диагнозе. Там придется думать про батчинг, per-chat state, шардирование update log, delivery cursors и более аккуратную модель contention. Но на раннем этапе важнее не потерять инвариант: сообщение и апдейт должны рождаться вместе.

После коммита можно будить WebSocket-курьеров, пуши, ботов и прочую живность.

Если WebSocket умер, не страшно. Курьер упал с велосипеда. Журнал апдейтов остался.

Клиент придет и скажет:

У меня pts=841. Что я пропустил?

Сервер достанет журнал и ответит.

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

Сообщение — это не одна галочка, а маленькая бюрократия

Обычный человек думает, что сообщение — это пузырь на экране.

Инженер знает, что сообщение — это пачка связанных последствий:

  • проверить сессию;

  • проверить права;

  • не принять дубль;

  • сохранить тело;

  • присвоить серверный message_id;

  • увеличить pts нужным участникам;

  • положить апдейты так, чтобы офлайн-клиент потом мог подняться из могилы и догнаться;

  • отдельно не сломать диалоги, unread’ы, read history и порядок событий;

  • запланировать push;

  • разбудить бота, если ему надо знать о сообщении;

  • не сжечь сервер, если клиент решил повторить запрос десять раз.

Снаружи это выглядит как «сообщение пришло».

Изнутри это выглядит как «я часами убеждал несколько состояний не врать друг другу».

Классика:

client -> sendMessage(client_msg_id=abc)
server -> saved
network -> killed ack
client -> retry sendMessage(client_msg_id=abc)

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

Нормальный же сервер скажет:

{
  "ok": true,
  "duplicate": true,
  "message_id": "297286200000000999"
}

А сделает примерно следующее:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 12

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

Gap — это не «ой», а штатный сценарий

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

На слайдах real-time почти всегда гладкий: клиент подключен, события идут по порядку, мир прекрасен, пользователь улыбается, PM получает бонус, CTO кивает, инвестор видит TAM.

В реальности у тебя:

  • сеть моргнула;

  • пакет пришел позже соседа;

  • устройство ушло в фон и вернулось из комы;

  • второе устройство пользователя успело прожить еще кусок жизни;

  • сервер уже на pts = 845, а клиент честно уверен, что сейчас максимум 841;

  • браузерная вкладка проснулась с выражением лица «а что я пропустила?».

Один из моих любимых лабораторных сценариев:

server sends:
  pts=842
  pts=843
  pts=844
  pts=845

client receives:
  pts=842
  pts=845

Наивный клиент применит 845 и поедет дальше с дырой в голове.

Нормальный клиент скажет:

expected=843
received=845
gap_detected=true
pause applying strict updates
call GetDifference(current_pts=842)

А процесс будет следующим:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 13

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

WARN  [sync] Gap detected: current=841 update=845 user=42 session=web-7fa
INFO  [sync] buffering update pts=845, expected=842
INFO  [sync] starting GetDifference user=42 from_pts=841 limit=100
INFO  [sync] applied pts=842 type=UpdateNewMessage
INFO  [sync] applied pts=843 type=UpdateReadHistory
INFO  [sync] applied pts=844 type=UpdateChatPinned
INFO  [sync] applied pts=845 type=UpdateNewMessage
INFO  [sync] completed user=42 new_pts=845 lag_ms=38

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

Durable и ephemeral: не надо хранить дым в сейфе

Не все события одинаково важны.

Сообщение важно.

Read state важен.

Изменение состава группы важно.

Удаление сообщения важно.

А вот typing... — это дым.

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

Нельзя проектировать систему так, будто каждое «Саша печатает…» — государственная тайна. Саша может и передумать. А вы уже транзакцию открыли, апдейт записали, клиентам разослали и сидите довольные, как будто спасли мир.

Поэтому:

strict / durable:
  - new_message
  - edit_message
  - delete_message
  - read_history
  - chat_member_changed
  - permissions_changed
  - pinned_changed

ephemeral / best effort:
  - typing
  - transient presence
  - call ringing hints
  - short UI hints

Важное идет через бухгалтерию. Дым летит быстрым каналом и имеет право исчезнуть.

Read state: две галочки, тысяча способов опозориться

Статусы прочтения выглядят как мелочь.

Пока у пользователя одно устройство.

Потом появляется телефон, десктоп и веб-вкладка, которая проснулась после сна:

phone:   read up to msg 100
desktop: read up to msg 96
web:     sends read up to msg 92 after wakeup

Если сервер тупо принимает последнее пришедшее значение, поздравляю: веб-вкладка из прошлого откатила прочтение назад. Это как если бы человек проснулся после Нового года и отменил вам на календарике что сейчас еще декабрь.

Нормальный read pointer должен двигаться монотонно:

UPDATE chat_members
SET last_read_message_id = GREATEST(last_read_message_id, :incoming_read_id)
WHERE chat_id = :chat_id
  AND user_id = :user_id;

И да, после этого нужно синхронизировать остальные устройства пользователя. Потому что если телефон прочитал чат, десктоп тоже желательно должен перестать показывать «+17 непрочитанных», иначе пользователь начинает подозревать, что внутри живет не архитектура, а бытовой полтергейст.

Вот такие штуки и отличают мессенджер от формы отправки комментариев.

Outbox: сообщение не должно зависеть от настроения воркера

Наивно хочется сделать так:

save message
publish event
send push
call bot webhook
return ok

Красиво. Прямо как план на понедельник. То есть развалится к обеду.

Push-сервис может деградировать. Webhook бота может вернуть 503. Realtime gateway может быть на рестарте. А сообщение уже сохранено.

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

pending -> processing -> sent
                    └── retry_after
                    └── dead_letter

Если webhook бота отвечает 503, его не надо долбить как дятел:

attempt 1 -> now
attempt 2 -> +10 sec
attempt 3 -> +60 sec
attempt 4 -> +300 sec

Система должна быть настойчивой, но не истеричной. Настойчивость — это retry с backoff. Истерика — это while true без сна и совести.

И да, тут тоже есть где спорить: outbox per message или per recipient? Для маленькой системы per message проще. Для большой доставки per recipient иногда честнее, потому что судьба апдейта у каждого получателя своя. Но если начинать с идеального варианта, можно умереть от архитектурной красоты еще до первого пользователя.

Медиа: Discord удобен, но я не хочу вечные ссылки

С файлами у многих удивительно наивные отношения.

Загрузили картинку. Получили URL. Кинули куда попало. Живет вечно. Отличный план, если ваша угроза — только отсутствие вкуса.

Я делаю иначе.

В Titanium сообщение хранит не URL, а media_id.

{
  "message_id": "297286200000000999",
  "chat_id": "297286200000000777",
  "content": {
    "type": "image",
    "media_id": "297286200000000555",
    "caption": "вот тут оно и сломалось"
  }
}

media_id — это не адрес файла. Это право спросить адрес.

Когда клиент хочет скачать файл, он идет не в S3 напрямую, а к серверу:

GET /media/297286200000000555/download

Сервер проверяет:

  • кто ты, воин;

  • есть ли сессия;

  • не удалено ли сообщение;

  • не отозван ли файл;

  • можно ли тебе сейчас дать доступ.

И только потом выдает короткоживущий presigned GET URL.

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 14

TTL здесь не должен быть вечностью под видом удобства. Для обычного скачивания мне ближе минуты, а не дни. Условные 2-5 минут для GET — нормально. Для медленного клиента можно перевыпустить ссылку. Если ссылка утекла, она должна быстро превратиться в тыкву, а не жить счастливую CDN-жизнь до пенсии.

Для больших файлов включается multipart:

requestUpload
  -> strategy: multipart
  -> parts[1..N].url

PUT part 1
PUT part 2
PUT part 3

finalizeUpload
  -> etags
  -> sha256
  -> size
  -> file_id

После порога в 10 MB включается multipart, части режутся по 5 MB, как у взрослых людей, а романтиков. Это не «мне так захотелось», а скучная суровая специфика S3-мира, которая внезапно спасает от самодеятельного ада.

Мертвые загрузки: кладбище, которое надо чистить

Пользователь начал грузить видео. Получил upload_url. Залил 80%. Ушел в метро. Приложение умерло. finalizeUpload не пришел.

Что лежит в S3?

Мусор.

Что лежит в базе?

В лучшем случае upload_pending.

Что будет, если это не чистить?

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

Поэтому у медиа должна быть своя жизнь:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 15

И свой уборщик:

SELECT id, object_key
FROM media_files
WHERE status = 'pending_upload'
  AND inserted_at < now() - interval '2 hours'
LIMIT 500;

Скучно? Да.

Нужно? Очень.

Все веселые системы умирают не от одного большого бага, а от тысячи маленьких «потом почистим».

Backpressure: право сервера сказать «остынь»

Когда пользователей мало, сервер добрый.

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

Например:

{
  "ok": false,
  "error": "too_many_requests",
  "retry_after": 3
}

Это не грубость. Это забота о выживании.

Если бот шлет 200 сообщений в секунду в группу, пользователь долбит sendChatAction каждую миллисекунду, клиент с плохой сетью накапливает WebSocket backlog, а медиа-загрузка ретраится без тормозов, сервер должен не улыбаться и страдать, а ставить границы.

В Bot API уже заложены понятные лимиты:

global bot limit: 30 req / sec
private chat:     1 req / sec
group/channel:    20 req / min
typing action:    1 req / 3 sec per (bot_id, chat_id)

Любая система без backpressure рано или поздно становится жертвой собственного оптимизма. А оптимизм в бэкенде — это прекрасно, пока он не начинает писать в базу.

Звонки: не надо заставлять Postgres быть видеочатом

Видеозвонки и голос — отдельная зверюга.

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

Поэтому звонки идут отдельным путем.

Вот как это выглядит, если раздвинуть шторы. Мой API выступает только в роли строгого консьержа. Он проверяет документы, выдает ключи (токены) и отправляет клиентов общаться на выделенный полигон (SFU). Никакой тяжелой медиа-даты через ядро.

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 16

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

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

  • пользователь принял звонок на телефоне, но десктоп еще звонит;

  • NAT решил, что он тут главный архитектор;

  • участник вышел, webhook пришел позже;

  • звонок завершился, а UI одного клиента еще живет прошлым;

  • входящий звонок в мобильном окружении — отдельный вид платформенного шаманизма.

Оно уже шевелится. А когда real-time медиа начинает шевелиться, инженер либо радуется, либо ищет валерьянку. Обычно одновременно.

Почему Go, Rust, Erlang/Elixir, а не «давайте все на одном»

После FastAPI-прототипа стало понятно: горячие части нужно выносить.

Не потому что модно.

А потому что разные участки системы пахнут разными инструментами.

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

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

Erlang/Elixir хороши там, где пахнет телекомом: много соединений, процессы, supervision, падение маленького кусочка вместо всего корабля, PubSub, каналы, воркеры. BEAM выглядит так, будто его придумали люди, которые заранее знали, что однажды я буду писать свой мессенджер и ругаться на reconnection.

Python остается хорош там, где нужна скорость разработки, админка, прототипы, glue logic, скрипты, внутренние инструменты.

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

Стек — это ящик инструментов. Если кто-то молится на молоток, это его личная духовная жизнь.

Почему я не взял «что-нибудь готовое и нормальное»

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

Matrix? Можно.

XMPP? Можно.

Готовый backend? Можно.

Только я строил это не как open-source-религию выходного дня и не как pitch deck для акселератора. Я строил это как собственный управляемый контур связи, где мне важнее контроль над логикой, чем чужое одобрение за «правильный выбор стека».

Когда система твоя, ты можешь:

  • менять протокол без совета старейшин;

  • перестраивать sync без миграции чужого кладбища легаси;

  • поджимать transport под свои реальные edge-cases, а не под рекламный буклет;

  • честно признавать косяки и чинить их без политбюро.

Это не значит, что всем надо так делать.

Это значит только одно: мне было нужно именно так.

Потому что могу.

Bot API: да, почти как у Telegram. Не удержался

Под капотом уже есть черновой Bot API v1.

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 17

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

Бот — это обычный пользователь с is_bot = true, токеном и очередью апдейтов.

Уже есть базовые ручки:

GET/POST /api/v1/bot<TOKEN>/getUpdates
POST     /api/v1/bot<TOKEN>/sendMessage
POST     /api/v1/bot<TOKEN>/sendPhoto
POST     /api/v1/bot<TOKEN>/sendChatAction
POST     /api/v1/bot<TOKEN>/requestUpload
POST     /api/v1/bot<TOKEN>/finalizeUpload
POST     /api/v1/bot<TOKEN>/setWebhook
POST     /api/v1/bot<TOKEN>/deleteWebhook

Простейший echo-бот через long polling выглядит без мистики:

offset = 0

while True:
    updates = get_updates(offset=offset, timeout=30)

    for update in updates:
        offset = max(offset, update["update_id"] + 1)

        msg = update.get("message")
        if not msg:
            continue

        chat_id = msg["chat"]["id"]
        text = msg.get("text", "")

        send_message(chat_id, f"echo: {text}")

Webhook тоже есть:

{
  "url": "https://your-domain.com/titanium/webhook",
  "secret_token": "my-secret"
}

И чтобы вам не накидали мусора в вебхук, я не верю голому интернету. Я подписываю каждое тело запроса секретом вашего бота. Выглядит эта криптографическая паранойя вот так:

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 18

Медиа у ботов идет тем же взрослым путем:

requestUpload -> PUT bytes to S3 -> finalizeUpload -> file_id -> sendPhoto

Примерно так:

{
  "chat_id": "297286200000000777",
  "photo": "297286200000000555",
  "caption": "Новая статья из RSS, принес бот"
}

Зачем это все?

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

Если хотите пощупать это руками, ключи для Bot API пока выдаю вручную через своего же BotFather. Пишите в личку или заходите в Plumb и пишите самому @BotFather:

/newbot
  -> имя
  -> username
  -> token

Токен выглядит как обычный боевой пропуск в маленькую империю:

<BOT_ID>:<SECRET>

После этого можно дергать:

curl -sS "https://<titanium-api-host>/api/v1/bot<TOKEN>/getUpdates?offset=0&limit=100&timeout=30"

или отправить первое сообщение:

curl -sS -X POST "https://<titanium-api-host>/api/v1/bot<TOKEN>/sendMessage" 
  -H "Content-Type: application/json" 
  -d '{"chat_id":"<USER_ID_OR_CHAT_ID>","text":"hello from the basement"}'

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

А если уроните bot flow каким-нибудь мерзким сценарием — отлично. Значит, вы пришли не просто посмотреть на витрину.

BotFather: своя маленькая империя требует дворецкого

Менеджмент ботов идет через BotFather flow.

Да, это узнаваемая механика.

/newbot
  -> name
  -> username
  -> token

/deletebot @username
  -> confirmation
  -> soft delete

/transfer @bot @new_owner
  -> confirmation
  -> ownership moved

Почему так? Потому что это удобный UX-паттерн. Пользователи и разработчики уже понимают, как это должно работать.

Я не собираюсь ради «уникальности» делать панель управления ботами в виде квеста на 12 экранов. Есть рабочий паттерн — берем, адаптируем, идем дальше.

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

Privacy mode для ботов: потому что ботам тоже нельзя верить

Боты прекрасны, пока не начинают читать все подряд.

В группах privacy mode по умолчанию включен: боту приходят команды и ответы на его сообщения, а не весь поток чата.

Иначе любой «погодный бот» внезапно превращается в маленький пылесос переписки. А потом мы все делаем удивленное лицо и пишем пост-мортем.

Правило простое: бот должен видеть только то, что ему нужно для работы.

Да, иногда это неудобно. Зато меньше шансов построить собственный мини-скандал на ровном месте.

Где у меня пока синяя изолента

Чтобы не было ощущения, что я тут выкатил идеальную крепость и теперь вещаю с башни: нет.

Местами это все еще лаборатория.

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

Plumb Messenger, или все-таки Titanium? Как я подсматривал у больших дядь и создавал бекэнд для самописного мессенджера - 19

И я это знаю.

Не лезьте туда голыми руками, убьет. Перепишу, когда начнет мешать скорости, надежности или моему сну. Техдолг сам по себе не преступление. Преступление — делать вид, что его нет, и продавать это как «зрелую архитектуру».

Что еще требует внимания:

  • чистка старых медиа и orphan objects;

  • более строгая модель удаления и отзыва доступа;

  • расширение Bot API без превращения его в свалку;

  • inline keyboard и команды для ботов, чтобы не начинались комментарии «это не Bot API, это открытка»;

  • нормальные лимиты на разные классы операций;

  • миграции без боли для старых клиентов;

  • более агрессивная симуляция плохой сети;

  • observability, чтобы по логам было видно не «что-то умерло», а кто кого ударил;

  • доведение звонков до состояния «можно дать людям, которые любят нажимать не туда, и не сидеть рядом с огнетушителем».

Вот это и есть честность, которая мне нравится: не «у нас все идеально», а «вот темная комната, вот табличка, вот план, вот каска».

Почему это не просто «еще один мессенджер»

Потому что для меня это не попытка победить Telegram.

У Telegram миллионы долларов, дата-центры, история, пользователи и тяжелая инженерная машина.

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

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

Я не хочу зависеть от того, что очередной привычный сервис внезапно:

  • ограничили;

  • заблокировали;

  • купили;

  • сломали;

  • испортили редизайном;

  • превратили в рекламный комбайн;

  • решили, что мой регион сегодня не в настроении.

Мне скучно быть арендатором чужой инфраструктуры.

Поэтому я строю свою.

Что я хочу от Хабра

Не аплодисментов.

Аплодисменты плохо профилируют систему.

Мне нужны люди, которые посмотрят и скажут:

  • здесь pts per user начнет спорить с порядком внутри чата;

  • тут FOR UPDATE станет узким местом;

  • тут read state откатится с третьего устройства;

  • здесь бот может устроить loop;

  • этот webhook retry будет слишком агрессивным;

  • тут presigned URL живет слишком долго или слишком мало;

  • здесь S3 garbage collection оставит мусор;

  • этот лимит даст ложные 429;

  • этот SFU-сценарий развалится за NAT;

  • здесь ты слишком рано полез в Rust;

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

Если хотите просто сказать «надо было взять Matrix» — тоже можно, Хабр большой, места хватит.

Но если хотите настоящей драки, берите сценарий, где:

два устройства
плохая сеть
повторная отправка
медиа upload без finalize
бот с webhook 503
прочтение с отставшей вкладки
звонок через NAT

И попробуйте объяснить, где Titanium начнет врать.

Я не обещаю, что он не хрустнет.

Я обещаю, что если хрустнет красиво, я скажу: «Красавчики, вот это уже похоже на тестирование», полезу в логи и буду чинить.

Потому что так и растят нормальные системы.

Не в презентациях.

Не в питч-деках.

Не в постах «мы использовали современные технологии».

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

Plumb — это морда.

Titanium — это кости.

А я пока рядом с отверткой, кофе и нездоровым интересом: кто из вас первым найдет, где они неправильно срослись?

Ссылки

Автор: igrym

Источник