UNPKG

uid-pool

Version:

High-performance UUID and unique ID pooling for Node.js. Pre-generate IDs in background worker threads for O(1) synchronous acquisition. Drop-in replacement for uuid.v4() and nanoid() with 10-100x better performance under load.

260 lines 9.18 kB
import { EventEmitter } from "events"; import { CircularBuffer } from "./circular-buffer.js"; import { GeneratorFailureError, InvalidGeneratorError, InvalidGeneratorOutputError, InvalidMinSizeError, InvalidPoolSizeError, InvalidRefillBatchSizeError, PoolEmptyError, PoolNotReadyError, PoolStoppedError, } from "./errors.js"; /** * Enhanced BaseIdPool with improved error handling and state management */ export class BaseIdPoolFixed extends EventEmitter { options; buffer; isRunning = false; isReady = false; stats; // Track async acquires waiting for IDs pendingAcquires = []; constructor(options) { super(); // Validate generator first if (typeof options.generator !== "function") { throw new InvalidGeneratorError(); } // Apply defaults this.options = { generator: options.generator, poolSize: options.poolSize ?? 1000, minSize: options.minSize ?? Math.floor((options.poolSize ?? 1000) * 0.25), refillBatchSize: options.refillBatchSize ?? Math.max(1, Math.floor((options.poolSize ?? 1000) * 0.5)), useWorker: options.useWorker ?? true, throwOnEmpty: options.throwOnEmpty ?? false, }; // Validate options if (!Number.isInteger(this.options.poolSize) || this.options.poolSize <= 0) { throw new InvalidPoolSizeError("poolSize must be a positive integer"); } if (!Number.isInteger(this.options.minSize) || this.options.minSize < 0 || this.options.minSize > this.options.poolSize) { throw new InvalidMinSizeError("minSize must be an integer between 0 and poolSize"); } if (!Number.isInteger(this.options.refillBatchSize) || this.options.refillBatchSize <= 0) { throw new InvalidRefillBatchSizeError("refillBatchSize must be a positive integer"); } this.buffer = new CircularBuffer(this.options.poolSize); this.stats = { totalAcquired: 0, refillCount: 0, emptyHits: 0, refillTimes: [], }; // Set max listeners to avoid warnings this.setMaxListeners(50); } /** * Synchronously acquire an ID from the pool. * Returns undefined if pool is empty (unless throwOnEmpty is true). */ acquire() { if (!this.isRunning) { if (this.options.throwOnEmpty) { throw new PoolNotReadyError(); } return undefined; } const id = this.buffer.dequeue(); if (id) { this.stats.totalAcquired++; // Check if we need to trigger a refill if (this.buffer.getSize() < this.options.minSize && this.isRunning) { this.triggerRefill().catch((err) => { this.emit("error", err); }); } return id; } // Pool is empty this.stats.emptyHits++; this.emit("empty"); if (this.options.throwOnEmpty) { throw new PoolEmptyError(); } return undefined; } /** * Asynchronously acquire an ID, waiting for refill if necessary. */ async acquireAsync() { if (!this.isRunning) { throw new PoolNotReadyError(); } // Try synchronous acquisition first try { const id = this.acquire(); if (id !== undefined) { return id; } } catch (err) { // If throwOnEmpty is true, acquire() will throw // For async, we want to wait instead if (this.options.throwOnEmpty && err instanceof Error && err.message === "ID pool is empty") { // Continue to wait for refill } else { throw err; } } // Wait for refill return new Promise((resolve, reject) => { // Add to pending queue this.pendingAcquires.push({ resolve, reject }); // Ensure refill is triggered if (this.isRunning) { this.triggerRefill().catch((err) => { reject(err); // Remove from pending queue const index = this.pendingAcquires.findIndex((p) => p.resolve === resolve); if (index > -1) { this.pendingAcquires.splice(index, 1); } }); } else { reject(new PoolStoppedError()); } }); } /** * Start the pool and begin filling it. */ start() { if (this.isRunning) { return; } this.isRunning = true; this.isReady = false; // Reset ready state this.startInternal(); // Initial fill this.triggerRefill() .then(() => { if (!this.isReady && this.buffer.getSize() >= this.options.minSize) { this.isReady = true; this.emit("ready"); } }) .catch((err) => { this.emit("error", err); }); } /** * Stop the pool and clean up resources. */ async stop() { if (!this.isRunning) { return; } this.isRunning = false; this.isReady = false; // Reset ready state // Reject all pending async acquires const pending = [...this.pendingAcquires]; this.pendingAcquires = []; for (const { reject } of pending) { reject(new PoolStoppedError("ID pool stopped")); } await this.stopInternal(); this.buffer.clear(); // Clear stats this.stats = { totalAcquired: 0, refillCount: 0, emptyHits: 0, refillTimes: [], }; } /** * Get current pool statistics. */ getStats() { const avgRefillTime = this.stats.refillTimes.length > 0 ? this.stats.refillTimes.reduce((a, b) => a + b, 0) / this.stats.refillTimes.length : 0; return { available: this.buffer.getSize(), capacity: this.options.poolSize, totalAcquired: this.stats.totalAcquired, refillCount: this.stats.refillCount, emptyHits: this.stats.emptyHits, avgRefillTime, }; } /** * Helper method to generate IDs using the configured generator. * Ensures all IDs are strings. */ async generateIds(count) { const ids = []; for (let i = 0; i < count; i++) { try { const id = await this.options.generator(); // Ensure ID is a string if (typeof id !== "string") { this.emit("error", new InvalidGeneratorOutputError(`Generator returned non-string value: ${typeof id}`)); // Convert to string for compatibility ids.push(String(id)); } else { ids.push(id); } } catch (error) { this.emit("error", error instanceof Error ? new GeneratorFailureError(`Generator failed: ${error.message}`, error) : new GeneratorFailureError(`Generator failed: ${String(error)}`)); // Don't break the loop - continue generating remaining IDs } } return ids; } /** * Add generated IDs to the buffer and emit refill event. * Also resolves any pending async acquires. */ addToBuffer(ids, duration) { const added = this.buffer.enqueueBatch(ids); if (added > 0) { this.stats.refillCount++; this.stats.refillTimes.push(duration); // Keep only last 100 refill times for avg calculation if (this.stats.refillTimes.length > 100) { this.stats.refillTimes.shift(); } // Resolve pending async acquires const pending = [...this.pendingAcquires]; this.pendingAcquires = []; for (const { resolve, reject } of pending) { const id = this.buffer.dequeue(); if (id) { this.stats.totalAcquired++; resolve(id); } else { // Put back in queue if we ran out of IDs this.pendingAcquires.push({ resolve, reject }); } } this.emit("refill", { added, duration }); // Check if we need to trigger another refill for remaining pending acquires if (this.pendingAcquires.length > 0 && this.isRunning) { this.triggerRefill().catch((err) => { this.emit("error", err); }); } } } } //# sourceMappingURL=pool-fixes.js.map