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>
Хотфикс release.yml: NPM auth через NODE_AUTH_TOKEN=NPM_TOKEN, снят provenance и id-token: write — Gitea Actions не выдаёт OIDC id-token, поэтому trusted publishing/provenance работать не могут.
Co-Authored-By: Claude Opus 4.7 <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>