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 };