ReconnectSupervisor был написан, протестирован и экспортнут, но НИГДЕ не
подключён к циклу чтения. Parser.start() имел голый for-await без try/catch:
падение ноды → ShipConnectionError наверх → темп переподключений целиком
зависел от внешнего супервизора процесса. Мёртвая нода = рестарт без пауз =
долбёжка ноды без ограничителей.
Что сделано:
- Цикл чтения вынесен в runStreamLoop (core/streamLoop.ts) с инъекцией
зависимостей — чтобы покрыть reconnect/resume юнит-тестами на фейках, без
реального Redis и SHiP.
- runStreamLoop оборачивает (пере)подключение в ReconnectSupervisor:
backoff 1→2→5→15→60с, после maxAttempts без прогресса — process.exit(1)
(оркестратор перезапустит штатно).
- Позиция возобновления перечитывается из Redis на КАЖДОМ подключении
(sync-hash пишется после каждого блока) — продолжаем ровно с последнего
записанного блока, без потерь и дублей.
- ReconnectSupervisor.run теперь передаёт fn callback resetBackoff: каждый
обработанный блок сбрасывает счётчик попыток, чтобы долгая стабильная сессия
после редкого разрыва не копила attempt до выхода.
- Штатная остановка (stop закрывает ws) ловится и не вызывает лишний backoff.
- Parser получил структурный логгер (opts.logger) для onAttempt/onGiveUp.
Тесты: +4 runStreamLoop (reconnect+resume, stop без reconnect, give-up на
мёртвой ноде, resetBackoff на прогрессе), +4 ReconnectSupervisor (resetBackoff).
Всего 229 unit. README: секция «Авто-переподключение к SHiP».
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Проблема: на длинном репарсинге (genesis→head) Parser XADD'ит события в Redis
быстрее, чем consumer (controller) их вычитывает. RAM парсера ограничена
SHiP-окном (max_messages_in_flight), а Redis-стрим — нет → растёт неограниченно
в памяти Redis → OOM. XtrimSupervisor не режет непрочитанное.
Решение: BackpressureGate. Перед обработкой каждого блока Parser проверяет
backlog = XLEN стрима. Если backlog >= highWater — чтение SHiP встаёт на паузу
(блок не ack'ается, SHiP сам тормозит после in-flight окна), ждём пока consumer
сольёт backlog <= lowWater, затем продолжаем. Ничего не теряется — события
остаются в стриме. Нет consumer → стрим дорастает до highWater и writer замирает.
Метрика backlog = XLEN: ровно та память Redis, что бережём; не зависит от числа
групп и версии Redis.
Конфиг backpressure: { enabled (default true), highWater (100k), lowWater (50k),
pollMs (200) }. По умолчанию включено — защита репарсинга из коробки.
Также: добавлен del в IRedisClient (латентный type-error в xtrim-интеграционном
тесте из прошлого коммита — tsc по тестам не гонялся в release-пуше).
Проверено на реальном Redis (integration-тест в CI): пауза при backlog>=highWater,
возобновление после слива. Unit: 221 зелёных, typecheck чистый. parser2 1.1.3→1.2.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Баг #3: consumer падал навсегда с TypeError "Cannot read properties of null
(reading 'length')" и переставал читать стрим. Два корня:
1. XtrimSupervisor.trim() брал MINID = lastDeliveredId. Но un-acked pending
записи старше lastDeliveredId (доставлены, не подтверждены) → XTRIM сносил
собственные pending группы. Теперь нижняя граница trim — самый старый
un-acked ID из XPENDING (новый метод RedisStore.xpendingMinId), а сравнение
stream-ID числовое по <ms>-<seq>, не лексикографическое ('1000' < '999').
2. parseStreamEntries падал на null rawFields: при перечитывании PEL
(XREADGROUP ... 0) Redis отдаёт [id, null] для обрезанных ID. Добавлен
гард `if (!rawFields) continue` — обрезанные записи пропускаются.
Триггер: consumer отстаёт от писателя (бутстрап с genesis) → растёт pending →
XtrimSupervisor сносит pending → PEL отдаёт null → краш в бесконечном цикле.
Воспроизведено и проверено на реальном Redis (новый integration-тест
xtrim.integration.test.ts, гоняется в CI): trim сохраняет все un-acked pending,
PEL-перечитка не падает на null. Unit: 211 зелёных.
parser2 1.1.2→1.1.3. ship-reader без изменений.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
block_time во всех событиях равнялся времени запуска парсера (new Date()),
одинаковому для всех блоков — point-in-time запросы («был ли ключ активен в
момент T») были невозможны.
ship-reader: decodeBlocksResult теперь декодит r.block (fetch_block:true уже
запрашивал, но байты игнорились) как block_header и берёт timestamp; ShipBlock
получает блок-уровневое поле blockTime; убран new Date()-сид в BlockStream.
parser2: BlockProcessor берёт block.blockTime — общий для всех событий блока,
включая trace-less genesis; фейковый new Date()-фолбэк убран, пустое время
логируется warn'ом.
ship-reader 0.3.0→0.3.1, parser2 1.1.1→1.1.2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deserializeNativeDelta делал JSON.parse(rowRaw), но rowRaw нативных таблиц —
это ABI-сериализованные байты state_history, а не JSON. Каждая native-строка
всегда падала на десериализации, а BlockProcessor глотал ошибку в catch {} →
поток native-delta событий был всегда пустой (permission/account/resource_*).
Фикс:
- WharfkitDeserializer.deserializeNativeDelta декодит через Serializer.decode
с ship-ABI (type = имя таблицы, это variant *_v0) и распаковывает variant
[typeName, row] → плоскую строку.
- Deserializer/streamNativeDeltas/ShipClient прокидывают ship-ABI до метода
(ShipClient.abi getter); адаптер parser2 передаёт this.client.abi.
- BlockProcessor больше не глушит ошибку молча — warn с block_num/table/err.
- Тесты native-rows переписаны под ABI-байты + регресс-гард «JSON теперь бросает».
Проверено на live mono-ai-1: 30/30 native-дельт декодятся (было 0/90).
ship-reader 0.2.0→0.3.0 (breaking: +abi в Deserializer/streamNativeDeltas),
parser2 1.1.0→1.1.1 (багфикс, публичный API не менялся).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Прогон на push в dev/main дублировал проверку, уже сделанную на PR, и жёг
по ~47 мин на нагруженном раннере. Оставляем только pull_request (main, dev):
ветки в эти ветки попадают через PR, который и валидируется; релиз main
триггерит отдельный release.yml.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Конфиг ноды через docker cp, нода в сети job-контейнера по IP, Redis по имени сервиса. Полный конвейер зелёный (integration 1 passed против живой ноды + SHiP + Redis).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Раннер — docker-in-docker (docker.sock хоста в job-контейнере), поэтому
пути bind-mount (-v) резолвятся на ФС хоста: ни mktemp, ни workspace демону
не видны → монтировался пустой каталог, nodeos падал с 'genesis file does
not exist'. Конфиг теперь кладём в контейнер через docker cp (tar по API,
от путей хоста не зависит), config-dir=/mnt/cfg (docker cp не создаёт
родителей, а /mnt/dev/config в образе нет). Проверено локально: chain API
get_info + SHiP 101 Switching Protocols.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Прогон 182 упал на стадии cache-restore/install (инфра, не код):
typecheck локально зелёный, паритет пакетов с dev подтверждён.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- tsup.config.ts: добавлен 3-й entry для src/workers/deserialize.worker.ts (CJS) → dist/deserialize.worker.cjs
- tsup: main index и CLI переведены на ESM-only (TLA в ioredis/piscina импортах несовместим с CJS)
- package.json exports/main/bin: указывают на .js (ESM) вместо .cjs
- WorkerPool.resolveWorkerPath(): ищет worker в dist/ относительно src/ при запуске из источников (нужно для integration тестов)
- ci.yml: добавлен step build @coopenomics/parser перед integration test — worker .cjs должен существовать
- добавлен node-config/config.ini (SHiP на 8080) + genesis.json
- services: blockchain заменён на docker run с монтированием config-dir и командой /usr/local/bin/nodeos
- добавлен вывод docker logs blockchain при failure для диагностики
- test/integration/parser.integration.test.ts: beforeAll поднимает блокчейн (preactivate → eosio.boot → features → eosio.token), стартует Parser + ParserClient, тест пушит transfer и ждёт событие
- test/integration/helpers/chain.ts: ChainHelper для деплоя контрактов и push action через @wharfkit/antelope + /v1/chain/abi_json_to_bin
- test/integration/contracts/: скомпилированные eosio.boot.wasm и eosio.token.wasm из boot/contracts/build
- ci.yml: добавлен service dicoop/blockchain:v5.1.0-dev (порты 8888+8080), health-check, CHAIN_URL/SHIP_URL env vars
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- удалён @eosrio/node-abieos из ship-reader/package.json: нативный модуль исключался на CI из-за несовместимости платформы, не попадал в lockfile и ломал --frozen-lockfile
- AbieosDeserializer теперь пробует @coopenomics/abieos, затем @eosrio/node-abieos (оба опциональны, устанавливаются вручную)
- @types/node исправлен с ^3.4.0 на ^20.3.0 (корректная версия)
- pnpm/action-setup обновлён с v3 на v4 во всех jobs (Node 20 deprecation)
- pnpm-lock.yaml регенерирован без abieos specifier
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>