Перейти к основному содержимому

Детерминизм в chaincode

Это та тема, на которой в Fabric спотыкаются чаще всего, и спотыкаются болезненно — потому что код проходит тесты, работает на одной машине, а в сети начинает вести себя так, будто transaction'ы теряются сами собой. Причина почти всегда одна: chaincode оказался недетерминированным. Здесь мы разберём, откуда это берётся и какие детерминированные источники даёт hlf-core взамен соблазнительных, но запрещённых Date.now() и Math.random().

Почему это вообще требование​

В Fabric transaction сначала исполняется на нескольких endorsing-пирах независимо друг от друга и только потом попадает в блок. Каждый пир прогоняет тот же самый chaincode на том же входе и формирует свой read-write set: что он прочитал из состояния и что собирается записать. Дальше эти наборы сравниваются, и если они не совпали байт в байт, endorsement не набирается, а transaction до ledger не доходит. Иными словами, у chaincode нет права на собственное мнение: на одном и том же запросе он обязан вернуть один и тот же результат на любом пире и в любой момент — иначе сеть просто не сможет договориться о том, что произошло.

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

Откуда берётся недетерминизм​

Перечислю источники по убыванию коварства — первые два выглядят безобиднее всего и ломают чаще всего:

  1. Локальное время — Date.now(), new Date() без аргумента, любой таймер. На двух пирах эти вызовы вернут разные значения, и read-write set разъедется.
  2. Случайность — Math.random(), системный генератор случайных чисел, crypto.randomUUID(). Случайность по определению не воспроизводится, а chaincode обязан воспроизводиться.
  3. Обращения во внешний мир — HTTP-запрос, чтение файла, переменная окружения, зависящая от узла. Внешний мир у каждого пира свой, и в transaction ему места нет.
  4. Порядок обхода — перебор Object.keys, Map, Set там, где сами ключи собраны недетерминированно (например, пришли из сети в произвольном порядке). Результат зависит от порядка, а порядок — от случая.
  5. Числа с плавающей точкой — 0.1 + 0.2, parseFloat, деление. Расхождение здесь не столько между пирами, сколько между ожиданием и реальностью, но в финансовой логике оно так же недопустимо.

Детерминированные источники: время и энтропия​

Главное, что нужно запомнить — время и «уникальность» в chaincode берутся не из системы, а из самой transaction. Fabric кладёт в предложение transaction её временную метку и идентификатор, одинаковые для всех пиров, и hlf-core отдаёт их через stub.transaction:

// holder.stub.transaction — одинаков на всех пирах-эндорсерах
const date = holder.stub.transaction.date; // детерминированное «сейчас»
const hash = holder.stub.transaction.hash; // детерминированный идентификатор transaction

stub.transaction.date — это и есть «сейчас» для chaincode. Любая доменная логика, которой нужно время (дата создания записи, срок действия, проверка «не истёк ли»), обязана брать его отсюда, а не из Date. И дата, которую мы кладём в сущность, и сравнение «не истёк ли срок» одинаково опираются на время transaction, а не узла:

// дата создания берётся из транзакции и одинакова на всех пирах
item.created = holder.stub.transaction.date;

// срок проверяется относительно того же времени, а не локального Date
if (item.expired < holder.stub.transaction.date) {
throw new EntityExpiredError(item.uid);
}

stub.transaction.hash закрывает вторую потребность — когда нужна уникальность. Там, где вне блокчейна напрашивался бы случайный UUID, в chaincode берётся хэш transaction: он уникален, но при этом одинаков на всех пирах, потому что вычислен из самого предложения, а не из генератора случайных чисел. На нём же удобно строить производные идентификаторы дочерних записей, созданных внутри одной transaction.

Деньги — только строками, через MathUtil​

Финансовую точность нельзя доверять числам с плавающей точкой, и дело здесь даже не столько в детерминизме между пирами, сколько в том, что на округлении неизбежно накапливается расхождение. Поэтому все суммы в hlf-core хранятся строками, а арифметика идёт через MathUtil поверх decimal.js:

import { MathUtil } from '@ts-core/common';

const after = MathUtil.add(balance.inUse, value); // не balance.inUse + value
const isEnough = MathUtil.greaterOrEqualThan(after, value);

Подробнее про модель балансов и перевод рубля в копейку — в разделе «Токены».

Порядок обхода​

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

const uids = Object.keys(request.items).sort(); // канонический порядок до записи
for (const uid of uids) {
await manager.save(request.items[uid]);
}

Та же логика, кстати, стоит и за сериализацией сущностей: при записи ключи объекта сортируются (isSortKeys: true), чтобы один и тот же объект всегда давал одну и ту же строку в ledger.

MVCC и nonce: детерминизм во времени​

Детерминизм это не только согласие двух пиров в один момент. Он касается и того, что происходит, когда две transaction'а трогают одно и то же состояние. Fabric разрешает это оптимистично, через MVCC: вместе с каждым чтением фиксируется версия ключа, и если к моменту коммита версия уже изменилась другой transaction'ой, наша получает MVCC_READ_CONFLICT и отклоняется. Ничего не портится, но transaction нужно повторить.

В hlf-core это проявляется предсказуемо и осознанно. Защита от повторного воспроизведения построена на строго возрастающем nonce по пользователю, а nonce — это общий ключ состояния. Значит две параллельные команды одного пользователя неизбежно конфликтуют на нём и сериализуются: вторая дождётся первой или будет повторена. Для учётной системы такое поведение чаще желательно, чем нет: оно гарантирует строгий порядок операций пользователя, но его стоит держать в голове, когда планируется высокая параллельность. Массовые операции ровно поэтому и уходят в batch-режим, где множество команд проводится одной transaction'ой и на общих ключах не толкается.

Отдельно стоит StateProxy — внутритранзакционный кэш с отложенным коммитом. В пределах одной transaction повторное чтение того же ключа идёт из кэша, а запись откладывается до конца, поэтому логика внутри одной команды видит собственные изменения согласованно и не плодит лишних обращений к состоянию. Это не ослабляет MVCC между transaction'ами, а наводит порядок внутри одной.

Короткий чек-лист​

Перед тем как ставить chaincode в сеть, стоит пройтись по списку запрещённого — почти все плавающие баги детерминизма ловятся на нём:

  1. Нет Date.now(), new Date() без аргумента, таймеров — время только из stub.transaction.date.
  2. Нет Math.random() и системной случайности — уникальность из stub.transaction.hash.
  3. Нет обращений во внешний мир из execute — ни сети, ни файлов, ни узлозависимых переменных.
  4. Суммы — строками через MathUtil, без арифметики на number.
  5. Любой обход набора с недетерминированным источником ключей — после сортировки.

Как эти правила выглядят в сборке рабочей команды целиком — в туториале, а типовые приёмы вроде своего crypto-менеджера или переноса операции в batch собраны в cookbook.