Диалоги — одна абстракция для P2P и групп

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

🔥 Инцидент: «Почему у Боба пять диалогов со мной?»

Фичу личных сообщений в v1 сделали «быстро»: диалог — это комната с двумя участниками, создаётся при первом открытии переписки. Через месяц аналитика показала: у активных пользователей до десятка «диалогов» с одним человеком. Причина — гонка: пользователь открыл переписку в двух вкладках, оба запроса «создай комнату, если нет» прошли проверку существования одновременно, и таблица получила две строки. А ещё в этих «диалогах» разные истории сообщений. В этом модуле делаем модель диалогов, в которой такая гонка невозможна по устройству базы.

📖 Теория

2.1 Одна таблица на два типа

Модель данных

Личный диалог и группа — разные продукты, но у них идентичны: лента сообщений, участники, read-состояние, доставка, списки и непрочитанные. Поэтому — одна сущность conversations с полем type ∈ {direct, group}:

sql
CREATE TABLE conversations (
    id         UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    type       TEXT NOT NULL CHECK (type IN ('direct', 'group')),
    title      TEXT NOT NULL DEFAULT '',       -- пусто у direct
    direct_key TEXT UNIQUE,                    -- только у direct
    created_by UUID NOT NULL REFERENCES users (id),
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (type <> 'direct' OR direct_key IS NOT NULL),
    CHECK (type <> 'group'  OR length(title) BETWEEN 1 AND 128)
);

Что покупаем: один код на ленту, один на доставку, один на непрочитанные. Что платим: nullable-поля и инварианты, которые защищают CHECK-и и код. Trade-off честный: две таблицы дублировали бы ~80% логики ради 20% различий.

2.2 Канонический ключ пары: гонка лечится базой

Для direct заводим direct_key — ключ пары пользователей, не зависящий от порядка:

go
// DirectKey: Алиса+Боб и Боб+Алиса → один и тот же ключ.
func DirectKey(a, b uuid.UUID) string {
	if a.String() > b.String() {
		a, b = b, a
	}
	return a.String() + ":" + b.String()
}

Создание — идемпотентным UPSERT'ом. Приём DO UPDATE с возвратом существующей строки — классика, которая часто удивляет:

sql
INSERT INTO conversations (type, direct_key, created_by)
VALUES ('direct', $1, $2)
ON CONFLICT (direct_key)
DO UPDATE SET type = 'direct'          -- no-op, чтобы RETURNING сработал
RETURNING id, created_at;

Оба конкурентных запроса вернут одну и ту же строку: один создал, второй получил её через конфликт. Никаких «пять диалогов с Бобом».

2.3 Миграция без остановки: expand → contract

Мы меняем rooms → conversations, но не удаляем старое в том же релизе. Правило продакшена: миграция сначала расширяет схему (новое рядом со старым), затем данные переносятся, и только отдельным релизом старое сжимается. Так старый образ, оставшийся на откате, продолжает работать.

sql
-- 000004_conversations.up.sql (expand)
CREATE TABLE conversations ( ... );
INSERT INTO conversations (id, type, title, created_by, created_at)
SELECT id, 'group', title, created_by, created_at FROM rooms;

CREATE TABLE conversation_participants (
    conversation_id UUID NOT NULL REFERENCES conversations (id) ON DELETE CASCADE,
    user_id         UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    role            TEXT NOT NULL DEFAULT 'member'
                    CHECK (role IN ('owner', 'admin', 'member')),
    last_read_seq   BIGINT NOT NULL DEFAULT 0,
    muted_until     TIMESTAMPTZ,
    joined_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (conversation_id, user_id)
);
INSERT INTO conversation_participants (conversation_id, user_id)
SELECT room_id, user_id FROM room_participants;

-- messages: добавляем новую колонку, старую (`room_id`) уберём в 000006
ALTER TABLE messages ADD COLUMN conversation_id UUID REFERENCES conversations (id);
UPDATE messages SET conversation_id = room_id;

Дробим на шаги осознанно: в этом модуле — создать новое и перенести, следующие модули пишут только в conversation_id, а уборку room_id сделаем в модуле про группы, когда новая модель уже обкатана.

Номера начинаются с 000004 не просто так: 000002 и 000003 в бойлерплейте уже заняты — там реакции и стикеры (см. §1.2), живой пример фичи поверх сообщений.

💻 Практика

  1. Напишите миграции 000004_conversations.{up,down}.sql по образцу выше и накатите: make migrate-up.

  2. Добавьте модели Conversation, ConversationType, ParticipantRole в internal/models. Инварианты — в конструкторы/валидацию юзкейса, а не только в БД: БД — последний рубеж, не первый.

  3. Реализуйте репозиторий Conversations:

    • GetOrCreateDirect(ctx, a, b uuid.UUID) — UPSERT по direct_key + вставка двух участников (ON CONFLICT DO NOTHING);
    • CreateGroup(ctx, title, creator) — группа + участник-владелец в одной транзакции.
  4. Юзкейс и хендлер POST /api/v1/conversations:

jsonc
// type=direct
{ "type": "direct", "peer_id": "uuid" }
// type=group
{ "type": "group", "title": "Команда", "member_ids": ["uuid", "uuid"] }
  1. Допишите пути в api/openapi.yaml и прогоните make validate-api — тест напомнит, если хендлер есть, а контракта нет (и наоборот).

  2. Тест на гонку — главный в модуле:

go
func TestGetOrCreateDirect_Concurrent(t *testing.T) {
	// 20 горутин создают диалог одной и той же пары;
	// все обязаны получить один id, в таблице — одна строка.
}

Чек-лист модуля: два одновременных запроса на диалог дают один conversation_id; группы и участники создаются транзакционно; validate-api зелёный; INSERT в conversation_participants не зависит от порядка аргументов (канонический ключ).

🧩 Quiz

1. Что делает ON CONFLICT (direct_key) DO UPDATE SET type = 'direct' в этом UPSERT'е?
2. Почему участник-владелец при создании группы добавляется в той же транзакции, что и группа?
3. Зачем direct_key, если можно проверить «есть ли диалог с этим пользователем» запросом?
4. Где должна стоять валидация «в группе от 1 до 128 символов в названии»?
5. Почему миграция «rooms → conversations» не удаляет старые колонки сразу?
6. У группы и личного диалога общая таблица. Что из перечисленного НЕ является ценой этого решения?

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

  • conversations — единая модель: direct и group с инвариантами в схеме.
  • Гонка дублей диалога закрыта каноническим direct_key + UPSERT — база, а не код.
  • Миграция показана в стиле expand→contract: старое живёт до отдельного релиза.
  • participants несут роль, read-позицию и mute — всё, что понадобится дальше.
  • make validate-api держит контракт синхронным после каждого изменения.

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

Диалоги есть, участники есть — но сообщения в них всё ещё упорядочены по created_at, и это мина: два сообщения в одну миллисекунду, пагинация на границе курсора теряет строки, а «последнее прочитанное» невыразимо одним временем. В следующем модуле вводим seq — строгий порядок внутри диалога, который затем станет фундаментом и синхронизации, и непрочитанных.