fix(kordoc): adaptive parse timeout + 동시 파싱 제한
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) <noreply@anthropic.com>
This commit is contained in:
+81
-27
@@ -9,7 +9,22 @@ import { parse, detectFormat } from 'kordoc';
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3100;
|
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' }));
|
app.use(express.json({ limit: '500mb' }));
|
||||||
|
|
||||||
@@ -73,35 +88,74 @@ app.post('/parse', async (req, res) => {
|
|||||||
|
|
||||||
// HWP/HWPX/PDF — kordoc 파싱
|
// HWP/HWPX/PDF — kordoc 파싱
|
||||||
if (PARSEABLE_FORMATS.has(ext)) {
|
if (PARSEABLE_FORMATS.has(ext)) {
|
||||||
const buffer = fs.readFileSync(filePath);
|
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}건), 재시도 필요` });
|
||||||
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 || [],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
const buffer = fs.readFileSync(filePath);
|
||||||
success: true,
|
const parseTimeout = getParseTimeoutMs(stat.size);
|
||||||
markdown: result.markdown || '',
|
const parseId = `${path.basename(filePath)}-${Date.now()}`;
|
||||||
metadata: {
|
|
||||||
...(result.metadata || {}),
|
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),
|
format: ext.slice(1),
|
||||||
fileSize: stat.size,
|
requires_ocr: false,
|
||||||
parseTime: Date.now() - startTime,
|
});
|
||||||
},
|
} catch (parseErr) {
|
||||||
format: ext.slice(1),
|
// 비-timeout 에러: 타이머 정리 + 슬롯 해제
|
||||||
requires_ocr: false,
|
const job = parseJobs.get(parseId);
|
||||||
});
|
if (job && job.state !== 'timed_out') {
|
||||||
|
clearTimeout(timeoutHandle);
|
||||||
|
releaseParseSlot(parseId);
|
||||||
|
}
|
||||||
|
// timeout 에러: 타이머는 이미 발화됨, 슬롯은 parse().finally()에서 해제 예정
|
||||||
|
throw parseErr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 미지원 포맷
|
// 미지원 포맷
|
||||||
|
|||||||
Reference in New Issue
Block a user