Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2218c7c1b7 | |||
| 92811c258f | |||
| 3bf7cf9c7a | |||
| be67debf51 | |||
| 3dbd2c9116 | |||
| 87af7a89d2 | |||
| 19a905d54b | |||
| c8b7107c3a | |||
| 71f7a22436 | |||
| 5319209cb2 | |||
| 30475c055f | |||
| 39eabb737b | |||
| d71a2d696d | |||
| f6360132f0 | |||
| 16bd87cca1 | |||
| 677dc35bde | |||
| 05d413b37f | |||
| 37efb4834e | |||
| d575da529b | |||
| 9f21c61a75 | |||
| fce498ce45 | |||
| 0c3c3bc002 | |||
| a1f72d3352 | |||
| ecc6a25734 | |||
| a464cdbd2d | |||
| 167c885a70 | |||
| aff8a2451d |
+50
-18
@@ -1,8 +1,6 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
|
||||
@@ -36,7 +34,7 @@ jobs:
|
||||
with: { node-version: '20', cache: 'pnpm' }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
- run: pnpm --filter @coopenomics/parser typecheck
|
||||
- run: pnpm --filter @coopenomics/parser2 typecheck
|
||||
|
||||
unit:
|
||||
name: Unit tests
|
||||
@@ -50,7 +48,7 @@ jobs:
|
||||
with: { node-version: '20', cache: 'pnpm' }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
- run: pnpm --filter @coopenomics/parser test:unit -- --reporter=verbose --coverage
|
||||
- run: pnpm --filter @coopenomics/parser2 test:unit -- --reporter=verbose --coverage
|
||||
- uses: codecov/codecov-action@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -79,36 +77,70 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
# Parser2 build: нужен dist/deserialize.worker.cjs для Piscina worker pool
|
||||
- run: pnpm --filter @coopenomics/parser build
|
||||
- run: pnpm --filter @coopenomics/parser2 build
|
||||
# Запускаем nodeos вручную через docker run: образ dicoop/blockchain:v5.1.0-dev
|
||||
# не имеет CMD по умолчанию, и GitHub Actions services: не поддерживает
|
||||
# передачу команды контейнеру. Монтируем 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/parser/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/parser test:integration
|
||||
- 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
|
||||
@@ -125,4 +157,4 @@ jobs:
|
||||
with: { node-version: '20', cache: 'pnpm' }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
- run: pnpm --filter @coopenomics/parser build
|
||||
- run: pnpm --filter @coopenomics/parser2 build
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
name: Release
|
||||
|
||||
# Запускается при каждом push в main (обычно — merge PR из dev).
|
||||
# Если версия в packages/parser/package.json изменилась (не совпадает с
|
||||
# Если версия в packages/parser2/package.json изменилась (не совпадает с
|
||||
# существующим git-тегом v<version>) — тогда:
|
||||
# 1. Публикуем workspace-пакеты в npm
|
||||
# 2. Создаём git-тег v<version> и пушим его
|
||||
# 3. Создаём GitHub Release с changelog'ом (changelogithub)
|
||||
# 4. Собираем и пушим Docker-образ в docker.io (Docker Hub)
|
||||
# 3. Создаём Gitea Release с changelog'ом через Gitea API
|
||||
# Если версия не менялась — workflow no-op (без ошибки).
|
||||
#
|
||||
# Требуемые secrets в настройках репозитория:
|
||||
# DOCKERHUB_USERNAME — Docker Hub логин с доступом к организации dicoop
|
||||
# DOCKERHUB_TOKEN — PAT с правами Read,Write из hub.docker.com/settings/security
|
||||
# GITHUB_TOKEN — выдаётся автоматически GitHub Actions
|
||||
#
|
||||
# npm publish работает через trusted publishing (OIDC) — токен не нужен:
|
||||
# настраивается на стороне npm через Trusted Publisher: GitHub Actions.
|
||||
# NPM_TOKEN — npm access token с правами publish на @coopenomics/*
|
||||
# (Gitea Actions не выдаёт OIDC id-token, поэтому npm
|
||||
# trusted publishing/provenance здесь недоступны —
|
||||
# используем классический auth через NODE_AUTH_TOKEN)
|
||||
# GITHUB_TOKEN — выдаётся автоматически Gitea Actions; используется для
|
||||
# создания релиза через ${GITHUB_SERVER_URL}/api/v1/...
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -26,8 +25,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write # tag + GitHub Release
|
||||
id-token: write # npm trusted publishing (OIDC) + sigstore provenance
|
||||
contents: write # tag + Gitea Release
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -39,7 +37,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # нужно для git tag проверки и changelogithub
|
||||
fetch-depth: 0 # нужно для git tag проверки и git log changelog
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: '10' }
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -50,12 +48,12 @@ jobs:
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# Читаем версию из packages/parser/package.json и проверяем
|
||||
# Читаем версию из packages/parser2/package.json и проверяем
|
||||
# что тега v<version> ещё не существует.
|
||||
- name: Check version change
|
||||
id: ver
|
||||
run: |
|
||||
VERSION=$(node -p "require('./packages/parser/package.json').version")
|
||||
VERSION=$(node -p "require('./packages/parser2/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
|
||||
echo "Tag v$VERSION already exists — nothing to release"
|
||||
@@ -69,21 +67,17 @@ jobs:
|
||||
if: steps.ver.outputs.released == 'true'
|
||||
run: |
|
||||
pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
pnpm --filter @coopenomics/parser build
|
||||
pnpm --filter @coopenomics/parser2 build
|
||||
|
||||
# Настраиваем npm auth для lerna/pnpm: NODE_AUTH_TOKEN из setup-node
|
||||
# уже прописывает _authToken в .npmrc, но lerna внутри читает из npm cli,
|
||||
# поэтому дублируем через env NPM_TOKEN → npm_config_//registry.npmjs.org/:_authToken.
|
||||
# npm trusted publishing: OIDC через id-token: write, без NPM_TOKEN.
|
||||
# Настраивается на стороне npm: packages/coopenomics-parser/settings/access
|
||||
# → Trusted Publisher → GitHub Actions (repo=coopenomics/parser,
|
||||
# workflow=.github/workflows/release.yml, environment="").
|
||||
# Provenance включён — sigstore подпишет каждый publish.
|
||||
# Auth через NODE_AUTH_TOKEN: actions/setup-node по registry-url прописал
|
||||
# //registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN} в ~/.npmrc, npm/lerna
|
||||
# читают токен из env. Provenance не включаем — требует OIDC id-token,
|
||||
# которого Gitea Actions не выдаёт (Trusted Publisher тоже не сработает).
|
||||
- name: Publish via Lerna (from-package)
|
||||
if: steps.ver.outputs.released == 'true'
|
||||
run: pnpm exec lerna publish from-package --yes --no-private
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Tag release
|
||||
@@ -94,52 +88,29 @@ jobs:
|
||||
git tag "v${{ steps.ver.outputs.version }}"
|
||||
git push origin "v${{ steps.ver.outputs.version }}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
# changelogithub знает только github.com и падает на git.coopenomics.world
|
||||
# ("Can not parse GitHub repo from url ..."). Создаём релиз напрямую через
|
||||
# Gitea REST API: GITHUB_SERVER_URL и GITHUB_REPOSITORY проставляются
|
||||
# раннером, GITHUB_TOKEN имеет права contents:write на тот же репо.
|
||||
# Changelog — git log между предыдущим v-тегом и текущим.
|
||||
- name: Create Gitea Release
|
||||
if: steps.ver.outputs.released == 'true'
|
||||
run: npx changelogithub
|
||||
run: |
|
||||
VERSION="${{ steps.ver.outputs.version }}"
|
||||
PREV=$(git tag --list 'v*' --sort=-v:refname | grep -vx "v${VERSION}" | head -1)
|
||||
if [ -n "$PREV" ]; then
|
||||
CHANGELOG=$(git log "${PREV}..v${VERSION}" --pretty=format:'- %s')
|
||||
else
|
||||
CHANGELOG=$(git log "v${VERSION}" --pretty=format:'- %s')
|
||||
fi
|
||||
BODY=$(jq -n \
|
||||
--arg tag "v${VERSION}" \
|
||||
--arg body "$CHANGELOG" \
|
||||
'{tag_name:$tag, name:$tag, body:$body, draft:false, prerelease:false}')
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY" \
|
||||
"${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
docker:
|
||||
name: Build & push Docker image
|
||||
runs-on: ubuntu-22.04
|
||||
needs: release
|
||||
if: needs.release.outputs.released == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: v${{ needs.release.outputs.version }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: '10' }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '20', cache: 'pnpm' }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --filter @coopenomics/coopos-ship-reader build
|
||||
- run: pnpm --filter @coopenomics/parser build
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
# Docker Hub auth: требует secrets DOCKERHUB_USERNAME + DOCKERHUB_TOKEN
|
||||
# (Access Token из hub.docker.com/settings/security с правами Read,Write)
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: dicoop/parser
|
||||
tags: |
|
||||
type=semver,pattern={{version}},value=v${{ needs.release.outputs.version }}
|
||||
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.release.outputs.version }}
|
||||
type=raw,value=latest
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@ RUN corepack enable && corepack prepare pnpm@10 --activate
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY packages/parser/package.json ./packages/parser/
|
||||
COPY packages/parser2/package.json ./packages/parser2/
|
||||
COPY packages/ship-reader/package.json ./packages/ship-reader/
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
@@ -17,12 +17,12 @@ WORKDIR /app
|
||||
RUN addgroup -g 1001 -S parser && adduser -u 1001 -S parser -G parser
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/packages/parser/node_modules ./packages/parser/node_modules
|
||||
COPY --from=deps /app/packages/parser2/node_modules ./packages/parser2/node_modules
|
||||
COPY --from=deps /app/packages/ship-reader/node_modules ./packages/ship-reader/node_modules
|
||||
|
||||
COPY packages/parser/dist ./packages/parser/dist
|
||||
COPY packages/parser2/dist ./packages/parser2/dist
|
||||
COPY packages/ship-reader/dist ./packages/ship-reader/dist
|
||||
COPY packages/parser/package.json packages/parser/LICENSE packages/parser/NOTICE ./packages/parser/
|
||||
COPY packages/parser2/package.json packages/parser2/LICENSE packages/parser2/NOTICE ./packages/parser2/
|
||||
COPY packages/ship-reader/package.json packages/ship-reader/LICENSE packages/ship-reader/NOTICE ./packages/ship-reader/
|
||||
COPY package.json pnpm-workspace.yaml README.md ./
|
||||
|
||||
@@ -31,5 +31,5 @@ USER parser
|
||||
ENV NODE_ENV=production
|
||||
ENV LOG_LEVEL=info
|
||||
|
||||
ENTRYPOINT ["node", "packages/parser/dist/cli/index.js"]
|
||||
ENTRYPOINT ["node", "packages/parser2/dist/cli/index.js"]
|
||||
CMD ["--help"]
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Универсальный индексер блокчейнов EOSIO / Antelope: читает блоки из State History Plugin (SHiP) по WebSocket, декодирует actions и дельты таблиц с учётом исторических ABI, публикует унифицированный поток событий в Redis Stream. Потребители получают события через `ParserClient` с single-active-consumer lock'ом, recovery после сбоев и dead-letter для poison-messages.
|
||||
|
||||
[](https://github.com/coopenomics/parser/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@coopenomics/parser)
|
||||
[](https://github.com/coopenomics/parser2/actions/workflows/ci.yml)
|
||||
[](https://www.npmjs.com/package/@coopenomics/parser2)
|
||||
[](LICENSE)
|
||||
|
||||
## Зачем
|
||||
@@ -14,7 +14,7 @@ SHiP отдаёт сырые бинарные блоки — чтобы прев
|
||||
|
||||
| Пакет | Описание | npm |
|
||||
|:---|:---|:---|
|
||||
| [`@coopenomics/parser`](packages/parser) | Ядро индексера (Parser) + подписочный клиент (ParserClient), CLI, observability | [`@coopenomics/parser`](https://www.npmjs.com/package/@coopenomics/parser) |
|
||||
| [`@coopenomics/parser2`](packages/parser2) | Ядро индексера (Parser) + подписочный клиент (ParserClient), CLI, observability | [`@coopenomics/parser2`](https://www.npmjs.com/package/@coopenomics/parser2) |
|
||||
| [`@coopenomics/coopos-ship-reader`](packages/ship-reader) | Низкоуровневый WebSocket SHiP клиент с поддержкой 24 нативных системных таблиц | [`@coopenomics/coopos-ship-reader`](https://www.npmjs.com/package/@coopenomics/coopos-ship-reader) |
|
||||
|
||||
## Быстрый старт
|
||||
@@ -22,7 +22,7 @@ SHiP отдаёт сырые бинарные блоки — чтобы прев
|
||||
### 1. Установка
|
||||
|
||||
```bash
|
||||
pnpm add @coopenomics/parser
|
||||
pnpm add @coopenomics/parser2
|
||||
# или если используете только SHiP-клиент без Redis-пайплайна:
|
||||
pnpm add @coopenomics/coopos-ship-reader
|
||||
```
|
||||
@@ -49,6 +49,11 @@ abiFallback: rpc-current
|
||||
xtrim:
|
||||
enabled: true
|
||||
intervalMs: 60000
|
||||
backpressure:
|
||||
enabled: true # пауза чтения SHiP когда стрим переполнен (защита Redis RAM)
|
||||
highWater: 100000 # порог паузы — число событий в стриме
|
||||
lowWater: 50000 # порог возобновления (должен быть < highWater)
|
||||
pollMs: 200 # период опроса XLEN во время паузы, мс
|
||||
logger:
|
||||
level: info
|
||||
pretty: false
|
||||
@@ -66,12 +71,12 @@ metrics:
|
||||
parser start --config parser.config.yaml
|
||||
```
|
||||
|
||||
Парсер подключится к SHiP, начнёт читать блоки с последней checkpoint-позиции (или с head если запускается впервые), и публиковать события в Redis stream `ce:parser:<chainId>:events`.
|
||||
Парсер подключится к SHiP, начнёт читать блоки с последней checkpoint-позиции (или с head если запускается впервые), и публиковать события в Redis stream `ce:parser2:<chainId>:events`.
|
||||
|
||||
### 3. Подписка на события из приложения
|
||||
|
||||
```typescript
|
||||
import { ParserClient } from '@coopenomics/parser'
|
||||
import { ParserClient } from '@coopenomics/parser2'
|
||||
|
||||
const client = new ParserClient({
|
||||
subscriptionId: 'my-app',
|
||||
@@ -116,6 +121,109 @@ parser replay-dead-letter --sub-id my-app --dl-id 1699999999999-0
|
||||
parser abi-prune --keep-last 10 --all-contracts
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Публичный образ: [`dicoop/parser2`](https://hub.docker.com/r/dicoop/parser2) — multi-arch (`linux/amd64`, `linux/arm64`).
|
||||
|
||||
```bash
|
||||
docker pull dicoop/parser2:latest
|
||||
# или конкретная версия
|
||||
docker pull dicoop/parser2:1.0.1
|
||||
```
|
||||
|
||||
### Минимальный запуск
|
||||
|
||||
Парсер читает конфигурацию из YAML-файла, путь передаётся через `--config`:
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
-v $(pwd)/parser.config.yaml:/app/parser.config.yaml:ro \
|
||||
--network host \
|
||||
dicoop/parser2:latest \
|
||||
start --config /app/parser.config.yaml
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
|
||||
Полная конфигурация с Redis:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: >-
|
||||
redis-server
|
||||
--appendonly yes
|
||||
--appendfsync everysec
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
parser:
|
||||
image: dicoop/parser2:1.0.1
|
||||
depends_on:
|
||||
- redis
|
||||
volumes:
|
||||
- ./parser.config.yaml:/app/parser.config.yaml:ro
|
||||
command: ["start", "--config", "/app/parser.config.yaml"]
|
||||
ports:
|
||||
- "8081:8081" # /health
|
||||
- "9090:9090" # /metrics (Prometheus)
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
```
|
||||
|
||||
`parser.config.yaml` рядом с compose-файлом:
|
||||
|
||||
```yaml
|
||||
ship:
|
||||
url: ws://nodeos:8080
|
||||
timeoutMs: 15000
|
||||
chain:
|
||||
url: http://nodeos:8888
|
||||
redis:
|
||||
url: redis://redis:6379
|
||||
abiFallback: rpc-current
|
||||
xtrim:
|
||||
enabled: true
|
||||
intervalMs: 60000
|
||||
backpressure:
|
||||
enabled: true # пауза чтения SHiP когда стрим переполнен (защита Redis RAM)
|
||||
highWater: 100000 # порог паузы — число событий в стриме
|
||||
lowWater: 50000 # порог возобновления (должен быть < highWater)
|
||||
pollMs: 200 # период опроса XLEN во время паузы, мс
|
||||
reconnect:
|
||||
maxAttempts: 10
|
||||
backoffSeconds: [1, 2, 5, 10, 30, 60, 120, 300, 600, 1800]
|
||||
logger:
|
||||
level: info
|
||||
pretty: false # для production — JSON logs в stdout
|
||||
health:
|
||||
enabled: true
|
||||
port: 8081
|
||||
metrics:
|
||||
enabled: true
|
||||
port: 9090
|
||||
irreversibleOnly: false # читать head-блоки (false) или ждать last_irreversible (true)
|
||||
```
|
||||
|
||||
Запуск: `docker compose up -d`. `/health` отдаст `200 OK` как только парсер подключился к SHiP, `/metrics` — экспорт для Prometheus.
|
||||
|
||||
### Переопределение CLI-команды
|
||||
|
||||
Контейнер по умолчанию запускает `parser start`, но можно использовать любую другую команду:
|
||||
|
||||
```bash
|
||||
# Посмотреть подписки в прод-Redis
|
||||
docker run --rm --network host \
|
||||
-e REDIS_URL=redis://localhost:6379 \
|
||||
dicoop/parser2:latest \
|
||||
list-subscriptions
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
@@ -126,7 +234,8 @@ parser abi-prune --keep-last 10 --all-contracts
|
||||
│ • Workers │ │ │
|
||||
│ • ForkDet. │ │ Streams + │
|
||||
│ • XtrimSup. │ │ Hashes + │
|
||||
└──────────────┘ │ ZSets │
|
||||
│ • Backpr. │ │ ZSets │
|
||||
└──────────────┘ │ │
|
||||
│ │
|
||||
┌──────────────┐ │ │
|
||||
│ ParserClient │◀─XREADGR──│ │
|
||||
@@ -142,6 +251,79 @@ parser abi-prune --keep-last 10 --all-contracts
|
||||
- [Redis key taxonomy](docs/redis-key-taxonomy.md)
|
||||
- [Disaster recovery / fork scenarios](docs/disaster-recovery.md)
|
||||
|
||||
## Backpressure: защита памяти Redis на репарсинге
|
||||
|
||||
Parser читает блоки из SHiP и пишет события в Redis-стрим. На длинном репарсинге (genesis → head, миллионы блоков) писатель легко обгоняет потребителя: события копятся в **памяти Redis** быстрее, чем `ParserClient` их вычитывает. RAM самого парсера ограничена окном SHiP (`max_messages_in_flight` + pull-based чтение с ack после обработки), а вот стрим в Redis может расти неограниченно — вплоть до OOM. `XtrimSupervisor` тут не спасает: он по дизайну не режет непрочитанные события.
|
||||
|
||||
`BackpressureGate` вводит **окно поглощения с гистерезисом**. Перед обработкой каждого блока парсер смотрит backlog = `XLEN` стрима:
|
||||
|
||||
- backlog ≥ **highWater** → чтение SHiP встаёт на **паузу** (блок не подтверждается, SHiP сам перестаёт слать после своего in-flight окна);
|
||||
- во время паузы парсер опрашивает `XLEN` каждые `pollMs` и ждёт, пока потребитель сольёт backlog ≤ **lowWater**;
|
||||
- слилось → чтение **возобновляется**.
|
||||
|
||||
Ничего не теряется — события остаются в стриме, парсер лишь перестаёт доливать. Если потребитель вообще не подключён, backlog дорастает до `highWater` и писатель замирает, удерживая Redis в пределах ~`highWater` событий.
|
||||
|
||||
**Почему не дёргается.** Разрыв high/low (по умолчанию 100k / 50k) — это антидребезг (гистерезис). Один цикл «пауза ↔ работа» перемалывает 50k событий, поэтому переключения происходят редко, а не на каждом блоке. В steady-state (потребитель у головы цепи) backlog почти нулевой → пауза не включается вообще, оверхед = один `XLEN` на блок (O(1)).
|
||||
|
||||
```
|
||||
backlog
|
||||
100k | /| /| /| <- highWater -> ПАУЗА
|
||||
| / | / | / |
|
||||
50k | / |______/ |______/ |___ <- lowWater -> ПУСК
|
||||
| / (пауза) (пауза)
|
||||
0 +--+----------------------------> время
|
||||
пишем ждём пишем ждём пишем
|
||||
```
|
||||
|
||||
Конфиг (включён по умолчанию):
|
||||
|
||||
```yaml
|
||||
backpressure:
|
||||
enabled: true # выключить можно enabled: false
|
||||
highWater: 100000 # порог паузы — число событий в стриме
|
||||
lowWater: 50000 # порог возобновления (< highWater)
|
||||
pollMs: 200 # период опроса XLEN во время паузы, мс
|
||||
```
|
||||
|
||||
Рекомендация по инфраструктуре: на Redis выставить `maxmemory` + `maxmemory-policy noeviction` — тогда при достижении лимита `XADD` вернёт ошибку (парсер её обработает), а не сработает OOM-killer.
|
||||
|
||||
## Авто-переподключение к SHiP
|
||||
|
||||
Если SHiP-нода падает или рвёт соединение, парсер **не пытается переподключиться мгновенно в цикле** — это превратило бы упавшую ноду в мишень для долбёжки без пауз. Вместо этого `ReconnectSupervisor` переподключается с **экспоненциальным backoff** (по умолчанию `1 → 2 → 5 → 15 → 60` секунд, дальше держит последнее значение):
|
||||
|
||||
- разрыв соединения → пауза по таблице `backoffSeconds`, затем новая попытка;
|
||||
- успешная попытка перечитывает позицию возобновления из Redis (`sync`-hash пишется после каждого блока) и продолжает **ровно с последнего записанного блока** — без потерь и дублей;
|
||||
- каждый обработанный блок сбрасывает счётчик попыток: долгая стабильная сессия после редкого разрыва не копит попытки;
|
||||
- `maxAttempts` неудач подряд (нода действительно мертва, прогресса нет) → парсер завершается с кодом 1, чтобы оркестратор (Docker/systemd/k8s) перезапустил процесс штатно.
|
||||
|
||||
```yaml
|
||||
reconnect:
|
||||
maxAttempts: 10
|
||||
backoffSeconds: [1, 2, 5, 10, 30, 60, 120, 300, 600, 1800]
|
||||
```
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
### Схлопывание дельт 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`.
|
||||
|
||||
## Разработка
|
||||
|
||||
### Структура монорепы
|
||||
@@ -149,7 +331,7 @@ parser abi-prune --keep-last 10 --all-contracts
|
||||
```
|
||||
.
|
||||
├── packages/
|
||||
│ ├── parser/ # @coopenomics/parser — ядро + CLI + ParserClient
|
||||
│ ├── parser/ # @coopenomics/parser2 — ядро + CLI + ParserClient
|
||||
│ └── ship-reader/ # @coopenomics/coopos-ship-reader — SHiP WS клиент
|
||||
├── docs/ # redis taxonomy, disaster recovery
|
||||
├── examples/ # пример verifier-like подписчика
|
||||
@@ -166,20 +348,20 @@ pnpm install
|
||||
|
||||
```bash
|
||||
pnpm build # build всех пакетов
|
||||
pnpm --filter @coopenomics/parser build # только parser
|
||||
pnpm --filter @coopenomics/parser2 build # только parser
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```bash
|
||||
pnpm test # unit во всех пакетах
|
||||
pnpm --filter @coopenomics/parser test:unit
|
||||
pnpm --filter @coopenomics/parser test:integration # нужен Docker для блокчейн-ноды
|
||||
pnpm --filter @coopenomics/parser2 test:unit
|
||||
pnpm --filter @coopenomics/parser2 test:integration # нужен Docker для блокчейн-ноды
|
||||
```
|
||||
|
||||
**Текущее покрытие:**
|
||||
- `@coopenomics/parser`: 81% statements / 74% functions (205 unit-тестов)
|
||||
- `@coopenomics/coopos-ship-reader`: 81% statements / 86% functions (51 unit-тест)
|
||||
- `@coopenomics/parser2`: 229 unit-тестов + integration (xtrim/backpressure на реальном Redis)
|
||||
- `@coopenomics/coopos-ship-reader`: 54 unit-теста
|
||||
|
||||
### Бенчмарк
|
||||
|
||||
@@ -210,7 +392,7 @@ pnpm release
|
||||
# 5. GitHub Actions release.yml автоматически:
|
||||
# • lerna publish from-package — опубликует в npm только пакеты с новой версией
|
||||
# • changelogithub — создаст GitHub Release с changelog
|
||||
# • docker build-push — опубликует образ в docker.io/dicoop/parser:<version>
|
||||
# • docker build-push — опубликует образ в docker.io/dicoop/parser2:<version>
|
||||
```
|
||||
|
||||
Альтернативно, если lerna version не использовалась — можно вручную поднять версию в `packages/*/package.json`, смёрджить в main, и workflow опубликует.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Disaster Recovery Runbook
|
||||
|
||||
This document covers recovery procedures for common failure scenarios involving `@coopenomics/parser`.
|
||||
This document covers recovery procedures for common failure scenarios involving `@coopenomics/parser2`.
|
||||
|
||||
---
|
||||
|
||||
## Scenario 1 — Redis data loss (RDB/AOF corruption or total loss)
|
||||
|
||||
**Symptoms:** Parser starts but emits no events; `redis-cli HGET parser:sync:<chainId> block_num` returns nil.
|
||||
**Symptoms:** Parser starts but emits no events; `redis-cli HGET parser2:sync:<chainId> block_num` returns nil.
|
||||
|
||||
**Impact:** The sync checkpoint is lost. The parser will restart from block 0 (or the last known position in config). Consumers will re-process old events — their handlers must be idempotent.
|
||||
|
||||
@@ -19,7 +19,7 @@ This document covers recovery procedures for common failure scenarios involving
|
||||
|
||||
2. **Decide start block.** If you know the last safely processed block, set it manually:
|
||||
```bash
|
||||
redis-cli HSET parser:sync:<chainId> block_num <N> block_id <ID> last_updated $(date -u +%FT%TZ)
|
||||
redis-cli HSET parser2:sync:<chainId> block_num <N> block_id <ID> last_updated $(date -u +%FT%TZ)
|
||||
```
|
||||
If unknown, leave empty and the parser will replay from 0.
|
||||
|
||||
@@ -30,7 +30,7 @@ This document covers recovery procedures for common failure scenarios involving
|
||||
|
||||
4. **Restart the parser.**
|
||||
|
||||
5. **Monitor lag** via `/health` endpoint or `parser_indexing_lag_seconds` Prometheus metric.
|
||||
5. **Monitor lag** via `/health` endpoint or `parser2_indexing_lag_seconds` Prometheus metric.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@ Simply restart the parser. The sync hash guarantees replay from the last complet
|
||||
|
||||
## Scenario 3 — Consumer handler failures → dead-letter accumulation
|
||||
|
||||
**Symptoms:** `ce:parser:<chainId>:dead:<subId>` stream grows; `parser_client_dead_letters_total` counter rising.
|
||||
**Symptoms:** `ce:parser2:<chainId>:dead:<subId>` stream grows; `parser2_client_dead_letters_total` counter rising.
|
||||
|
||||
### Recovery steps
|
||||
|
||||
@@ -128,6 +128,6 @@ The parser will automatically re-fetch the ABI from the chain RPC on the next bl
|
||||
|---|---|
|
||||
| Parser lag | `curl http://localhost:9090/health` |
|
||||
| Prometheus metrics | `curl http://localhost:9090/metrics` |
|
||||
| Stream length | `redis-cli XLEN ce:parser:<chainId>:events` |
|
||||
| Sync position | `redis-cli HGETALL parser:sync:<chainId>` |
|
||||
| Stream length | `redis-cli XLEN ce:parser2:<chainId>:events` |
|
||||
| Sync position | `redis-cli HGETALL parser2:sync:<chainId>` |
|
||||
| Dead letters | `parser list-dead-letters --chain <chainId> --all` |
|
||||
|
||||
+11
-11
@@ -1,21 +1,21 @@
|
||||
# Redis Key Taxonomy
|
||||
|
||||
All keys used by `@coopenomics/parser` follow a consistent namespace scheme.
|
||||
Keys produced by the **parser (indexer)** use the prefix `ce:parser:` or `parser:`.
|
||||
All keys used by `@coopenomics/parser2` follow a consistent namespace scheme.
|
||||
Keys produced by the **parser (indexer)** use the prefix `ce:parser2:` or `parser:`.
|
||||
|
||||
## Key catalogue
|
||||
|
||||
| Key pattern | Type | TTL | Produced by | Consumed by | Description |
|
||||
|---|---|---|---|---|---|
|
||||
| `ce:parser:{chainId}:events` | Stream | ∞ (XTRIM) | Parser | ParserClient | Unified event stream: all `action`, `delta`, `native-delta`, `fork` events |
|
||||
| `ce:parser:{chainId}:dead:{subId}` | Stream | ∞ | FailureTracker | CLI / admin | Dead-letter stream for a specific subscription; entries include `data`, `failureCount`, `lastError`, `subId` |
|
||||
| `ce:parser:{chainId}:reparse:{jobId}` | Stream | ∞ | CLI (future) | Parser | On-demand reparse job stream |
|
||||
| `parser:abi:{contract}` | Sorted Set | ∞ | AbiStore | BlockProcessor / AbiBootstrapper | ABI version history; score = block_num, member = base64-encoded raw ABI bytes |
|
||||
| `parser:sync:{chainId}` | Hash | ∞ | Parser | Parser (crash-recovery) | Sync checkpoint: `block_num`, `block_id`, `last_updated` |
|
||||
| `parser:subs` | Hash | ∞ | ParserClient | CLI (list-subscriptions) | Subscription registry; field = subId, value = JSON metadata |
|
||||
| `parser:sub:{subId}:failures` | Hash | 24 h | FailureTracker | FailureTracker / CLI | Per-event failure counter; field = event_id, value = count |
|
||||
| `parser:sub:{subId}:lock` | String | 10 s (auto-renew) | SubscriptionLock | SubscriptionLock | Active-standby lock; value = instanceId |
|
||||
| `parser:reparse:{jobId}` | Hash | ∞ | CLI (future) | Parser | Reparse job metadata |
|
||||
| `ce:parser2:{chainId}:events` | Stream | ∞ (XTRIM) | Parser | ParserClient | Unified event stream: all `action`, `delta`, `native-delta`, `fork` events |
|
||||
| `ce:parser2:{chainId}:dead:{subId}` | Stream | ∞ | FailureTracker | CLI / admin | Dead-letter stream for a specific subscription; entries include `data`, `failureCount`, `lastError`, `subId` |
|
||||
| `ce:parser2:{chainId}:reparse:{jobId}` | Stream | ∞ | CLI (future) | Parser | On-demand reparse job stream |
|
||||
| `parser2:abi:{contract}` | Sorted Set | ∞ | AbiStore | BlockProcessor / AbiBootstrapper | ABI version history; score = block_num, member = base64-encoded raw ABI bytes |
|
||||
| `parser2:sync:{chainId}` | Hash | ∞ | Parser | Parser (crash-recovery) | Sync checkpoint: `block_num`, `block_id`, `last_updated` |
|
||||
| `parser2:subs` | Hash | ∞ | ParserClient | CLI (list-subscriptions) | Subscription registry; field = subId, value = JSON metadata |
|
||||
| `parser2:sub:{subId}:failures` | Hash | 24 h | FailureTracker | FailureTracker / CLI | Per-event failure counter; field = event_id, value = count |
|
||||
| `parser2:sub:{subId}:lock` | String | 10 s (auto-renew) | SubscriptionLock | SubscriptionLock | Active-standby lock; value = instanceId |
|
||||
| `parser2:reparse:{jobId}` | Hash | ∞ | CLI (future) | Parser | Reparse job metadata |
|
||||
|
||||
## Naming rules
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Minimal example: Parser (SHiP → Redis indexer) and ParserClient (consumer) run
|
||||
**Step 1 — Clone and install**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/coopenomics/parser.git
|
||||
git clone https://github.com/coopenomics/parser2.git
|
||||
cd parser
|
||||
pnpm install
|
||||
```
|
||||
@@ -22,7 +22,7 @@ pnpm install
|
||||
**Step 2 — Build the package**
|
||||
|
||||
```bash
|
||||
pnpm --filter @coopenomics/parser build
|
||||
pnpm --filter @coopenomics/parser2 build
|
||||
```
|
||||
|
||||
**Step 3 — Configure**
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* See README.md for the 5-step startup guide.
|
||||
*/
|
||||
|
||||
import { Parser, ParserClient } from '@coopenomics/parser'
|
||||
import type { NativeDeltaEvent, NativePermissionRow } from '@coopenomics/parser'
|
||||
import { Parser, ParserClient } from '@coopenomics/parser2'
|
||||
import type { NativeDeltaEvent, NativePermissionRow } from '@coopenomics/parser2'
|
||||
|
||||
// ── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"start": "tsx index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@coopenomics/parser": "workspace:*"
|
||||
"@coopenomics/parser2": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.15.0"
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ReconnectSupervisor } from '../../src/core/ReconnectSupervisor.js'
|
||||
|
||||
describe('ReconnectSupervisor — success on first try', () => {
|
||||
it('returns the result without retrying', async () => {
|
||||
const sup = new ReconnectSupervisor({ backoffSeconds: [0] })
|
||||
const result = await sup.run(async () => 42)
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReconnectSupervisor — retries on failure', () => {
|
||||
it('retries and succeeds on second attempt', async () => {
|
||||
const sup = new ReconnectSupervisor({ maxAttempts: 5, backoffSeconds: [0] })
|
||||
let calls = 0
|
||||
const result = await sup.run(async () => {
|
||||
calls++
|
||||
if (calls < 2) throw new Error('fail')
|
||||
return 'ok'
|
||||
})
|
||||
expect(result).toBe('ok')
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('calls onAttempt with correct attempt number and delay', async () => {
|
||||
const attempts: Array<{ attempt: number; delayMs: number }> = []
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 5,
|
||||
backoffSeconds: [0, 0],
|
||||
onAttempt: (attempt, delayMs) => attempts.push({ attempt, delayMs }),
|
||||
})
|
||||
let calls = 0
|
||||
await sup.run(async () => {
|
||||
calls++
|
||||
if (calls < 3) throw new Error('fail')
|
||||
return 'done'
|
||||
})
|
||||
expect(attempts).toHaveLength(2)
|
||||
expect(attempts[0]?.attempt).toBe(1)
|
||||
expect(attempts[1]?.attempt).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReconnectSupervisor — exhaustion', () => {
|
||||
it('calls onGiveUp and throws after maxAttempts', async () => {
|
||||
let gaveUp = false
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
await expect(
|
||||
sup.run(async () => { throw new Error('always fails') })
|
||||
).rejects.toThrow()
|
||||
expect(gaveUp).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the last backoff value when attempts exceed backoff array length', async () => {
|
||||
const delays: number[] = []
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 5,
|
||||
backoffSeconds: [0, 0],
|
||||
onAttempt: (_, ms) => delays.push(ms),
|
||||
onGiveUp: () => { throw new Error('gave up') },
|
||||
})
|
||||
await expect(
|
||||
sup.run(async () => { throw new Error('always fails') })
|
||||
).rejects.toThrow()
|
||||
// 4 retries (attempts 1..4) — last 2 should clamp to backoff[1]=0
|
||||
expect(delays).toHaveLength(4)
|
||||
delays.forEach(d => expect(d).toBe(0))
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@coopenomics/parser",
|
||||
"version": "1.0.0",
|
||||
"name": "@coopenomics/parser2",
|
||||
"version": "1.3.0",
|
||||
"description": "Universal EOSIO/Antelope SHiP-to-Redis blockchain indexer (parser)",
|
||||
"license": "MIT",
|
||||
"author": "Coopenomics contributors",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/coopenomics/parser"
|
||||
"url": "https://github.com/coopenomics/parser2"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"parser": "./dist/cli/index.js"
|
||||
"parser2": "./dist/cli/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
+23
-2
@@ -37,10 +37,13 @@ interface IRedisClient {
|
||||
count: string, countVal: number,
|
||||
block: string, blockMs: number,
|
||||
streams: string, stream: string, id: string,
|
||||
): Promise<Array<[string, Array<[string, string[]]>]> | null>
|
||||
): Promise<Array<[string, Array<[string, string[] | null]>]> | null>
|
||||
// XPENDING key group (summary): [count, min-id, max-id, [[consumer, count],...]|null]
|
||||
xpending(key: string, group: string): Promise<[number, string | null, string | null, Array<[string, string]> | null] | null>
|
||||
xrange(key: string, start: string, end: string, count: string, countVal: number): Promise<Array<[string, string[]]>>
|
||||
xrevrange(key: string, end: string, start: string, count: string, countVal: number): Promise<Array<[string, string[]]>>
|
||||
xlen(key: string): Promise<number>
|
||||
del(...keys: string[]): Promise<number>
|
||||
xdel(key: string, ...ids: string[]): Promise<number>
|
||||
xack(stream: string, group: string, id: string): Promise<number>
|
||||
zadd(key: string, score: number, member: string): Promise<number>
|
||||
@@ -94,9 +97,14 @@ return 0
|
||||
* Redis возвращает: [[id, [field1, val1, field2, val2, ...]], ...]
|
||||
* Мы конвертируем в: [{id, fields: {field1: val1, ...}}, ...]
|
||||
*/
|
||||
function parseStreamEntries(raw: Array<[string, string[]]>): StreamMessage[] {
|
||||
function parseStreamEntries(raw: Array<[string, string[] | null]>): StreamMessage[] {
|
||||
const messages: StreamMessage[] = []
|
||||
for (const [msgId, rawFields] of raw) {
|
||||
// Обрезанные/удалённые записи: при чтении PEL (XREADGROUP ... 0) Redis
|
||||
// отдаёт [id, null] для ID, которых уже нет в стриме (XTRIM/XDEL их снёс).
|
||||
// rawFields === null → .length бросает TypeError и убивает consumer навсегда.
|
||||
// Пропускаем — запись недоступна, восстановить нечего.
|
||||
if (!rawFields) continue
|
||||
const fields: Record<string, string> = {}
|
||||
// rawFields — плоский массив [key, val, key, val, ...], шагаем по 2
|
||||
for (let i = 0; i + 1 < rawFields.length; i += 2) {
|
||||
@@ -246,6 +254,19 @@ export class IoRedisStore implements RedisStore {
|
||||
await this.client.xack(stream, group, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* XPENDING stream group (summary-форма) → самый старый un-acked ID в PEL группы.
|
||||
* null если pending нет. Нужен для безопасного XTRIM: триммить можно только
|
||||
* НИЖЕ этого ID, иначе снесём собственные недоставленные/неподтверждённые записи.
|
||||
*/
|
||||
async xpendingMinId(stream: string, group: string): Promise<string | null> {
|
||||
const res = await this.client.xpending(stream, group)
|
||||
if (!Array.isArray(res)) return null
|
||||
const [count, minId] = res
|
||||
if (!count || !minId) return null
|
||||
return String(minId)
|
||||
}
|
||||
|
||||
/** ZADD key score member. */
|
||||
async zadd(key: string, score: number, member: string): Promise<void> {
|
||||
await this.client.zadd(key, score, member)
|
||||
+1
-1
@@ -86,6 +86,6 @@ export class ShipReaderAdapter implements ChainClient {
|
||||
* содержит hardcoded-схемы нативных таблиц (permission, account, …).
|
||||
*/
|
||||
deserializeNativeDelta(delta: ShipDelta): ShipNativeDeltaEvent {
|
||||
return this.client.deserializer.deserializeNativeDelta(delta)
|
||||
return this.client.deserializer.deserializeNativeDelta(delta, this.client.abi)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -60,7 +60,7 @@ async function pruneContract(
|
||||
* @param contract — имя контракта (null если allContracts=true).
|
||||
* @param olderThan — block_num: удалить версии с block_num < olderThan.
|
||||
* @param dryRun — только показать, не удалять.
|
||||
* @param allContracts — обработать все контракты в Redis (SCAN parser:abi:*).
|
||||
* @param allContracts — обработать все контракты в Redis (SCAN parser2:abi:*).
|
||||
*/
|
||||
export async function abiPrune(
|
||||
redis: RedisStore,
|
||||
@@ -75,7 +75,7 @@ export async function abiPrune(
|
||||
|
||||
if (allContracts) {
|
||||
// SCAN по паттерну: находим все ABI-ключи во всех контрактах
|
||||
const keys = await redis.scan('parser:abi:*')
|
||||
const keys = await redis.scan('parser2:abi:*')
|
||||
if (keys.length === 0) {
|
||||
console.log('No ABI history found.')
|
||||
return
|
||||
@@ -83,7 +83,7 @@ export async function abiPrune(
|
||||
|
||||
const rows: Array<{ contract: string; pruned: number; remaining: number }> = []
|
||||
for (const key of keys) {
|
||||
const name = key.replace(/^parser:abi:/, '')
|
||||
const name = key.replace(/^parser2:abi:/, '')
|
||||
try {
|
||||
const result = await pruneContract(redis, name, olderThan, dryRun)
|
||||
rows.push({ contract: name, pruned: dryRun ? result.remaining : result.pruned, remaining: result.remaining })
|
||||
+4
-4
@@ -3,14 +3,14 @@
|
||||
*
|
||||
* Выводит содержимое dead-letter stream(ов) в табличном или JSON формате.
|
||||
*
|
||||
* Dead-letter stream: ce:parser:<chainId>:dead:<subId>
|
||||
* Dead-letter stream: ce:parser2:<chainId>:dead:<subId>
|
||||
* Каждая запись содержит поля:
|
||||
* data — оригинальный JSON ParserEvent
|
||||
* failureCount — число провалов (обычно = FAILURE_THRESHOLD = 3)
|
||||
* lastError — текст последнего исключения
|
||||
* subId — идентификатор подписки-владельца
|
||||
*
|
||||
* Режим --all: сканирует Redis по паттерну ce:parser:<chainId>:dead:*
|
||||
* Режим --all: сканирует Redis по паттерну ce:parser2:<chainId>:dead:*
|
||||
* и показывает все dead-letter стримы для цепи.
|
||||
*
|
||||
* Режим --json: выводит JSON-массив объектов с разобранными полями —
|
||||
@@ -105,10 +105,10 @@ export async function listDeadLetters(
|
||||
let streams: Array<{ key: string; subId: string }> = []
|
||||
|
||||
if (all) {
|
||||
// SCAN по паттерну: ce:parser:<chainId>:dead:*
|
||||
// SCAN по паттерну: ce:parser2:<chainId>:dead:*
|
||||
const pattern = RedisKeys.deadLetterStream(chainId, '*')
|
||||
const keys = await redis.scan(pattern)
|
||||
const prefix = `ce:parser:${chainId}:dead:`
|
||||
const prefix = `ce:parser2:${chainId}:dead:`
|
||||
streams = keys.map(k => ({ key: k, subId: k.slice(prefix.length) }))
|
||||
if (streams.length === 0) {
|
||||
console.log('No dead-letter streams found.')
|
||||
+4
-4
@@ -4,10 +4,10 @@
|
||||
* Показывает зарегистрированные подписки и их текущее состояние в consumer group.
|
||||
*
|
||||
* Источник данных — два ключа Redis:
|
||||
* 1. parser:subs — HASH: subId → JSON с метаданными подписки (filters, startFrom, registeredAt).
|
||||
* 1. parser2:subs — HASH: subId → JSON с метаданными подписки (filters, startFrom, registeredAt).
|
||||
* Записывается при вызове ParserClient.subscribe(), сохраняется между перезапусками.
|
||||
*
|
||||
* 2. ce:parser:<chainId>:events — Redis Stream с consumer groups.
|
||||
* 2. ce:parser2:<chainId>:events — Redis Stream с consumer groups.
|
||||
* XINFO GROUPS даёт для каждой группы: pending count, lag, last-delivered-id.
|
||||
* Если стрим ещё не создан (парсер не запускался) — xinfoGroups выбросит ошибку;
|
||||
* мы перехватываем её и показываем все подписки как «not started».
|
||||
@@ -26,7 +26,7 @@ import type { RedisStore } from '../../ports/RedisStore.js'
|
||||
import { RedisKeys } from '../../redis/keys.js'
|
||||
|
||||
/**
|
||||
* Метаданные подписки, хранимые в Redis HASH parser:subs.
|
||||
* Метаданные подписки, хранимые в Redis HASH parser2:subs.
|
||||
* Сохраняются при registerSubscription() и используются для восстановления после рестарта.
|
||||
*/
|
||||
interface SubMeta {
|
||||
@@ -81,7 +81,7 @@ function formatFilters(filters: Array<Record<string, string>>): string {
|
||||
* Основная функция команды list-subscriptions.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. HGETALL parser:subs → все зарегистрированные подписки.
|
||||
* 1. HGETALL parser2:subs → все зарегистрированные подписки.
|
||||
* 2. XINFO GROUPS <stream> → runtime-статистика consumer groups (может упасть если стрим не создан).
|
||||
* 3. JOIN по subId: дополняем каждую подписку данными из consumer group.
|
||||
* 4. Вывод: JSON-массив или ASCII-таблица с выравниванием.
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* CLI точка входа для @coopenomics/parser.
|
||||
* CLI точка входа для @coopenomics/parser2.
|
||||
*
|
||||
* Инструмент командной строки `parser` предоставляет набор операционных команд
|
||||
* для управления парсером без его перезапуска:
|
||||
@@ -32,7 +32,7 @@ const program = new Command()
|
||||
|
||||
program
|
||||
.name('parser')
|
||||
.description('@coopenomics/parser — universal EOSIO/Antelope blockchain indexer')
|
||||
.description('@coopenomics/parser2 — universal EOSIO/Antelope blockchain indexer')
|
||||
.version('0.1.0')
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,7 @@ program
|
||||
|
||||
/**
|
||||
* Команда `list-subscriptions`: показывает все зарегистрированные подписки.
|
||||
* Читает из parser:subs HASH и объединяет с данными XINFO GROUPS.
|
||||
* Читает из parser2:subs HASH и объединяет с данными XINFO GROUPS.
|
||||
* Полезна для мониторинга: видно pending, lag и last-delivered-id каждой группы.
|
||||
*/
|
||||
program
|
||||
@@ -165,7 +165,7 @@ program
|
||||
* Команда `abi-prune`: удаляет устаревшие версии ABI из Redis ZSET.
|
||||
* Без периодической очистки активные контракты накапливают сотни версий.
|
||||
* --older-than <block> → удалить версии с block_num < этого значения.
|
||||
* --all-contracts → SCAN по parser:abi:* и применить к каждому контракту.
|
||||
* --all-contracts → SCAN по parser2:abi:* и применить к каждому контракту.
|
||||
* --dry-run → показать количество версий для удаления без изменений.
|
||||
*/
|
||||
program
|
||||
@@ -196,7 +196,7 @@ program
|
||||
|
||||
/**
|
||||
* Команда `list-dead-letters`: инспектирует dead-letter stream(ы).
|
||||
* Dead-letter stream: ce:parser:<chainId>:dead:<subId>
|
||||
* Dead-letter stream: ce:parser2:<chainId>:dead:<subId>
|
||||
* Содержит события которые handler не смог обработать 3 раза подряд.
|
||||
* --all → сканировать все dead-letter стримы для цепи (SCAN dead:*).
|
||||
* --limit → максимум записей за вызов (пагинация через --from).
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
* Вместо немедленного перевода в dead-letter даём несколько попыток (FAILURE_THRESHOLD = 3).
|
||||
* После FAILURE_THRESHOLD провалов событие переводится в dead-letter stream.
|
||||
*
|
||||
* Счётчики хранятся в Redis Hash (parser:sub:<subId>:failures) с TTL 24 ч.
|
||||
* Счётчики хранятся в Redis Hash (parser2:sub:<subId>:failures) с TTL 24 ч.
|
||||
* TTL сбрасывается при каждом новом провале — чтобы не накапливать стали счётчики.
|
||||
*
|
||||
* Почему Redis, а не in-memory: при рестарте consumer'а незакрытые ошибки
|
||||
@@ -61,7 +61,7 @@ export class FailureTracker {
|
||||
|
||||
/**
|
||||
* Записывает событие в dead-letter stream с метаданными об ошибке.
|
||||
* Dead-letter stream: ce:parser:<chainId>:dead:<subId>
|
||||
* Dead-letter stream: ce:parser2:<chainId>:dead:<subId>
|
||||
* Поля записи: data (оригинальный payload), failureCount, lastError, subId.
|
||||
*/
|
||||
async routeToDeadLetter(
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
* получать события из блокчейна без прямого доступа к Redis или SHiP.
|
||||
*
|
||||
* Что делает:
|
||||
* 1. Регистрирует подписку (метаданные) в Redis Hash (parser:subs).
|
||||
* 1. Регистрирует подписку (метаданные) в Redis Hash (parser2:subs).
|
||||
* 2. Захватывает distributed lock (single-active-consumer) через SubscriptionLock.
|
||||
* 3. Читает события из Redis Stream через RedisConsumer (XREADGROUP).
|
||||
* 4. Применяет фильтры (matchFilters) — пропускает нерелевантные события.
|
||||
@@ -74,7 +74,7 @@ export class ParserClient {
|
||||
*
|
||||
* Этапы старта:
|
||||
* 1. Подключаемся к Redis.
|
||||
* 2. Регистрируем подписку в HSET parser:subs.
|
||||
* 2. Регистрируем подписку в HSET parser2:subs.
|
||||
* 3. Пытаемся захватить lock; если занят — ждём освобождения.
|
||||
* 4. Определяем startId для consumer group.
|
||||
* 5. Создаём consumer group (XGROUP CREATE).
|
||||
@@ -40,6 +40,12 @@ export interface ParserOptions {
|
||||
abiFallback?: 'rpc-current' | 'fail'
|
||||
/** XtrimSupervisor: интервал проверки и включение/отключение автообрезки стрима. */
|
||||
xtrim?: { intervalMs?: number; enabled?: boolean }
|
||||
/**
|
||||
* Backpressure-окно: пауза чтения SHiP когда backlog стрима (XLEN) достигает
|
||||
* highWater, возобновление при сливе до lowWater. Защита Redis RAM на
|
||||
* репарсинге, когда consumer медленнее писателя. По умолчанию включено.
|
||||
*/
|
||||
backpressure?: { enabled?: boolean; highWater?: number; lowWater?: number; pollMs?: number }
|
||||
/** ReconnectSupervisor: максимум попыток и backoff-таблица в секундах. */
|
||||
reconnect?: { maxAttempts?: number; backoffSeconds?: number[] }
|
||||
/** Десериализатор ABI-данных. Единственный вариант — 'wharfkit'. */
|
||||
@@ -82,6 +82,26 @@ export const configSchema = {
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
/**
|
||||
* Backpressure: окно поглощения между писателем (SHiP) и consumer'ом.
|
||||
* Когда backlog стрима (XLEN) ≥ highWater — чтение блоков встаёт на паузу,
|
||||
* пока consumer не сольёт backlog ≤ lowWater. Бережёт Redis RAM на длинном
|
||||
* репарсинге. Ничего не теряет — события остаются в стриме.
|
||||
*/
|
||||
backpressure: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
/** Включить/отключить. По умолчанию true. */
|
||||
enabled: { type: 'boolean', default: true },
|
||||
/** Верхняя граница backlog (XLEN) — пауза чтения. По умолчанию 100000. */
|
||||
highWater: { type: 'number', default: 100000 },
|
||||
/** Нижняя граница — возобновление. Должна быть < highWater. По умолчанию 50000. */
|
||||
lowWater: { type: 'number', default: 50000 },
|
||||
/** Интервал опроса XLEN во время паузы, мс. По умолчанию 200. */
|
||||
pollMs: { type: 'number', default: 200 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
/** ReconnectSupervisor: поведение при разрыве SHiP соединения. */
|
||||
reconnect: {
|
||||
type: 'object',
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Backpressure-окно между чтением блокчейна (писатель) и consumer'ом (читатель).
|
||||
*
|
||||
* Проблема: Parser читает блоки из SHiP и XADD'ит события в Redis-стрим быстрее,
|
||||
* чем ParserClient (controller) их вычитывает. На длинном репарсинге (genesis →
|
||||
* head, миллионы блоков) стрим растёт в ПАМЯТИ REDIS неограниченно → Redis OOM.
|
||||
* RAM самого парсера ограничена SHiP-окном (max_messages_in_flight), а вот
|
||||
* Redis-стрим — нет. XtrimSupervisor не спасает: он не режет непрочитанное.
|
||||
*
|
||||
* Решение: перед обработкой каждого блока проверяем backlog = XLEN стрима.
|
||||
* Если backlog >= highWater — СТАВИМ чтение на паузу (не ack'аем блок, SHiP
|
||||
* перестаёт слать) и ждём пока consumer сольёт backlog до lowWater. Потом
|
||||
* продолжаем. Ничего не теряем: события лежат в стриме, мы лишь не доливаем.
|
||||
*
|
||||
* Если consumer вообще не подключён — backlog растёт до highWater и писатель
|
||||
* замирает, держа Redis в пределах ~highWater событий.
|
||||
*
|
||||
* Почему XLEN, а не lag группы: XLEN — это ровно тот объём, что занят в Redis
|
||||
* RAM (единственный защищаемый ресурс). Не зависит от числа/наличия групп и
|
||||
* версии Redis (lag появился в 7.0). Консервативен: считает и непрочитанное,
|
||||
* и ещё не обрезанное XtrimSupervisor'ом — пауза чуть раньше, это безопасно.
|
||||
*/
|
||||
|
||||
import type { RedisStore } from '../ports/RedisStore.js'
|
||||
import { rootLogger, type Logger } from '../logger.js'
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
export interface BackpressureGateOpts {
|
||||
redis: RedisStore
|
||||
/** Стрим, backlog которого ограничиваем (ce:parser2:<chainId>:events). */
|
||||
stream: string
|
||||
/** Достигли этого XLEN — пауза чтения SHiP. */
|
||||
highWater: number
|
||||
/** Возобновляем чтение когда backlog слился до этого XLEN. < highWater. */
|
||||
lowWater: number
|
||||
/** Интервал опроса XLEN во время паузы, мс. По умолчанию 200. */
|
||||
pollMs?: number
|
||||
/** Логгер (по умолчанию rootLogger). */
|
||||
logger?: Logger
|
||||
}
|
||||
|
||||
export class BackpressureGate {
|
||||
private readonly redis: RedisStore
|
||||
private readonly stream: string
|
||||
private readonly highWater: number
|
||||
private readonly lowWater: number
|
||||
private readonly pollMs: number
|
||||
private readonly log: Logger
|
||||
private pausedNow = false
|
||||
|
||||
constructor(opts: BackpressureGateOpts) {
|
||||
if (!Number.isFinite(opts.highWater) || opts.highWater <= 0) {
|
||||
throw new Error(`BackpressureGate: highWater must be > 0, got ${opts.highWater}`)
|
||||
}
|
||||
if (!Number.isFinite(opts.lowWater) || opts.lowWater < 0 || opts.lowWater >= opts.highWater) {
|
||||
throw new Error(`BackpressureGate: lowWater must be in [0, highWater), got ${opts.lowWater} (highWater=${opts.highWater})`)
|
||||
}
|
||||
this.redis = opts.redis
|
||||
this.stream = opts.stream
|
||||
this.highWater = opts.highWater
|
||||
this.lowWater = opts.lowWater
|
||||
this.pollMs = opts.pollMs ?? 200
|
||||
this.log = (opts.logger ?? rootLogger).child({ component: 'BackpressureGate', stream: opts.stream })
|
||||
}
|
||||
|
||||
/** true пока чтение блоков стоит на паузе (для метрик/health). */
|
||||
get isPaused(): boolean {
|
||||
return this.pausedNow
|
||||
}
|
||||
|
||||
/**
|
||||
* Блокирует выполнение пока backlog (XLEN стрима) >= highWater.
|
||||
* Возвращается когда backlog опустился < lowWater ИЛИ shouldStop() стал true
|
||||
* (graceful shutdown). Если backlog уже < highWater — возвращается сразу,
|
||||
* не делая лишних RTT после первого XLEN.
|
||||
*
|
||||
* @param shouldStop предикат прерывания (обычно () => this.stopSignal)
|
||||
*/
|
||||
async waitForCapacity(shouldStop: () => boolean = () => false): Promise<void> {
|
||||
let backlog = await this.redis.xlen(this.stream)
|
||||
if (backlog < this.highWater) return
|
||||
|
||||
this.pausedNow = true
|
||||
this.log.warn(
|
||||
{ backlog, highWater: this.highWater, lowWater: this.lowWater },
|
||||
'backpressure: чтение блоков на паузе — consumer отстаёт, ждём слива стрима',
|
||||
)
|
||||
try {
|
||||
while (!shouldStop()) {
|
||||
await sleep(this.pollMs)
|
||||
backlog = await this.redis.xlen(this.stream)
|
||||
if (backlog <= this.lowWater) break
|
||||
}
|
||||
} finally {
|
||||
this.pausedNow = false
|
||||
}
|
||||
this.log.info({ backlog, lowWater: this.lowWater }, 'backpressure: backlog слит, чтение возобновлено')
|
||||
}
|
||||
}
|
||||
+23
-4
@@ -29,6 +29,7 @@ import { computeEventId } from '../events/eventId.js'
|
||||
import type { AbiBootstrapper } from '../abi/AbiBootstrapper.js'
|
||||
import type { AbiStore } from '../abi/AbiStore.js'
|
||||
import type { ChainClient } from '../ports/ChainClient.js'
|
||||
import { rootLogger, type Logger } from '../logger.js'
|
||||
|
||||
interface BlockProcessorOptions {
|
||||
/** Идентификатор цепи — проставляется в каждое событие. */
|
||||
@@ -68,6 +69,7 @@ export class BlockProcessor {
|
||||
private abiBootstrapper: AbiBootstrapper
|
||||
private abiStore: AbiStore
|
||||
private chainClient: ChainClient
|
||||
private log: Logger
|
||||
|
||||
constructor(opts: BlockProcessorOptions) {
|
||||
this.chainId = opts.chainId
|
||||
@@ -75,6 +77,7 @@ export class BlockProcessor {
|
||||
this.abiBootstrapper = opts.abiBootstrapper
|
||||
this.abiStore = opts.abiStore
|
||||
this.chainClient = opts.chainClient
|
||||
this.log = rootLogger.child({ component: 'BlockProcessor', chain_id: opts.chainId })
|
||||
this.queue = new PQueue({ concurrency: 1 })
|
||||
}
|
||||
|
||||
@@ -94,8 +97,13 @@ export class BlockProcessor {
|
||||
|
||||
const blockNum = block.thisBlock.blockNum
|
||||
const blockId = block.thisBlock.blockId
|
||||
// blockTime берём из первой трассировки; если трассировок нет — текущее время
|
||||
const blockTime = block.traces[0]?.blockTime ?? new Date().toISOString()
|
||||
// block_time — on-chain время блока (signed_block.timestamp), общее для всех событий
|
||||
// блока, включая trace-less блоки (genesis). НЕ wall-clock: подставлять new Date()
|
||||
// нельзя — это ломает point-in-time запросы. Пустое = block не запрошен у SHiP.
|
||||
const blockTime = block.blockTime
|
||||
if (!blockTime) {
|
||||
this.log.warn({ block_num: blockNum }, 'block_time missing from ship block (fetch_block disabled?)')
|
||||
}
|
||||
|
||||
// ── Фаза 1: Action traces ─────────────────────────────────────────────────
|
||||
for (const trace of block.traces) {
|
||||
@@ -136,13 +144,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) })
|
||||
@@ -231,8 +245,13 @@ export class BlockProcessor {
|
||||
present: native.present,
|
||||
}
|
||||
nativeDeltaEvents.push({ ...partial, event_id: computeEventId(partial) })
|
||||
} catch {
|
||||
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок
|
||||
} catch (err) {
|
||||
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок,
|
||||
// но молча терять их нельзя — иначе регрессии десериализации невидимы.
|
||||
this.log.warn(
|
||||
{ block_num: blockNum, table: delta.name, err: err instanceof Error ? err.message : String(err) },
|
||||
'native delta deserialize failed',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,16 @@ import { WorkerPool } from '../workers/WorkerPool.js'
|
||||
import { BlockProcessor } from './BlockProcessor.js'
|
||||
import type { XtrimSupervisorOpts } from './XtrimSupervisor.js'
|
||||
import { XtrimSupervisor } from './XtrimSupervisor.js'
|
||||
import { BackpressureGate } from './BackpressureGate.js'
|
||||
import { ReconnectSupervisor } from './ReconnectSupervisor.js'
|
||||
import { runStreamLoop } from './streamLoop.js'
|
||||
import { ForkDetector } from './ForkDetector.js'
|
||||
import { RedisKeys } from '../redis/keys.js'
|
||||
import { ChainIdMismatchError } from '../errors.js'
|
||||
import { AbiStore } from '../abi/AbiStore.js'
|
||||
import { AbiBootstrapper } from '../abi/AbiBootstrapper.js'
|
||||
import { createLogger } from '../logger.js'
|
||||
import type { Logger } from '../logger.js'
|
||||
import type { ParserEvent } from '../types.js'
|
||||
|
||||
export class Parser {
|
||||
@@ -20,6 +25,8 @@ export class Parser {
|
||||
private workerPool: WorkerPool | null = null
|
||||
private blockProcessor: BlockProcessor | null = null
|
||||
private xtrimSupervisor: XtrimSupervisor | null = null
|
||||
private backpressureGate: BackpressureGate | null = null
|
||||
private log: Logger | null = null
|
||||
private running = false
|
||||
private stopSignal = false
|
||||
|
||||
@@ -66,6 +73,12 @@ export class Parser {
|
||||
throw new ChainIdMismatchError(this.opts.chain.id, chainId)
|
||||
}
|
||||
|
||||
this.log = createLogger({
|
||||
...(this.opts.logger?.level !== undefined ? { level: this.opts.logger.level } : {}),
|
||||
...(this.opts.logger?.pretty !== undefined ? { pretty: this.opts.logger.pretty } : {}),
|
||||
chain_id: chainId,
|
||||
}).child({ component: 'Parser' })
|
||||
|
||||
const abiFallback = this.opts.abiFallback ?? 'rpc-current'
|
||||
const abiStore = new AbiStore(this.redis)
|
||||
const abiBootstrapper = new AbiBootstrapper(this.chainClient, abiStore, { abiFallback })
|
||||
@@ -81,14 +94,6 @@ export class Parser {
|
||||
const syncKey = RedisKeys.syncHash(chainId)
|
||||
const eventsStream = RedisKeys.eventsStream(chainId)
|
||||
|
||||
const lastBlockNum = await this.redis.hget(syncKey, 'block_num')
|
||||
const lastBlockId = await this.redis.hget(syncKey, 'block_id')
|
||||
|
||||
const havePositions =
|
||||
lastBlockNum && lastBlockId
|
||||
? [{ blockNum: Number(lastBlockNum), blockId: lastBlockId }]
|
||||
: []
|
||||
|
||||
const xtrimOpts: XtrimSupervisorOpts = {
|
||||
redis: this.redis,
|
||||
stream: eventsStream,
|
||||
@@ -100,38 +105,54 @@ export class Parser {
|
||||
this.xtrimSupervisor.start()
|
||||
}
|
||||
|
||||
const streamOpts = {
|
||||
startBlock: havePositions[0]?.blockNum ?? 0,
|
||||
havePositions,
|
||||
// Backpressure-окно: пауза чтения SHiP когда backlog стрима упирается в
|
||||
// highWater, чтобы Redis не рос неограниченно на репарсинге. По умолчанию вкл.
|
||||
if (this.opts.backpressure?.enabled !== false) {
|
||||
const bp = this.opts.backpressure
|
||||
this.backpressureGate = new BackpressureGate({
|
||||
redis: this.redis,
|
||||
stream: eventsStream,
|
||||
highWater: bp?.highWater ?? 100_000,
|
||||
lowWater: bp?.lowWater ?? 50_000,
|
||||
...(bp?.pollMs !== undefined ? { pollMs: bp.pollMs } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const irreversibleOnly = this.opts.irreversibleOnly ?? false
|
||||
const forkDetector = new ForkDetector(chainId)
|
||||
|
||||
for await (const block of this.chainClient.streamBlocks(streamOpts)) {
|
||||
if (this.stopSignal) break
|
||||
// ReconnectSupervisor оборачивает (пере)подключение к SHiP экспоненциальным
|
||||
// backoff. Без него разрыв ноды бросал ShipConnectionError наверх из start(),
|
||||
// и темп переподключений целиком зависел от внешнего супервизора процесса —
|
||||
// мёртвая нода = рестарт без пауз = долбёжка. Теперь паузы 1→2→5→15→60с,
|
||||
// а после maxAttempts подряд неудач — чистый выход (процесс рестартит оркестратор).
|
||||
const reconnectCfg = this.opts.reconnect ?? {}
|
||||
const supervisor = new ReconnectSupervisor({
|
||||
...(reconnectCfg.maxAttempts !== undefined ? { maxAttempts: reconnectCfg.maxAttempts } : {}),
|
||||
...(reconnectCfg.backoffSeconds !== undefined ? { backoffSeconds: reconnectCfg.backoffSeconds } : {}),
|
||||
onAttempt: (attempt, delayMs) => {
|
||||
this.log?.warn({ attempt, delayMs }, 'SHiP-соединение потеряно, переподключение через backoff')
|
||||
},
|
||||
onGiveUp: (attempts) => {
|
||||
this.log?.error({ attempts }, 'SHiP-переподключение исчерпало все попытки, остановка парсера')
|
||||
if (!this.opts.noSignalHandlers) process.exit(1)
|
||||
},
|
||||
})
|
||||
|
||||
if (irreversibleOnly && block.thisBlock.blockNum > block.lastIrreversible.blockNum) {
|
||||
this.chainClient.ack(1)
|
||||
continue
|
||||
}
|
||||
|
||||
const forkEvent = forkDetector.check(block.thisBlock.blockNum, block.thisBlock.blockId)
|
||||
const events: ParserEvent[] = await this.blockProcessor.process(block)
|
||||
const toPublish: ParserEvent[] = forkEvent ? [forkEvent, ...events] : events
|
||||
|
||||
for (const event of toPublish) {
|
||||
await this.redis.xadd(eventsStream, this.eventToFields(event))
|
||||
}
|
||||
|
||||
await this.redis.hset(syncKey, {
|
||||
block_num: String(block.thisBlock.blockNum),
|
||||
block_id: block.thisBlock.blockId,
|
||||
last_updated: new Date().toISOString(),
|
||||
})
|
||||
|
||||
this.chainClient.ack(1)
|
||||
}
|
||||
await runStreamLoop({
|
||||
chainClient: this.chainClient!,
|
||||
redis: this.redis!,
|
||||
blockProcessor: this.blockProcessor!,
|
||||
backpressureGate: this.backpressureGate,
|
||||
forkDetector,
|
||||
supervisor,
|
||||
syncKey,
|
||||
eventsStream,
|
||||
irreversibleOnly,
|
||||
eventToFields: (event) => this.eventToFields(event),
|
||||
isStopped: () => this.stopSignal,
|
||||
log: this.log,
|
||||
})
|
||||
}
|
||||
|
||||
private eventToFields(event: ParserEvent): Record<string, string> {
|
||||
@@ -181,7 +202,7 @@ export class Parser {
|
||||
private async checkRedisPersistence(): Promise<void> {
|
||||
const redis = this.redis!
|
||||
try {
|
||||
const aofResult = await redis.hget('__parser_check__', '__noop__')
|
||||
const aofResult = await redis.hget('__parser2_check__', '__noop__')
|
||||
void aofResult
|
||||
} catch {
|
||||
// non-fatal check failure
|
||||
+10
-3
@@ -51,9 +51,15 @@ export class ReconnectSupervisor {
|
||||
/**
|
||||
* Запускает fn и повторяет при исключении с паузами.
|
||||
*
|
||||
* fn получает callback resetBackoff: вызови его, когда fn сделала реальный
|
||||
* прогресс (например успешно обработала блок на свежем соединении) — счётчик
|
||||
* попыток сбросится в 0. Без сброса долгоживущее соединение, которое изредка
|
||||
* рвётся, копило бы attempt до maxAttempts и завершало процесс, хотя между
|
||||
* разрывами оно часами работало штатно.
|
||||
*
|
||||
* Псевдокод:
|
||||
* loop:
|
||||
* try: return await fn()
|
||||
* try: return await fn(resetBackoff)
|
||||
* catch: attempt++
|
||||
* if attempt >= maxAttempts: onGiveUp(); throw
|
||||
* delay = backoffSeconds[min(attempt-1, len-1)] * 1000
|
||||
@@ -61,11 +67,12 @@ export class ReconnectSupervisor {
|
||||
*
|
||||
* @returns Результат первого успешного вызова fn.
|
||||
*/
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
async run<T>(fn: (resetBackoff: () => void) => Promise<T>): Promise<T> {
|
||||
let attempt = 0
|
||||
const resetBackoff = (): void => { attempt = 0 }
|
||||
for (;;) {
|
||||
try {
|
||||
return await fn()
|
||||
return await fn(resetBackoff)
|
||||
} catch (err) {
|
||||
attempt++
|
||||
if (attempt >= this.maxAttempts) {
|
||||
+35
-9
@@ -7,9 +7,15 @@
|
||||
* Стратегия MINID:
|
||||
* Вместо хранения фиксированного числа записей (MAXLEN), мы сохраняем все
|
||||
* записи, которые ещё не подтверждены (pending) хотя бы одной consumer group.
|
||||
* minId = min(lastDeliveredId всех групп с pending > 0).
|
||||
* minId = min(самый старый un-acked pending ID всех групп с pending > 0).
|
||||
* XTRIM stream MINID minId удаляет всё с ID < minId.
|
||||
*
|
||||
* ВАЖНО: триммить по lastDeliveredId НЕЛЬЗЯ — un-acked pending записи старше
|
||||
* lastDeliveredId (доставлены, но не подтверждены: их ID < last-delivered).
|
||||
* XTRIM MINID lastDeliveredId снёс бы собственные pending группы → consumer при
|
||||
* перечитывании PEL получает [id, null] и падает. Поэтому нижняя граница trim —
|
||||
* самый старый un-acked ID из XPENDING, а не lastDeliveredId.
|
||||
*
|
||||
* Это гарантирует, что ни один consumer не потеряет сообщения при trim:
|
||||
* группа с отставанием «тормозит» trim, пока не догонит.
|
||||
*
|
||||
@@ -18,9 +24,22 @@
|
||||
|
||||
import type { RedisStore } from '../ports/RedisStore.js'
|
||||
|
||||
/**
|
||||
* Числовое сравнение Redis Stream ID формата `<ms>-<seq>`.
|
||||
* Строковое сравнение (a < b) неверно: '1000-0' < '999-0' лексикографически
|
||||
* true, хотя по времени 1000 > 999. Возвращает <0, 0, >0.
|
||||
*/
|
||||
function compareStreamIds(a: string, b: string): number {
|
||||
const [aMs, aSeq = '0'] = a.split('-')
|
||||
const [bMs, bSeq = '0'] = b.split('-')
|
||||
const ms = Number(aMs) - Number(bMs)
|
||||
if (ms !== 0) return ms
|
||||
return Number(aSeq) - Number(bSeq)
|
||||
}
|
||||
|
||||
export interface XtrimSupervisorOpts {
|
||||
redis: RedisStore
|
||||
/** Имя стрима для очистки (обычно ce:parser:<chainId>:events). */
|
||||
/** Имя стрима для очистки (обычно ce:parser2:<chainId>:events). */
|
||||
stream: string
|
||||
/** Интервал между trim-циклами в мс. По умолчанию 60 000 (1 минута). */
|
||||
intervalMs?: number
|
||||
@@ -60,8 +79,8 @@ export class XtrimSupervisor {
|
||||
* Один цикл очистки:
|
||||
* 1. Получаем список consumer groups через XINFO GROUPS.
|
||||
* 2. Фильтруем группы у которых есть pending сообщения (pending > 0).
|
||||
* 3. Находим минимальный lastDeliveredId среди таких групп.
|
||||
* 4. XTRIM stream MINID minId — удаляем всё старее этого ID.
|
||||
* 3. Для каждой берём самый старый un-acked pending ID (XPENDING).
|
||||
* 4. minId = числовой минимум этих ID; XTRIM stream MINID minId.
|
||||
*
|
||||
* Если pending-групп нет — trim не делается (всё подтверждено).
|
||||
* Если стрим не существует или XInfo бросает — тихо игнорируем (best-effort).
|
||||
@@ -75,12 +94,19 @@ export class XtrimSupervisor {
|
||||
const pendingGroups = groups.filter(g => g.pending > 0)
|
||||
if (pendingGroups.length === 0) return
|
||||
|
||||
// Наименьший lastDeliveredId = самый отстающий consumer
|
||||
const minId = pendingGroups
|
||||
.map(g => g.lastDeliveredId)
|
||||
.reduce((a, b) => (a < b ? a : b))
|
||||
// Самый старый un-acked pending ID по каждой группе. НЕ lastDeliveredId:
|
||||
// он выше pending, trim по нему снёс бы собственные неподтверждённые записи.
|
||||
const oldestPending: string[] = []
|
||||
for (const g of pendingGroups) {
|
||||
const id = await this.redis.xpendingMinId(this.stream, g.name)
|
||||
if (id) oldestPending.push(id)
|
||||
}
|
||||
if (oldestPending.length === 0) return
|
||||
|
||||
if (minId) await this.redis.xtrim(this.stream, minId)
|
||||
// Нижняя граница trim = самый старый un-acked ID среди всех групп
|
||||
const minId = oldestPending.reduce((a, b) => (compareStreamIds(a, b) <= 0 ? a : b))
|
||||
|
||||
await this.redis.xtrim(this.stream, minId)
|
||||
} catch {
|
||||
// XTRIM — best-effort: ошибки не должны влиять на основной поток обработки
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Цикл чтения блоков SHiP с авто-переподключением.
|
||||
*
|
||||
* Вынесен из Parser.start() отдельной функцией с инъекцией зависимостей —
|
||||
* чтобы покрыть reconnect/resume-логику юнит-тестами на фейках, без реального
|
||||
* Redis и SHiP-ноды.
|
||||
*
|
||||
* Контракт:
|
||||
* - supervisor оборачивает одну попытку соединения экспоненциальным backoff;
|
||||
* - разрыв ноды → streamBlocks бросает → supervisor ждёт паузу и зовёт заново;
|
||||
* - позиция возобновления перечитывается из Redis на КАЖДОМ подключении
|
||||
* (sync-hash пишется после каждого блока) — продолжаем ровно с последнего
|
||||
* записанного блока, без потерь и дублей;
|
||||
* - resetBackoff() на каждом обработанном блоке: реальный прогресс возвращает
|
||||
* лесенку backoff в начало, чтобы долгая стабильная сессия после редкого
|
||||
* разрыва не копила попытки до выхода;
|
||||
* - штатная остановка (isStopped()) закрывает сокет извне → for-await бросает;
|
||||
* ловим и выходим без backoff и без retry.
|
||||
*/
|
||||
|
||||
import type { ShipBlock, GetBlocksOptions } from '@coopenomics/coopos-ship-reader'
|
||||
import type { ReconnectSupervisor } from './ReconnectSupervisor.js'
|
||||
import type { BackpressureGate } from './BackpressureGate.js'
|
||||
import type { ForkDetector } from './ForkDetector.js'
|
||||
import type { Logger } from '../logger.js'
|
||||
import type { ParserEvent } from '../types.js'
|
||||
|
||||
/** Узкий срез ChainClient, нужный циклу (упрощает фейки в тестах). */
|
||||
export interface StreamLoopChainClient {
|
||||
connect(): Promise<unknown>
|
||||
streamBlocks(opts: GetBlocksOptions): AsyncIterable<ShipBlock>
|
||||
ack(n: number): void
|
||||
}
|
||||
|
||||
/** Узкий срез RedisStore, нужный циклу. */
|
||||
export interface StreamLoopRedis {
|
||||
hget(key: string, field: string): Promise<string | null>
|
||||
hset(key: string, fields: Record<string, string>): Promise<unknown>
|
||||
xadd(stream: string, fields: Record<string, string>): Promise<unknown>
|
||||
}
|
||||
|
||||
/** Узкий срез BlockProcessor. */
|
||||
export interface StreamLoopBlockProcessor {
|
||||
process(block: ShipBlock): Promise<ParserEvent[]>
|
||||
}
|
||||
|
||||
export interface StreamLoopDeps {
|
||||
chainClient: StreamLoopChainClient
|
||||
redis: StreamLoopRedis
|
||||
blockProcessor: StreamLoopBlockProcessor
|
||||
backpressureGate: BackpressureGate | null
|
||||
forkDetector: Pick<ForkDetector, 'check'>
|
||||
supervisor: ReconnectSupervisor
|
||||
syncKey: string
|
||||
eventsStream: string
|
||||
irreversibleOnly: boolean
|
||||
eventToFields: (event: ParserEvent) => Record<string, string>
|
||||
isStopped: () => boolean
|
||||
log: Logger | null
|
||||
}
|
||||
|
||||
export async function runStreamLoop(deps: StreamLoopDeps): Promise<void> {
|
||||
const {
|
||||
chainClient, redis, blockProcessor, backpressureGate, forkDetector,
|
||||
supervisor, syncKey, eventsStream, irreversibleOnly, eventToFields, isStopped,
|
||||
} = deps
|
||||
|
||||
const runOneConnection = async (resetBackoff: () => void): Promise<void> => {
|
||||
if (isStopped()) return
|
||||
|
||||
// connect() идемпотентен при OPEN ws (первый заход — соединение уже из
|
||||
// start-connect выше); после разрыва создаёт свежий WebSocket. handshake
|
||||
// внутри отдаёт кешированный chainId, повторный get_status не шлётся.
|
||||
await chainClient.connect()
|
||||
|
||||
const resumeNum = await redis.hget(syncKey, 'block_num')
|
||||
const resumeId = await redis.hget(syncKey, 'block_id')
|
||||
const havePositions =
|
||||
resumeNum && resumeId ? [{ blockNum: Number(resumeNum), blockId: resumeId }] : []
|
||||
const streamOpts = {
|
||||
startBlock: havePositions[0]?.blockNum ?? 0,
|
||||
havePositions,
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const block of chainClient.streamBlocks(streamOpts)) {
|
||||
if (isStopped()) return
|
||||
|
||||
// Backpressure: пока backlog стрима выше окна — ждём, не доливаем и не
|
||||
// ack'аем (SHiP сам притормозит после max_messages_in_flight). Блок уже
|
||||
// в RAM (≤ окна SHiP), обработаем его как только consumer освободит место.
|
||||
if (backpressureGate) {
|
||||
await backpressureGate.waitForCapacity(() => isStopped())
|
||||
if (isStopped()) return
|
||||
}
|
||||
|
||||
if (irreversibleOnly && block.thisBlock.blockNum > block.lastIrreversible.blockNum) {
|
||||
chainClient.ack(1)
|
||||
resetBackoff()
|
||||
continue
|
||||
}
|
||||
|
||||
const forkEvent = forkDetector.check(block.thisBlock.blockNum, block.thisBlock.blockId)
|
||||
const events = await blockProcessor.process(block)
|
||||
const toPublish: ParserEvent[] = forkEvent ? [forkEvent, ...events] : events
|
||||
|
||||
for (const event of toPublish) {
|
||||
await redis.xadd(eventsStream, eventToFields(event))
|
||||
}
|
||||
|
||||
await redis.hset(syncKey, {
|
||||
block_num: String(block.thisBlock.blockNum),
|
||||
block_id: block.thisBlock.blockId,
|
||||
last_updated: new Date().toISOString(),
|
||||
})
|
||||
|
||||
chainClient.ack(1)
|
||||
resetBackoff()
|
||||
}
|
||||
} catch (err) {
|
||||
// Штатная остановка закрывает WebSocket → for-await бросает
|
||||
// ShipConnectionError. Это не сбой — выходим без backoff и без retry.
|
||||
if (isStopped()) return
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await supervisor.run(runOneConnection)
|
||||
}
|
||||
+2
-2
@@ -55,9 +55,9 @@ export interface ClientMetrics {
|
||||
/**
|
||||
* Создаёт набор клиентских метрик в изолированном Registry.
|
||||
*
|
||||
* @param prefix — префикс имён метрик. По умолчанию 'parser_client'.
|
||||
* @param prefix — префикс имён метрик. По умолчанию 'parser2_client'.
|
||||
*/
|
||||
export function createClientMetrics(prefix = 'parser_client'): ClientMetrics {
|
||||
export function createClientMetrics(prefix = 'parser2_client'): ClientMetrics {
|
||||
const registry = new Registry()
|
||||
|
||||
const handlerErrorsTotal = new Counter<'sub_id' | 'kind'>({
|
||||
@@ -79,6 +79,13 @@ export interface RedisStore {
|
||||
/** XACK stream group id — подтверждает обработку, убирает из PEL. */
|
||||
xack(stream: string, group: string, id: string): Promise<void>
|
||||
|
||||
/**
|
||||
* XPENDING stream group → ID самого старого un-acked сообщения в PEL группы
|
||||
* (или null если pending нет). Используется XtrimSupervisor: trim допустим
|
||||
* только НИЖЕ этого ID, иначе un-acked записи будут потеряны.
|
||||
*/
|
||||
xpendingMinId(stream: string, group: string): Promise<string | null>
|
||||
|
||||
// ── Sorted Set ────────────────────────────────────────────────────────────
|
||||
|
||||
/** ZADD key score member. */
|
||||
@@ -5,51 +5,51 @@
|
||||
* поиск по кодовой базе. Полная документация формата — docs/redis-key-taxonomy.md.
|
||||
*
|
||||
* Префиксы:
|
||||
* ce:parser:<chainId>: — Stream-ключи, относящиеся к конкретной цепи.
|
||||
* ce:parser2:<chainId>: — Stream-ключи, относящиеся к конкретной цепи.
|
||||
* parser: — Hash/ZSET/String ключи с глобальным скоупом.
|
||||
*/
|
||||
export const RedisKeys = {
|
||||
/**
|
||||
* Главный поток событий парсера (unified event stream).
|
||||
* Тип Redis: Stream. Тримируется XtrimSupervisor'ом.
|
||||
* Пример: ce:parser:eos-mainnet:events
|
||||
* Пример: ce:parser2:eos-mainnet:events
|
||||
*/
|
||||
eventsStream: (chainId: string) => `ce:parser:${chainId}:events`,
|
||||
eventsStream: (chainId: string) => `ce:parser2:${chainId}:events`,
|
||||
|
||||
/**
|
||||
* Dead-letter поток для конкретной подписки.
|
||||
* Содержит сообщения, которые не смог обработать consumer после N попыток.
|
||||
* Тип Redis: Stream.
|
||||
* Пример: ce:parser:eos-mainnet:dead:verifier
|
||||
* Пример: ce:parser2:eos-mainnet:dead:verifier
|
||||
*/
|
||||
deadLetterStream: (chainId: string, subId: string) => `ce:parser:${chainId}:dead:${subId}`,
|
||||
deadLetterStream: (chainId: string, subId: string) => `ce:parser2:${chainId}:dead:${subId}`,
|
||||
|
||||
/**
|
||||
* Поток для задания on-demand reparse (зарезервировано для будущего).
|
||||
* Тип Redis: Stream.
|
||||
*/
|
||||
reparseStream: (chainId: string, jobId: string) => `ce:parser:${chainId}:reparse:${jobId}`,
|
||||
reparseStream: (chainId: string, jobId: string) => `ce:parser2:${chainId}:reparse:${jobId}`,
|
||||
|
||||
/**
|
||||
* История версий ABI конкретного контракта.
|
||||
* Тип Redis: Sorted Set. Score = block_num, member = base64(rawAbiBytes).
|
||||
* При поиске ABI для блока N используется ZREVRANGEBYSCORE … N -inf LIMIT 0 1.
|
||||
* Пример: parser:abi:eosio.token
|
||||
* Пример: parser2:abi:eosio.token
|
||||
*/
|
||||
abiZset: (contract: string) => `parser:abi:${contract}`,
|
||||
abiZset: (contract: string) => `parser2:abi:${contract}`,
|
||||
|
||||
/**
|
||||
* Контрольная точка синхронизации парсера (crash-recovery).
|
||||
* Тип Redis: Hash. Поля: block_num, block_id, last_updated.
|
||||
* При рестарте парсер читает отсюда позицию и продолжает с неё.
|
||||
*/
|
||||
syncHash: (chainId: string) => `parser:sync:${chainId}`,
|
||||
syncHash: (chainId: string) => `parser2:sync:${chainId}`,
|
||||
|
||||
/**
|
||||
* Реестр всех зарегистрированных подписок.
|
||||
* Тип Redis: Hash. Ключ поля = subId, значение = JSON-метаданные подписки.
|
||||
*/
|
||||
subsHash: () => `parser:subs`,
|
||||
subsHash: () => `parser2:subs`,
|
||||
|
||||
/**
|
||||
* Счётчики ошибок per-event для конкретной подписки.
|
||||
@@ -57,18 +57,18 @@ export const RedisKeys = {
|
||||
* TTL: 24 часа (обновляется при каждом новом провале).
|
||||
* Используется FailureTracker для решения о переводе в dead-letter.
|
||||
*/
|
||||
subFailuresHash: (subId: string) => `parser:sub:${subId}:failures`,
|
||||
subFailuresHash: (subId: string) => `parser2:sub:${subId}:failures`,
|
||||
|
||||
/**
|
||||
* Блокировка single-active-consumer для подписки.
|
||||
* Тип Redis: String (instanceId держателя блокировки). TTL: 10 с (автопродление).
|
||||
* Только один экземпляр consumer-а может быть active; остальные — standby.
|
||||
*/
|
||||
subLock: (subId: string) => `parser:sub:${subId}:lock`,
|
||||
subLock: (subId: string) => `parser2:sub:${subId}:lock`,
|
||||
|
||||
/**
|
||||
* Метаданные задания reparse (зарезервировано для будущего).
|
||||
* Тип Redis: Hash.
|
||||
*/
|
||||
reparseJobHash: (jobId: string) => `parser:reparse:${jobId}`,
|
||||
reparseJobHash: (jobId: string) => `parser2:reparse:${jobId}`,
|
||||
} as const
|
||||
@@ -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). */
|
||||
+1
-1
@@ -85,7 +85,7 @@ export class WorkerPool {
|
||||
|
||||
/**
|
||||
* Доля занятых потоков от общего числа (0..1).
|
||||
* Полезно для метрики parser_worker_pool_queue_depth.
|
||||
* Полезно для метрики parser2_worker_pool_queue_depth.
|
||||
*/
|
||||
get utilization(): number {
|
||||
return this.pool.utilization
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Integration тест против реального Redis: backpressure-окно.
|
||||
*
|
||||
* Проверяем на живом Redis (chain/SHiP не нужны):
|
||||
* - backlog < highWater → waitForCapacity не паузит, возвращается сразу
|
||||
* - backlog >= highWater → пауза; после слива стрима до <= lowWater — возврат
|
||||
*
|
||||
* Требование: Redis на REDIS_URL (в CI всегда есть). Без Redis — тесты skip.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { IoRedisStore } from '../../src/adapters/IoRedisStore.js'
|
||||
import { BackpressureGate } from '../../src/core/BackpressureGate.js'
|
||||
import type { Logger } from '../../src/logger.js'
|
||||
|
||||
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://localhost:6379/14'
|
||||
const STREAM = '__it_bp__:stream'
|
||||
|
||||
const silentLog = {
|
||||
child: () => silentLog, warn: () => {}, info: () => {}, debug: () => {}, error: () => {},
|
||||
} as unknown as Logger
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
let store: IoRedisStore
|
||||
let redisUp = false
|
||||
|
||||
beforeAll(async () => {
|
||||
store = new IoRedisStore({ url: REDIS_URL })
|
||||
try {
|
||||
await store.connect()
|
||||
await store.client.del(STREAM)
|
||||
redisUp = true
|
||||
} catch {
|
||||
redisUp = false
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (redisUp) {
|
||||
await store.client.del(STREAM)
|
||||
await store.quit()
|
||||
}
|
||||
})
|
||||
|
||||
describe('backpressure — окно на реальном Redis', () => {
|
||||
it('не паузит когда backlog < highWater', async () => {
|
||||
if (!redisUp) return
|
||||
await store.client.del(STREAM)
|
||||
await store.xadd(STREAM, { n: '1' })
|
||||
const gate = new BackpressureGate({ redis: store, stream: STREAM, highWater: 5, lowWater: 2, pollMs: 10, logger: silentLog })
|
||||
await gate.waitForCapacity()
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('паузит при backlog >= highWater и возобновляет после слива до <= lowWater', async () => {
|
||||
if (!redisUp) return
|
||||
await store.client.del(STREAM)
|
||||
const ids: string[] = []
|
||||
for (let i = 0; i < 6; i++) ids.push(await store.xadd(STREAM, { n: String(i) }))
|
||||
expect(await store.xlen(STREAM)).toBe(6)
|
||||
|
||||
const gate = new BackpressureGate({ redis: store, stream: STREAM, highWater: 5, lowWater: 2, pollMs: 10, logger: silentLog })
|
||||
let resolved = false
|
||||
const p = gate.waitForCapacity().then(() => { resolved = true })
|
||||
|
||||
// даём gate войти в паузу и подождать
|
||||
await sleep(40)
|
||||
expect(gate.isPaused).toBe(true)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// сливаем backlog: удаляем 4 записи → XLEN=2 <= lowWater
|
||||
await store.client.xdel(STREAM, ids[0]!, ids[1]!, ids[2]!, ids[3]!)
|
||||
expect(await store.xlen(STREAM)).toBe(2)
|
||||
|
||||
await p // должно разблокироваться
|
||||
expect(resolved).toBe(true)
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Integration тест против реального Redis: регресс-гард на баг #3.
|
||||
*
|
||||
* Баг: XtrimSupervisor триммил по lastDeliveredId и сносил собственные un-acked
|
||||
* pending записи; при перечитывании PEL Redis отдавал [id, null], а
|
||||
* parseStreamEntries падал на null.length → consumer умирал навсегда.
|
||||
*
|
||||
* Здесь воспроизводим точный путь на живом Redis (chain/SHiP не нужны):
|
||||
* A. trim по lastDeliveredId зануляет pending → xreadGroup(PEL) НЕ падает на null.
|
||||
* B. XtrimSupervisor.trim() (новая логика) сохраняет все un-acked pending.
|
||||
*
|
||||
* Требование: Redis на REDIS_URL (в CI всегда есть). Без Redis — тесты skip.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { IoRedisStore } from '../../src/adapters/IoRedisStore.js'
|
||||
import { XtrimSupervisor } from '../../src/core/XtrimSupervisor.js'
|
||||
|
||||
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://localhost:6379/14'
|
||||
const STREAM = '__it_bug3__:stream'
|
||||
const GROUP = 'g'
|
||||
|
||||
let store: IoRedisStore
|
||||
let redisUp = false
|
||||
|
||||
// XtrimSupervisor.trim приватный — дёргаем один цикл напрямую через каст.
|
||||
function runTrimOnce(sup: XtrimSupervisor): Promise<void> {
|
||||
return (sup as unknown as { trim(): Promise<void> }).trim()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
store = new IoRedisStore({ url: REDIS_URL })
|
||||
try {
|
||||
await store.connect()
|
||||
await store.client.del(STREAM)
|
||||
redisUp = true
|
||||
} catch {
|
||||
redisUp = false
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (redisUp) {
|
||||
await store.client.del(STREAM)
|
||||
await store.quit()
|
||||
}
|
||||
})
|
||||
|
||||
describe('bug #3 — XTRIM/PEL на реальном Redis', () => {
|
||||
it('xreadGroup(PEL) не падает на обрезанных [id,null] записях', async () => {
|
||||
if (!redisUp) return
|
||||
await store.client.del(STREAM)
|
||||
await store.xgroupCreate(STREAM, GROUP, '0')
|
||||
for (let i = 0; i < 10; i++) await store.xadd(STREAM, { kind: 'action', n: String(i) })
|
||||
|
||||
// читаем 5 → они pending (un-acked); lastDeliveredId = id 5-й записи
|
||||
const read = await store.xreadGroup(STREAM, GROUP, 'c1', 5, 0, '>')
|
||||
expect(read).toHaveLength(5)
|
||||
const lastDelivered = read[4]!.id
|
||||
|
||||
// ИМИТАЦИЯ СТАРОГО БАГА: trim по lastDeliveredId сносит первые 4 pending
|
||||
await store.client.xtrim(STREAM, 'MINID', lastDelivered)
|
||||
|
||||
// consumer перечитывает PEL — Redis отдаёт [id,null] для снесённых.
|
||||
// Не должно бросать (старый parseStreamEntries падал на null.length).
|
||||
const pel = await store.xreadGroup(STREAM, GROUP, 'c1', 100, 0, '0')
|
||||
// обрезанные пропущены, выжившие — с полями
|
||||
expect(pel.every(m => Object.keys(m.fields).length > 0)).toBe(true)
|
||||
expect(pel.some(m => m.id === lastDelivered)).toBe(true)
|
||||
})
|
||||
|
||||
it('XtrimSupervisor.trim() сохраняет все un-acked pending записи', async () => {
|
||||
if (!redisUp) return
|
||||
await store.client.del(STREAM)
|
||||
await store.xgroupCreate(STREAM, GROUP, '0')
|
||||
for (let i = 0; i < 10; i++) await store.xadd(STREAM, { kind: 'action', n: String(i) })
|
||||
|
||||
const read = await store.xreadGroup(STREAM, GROUP, 'c1', 5, 0, '>')
|
||||
expect(read).toHaveLength(5)
|
||||
const oldestPending = read[0]!.id
|
||||
|
||||
const sup = new XtrimSupervisor({ redis: store, stream: STREAM, intervalMs: 999_999 })
|
||||
await runTrimOnce(sup)
|
||||
|
||||
// consumer перечитывает PEL — все 5 pending целы, без null, без краша
|
||||
const pel = await store.xreadGroup(STREAM, GROUP, 'c1', 100, 0, '0')
|
||||
expect(pel).toHaveLength(5)
|
||||
expect(pel.every(m => Object.keys(m.fields).length > 0)).toBe(true)
|
||||
// нижняя граница trim = oldestPending → первый pending на месте
|
||||
expect(pel[0]!.id).toBe(oldestPending)
|
||||
})
|
||||
|
||||
it('xpendingMinId возвращает самый старый un-acked ID, null без pending', async () => {
|
||||
if (!redisUp) return
|
||||
await store.client.del(STREAM)
|
||||
await store.xgroupCreate(STREAM, GROUP, '0')
|
||||
expect(await store.xpendingMinId(STREAM, GROUP)).toBeNull()
|
||||
|
||||
for (let i = 0; i < 3; i++) await store.xadd(STREAM, { kind: 'action', n: String(i) })
|
||||
const read = await store.xreadGroup(STREAM, GROUP, 'c1', 3, 0, '>')
|
||||
expect(await store.xpendingMinId(STREAM, GROUP)).toBe(read[0]!.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Тесты BackpressureGate — окна поглощения между писателем и consumer'ом.
|
||||
*
|
||||
* Проверяем:
|
||||
* - валидацию highWater/lowWater в конструкторе
|
||||
* - мгновенный возврат когда backlog < highWater (без паузы)
|
||||
* - паузу при backlog >= highWater и возобновление при сливе до <= lowWater
|
||||
* - isPaused отражает состояние
|
||||
* - shouldStop() прерывает паузу (graceful shutdown) даже если backlog высок
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BackpressureGate } from '../../src/core/BackpressureGate.js'
|
||||
import type { RedisStore } from '../../src/ports/RedisStore.js'
|
||||
import type { Logger } from '../../src/logger.js'
|
||||
|
||||
// Молчаливый логгер — не засоряет вывод тестов.
|
||||
const silentLog = {
|
||||
child: () => silentLog,
|
||||
warn: () => {},
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as unknown as Logger
|
||||
|
||||
function makeRedis(xlen: ReturnType<typeof vi.fn>): RedisStore {
|
||||
return { xlen } as unknown as RedisStore
|
||||
}
|
||||
|
||||
function makeGate(xlen: ReturnType<typeof vi.fn>, over: Partial<{ highWater: number; lowWater: number; pollMs: number }> = {}): BackpressureGate {
|
||||
return new BackpressureGate({
|
||||
redis: makeRedis(xlen),
|
||||
stream: 's',
|
||||
highWater: over.highWater ?? 100,
|
||||
lowWater: over.lowWater ?? 50,
|
||||
pollMs: over.pollMs ?? 1,
|
||||
logger: silentLog,
|
||||
})
|
||||
}
|
||||
|
||||
describe('BackpressureGate — конструктор', () => {
|
||||
const redis = makeRedis(vi.fn())
|
||||
|
||||
it('бросает если highWater <= 0', () => {
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 0, lowWater: 0 })).toThrow(/highWater/)
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: -5, lowWater: 0 })).toThrow(/highWater/)
|
||||
})
|
||||
|
||||
it('бросает если lowWater >= highWater', () => {
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 100, lowWater: 100 })).toThrow(/lowWater/)
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 100, lowWater: 150 })).toThrow(/lowWater/)
|
||||
})
|
||||
|
||||
it('бросает если lowWater < 0', () => {
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 100, lowWater: -1 })).toThrow(/lowWater/)
|
||||
})
|
||||
|
||||
it('принимает валидные границы', () => {
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 100, lowWater: 50 })).not.toThrow()
|
||||
expect(() => new BackpressureGate({ redis, stream: 's', highWater: 100, lowWater: 0 })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackpressureGate — waitForCapacity', () => {
|
||||
it('возвращается сразу когда backlog < highWater (одна проверка XLEN, без паузы)', async () => {
|
||||
const xlen = vi.fn().mockResolvedValue(50)
|
||||
const gate = makeGate(xlen)
|
||||
await gate.waitForCapacity()
|
||||
expect(xlen).toHaveBeenCalledTimes(1)
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('возвращается сразу при backlog = highWater - 1', async () => {
|
||||
const xlen = vi.fn().mockResolvedValue(99)
|
||||
const gate = makeGate(xlen)
|
||||
await gate.waitForCapacity()
|
||||
expect(xlen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ставит паузу при backlog >= highWater и возобновляет при сливе до <= lowWater', async () => {
|
||||
// 150 (пауза) → опрос: 150, 70 (>50), 30 (<=50 → break)
|
||||
const xlen = vi.fn()
|
||||
.mockResolvedValueOnce(150)
|
||||
.mockResolvedValueOnce(150)
|
||||
.mockResolvedValueOnce(70)
|
||||
.mockResolvedValueOnce(30)
|
||||
const gate = makeGate(xlen)
|
||||
await gate.waitForCapacity()
|
||||
expect(xlen).toHaveBeenCalledTimes(4)
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('пауза срабатывает ровно на backlog == highWater (граница >=)', async () => {
|
||||
const xlen = vi.fn().mockResolvedValueOnce(100).mockResolvedValueOnce(40)
|
||||
const gate = makeGate(xlen)
|
||||
await gate.waitForCapacity()
|
||||
expect(xlen).toHaveBeenCalledTimes(2) // 100 → пауза, 40 → выход
|
||||
})
|
||||
|
||||
it('isPaused = true во время паузы', async () => {
|
||||
const snapshots: boolean[] = []
|
||||
let gate: BackpressureGate
|
||||
const xlen = vi.fn().mockImplementation(() => {
|
||||
// снимок состояния паузы в момент опроса XLEN
|
||||
snapshots.push(gate.isPaused)
|
||||
return Promise.resolve(snapshots.length >= 3 ? 10 : 200)
|
||||
})
|
||||
gate = makeGate(xlen)
|
||||
await gate.waitForCapacity()
|
||||
// 1-й вызов — до установки паузы (false); внутри цикла — true
|
||||
expect(snapshots[0]).toBe(false)
|
||||
expect(snapshots.some(s => s === true)).toBe(true)
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
|
||||
it('shouldStop() прерывает паузу даже при высоком backlog (graceful shutdown)', async () => {
|
||||
const xlen = vi.fn().mockResolvedValue(9999) // никогда не сольётся
|
||||
const gate = makeGate(xlen)
|
||||
let stop = false
|
||||
const p = gate.waitForCapacity(() => stop)
|
||||
// через тик включаем стоп
|
||||
setTimeout(() => { stop = true }, 5)
|
||||
await p // не должно зависнуть
|
||||
expect(gate.isPaused).toBe(false)
|
||||
})
|
||||
})
|
||||
+26
-3
@@ -51,14 +51,20 @@ function makeBlock(blockNum = 1, numTraces = 0, numDeltas = 0): ShipBlock {
|
||||
head: blockPosition,
|
||||
lastIrreversible: blockPosition,
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: Array.from({ length: numTraces }, (_, i) => ({
|
||||
account: 'eosio.token',
|
||||
name: 'transfer',
|
||||
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',
|
||||
@@ -170,14 +176,20 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => {
|
||||
head: { blockNum: 500, blockId: 'c'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 500, blockId: 'c'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: [{
|
||||
account: 'eosio',
|
||||
name: 'setabi',
|
||||
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',
|
||||
@@ -204,14 +216,20 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => {
|
||||
head: { blockNum: 501, blockId: 'c'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 501, blockId: 'c'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: [{
|
||||
account: 'eosio',
|
||||
name: 'setabi',
|
||||
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',
|
||||
@@ -238,6 +256,7 @@ describe('BlockProcessor — ABI updates (Story 4.3)', () => {
|
||||
head: { blockNum: 600, blockId: 'e'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 600, blockId: 'e'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: [],
|
||||
deltas: [{
|
||||
name: 'account' as never,
|
||||
@@ -268,6 +287,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
|
||||
head: { blockNum: 10, blockId: 'a'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 10, blockId: 'a'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: [],
|
||||
deltas: nativeTableNames.map(name => ({
|
||||
name: name as never,
|
||||
@@ -323,9 +343,10 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
|
||||
head: { blockNum: 20, blockId: 'b'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 20, blockId: 'b'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
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 +376,8 @@ 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) }],
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
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]) },
|
||||
@@ -375,6 +397,7 @@ describe('BlockProcessor — native deltas (Epic 6)', () => {
|
||||
head: { blockNum: 700, blockId: 'f'.repeat(64) },
|
||||
lastIrreversible: { blockNum: 700, blockId: 'f'.repeat(64) },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T12:00:00.000',
|
||||
traces: [],
|
||||
deltas: [{ name: 'account' as never, present: true, rowRaw: new Uint8Array([1, 2, 3]) }],
|
||||
}
|
||||
+1
-1
@@ -116,7 +116,7 @@ describe('listDeadLetters — --all flag', () => {
|
||||
})
|
||||
|
||||
it('calls scan with correct pattern for --all', async () => {
|
||||
const redis = makeRedis({ scanResult: ['ce:parser:mainnet:dead:sub1'] })
|
||||
const redis = makeRedis({ scanResult: ['ce:parser2:mainnet:dead:sub1'] })
|
||||
vi.spyOn(redis, 'xlen').mockResolvedValue(0)
|
||||
vi.spyOn(redis, 'xrange').mockResolvedValue([])
|
||||
const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
@@ -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 = {
|
||||
+37
-7
@@ -5,6 +5,8 @@ vi.mock('ioredis', () => {
|
||||
const mockRedis = {
|
||||
xadd: vi.fn().mockResolvedValue('1700000000000-0'),
|
||||
xtrim: vi.fn().mockResolvedValue(5),
|
||||
xreadgroup: vi.fn().mockResolvedValue(null),
|
||||
xpending: vi.fn().mockResolvedValue([0, null, null, null]),
|
||||
zadd: vi.fn().mockResolvedValue(1),
|
||||
zrangebyscore: vi.fn().mockResolvedValue(['{"version":"eosio::abi/1.0"}']),
|
||||
zrevrangebyscore: vi.fn().mockResolvedValue(['{"version":"eosio::abi/1.0"}']),
|
||||
@@ -37,24 +39,52 @@ describe('IoRedisStore', () => {
|
||||
expect(store.client.xtrim).toHaveBeenCalledWith('mystream', 'MINID', '1234567890000-0')
|
||||
})
|
||||
|
||||
it('xreadGroup skips trimmed entries with null fields (no crash — баг #3)', async () => {
|
||||
// Redis отдаёт [id, null] для обрезанных/удалённых ID при чтении PEL.
|
||||
// Раньше rawFields.length на null убивал consumer навсегда.
|
||||
vi.mocked(store.client.xreadgroup).mockResolvedValueOnce([
|
||||
['mystream', [
|
||||
['100-0', ['kind', 'action']],
|
||||
['101-0', null], // обрезанная запись (XTRIM/XDEL)
|
||||
['102-0', ['kind', 'delta']],
|
||||
]],
|
||||
])
|
||||
const msgs = await store.xreadGroup('mystream', 'g', 'c', 10, 0, '0')
|
||||
expect(msgs).toHaveLength(2)
|
||||
expect(msgs.map(m => m.id)).toEqual(['100-0', '102-0'])
|
||||
expect(msgs[0]!.fields).toEqual({ kind: 'action' })
|
||||
})
|
||||
|
||||
it('xpendingMinId returns oldest pending id from XPENDING summary', async () => {
|
||||
vi.mocked(store.client.xpending).mockResolvedValueOnce([5, '90-0', '100-0', [['c', '5']]])
|
||||
const id = await store.xpendingMinId('mystream', 'g')
|
||||
expect(id).toBe('90-0')
|
||||
})
|
||||
|
||||
it('xpendingMinId returns null when group has no pending', async () => {
|
||||
vi.mocked(store.client.xpending).mockResolvedValueOnce([0, null, null, null])
|
||||
const id = await store.xpendingMinId('mystream', 'g')
|
||||
expect(id).toBeNull()
|
||||
})
|
||||
|
||||
it('zadd calls redis.zadd with score and member', async () => {
|
||||
await store.zadd('parser:abi:eosio', 100, '{"version":"eosio::abi/1.0"}')
|
||||
expect(store.client.zadd).toHaveBeenCalledWith('parser:abi:eosio', 100, '{"version":"eosio::abi/1.0"}')
|
||||
await store.zadd('parser2:abi:eosio', 100, '{"version":"eosio::abi/1.0"}')
|
||||
expect(store.client.zadd).toHaveBeenCalledWith('parser2:abi:eosio', 100, '{"version":"eosio::abi/1.0"}')
|
||||
})
|
||||
|
||||
it('zrangeByscoreRev calls redis.zrevrangebyscore with max first, then min', async () => {
|
||||
const result = await store.zrangeByscoreRev('parser:abi:eosio', '250', '-inf')
|
||||
const result = await store.zrangeByscoreRev('parser2:abi:eosio', '250', '-inf')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(store.client.zrevrangebyscore).toHaveBeenCalledWith('parser:abi:eosio', '250', '-inf', 'LIMIT', 0, 1)
|
||||
expect(store.client.zrevrangebyscore).toHaveBeenCalledWith('parser2:abi:eosio', '250', '-inf', 'LIMIT', 0, 1)
|
||||
})
|
||||
|
||||
it('hset calls redis.hset with flattened args', async () => {
|
||||
await store.hset('parser:sync:mychain', { block_num: '100', block_id: 'abc' })
|
||||
expect(store.client.hset).toHaveBeenCalledWith('parser:sync:mychain', 'block_num', '100', 'block_id', 'abc')
|
||||
await store.hset('parser2:sync:mychain', { block_num: '100', block_id: 'abc' })
|
||||
expect(store.client.hset).toHaveBeenCalledWith('parser2:sync:mychain', 'block_num', '100', 'block_id', 'abc')
|
||||
})
|
||||
|
||||
it('hget returns value', async () => {
|
||||
const val = await store.hget('parser:sync:mychain', 'block_num')
|
||||
const val = await store.hget('parser2:sync:mychain', 'block_num')
|
||||
expect(val).toBe('100')
|
||||
})
|
||||
|
||||
+3
-2
@@ -6,7 +6,7 @@
|
||||
* streams/locks и отслеживаем ack.
|
||||
*
|
||||
* Покрываем:
|
||||
* - Регистрация подписки в parser:subs
|
||||
* - Регистрация подписки в parser2:subs
|
||||
* - Определение startId по startFrom
|
||||
* - Ожидание промотации если lock занят
|
||||
* - Применение фильтров: не-matching события XACK'аются молча
|
||||
@@ -69,6 +69,7 @@ class FakeRedis implements RedisStore {
|
||||
xlen = vi.fn(async (): Promise<number> => 0)
|
||||
xdel = vi.fn(async (): Promise<number> => 0)
|
||||
xack = vi.fn(async (): Promise<void> => {})
|
||||
xpendingMinId = vi.fn(async (): Promise<string | null> => null)
|
||||
|
||||
zadd = vi.fn(async (key: string, score: number, member: string): Promise<void> => {
|
||||
if (!this.zsets.has(key)) this.zsets.set(key, new Map())
|
||||
@@ -185,7 +186,7 @@ describe('ParserClient — subscription registration', () => {
|
||||
beforeEach(() => { fakeRedisInstances.length = 0 })
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('registers subscription metadata in parser:subs hash on stream() start', async () => {
|
||||
it('registers subscription metadata in parser2:subs hash on stream() start', async () => {
|
||||
const client = new ParserClient({
|
||||
subscriptionId: 'my-sub',
|
||||
filters: [{ kind: 'action', account: 'eosio.token' }],
|
||||
+7
-7
@@ -17,36 +17,36 @@ describe('createParserMetrics', () => {
|
||||
})
|
||||
|
||||
it('registry contains exactly 10 metrics', async () => {
|
||||
const m = createParserMetrics('test_parser_count')
|
||||
const m = createParserMetrics('test_parser2_count')
|
||||
const all = await m.registry.getMetricsAsJSON()
|
||||
expect(all.length).toBe(10)
|
||||
})
|
||||
|
||||
it('blocksProcessedTotal increments', async () => {
|
||||
const m = createParserMetrics('test_parser_inc')
|
||||
const m = createParserMetrics('test_parser2_inc')
|
||||
m.blocksProcessedTotal.inc()
|
||||
m.blocksProcessedTotal.inc()
|
||||
const all = await m.registry.getMetricsAsJSON()
|
||||
const metric = all.find(x => x.name === 'test_parser_inc_blocks_processed_total')
|
||||
const metric = all.find(x => x.name === 'test_parser2_inc_blocks_processed_total')
|
||||
expect(metric?.values[0]?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('eventsPublishedTotal supports kind label', async () => {
|
||||
const m = createParserMetrics('test_parser_labels')
|
||||
const m = createParserMetrics('test_parser2_labels')
|
||||
m.eventsPublishedTotal.inc({ kind: 'action' })
|
||||
m.eventsPublishedTotal.inc({ kind: 'delta' })
|
||||
m.eventsPublishedTotal.inc({ kind: 'action' })
|
||||
const all = await m.registry.getMetricsAsJSON()
|
||||
const metric = all.find(x => x.name === 'test_parser_labels_events_published_total')
|
||||
const metric = all.find(x => x.name === 'test_parser2_labels_events_published_total')
|
||||
const actions = metric?.values.find(v => v.labels['kind'] === 'action')
|
||||
expect(actions?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('indexingLagSeconds can be set to a float', async () => {
|
||||
const m = createParserMetrics('test_parser_lag')
|
||||
const m = createParserMetrics('test_parser2_lag')
|
||||
m.indexingLagSeconds.set(3.5)
|
||||
const all = await m.registry.getMetricsAsJSON()
|
||||
const metric = all.find(x => x.name === 'test_parser_lag_indexing_lag_seconds')
|
||||
const metric = all.find(x => x.name === 'test_parser2_lag_indexing_lag_seconds')
|
||||
expect(metric?.values[0]?.value).toBe(3.5)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { ReconnectSupervisor } from '../../src/core/ReconnectSupervisor.js'
|
||||
|
||||
describe('ReconnectSupervisor — success on first try', () => {
|
||||
it('returns the result without retrying', async () => {
|
||||
const sup = new ReconnectSupervisor({ backoffSeconds: [0] })
|
||||
const result = await sup.run(async () => 42)
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReconnectSupervisor — retries on failure', () => {
|
||||
it('retries and succeeds on second attempt', async () => {
|
||||
const sup = new ReconnectSupervisor({ maxAttempts: 5, backoffSeconds: [0] })
|
||||
let calls = 0
|
||||
const result = await sup.run(async () => {
|
||||
calls++
|
||||
if (calls < 2) throw new Error('fail')
|
||||
return 'ok'
|
||||
})
|
||||
expect(result).toBe('ok')
|
||||
expect(calls).toBe(2)
|
||||
})
|
||||
|
||||
it('calls onAttempt with correct attempt number and delay', async () => {
|
||||
const attempts: Array<{ attempt: number; delayMs: number }> = []
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 5,
|
||||
backoffSeconds: [0, 0],
|
||||
onAttempt: (attempt, delayMs) => attempts.push({ attempt, delayMs }),
|
||||
})
|
||||
let calls = 0
|
||||
await sup.run(async () => {
|
||||
calls++
|
||||
if (calls < 3) throw new Error('fail')
|
||||
return 'done'
|
||||
})
|
||||
expect(attempts).toHaveLength(2)
|
||||
expect(attempts[0]?.attempt).toBe(1)
|
||||
expect(attempts[1]?.attempt).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReconnectSupervisor — exhaustion', () => {
|
||||
it('calls onGiveUp and throws after maxAttempts', async () => {
|
||||
let gaveUp = false
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
await expect(
|
||||
sup.run(async () => { throw new Error('always fails') })
|
||||
).rejects.toThrow()
|
||||
expect(gaveUp).toBe(true)
|
||||
})
|
||||
|
||||
it('uses the last backoff value when attempts exceed backoff array length', async () => {
|
||||
const delays: number[] = []
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 5,
|
||||
backoffSeconds: [0, 0],
|
||||
onAttempt: (_, ms) => delays.push(ms),
|
||||
onGiveUp: () => { throw new Error('gave up') },
|
||||
})
|
||||
await expect(
|
||||
sup.run(async () => { throw new Error('always fails') })
|
||||
).rejects.toThrow()
|
||||
// 4 retries (attempts 1..4) — last 2 should clamp to backoff[1]=0
|
||||
expect(delays).toHaveLength(4)
|
||||
delays.forEach(d => expect(d).toBe(0))
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReconnectSupervisor — resetBackoff (прогресс сбрасывает счётчик)', () => {
|
||||
it('передаёт resetBackoff в fn', async () => {
|
||||
const sup = new ReconnectSupervisor({ backoffSeconds: [0] })
|
||||
let gotCallback = false
|
||||
await sup.run(async (reset) => {
|
||||
gotCallback = typeof reset === 'function'
|
||||
return 'ok'
|
||||
})
|
||||
expect(gotCallback).toBe(true)
|
||||
})
|
||||
|
||||
it('не вызывает onGiveUp пока fn делает прогресс между разрывами', async () => {
|
||||
// Сценарий: соединение каждый раз отдаёт «блок» (прогресс), затем рвётся.
|
||||
// Без сброса счётчик дошёл бы до maxAttempts=3 и завершил процесс. С resetBackoff
|
||||
// на каждом прогрессе счётчик возвращается в 0 — даём 10 циклов разрыва, всё живёт.
|
||||
let gaveUp = false
|
||||
let drops = 0
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
await sup.run(async (reset) => {
|
||||
reset() // обработали блок — прогресс есть
|
||||
drops++
|
||||
if (drops < 10) throw new Error('node dropped') // разрыв
|
||||
return 'done'
|
||||
})
|
||||
expect(gaveUp).toBe(false)
|
||||
expect(drops).toBe(10)
|
||||
})
|
||||
|
||||
it('БЕЗ прогресса исчерпывает попытки и вызывает onGiveUp', async () => {
|
||||
// Контроль к предыдущему: мёртвая нода (resetBackoff не зовётся) → exit.
|
||||
let gaveUp = false
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
await expect(
|
||||
sup.run(async () => { throw new Error('dead node') }),
|
||||
).rejects.toThrow()
|
||||
expect(gaveUp).toBe(true)
|
||||
})
|
||||
|
||||
it('resetBackoff возвращает нумерацию onAttempt в начало', async () => {
|
||||
const attempts: number[] = []
|
||||
const sup = new ReconnectSupervisor({
|
||||
maxAttempts: 10,
|
||||
backoffSeconds: [0],
|
||||
onAttempt: (attempt) => attempts.push(attempt),
|
||||
})
|
||||
let calls = 0
|
||||
await sup.run(async (reset) => {
|
||||
calls++
|
||||
// calls: 1 throw → attempt 1; 2 throw → attempt 2; 3 reset+throw → attempt 1 снова; 4 ok
|
||||
if (calls === 3) reset()
|
||||
if (calls < 4) throw new Error('fail')
|
||||
return 'ok'
|
||||
})
|
||||
// attempt-числа: [1, 2, 1] — третий разрыв после reset снова стартует с 1
|
||||
expect(attempts).toEqual([1, 2, 1])
|
||||
})
|
||||
})
|
||||
+23
-23
@@ -9,65 +9,65 @@ import { describe, it, expect } from 'vitest'
|
||||
import { RedisKeys } from '../../src/redis/keys.js'
|
||||
|
||||
describe('RedisKeys — event streams', () => {
|
||||
it('eventsStream embeds chainId in ce:parser:<chainId>:events', () => {
|
||||
expect(RedisKeys.eventsStream('eos-mainnet')).toBe('ce:parser:eos-mainnet:events')
|
||||
expect(RedisKeys.eventsStream('telos-test')).toBe('ce:parser:telos-test:events')
|
||||
it('eventsStream embeds chainId in ce:parser2:<chainId>:events', () => {
|
||||
expect(RedisKeys.eventsStream('eos-mainnet')).toBe('ce:parser2:eos-mainnet:events')
|
||||
expect(RedisKeys.eventsStream('telos-test')).toBe('ce:parser2:telos-test:events')
|
||||
})
|
||||
|
||||
it('deadLetterStream includes both chainId and subId', () => {
|
||||
expect(RedisKeys.deadLetterStream('eos', 'verifier'))
|
||||
.toBe('ce:parser:eos:dead:verifier')
|
||||
.toBe('ce:parser2:eos:dead:verifier')
|
||||
})
|
||||
|
||||
it('reparseStream includes chainId and jobId', () => {
|
||||
expect(RedisKeys.reparseStream('eos', 'job-42'))
|
||||
.toBe('ce:parser:eos:reparse:job-42')
|
||||
.toBe('ce:parser2:eos:reparse:job-42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RedisKeys — ABI and sync', () => {
|
||||
it('abiZset is namespaced by contract name', () => {
|
||||
expect(RedisKeys.abiZset('eosio.token')).toBe('parser:abi:eosio.token')
|
||||
expect(RedisKeys.abiZset('eosio')).toBe('parser:abi:eosio')
|
||||
expect(RedisKeys.abiZset('eosio.token')).toBe('parser2:abi:eosio.token')
|
||||
expect(RedisKeys.abiZset('eosio')).toBe('parser2:abi:eosio')
|
||||
})
|
||||
|
||||
it('syncHash is keyed by chainId only', () => {
|
||||
expect(RedisKeys.syncHash('eos-mainnet')).toBe('parser:sync:eos-mainnet')
|
||||
expect(RedisKeys.syncHash('eos-mainnet')).toBe('parser2:sync:eos-mainnet')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RedisKeys — subscriptions', () => {
|
||||
it('subsHash is a global registry (no params)', () => {
|
||||
expect(RedisKeys.subsHash()).toBe('parser:subs')
|
||||
expect(RedisKeys.subsHash()).toBe('parser2:subs')
|
||||
})
|
||||
|
||||
it('subFailuresHash is keyed by subId', () => {
|
||||
expect(RedisKeys.subFailuresHash('verifier')).toBe('parser:sub:verifier:failures')
|
||||
expect(RedisKeys.subFailuresHash('verifier')).toBe('parser2:sub:verifier:failures')
|
||||
})
|
||||
|
||||
it('subLock is keyed by subId', () => {
|
||||
expect(RedisKeys.subLock('verifier')).toBe('parser:sub:verifier:lock')
|
||||
expect(RedisKeys.subLock('verifier')).toBe('parser2:sub:verifier:lock')
|
||||
})
|
||||
|
||||
it('reparseJobHash is keyed by jobId', () => {
|
||||
expect(RedisKeys.reparseJobHash('job-42')).toBe('parser:reparse:job-42')
|
||||
expect(RedisKeys.reparseJobHash('job-42')).toBe('parser2:reparse:job-42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RedisKeys — prefix namespacing invariants', () => {
|
||||
it('all stream keys start with ce:parser:', () => {
|
||||
expect(RedisKeys.eventsStream('x')).toMatch(/^ce:parser:/)
|
||||
expect(RedisKeys.deadLetterStream('x', 's')).toMatch(/^ce:parser:/)
|
||||
expect(RedisKeys.reparseStream('x', 'j')).toMatch(/^ce:parser:/)
|
||||
it('all stream keys start with ce:parser2:', () => {
|
||||
expect(RedisKeys.eventsStream('x')).toMatch(/^ce:parser2:/)
|
||||
expect(RedisKeys.deadLetterStream('x', 's')).toMatch(/^ce:parser2:/)
|
||||
expect(RedisKeys.reparseStream('x', 'j')).toMatch(/^ce:parser2:/)
|
||||
})
|
||||
|
||||
it('all non-stream keys start with parser:', () => {
|
||||
expect(RedisKeys.abiZset('x')).toMatch(/^parser:/)
|
||||
expect(RedisKeys.syncHash('x')).toMatch(/^parser:/)
|
||||
expect(RedisKeys.subsHash()).toMatch(/^parser:/)
|
||||
expect(RedisKeys.subFailuresHash('x')).toMatch(/^parser:/)
|
||||
expect(RedisKeys.subLock('x')).toMatch(/^parser:/)
|
||||
expect(RedisKeys.reparseJobHash('x')).toMatch(/^parser:/)
|
||||
it('all non-stream keys start with parser2:', () => {
|
||||
expect(RedisKeys.abiZset('x')).toMatch(/^parser2:/)
|
||||
expect(RedisKeys.syncHash('x')).toMatch(/^parser2:/)
|
||||
expect(RedisKeys.subsHash()).toMatch(/^parser2:/)
|
||||
expect(RedisKeys.subFailuresHash('x')).toMatch(/^parser2:/)
|
||||
expect(RedisKeys.subLock('x')).toMatch(/^parser2:/)
|
||||
expect(RedisKeys.reparseJobHash('x')).toMatch(/^parser2:/)
|
||||
})
|
||||
|
||||
it('keys for different chains are disjoint (no collision)', () => {
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Юнит-тесты цикла чтения с авто-переподключением (runStreamLoop) на фейках —
|
||||
* без реального Redis и SHiP-ноды.
|
||||
*
|
||||
* Покрываем то, ради чего цикл вынесен из Parser:
|
||||
* 1. разрыв ноды → backoff → переподключение, чтение продолжается;
|
||||
* 2. после разрыва позиция возобновления берётся из Redis (последний блок) —
|
||||
* без потерь и дублей;
|
||||
* 3. штатная остановка не вызывает reconnect;
|
||||
* 4. полностью мёртвая нода исчерпывает попытки и завершается (onGiveUp),
|
||||
* а НЕ долбит без пауз.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { GetBlocksOptions, ShipBlock } from '@coopenomics/coopos-ship-reader'
|
||||
import { runStreamLoop } from '../../src/core/streamLoop.js'
|
||||
import type { StreamLoopChainClient, StreamLoopRedis } from '../../src/core/streamLoop.js'
|
||||
import { ReconnectSupervisor } from '../../src/core/ReconnectSupervisor.js'
|
||||
|
||||
function makeBlock(n: number): ShipBlock {
|
||||
return {
|
||||
thisBlock: { blockNum: n, blockId: `id${n}` },
|
||||
lastIrreversible: { blockNum: n, blockId: `id${n}` },
|
||||
head: { blockNum: n, blockId: `id${n}` },
|
||||
prevBlock: null,
|
||||
blockTime: '2024-06-01T00:00:00.000',
|
||||
traces: [],
|
||||
deltas: [],
|
||||
} as unknown as ShipBlock
|
||||
}
|
||||
|
||||
function makeRedis(): StreamLoopRedis & { hash: Record<string, Record<string, string>> } {
|
||||
const hash: Record<string, Record<string, string>> = {}
|
||||
return {
|
||||
hash,
|
||||
async hget(key, field) { return hash[key]?.[field] ?? null },
|
||||
async hset(key, fields) { hash[key] = { ...(hash[key] ?? {}), ...fields }; return 'OK' },
|
||||
async xadd() { return '1-0' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Скриптованный SHiP-клиент: массив сессий, каждая — список блоков + рвётся/нет. */
|
||||
function makeChainClient(sessions: Array<{ blocks: number[]; drop: boolean }>) {
|
||||
const captured: GetBlocksOptions[] = []
|
||||
let session = 0
|
||||
let connectCalls = 0
|
||||
const client: StreamLoopChainClient = {
|
||||
async connect() { connectCalls++ },
|
||||
ack() {},
|
||||
async *streamBlocks(opts: GetBlocksOptions) {
|
||||
captured.push(opts)
|
||||
const s = sessions[session++]
|
||||
if (!s) return
|
||||
for (const n of s.blocks) yield makeBlock(n)
|
||||
if (s.drop) throw new Error('node dropped')
|
||||
},
|
||||
}
|
||||
return {
|
||||
client,
|
||||
captured,
|
||||
get connectCalls() { return connectCalls },
|
||||
}
|
||||
}
|
||||
|
||||
const baseDeps = (over: Partial<Parameters<typeof runStreamLoop>[0]>): Parameters<typeof runStreamLoop>[0] => ({
|
||||
chainClient: over.chainClient!,
|
||||
redis: over.redis!,
|
||||
blockProcessor: over.blockProcessor ?? { process: async () => [] },
|
||||
backpressureGate: null,
|
||||
forkDetector: { check: () => null },
|
||||
supervisor: over.supervisor ?? new ReconnectSupervisor({ maxAttempts: 5, backoffSeconds: [0] }),
|
||||
syncKey: 'sync',
|
||||
eventsStream: 'events',
|
||||
irreversibleOnly: false,
|
||||
eventToFields: () => ({ data: '{}' }),
|
||||
isStopped: over.isStopped ?? (() => false),
|
||||
log: null,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('runStreamLoop — переподключение после разрыва', () => {
|
||||
it('после разрыва переподключается и продолжает с последнего записанного блока', async () => {
|
||||
const redis = makeRedis()
|
||||
const cc = makeChainClient([
|
||||
{ blocks: [1, 2, 3], drop: true }, // упала после блока 3
|
||||
{ blocks: [4, 5], drop: false }, // реконнект → дочитали и штатно завершились
|
||||
])
|
||||
|
||||
await runStreamLoop(baseDeps({ chainClient: cc.client, redis }))
|
||||
|
||||
// Подключались дважды: первичное + 1 реконнект
|
||||
expect(cc.connectCalls).toBe(2)
|
||||
expect(cc.captured).toHaveLength(2)
|
||||
// Первая сессия — с нуля (позиции в Redis ещё нет)
|
||||
expect(cc.captured[0]?.startBlock).toBe(0)
|
||||
// Вторая — возобновление ровно с блока 3 (последнего записанного), без потерь/дублей
|
||||
expect(cc.captured[1]?.startBlock).toBe(3)
|
||||
expect(cc.captured[1]?.havePositions).toEqual([{ blockNum: 3, blockId: 'id3' }])
|
||||
// Финальная позиция — блок 5
|
||||
expect(redis.hash['sync']?.['block_num']).toBe('5')
|
||||
})
|
||||
|
||||
it('штатная остановка не вызывает reconnect', async () => {
|
||||
const redis = makeRedis()
|
||||
const cc = makeChainClient([{ blocks: [1, 2, 3, 4], drop: false }])
|
||||
let processed = 0
|
||||
const deps = baseDeps({
|
||||
chainClient: cc.client,
|
||||
redis,
|
||||
blockProcessor: { process: async () => { processed++; return [] } },
|
||||
isStopped: () => processed >= 2, // останавливаемся после 2 блоков
|
||||
})
|
||||
|
||||
await runStreamLoop(deps)
|
||||
|
||||
expect(cc.connectCalls).toBe(1) // без переподключений
|
||||
expect(redis.hash['sync']?.['block_num']).toBe('2')
|
||||
})
|
||||
|
||||
it('мёртвая нода исчерпывает попытки и вызывает onGiveUp (не долбит без пауз)', async () => {
|
||||
const redis = makeRedis()
|
||||
const cc = makeChainClient([
|
||||
{ blocks: [], drop: true },
|
||||
{ blocks: [], drop: true },
|
||||
{ blocks: [], drop: true },
|
||||
{ blocks: [], drop: true },
|
||||
])
|
||||
let gaveUp = false
|
||||
const supervisor = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
|
||||
await expect(
|
||||
runStreamLoop(baseDeps({ chainClient: cc.client, redis, supervisor })),
|
||||
).rejects.toThrow()
|
||||
expect(gaveUp).toBe(true)
|
||||
})
|
||||
|
||||
it('долгая стабильная сессия после редкого разрыва не копит попытки до выхода', async () => {
|
||||
// 6 разрывов подряд, но каждая сессия отдаёт блок (прогресс) → resetBackoff
|
||||
// держит счётчик у нуля. maxAttempts=3 НЕ срабатывает, т.к. между разрывами прогресс.
|
||||
const redis = makeRedis()
|
||||
const sessions = Array.from({ length: 6 }, (_, i) => ({ blocks: [i + 1], drop: i < 5 }))
|
||||
const cc = makeChainClient(sessions)
|
||||
let gaveUp = false
|
||||
const supervisor = new ReconnectSupervisor({
|
||||
maxAttempts: 3,
|
||||
backoffSeconds: [0],
|
||||
onGiveUp: () => { gaveUp = true; throw new Error('gave up') },
|
||||
})
|
||||
|
||||
await runStreamLoop(baseDeps({ chainClient: cc.client, redis, supervisor }))
|
||||
|
||||
expect(gaveUp).toBe(false)
|
||||
expect(cc.connectCalls).toBe(6)
|
||||
expect(redis.hash['sync']?.['block_num']).toBe('6')
|
||||
})
|
||||
})
|
||||
+93
-24
@@ -3,20 +3,28 @@
|
||||
*
|
||||
* Проверяем:
|
||||
* - start/stop идемпотентны
|
||||
* - trim использует MINID по отстающей группе с pending > 0
|
||||
* - trim использует MINID по самому старому un-acked pending ID (XPENDING),
|
||||
* а НЕ по lastDeliveredId (иначе сносит собственные pending — баг #3)
|
||||
* - числовое сравнение ID (а не лексикографическое)
|
||||
* - группы без pending не мешают trim'у
|
||||
* - XTRIM не вызывается если групп нет или ни у одной нет pending
|
||||
* - ошибки в xinfoGroups не прерывают supervisor
|
||||
* - XTRIM не вызывается если групп нет, ни у одной нет pending, или pending-ID null
|
||||
* - ошибки в xinfoGroups/xtrim не прерывают supervisor
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { XtrimSupervisor } from '../../src/core/XtrimSupervisor.js'
|
||||
import type { RedisStore, XGroupInfo } from '../../src/ports/RedisStore.js'
|
||||
|
||||
function makeRedis(groups: XGroupInfo[] = []): RedisStore {
|
||||
function makeRedis(
|
||||
groups: XGroupInfo[] = [],
|
||||
pendingIds: Record<string, string | null> = {},
|
||||
): RedisStore {
|
||||
return {
|
||||
xinfoGroups: vi.fn().mockResolvedValue(groups),
|
||||
xtrim: vi.fn().mockResolvedValue(0),
|
||||
xpendingMinId: vi.fn().mockImplementation((_stream: string, group: string) =>
|
||||
Promise.resolve(pendingIds[group] ?? null),
|
||||
),
|
||||
// остальные методы не используются в supervisor
|
||||
} as unknown as RedisStore
|
||||
}
|
||||
@@ -25,6 +33,7 @@ function makeRedis(groups: XGroupInfo[] = []): RedisStore {
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('XtrimSupervisor — lifecycle', () => {
|
||||
@@ -124,36 +133,95 @@ describe('XtrimSupervisor — trim logic', () => {
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
it('trims to minimum lastDeliveredId among groups with pending > 0', async () => {
|
||||
// g1 pending но отстал на 100 — должен стать minId
|
||||
// g2 pending и догнал до 500 — игнорируется для выбора min
|
||||
// g3 нет pending — вообще не учитывается
|
||||
const redis = makeRedis([
|
||||
{ name: 'g1', pending: 5, lastDeliveredId: '100-0', lag: 5, consumers: 1 },
|
||||
{ name: 'g2', pending: 2, lastDeliveredId: '500-0', lag: 2, consumers: 1 },
|
||||
{ name: 'g3', pending: 0, lastDeliveredId: '50-0', lag: 0, consumers: 1 },
|
||||
])
|
||||
it('trims to the OLDEST un-acked pending ID (XPENDING), NOT lastDeliveredId', async () => {
|
||||
// Регресс-гард на баг #3: pending-записи старше lastDeliveredId.
|
||||
// g1: lastDelivered=100-0, но самый старый un-acked pending = 90-0.
|
||||
// Триммить надо по 90-0, иначе un-acked 90..99 теряются и consumer падает.
|
||||
const redis = makeRedis(
|
||||
[{ name: 'g1', pending: 5, lastDeliveredId: '100-0', lag: 5, consumers: 1 }],
|
||||
{ g1: '90-0' },
|
||||
)
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
sup.start()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await flushPromises()
|
||||
|
||||
expect(redis.xtrim).toHaveBeenCalledTimes(1)
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '100-0')
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '90-0')
|
||||
// именно НЕ lastDeliveredId
|
||||
expect(redis.xtrim).not.toHaveBeenCalledWith('s', '100-0')
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
it('uses lexicographic comparison for stream IDs (which is also numeric-correct for same-length)', async () => {
|
||||
const redis = makeRedis([
|
||||
{ name: 'g1', pending: 1, lastDeliveredId: '1000-0', lag: 1, consumers: 1 },
|
||||
{ name: 'g2', pending: 1, lastDeliveredId: '999-0', lag: 1, consumers: 1 },
|
||||
])
|
||||
it('trims to minimum oldest-pending-id among groups with pending > 0', async () => {
|
||||
// g1 отстал (oldest pending 90-0) → станет minId
|
||||
// g2 догнал (oldest pending 480-0) → не минимум
|
||||
// g3 нет pending → не учитывается (xpendingMinId не зовётся)
|
||||
const redis = makeRedis(
|
||||
[
|
||||
{ name: 'g1', pending: 5, lastDeliveredId: '100-0', lag: 5, consumers: 1 },
|
||||
{ name: 'g2', pending: 2, lastDeliveredId: '500-0', lag: 2, consumers: 1 },
|
||||
{ name: 'g3', pending: 0, lastDeliveredId: '50-0', lag: 0, consumers: 1 },
|
||||
],
|
||||
{ g1: '90-0', g2: '480-0' },
|
||||
)
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
sup.start()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await flushPromises()
|
||||
// lexicographic: '1000-0' < '999-0' → выбран '1000-0'
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '1000-0')
|
||||
|
||||
expect(redis.xtrim).toHaveBeenCalledTimes(1)
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '90-0')
|
||||
// g3 без pending — XPENDING не запрашивался
|
||||
expect(redis.xpendingMinId).not.toHaveBeenCalledWith('s', 'g3')
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
it('uses NUMERIC (not lexicographic) comparison for stream IDs', async () => {
|
||||
// Лексикографически '1000-0' < '999-0' (неверно). Численно 999 < 1000.
|
||||
const redis = makeRedis(
|
||||
[
|
||||
{ name: 'g1', pending: 1, lastDeliveredId: '1000-0', lag: 1, consumers: 1 },
|
||||
{ name: 'g2', pending: 1, lastDeliveredId: '999-0', lag: 1, consumers: 1 },
|
||||
],
|
||||
{ g1: '1000-0', g2: '999-0' },
|
||||
)
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
sup.start()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await flushPromises()
|
||||
// численный минимум = '999-0'
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '999-0')
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
it('compares by sequence when ms part is equal', async () => {
|
||||
const redis = makeRedis(
|
||||
[
|
||||
{ name: 'g1', pending: 1, lastDeliveredId: '5-9', lag: 1, consumers: 1 },
|
||||
{ name: 'g2', pending: 1, lastDeliveredId: '5-2', lag: 1, consumers: 1 },
|
||||
],
|
||||
{ g1: '5-9', g2: '5-2' },
|
||||
)
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
sup.start()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await flushPromises()
|
||||
expect(redis.xtrim).toHaveBeenCalledWith('s', '5-2')
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
it('skips trim when pending groups exist but XPENDING returns null (race)', async () => {
|
||||
// pending>0 в XINFO, но к моменту XPENDING всё подтверждено → null → не триммим
|
||||
const redis = makeRedis(
|
||||
[{ name: 'g1', pending: 3, lastDeliveredId: '100-0', lag: 3, consumers: 1 }],
|
||||
{ g1: null },
|
||||
)
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
sup.start()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await flushPromises()
|
||||
expect(redis.xtrim).not.toHaveBeenCalled()
|
||||
sup.stop()
|
||||
})
|
||||
|
||||
@@ -173,9 +241,10 @@ describe('XtrimSupervisor — trim logic', () => {
|
||||
})
|
||||
|
||||
it('silently swallows errors from xtrim itself', async () => {
|
||||
const redis = makeRedis([
|
||||
{ name: 'g1', pending: 1, lastDeliveredId: '100-0', lag: 1, consumers: 1 },
|
||||
])
|
||||
const redis = makeRedis(
|
||||
[{ name: 'g1', pending: 1, lastDeliveredId: '100-0', lag: 1, consumers: 1 }],
|
||||
{ g1: '95-0' },
|
||||
)
|
||||
;(redis.xtrim as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('xtrim failed'))
|
||||
|
||||
const sup = new XtrimSupervisor({ redis, stream: 's', intervalMs: 100 })
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains. Streams blockchain blocks, deserializes actions and deltas via `@wharfkit/antelope`, with support for all 24 native system table delta types.
|
||||
|
||||
Primary consumer: [`@coopenomics/parser`](https://github.com/coopenomics/parser).
|
||||
Primary consumer: [`@coopenomics/parser2`](https://github.com/coopenomics/parser2).
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/coopos-ship-reader",
|
||||
"version": "0.1.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains",
|
||||
"license": "MIT",
|
||||
"author": "Coopenomics contributors",
|
||||
|
||||
@@ -79,7 +79,6 @@ export async function* createBlockStream(
|
||||
}
|
||||
|
||||
let blockNum = opts.startBlock
|
||||
let blockTime = new Date().toISOString()
|
||||
|
||||
while (true) {
|
||||
const msg = await nextMessage()
|
||||
@@ -91,7 +90,7 @@ export async function* createBlockStream(
|
||||
const [type, raw] = decodeResult(new Uint8Array(msg), abi)
|
||||
if (type !== 'get_blocks_result_v0') continue
|
||||
|
||||
const block = decodeBlocksResult(raw, abi, blockNum, '', blockTime)
|
||||
const block = decodeBlocksResult(raw, abi, blockNum, '')
|
||||
blockNum = block.thisBlock.blockNum
|
||||
|
||||
yield block
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ABI } from '@wharfkit/antelope'
|
||||
import type { ShipDelta } from './types/ship.js'
|
||||
import type { NativeDeltaEvent } from './native-tables/index.js'
|
||||
import { isNativeTableName } from './native-tables/index.js'
|
||||
@@ -10,9 +11,10 @@ export function filterNativeDeltas(deltas: readonly ShipDelta[]): ShipDelta[] {
|
||||
export function* streamNativeDeltas(
|
||||
deltas: readonly ShipDelta[],
|
||||
deserializer: WharfkitDeserializer,
|
||||
abi: ABI,
|
||||
): Generator<NativeDeltaEvent> {
|
||||
for (const delta of deltas) {
|
||||
if (!isNativeTableName(delta.name)) continue
|
||||
yield deserializer.deserializeNativeDelta(delta)
|
||||
yield deserializer.deserializeNativeDelta(delta, abi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export class ShipClient {
|
||||
this.deserializer = new WharfkitDeserializer()
|
||||
}
|
||||
|
||||
/**
|
||||
* SHiP-ABI (state_history), доступен после connect().
|
||||
* Нужен для десериализации нативных дельт (их строки сериализованы этим ABI).
|
||||
*/
|
||||
get abi(): ShipAbi {
|
||||
if (!this.shipAbi) throw new ShipConnectionError('Call connect() first')
|
||||
return this.shipAbi
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user