Merge pull request 'release: parser2 1.1.0 + ship-reader 0.2.0 (полный SHiP action_trace)' (#3) from dev into main
Release / Release (push) Failing after 5m30s
Release / Build & push Docker image (push) Has been skipped

Reviewed-on: #3
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
2026-05-27 06:36:18 +00:00
12 changed files with 162 additions and 21 deletions
+45 -13
View File
@@ -1,8 +1,6 @@
name: CI
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
@@ -85,30 +83,64 @@ jobs:
# передачу команды контейнеру. Монтируем config.ini (SHiP на 8080) и genesis.json.
- name: Start blockchain node
run: |
docker run -d --name blockchain \
-p 8888:8888 -p 8080:8080 -p 9876:9876 \
-v "${GITHUB_WORKSPACE}/packages/parser2/test/integration/node-config:/mnt/dev/config" \
# Раннер self-hosted и персистентный: упавший прогон оставляет контейнер
# с фиксированным именем blockchain, и следующий старт падает с exit 125
# (Conflict, name already in use). Сносим остаток перед стартом.
docker rm -f blockchain 2>/dev/null || true
# Конфиг кладём в контейнер через docker cp, а НЕ через bind-mount (-v).
# Раннер работает по схеме docker-in-docker (docker.sock хоста проброшен
# в job-контейнер), поэтому пути bind-mount резолвятся на ФС ХОСТА, а не
# внутри job-контейнера: ни mktemp, ни workspace хостовому демону не видны
# → монтируется ПУСТОЙ каталог (nodeos пишет туда protocol_features, но
# genesis.json/config.ini отсутствуют → 'genesis file does not exist').
# docker cp передаёт файлы tar-стримом по API и от путей хоста не зависит.
# /mnt/dev/config в образе нет (его создавал сам mount), а docker cp не
# создаёт родительские каталоги — поэтому config-dir = /mnt/cfg (родитель
# /mnt существует). protocol_features узел пишет туда же, в свежесозданный
# контейнер — никакого мусора между прогонами.
docker create --name blockchain \
dicoop/blockchain:v5.1.0-dev \
/bin/bash -c '/usr/local/bin/nodeos -d /mnt/dev/data -p eosio --config-dir /mnt/dev/config --genesis-json /mnt/dev/config/genesis.json --disable-replay-opts'
/bin/bash -c 'mkdir -p /mnt/data && exec /usr/local/bin/nodeos -d /mnt/data -p eosio --config-dir /mnt/cfg --genesis-json /mnt/cfg/genesis.json --disable-replay-opts'
docker cp "${GITHUB_WORKSPACE}/packages/parser2/test/integration/node-config" blockchain:/mnt/cfg
# Сеть в DinD: порт-маппинг -p публикует на ХОСТЕ, а шаги job'а крутятся
# внутри job-контейнера — его localhost хостовых портов не видит (поэтому
# localhost:8888 был недостижим). redis-сервис доступен по localhost лишь
# потому, что его сеть настраивает сам Gitea. Свою ноду подключаем в ту же
# сеть, что и job-контейнер, и адресуем по её IP на этой сети (надёжнее
# DNS — работает и на bridge); IP пробрасываем в env последующих шагов.
SELF=$(cat /etc/hostname)
NET=$(docker inspect "$SELF" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' | grep -v '^bridge$' | head -1)
[ -z "$NET" ] && NET=bridge
docker network connect "$NET" blockchain
docker start blockchain
NODE_HOST=$(docker inspect blockchain --format "{{(index .NetworkSettings.Networks \"$NET\").IPAddress}}")
echo "Нода в сети '$NET' с IP $NODE_HOST"
echo "NODE_HOST=$NODE_HOST" >> "$GITHUB_ENV"
sleep 3
- name: Wait for Chain API
run: |
for i in $(seq 1 60); do
if curl -sf http://localhost:8888/v1/chain/get_info > /dev/null 2>&1; then
# Бюджет 120 попыток × (≤2с curl + 2с sleep) ≈ до 8 мин: на нагруженном
# раннере nodeos поднимается медленно (наблюдали >6 мин до get_info), а
# curl без --max-time подвисал на ещё-не-отвечающем порту и съедал бюджет.
for i in $(seq 1 120); do
if curl -sf --max-time 2 "http://${NODE_HOST}:8888/v1/chain/get_info" > /dev/null 2>&1; then
echo "Chain API ready after $i attempts"
exit 0
fi
echo " attempt $i/60..."
sleep 2
echo " attempt $i/120..."
sleep 4
done
echo "ERROR: Chain API did not become ready. Container logs:"
docker logs blockchain | tail -100
exit 1
- run: pnpm --filter @coopenomics/parser2 test:integration
env:
REDIS_URL: redis://localhost:6379
CHAIN_URL: http://localhost:8888
SHIP_URL: ws://localhost:8080
# В Gitea/act job крутится в контейнере, а services: — контейнеры-соседи
# в той же сети, доступные ПО ИМЕНИ СЕРВИСА (redis), а не через localhost
# (localhost сработал бы на GitHub-хостед раннере, но не в DinD).
REDIS_URL: redis://redis:6379
CHAIN_URL: http://${{ env.NODE_HOST }}:8888
SHIP_URL: ws://${{ env.NODE_HOST }}:8080
- name: Show blockchain logs on failure
if: failure()
run: docker logs blockchain | tail -200
+22
View File
@@ -240,6 +240,28 @@ docker run --rm --network host \
- [Redis key taxonomy](docs/redis-key-taxonomy.md)
- [Disaster recovery / fork scenarios](docs/disaster-recovery.md)
## Известные ограничения
### Схлопывание дельт create+remove внутри одного блока
Парсер **не увидит** жизненный цикл строки таблицы, у которой `emplace` и `erase` произошли **внутри одного блока** (даже в разных транзакциях). Это поведение унаследовано от upstream-плагина `state_history_plugin` (SHiP) ноджеса: на уровне `chainbase::undo_index::on_remove` строка, созданная в текущей undo session и тут же удалённая, физически уничтожается без записи в `_removed_values` — это by-design оптимизация для отката блока (нечего откатывать), но она же делает event невидимым для SHiP-снимка состояния. SHiP пакует только то, что есть в undo session, и не имеет шанса увидеть транзиентную пару.
**Симптомы:**
- На цепочке таблица пустая (получена и тут же удалена).
- ACTION-логи и `getclearance`/`apprvappndx`-подобные действия видны нормально — экшены идут параллельным потоком и не зависят от undo.
- В Redis stream / БД-индексе нет ни `present:true`, ни `present:false` события для конкретного `primary_key`.
**Когда это всплывает на практике:**
- Контракт делает `emplace + erase` одной сущности в одной транзакции (anti-pattern, но встречается).
- Несколько коротких транзакций над одной сущностью пакуются производителем блоков в один блок (массовый импорт, auto-approve, bulk-операции, миграции, batch-сценарии).
**Workaround на стороне приложения:**
- Разнести `emplace` и `erase` по разным блокам — в seed/bulk-сценариях добавить sleep ≥ 1 × `block_time` (≈500 мс для EOSIO) между транзакциями. В production-flow ручного approve'а это не нужно — между нажатием кнопки и второй транзакцией всегда проходит более одного блока.
- Если транзиентные пары — часть нормального дизайна контракта, рассмотреть переписывание на `modify(status="approved")` вместо `erase`: SHiP корректно эмиттит дельту изменения статуса.
**Системный фикс (планируется):**
Drop-in патч `chainbase` + `state_history_plugin` в нашем форке `~/coopos` (uENOSIO) — отдельный список `_transient_removed_values` в undo_index + паковка пар `[present:true, present:false]` в `table_delta_v0` без изменения wire-формата. Wire-совместимо со всеми существующими SHiP-клиентами; после деплоя форка парсер автоматически начнёт видеть транзиентные пары без изменений в коде. Технические детали и план работ — в задаче `f3-shipchainbase-patch-vidimost-tranzientnykh-delt-createremove-vnutri-bloka.md` проекта parser2 в `_blago`.
## Разработка
### Структура монорепы
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/parser2",
"version": "1.0.3",
"version": "1.1.0",
"description": "Universal EOSIO/Antelope SHiP-to-Redis blockchain indexer (parser)",
"license": "MIT",
"author": "Coopenomics contributors",
@@ -136,13 +136,19 @@ export class BlockProcessor {
block_num: blockNum,
block_time: blockTime,
block_id: blockId,
transaction_id: trace.transactionId,
account: trace.account,
name: trace.name,
authorization: [...trace.authorization],
data,
action_ordinal: trace.actionOrdinal,
creator_action_ordinal: trace.creatorActionOrdinal,
global_sequence: trace.globalSequence,
receipt: trace.receipt,
context_free: trace.contextFree,
elapsed: trace.elapsed,
console: trace.console,
account_ram_deltas: [...trace.accountRamDeltas],
}
actionEvents.push({ ...partial, event_id: computeEventId(partial) })
+13 -1
View File
@@ -8,7 +8,7 @@
* повторные доставки.
*/
import type { ActionAuthorization, ActionReceipt } from '@coopenomics/coopos-ship-reader'
import type { ActionAuthorization, ActionReceipt, AccountRamDelta } from '@coopenomics/coopos-ship-reader'
/** Событие вызова смарт-контракта (inline action). */
export interface ActionEvent {
@@ -20,6 +20,8 @@ export interface ActionEvent {
/** ISO-8601 время блока из трассировки транзакции. */
block_time: string
block_id: string
/** Хеш транзакции, в которой исполнено действие. */
transaction_id: string
/** Аккаунт-владелец контракта (account). */
account: string
/** Имя действия (action name). */
@@ -29,10 +31,20 @@ export interface ActionEvent {
data: Record<string, unknown>
/** Порядковый номер действия внутри транзакции (1-based). */
action_ordinal: number
/** Порядковый номер родительского (создавшего) действия; 0 — если действие верхнего уровня. */
creator_action_ordinal: number
/** Глобальная уникальная последовательность — монотонный счётчик действий в цепи. */
global_sequence: bigint
/** Квитанция об исполнении; null если нет трассировки. */
receipt: ActionReceipt | null
/** Является ли действие context-free. */
context_free: boolean
/** Время исполнения действия в микросекундах. */
elapsed: number
/** Консольный вывод действия (eosio::print и т.п.). */
console: string
/** Изменения RAM по аккаунтам в результате действия. */
account_ram_deltas: AccountRamDelta[]
}
/** Изменение строки в пользовательской таблице смарт-контракта (contract_row delta). */
@@ -57,8 +57,13 @@ function makeBlock(blockNum = 1, numTraces = 0, numDeltas = 0): ShipBlock {
authorization: [{ actor: 'alice', permission: 'active' }],
actRaw: new Uint8Array([1, 2, 3]),
actionOrdinal: i + 1,
creatorActionOrdinal: 0,
globalSequence: BigInt(100 + i),
receipt: null,
contextFree: false,
elapsed: 0,
console: '',
accountRamDeltas: [],
blockNum,
blockId: 'a'.repeat(64),
blockTime: '2024-01-01T00:00:00.000',
@@ -176,8 +181,13 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => {
authorization: [],
actRaw: new Uint8Array([1]),
actionOrdinal: 1,
creatorActionOrdinal: 0,
globalSequence: BigInt(1),
receipt: null,
contextFree: false,
elapsed: 0,
console: '',
accountRamDeltas: [],
blockNum: 500,
blockId: 'c'.repeat(64),
blockTime: '2024-01-01T00:00:00.000',
@@ -210,8 +220,13 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => {
authorization: [],
actRaw: new Uint8Array([1]),
actionOrdinal: 1,
creatorActionOrdinal: 0,
globalSequence: BigInt(2),
receipt: null,
contextFree: false,
elapsed: 0,
console: '',
accountRamDeltas: [],
blockNum: 501,
blockId: 'c'.repeat(64),
blockTime: '2024-01-01T00:00:00.000',
@@ -324,8 +339,8 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
lastIrreversible: { blockNum: 20, blockId: 'b'.repeat(64) },
prevBlock: null,
traces: [
{ account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, globalSequence: BigInt(1), receipt: null, blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) },
{ account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 2, globalSequence: BigInt(2), receipt: null, blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) },
{ account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, creatorActionOrdinal: 0, globalSequence: BigInt(1), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) },
{ account: 'eosio.token', name: 'transfer', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 2, creatorActionOrdinal: 0, globalSequence: BigInt(2), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 20, blockId: 'b'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'c'.repeat(64) },
],
deltas: [
{ name: 'contract_row', present: true, rowRaw: new Uint8Array([1]), code: 'eosio.token', scope: 'alice', table: 'accounts', primaryKey: '1' },
@@ -355,7 +370,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
head: { blockNum: 5, blockId: 'd'.repeat(64) },
lastIrreversible: { blockNum: 5, blockId: 'd'.repeat(64) },
prevBlock: null,
traces: [{ account: 'eosio', name: 'newaccount', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, globalSequence: BigInt(10), receipt: null, blockNum: 5, blockId: 'd'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'e'.repeat(64) }],
traces: [{ account: 'eosio', name: 'newaccount', authorization: [], actRaw: new Uint8Array([1]), actionOrdinal: 1, creatorActionOrdinal: 0, globalSequence: BigInt(10), receipt: null, contextFree: false, elapsed: 0, console: '', accountRamDeltas: [], blockNum: 5, blockId: 'd'.repeat(64), blockTime: '2024-01-01T00:00:00.000', transactionId: 'e'.repeat(64) }],
deltas: [
{ name: 'contract_row', present: true, rowRaw: new Uint8Array([1]), code: 'eosio', scope: 'eosio', table: 'global', primaryKey: '0' },
{ name: 'permission' as never, present: true, rowRaw: new Uint8Array([1]) },
@@ -15,13 +15,19 @@ function makeAction(override: Partial<WithoutId<Extract<ParserEvent, { kind: 'ac
block_num: 100,
block_time: '2024-01-01T00:00:00.000',
block_id: BLOCK_ID,
transaction_id: 'tx0000000000000000000000000000000000000000000000000000000000000000',
account: 'eosio.token',
name: 'transfer',
authorization: [],
data: {},
action_ordinal: 1,
creator_action_ordinal: 0,
global_sequence: 12345n,
receipt: null,
context_free: false,
elapsed: 0,
console: '',
account_ram_deltas: [],
...override,
}
}
@@ -10,13 +10,19 @@ const actionEvent: ParserEvent = {
kind: 'action',
event_id: 'id1',
...BLOCK,
transaction_id: 'tx0000000000000000000000000000000000000000000000000000000000000000',
account: 'eosio',
name: 'updateauth',
authorization: [],
data: { from: 'alice', permission: 'active' },
action_ordinal: 1,
creator_action_ordinal: 0,
global_sequence: 100n,
receipt: null,
context_free: false,
elapsed: 0,
console: '',
account_ram_deltas: [],
}
const deltaEvent: ParserEvent = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/coopos-ship-reader",
"version": "0.1.0",
"version": "0.2.0",
"description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains",
"license": "MIT",
"author": "Coopenomics contributors",
+26 -2
View File
@@ -1,6 +1,6 @@
import { ABI, Serializer, Bytes } from '@wharfkit/antelope'
import { ShipProtocolError } from './errors.js'
import type { BlockPosition, ShipBlock, ShipTrace, ShipDelta, ActionReceipt, ActionAuthorization } from './types/ship.js'
import type { BlockPosition, ShipBlock, ShipTrace, ShipDelta, ActionReceipt, ActionAuthorization, AuthSequence, AccountRamDelta } from './types/ship.js'
export type ShipAbi = ABI
@@ -61,7 +61,12 @@ interface RawActionTrace {
}
receipt: RawActionReceipt | null
action_ordinal: number
global_sequence: string
creator_action_ordinal?: number
global_sequence?: string
context_free?: boolean
elapsed?: string | number
console?: string
account_ram_deltas?: Array<{ account: string; delta: string | number }>
}
interface RawActionReceipt {
@@ -69,6 +74,9 @@ interface RawActionReceipt {
act_digest: string
global_sequence: string
recv_sequence: string
// auth_sequence — массив пар (account, sequence). wharfkit может отдать как
// объект {account, sequence} или как кортеж [account, sequence].
auth_sequence?: Array<{ account: string; sequence: string | number } | [string, string | number]>
code_sequence: number
abi_sequence: number
}
@@ -142,17 +150,28 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number,
const receiptData = Array.isArray(at.receipt)
? (at.receipt[1] as RawActionTrace['receipt'])
: at.receipt
const authSequence: AuthSequence[] = (receiptData?.auth_sequence ?? []).map(pair =>
Array.isArray(pair)
? { account: String(pair[0]), sequence: String(pair[1]) }
: { account: String(pair.account), sequence: String(pair.sequence) },
)
const receipt: ActionReceipt | null = receiptData
? ({
receiver: String(receiptData.receiver),
actDigest: String(receiptData.act_digest),
globalSequence: BigInt(receiptData.global_sequence),
recvSequence: BigInt(receiptData.recv_sequence),
authSequence,
codeSequence: receiptData.code_sequence,
abiSequence: receiptData.abi_sequence,
} as ActionReceipt)
: null
const accountRamDeltas: AccountRamDelta[] = (at.account_ram_deltas ?? []).map(d => ({
account: String(d.account),
delta: Number(d.delta),
}))
// action_trace_v1 не имеет top-level global_sequence (только в receipt).
// Fallback: если at.global_sequence undefined — берём из receipt.
const topGlobalSeq = at.global_sequence ?? receiptData?.global_sequence
@@ -162,8 +181,13 @@ export function decodeBlocksResult(raw: unknown, abi: ShipAbi, blockNum: number,
authorization,
actRaw: at.act.data instanceof Bytes ? at.act.data.array : Uint8Array.from([]),
actionOrdinal: at.action_ordinal,
creatorActionOrdinal: at.creator_action_ordinal ?? 0,
globalSequence: topGlobalSeq !== undefined ? BigInt(topGlobalSeq) : 0n,
receipt,
contextFree: at.context_free ?? false,
elapsed: at.elapsed !== undefined ? Number(at.elapsed) : 0,
console: at.console ?? '',
accountRamDeltas,
blockNum: thisBlock.blockNum,
blockId: thisBlock.blockId,
blockTime,
+2
View File
@@ -23,6 +23,8 @@ export type {
BlockPosition,
ActionReceipt,
ActionAuthorization,
AuthSequence,
AccountRamDelta,
} from './types/ship.js'
export type {
+16
View File
@@ -8,11 +8,22 @@ export interface ActionAuthorization {
readonly permission: string
}
export interface AuthSequence {
readonly account: string
readonly sequence: string
}
export interface AccountRamDelta {
readonly account: string
readonly delta: number
}
export interface ActionReceipt {
readonly receiver: string
readonly actDigest: string
readonly globalSequence: bigint
readonly recvSequence: bigint
readonly authSequence: readonly AuthSequence[]
readonly codeSequence: number
readonly abiSequence: number
}
@@ -23,8 +34,13 @@ export interface ShipTrace {
readonly authorization: readonly ActionAuthorization[]
readonly actRaw: Uint8Array
readonly actionOrdinal: number
readonly creatorActionOrdinal: number
readonly globalSequence: bigint
readonly receipt: ActionReceipt | null
readonly contextFree: boolean
readonly elapsed: number
readonly console: string
readonly accountRamDeltas: readonly AccountRamDelta[]
readonly blockNum: number
readonly blockId: string
readonly blockTime: string