Spaces:
Running
Running
File size: 7,245 Bytes
24a2ddf | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 | /**
* 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 };
|