/** * KV Manager for meta-fuse (FOLLOWER-only) * * Simplified KV manager that: * 1. Discovers the leader via LeaderClient (reads kv-leader.info) * 2. Creates Redis client connection * 3. Handles reconnection on leader failure * * Unlike meta-sort's KVManager, this version never spawns Redis - * it only connects as a client. Leader election is handled by meta-core. */ import { hostname } from 'os'; import { Logger } from 'tslog'; import { LeaderClient } from './LeaderClient.js'; import { ServiceDiscovery } from './ServiceDiscovery.js'; import { RedisClient } from './RedisClient.js'; import type { LeaderLockInfo } from './IKVClient.js'; const logger = new Logger({ name: 'KVManager' }); interface KVManagerConfig { /** Path to META_CORE_VOLUME (e.g., /meta-core) */ metaCorePath: string; /** Path to FILES_VOLUME (e.g., /files) */ filesPath: string; /** Service name (for logging) */ serviceName?: string; /** HTTP API port */ apiPort?: number; /** Override Redis URL (skip leader discovery if provided) */ redisUrl?: string; /** Redis key prefix (default: 'meta-sort:') */ redisPrefix?: string; /** Base URL for service discovery (overrides auto-detected URL) */ baseUrl?: string; /** meta-core API URL (e.g., http://localhost:9000) */ metaCoreUrl?: string; } export class KVManager { private config: Required; private leaderClient: LeaderClient | null = null; private serviceDiscovery: ServiceDiscovery | null = null; private redisClient: RedisClient | null = null; private isStarted = false; private isShuttingDown = false; // Event callbacks private onReadyCallbacks: (() => void)[] = []; private onDisconnectCallbacks: (() => void)[] = []; constructor(config: KVManagerConfig) { this.config = { serviceName: 'meta-fuse', apiPort: 3000, redisUrl: '', redisPrefix: 'meta-sort:', baseUrl: '', metaCoreUrl: '', ...config }; } /** * Start the KV manager */ async start(): Promise { if (this.isStarted) { logger.warn('KVManager already started'); return; } logger.info(`Starting KVManager for ${this.config.serviceName}...`); // Initialize and start service discovery for registration and heartbeat const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; this.serviceDiscovery = new ServiceDiscovery({ metaCorePath: this.config.metaCorePath, serviceName: this.config.serviceName, apiUrl }); await this.serviceDiscovery.start(); // If direct Redis URL provided, skip leader discovery if (this.config.redisUrl) { logger.info(`Using direct Redis URL: ${this.config.redisUrl}`); await this.connectToRedis(this.config.redisUrl); this.isStarted = true; return; } // Use leader client this.leaderClient = new LeaderClient({ metaCorePath: this.config.metaCorePath, metaCoreUrl: this.config.metaCoreUrl || undefined }); // Set up leader change callback this.leaderClient.onChange(async () => { logger.info('Leader changed, reconnecting...'); await this.reconnect(); }); // Wait for leader and connect try { const leaderInfo = await this.leaderClient.waitForLeader(30000); logger.info(`Connecting to Redis at ${leaderInfo.redisUrl}...`); await this.connectToRedis(leaderInfo.redisUrl); } catch (error: any) { logger.error(`Failed to connect to leader: ${error.message}`); throw error; } // Start watching for leader changes this.leaderClient.startWatching(); this.isStarted = true; } /** * Reconnect to Redis after leader change */ private async reconnect(): Promise { if (this.isShuttingDown || !this.leaderClient) { return; } // Disconnect existing await this.disconnectRedis(); this.notifyDisconnect(); // Wait for new leader and connect try { const leaderInfo = await this.leaderClient.waitForLeader(30000); logger.info(`Reconnecting to Redis at ${leaderInfo.redisUrl}...`); await this.connectToRedis(leaderInfo.redisUrl); } catch (error: any) { logger.error(`Failed to reconnect: ${error.message}`); } } /** * Stop the KV manager */ async stop(): Promise { if (!this.isStarted) return; logger.info('Stopping KVManager...'); this.isShuttingDown = true; // Stop leader client if (this.leaderClient) { this.leaderClient.close(); this.leaderClient = null; } // Stop service discovery (if it was started) if (this.serviceDiscovery) { await this.serviceDiscovery.stop(); this.serviceDiscovery = null; } // Disconnect Redis await this.disconnectRedis(); this.isStarted = false; logger.info('KVManager stopped'); } /** * Connect to Redis */ private async connectToRedis(url: string): Promise { try { // Disconnect existing client if any await this.disconnectRedis(); // Create new client this.redisClient = new RedisClient({ url, prefix: this.config.redisPrefix, filesVolume: this.config.filesPath }); await this.redisClient.connect(); logger.info('Connected to Redis'); // Notify ready this.notifyReady(); } catch (error: any) { logger.error(`Failed to connect to Redis: ${error.message}`); this.redisClient = null; } } /** * Disconnect from Redis */ private async disconnectRedis(): Promise { if (this.redisClient) { try { await this.redisClient.disconnect(); } catch (error: any) { logger.warn(`Error disconnecting Redis: ${error.message}`); } this.redisClient = null; } } /** * Notify ready callbacks */ private notifyReady(): void { for (const callback of this.onReadyCallbacks) { try { callback(); } catch (error: any) { logger.error(`Error in ready callback: ${error.message}`); } } } /** * Notify disconnect callbacks */ private notifyDisconnect(): void { for (const callback of this.onDisconnectCallbacks) { try { callback(); } catch (error: any) { logger.error(`Error in disconnect callback: ${error.message}`); } } } // ======================================================================== // Event Registration // ======================================================================== /** * Register callback for when KV client is ready */ onReady(callback: () => void): this { this.onReadyCallbacks.push(callback); // If already ready, call immediately if (this.redisClient?.connected) { callback(); } return this; } /** * Register callback for disconnection */ onDisconnect(callback: () => void): this { this.onDisconnectCallbacks.push(callback); return this; } /** * Wait for KV client to be ready */ async waitForReady(timeoutMs: number = 30000): Promise { if (this.redisClient?.connected) return; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error(`KV client not ready after ${timeoutMs}ms`)); }, timeoutMs); this.onReady(() => { clearTimeout(timeout); resolve(); }); }); } // ======================================================================== // Getters // ======================================================================== /** * Get the Redis client (may be null if not connected) */ getClient(): RedisClient | null { return this.redisClient; } /** * Get the Redis client (throws if not connected) */ requireClient(): RedisClient { if (!this.redisClient) { throw new Error('Redis client not available. Ensure start() was called and leader is available.'); } return this.redisClient; } /** * Check if connected */ isConnected(): boolean { return this.redisClient?.connected ?? false; } /** * Health check */ async isHealthy(): Promise { if (!this.redisClient) return false; try { const stats = await this.redisClient.getStats(); return stats.connected; } catch { return false; } } /** * Get leader info */ getLeaderInfo(): LeaderLockInfo | null { return this.leaderClient?.getCachedLeaderInfo() ?? null; } /** * Get leader client */ getLeaderClient(): LeaderClient | null { return this.leaderClient; } /** * Get service discovery (for inter-service navigation) */ getServiceDiscovery(): ServiceDiscovery | null { return this.serviceDiscovery; } /** * Get configured paths */ getPaths() { return { metaCorePath: this.config.metaCorePath, filesPath: this.config.filesPath }; } }