perf(assist): кэш индекса (TTL 10 мин) + предрасчёт списков + дедлайн 40с + логирование этапов

This commit is contained in:
Repinoid
2026-10-07 17:20:24 +03:00
parent 9728fe4cf8
commit 99e034dfeb
2 changed files with 53 additions and 5 deletions
+44 -2
View File
@@ -27,22 +27,35 @@ const HTTPS = require('https');
const TOTAL_DEADLINE_MS = ENV.ASSIST_TOTAL_DEADLINE_MS || 40000;
const RETRY_STAGE1_MIN_BUDGET_MS = 15000;
const STAND_RE = /^[A-Za-z0-9._-]+$/;
const INDEX_TTL_MS = Number(process.env.ASSIST_INDEX_TTL_MS) || 600000;
// Кэш индекса по stand (в памяти): экономит HTTP-запрос и парсинг на каждом вопросе.
const indexCache = new Map();
/**
* Загружает search_index.json с VM_DOCS_BASE_URL/<stand>/search/search_index.json.
* Кэширует результат в памяти (на стенд).
* Мемоизирует результат по stand с TTL. При сбое возвращает свежий кэш, если есть.
*
* @param {string} stand
* @param {string} vmDocsBaseUrl
* @returns {Promise<object|null>} - index из loadIndex() или null.
*/
async function fetchIndex(stand, vmDocsBaseUrl) {
const now = Date.now();
const cached = indexCache.get(stand);
if (cached && (now - cached.at) < INDEX_TTL_MS) {
return cached.index;
}
try {
const url = `${vmDocsBaseUrl.replace(/\/+$/, '')}/${stand}/search/search_index.json`;
const mod = url.startsWith('https:') ? HTTPS : HTTP;
const text = await httpGet(mod, url);
return loadIndex(text);
const index = loadIndex(text);
indexCache.set(stand, { index, at: now });
return index;
} catch (e) {
// Свежий кэш лучше ошибки: продолжаем работу на устаревшем индексе.
if (cached) return cached.index;
return null;
}
}
@@ -125,6 +138,9 @@ async function handleAssist(req, res, config) {
const start = Date.now();
let holderActive = true;
// Общий бюджет времени: по истечении прерываем вызовы ЛЛМ.
const deadlineTimer = setTimeout(() => controller.abort(), TOTAL_DEADLINE_MS);
// Прерывание LLM при закрытии соединения клиентом.
const onClose = () => {
inflightEntry.holderCount -= 1;
@@ -135,6 +151,7 @@ async function handleAssist(req, res, config) {
req.on('close', onClose);
const release = () => {
clearTimeout(deadlineTimer);
try { sem.release(); } catch { }
req.removeListener('close', onClose);
config.shared.inflight.delete(normKey);
@@ -149,17 +166,34 @@ async function handleAssist(req, res, config) {
cache.set(normKey, body);
sendJson(res, 200, body);
for (const w of inflightEntry.waiters) w.succeed(body);
logAssist('ok', stand, question.length, result.meta, Date.now() - start);
} catch (err) {
holderActive = false;
const code = err && err.code ? err.code : 'internal_error';
const status = mapPipelineError(code);
for (const w of inflightEntry.waiters) w.fail(status, code);
jsonError(res, status, code, err && err.message ? err.message : 'internal error');
logAssist('error', stand, question.length, { error: code }, Date.now() - start);
} finally {
release();
}
}
/** Однострочный JSON-лог без текста запроса (7.11 брифа). */
function logAssist(status, stand, questionLen, meta, totalMs) {
try {
const line = JSON.stringify({
t: new Date().toISOString(),
status,
stand,
question_len: questionLen,
total_ms: totalMs,
...meta,
});
process.stdout.write(line + '\n');
} catch { /* лог не должен ронять */ }
}
/** Возвращает IP клиента (7.3: X-Forwarded-For только для доверенных proxy). */
function clientIp(req) {
const trusted = (ENV.ASSIST_TRUSTED_PROXY_CIDR || '').trim();
@@ -202,6 +236,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
throw { code: 'index_unavailable', message: 'search index unavailable' };
}
const resourceTypes = index.listResourceTypes();
const tIndex = Date.now();
// Этап 1 (resource_type).
let resourceType = null;
@@ -215,11 +250,13 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
stage1Ok = r1.ok;
if (r1.ok && r1.resourceType) resourceType = r1.resourceType;
}
const tStage1 = Date.now();
// Поиск по токенам исходного запроса — всегда (параллельно уже можно, но для простоты
// объединяем в mergeSearchResults, который сам делает поиск по query и по resource_type).
const merged = mergeSearchResults(index, question, resourceType, { topK: 5 });
const context = buildContext(merged, 1500);
const tSearch = Date.now();
// Этап 2.
const r2 = await stage2Generate({
@@ -231,6 +268,7 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
maxTokens: 2048,
controller,
});
const tStage2 = Date.now();
if (!r2.ok) {
throw { code: r2.error, message: r2.error === 'bad_llm_output' ? 'bad llm output' : 'llm error' };
@@ -246,6 +284,10 @@ async function runPipeline(question, stand, vmDocsBaseUrl, controller, start) {
resource_type: resourceType,
stage1_ok: stage1Ok,
elapsed_ms: Date.now() - t1,
index_ms: tIndex - t1,
stage1_ms: tStage1 - tIndex,
search_ms: tSearch - tStage1,
stage2_ms: tStage2 - tSearch,
},
};
}
+9 -3
View File
@@ -145,13 +145,19 @@ function loadIndex(rawJson) {
index = { docs: Array.isArray(index) ? index : [] };
}
const docs = Array.isArray(index.docs) ? index.docs : [];
// Предрасчёт: списки ресурсов считаются ОДИН раз при загрузке индекса,
// а не на каждый запрос (экономия CPU на regex по всем докам).
const resourceTypes = extractResourceTypes({ docs });
const pages = resourcePages({ docs });
return {
docs: Array.isArray(index.docs) ? index.docs : [],
docs,
listResourceTypes() {
return extractResourceTypes({ docs: this.docs });
return resourceTypes;
},
resourcePages() {
return resourcePages({ docs: this.docs });
return pages;
},
};
}