UNPKG

@stalkchain/grpc-pool

Version:

High-availability gRPC connection pooling module with active-active configuration, deduplication, and stale connection detection

332 lines 15.4 kB
"use strict"; /** * lib/pool.ts - Main gRPC pool manager * * Manages connections to 3 gRPC endpoints simultaneously and routes * transaction data from all of them. Handles all stream management internally, * emits processed messages to the user, and monitors for stale connections. * * @module lib/pool * @author StalkChain Team * @version 1.1.0 */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.GrpcPool = void 0; const events_1 = require("events"); const client_1 = require("./client"); const deduplication_1 = require("./deduplication"); const constants_1 = require("../constants"); const bs58_1 = __importDefault(require("bs58")); /** * Main pool manager for multiple gRPC connections * * This class extends EventEmitter and handles all the complexity of managing * multiple gRPC streams internally. Users simply listen for 'message-processed' * events to receive transaction data. Includes automatic stale detection. */ class GrpcPool extends events_1.EventEmitter { constructor(config, options = {}) { super(); this.clients = []; this.connected = false; this.pingInterval = null; this.staleCheckInterval = null; this.currentSubscription = null; this.endpointStates = new Map(); // Track individual endpoint connection states this.config = config; // Merge user options with defaults this.options = { pingIntervalMs: options.pingIntervalMs ?? constants_1.DEFAULT_CONFIG.PING_INTERVAL_MS, staleTimeoutMs: options.staleTimeoutMs ?? constants_1.DEFAULT_CONFIG.STALE_CONNECTION_TIMEOUT_MS, deduplicationTtlMs: options.deduplicationTtlMs ?? constants_1.DEFAULT_CONFIG.DEDUP_TTL_MS, maxCacheSize: options.maxCacheSize ?? constants_1.DEFAULT_CONFIG.MAX_DEDUP_SIGNATURES, initialRetryDelayMs: options.initialRetryDelayMs ?? constants_1.DEFAULT_CONFIG.INITIAL_RETRY_DELAY_MS, maxRetryDelayMs: options.maxRetryDelayMs ?? constants_1.DEFAULT_CONFIG.MAX_RETRY_DELAY_MS, retryBackoffFactor: options.retryBackoffFactor ?? constants_1.DEFAULT_CONFIG.RETRY_BACKOFF_FACTOR, staleCheckFraction: constants_1.DEFAULT_CONFIG.STALE_CHECK_FRACTION, minStaleCheckIntervalMs: constants_1.DEFAULT_CONFIG.MIN_STALE_CHECK_INTERVAL_MS, maxStaleCheckIntervalMs: constants_1.DEFAULT_CONFIG.MAX_STALE_CHECK_INTERVAL_MS }; this.deduplicationService = new deduplication_1.DeduplicationService(this.options); } /** * Connect to all endpoints in the pool and set up internal stream management */ async connect() { console.log(`🚀 Connecting to ${this.config.endpoints.length} gRPC endpoints...`); // Create clients for each endpoint and initialize their states this.clients = this.config.endpoints.map(endpoint => { this.endpointStates.set(endpoint.endpoint, false); return new client_1.GrpcClient(endpoint, { staleTimeoutMs: this.options.staleTimeoutMs, initialRetryDelayMs: this.options.initialRetryDelayMs, maxRetryDelayMs: this.options.maxRetryDelayMs, retryBackoffFactor: this.options.retryBackoffFactor }); }); // Connect to all endpoints const connectionPromises = this.clients.map(client => client.connect().catch(error => { console.error(`Connection failed for ${client.getEndpoint().endpoint}:`, error.message); return null; // Don't fail the whole pool if one endpoint fails })); await Promise.allSettled(connectionPromises); // Check if at least one connection succeeded const connectedClients = this.clients.filter(client => client.isConnected()); if (connectedClients.length === 0) { throw new Error('❌ Failed to connect to any gRPC endpoints'); } // Set up internal stream management this.setupInternalStreams(); // Update endpoint states for initially connected clients connectedClients.forEach(client => { const endpoint = client.getEndpoint().endpoint; this.endpointStates.set(endpoint, true); // Emit initial endpoint connected events const endpointEvent = { endpoint, status: 'connected', timestamp: Date.now() }; this.emit('endpoint', endpointEvent); }); // Set pool as connected and emit connected event this.connected = true; this.emit('connected'); console.log(`✅ Connected to ${connectedClients.length}/${this.config.endpoints.length} endpoints`); // Start automatic ping management this.startPingInterval(); // Start stale connection monitoring this.startStaleDetection(); } /** * Set up internal stream listeners for all connected clients */ setupInternalStreams() { const connectedClients = this.clients.filter(client => client.isConnected()); connectedClients.forEach(client => { client.on('data', (data) => { // Process transaction data and emit to user if (data.transaction) { // Extract signature buffer from full transaction data for deduplication const signatureBuffer = data.transaction.transaction?.signature; // Only process transactions that have signatures if (!signatureBuffer || !Buffer.isBuffer(signatureBuffer)) { return; // Skip transactions without valid signatures } // === DEDUPLICATION CHECK === { const isDuplicate = this.deduplicationService.isDuplicate(signatureBuffer); const signature = bs58_1.default.encode(signatureBuffer); const truncatedSignature = signature.substring(0, 32) + '...'; if (isDuplicate) { // Emit duplicate event for filtered transactions const duplicateEvent = { signature: bs58_1.default.encode(signatureBuffer), // Full signature, not truncated source: client.getEndpoint().endpoint, timestamp: Date.now() }; this.emit('duplicate', duplicateEvent); return; // Don't emit duplicate transactions } } // Create transaction event with full transaction data + our metadata const transactionEvent = { signature: bs58_1.default.encode(signatureBuffer), // Full base58 signature data: data.transaction, // Full gRPC transaction object source: client.getEndpoint().endpoint, // Which endpoint sent this timestamp: data.receivedTimestamp || Date.now() // Use client timestamp or fallback }; // Emit transaction event to user (only unique transactions reach here) this.emit('transaction', transactionEvent); } // Handle pong responses silently }); client.on('error', (error) => { this.emit('error', error); }); client.on('connected', () => { const endpoint = client.getEndpoint().endpoint; const wasConnected = this.endpointStates.get(endpoint); this.endpointStates.set(endpoint, true); // Determine status: reconnected if was previously false, connected if undefined or first time let status = 'connected'; if (wasConnected === false) { status = 'reconnected'; } // Emit endpoint event const endpointEvent = { endpoint, status, timestamp: Date.now() }; this.emit('endpoint', endpointEvent); // Check if pool should be considered connected this.checkPoolConnectionStatus(); }); client.on('disconnected', () => { const endpoint = client.getEndpoint().endpoint; this.endpointStates.set(endpoint, false); // Emit endpoint disconnection event const endpointEvent = { endpoint, status: 'disconnected', timestamp: Date.now() }; this.emit('endpoint', endpointEvent); // Check if pool should be considered disconnected this.checkPoolConnectionStatus(); }); }); } /** * Check pool connection status and emit connected/disconnected events */ checkPoolConnectionStatus() { const connectedEndpoints = Array.from(this.endpointStates.values()).filter(connected => connected).length; const wasConnected = this.connected; if (connectedEndpoints > 0 && !wasConnected) { // Pool just became connected this.connected = true; this.emit('connected'); } else if (connectedEndpoints === 0 && wasConnected) { // Pool just became disconnected this.connected = false; this.emit('disconnected'); } } /** * Start automatic ping interval for connection health */ startPingInterval() { this.pingInterval = setInterval(async () => { try { await this.pingAllEndpoints(); } catch (error) { console.error('❌ Ping interval failed:', error); } }, this.options.pingIntervalMs); } /** * Start stale connection detection monitoring */ startStaleDetection() { // Calculate check interval as fraction of stale timeout, with bounds const calculatedInterval = this.options.staleTimeoutMs * this.options.staleCheckFraction; const checkInterval = Math.max(this.options.minStaleCheckIntervalMs, Math.min(calculatedInterval, this.options.maxStaleCheckIntervalMs)); console.log(`🔍 Starting stale detection: checking every ${Math.round(checkInterval / 1000)}s for connections stale after ${Math.round(this.options.staleTimeoutMs / 1000)}s`); this.staleCheckInterval = setInterval(async () => { try { await this.checkForStaleConnections(); } catch (error) { console.error('❌ Stale detection check failed:', error); } }, checkInterval); } /** * Check all clients for stale connections and force reconnect if needed */ async checkForStaleConnections() { const staleClients = this.clients.filter(client => client.isStale()); if (staleClients.length > 0) { console.log(`🔍 Found ${staleClients.length} stale connection(s), forcing reconnection...`); const reconnectPromises = staleClients.map(async (client) => { const timeSinceLastMessage = client.getTimeSinceLastMessage(); const endpoint = client.getEndpoint().endpoint; console.log(`⚠️ Stale connection detected: ${endpoint} (${Math.round(timeSinceLastMessage / 1000)}s since last message)`); try { await client.forceReconnect(); console.log(`🔄 Forcing reconnection for: ${endpoint}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`❌ Failed to force reconnect ${endpoint}:`, errorMessage); } }); await Promise.allSettled(reconnectPromises); } } /** * Subscribe to transactions using the simplified API * * This method handles all the internal stream setup and subscription management. * Users just need to call this once and listen for 'message-processed' events. */ async subscribe(subscribeRequest) { if (!this.connected) { throw new Error('Pool not connected. Call connect() first.'); } console.log('📡 Setting up subscriptions on all connected endpoints...'); // Store current subscription for potential resubscription this.currentSubscription = subscribeRequest; // Send subscription to all connected clients const connectedClients = this.clients.filter(client => client.isConnected()); const subscriptionPromises = connectedClients.map(client => client.subscribe(subscribeRequest).catch(error => { console.error(`Subscription failed for ${client.getEndpoint().endpoint}:`, error.message); return null; })); await Promise.allSettled(subscriptionPromises); console.log('✅ Subscriptions active! Pool will emit "message-processed" events.'); } /** * Send ping to all endpoints that support it (internal method) */ async pingAllEndpoints() { const pingId = Date.now(); const connectedClients = this.clients.filter(client => client.isConnected()); const pingPromises = connectedClients.map(client => client.ping(pingId)); await Promise.allSettled(pingPromises); } /** * Close all connections and clean up resources */ async close() { console.log('🔒 Closing all pool connections...'); // Clear ping interval if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } // Clear stale detection interval if (this.staleCheckInterval) { clearInterval(this.staleCheckInterval); this.staleCheckInterval = null; } // Clean up deduplication service this.deduplicationService.destroy(); // Close all client connections const closePromises = this.clients.map(client => client.close()); await Promise.allSettled(closePromises); this.connected = false; this.currentSubscription = null; console.log('✅ Pool closed'); } /** * Get connection status for monitoring */ getStatus() { return this.clients.map(client => { const status = { endpoint: client.getEndpoint().endpoint, connected: client.isConnected() }; if (client.isConnected()) { status.timeSinceLastMessage = client.getTimeSinceLastMessage(); } return status; }); } /** * Get deduplication statistics for monitoring */ getDeduplicationStats() { return this.deduplicationService.getStats(); } } exports.GrpcPool = GrpcPool; //# sourceMappingURL=pool.js.map