[794-5][@ant] fix: current = high-water mark парсера + относительный API base — честный статус индекса
CI / build (push) Failing after 7s

Два дефекта отображения синка:

1. SPA: new URL(path, '') бросал TypeError при пустом VITE_API_BASE → health-проба
   всегда null → «индекс недоступен», хотя бэкенд synced. Фолбэк base на
   window.location.origin (vite-proxy/nginx шлёт /v1 на api). vite.config —
   фикс-порт 5174 (strictPort) + proxy /v1 → :3030, наружу один ssh-проброс.

2. health.current_block брался из последней строки permission_history → «висел» на
   блоке последней смены прав (6303) в тихие периоды и читался как зависание.
   Теперь current = block_num tip-записи стрима (getParserHead) = докуда реально
   обработал парсер; растёт у головы в реальном времени. Побочно чинит C6:
   beyond_index сравнивал at с устаревшим current_block_time (31 мая) и ложно
   отбраковывал свежие запросы.
This commit is contained in:
coopops
2026-06-03 17:00:22 +00:00
parent 73b3a235bf
commit 18471aeea6
4 changed files with 63 additions and 24 deletions
+13 -22
View File
@@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
import type { Database, KeyWeight } from './db/schema.js';
import { SYNC_KEY } from './config.js';
import { classify, type HealthView } from './services/health-status.js';
import { getStreamLag } from './services/stream-lag.js';
import { getStreamLag, getParserHead } from './services/stream-lag.js';
export interface CheckResultRow {
active: boolean;
@@ -89,32 +89,23 @@ export async function getHealth(db: Kysely<Database>): Promise<HealthView> {
.where('key', '=', SYNC_KEY)
.executeTakeFirst();
const { lag } = await getStreamLag();
const [{ lag }, parser] = await Promise.all([getStreamLag(), getParserHead()]);
if (!p) {
return {
head_block: 0,
head_block_time: null,
current_block: 0,
current_block_time: null,
behind_by: 0,
stream_lag: lag,
status: classify(lag),
bootstrap_done: false,
source: 'verifier-indexer',
};
}
// current = high-water mark парсера (докуда реально обработано), НЕ последняя запись
// permission_history — иначе в тихие периоды current «висит» на блоке последней смены прав.
const head = p ? Number(p.head_block_num) : 0;
const current = parser.block_num ?? (p ? Number(p.block_num) : 0);
const currentTime = parser.block_time ?? (p ? toIso(p.block_time) : null);
const behind_by = Math.max(0, Number(p.head_block_num) - Number(p.block_num));
return {
head_block: Number(p.head_block_num),
head_block_time: toIso(p.head_block_time),
current_block: Number(p.block_num),
current_block_time: toIso(p.block_time),
behind_by,
head_block: head,
head_block_time: p ? toIso(p.head_block_time) : null,
current_block: current,
current_block_time: currentTime,
behind_by: Math.max(0, head - current),
stream_lag: lag,
status: classify(lag),
bootstrap_done: p.bootstrap_done,
bootstrap_done: p ? p.bootstrap_done : false,
source: 'verifier-indexer',
};
}
+35
View File
@@ -58,6 +58,41 @@ export async function getStreamLag(): Promise<StreamLag> {
}
}
export interface ParserHead {
/** Последний блок, который парсер обработал и записал в стрим (high-water mark). */
block_num: number | null;
/** Время этого блока (UTC ISO). */
block_time: string | null;
}
/**
* High-water mark парсера = block_num последней записи стрима (tip).
*
* Зачем отдельно от permission-прогресса: индексатор пишет в БД только при смене прав,
* поэтому «последняя строка permission_history» застывает в тихие периоды и читается как
* зависание. Честное «докуда обработано» — то, что парсер уже опубликовал в стрим; consumer
* с lag=0 стоит на том же tip. Эта величина и есть UI-`current` и граница beyond_index для C6.
*/
export async function getParserHead(): Promise<ParserHead> {
try {
const raw = (await client().call('XREVRANGE', streamKey(), '+', '-', 'COUNT', '1')) as unknown[];
const entry = raw[0] as [string, string[]] | undefined;
if (!entry) return { block_num: null, block_time: null };
const fields = pairs(entry[1]);
const data = typeof fields.data === 'string' ? JSON.parse(fields.data) : null;
if (!data || typeof data.block_num !== 'number') return { block_num: null, block_time: null };
return { block_num: data.block_num, block_time: toUtc(data.block_time) };
} catch {
return { block_num: null, block_time: null };
}
}
/** block_time из цепи приходит без таймзоны (`2026-06-03T16:56:03.500`) — это UTC, помечаем Z. */
function toUtc(t: unknown): string | null {
if (typeof t !== 'string' || t.length === 0) return null;
return /[zZ]|[+-]\d\d:?\d\d$/.test(t) ? t : `${t}Z`;
}
export async function closeStreamLag(): Promise<void> {
if (redis) {
await redis.quit().catch(() => {});
+4 -1
View File
@@ -1,4 +1,7 @@
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:3030';
// Пустой VITE_API_BASE => относительные запросы от origin страницы (vite-proxy/прод-nginx
// шлёт /v1 на api). new URL() с пустым base бросает TypeError, поэтому фолбэк на origin.
const API_BASE =
import.meta.env.VITE_API_BASE || (typeof window !== 'undefined' ? window.location.origin : '');
export interface ActiveCheckResponse {
active: boolean;
+11 -1
View File
@@ -2,7 +2,17 @@ import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import tailwindcss from '@tailwindcss/vite';
// Фиксированный порт 5174 (strictPort — не «уползать» на 5175, чтобы ssh-проброс был стабилен).
// /v1 проксируется на локальный verifier-api :3030 — наружу нужен ОДИН проброс (5174),
// фронт ходит относительным путём (VITE_API_BASE='' в .env.local).
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: { port: 5173 },
server: {
host: true,
port: 5174,
strictPort: true,
proxy: {
'/v1': { target: 'http://localhost:3030', changeOrigin: true },
},
},
});