Перейти к содержанию

Индексатор Ethereum: идемпотентное сохранение событий

Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 14 из 30.

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

Предполагаются нормализованный формат и checkpoint предыдущих уроков. База и программы не запускались. История принадлежит конкретным environmentId, сети и экземпляру CourseCatalog; личный прогресс читателя сюда не добавляется. Реальные хеши не приводятся как измеренные результаты.

Что считать одинаковым наблюдением

В одной транзакции может быть несколько событий, поэтому один transactionHash недостаточен. В разных блоках может наблюдаться похожее действие, поэтому аргументы courseId и contentHash тоже не являются уникальным ключом события. Повторная публикация одинакового отпечатка допустима базовым контрактом и создаёт самостоятельную историческую запись.

Ключ нашего наблюдения включает окружение, сеть, адрес, blockHash, transactionHash и logIndex. Это подробная схема, которая явно связывает запись с её происхождением. Внутри блока logIndex уже различает журналы, но сохранение хеша транзакции помогает диагностике и делает смысл ключа понятным. При другой ветви blockHash изменится, и запись не потеряется как ложный дубль.

Та же транзакция после реорганизации может оказаться в другом блоке. Это два разных наблюдения расположения, хотя прикладное действие связано одним хешем. Каноничная история выбирает нужное наблюдение отдельной политикой. Не следует удалять любое повторение transactionHash без рассмотрения его блока: так можно потерять новую корректную позицию.

Таблица событий

Минимальная схема хранения ниже предназначена для обсуждения адаптера PostgreSQL. Миграция не выполнялась. Форматы адресов и хешей проверяются до insert, а числовые значения сохраняются по объявленным типам.

CREATE TABLE catalog_events (
  environment_id text NOT NULL,
  chain_id numeric(78,0) NOT NULL,
  contract_address text NOT NULL,
  block_number numeric(78,0) NOT NULL,
  block_hash text NOT NULL,
  transaction_hash text NOT NULL,
  log_index integer NOT NULL,
  event_name text NOT NULL,
  course_id numeric(78,0) NOT NULL,
  content_hash text,
  canonical boolean NOT NULL,
  PRIMARY KEY (environment_id, chain_id, contract_address,
               block_hash, transaction_hash, log_index)
);

content_hash отсутствует у Deactivated по схеме базового события. canonical обозначает решение текущей обработки, а не неизменяемое свойство любых полученных байтов. Переход ветви может поменять видимую каноничную часть, сохранив архив неканоничных наблюдений для расследования.

Поле eventName допускает только известные типы нашего ABI. В полной миграции можно добавить CHECK и ограничения связи полей, например необходимость отпечатка у публикации. Это помогает не сохранить логически невозможный объект. Однако схема базы не проверяет контракт сама: она доверяет корректно настроенному адаптеру чтения и его источнику.

Повтор insert

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

INSERT INTO catalog_events (
 environment_id, chain_id, contract_address, block_number, block_hash,
 transaction_hash, log_index, event_name, course_id, content_hash, canonical
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,true)
ON CONFLICT (environment_id, chain_id, contract_address,
             block_hash, transaction_hash, log_index)
DO NOTHING;

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

События порции и checkpoint сохраняются в одной транзакции базы. Идемпотентность позволяет повторить чтение после неизвестного результата commit, не удваивая историю. Если commit уже произошёл, ключи дадут совпадение; если не произошёл, строки добавятся заново. Курсор при этом проверяет ожидаемую прежнюю позицию.

Идемпотентность проекции

Даже если журнал не дублируется, обработчик может повторно применить одно событие к счётчику или внешней выдаче. Например, наивное увеличение publishCount при каждом чтении порции даст неверный результат. Проекция имеет свой checkpoint применения или пересобирается из уникальной каноничной истории.

Для текущей записи курса assignment публикации тех же значений выглядит идемпотентным, но история, уведомления и персональные эффекты могут быть не такими. Поэтому нельзя делать вывод о безопасности всего процесса по одному полю contentHash. Каждое производное действие имеет собственный идентификатор и правило повторов.

Выдачу доступа читателю также не следует запускать напрямую на каждом событии Published: базовый реестр объявляет выпуск, а не покупку. Прикладная обработка должна соответствовать смыслу события. Ошибка модели источника не исправляется тем, что insert имеет правильный уникальный ключ.

Порядок сохранения

Сначала событие нормализуется и проверяется, затем попадает в отсортированную порцию, затем сохраняется. Порядок получения HTTP-ответов не становится порядком исполнения цепочки. После двух параллельных чтений публикация позднего блока могла прийти раньше деактивации раннего. Проекция использует номер блока и logIndex, а не время insert.

При отказе одной порции checkpoint остаётся до непрерывного обработанного префикса. Наличие нескольких будущих строк в журнале не разрешает показать проекцию как полную до их блока. Видимый индексированный уровень связан с доказанно завершённой последовательностью, а не максимальным номером любой сохранённой записи.

Ожидаемые случаи

Повтор одного диапазона сохраняет те же строки без дублей. Две разные публикации одинакового отпечатка сохраняются обе. Та же транзакция в другом блоке получает отдельное наблюдение и рассматривается процедурой каноничности. Несовпадение payload по одному ключу вызывает диагностический отказ, а не тихую замену.

Для упражнения сравните уникальность по аргументам, по transactionHash и по полному ключу наблюдения. Объясните, какие реальные случаи теряет каждый короткий вариант. Ожидаемый итог — повторы транспорта отделены от самостоятельных действий и смены ветви. SQL-конфликты описаны в PostgreSQL INSERT.

Конфликт одинакового ключа

Если уникальный ключ уже существует, сравниваем неизменяемые поля наблюдения. Идентичные байты означают повтор доставки того же факта. Другой courseId или contentHash при том же ключе — несогласованность источника, декодера или хранения. ON CONFLICT сам по себе не даёт права проигнорировать такое расхождение как обычный повтор.

Для диагностики сохраняем безопасное описание конфликта и прекращаем продвижение соответствующего диапазона до выяснения причины. Не выбираем «последний ответ» только по времени получения: быстрее или позже пришедший RPC не становится автоматически более достоверным.

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

Оглавление серии