Присутствие и typing

Перед началом: доставка и sync (Модуль 6). Инструменты: Redis, Go, WebSocket. Трудоёмкость: ~5 ч.

🔥 Инцидент: «Призраки онлайн»

Первая версия presence хранила статус в таблице users: вошёл — online, вышел — offline. Через неделю аналитика: 40% пользователей «онлайн» круглосуточно. Причина банальна: закрытый ноутбук не отправляет «выход» — ни logout, ни крестик никто не жмёт. Вторая половина инцидента — typing: каждое нажатие клавиши писалось в messages-подобную таблицу, за месяц она набрала 90 миллионов «печатает» — и ни одной строки, которую стоило бы хранить. В этом модуле присутствие и typing делаем так, как положено: эфемерно, с TTL, вне основной БД.

📖 Теория

7.1 Presence — это TTL, а не статус

Присутствие — производное от «клиент сейчас держит соединение». Значит:

  • источник истины — соединения (у нас уже есть таблица userID → conns из Модуля 6);
  • Redis-ключ presence:{user_id} живёт с TTL (SETEX, 60 секунд) и обновляется самим фактом активности соединения: клиент шлёт ping, сервер отвечает — и заодно продлевает ключ;
  • смерть процесса/ноутбука = ключ истекает сам. Никаких «забыл выйти».
go
// heartbeat: раз в 20 секунд на активное соединение
redis.SetEx(ctx, "presence:"+userID, "1", 60*time.Second)
// при закрытии ПОСЛЕДНЕГО соединения пользователя
redis.Del(ctx, "presence:"+userID)
presenceChanged(userID, "offline", lastSeenAt)

Тонкость с несколькими вкладками: offline шлём только когда закрылось последнее соединение (счётчик из Модуля 6), иначе закрытие одной вкладки «гасит» пользователя, который тут же откроет другую.

7.2 Кто видит присутствие

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

  • direct-собеседники — видят всегда: это ожидание WhatsApp/Telegram;
  • группы — не показываем по умолчанию: N×M событий на группу — это и нагрузка, и слежка. Альтернатива (показывать) — обсуждаемая опция, не дефолт;
  • событие presence {user_id, status, last_seen} фан-аутится только в каналы direct-диалогов пользователя: кому надо — тем и уходит.

Статус при подключении: клиент получает срез (SELECT по собеседникам) при sync — чтобы не ждать события.

7.3 Typing — эфемерное событие с ограничителем

Typing — противоположность надёжности: потеря события — норма (клиент рисует индикатор 3 секунды и гасит). Поэтому:

  • не пишется в БД, не идёт через outbox — публикуется напрямую в канал;
  • rate-limit: не чаще одного события на диалог раз в ~2 секунды (клиент троттлит, сервер — проверяет; дешёвый счётчик в Redis или in-memory token bucket на соединение);
  • получатели: участники диалога, кроме отправителя.
jsonc
// клиент → сервер
{"type": "typing", "conversation_id": "…"}
// сервер → участникам (кроме автора)
{"type": "typing", "conversation_id": "…", "user_id": "…"}

7.4 Деградация без Redis

Что если Redis недоступен? Принцип: деградируют украшения, не ядро. Сообщения, sync, receipts живут в Postgres и не замечают Redis. Присутствие и typing — гаснут: ошибки Redis-вызовов логируются, но не всплывают пользователю; присутствие показывается как «неизвестно». Это тот самый случай, когда «фича не работает» лучше, чем «мессенджер не работает».

💻 Практика

  1. Подключите Redis-клиент (в compose он уже поднят; в конфиг — REDIS_URL, дефолт localhost:6380).

  2. Presence:

    • на каждый активный WS-коннект — heartbeat-продление ключа (совместите с ping-loop из Модуля 6: раз в 20 секунд);
    • при закрытии соединения: releaseConn уже знает, последнее ли это соединение — на последнем делайте DEL + presenceChanged;
    • событие presence фан-аут в direct-каналы пользователя;
    • в sync-ответ добавьте срез присутствия собеседников.
  3. Typing:

    • приём typing от клиента: проверка участия + rate-limit (не чаще 1 события / 2 с на диалог; превышение — молча дропаем);
    • публикация в канал диалога без записи в БД.
  4. Мини-клиент для проверки (до фронтенда Модуля 8):

    • откройте wscat или тестовую страничку от имени Алисы и Боба в одном прямом диалоге;
    • Боб «печатает» — Алиса видит typing в консоли;
    • Боб закрывает вкладку — Алиса получает presence offline.
  5. Тесты и сценарии:

    • две вкладки Боба: закрытие одной не шлёт offline, закрытие второй — шлёт;
    • убийство Redis в процессе: сообщения продолжают ходить, presence не сыплет ошибками наружу (в логах — warning);
    • рестарт сервера: через TTL presence самоочищается (нет вечных «онлайн»).

Чек-лист модуля: статус живёт TTL-ключом и обновляется heartbeat'ом; несколько вкладок не «гасят» пользователя; typing не попадает в БД и ограничен по частоте; Redis-падение не ломает сообщения.

🧩 Quiz

1. Почему presence в Postgres дал 40% вечных «онлайн»?
2. Пользователь закрыл одну из двух вкладок. Когда слать offline?
3. Typing не проходит через outbox. Почему?
4. Redis упал. Что должно произойти с мессенджером?
5. Кому фан-аутится presence-событие в нашей политике приватности?
6. Зачем серверный rate-limit на typing, если клиент сам троттлит?

✅ Итоги модуля

  • Presence — TTL-ключ Redis плюс heartbeat соединения: «вечных онлайн» не бывает по устройству.
  • Мультивкладочность учтена счётчиком соединений — offline только на последнем.
  • Приватность присутствия — явная политика: direct да, группы нет.
  • Typing — эфемерное событие мимо outbox, с throttle на сервере.
  • Деградация без Redis не трогает ядро: сообщения, история, receipts живы.

🔗 Мостик к Модулю 8

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