File size: 10,475 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
/**
 * SessionPool β€” Pool manager for anonymous web sessions
 *
 * Manages N sessions across N browser fingerprints, distributing requests
 * round-robin to avoid rate-limiting any single "browser identity."
 *
 * Key behaviors:
 *   - acquire(): Returns the next available session (round-robin)
 *   - reportSuccess(session): Updates metrics, marks session healthy
 *   - reportFailure(session, status): Cooldown on 429, dead on 5xx
 *   - Scalability: Round-robin across sessions prevents one session from
 *     being overused while others sit idle.
 *   - Auto-heal: Sessions in cooldown auto-recover after the backoff window.
 *   - Self-repair: Dead sessions are replaced on next acquire if below maxSessions.
 *   - Thread-safe for concurrent request patterns (each acquire/release pair
 *     is atomic within one async flow).
 *
 * Usage:
 *   const pool = new SessionPool("pollinations", poolConfig, factory);
 *   await pool.ensureMinSessions();
 *   const session = pool.acquire();
 *   try {
 *     const res = await fetch(url, { headers: session.buildHeaders() });
 *     if (!res.ok && res.status === 429) pool.reportCooldown(session);
 *     else pool.reportSuccess(session);
 *   } catch { pool.reportDead(session); }
 *   finally { session.release(); }
 */

import { type EventEmitter } from "node:events";
import { createHash, randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";

import { Session } from "./session.ts";
import { SessionFactory } from "./sessionFactory.ts";
import {
  type PoolConfig,
  type PoolSessionDetail,
  type PoolStats,
  DEFAULT_POOL_CONFIG,
} from "./types.ts";

export class SessionPool {
  readonly provider: string;
  readonly poolId: string;
  readonly createdAt: number;

  private sessions: Session[] = [];
  private index = 0;
  private config: PoolConfig;
  private factory: SessionFactory;

  // Aggregate stats
  totalRequests = 0;
  successfulRequests = 0;
  rate429count = 0;
  otherErrors = 0;

  // Track throughput
  private startTime: number = Date.now();
  private lastLog = 0;

  constructor(
    provider: string,
    config?: Partial<PoolConfig>,
    factory?: SessionFactory,
  ) {
    this.provider = provider;
    this.poolId = `pool-${provider}-${Date.now().toString(36)}`;
    this.createdAt = Date.now();
    this.config = { ...DEFAULT_POOL_CONFIG, ...config };
    this.factory = factory ?? new SessionFactory(this.config);
  }

  // ─── Pool Lifecycle ──────────────────────────────────────────────────

  /** Ensure the pool has at least minSessions ready */
  async ensureMinSessions(): Promise<void> {
    const needed = this.config.minSessions - this.sessions.length;
    if (needed <= 0) return;

    const promises: Promise<Session>[] = [];
    for (let i = 0; i < needed; i++) {
      promises.push(Promise.resolve(this.createSession()));
    }
    await Promise.allSettled(promises);
  }

  /** Warm up the pool to a specific size (bypasses minSessions limit) */
  async warmUp(count: number): Promise<void> {
    const target = Math.min(count, this.config.maxSessions);
    const needed = target - this.sessions.length;
    if (needed <= 0) return;

    const promises: Promise<Session>[] = [];
    for (let i = 0; i < needed; i++) {
      promises.push(Promise.resolve(this.createSession()));
    }
    await Promise.allSettled(promises);
  }

  /** Graceful shutdown β€” mark all sessions dead */
  async shutdown(): Promise<void> {
    for (const s of this.sessions) {
      s.markDead();
    }
    this.sessions = [];
  }

  // ─── Acquire / Release ───────────────────────────────────────────────

  /**
   * Acquire the next available session (round-robin with availability check).
   * Returns null if no sessions are available (all on cooldown/dead).
   */
  acquire(): Session | null {
    // First pass: try round-robin from current index
    if (this.sessions.length === 0) return null;

    const startIdx = this.index % this.sessions.length;

    for (let i = 0; i < this.sessions.length; i++) {
      const idx = (startIdx + i) % this.sessions.length;
      const session = this.sessions[idx];
      if (session.isAvailable) {
        // Skip sessions that hit max inflight limit (safety valve)
        // For anonymous web providers we allow high concurrency per session
        this.index = (idx + 1) % this.sessions.length;
        session.acquire();
        this.totalRequests++;
        return session;
      }
    }

    // No ready sessions β€” try to create a new one if under max
    if (this.sessions.length < this.config.maxSessions) {
      const session = this.createSession();
      session.acquire();
      this.totalRequests++;
      return session;
    }

    // Last resort: wait for the nearest cooldown to expire (caller should retry)
    return null;
  }

  /**
   * Report a successful request. Updates metrics pool-wide and per-session.
   */
  reportSuccess(session: Session): void {
    session.markSuccess();
    this.successfulRequests++;
  }

  /**
   * Report a rate-limit (429). Puts the session into exponential-backoff cooldown.
   */
  reportCooldown(session: Session): void {
    session.markCooldown();
    this.rate429count++;
    this.maybeLog();
  }

  /**
   * Report a non-recoverable error. Marks session as dead.
   */
  reportDead(session: Session): void {
    session.markDead();
    this.otherErrors++;
  }

  // ─── Health / Stats ──────────────────────────────────────────────────

  /** Count of available (active, not in cooldown) sessions */
  get availableCount(): number {
    return this.sessions.filter((s) => s.isAvailable).length;
  }

  /** Number of sessions currently in cooldown */
  get cooldownCount(): number {
    return this.sessions.filter((s) => s.status === "cooldown").length;
  }

  /** Number of dead sessions */
  get deadCount(): number {
    return this.sessions.filter((s) => s.status === "dead").length;
  }

  /** Total sessions managed */
  get totalCount(): number {
    return this.sessions.length;
  }

  /** Current throughput in req/s */
  get currentThroughput(): number {
    const elapsed = (Date.now() - this.startTime) / 1000;
    return elapsed > 0 ? this.totalRequests / elapsed : 0;
  }

  /** Snapshot for dashboard/API */
  getStats(): PoolStats {
    const elapsed = (Date.now() - this.startTime) / 1000;
    return {
      provider: this.provider,
      sessions: {
        total: this.sessions.length,
        active: this.availableCount,
        cooldown: this.cooldownCount,
        dead: this.deadCount,
      },
      requests: {
        total: this.totalRequests,
        success: this.successfulRequests,
        rate429: this.rate429count,
        otherErrors: this.otherErrors,
      },
      throughput: this.currentThroughput.toFixed(1),
      successRate:
        this.totalRequests > 0
          ? ((this.successfulRequests / this.totalRequests) * 100).toFixed(1)
          : "100.0",
      elapsed: elapsed.toFixed(0),
    };
  }

  /** Per-session details */
  getSessionDetails(): PoolSessionDetail[] {
    return this.sessions.map((s) => ({
      id: s.id,
      fingerprint: s.fingerprint.id,
      status: s.status,
      totalRequests: s.totalRequests,
      successfulRequests: s.successfulRequests,
      successRate:
        s.totalRequests > 0
          ? ((s.successfulRequests / s.totalRequests) * 100).toFixed(1)
          : "100.0",
      inflight: s.inflight,
      cooldownRemaining: s.cooldownRemaining > 0
        ? `${(s.cooldownRemaining / 1000).toFixed(1)}s`
        : "0s",
      age: `${(s.age / 1000).toFixed(0)}s`,
    }));
  }

  /** As acquire(), but blocks until a session is available */
  async acquireBlocking(timeoutMs = 10_000): Promise<Session> {
    const deadline = Date.now() + timeoutMs;

    // Fast path
    const fast = this.acquire();
    if (fast) return fast;

    // Wait-loop with backoff (50ms β†’ 200ms)
    let delay = 50;
    while (Date.now() < deadline) {
      await sleep(delay);
      const session = this.acquire();
      if (session) return session;
      delay = Math.min(delay * 2, 200);
    }

    throw new Error(
      `[SessionPool:${this.provider}] No session available after ${timeoutMs}ms timeout`,
    );
  }

  /** As acquireBlocking(), but accepts arbitrary function to wrap */
  async executeWithSession<T>(
    fn: (session: Session) => Promise<T>,
    timeoutMs = 10_000,
  ): Promise<T> {
    const session = await this.acquireBlocking(timeoutMs);
    try {
      const result = await fn(session);
      return result;
    } finally {
      session.release();
    }
  }

  // ─── Internal ────────────────────────────────────────────────────────

  /** Create and register a new session */
  private createSession(): Session {
    const session = this.factory.createSession();
    this.sessions.push(session);
    return session;
  }

  /** Periodic log of pool health (every 5s) */
  private maybeLog(): void {
    const now = Date.now();
    if (now - this.lastLog < 5_000) return;
    this.lastLog = now;

    const stats = this.getStats();
    if (stats.requests.total % 50 === 0) {
      // Rate-limited to avoid log spam
    }
  }

  /** Remove dead sessions and idle sessions older than maxIdleMs */
  pruneDeadSessions(maxIdleMs = 300_000): void {
    const now = Date.now();
    const before = this.sessions.length;
    this.sessions = this.sessions.filter((s) => {
      if (s.status === "dead") return false;
      // Prune idle sessions older than maxIdleMs (default 5min)
      if (s.inflight === 0 && s.lastUsedAt > 0 && now - s.lastUsedAt > maxIdleMs) return false;
      return true;
    });

    // If we pruned sessions, ensure minimum
    if (this.sessions.length < before && this.sessions.length < this.config.minSessions) {
      this.ensureMinSessions();
    }
  }

  /** Start periodic pruning (every 60s) */
  startAutoPrune(intervalMs = 60_000): ReturnType<typeof setInterval> {
    const timer = setInterval(() => this.pruneDeadSessions(), intervalMs);
    timer.unref();
    return timer;
  }
}