Порядок — seq, курсоры и пагинация

Перед началом: модель диалогов из Модуля 2. Инструменты: PostgreSQL (транзакции, оконные функции), Go. Трудоёмкость: ~6 ч.

🔥 Инцидент: «Сообщение из будущего»

Пользователь жалуется: на телефоне его сообщение стоит после ответа собеседника, на ноутбуке — перед. Начинаем разбираться: оба сообщения записаны в одну миллисекунду, ORDER BY created_at на разных репликах выбирает разный порядок среди «равных» строк. Второй звонок того же дня: при пролистывании истории вверх пропало сообщение — пагинация WHERE created_at > cursor потеряла строку ровно на границе страницы, потому что сравнение по времени не отличает «строго после» от «в ту же миллисекунду». Оба инцидента — об одном: время не является порядком.

📖 Теория

3.1 Почему created_at не годится

  • Дубли значений. У миллисекундного разрешения есть коллизии; порядок среди равных не детерминирован.
  • Курсоры. Пагинация «строго позже X» пропускает строки, «не раньше X» дублирует их.
  • Read-позиция. «Прочитано до времени T» невыразимо безопасно: непрочитанное = count(created_at > T) — это диапазонный скан вместо разности двух чисел.
  • Часы. В распределённой системе время записи зависит от того, чьи часы тикают; порядок должен жить в данных, а не в метаданных.

Решение: у каждого диалога — монотонный счётчик last_seq, а у сообщения — номер seq внутри диалога.

3.2 Выделение seq — в той же транзакции

sql
-- в одной транзакции с INSERT сообщения и INSERT события в outbox
UPDATE conversations
SET last_seq = last_seq + 1,
    last_message_at = now()
WHERE id = $1
RETURNING last_seq;

Почему UPDATE ... RETURNING, а не SELECT max(seq)+1:

  • max(seq)+1 при параллельных отправках даст одинаковые номера — классическая ошибка, которую ловит UNIQUE-индекс;
  • UPDATE берёт блокировку строки диалога: параллельные отправки в один диалог выстраиваются в очередь — а это и есть нужная сериализация;
  • отправки в разные диалоги не конкурируют вовсе (разные строки).

Инвариант закрепляем схемой:

sql
CREATE UNIQUE INDEX messages_conv_seq ON messages (conversation_id, seq);

3.3 Идемпотентная отправка: client_msg_id

Клиент ретраит отправку, когда ответ не пришёл (таймаут, обрыв). Чтобы ретрай не создал дубль, клиент генерирует client_msg_id (UUID на сообщение), а сервер дедуплицирует:

sql
CREATE UNIQUE INDEX messages_sender_client
    ON messages (sender_id, client_msg_id)
    WHERE client_msg_id IS NOT NULL;

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

go
// псевдокод SendMessage v2
tx := begin()
if existing := findByClientID(tx, senderID, clientMsgID); existing != nil {
    return existing, nil            // ретрай: сообщение уже есть, отвечаем 200
}
seq := allocateSeq(tx, conversationID)   // UPDATE ... RETURNING
insertMessage(tx, ..., seq, clientMsgID)
insertOutbox(tx, event(seq))
commit()

Гонка двух одновременных ретраев с одним client_msg_id ловится UNIQUE-индексом: проигравший получает unique violation, делает SELECT по паре и возвращает существующее сообщение.

3.4 Курсорная пагинация и дыры

История читается строго по seq, от новых к старым:

sql
SELECT id, seq, sender_id, text, created_at
FROM messages
WHERE conversation_id = $1
  AND ($2::bigint IS NULL OR seq < $2)
ORDER BY seq DESC
LIMIT $3;

seq детерминирован, поэтому страницы стыкуются без пропусков и дублей. Дыры в seq допустимы: уникальный индекс ловится, транзакция откатывается — номер «сгорает». Клиент обязан относиться к seq как к порядку, а не как к «количеству сообщений» (не рисовать «пропуск 41» по отсутствию 41-го).

3.5 Read-позиция

«Прочитано» — это число, а не флаг:

sql
UPDATE conversation_participants
SET last_read_seq = GREATEST(last_read_seq, $3)
WHERE conversation_id = $1 AND user_id = $2;

GREATEST защищает от гонки: рецепт от старого устройства с меньшим номером не откатит позицию назад. Непрочитанные считаются вычитанием: last_seq - last_read_seq — без сканов ленты.

💻 Практика

  1. Миграция 000005_messages_seq.{up,down}.sql:
sql
ALTER TABLE messages ADD COLUMN seq BIGINT;
ALTER TABLE messages ADD COLUMN client_msg_id TEXT;

UPDATE messages m SET seq = s.rn
FROM (SELECT id, row_number() OVER (
        PARTITION BY conversation_id ORDER BY created_at, id) AS rn
      FROM messages) s
WHERE m.id = s.id;

UPDATE conversations c SET last_seq = m.max_seq
FROM (SELECT conversation_id, max(seq) AS max_seq FROM messages
      GROUP BY conversation_id) m
WHERE c.id = m.conversation_id;

ALTER TABLE messages ALTER COLUMN seq SET NOT NULL;
CREATE UNIQUE INDEX messages_conv_seq ON messages (conversation_id, seq);
CREATE UNIQUE INDEX messages_sender_client
    ON messages (sender_id, client_msg_id) WHERE client_msg_id IS NOT NULL;
  1. Перепишите SendMessage по псевдокоду §3.3. Сигнатура:
go
SendMessage(ctx, conversationID, senderID uuid.UUID, text, clientMsgID string) (*models.Message, error)
  1. Обновите POST /conversations/{id}/messages (новый путь из Модуля 2): client_msg_id обязателен, повторный запрос с тем же id возвращает 200 с существующим сообщением, а не 409.

  2. История: GET /conversations/{id}/messages?before_seq=&limit= — по §3.4. Отдельно проверьте край: limit=50 на диалоге с 120 сообщениями — три страницы, ноль дублей и пропусков.

  3. POST /conversations/{id}/read с телом {"seq": 124} — по §3.5. Обновите путь в openapi и прогоните make validate-api.

  4. Тесты:

    • 50 параллельных отправок в один диалог → seq образуют 1..50 без дублей;
    • два запроса с одинаковым client_msg_id → одно сообщение, оба ответа содержат его id;
    • рецепт с меньшим seq после большего не откатывает last_read_seq.

Чек-лист модуля: порядок детерминирован и не зависит от часов; пагинация стыкуется без дыр; ретраи безопасны; непрочитанные считаются разностью чисел.

🧩 Quiz

1. Клиент получил сообщения с seq 40 и 43. Что это значит?
2. Почему SELECT max(seq) + 1 для выделения номера — ошибка?
3. Пагинация WHERE seq < $cursor ORDER BY seq DESC LIMIT 50. Почему не <=?
4. Зачем client_msg_id обязателен, хотя у сообщения уже есть серверный id?
5. Почему рецепт «прочитано» использует GREATEST, а не просто присваивание?
6. Что произойдёт, если транзакция с выделенным seq упадёт до коммита?

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

  • Время больше не определяет порядок: seq детерминирован и сериализован через UPDATE ... RETURNING.
  • Отправка идемпотентна: client_msg_id + UNIQUE + «поиск до выделения seq».
  • Пагинация по seq стыкуется без дублей и пропусков; дыры допустимы и клиент к ним устойчив.
  • Read-позиция — одно число на участника, защищённое GREATEST.
  • Непрочитанные — разность last_seq - last_read_seq, без сканов.

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

Фундамент залит: диалоги, порядок, идемпотентность. Теперь собираем пользовательский опыт P2P-переписки: список диалогов с последним сообщением и непрочитанными, старт переписки из контактов, формирование заголовка личного диалога из имени собеседника — так, чтобы «диалог» ощущался живым уже на пустом.