Архив партий VOG в Parquet: 17 миллионов игр с диска сервера в S3

Как архив VOG за двадцать пять лет — около 17,7 млн партий в собственном файловом формате на диске игрового сервера — переехал в Parquet в S3-совместимом хранилище: почему не Iceberg и не protobuf, почему первое поколение с ходами по колонкам пришлось переделать в «транскрипт как есть», что ломалось при выгрузке, как сверяли результат и как старые партии стали открываться в браузере

Это седьмая статья серии «VOG изнутри». В статье про порядок вывода легаси среди старых проблем было хранение архивов на файловой системе игрового сервера. Здесь — как этот архив переехал в Parquet на S3: как выбирали подход, как переупаковывали, что при этом ломалось и как проверяли, что ничего не потерялось.

Всё ниже описывает состояние на 8 октября 2026 года.

Схема в стиле журнала «Полдень»: из старого шкафа с картотекой «.i» и «.b» под табличкой «2001» лента с записями партий — нарды, шахматы, шашки, реверси, червы, эрудит — уходит в машину «упаковщик» со стрелкой «память»; она прессует записи в полосатые блоки «parquet», которые встают на полки склада «S3» по годам 2001, 2006, 2026; на переднем плане проверяющий с лупой сравнивает старую ленту с новой, рядом лист «сверка»; во врезке зачёркнутый календарь «1970» заменён восстановленной датой

В статье

Зачем переносить архив

Архив партий VOG — это двадцать пять лет игр: нарды, шашки, шахматы, реверси, червы и эрудит (в прежних статьях серии он назван по-клиентски — «Слова»). Самые первые партии хранились в базе, и при миграции восстановили не все, поэтому сегодня архив начинается с 2001 года и местами неполон в первые годы. Остальное — около 17,7 млн партий — лежало в файлах на диске игрового сервера, в собственном формате, который читал только сам сервер.

Главная причина переноса — не формат, а локальность. Пока архив лежит на диске одной машины, всё, что с ним работает, привязано к этой машине. Такая привязка мешала размещению, которое нам нужно в Kubernetes: сервисы, работающие с архивом, — показ партии, индекс для поиска, сверки, — должны подниматься на любой ноде и не в одном экземпляре. Им нужно хранилище, доступное по сети, а не каталог на чужом диске.

S3-совместимое хранилище эту привязку снимает. Но полностью от промежуточного хранения оно не избавляет. Закрытый файл Parquet нельзя дописать: пополнение — это новый файл, а перепаковка — чтение старых и запись нового. А наш упаковщик к тому же сортирует строки партиции перед записью (зачем — ниже), поэтому держит партицию в памяти целиком, прежде чем выгрузить файл. Сам формат этого не требует, это цена нашей сортировки. Упаковка и перепаковка поэтому требуют места, где файл собирается, — у нас это память упаковщика с жёстким пределом.

Что лежало на диске

Старый сервер хранил партии в формате, который в коде называется CFastLibrary. Устроен он просто. Партии лежат парами файлов .i и .b, по 16 384 номера партии на пару. Файл .i — массив 32-битных смещений, номер записи в нём — номер партии внутри диапазона; значение 0xFFFFFFFF означает «партии нет». В .b по смещению лежит длина и сами байты транскрипта. Партия открывается по номеру за два чтения.

bool CFastLibrary::AppendBlock(uint32_t Index, unsigned char *Block, unsigned int uiSize) {
    std::lock_guard<std::mutex> locker(m_ppLock);

    struct stat statv{};
    uint32_t PosBlock;
    int HandleIdx, HandleBlock;

    if (m_strBase == nullptr) {
        return false;
    }

    Index = FillBase(Index);

    m_strIndex[9] = 'b';
    if ((HandleBlock = open(m_strBase, O_CREAT | O_RDWR, 0660)) != -1) {
        PosBlock = lseek(HandleBlock, 0, SEEK_END);
        if (PosBlock != (uint32_t) -1) {
            auto w = write(HandleBlock, &uiSize, sizeof(uiSize));
            w = write(HandleBlock, Block, uiSize);
            // …

Транскрипт внутри — цепочка кусков вида «тип, длина, тело», упакованных без выравнивания. Кусок 0x00 — заголовок партии, ровно 22 байта; 0x01 — игрок с ником на момент партии; ходы — в 0x83, и раскладка хода у каждой из шести игр своя. Версия формата записи клиента — 28, отсюда и имя, под которым этот формат живёт в новом коде: chunk28.

В файлы попадала не каждая партия. Транскрипт писался только у рейтинговых, и только если за столом не сидел турнирный бот. Строка в базе при этом писалась всегда. По правилу транскрипт полагался 17 616 258 партиям из 17 617 344 строк базы — разница общих количеств крошечная. Но близкие суммы не означают одинаковый состав: ниже, на сверке, видно, что файлы и база расходятся в обе стороны на тысячи партий.

Весь файловый архив занимал 24 ГБ (здесь и дальше, где источник даёт двоичные единицы, пишем ГиБ и МиБ). Больше всего партий у нард — 8,7 млн, дальше эрудит (3,0 млн), шашки (2,8), реверси (1,19), шахматы (1,13) и червы (0,78). Средний транскрипт — около полутора килобайт.

Почему Parquet, а не Iceberg и не protobuf

Сначала о том, что с чем сравнивается. Parquet — формат файла, protobuf — способ сериализовать полезную нагрузку внутри файла или сообщения, Iceberg — слой управления таблицей поверх файлов. Это ответы на вопросы разных уровней, и выбирали мы на каждом уровне отдельно.

Решение записано в архитектурном решении о хранении партий. Главная мысль в нём — хранение делится по способу обращения к данным, а не по возрасту партии и не по тому, какой сервис её записал:

  • горячее — идущие и отложенные партии. Частая запись, чтение по ключу, партия должна пережить перезапуск. Это ScyllaDB;
  • тёплое — показать одну доигранную партию по запросу игрока. Точечное чтение по ключу;
  • холодное — аналитика по всему массиву: сколько сыграно, как менялся рейтинг. Это Parquet в S3, который читается DuckDB прямо из хранилища — без кластера, без демона, без каталога.

Iceberg мы не завели. Он добавляет к Parquet каталог таблиц, снимки, эволюцию схемы и построчное удаление. Каталог не обязательно отдельный сервер — бывают каталоги на файловой системе или в обычной базе, — но это всё равно ещё один слой, который нужно обслуживать: уплотнение, чистка снимков. Нам пока хватает одного писателя на партицию и DuckDB для разовых запросов, архив целиком помещается на один диск, и вводить управление таблицами — а вместе с ним Trino или другой движок запросов — мы решили не сейчас. Условия, при которых к Iceberg стоит вернуться, записаны заранее: несколько независимых писателей, удаление задним числом или схема, меняющаяся чаще раза в год.

Protobuf проиграл даже исходному бинарному формату. Формат хранения ходов выбирали измерением на 20 000 реальных партий, в байтах на партию:

Вариант Реверси Нарды
сырой блок в старом архиве 297 1620
тот же блок, сжатый внутри Parquet 177 897
protobuf внутри Parquet 200 1242
ходы колонками, LIST<STRUCT> 165 1017

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

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

Первое поколение: ходы по колонкам

Первая версия архива разбирала каждую партию по правилам её игры и раскладывала ходы колонками. Путь в хранилище был games/year=…/sid=…/game_type=…/part-NNNN.parquet: год, сервер (он же игра) и тип игры внутри сервера.

Время на ход округлили до десятых долей секунды. У нард колонка времени занимала 28% объёма, и округление сократило партию с 1017 до 891 байта. Точная миллисекунда у новых партий остаётся в Scylla, где по ней считают часы.

Одна деталь стоила трети объёма. Сначала выгрузку делал скрипт на Python с pyarrow, потом упаковщик переписали на Go. pyarrow включает словарное кодирование колонок сам, библиотека parquet-go — нет, и первая сборка на Go при тех же данных вышла на треть тяжелее. Словарь снимает 15–20% объёма почти у всех колонок: клетка доски, вид хода, масть, имя — значений у них в пределах файла немного.

Первое поколение заняло 11,5 ГБ на 17,6 млн партий. И почти сразу стало понятно, что формат неудачен:

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

Второе поколение: транскрипт как есть

Через два дня архив переделали. Теперь в нём лежит сам транскрипт, тем же куском байт, который читает игровой клиент. Разбирается только общее для всех игр: кто играл, когда, чем кончилось, сколько ходов. Разбор ходов, когда понадобится аналитика по ним, переедет в ClickHouse.

Рядом с байтами в каждой строке записан их формат: chunk28 для старых партий, jsonmoves1 для партий новых игровых сервисов. Формат читается до разбора. Двоичные данные охотно притворяются любыми другими двоичными данными, и партия нового сервиса, разобранная по старым правилам, дала бы правдоподобный мусор.

Путь в хранилище тоже изменился: games/year=YYYY/sid=NN/game_class=…/part-<прогон>-NNNN.parquet. Вместо типа игры — разряд партии (рейтинговая, обычная, турнирная, клубная), а в имени файла — метка прогона, чтобы случайный второй писатель не затёр чужой файл.

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

Точечное чтение из колоночного файла

В решении о хранении сначала было записано, что для показа одной партии Parquet противопоказан: чтобы достать одну запись, придётся открыть файл с сотнями тысяч чужих. Второе поколение это обошло, и теперь одна партия читается прямо из Parquet. Путь от запроса до байт такой:

  1. Ключ запроса. Старый клиент и веб приносят сервер и номер партии, новые сервисы — идентификатор.
  2. Партиция. Её подсказывает индекс в ClickHouse (о нём ниже): по серверу и номеру он отдаёт год и разряд партии, то есть каталог в хранилище.
  3. Файл и группа строк. В исторических партициях после выгрузки файлов единицы; в подвале каждого записаны границы номеров по группам строк, и по ним выбирается одна группа одного файла.
  4. Страница. В группе по колонке номеров находится место партии, и по индексу страниц читается одна страница колонки транскрипта.

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

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

Чтобы этот путь был дешёвым, понадобились три вещи.

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

// Сортировка — то, на чём держится дешёвое точечное чтение: статистика
	// групп позволяет пропустить всё, кроме одной.
	//
	// Ключ составной, потому что спрашивают двумя способами. Сначала легасийный
	// номер: по нему приходит легасийный клиент, и он же даёт группам узкие
	// границы. Партии новых движков номера не имеют, собираются в блок с нулём и
	// внутри упорядочены по идентификатору — отсечение работает и там.
	sort.Slice(rows, func(i, j int) bool {
		if rows[i].LegacyGameID != rows[j].LegacyGameID {
			return rows[i].LegacyGameID < rows[j].LegacyGameID
		}
		return bytes.Compare(rows[i].GameID[:], rows[j].GameID[:]) < 0
	})

Страница — 64 КБ. Это целевой размер страницы до сжатия, а не объём сетевого чтения. Сжатие (zstd) идёт постранично, и достать один транскрипт, не распаковав его страницу, нельзя. При странице в мегабайт открытие партии означало бы распаковку мегабайта ради полутора килобайт полезных.

Чтение идёт колонками, а не строками. Сначала перебирается колонка номеров одной группы, потом по индексу смещений берётся одна страница колонки транскрипта. Строками так нельзя: parquet-go поднимает каждую строку целиком со всеми колонками. Мы это узнали, когда служба показа партий упала по памяти на выборке старых нард: чтение одной партии строками выделяло 43 МБ, колонками — 2,9 МБ.

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

Две личности партии

У партии в архиве два ключа: постоянный идентификатор game_id (UUID) и старый номер legacy_game_id. Номер уникален только внутри сервера, а у партий новых сервисов его нет вовсе — они получают UUIDv7. Идентификатор старой партии вычисляется из пары «сервер и номер»:

// GameIDForLegacy — идентификатор партии по её легасийному номеру.
//
// Считается из пары «сервер и номер»: номер уникален только внутри сервера, и
// без сервера партии разных серверов слились бы в одну.
//
// Функция чистая и повторяемая — в этом весь смысл. Старый архив, перенесённый
// сегодня, получит те же идентификаторы, что живая партия получила бы тогда, а
// повторный перенос не заведёт вторую запись вместо первой.
func GameIDForLegacy(serverID int, legacyGameID uint32) uuid.UUID {
	return uuid.NewSHA1(GameIDNamespace, []byte(fmt.Sprintf("%d/%d", serverID, legacyGameID)))
}

Это позволило упаковывать одним кодом обе дороги: разовый перенос из файлов и регулярную упаковку доигранных партий из Scylla. Партия, которую старый сервер вёл вчера и записал в оба места, получает в архиве один и тот же идентификатор, откуда бы её ни взяли.

Записи двадцатилетней давности

Перед выгрузкой формат разобрали заново на 60 000 партий с прода, по 10 000 на игру, с отдельным ревью разбора каждой игры. Документации на формат не было, только исходники сервера и клиента. Особенностей набралось много; вот те, что повлияли на архив.

  • Порядок битовых полей. Первое поле лежит в младших битах. Обратное чтение выдавало пас в реверси за клетку (0, 15) — правдоподобно и неверно.
  • Автор хода записан не везде. В шахматах место ходившего есть в самом ходе. В реверси и шашках автора восстанавливают чередованием с учётом пропусков. В червах его выводят из индекса карты: карты раздаются по кругу, и по отрицательному индексу карты из руки считается место игрока. Проверка дала совпадение на 827 124 ходах из 827 158; все 34 промаха — в одной битой партии.
  • Время хода меряет клиент. Сервер кладёт присланное значение как есть. Нашлись ходы длиной около 49,6 суток — это аптайм машины игрока, попавший в поле целиком, когда стартовая отметка была нулевой. Такие ходы не отбрасываются, а считаются: значение может быть сломанным, а может быть законным, например в отложенной партии.
  • Дат нет у 410 630 партий. Они записаны с нулевой меткой и показывались бы 1970 годом. Дату восстановили по ближайшим соседям: номера выдаются во времени. Метод проверили на 8,5 млн нард с известной датой — год верен в 99,9% случаев, день в 96%, медианная ошибка 4,5 минуты. Рядом с такой датой стоит признак date_estimated: через год никто не отличит партию, сыгранную в 2004-м, от той, которой 2004-й приписан.
  • Битые записи встречаются. В реверси десять партий из 10 000 содержат сотни «ходов» на доске 8×8. Разбор обрывается на первой неправдоподобной длине и возвращает прочитанное:
// chunks разбирает запись на куски. Обрывается на первой неправдоподобной
// длине: записи двадцатилетней давности встречаются испорченными, и лучше
// вернуть прочитанное, чем ничего.
func chunks(blob []byte) []chunk {
	var out []chunk
	for off := chunkPrefix; off+chunkHeader <= len(blob); {
		length := int(binary.LittleEndian.Uint16(blob[off+1:]))
		if length < chunkHeader || off+length > len(blob) {
			return out
		}
		out = append(out, chunk{tag: blob[off], body: blob[off+chunkHeader : off+length]})
		off += length
	}
	return out
}
  • Структуры менялись между версиями клиента. У эрудита кусок правил бывает 32 и 33 байта, в другом куске поля смещены на 12 байт. Имена — в cp1251, декодер для неё написан свой, без зависимостей.

Отдельно оказалось, что «424 неразобранные партии нард из 10 000» — ошибка измерения: столько партий вовсе не имеют ходов. У шахмат таких 7%, почти все с причиной «партия не началась». Объявить их испорченными значило бы объявить испорченными семь процентов архива.

Выгрузка: что шло не так

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

Прод-машина ушла в своп. Упаковщик копил партии в памяти по партициям и сбрасывал партицию на диск, когда в ней набиралось 200 000 партий. Общего предела не было. На пробном прогоне брали узкий диапазон номеров, и всё было хорошо. На полном проходе реверси — 1,18 млн партий, разложенных на два десятка лет и пять типов игр, — ни одна партиция не дошла до порога, и весь миллион остался в памяти. Машина с 8 ГБ, где рядом работали игровые контейнеры, ушла в своп. Игровые серверы выжили, а ssh не отвечал, и выгрузку останавливали с консоли гипервизора.

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

Повтор на нардах. Следующий прогон «умер молча»: в логе осталась строка про сервер нард без итога, процесс убило ядро. У нард полторы сотни партиций — двадцать шесть лет на несколько видов правил, — и предел на партицию умножался на их число. Теперь предел общий, и при его превышении сбрасывается самая тяжёлая партиция. Выгрузку направили прямо в хранилище: на диске машины оставалось 23 ГБ при архиве в 24.

Бакет оказался пуст. Выгрузку почистили перед новым прогоном, а прогон упал на разборе. Обнаружилось это, когда я спросил, можно ли уже делать миграцию. Потеряли часы машинного времени, а не партии: Parquet производный, его можно собрать заново. Но после переключения на новые сервисы подстраховки не станет — партия будет только в архиве. Поэтому:

  • решено, что новый игровой сервис на переходный период пишет доигранную партию дважды: в Parquet и в пары .i/.b тем же форматом, что старый сервер, — чтобы при ошибке упаковки перепаковать партии с диска. Выключать вторую запись — по признаку, а не по ощущению: когда сверка подтвердит каждую партию за достаточный срок. На 8 октября это правило исполняется только наполовину. Партии, которые ведёт старый сервер, по-прежнему ложатся в .i/.b. У новых сервисов вторая запись ещё не сделана; их партии страхует Scylla, из которой доигранные партии пока не удаляются вовсе;
  • появилась сверка числа партий в архиве с базой по каждой игре.

Файл на партию. По ошибке упаковщик писал по файлу на партию, что дало бы двести тысяч объектов в день. Отсюда правило: пополнение идёт пакетами, а файлы должны укрупняться по мере остывания.

Упаковщик ходил в Amazon. Задание в кластере не передавало адрес хранилища, а пустой адрес в SDK означает не «по умолчанию», а настоящий AWS. Ключ был от нашего хранилища, упаковка при каждом запуске получала «такого ключа не существует» и падала. Ошибка выглядела как проблема с доступом, а была промахом мимо хранилища. Партии при этом не терялись: они оставались в Scylla, а старый сервер ещё и писал их в файлы.

Дубли. Файлы второго поколения отдали 17 723 479 партий, а в индексе их оказалось на 1003 меньше: у части партий пара «сервер и номер» повторялась, и в индексе вторая строка вытесняла первую. Таких партий 37, некоторые повторены помногу раз. Опасно это, только если копии разные — тогда мы молча выбрали бы, какую запись истории считать настоящей. Отдельный инструмент сравнил все копии по SHA-256: совпали побайтово. Обычная сверка этого бы не увидела — она строит раскладку тем же отображением «номер → место», и вторая копия затирает первую ещё до сравнения.

Числа разных этапов не совпадают, и это не ошибка счёта — они считают разное:

Этап Дата Строк Уникальных партий Что считали
База старого сервера 21.08 17 617 344 — строки базы; транскрипт по правилу полагался 17 616 258 из них
Первое поколение 21–22.08 около 17,6 млн — черновой прогон шёл по вручную заданным диапазонам номеров, перенос на Go — уже по файлам
Второе поколение 24.08 17 723 479 17 722 476 обход по самим файлам .i/.b: всё, что лежит на диске, включая партии без строки в базе

Причин у разницы чисел две, и они независимы. Первая — неполнота диапазонов: обход по файлам находит больше, чем обход по заданному вручную диапазону номеров; у реверси черновой прогон недосчитался 4485 партий, у шахмат — 2137. Это объясняет разницу между выгрузками. Вторая — сами файлы и база расходятся по составу: есть партии с транскриптом и без строки в базе, и наоборот (подробно — в разделе о проверке). Это объясняет, почему во втором поколении партий больше, чем строк в базе. Отсюда правило для сверок: числа предыдущей выгрузки не эталон, сверять надо с базой и с файлами.

Архив второго поколения занимает 15,1 ГиБ — меньше, чем 24 ГБ исходных файлов, хотя хранит больше, чем первое поколение.

Как проверяли

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

Тот же ли разбор? Разборщик переносили со скрипта на Go и сверяли с прежней реализацией на тех же 60 000 партий до полного совпадения. Сверка нашла три ошибки: знаковое чтение превращало 255 в −1, время тихо обрезалось по часу, а номер подыгры терялся в потоке ходов.

Верен ли автор хода? Чередование проверили на шахматах, где автор записан явно: 57 916 ходов, ноль расхождений. Ошибка здесь не видна на глаз — партия с неверным автором разбирается и выглядит целой. У эрудита все ходы какое-то время приписывались одному игроку, пока это не поймала сверка.

Не потерялось ли что-то при упаковке? Каждый прогон упаковщика перечитывает записанное и сверяет:

  1. число партий с числом в списке доигранных;
  2. номер и сервер из транскрипта с теми, что назвал сервис, который вёл партию. Несовпадение означает чужие байты в партии — худшее, что может случиться с архивом, — и прогон завершается ошибкой;
  3. число ходов: расхождение считается отдельно, обрезанная запись лучше отсутствующей;
  4. счётчики пропусков — не хранится, не найдено, без транскрипта, чужой формат, не разобрано. Они видны в логе каждого прогона. Молчаливый пропуск читался бы как «упаковали всё».

Те же ли байты? Сравнение с файлами .i/.b байт в байт стало возможным только во втором поколении: в архиве те же байты, а не результат разбора. Червы проверили целиком — 783 030 партий за 9 минут, все совпали.

Сходится ли состав с базой? На реверси нашлись 4103 партии, которые есть в файлах и нет в базе: вокруг каждой в базе лежат соседние номера, то есть файл записался, а строка — нет, или партию позже удалили из базы. И 877 партий есть в базе, но нет в файлах, почти все из 2001–2002 годов: похоже, файлы тех лет просто не сохранились. Общие количества при этом почти равны — именно поэтому сверять нужно множества, а не суммы.

Один формат для показа

В архиве два формата: chunk28 у старых партий и транскрипт новых сервисов. Показывать их браузеру двумя путями значило бы держать два просмотрщика. Поэтому на стороне показа есть переводчик старой записи в транскрипт новых сервисов. Им пользуются веб-архив партий и инструменты администрирования.

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

Игра Выборка Что проверяли
Реверси 1133 партии все ходы законны, победитель совпал во всех доигранных; 13 партий 2001–2007 годов со своей начальной расстановкой поддержаны отдельно
Шахматы 1200 партий 2001–2026 годов все ходы законны; в победах последний ход за победителем
Червы 1175 партий 9818 раздач, кроме двух с испорченной картой
Эрудит 96 партий 1155 слов, счёт совпал
Шашки 1199 партий одна партия со своей расстановкой отвергнута
Нарды 178 партий счёт матча равен сумме подпартий

По дороге всплыли новые особенности: итальянские шашки записаны на другой раскраске доски, точку поворота при бое клиент местами писал дважды, а у 40% партий нард нет правил — в начале 2000-х там лежал мусор.

Итоговая проверка: случайная выборка по 5000 старых партий каждой игры прямо из хранилища, минуя службу показа. Успехом считался перевод без ошибки переводчика; законность ходов и совпадение результата проверялись раньше, на выборках из таблицы. Переведены все 30 000. При нуле отказов из 5000 односторонняя 95-процентная верхняя граница доли непереводимых — 0,06% на игру. Это оценка по выбранному критерию — «перевод не падает», — а не по всем возможным ошибкам архива.

Архив в работе

flowchart LR L["Старый сервер"] --> SC["ScyllaDB
идущие и доигранные партии"] L -.-> F["Файлы .i/.b
подстраховка"] G["Новые игровые сервисы"] --> SC SC --> P["Упаковщик"] F -.->|"перенос и перепаковка"| P P --> S3["Parquet в S3"] S3 --> IX["Индекс
ClickHouse"] S3 --> V["Показ партии"] IX --> W["Поиск партий в вебе"] V --> W

flowchart LR
    L["Старый сервер"] --> SC["ScyllaDB
идущие и доигранные партии"] L -.-> F["Файлы .i/.b
подстраховка"] G["Новые игровые сервисы"] --> SC SC --> P["Упаковщик"] F -.->|"перенос и перепаковка"| P P --> S3["Parquet в S3"] S3 --> IX["Индекс
ClickHouse"] S3 --> V["Показ партии"] IX --> W["Поиск партий в вебе"] V --> W
Путь партии: от сервиса, который её вёл, до архива, индекса и показа

Состояние механизмов на 8 октября:

Механизм Статус
Упаковка доигранных партий из Scylla работает на проде
Индекс в ClickHouse из Parquet работает на проде
Показ старой и новой партии из Parquet работает на проде
Журнал удалений сделан
Перепаковка партиции (удаление, уплотнение) спроектирована, не реализована
Полная пересборка индекса из архива не проверялась на полном объёме

Упаковка. Свежие партии лежат в Scylla и показываются оттуда, пока не попали в архив. Сервис, закрывая партию, кладёт её в список доигранных за день. Упаковщик идёт по расписанию, берёт из списков вчерашний и сегодняшний день и ведёт отметку «докуда упаковано»: запускать его можно хоть каждые четверть часа, и при штатной работе уже упакованный участок повторно не обрабатывается. Граница у этой гарантии есть. Отметка сдвигается отдельным шагом после записи файлов, и если процесс упадёт между ними, файлы уже лежат в архиве, а следующий прогон упакует те же партии ещё раз — в файл с меткой нового прогона, то есть рядом, а не поверх. Окно узкое — между сбросом файлов и записью отметки; открывается оно на каждой контрольной точке (раз в 20 000 партий) и в конце дня. Но дубли после такого сбоя возможны, и отдельной проверки на них пока нет. Писатель на партицию один, режим только дописывающий, имя файла содержит метку прогона. Перезапись партиции целиком осталась инструментом миграции и в работающую систему не попала.

У этого окна в двое суток есть слабое место. Окно отсчитывается от дня завершения партии, а не от её начала, поэтому долгие и отложенные партии ему не страшны. Опасен простой: если упаковщик не работал дольше двух суток или партия не попала в список дня при закрытии, назад он не посмотрит, а прогон отчитается нулями и будет выглядеть здоровым. Необратимой потери при этом нет: доигранные партии из Scylla сейчас не удаляются. Но восстанавливаются два случая по-разному. Простой упаковщика догоняется ручным прогоном за пропущенные дни. Партию, которой нет в списке дня, повторное чтение того же списка не найдёт: нужен отдельный поиск доигранных и не упакованных партий в Scylla, а его пока нет. И заметить ни тот, ни другой пропуск сейчас нечем — оба записаны в долг (ниже).

Файлы от частых прогонов получаются мелкими. Укрупнять их по мере остывания — суточные в месячные, месячные в годовые — задумано отдельным проходом, который переносит группы строк целиком, без перекодирования. Пока он не написан.

Поиск партий идёт не по Parquet, а по индексу в ClickHouse, который строится из него: строка на партию и строка на участие игрока. Замер делали 22.08 на загрузке из первого поколения — 17,6 млн партий и 36,7 млн участий; нынешний индекс строится из второго поколения тем же способом. Загрузка заняла 51 секунду, индекс занял 386 МиБ, а поиск партий игрока за двадцать шесть лет — 80 мс. Те же данные в PostgreSQL занимали 7,4 ГБ; сравнение грубое — разные движки, своё сжатие и свои индексы, — но порядок разницы показателен. Индекс отмечает разобранные файлы и пропускает их, загрузка повторяемая.

Удаление. Партии иногда нужно удалять: испорченная запись или требование убрать данные игрока. Объект в S3 неизменяем, и удаление задумано в два срока: запись в журнал удалений сразу скрывает партию от всех читателей службы, а отложенный проход перепаковывает партицию без неё. Журнал уже есть, перепаковки ещё нет — пока партия только скрывается.

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

Что осталось

  • Сплошная побайтовая сверка сделана только для червей; для остальных игр — выборочные проверки.
  • Сверка по множествам и манифест публикации. Сейчас сверяется число партий, а не их состав: две ошибки, компенсирующие друг друга, пройдут незамеченными.
  • Расхождения с базой на реверси (4103 и 877 партий) не разобраны, как и несколько одиночных странностей: шахматная партия с номером 304 280 987 при потолке около 28 млн у остальных и строка базы с номером сервера 111.
  • Перепаковка — для удаления и уплотнения файлов до целевых 128–256 МБ — не реализована.
  • Вторая запись в .i/.b у новых сервисов не сделана; их партии страхует только Scylla.
  • Пересборка индекса из архива целиком ни разу не проверялась на полном объёме.
  • Отставание упаковщика не видно. Партия, выпавшая из двухсуточного окна, останется в Scylla, но в архив сама не уедет, и ни один отчёт этого не покажет. Нужно число «закрыто и не упаковано» с возрастом самой старой такой партии.
  • Партия, не попавшая в список дня при закрытии, не находится ни упаковщиком, ни его повторным прогоном. Нужен поиск доигранных и не упакованных партий по самой Scylla.
  • Дубли после сбоя упаковщика. Падение между записью файлов и сдвигом отметки оставляет те же партии в двух файлах. Нужна проверка уникальности номера партии в партиции — при перепаковке или отдельным проходом по индексу.
  • Старые файлы .i/.b хранятся до конца перехода: пока идёт хоть одна сверка, они — единственный первоисточник.

Итог

  • Переносили ради независимости от диска. Архив на диске одной машины привязывает к ней всех, кто с ним работает. Parquet в S3-совместимом хранилище эту привязку снимает, но не до конца: упаковка и перепаковка всё равно требуют места, где собирается файл.
  • Хранение делится по способу обращения. Горячее — Scylla, аналитика — Parquet и индекс в ClickHouse. Слой управления таблицами вроде Iceberg нам пока не нужен: писатель один, объём помещается на один диск. Условия возврата к нему записаны заранее.
  • Храните исходные байты, а не результат разбора. Первое поколение с ходами по колонкам теряло данные и не годилось для показа. Транскрипт как есть с форматом в каждой строке — и полнее, и проще.
  • Точечное чтение из Parquet возможно, если отсортировать строки по ключу, взять мелкую страницу, читать колонками и диапазонами байт, а партицию брать из индекса.
  • Пробный прогон должен иметь ту же форму, что полный. Своп на проде случился потому, что в пробе не было того, что ломает.
  • Сверять множества, а не суммы. Близкие общие количества скрывали тысячи расхождений в составе в обе стороны.
  • Каждый пропуск — счётчик, а не тишина. Партия без транскрипта, битая запись, восстановленная дата — всё это считается и помечается, иначе расхождение всплывает много позже.

Обсуждение в Telegram

Присоединиться →

Комментарии