Детерминизм в chaincode
Это та тема, на которой в Fabric спотыкаются чаще всего, и спотыкаются болезненно — потому что код
проходит тесты, работает на одной машине, а в сети начинает вести себя так, будто transaction'ы
теряются сами собой. Причина почти всегда одна: chaincode оказался недетерминированным. Здесь мы
разберём, откуда это берётся и какие детерминированные источники даёт hlf-core взамен соблазнительных,
но запрещённых Date.now() и Math.random().
Почему это вообще требование
В Fabric transaction сначала исполняется на нескольких endorsing-пирах независимо друг от друга и только потом попадает в блок. Каждый пир прогоняет тот же самый chaincode на том же входе и формирует свой read-write set: что он прочитал из состояния и что собирается записать. Дальше эти наборы сравниваются, и если они не совпали байт в байт, endorsement не набирается, а transaction до ledger не доходит. Иными словами, у chaincode нет права на собственное мнение: на одном и том же запросе он обязан вернуть один и тот же результат на любом пире и в любой момент — иначе сеть просто не сможет договориться о том, что произошло.
Отсюда простое правило, из которого растёт всё остальное: ничто в chaincode не должно зависеть от локального времени пира, от случайности, от внешнего мира и от любого порядка, который на разных пирах может оказаться разным.
Откуда берётся недетерминизм
Перечислю источники по убыванию коварства — первые два выглядят безобиднее всего и ломают чаще всего:
- Локальное время —
Date.now(),new Date()без аргумента, любой таймер. На двух пирах эти вызовы вернут разные значения, и read-write set разъедется. - Случайность —
Math.random(), системный генератор случайных чисел,crypto.randomUUID(). Случайность по определению не воспроизводится, а chaincode обязан воспроизводиться. - Обращения во внешний мир — HTTP-запрос, чтение файла, переменная окружения, зависящая от узла. Внешний мир у каждого пира свой, и в transaction ему места нет.
- Порядок обхода — перебор
Object.keys,Map,Setтам, где сами ключи собраны недетерминированно (например, пришли из сети в произвольном порядке). Результат зависит от порядка, а порядок — от случая. - Числа с плавающей точкой —
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 в сеть, стоит пройтись по списку запрещённого — почти все плавающие баги детерминизма ловятся на нём:
- Нет
Date.now(),new Date()без аргумента, таймеров — время только изstub.transaction.date. - Нет
Math.random()и системной случайности — уникальность изstub.transaction.hash. - Нет обращений во внешний мир из
execute— ни сети, ни файлов, ни узлозависимых переменных. - Суммы — строками через
MathUtil, без арифметики наnumber. - Любой обход набора с недетерминированным источником ключей — после сортировки.
Как эти правила выглядят в сборке рабочей команды целиком — в туториале, а типовые приёмы вроде своего crypto-менеджера или переноса операции в batch собраны в cookbook.