From 2a240cb9e9720e7dd6faf15c8951accc2b10e1ea Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Wed, 15 Apr 2026 14:00:12 +0900 Subject: [PATCH] =?UTF-8?q?fix(kordoc):=20adaptive=20parse=20timeout=20+?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=20=ED=8C=8C=EC=8B=B1=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kordoc의 30초 하드 타임아웃을 파일 크기 비례 adaptive(60~300초)로 변경. 대형 PDF/HWP가 파싱 타임아웃으로 영구 실패하던 문제 해결. - getParseTimeoutMs(): 10MB당 60초, 최소 60초, 최대 300초 - parseJobs Map 기반 동시 파싱 2건 제한 (유령 작업 누적 방지) - 상세 로그: START/DONE/ZOMBIE_DONE/REJECTED + ext/size/elapsed/active - clearTimeout으로 정상 완료 시 불필요한 타이머 콜백 정리 Co-Authored-By: Claude Opus 4.6 (1M context) --- services/kordoc/server.js | 108 ++++++++++++++++++++++++++++---------- 1 file changed, 81 insertions(+), 27 deletions(-) diff --git a/services/kordoc/server.js b/services/kordoc/server.js index 43f9288..5070811 100644 --- a/services/kordoc/server.js +++ b/services/kordoc/server.js @@ -9,7 +9,22 @@ import { parse, detectFormat } from 'kordoc'; const app = express(); const PORT = 3100; -const PARSE_TIMEOUT_MS = 30000; + +// 동시 파싱 제한 — kordoc parse()는 취소 불가(AbortSignal 미지원)이므로 +// Promise.race 타임아웃 후 유령 작업이 남을 수 있음. 슬롯 제한으로 누적 방지. +const MAX_CONCURRENT_PARSES = 2; +const parseJobs = new Map(); // parseId → { state: "running"|"timed_out" } + +function getParseTimeoutMs(fileSize) { + // 10MB당 60초, 최소 60초, 최대 300초 (extract_worker와 동일 공식) + return Math.min(300000, Math.max(60000, Math.floor(fileSize / (10 * 1024 * 1024)) * 60000 + 60000)); +} + +function releaseParseSlot(parseId) { + if (parseJobs.has(parseId)) { + parseJobs.delete(parseId); + } +} app.use(express.json({ limit: '500mb' })); @@ -73,35 +88,74 @@ app.post('/parse', async (req, res) => { // HWP/HWPX/PDF — kordoc 파싱 if (PARSEABLE_FORMATS.has(ext)) { - const buffer = fs.readFileSync(filePath); - - // 타임아웃 처리 - const result = await Promise.race([ - parse(buffer), - new Promise((_, reject) => - setTimeout(() => reject(new Error('파싱 타임아웃 (30초)')), PARSE_TIMEOUT_MS) - ), - ]); - - if (!result.success) { - return res.status(422).json({ - error: '문서 파싱 실패', - warnings: result.warnings || [], - }); + if (parseJobs.size >= MAX_CONCURRENT_PARSES) { + console.warn(`[PARSE] REJECTED ${filePath} — concurrent limit (${parseJobs.size}/${MAX_CONCURRENT_PARSES})`); + return res.status(503).json({ error: `동시 파싱 한도 초과 (${MAX_CONCURRENT_PARSES}건), 재시도 필요` }); } - return res.json({ - success: true, - markdown: result.markdown || '', - metadata: { - ...(result.metadata || {}), + const buffer = fs.readFileSync(filePath); + const parseTimeout = getParseTimeoutMs(stat.size); + const parseId = `${path.basename(filePath)}-${Date.now()}`; + + parseJobs.set(parseId, { state: 'running' }); + console.log(`[PARSE] START ${parseId} ext=${ext} size=${(stat.size / (1024 * 1024)).toFixed(1)}MB timeout=${parseTimeout / 1000}s active=${parseJobs.size}`); + + let timeoutHandle; + try { + const result = await Promise.race([ + parse(buffer).finally(() => { + // timed_out 상태에서만 여기서 슬롯 해제 — 정상 경로는 아래에서 처리 + const job = parseJobs.get(parseId); + if (job && job.state === 'timed_out') { + releaseParseSlot(parseId); + console.warn(`[PARSE] ZOMBIE_DONE ${parseId} active=${parseJobs.size}`); + } + }), + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + const job = parseJobs.get(parseId); + if (job) job.state = 'timed_out'; + // 슬롯은 유령 완료 시점(finally)에서 해제 — 여기서 빼지 않음 + reject(new Error(`파싱 타임아웃 (${parseTimeout / 1000}초, ${(stat.size / (1024 * 1024)).toFixed(1)}MB, ext=${ext.slice(1)})`)); + }, parseTimeout); + }), + ]); + + // 정상 완료: 타이머 정리 + 슬롯 해제 + clearTimeout(timeoutHandle); + releaseParseSlot(parseId); + const elapsed = Date.now() - startTime; + console.log(`[PARSE] DONE ${parseId} elapsed=${elapsed}ms chars=${(result.markdown || '').length} active=${parseJobs.size}`); + + if (!result.success) { + return res.status(422).json({ + error: '문서 파싱 실패', + warnings: result.warnings || [], + }); + } + + return res.json({ + success: true, + markdown: result.markdown || '', + metadata: { + ...(result.metadata || {}), + format: ext.slice(1), + fileSize: stat.size, + parseTime: elapsed, + }, format: ext.slice(1), - fileSize: stat.size, - parseTime: Date.now() - startTime, - }, - format: ext.slice(1), - requires_ocr: false, - }); + requires_ocr: false, + }); + } catch (parseErr) { + // 비-timeout 에러: 타이머 정리 + 슬롯 해제 + const job = parseJobs.get(parseId); + if (job && job.state !== 'timed_out') { + clearTimeout(timeoutHandle); + releaseParseSlot(parseId); + } + // timeout 에러: 타이머는 이미 발화됨, 슬롯은 parse().finally()에서 해제 예정 + throw parseErr; + } } // 미지원 포맷