diff --git a/docs/fig-extract-integration.md b/docs/fig-extract-integration.md index 97be065..5e1ec3a 100644 --- a/docs/fig-extract-integration.md +++ b/docs/fig-extract-integration.md @@ -169,6 +169,22 @@ const entries = seeds.map((seed) => PNG 문자열은 회수 대상이 아니고, 논문당 상주도 최대 130MB → 수MB로 떨어진다. - 페이지 전체 캔버스도 스캔 중 동시 상주 최대 1장이고 페이지가 끝나면 즉시 반환된다. - 페이지 렌더 LRU·object URL revoke는 여전히 Margin 몫이다. +- **`opts.releasePages`** (v2.26.2+, `[필드 추가]` — **Margin이 켤지 판단해야 하는 유일한 항목**): + 엔진은 스캔이 끝난 페이지의 pdf.js 캐시(디코드된 이미지)를 `page.cleanup()`으로 반환한다. + **출력은 불변**이다 — 비우는 것은 캐시뿐이고 이후 다시 필요하면 pdf.js가 재파싱한다. + - 왜 필요했나: 엔진이 `page.cleanup()`을 한 번도 부르지 않아 **문서를 다 훑을 때까지 전 페이지의 + 디코드 결과가 함께 상주**했다(40MB PDF 한 편이 렌더러 2.4GB, 130MB가 3.9GB). 게다가 pdf.js는 + operator list와 display에 **다른 캐시 키**를 쓰므로 캡션 페이지마다 같은 이미지가 두 번 디코드돼 + **두 벌이 동시에** 남아 있었다. 렌더 직전에 한 벌을 놓아주는 것이 절감의 큰 몫이다. + - **기본값은 Margin에서 꺼져 있다.** `pdfDocument`로 넘긴 문서는 사용자가 지금 보고 있는 뷰어의 + 살아 있는 문서라, 엔진이 그 캐시를 비우면 뷰어의 다음 렌더가 재파싱을 물고, 뷰어가 그 페이지를 + 렌더하는 중이면 pdf.js가 지연 정리를 걸어 5초 뒤에 지운다. 그래서 **호스트가 명시적으로 켜야만** + 적용된다(엔진이 직접 연 문서는 이 값과 무관하게 항상 해제 — 배치 러너·frontend가 그 경우다). + - **켜는 것을 권하는 조건**: 위 `FigRenderError`를 자주 만나는 환경. 엔진 repo 실측(330편 배치, + `--jobs 2`)에서 렌더러 private 피크 5,407 → 4,707MB, `FigRenderError` 7건 → 1건이었다. + 대가는 스캔 후 사용자가 그 페이지를 다시 볼 때 한 번 더 파싱하는 것뿐이다. + - 이번 벤더링에서는 **선언만 추가하고 호출부는 두지 않았다** — 켜는 판단은 Margin의 UX 트레이드오프 + (스캔 후 첫 렌더 지연 vs 메모리)라 별도 `feat:` 변경으로 분리한다. - **`FigRenderError`** (v2.19.1+): **엔진이 직접 렌더한** 페이지 캔버스의 불투명 픽셀이 절반 미만이거나 크롭 캔버스가 투명하면 `extract()`가 `name === 'FigRenderError'`인 오류로 reject한다 — 렌더 결과가 존재하지 않는다는 뜻이다(위 메모리 압력 상황). pdf.js가 페이지를 불투명 흰색으로 채우고 시작하므로 diff --git a/src/core/fig-engine.ts b/src/core/fig-engine.ts index bf0bc63..bde68a9 100644 --- a/src/core/fig-engine.ts +++ b/src/core/fig-engine.ts @@ -22,7 +22,7 @@ if (!globalScope.pdfjsLib) globalScope.pdfjsLib = pdfjs; * (아래 ⚠). 타입 ↔ 엔진 정합은 `docs/fig-extract-integration.md` §갱신 절차가 지키는 사람의 몫이고, * 핀은 "그 절차를 다시 읽어라"는 알람일 뿐이다. */ -export const VENDORED_ENGINE_VERSION = '2.26.1'; +export const VENDORED_ENGINE_VERSION = '2.26.2'; /** pt 단위, 좌상단 원점 사각형 (엔진 좌표계) */ export interface EngineBox { @@ -104,6 +104,22 @@ export interface ExtractOptions { * 부르면 **throw**한다. 프리뷰 카드를 그리는 뷰어 경로에서는 쓰지 말 것. */ cropImages?: boolean; + /** + * v2.26.2+ `[필드 추가]`: 스캔이 끝난 페이지의 pdf.js 캐시(디코드된 이미지)를 + * `page.cleanup()`으로 반환할지. **출력에는 영향이 없다** — 비우는 것은 캐시뿐이고 이후 다시 + * 필요하면 pdf.js가 재파싱한다. + * + * 엔진이 직접 연 문서(`pdfDocument` 미지정)는 이 값과 무관하게 항상 해제한다. 이 옵션은 + * **`pdfDocument`로 넘긴 문서에만** 적용되고 기본값은 `false`다 — Margin이 넘기는 것은 + * 사용자가 지금 보고 있는 뷰어의 살아 있는 문서라, 엔진이 캐시를 비우면 뷰어의 다음 렌더가 + * 재파싱을 물고, 뷰어가 그 페이지를 렌더하는 중이면 pdf.js가 지연 정리를 걸어 5초 뒤에 지운다. + * + * 켜면 스캔 중 메모리 피크가 내려간다 — 엔진 repo 실측(330편 배치)에서 렌더러 private 피크가 + * 5,407 → 4,707MB, `FigRenderError` 발생이 7건 → 1건이었다. **`FigRenderError`(#12 → B7)를 + * 자주 만나는 환경이라면 이 옵션이 그 압력을 낮추는 손잡이다.** 대가는 뷰어 렌더 캐시가 + * 비워지는 것뿐이므로, 스캔 후 사용자가 그 페이지를 다시 볼 때 한 번 더 파싱한다. + */ + releasePages?: boolean; } export interface FigExtractApi { diff --git a/src/core/fig-extract.js b/src/core/fig-extract.js index 2188b19..348eef4 100644 --- a/src/core/fig-extract.js +++ b/src/core/fig-extract.js @@ -41,7 +41,30 @@ const FigExtract = (() => { -const VERSION = "2.26.1"; +const VERSION = "2.26.2"; +// 2.26.2: [필드 추가: optional opts.releasePages] **페이지 캐시 해제**. 엔진은 pdf.js +// `page.cleanup()`을 한 번도 부르지 않아, 스캔한 페이지의 디코드된 이미지가 **문서를 다 +// 훑을 때까지 전부 함께 상주**했다 (40MB PDF 한 편이 렌더러 2.4GB, 130MB가 3.9GB — +// 배치 OOM과 B7 압력의 직접 원인). 두 곳에서 해제한다: +// ① `getImageBoxes`(=`getOperatorList`) 직후·렌더 직전 — pdf.js는 oplist와 display에 +// **다른 캐시 키**를 쓰고 objId 카운터를 리셋하지 않으므로 캡션 페이지는 두 번 파싱되고 +// **디코드 사본 두 벌이 동시에 상주**한다. 렌더 전에 oplist 벌을 놓아주면 페이지 내부 +// 피크가 대략 절반이 된다. 재파싱 비용은 없다 — 어차피 캐시 키가 달라 다시 파싱한다. +// ② 페이지를 다 쓴 시점(크롭까지 끝나고 캔버스를 반환하는 자리) — 페이지 간 누적을 끊는다. +// 감지 결과는 불변이다: 해제되는 것은 캐시뿐이고 objId는 출력에 들어가지 않으며 이미지 +// 디코드는 결정적이다. **다만 전수 diff가 0이라는 뜻은 아니다** — 이 변경은 메모리 압력을 +// 바꾸고 그 압력이 B7(`FigRenderError`)의 유일한 입력이라, 이전에 압력으로 죽던 논문이 +// 완주하면 ADDED로 나타난다. 그 방향의 차이는 회귀가 아니다. +// ★ **호스트가 `opts.pdfDocument`로 넘긴 문서에는 기본 적용하지 않는다** — 그 문서는 +// 사용자가 지금 보고 있는 뷰어의 살아 있는 문서라(Margin `tab-figures.ts`), 엔진이 캐시를 +// 비우면 뷰어의 다음 렌더가 재파싱을 물고, 뷰어가 그 페이지를 **렌더 중이면** pdf.js가 +// `#pendingCleanup`을 걸어 5초 뒤에 지운다. 호스트가 메모리를 우선하려면 `opts.releasePages: +// true`로 켠다 — Margin의 B7 압력을 낮추는 손잡이가 이것이다. 엔진이 직접 연 문서 +// (배치 러너·frontend)는 항상 해제하고, 문서를 다 쓴 뒤 `pdf.cleanup()`으로 워커 쪽 +// 공용 캐시(`commonObjs`·폰트)까지 반환한다 — **성공·실패·취소 어느 경로로 빠져나가도** +// 돈다. 실패 경로가 곧 메모리 압력 경로(`FigRenderError`)라, 거기서 건너뛰면 정리가 가장 +// 필요한 순간에만 안 도는 셈이기 때문이다. 그래서 문서 소유를 얇은 래퍼 `extract`로 두고 +// 본문은 `extractWithDocument`에 있다 (CodeRabbit 리뷰, PDFViewer#42). // 2.26.1: [계약 무변경] x 클램프의 `PARA_X_MIN_DELTA`를 12 → 2px. v2.26.0이 막았던 "방출 // 여백(10px ≈ 4.55pt)만 깎는 발화" 54행 중 **51행이 `body_text_sliver` 보유**였다 — // 여백이 옆 컬럼 본문과 겹쳐 글자 조각이 들어와 있었고 4.6pt를 깎으면 그게 빠진다. @@ -4190,6 +4213,10 @@ async function observeAdjacentPages(pageData, dom, diag, opts, checkAborted, sna imageBoxes = await getImageBoxes(pd.page, pd.h, error => { operatorError = error; }); checkAborted(); if (operatorError) throw operatorError; + /* same-page 루프와 같은 이유로 렌더 전에 oplist 벌을 놓아준다 — 여기는 특히 중요하다. + * 12-B는 이미 해제된 페이지를 **다시** 파싱하므로 그 전량이 새로 생긴 참이고, 바로 아래가 + * FigRenderError를 던지는 자리(압력에 가장 민감한 지점)다. */ + releasePageCache(pd, pageReleaseEnabled(opts)); if (opts.renderPage) { checkAborted(); canvas = await opts.renderPage(targetPage, S); @@ -4212,6 +4239,9 @@ async function observeAdjacentPages(pageData, dom, diag, opts, checkAborted, sna grid = makeInk(canvas, !opts.renderPage); // 카나리아는 엔진 소유 캔버스에서만 (B7) } catch (error) { releaseOwnedCanvas(); + /* 이 catch는 `continue`로 빠져나가 아래 finally를 타지 않는다 — 여기서 안 놓아주면 + * 방금 getImageBoxes가 만든 디코드 결과가 12-B 나머지와 호출자 수명 내내 남는다. */ + releasePageCache(pd, pageReleaseEnabled(opts)); if (opts.signal && opts.signal.aborted) throw new DOMException("figure 추출이 취소됨", "AbortError"); /* 죽은 캔버스는 "이 PDF가 특이하다"가 아니라 실행 환경 실패다 (B7). unobservable로 삼키면 @@ -4574,13 +4604,50 @@ async function observeAdjacentPages(pageData, dom, diag, opts, checkAborted, sna } } finally { releaseOwnedCanvas(); + /* same-page 루프와 같은 처리 — 12-B는 N−1 페이지를 **다시** 파싱·렌더하므로 여기서 + * 안 놓아주면 방금 재생성한 디코드 캐시가 문서 끝까지 그대로 남는다. */ + releasePageCache(pd, pageReleaseEnabled(opts)); } } return resolved; } +/* pdf.js 페이지 캐시 해제 (v2.27.0). `page.cleanup()`은 **동기**라 여기서 await가 생기지 않는다 — + * 페이지 본문의 "makeInk 이후 크롭까지 await 없음" 보장(B7 크롭 카나리아의 근거)을 깨지 않는다. + * 렌더가 진행 중이면 pdf.js가 아무것도 하지 않고 false를 돌려주므로 반드시 렌더·크롭이 끝난 뒤에 + * 부른다. 실패는 삼킨다 — 캐시 해제는 최적화지 정확성 요건이 아니고, 여기서 던지면 정상 추출이 + * 메모리 최적화 때문에 실패하는 뒤바뀐 상황이 된다. */ +function releasePageCache(pd, enabled) { + if (!enabled || !pd || !pd.page || typeof pd.page.cleanup !== "function") return; + try { pd.page.cleanup(); } catch (e) { /* 무해 */ } +} +/* 해제해도 되는 문서인가 — 판정을 한 곳에 둔다(두 경로가 갈리면 한쪽만 고치는 수정이 조용한 + * 분기를 만든다). 호스트가 넘긴 문서는 옵트인, 엔진이 직접 연 문서는 항상 해제. */ +const pageReleaseEnabled = opts => (opts.pdfDocument ? opts.releasePages === true : true); + /* ===================== 메인 파이프라인 ===================== */ +/* 문서 수명 소유자. 엔진이 직접 연 문서는 **성공·실패·취소 어느 경로로 빠져나가도** + * 정리한다 — 초안은 성공 반환 직전에만 정리해서, 예외나 AbortError로 빠지면 `commonObjs` + * 전역 캐시가 그대로 남았다. 하필 **실패 경로가 곧 메모리 압력 경로**다(FigRenderError는 정의상 + * 메모리가 모자랄 때 난다) — 가장 필요한 순간에만 안 도는 셈이라 방향이 거꾸로였다. + * 호스트가 `opts.pdfDocument`로 넘긴 문서는 우리 것이 아니므로 손대지 않는다(수명은 호스트 몫). + * 본문을 통째로 try로 감싸는 대신 소유 계층을 분리한 이유: 500줄을 들여쓰기만 바꿔 diff를 + * 못 읽게 만드는 것보다, "누가 문서를 소유하는가"를 코드 구조로 드러내는 편이 낫다. */ async function extract(data, opts = {}) { + if (opts.pdfDocument) return extractWithDocument(opts.pdfDocument, opts); + /* 문서를 열기 전 취소 확인 — 아래 core도 같은 검사를 하지만 그건 문서를 연 뒤다. */ + if (opts.signal && opts.signal.aborted) + throw new DOMException("figure 추출이 취소됨", "AbortError"); + const pdf = await pdfjsLib.getDocument({ data }).promise; + try { + return await extractWithDocument(pdf, opts); + } finally { + /* 정리 실패가 원래 오류를 가리면 안 된다 — 삼킨다. */ + try { await pdf.cleanup(); } catch (e) { /* 최적화라 실패 무해 */ } + } +} + +async function extractWithDocument(pdf, opts) { const onProgress = opts.onProgress || (() => {}); const dbg = opts.debug || (() => {}); const diag = makeDiagnosticRecorder(opts.onDiagnostic); @@ -4596,8 +4663,12 @@ async function extract(data, opts = {}) { }; checkAborted(); - const pdf = opts.pdfDocument || await pdfjsLib.getDocument({ data }).promise; checkAborted(); + /* 페이지 리소스 해제 (v2.26.2). pdf.js는 파싱·렌더가 디코드한 이미지를 페이지별 `page.objs`에 + * 캐시하고, 그걸 비우는 유일한 수단이 `page.cleanup()`이다. 안 부르면 문서를 다 훑을 때까지 + * 전 페이지의 디코드 결과가 함께 상주한다 — 한 편 안에서의 메모리 피크가 바로 이것이다. + * 호스트 문서에 기본 적용하지 않는 이유는 헤더 체인지로그 참고(뷰어의 살아 있는 캐시다). */ + const releasePages = pageReleaseEnabled(opts); let title = null; try { const meta = await pdf.getMetadata(); @@ -4636,9 +4707,16 @@ async function extract(data, opts = {}) { const allFigs = []; for (const pd of pageData) { checkAborted(); + /* 캡션 없는 페이지에는 해제할 것이 없다 — `getTextContent`는 스트림이라 intent state도 + * `page.objs` 항목도 만들지 않는다. 여기서 cleanup을 불러 봐야 빈 맵을 비운다. */ if (!pd.captionData.anchors.length) continue; onProgress(`figure 감지… p.${pd.num}`); pd.images = await getImageBoxes(pd.page, pd.h); + /* ★ 렌더 **전에** oplist 벌을 놓아준다. pdf.js는 oplist와 display에 다른 캐시 키를 쓰고 + * objId 카운터를 리셋하지 않아, 아래 render가 같은 이미지를 새 objId로 다시 디코드해 + * 두 벌이 동시에 상주한다. 여기서 끊으면 페이지 내부 피크가 대략 절반이 되고, 재파싱 + * 비용은 늘지 않는다 — 캐시 키가 달라 어차피 다시 파싱하기 때문이다. */ + releasePageCache(pd, releasePages); /* 페이지 렌더: 호스트(Margin 등)가 renderPage(pageNum, scale)를 주입하면 그걸 사용 */ let canvas; if (opts.renderPage) { @@ -5002,6 +5080,10 @@ async function extract(data, opts = {}) { * 크롭이 이미 PNG로 직렬화돼 더 볼 일이 없고, GC를 기다리는 사이 다음 페이지 * 캔버스와 동시 상주하는 것을 없앤다. adjacent 경로의 releaseOwnedCanvas와 같은 처리. */ releasePageCanvas(); + /* 캔버스와 같은 자리에서 pdf.js 쪽 페이지 캐시(디코드된 이미지)도 반환한다 (v2.27.0). + * 캔버스만 놓아주고 이걸 두면 페이지마다 디코드 결과가 쌓여 문서 끝까지 상주한다 — + * 크롭까지 끝난 이 시점 이후로 이 페이지를 same-page 경로에서 다시 볼 일은 없다. */ + releasePageCache(pd, releasePages); } } /* N−1 observer 입력은 same-page lifecycle이 모두 끝난 이 지점에서 scalar로 동결한다. @@ -5121,6 +5203,8 @@ async function extract(data, opts = {}) { }); diag.finish(); } + /* 문서 단위 정리(`page.cleanup()`이 닿지 못하는 `commonObjs`·폰트·CMap 반환)는 여기가 아니라 + * 소유자인 `extract`의 finally가 한다 — 실패·취소 경로에서도 돌아야 하기 때문이다. */ return { title, numPages: pdf.numPages, figures, suspectedMissing, engineVersion: VERSION }; }