api-server / browser-pool.js
XWX-AI's picture
fix(widget): DOCX图表fallback成数据表格根因修复 — widget渲染浏览器池+CDP有限超时+渲染硬超时+队列超时取消+BrowserPool补位bug+body-parser JSON兜底, bump v2.1.11
24a2ddf
Raw
History Blame Contribute Delete
7.25 kB
/**
* BrowserPool — a small, robust pool of reusable Puppeteer browsers.
*
* Why: launching a fresh Chromium per request is the #1 cause of poor
* PDF-export concurrency (each instance ~0.3–1.5s to start + 200–300MB RAM).
* This pool keeps `size` browsers warm; a job checks out a browser, does its
* work, then returns it. Browsers are recycled after `recycleAfter` jobs so a
* long-lived Chromium never accumulates too much memory.
*
* Design notes (industry best practice, verified by web research):
* - Concurrency = pool size. PDF/page rendering is CPU-bound; running more
* parallel jobs than the CPU count only degrades latency.
* - One job per browser at a time (no shared page juggling) keeps isolation
* and error handling trivial.
* - Browser crash / disconnect → replaced lazily on next acquire.
* - Callers must ALWAYS release in a finally block.
*/
const puppeteer = require('puppeteer');
const MAX_LAUNCH_FAILURES_PER_SLOT = 3;
class BrowserPool {
/**
* @param {object} opts
* @param {string} opts.name label for logs
* @param {number} opts.size number of browsers to keep
* @param {object} opts.launchOptions puppeteer.launch() options
* @param {number} [opts.recycleAfter] jobs per browser before recycle (default 30)
* @param {number} [opts.acquireTimeoutMs] how long a waiter waits for a free browser (default 120000)
* @param {Function} [opts.log]
*/
constructor(opts) {
this.name = opts.name || 'pool';
this.size = Math.max(1, Math.min(16, Math.floor(opts.size) || 1));
this.launchOptions = opts.launchOptions || {};
this.recycleAfter = opts.recycleAfter || 30;
this.acquireTimeoutMs = opts.acquireTimeoutMs || 120000;
this.log = opts.log || (() => {});
this._slots = [];
this._waiters = [];
this.stats = { acquires: 0, launches: 0, recycles: 0, waits: 0, waitTimeouts: 0, errors: 0 };
}
_log(msg) {
this.log(`[POOL:${this.name}] ${msg}`);
}
_newSlot() {
return { browser: null, jobs: 0, available: true, closed: false, launching: false, launchFailures: 0 };
}
async _launch() {
this.stats.launches++;
this._log(`launching browser (total launches=${this.stats.launches})`);
const browser = await puppeteer.launch(this.launchOptions);
const slot = this._newSlot();
slot.browser = browser;
browser.on('disconnected', () => {
slot.closed = true;
slot.available = true;
this._log('browser disconnected (crash/kill); slot marked closed');
});
return slot;
}
_pump() {
// 1. Hand free browsers to waiting jobs.
while (this._waiters.length > 0) {
const slot = this._slots.find((s) => s.available && !s.closed && s.browser);
if (!slot) break;
const waiter = this._waiters.shift();
clearTimeout(waiter.timer);
slot.available = false;
// IMPORTANT: resolve with the wrapper ({browser, release}), same shape
// as the warm-path return in acquire(). Resolving with the raw slot made
// callers' `acquired.release` undefined → slots never released → pool
// deadlocked (3/4 busy forever).
waiter.resolve(this._wrap(slot));
}
// 2. Grow/refill the pool (launch one browser per pump pass).
// 必须「无论是否有 waiter」都启动空槽位:否则第 size 个槽位被 push 时
// slots.length === size 使 `length < size` 为假,该槽位永远不会被启动,
// 池实际并发只有 size-1,第 N 个任务必须等第一个任务释放浏览器后才能执行
// (实测 3 个 widget 的批次因此从 ~2.5s 被串行拖到 ~5s)。
// 浏览器回收(release 中 splice 移除槽位)后也要补位,保证池始终补齐到 size。
if (this._slots.length < this.size) {
this._slots.push(this._newSlot());
}
const empty = this._slots.find((s) => !s.browser && !s.launching);
if (empty) {
empty.launching = true;
this._launch().then((slot) => {
const i = this._slots.indexOf(empty);
if (i === -1) { slot.browser.close().catch(() => {}); return; }
this._slots[i] = slot;
this._pump();
}).catch((err) => {
this.stats.errors++;
this._log(`browser launch failed: ${err.message}`);
empty.launchFailures++;
const i = this._slots.indexOf(empty);
if (i !== -1) {
if (empty.launchFailures >= MAX_LAUNCH_FAILURES_PER_SLOT) {
this._slots.splice(i, 1);
const waiter = this._waiters.shift();
if (waiter) {
clearTimeout(waiter.timer);
waiter.reject(new Error(`[POOL:${this.name}] browser launch failed: ${err.message}`));
}
} else {
empty.launching = false; // allow retry
}
}
this._pump();
});
}
}
/**
* Check out a browser slot for one job.
* @returns {Promise<{browser: object, release: Function}>}
*/
async acquire() {
this.stats.acquires++;
const warm = this._slots.find((s) => s.available && !s.closed && s.browser);
if (warm) {
warm.available = false;
this._log(`acquire: warm slot (inUse=${this._inUse()}/${this.size})`);
return this._wrap(warm);
}
// Reserve capacity to grow the pool.
if (this._slots.length < this.size) {
this._slots.push(this._newSlot());
this._pump();
}
this.stats.waits++;
this._log(`acquire: no free slot, queued (inUse=${this._inUse()}/${this.size})`);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const i = this._waiters.indexOf(waiter);
if (i !== -1) this._waiters.splice(i, 1);
this.stats.waitTimeouts++;
this._log(`acquire timed out after ${this.acquireTimeoutMs}ms`);
reject(new Error(`[POOL:${this.name}] no free browser within ${this.acquireTimeoutMs}ms (busy=${this._inUse()}/${this.size})`));
}, this.acquireTimeoutMs);
const waiter = { resolve, reject, timer };
this._waiters.push(waiter);
this._pump();
});
}
_inUse() {
return this._slots.filter((s) => !s.available).length;
}
_wrap(slot) {
return {
browser: slot.browser,
release: async () => {
slot.jobs++;
if (slot.closed || slot.jobs >= this.recycleAfter) {
this.stats.recycles++;
this._log(`recycling browser after ${slot.jobs} jobs (recycles=${this.stats.recycles})`);
const i = this._slots.indexOf(slot);
if (i !== -1) this._slots.splice(i, 1);
try { await slot.browser.close(); } catch (e) {}
slot.closed = true;
} else {
slot.available = true;
this._log(`released browser (jobs=${slot.jobs}, inUse=${this._inUse()}/${this.size})`);
}
this._pump();
},
};
}
async close() {
const slots = this._slots.splice(0);
for (const s of slots) {
if (s.browser) { try { await s.browser.close(); } catch (e) {} }
}
for (const w of this._waiters.splice(0)) {
clearTimeout(w.timer);
w.reject(new Error(`[POOL:${this.name}] pool closed`));
}
}
}
module.exports = { BrowserPool };