initial commit
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* KV Manager for meta-fuse (FOLLOWER-only)
|
||||
*
|
||||
* Simplified KV manager that:
|
||||
* 1. Discovers the leader via lock file
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Logger } from 'tslog';
|
||||
import { LeaderDiscovery } from './LeaderDiscovery.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;
|
||||
|
||||
/** Service version */
|
||||
version?: 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;
|
||||
|
||||
/** Service capabilities */
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export class KVManager {
|
||||
private config: Required<KVManagerConfig>;
|
||||
private leaderDiscovery: LeaderDiscovery | 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',
|
||||
version: '1.0.0',
|
||||
apiPort: 3000,
|
||||
redisUrl: '',
|
||||
redisPrefix: 'meta-sort:',
|
||||
baseUrl: '',
|
||||
capabilities: ['read', 'vfs'],
|
||||
...config
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the KV manager
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isStarted) {
|
||||
logger.warn('KVManager already started');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
|
||||
|
||||
// Initialize service discovery
|
||||
const apiUrl = this.config.baseUrl || `http://localhost:${this.config.apiPort}`;
|
||||
this.serviceDiscovery = new ServiceDiscovery({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
version: this.config.version,
|
||||
apiUrl,
|
||||
baseUrl: this.config.baseUrl || undefined,
|
||||
capabilities: this.config.capabilities,
|
||||
endpoints: {
|
||||
health: '/api/fuse/health',
|
||||
dashboard: '/',
|
||||
stats: '/api/fuse/stats',
|
||||
}
|
||||
});
|
||||
|
||||
// Start service discovery
|
||||
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 discovery
|
||||
this.leaderDiscovery = new LeaderDiscovery({
|
||||
metaCorePath: this.config.metaCorePath
|
||||
});
|
||||
|
||||
// Set up leader discovery callbacks
|
||||
this.leaderDiscovery.onLeaderFound(async (info: LeaderLockInfo) => {
|
||||
logger.info(`Connecting to leader at ${info.api}...`);
|
||||
await this.connectToRedis(info.api);
|
||||
});
|
||||
|
||||
this.leaderDiscovery.onLeaderLost(async () => {
|
||||
logger.warn('Leader lost, disconnecting...');
|
||||
await this.disconnectRedis();
|
||||
this.notifyDisconnect();
|
||||
});
|
||||
|
||||
// Start leader discovery
|
||||
await this.leaderDiscovery.start();
|
||||
|
||||
this.isStarted = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the KV manager
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isStarted) return;
|
||||
|
||||
logger.info('Stopping KVManager...');
|
||||
this.isShuttingDown = true;
|
||||
|
||||
// Stop service discovery
|
||||
if (this.serviceDiscovery) {
|
||||
await this.serviceDiscovery.stop();
|
||||
this.serviceDiscovery = null;
|
||||
}
|
||||
|
||||
// Stop leader discovery
|
||||
if (this.leaderDiscovery) {
|
||||
await this.leaderDiscovery.stop();
|
||||
this.leaderDiscovery = null;
|
||||
}
|
||||
|
||||
// Disconnect Redis
|
||||
await this.disconnectRedis();
|
||||
|
||||
this.isStarted = false;
|
||||
logger.info('KVManager stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Redis
|
||||
*/
|
||||
private async connectToRedis(url: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
if (!this.redisClient) return false;
|
||||
try {
|
||||
const stats = await this.redisClient.getStats();
|
||||
return stats.connected;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get leader info (if using leader discovery)
|
||||
*/
|
||||
getLeaderInfo(): LeaderLockInfo | null {
|
||||
return this.leaderDiscovery?.getLeaderInfo() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user