bugfix on service discovery

This commit is contained in:
Pierre
2026-02-02 00:03:01 +01:00
parent 34ff1375c6
commit f430519dd5
11 changed files with 217 additions and 66 deletions
@@ -988,6 +988,7 @@ export class UnifiedAPIServer {
url: string; url: string;
api: string; api: string;
status: string; status: string;
role?: string;
}> = []; }> = [];
try { try {
@@ -1001,6 +1002,7 @@ export class UnifiedAPIServer {
url: svc.baseUrl || '', url: svc.baseUrl || '',
api: svc.baseUrl || '', api: svc.baseUrl || '',
status: svc.status || 'unknown', status: svc.status || 'unknown',
role: svc.role,
}); });
} }
} }
@@ -19,9 +19,13 @@ import type {
} from './types.js'; } from './types.js';
import type { TaskQueueType } from '../plugin-engine/types.js'; import type { TaskQueueType } from '../plugin-engine/types.js';
import type { IKVClient } from '../kv/IKVClient.js'; import type { IKVClient } from '../kv/IKVClient.js';
import { hasPublish, type ExtendedContainerTask } from '../types/ExtendedInterfaces.js'; import { hasStreamSupport, type ExtendedContainerTask } from '../types/ExtendedInterfaces.js';
import { config } from '../config/EnvConfig.js'; import { config } from '../config/EnvConfig.js';
// Redis Streams for reliable event delivery
const EVENTS_STREAM = 'meta-sort:events';
const STREAM_MAXLEN = 10000;
/** /**
* Scheduler events * Scheduler events
*/ */
@@ -212,27 +216,30 @@ export class ContainerPluginScheduler extends EventEmitter {
} }
/** /**
* Publish plugin completion event to Redis * Publish plugin completion event to Redis Streams
* Channel: meta-sort:plugin:complete
* *
* External services (like meta-stremio) can subscribe to this channel * External services (like meta-fuse, meta-stremio) consume this stream
* to be notified when specific plugins complete for a file. * to be notified when specific plugins complete for a file.
*/ */
private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> { private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> {
if (!this.kvClient || !hasPublish(this.kvClient)) { if (!this.kvClient || !hasStreamSupport(this.kvClient)) {
return; return;
} }
try { try {
const message = JSON.stringify({ const payload = JSON.stringify({
fileHash, fileHash,
pluginId, pluginId,
filePath, filePath,
timestamp: Date.now() timestamp: Date.now()
}); });
await this.kvClient.publish('meta-sort:plugin:complete', message); await this.kvClient.xadd(EVENTS_STREAM, STREAM_MAXLEN, {
type: 'plugin:complete',
payload,
timestamp: Date.now().toString()
});
} catch (error) { } catch (error) {
// Silent fail - pub/sub is optional // Silent fail - stream publish is optional
} }
} }
@@ -184,4 +184,7 @@ export interface ServiceInfo {
/** Last heartbeat timestamp (ISO string) */ /** Last heartbeat timestamp (ISO string) */
lastHeartbeat: string; lastHeartbeat: string;
/** Role for meta-core instances: 'leader', 'follower', or undefined for other services */
role?: string;
} }
+2 -3
View File
@@ -130,8 +130,7 @@ export class KVManager {
// Start watching for leader changes // Start watching for leader changes
this.leaderClient.startWatching(); this.leaderClient.startWatching();
// Initialize service discovery (meta-core handles actual registration) // Initialize and start service discovery for registration and heartbeat
// We keep this for service discovery (reading other services)
const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`; const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`;
this.serviceDiscovery = new ServiceDiscovery({ this.serviceDiscovery = new ServiceDiscovery({
metaCorePath: this.config.metaCorePath, metaCorePath: this.config.metaCorePath,
@@ -139,7 +138,7 @@ export class KVManager {
version: '1.0.0', version: '1.0.0',
apiUrl apiUrl
}); });
// Note: We don't start ServiceDiscovery - meta-core handles registration await this.serviceDiscovery.start();
this.isStarted = true; this.isStarted = true;
this.notifyReady(); this.notifyReady();
+80 -24
View File
@@ -39,18 +39,87 @@ export class LeaderClient {
private watcher: FSWatcher | null = null; private watcher: FSWatcher | null = null;
private onChangeCallbacks: (() => void)[] = []; private onChangeCallbacks: (() => void)[] = [];
// URL caching
private cachedUrls: URLsResponse | null = null;
private urlsCacheTime: number = 0;
private readonly urlsCacheTTL: number = 5000; // 5 seconds
constructor(config: LeaderClientConfig) { constructor(config: LeaderClientConfig) {
this.config = config; this.config = config;
this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`; this.infoFilePath = `${config.metaCorePath}/locks/kv-leader.info`;
} }
/** /**
* Read leader info from the info file * Read API URL from file (plain text format)
*/
private async getApiUrlFromFile(): Promise<string | null> {
try {
const content = await fs.readFile(this.infoFilePath, 'utf-8');
return content.trim() || null;
} catch (error) {
console.error('[LeaderClient] Failed to read API URL from file:', error);
return null;
}
}
/**
* Fetch URLs from meta-core /urls API with caching
*/
private async fetchUrls(apiUrl: string): Promise<URLsResponse | null> {
// Check cache
const now = Date.now();
if (this.cachedUrls && (now - this.urlsCacheTime) < this.urlsCacheTTL) {
return this.cachedUrls;
}
try {
const response = await fetch(`${apiUrl}/urls`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.error('[LeaderClient] Failed to fetch URLs:', response.status, response.statusText);
return null;
}
this.cachedUrls = await response.json() as URLsResponse;
this.urlsCacheTime = now;
return this.cachedUrls;
} catch (error) {
console.error('[LeaderClient] Error calling /urls API:', error);
return null;
}
}
/**
* Read leader info from file and /urls API
*/ */
async getLeaderInfo(): Promise<LeaderLockInfo | null> { async getLeaderInfo(): Promise<LeaderLockInfo | null> {
try { try {
const content = await fs.readFile(this.infoFilePath, 'utf-8'); // Read API URL from file (plain text)
this.leaderInfo = JSON.parse(content) as LeaderLockInfo; const apiUrl = await this.getApiUrlFromFile();
if (!apiUrl) {
return null;
}
// Fetch full info from /urls API
const urls = await this.fetchUrls(apiUrl);
if (!urls) {
return null;
}
// Convert URLsResponse to LeaderLockInfo
this.leaderInfo = {
hostname: urls.hostname,
baseUrl: urls.baseUrl,
apiUrl: urls.apiUrl,
redisUrl: urls.redisUrl,
webdavUrl: urls.webdavUrl,
timestamp: Date.now(),
pid: 0 // Unknown for remote leader
};
return this.leaderInfo; return this.leaderInfo;
} catch (error) { } catch (error) {
console.error('[LeaderClient] Failed to read leader info:', error); console.error('[LeaderClient] Failed to read leader info:', error);
@@ -90,10 +159,9 @@ export class LeaderClient {
// First try using configured metaCoreUrl // First try using configured metaCoreUrl
let apiUrl = this.config.metaCoreUrl; let apiUrl = this.config.metaCoreUrl;
// Fall back to reading from leader info // Fall back to reading from file
if (!apiUrl) { if (!apiUrl) {
const info = await this.getLeaderInfo(); apiUrl = await this.getApiUrlFromFile();
apiUrl = info?.apiUrl;
} }
if (!apiUrl) { if (!apiUrl) {
@@ -101,23 +169,7 @@ export class LeaderClient {
return null; return null;
} }
try { return this.fetchUrls(apiUrl);
const response = await fetch(`${apiUrl}/urls`, {
method: 'GET',
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.error('[LeaderClient] Failed to get URLs:', response.status, response.statusText);
return null;
}
return await response.json() as URLsResponse;
} catch (error) {
console.error('[LeaderClient] Error calling /urls API:', error);
return null;
}
} }
/** /**
@@ -154,7 +206,11 @@ export class LeaderClient {
try { try {
this.watcher = watch(lockDir, (eventType, filename) => { this.watcher = watch(lockDir, (eventType, filename) => {
if (filename === 'kv-leader.info') { if (filename === 'kv-leader.info') {
console.log('[LeaderClient] Leader info changed, reloading...'); console.log('[LeaderClient] Leader info changed, invalidating cache...');
// Invalidate cache to force fresh API call
this.cachedUrls = null;
this.urlsCacheTime = 0;
this.getLeaderInfo().then(() => { this.getLeaderInfo().then(() => {
this.notifyChange(); this.notifyChange();
}).catch(console.error); }).catch(console.error);
@@ -338,6 +338,39 @@ export class RedisKVClient implements IKVClient {
return await this.redis.publish(channel, message); return await this.redis.publish(channel, message);
} }
// ========================================================================
// Redis Streams Operations
// ========================================================================
/**
* Add an entry to a Redis stream
* Used for reliable event delivery between services (replaces pub/sub)
*
* @param stream - Stream name (e.g., 'meta-sort:events')
* @param maxlen - Maximum stream length (approximate, uses ~ for efficiency)
* @param fields - Object containing field-value pairs to add
* @returns The ID of the added entry
*/
async xadd(stream: string, maxlen: number, fields: Record<string, string>): Promise<string> {
// Convert fields object to flat array for ioredis
const fieldArray: string[] = [];
for (const [key, value] of Object.entries(fields)) {
fieldArray.push(key, value);
}
// Use XADD with MAXLEN ~ for approximate trimming (more efficient)
const result = await this.redis.xadd(
stream,
'MAXLEN',
'~',
maxlen.toString(),
'*',
...fieldArray
);
return result as string;
}
// ======================================================================== // ========================================================================
// Lifecycle // Lifecycle
// ======================================================================== // ========================================================================
@@ -7,8 +7,8 @@
* Services can discover each other by reading these JSON files. * Services can discover each other by reading these JSON files.
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered stale. * Stale detection: if lastHeartbeat > 60 seconds ago, service is considered stale.
* *
* Note: meta-core (Go sidecar) handles actual service registration. * Each service must call start() to register itself and begin heartbeat updates.
* This class is primarily used for discovering other services. * meta-core runs a cleanup process that removes services with stale heartbeats.
*/ */
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
@@ -79,9 +79,8 @@ export class ServiceDiscovery {
} }
/** /**
* Register this service * Register this service by writing service info to the shared filesystem.
* Note: In the new architecture, meta-core handles registration. * meta-core's cleanup process monitors these files and removes stale entries.
* This is kept for backwards compatibility and testing.
*/ */
async register(): Promise<void> { async register(): Promise<void> {
await this.ensureServicesDir(); await this.ensureServicesDir();
@@ -314,8 +313,7 @@ export class ServiceDiscovery {
// ======================================================================== // ========================================================================
/** /**
* Full startup sequence * Full startup sequence: register service and start heartbeat loop.
* Note: In the new architecture, meta-core handles registration.
*/ */
async start(): Promise<void> { async start(): Promise<void> {
await this.register(); await this.register();
@@ -15,7 +15,11 @@ import {VirtualFileSystem} from "../api/VirtualFileSystem.js";
import {performanceMetrics} from "../metrics/PerformanceMetrics.js"; import {performanceMetrics} from "../metrics/PerformanceMetrics.js";
import {UnifiedProcessingStateManager} from "./UnifiedProcessingStateManager.js"; import {UnifiedProcessingStateManager} from "./UnifiedProcessingStateManager.js";
import type {IKVClient} from "../kv/IKVClient.js"; import type {IKVClient} from "../kv/IKVClient.js";
import { hasPublish, type ExtendedFileAnalyzer, type ExtendedHashMeta } from "../types/ExtendedInterfaces.js"; import { hasStreamSupport, type ExtendedFileAnalyzer, type ExtendedHashMeta } from "../types/ExtendedInterfaces.js";
// Redis Streams for reliable event delivery
const EVENTS_STREAM = 'meta-sort:events';
const STREAM_MAXLEN = 10000;
import PQueue from "p-queue"; import PQueue from "p-queue";
import os from 'os'; import os from 'os';
import { TaskScheduler, createTaskScheduler, MetadataNodeKVStore } from "../plugin-engine/index.js"; import { TaskScheduler, createTaskScheduler, MetadataNodeKVStore } from "../plugin-engine/index.js";
@@ -244,25 +248,28 @@ export class WatchedFileProcessor implements FileProcessorInterface {
} }
/** /**
* Publish plugin completion event to Redis * Publish plugin completion event to Redis Streams
* Channel: meta-sort:plugin:complete
*/ */
private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> { private async publishPluginComplete(fileHash: string, pluginId: string, filePath: string): Promise<void> {
const kvClient = this.getKVClient(); const kvClient = this.getKVClient();
if (!kvClient || !hasPublish(kvClient)) { if (!kvClient || !hasStreamSupport(kvClient)) {
return; return;
} }
try { try {
const message = JSON.stringify({ const payload = JSON.stringify({
fileHash, fileHash,
pluginId, pluginId,
filePath, filePath,
timestamp: Date.now() timestamp: Date.now()
}); });
await kvClient.publish('meta-sort:plugin:complete', message); await kvClient.xadd(EVENTS_STREAM, STREAM_MAXLEN, {
type: 'plugin:complete',
payload,
timestamp: Date.now().toString()
});
} catch (error) { } catch (error) {
// Silent fail - pub/sub is optional // Silent fail - stream publish is optional
} }
} }
@@ -2,12 +2,14 @@ import * as path from 'path';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import {PipelineConfig} from './PipelineConfig.js'; import {PipelineConfig} from './PipelineConfig.js';
import {performanceMetrics} from '../../metrics/PerformanceMetrics.js'; import {performanceMetrics} from '../../metrics/PerformanceMetrics.js';
import { hasStreamSupport } from '../../types/ExtendedInterfaces.js';
// Pub/sub channel for batched updates to meta-fuse // Redis Streams for reliable event delivery to meta-fuse
const UPDATE_CHANNEL = 'meta-sort:file:batch'; // Single stream with type field for all event types (batch, reset, plugin:complete)
const EVENTS_STREAM = 'meta-sort:events';
// Pub/sub channel for scan reset notification // Maximum stream length (approximate, ~10000 entries provides hours of history)
const RESET_CHANNEL = 'meta-sort:scan:reset'; const STREAM_MAXLEN = 10000;
// Batch update interval (5 seconds) // Batch update interval (5 seconds)
const BATCH_INTERVAL_MS = 5000; const BATCH_INTERVAL_MS = 5000;
@@ -24,7 +26,7 @@ interface BatchChange {
* *
* With midhash256 being instant (< 1s), files appear in VFS immediately * With midhash256 being instant (< 1s), files appear in VFS immediately
* *
* Publishes batched updates to meta-fuse via Redis pub/sub every 5 seconds. * Publishes batched updates to meta-fuse via Redis Streams every 5 seconds.
*/ */
export class StreamingPipeline { export class StreamingPipeline {
private validationQueue: PQueue; private validationQueue: PQueue;
@@ -37,7 +39,7 @@ export class StreamingPipeline {
private fastProcessedCount = 0; private fastProcessedCount = 0;
private backgroundProcessedCount = 0; private backgroundProcessedCount = 0;
// Batch buffer for pub/sub notifications // Batch buffer for stream notifications
private batchBuffer: BatchChange[] = []; private batchBuffer: BatchChange[] = [];
private batchTimer: NodeJS.Timeout | null = null; private batchTimer: NodeJS.Timeout | null = null;
@@ -100,7 +102,7 @@ export class StreamingPipeline {
} }
/** /**
* Flush the batch buffer and publish to Redis * Flush the batch buffer and publish to Redis Streams
*/ */
private async flushBatch(): Promise<void> { private async flushBatch(): Promise<void> {
if (this.batchBuffer.length === 0) { if (this.batchBuffer.length === 0) {
@@ -112,13 +114,17 @@ export class StreamingPipeline {
try { try {
const kvClient = this.config.kvClient; const kvClient = this.config.kvClient;
if (kvClient && typeof (kvClient as any).publish === 'function') { if (kvClient && hasStreamSupport(kvClient)) {
const message = JSON.stringify({ const payload = JSON.stringify({
timestamp: Date.now(), timestamp: Date.now(),
changes changes
}); });
await (kvClient as any).publish(UPDATE_CHANNEL, message); await kvClient.xadd(EVENTS_STREAM, STREAM_MAXLEN, {
console.log(`[Pipeline] Published batch update: ${changes.length} changes`); type: 'batch',
payload,
timestamp: Date.now().toString()
});
console.log(`[Pipeline] Published batch update to stream: ${changes.length} changes`);
} }
} catch (error: any) { } catch (error: any) {
// Put changes back in buffer on failure // Put changes back in buffer on failure
@@ -423,15 +429,19 @@ export class StreamingPipeline {
// Clear state manager (discovered, processing, done states) // Clear state manager (discovered, processing, done states)
this.config.stateManager.clear(); this.config.stateManager.clear();
// Publish reset event to notify meta-fuse // Publish reset event to Redis Streams to notify meta-fuse
if (this.config.kvClient && typeof (this.config.kvClient as any).publish === 'function') { if (this.config.kvClient && hasStreamSupport(this.config.kvClient)) {
try { try {
const message = JSON.stringify({ const payload = JSON.stringify({
timestamp: Date.now(), timestamp: Date.now(),
action: 'reset' action: 'reset'
}); });
await (this.config.kvClient as any).publish(RESET_CHANNEL, message); await this.config.kvClient.xadd(EVENTS_STREAM, STREAM_MAXLEN, {
console.log('[Pipeline] Reset event published to meta-fuse'); type: 'reset',
payload,
timestamp: Date.now().toString()
});
console.log('[Pipeline] Reset event published to stream');
} catch (error) { } catch (error) {
console.error('[Pipeline] Failed to publish reset event:', error); console.error('[Pipeline] Failed to publish reset event:', error);
} }
@@ -41,6 +41,37 @@ export function hasPublish(client: IKVClient): client is IKVClientWithPubSub {
return typeof (client as IKVClientWithPubSub).publish === 'function'; return typeof (client as IKVClientWithPubSub).publish === 'function';
} }
/**
* IKVClient with Redis Streams capabilities
*
* Redis Streams provide reliable event delivery with persistence,
* consumer groups, and replay capabilities. Preferred over pub/sub
* for service-to-service communication.
*/
export interface IKVClientWithStreams extends IKVClient {
/**
* Add an entry to a Redis stream
*
* @param stream - Stream name (e.g., 'meta-sort:events')
* @param maxlen - Maximum stream length (approximate, uses ~ for efficiency)
* @param fields - Object containing field-value pairs to add
* @returns The ID of the added entry
*/
xadd(stream: string, maxlen: number, fields: Record<string, string>): Promise<string>;
/**
* Get the underlying Redis client (implementation-specific)
*/
getRedisClient?(): unknown;
}
/**
* Type guard to check if a KVClient supports Redis Streams
*/
export function hasStreamSupport(client: IKVClient): client is IKVClientWithStreams {
return typeof (client as IKVClientWithStreams).xadd === 'function';
}
// ============================================================================= // =============================================================================
// HashMeta Extensions // HashMeta Extensions
// ============================================================================= // =============================================================================
@@ -7,6 +7,7 @@ interface ServiceInfo {
status: string; status: string;
capabilities: string[]; capabilities: string[];
version: string; version: string;
role?: string; // "leader", "follower", or undefined (for non-meta-core services)
} }
interface ServicesResponse { interface ServicesResponse {
@@ -55,7 +56,11 @@ function ServiceNav() {
if (loading) return null; if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name)); // Filter out follower meta-core instances (only show leader)
const filteredServices = services.filter(
s => s.name !== 'meta-core' || s.role === 'leader'
);
const sortedServices = [...filteredServices].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null; if (sortedServices.length === 0) return null;