@stalkchain/grpc-pool
Version:
High-availability gRPC connection pooling module with active-active configuration, deduplication, and stale connection detection
332 lines • 15.4 kB
JavaScript
"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