Files
2026-07-13 13:39:12 +08:00

327 lines
10 KiB
TypeScript

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