🔑 Идентичность, источники и дедупликация

Партии в аналитику пишут четыре независимых писателя: observer, sync (бэкфилл), beturanga-observer и dicechess-extension. Ни один из них не координируется с остальными в момент записи. И всё-таки на выходе мы хотим ровно одну строку на реальную партию и ровно одну строку на реального игрока — иначе вся статистика (win-rate, число партий, сила алгоритма) поедет.

Эта страница — про сквозной контракт, который заставляет четырёх писателей сойтись на одних и тех же идентичностях. Он держится на двух ключах и одном дисциплинирующем правиле.

Два ключа идентичности

  • games.id (UUID) — ключ идентичности партии. Одна реальная партия → один UUID, у кого бы её ни записали.
  • players.external_id — ключ идентичности игрока. Одна сущность-игрок → один external_id, и через него — одна строка в players.

Само правило — first-writer-wins: кто первым долетел с данным games.id, тот и определяет строку партии и идентичности её игроков; все последующие писатели по тому же id получают 200 и ничего не перезаписывают (см. 07 Контракт ingest и валидация движком).


1. Ключи идентичности в схеме

В аналитике (Postgres на aurora, 192.168.10.3) обе идентичности закреплены ограничениями целостности в V1__initial_schema.sql:

CREATE TABLE players (
    id            UUID PRIMARY KEY,
    external_id   VARCHAR(50) UNIQUE,     -- ключ идентичности игрока
    username      VARCHAR(50),
    player_type   VARCHAR(10) NOT NULL DEFAULT 'human',
    metadata_json JSONB,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE TABLE games (
    id                   UUID PRIMARY KEY,   -- ключ идентичности партии
    source               VARCHAR(20) NOT NULL,
    white_player_id      UUID REFERENCES players (id),
    black_player_id      UUID REFERENCES players (id),
    ...
);
  • players.external_idUNIQUE. Это и есть точка схождения: писатель присылает строковый external_id, а аналитика резолвит его в players.id (внутренний UUID) через upsertPlayer с ON CONFLICT (external_id). Внутренний players.id — случайный UUID, его никто снаружи не знает и не присылает; единственное, что писатель контролирует, — это external_id.
  • games.idPRIMARY KEY. Писатель сам генерирует UUID партии (нативный либо детерминированный, см. ниже) и присылает его; ON CONFLICT (id) DO NOTHING обеспечивает идемпотентность.

Таким образом вся согласованность сводится к одному вопросу: договорились ли все писатели, какой external_id и какой games.id соответствуют одной и той же реальной сущности? Договорились — это и есть конвенция ниже.


2. Конвенция external_id по всем писателям

external_id — гетерогенная строка: в одной и той же колонке VARCHAR(50) живут целые id с dicechess.com, hex-ObjectId с beturanga.com и синтетические bot:<algorithm>. Это нормально — они не пересекаются между собой по форме, поэтому коллизий нет. Вот полная таблица:

ПисательСущностьexternal_idsourceПример
observer / sync / extensionчеловек на dicechess.comString(userId) — положительное целоеdicechess.com"81542"
observer / sync / extensionсайт-бот dicechess.comString(userId)отрицательное целое (нативный id, знак = бот)dicechess.com"-42"
только extensionнаш движок-ботbot:<algorithm>dicechess.com"bot:aggressive"
beturanga-observerигрок beturanga.comсырой Mongo ObjectId (24 hex-символа)beturanga.com"6512a0f3…e91b"

И ключ партии:

Источникgames.id (UUID)Как получен
dicechess.comнативный UUID партиисайт уже отдаёт gameId как UUID → прокидываем напрямую
beturanga.comUUIDv5детерминированно из ObjectId (Mongo не отдаёт UUID)

dicechess.com — знак id несёт смысл

На dicechess.com у людей положительный userId, у сайт-резидентных ботов — отрицательный. Этот знак — и есть пометка «это бот»: нативный id уже канонический, перекодировать его в синтетический bot:* нельзя.

В observer (src/assemble.ts, makePlayer):

external_id: String(p.userId),
player_type: p.userId < 0 ? "bot" : "human",

В sync (src/pipeline.ts, buildPlayer) — та же логика, с фолбэком userId < 0 ? 'bot' : 'human', если фронтир ещё не классифицировал игрока.

beturanga.com — ObjectId напрямую, UUIDv5 для партии

beturanga — это Socket.IO-источник, а не REST (см. Beturanga — второй источник); идентичности приходят как Mongo ObjectId. Игрок кладётся напрямую (src/normalize.ts, toPlayerWire):

external_id: p._id,                               // 24 hex ObjectId
player_type: p.isBot ? "bot" : "human",

А вот ключ партии нужно синтезировать: у Mongo нет UUID. Берём детерминированный UUIDv5 в стандартном URL-namespace по канонической строке beturanga.com/game/<objectId> (src/uuid.ts):

const URL_NAMESPACE = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"; // RFC 4122 URL namespace
export function gameUuid(objectId: string): string {
  return uuidv5(`beturanga.com/game/${objectId}`);
}

Детерминизм здесь — не косметика, а условие идемпотентности: один и тот же ObjectId всегда даёт один и тот же games.id, поэтому повторный заброс той же партии корректно ловится first-writer-wins, а не плодит дубль.

Сайт-боты держат свой нативный отрицательный id; bot:<algorithm> зарезервирован под наш движок

На dicechess.com сайт-бот идентифицируется нативным отрицательным userId ("-42"), и так его пишут все три dicechess-писателя. Синтетическая схема bot:<algorithm> ("bot:aggressive") принадлежит исключительно нашему движку-боту и присваивается только расширением. Смешать эти две схемы — значит либо расщепить одного сайт-бота на несколько строк, либо склеить наш алгоритм с чужим ботом. Именно эту путаницу пришлось чинить миграцией V6 (см. §6).


3. source — откуда пришла партия

Колонка games.source (VARCHAR(20) NOT NULL) маркирует площадку:

  • "dicechess.com" — пишут observer, sync и extension;
  • "beturanga.com" — пишет beturanga-observer.

source относится к партии, а не к игроку — в players поля источника нет. Игрок узнаётся только по форме external_id (целое → dicechess, hex → beturanga, bot:… → наш движок).


4. player_type — липкая классификация

players.player_type (NOT NULL DEFAULT 'human') принимает значения, которые расставляют писатели:

  • bot — по знаку id (userId < 0) у dicechess, по флагу isBot у beturanga, и явно у нашего движка-бота.
  • guest — добавляет только sync (src/frontier.ts, classify): аккаунты с именем, начинающимся на Guest, — эфемерные, их не краулим как источники (паркуются в skipped).
  • human — значение по умолчанию для всех остальных.
function classify(userId: number, username: string | null | undefined): PlayerType {
  if (userId < 0) return 'bot';
  if (username && username.startsWith('Guest')) return 'guest';
  return 'human';
}

player_type фактически first-writer-wins

Апсерт в аналитике (IngestRepository.upsertPlayer) обновляет только username:

ON CONFLICT (external_id)
DO UPDATE SET username = COALESCE(EXCLUDED.username, players.username)

player_type ставится только при вставке (player_type берётся из присланного значения или 'human' по умолчанию) и больше не пересматривается. Значит, ошибочная классификация при первом ingest — липкая: если игрок впервые попал гостем или человеком, последующие забросы его уже не «дотипизируют». Это сознательный компромисс (upsert не должен затирать данные первого писателя), но о нём надо помнить при разборе аномалий.


5. Подмена игрока расширением — зачем оно вообще существует

Наблюдатели записывают площадку «как есть». Расширение делает нечто иное и в этом весь его смысл: оно подменяет нашу сторону — вместо хост-аккаунта (под которым физически залогинен браузер) записывает игрока как наш алгоритм bot:<algorithm>. Это нужно, чтобы измерять силу самого алгоритма: какой движок-бот сколько партий выиграл. Это знание невозможно восстановить из сырья сайта — для сайта это обычная партия обычного аккаунта; то, что за доской сидел наш aggressive, знает только расширение в момент игры (см. Расширение dicechess-extension).

В src/gameRecorder.js (_player) три ветки разрешения стороны в идентичность:

_player(id) {
  if (id == null) return null;
 
  // (1) наша сторона → алгоритм, а не хост-аккаунт
  if (id === this.myPlayerId && this.algorithm && !this.humanPlayed) {
    return { external_id: `bot:${this.algorithm}`, username: `${cap(this.algorithm)} Bot`,
             player_type: 'bot', rating: null };
  }
 
  // (2) сайт-бот (отрицательный id) → нативный id, как у observer
  if (typeof id === 'number' && id < 0) {
    return { external_id: String(id), username: SITE_BOT_NAMES[id] ?? `Site Bot ${id}`,
             player_type: 'bot', rating: null };
  }
 
  // (3) иначе — человеческий аккаунт
  const p = this.players[id] || {};
  return { external_id: String(id), username: p.username ?? null,
           player_type: p.type ?? 'human', rating: p.rating ?? null };
}

Три важных момента:

  1. Подмена выключается при перехвате человеком. Если человек сыграл за нашу сторону (бот был на паузе — например, появилась цветная/сборная фигура, ход за нашим цветом сделан вручную), connector.js вызывает recorder.markHumanPlayed(), флаг humanPlayed встаёт, и ветка (1) пропускается: партия атрибутируется хост-аккаунту, а не алгоритму. Иначе мы бы записали человеческий ход на счёт бота и испортили статистику силы.
  2. Сайт-боты — по нативному отрицательному id. Ветка (2) совпадает с observer/sync: external_id = String(id). SITE_BOT_NAMES (-40…-43 → DC Coach Rookie/Beginner/Amateur/Master) даёт только дружелюбное имя, но не меняет id.
  3. dicechess gameId уже UUID → расширение прокидывает его напрямую как games.id, и поэтому повторная отправка той же партии идемпотентна.

6. First-writer-wins: гонка observer ↔ extension

Один и тот же dicechess gameId могут запостить observer, sync и extension — это не теоретическая, а реальная ситуация: расширение играет партию, которую наблюдатель видит в общем потоке active-партий. Кто долетел первым, тот определил строку партии и идентичности игроков; повторный POST получает 200 и не перезаписывает (см. 07 Контракт ingest и валидация движком).

Главное следствие — гонка вокруг нашей стороны: observer запишет её как хост-аккаунт (String(userId)), а extension — как bot:<algorithm>. Кто первый, тот и зафиксировал, кем «был» наш игрок в этой партии.

sequenceDiagram
    autonumber
    participant Ext as extension
    participant Obs as observer
    participant API as analytics (POST /api/games)
    participant DB as Postgres (games / players)

    Note over Ext,Obs: одна реальная партия, один gameId (UUID)
    Ext->>API: POST gameId=G, наша сторона = bot:aggressive
    API->>DB: id=G отсутствует → INSERT
    DB-->>API: created
    API-->>Ext: 201 Created

    Obs->>API: POST gameId=G, наша сторона = String(userId)
    API->>DB: id=G уже есть → ON CONFLICT (id) DO NOTHING
    DB-->>API: no-op
    API-->>Obs: 200 OK (не перезаписано)

    Note over DB: партия осталась с bot:aggressive — победил первый писатель

Если бы первым успел observer, та же партия осталась бы за хост-аккаунтом, а заброс extension получил бы 200 и не смог бы переписать сторону на bot:aggressive. Это известный риск: измерение силы алгоритма по партиям, где наблюдатель опередил расширение, «теряет» подмену. Жёсткой синхронизации между писателями нет — мы сознательно платим этим за простоту и идемпотентность.

Идемпотентность ≠ согласие

First-writer-wins гарантирует, что дубля не будет, но не гарантирует, что победит «правильный» писатель. Для большинства партий (человек×человек, человек×сайт-бот) победитель неважен — идентичности у всех писателей совпадают. Расхождение возможно только на нашей стороне в партиях расширения.


7. Миграция V6 — дедуп сайт-ботов

V6__dedupe_site_bots.sql чинит исторический баг: более ранняя версия расширения писала четырёх сайт-ботов «DC Coach» под синтетическими id bot:dc-coach-* (и могла писать bot:site-<n> для прочих), хотя наблюдатель уже писал их по нативному отрицательному id. Результат — две строки players на одного бота: половина его партий висела на синтетической строке, половина на нативной. Статистика бота расщеплялась.

V6 сливает каждую синтетическую строку в её нативного «близнеца»:

Синтетический external_idНативный id
bot:dc-coach-rookie-40
bot:dc-coach-beginner-41
bot:dc-coach-amateur-42
bot:dc-coach-master-43
bot:site-<n>-<n>

Алгоритм миграции (идемпотентный — повторный прогон no-op):

flowchart TD
    A["строка players с<br/>external_id LIKE 'bot:dc-coach-%'<br/>или 'bot:site-%'"] --> B{"маппится в<br/>нативный id?"}
    B -->|"нет (неизвестный вариант)"| S["RAISE NOTICE,<br/>пропустить — не трогать"]
    B -->|"да → native_id"| C{"есть строка-близнец<br/>с external_id = native_id?"}
    C -->|"нет"| D["UPDATE players SET external_id = native_id<br/>(просто переименовать)"]
    C -->|"да (survivor)"| E["UPDATE games: white/black_player_id → survivor<br/>DELETE дубль из players"]

Ключевые гарантии миграции:

  • Точный матч. Маппинг идёт по строгому списку (bot:dc-coach-rookie и т.п.) плюс регэксп ^bot:site-[0-9]+$. Что не распозналось — резолвится в NULL и пропускается (RAISE NOTICE … skipping unmapped), чтобы будущий неизвестный вариант никогда не нарезался в битый id.
  • Перецепка партий. Перед удалением дубля все ссылки games.white_player_id / games.black_player_id переводятся на выжившую строку — ни одна партия не теряет игрока.
  • Наш движок-бот не трогается. bot:<algorithm> (bot:aggressive, bot:montecarlo и т.д.) — это настоящий отдельный игрок, а не сайт-бот, и V6 его сознательно оставляет как есть.

И починка на стороне писателя: текущее расширение уже ключует сайт-ботов по нативному отрицательному id (см. SITE_BOT_NAMES и ветку (2) в §5), так что V6 — разовая зачистка наследия, а не постоянный костыль. Подробнее о понятии — в 🎓 Что такое “дедупликация”.


8. Гетерогенный столбец и отсутствие кросс-сайтовой связки

players.external_id хранит сразу три формы значений в одной колонке:

  • целые dicechess ("81542", "-42"),
  • hex-ObjectId beturanga ("6512a0f3…"),
  • синтетические bot:<algorithm> нашего движка.

Формы не пересекаются (целое ≠ 24-hex ≠ строка с префиксом bot:), поэтому коллизии исключены — столбец безопасно гетерогенный. Но из этого следует важное ограничение:

Кросс-сайтовой связки личностей нет

Один и тот же человек, играющий и на dicechess.com, и на beturanga.com, — это две разные строки в players (целочисленный external_id против ObjectId). Мы не связываем личности между площадками: у нас нет ни общего идентификатора, ни процедуры мэтчинга. Аналитика по игроку всегда в рамках одной площадки. Если когда-нибудь понадобится «единый игрок», это будет отдельный слой поверх players, а не правка external_id.


См. также