replace file discovery by udp discovery
This commit is contained in:
@@ -237,7 +237,7 @@ export class UnifiedAPIServer {
|
||||
this.setupStatsRoutes();
|
||||
}
|
||||
|
||||
// Service discovery — proxies to meta-core's /api/services using the
|
||||
// Service discovery — meta-discovery v1 neighbour list, served from the
|
||||
// LeaderClient to resolve meta-core's current URL dynamically.
|
||||
this.setupServiceDiscoveryRoutes();
|
||||
|
||||
@@ -592,33 +592,28 @@ export class UnifiedAPIServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Service discovery — proxies to meta-core's /api/services, resolving the
|
||||
* current leader's URL via LeaderClient (no hardcoded hostname).
|
||||
* Service discovery — meta-discovery v1. Served from this service's own UDP
|
||||
* neighbour map; no meta-core round trip, so the nav survives a core outage.
|
||||
*/
|
||||
private setupServiceDiscoveryRoutes(): void {
|
||||
this.app.get('/api/services', async (_request, reply) => {
|
||||
try {
|
||||
// meta-discovery v1: neighbours heard over UDP, served from this service's
|
||||
// own map. Unlike /api/services below it needs no meta-core, so the nav
|
||||
// still renders when the core is down. `services` is kept as an alias
|
||||
// while any older dashboard build is still in circulation.
|
||||
this.app.get('/api/neighbors', async (_request, reply) => {
|
||||
const leaderClient = this.kvManager?.getLeaderClient();
|
||||
if (!leaderClient) {
|
||||
return reply.status(503).send({ error: 'KVManager not initialized' });
|
||||
return reply.send({ current: 'meta-sort', enabled: false, count: 0, neighbors: [] });
|
||||
}
|
||||
const apiUrl = await leaderClient.getApiUrl();
|
||||
if (!apiUrl) {
|
||||
return reply.status(503).send({ error: 'Leader not available yet' });
|
||||
}
|
||||
const response = await fetch(`${apiUrl}/api/services`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
const neighbors = leaderClient.getNeighbors();
|
||||
return reply.send({
|
||||
current: 'meta-sort',
|
||||
enabled: true,
|
||||
count: neighbors.length,
|
||||
neighbors,
|
||||
services: neighbors,
|
||||
self: leaderClient.self(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return reply.status(response.status).send({
|
||||
error: `Upstream ${apiUrl} returned ${response.status}`,
|
||||
});
|
||||
}
|
||||
const body = await response.json();
|
||||
return reply.send(body);
|
||||
} catch (err) {
|
||||
return reply.status(502).send({ error: getErrorMessage(err) });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ export interface KVClientConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Leader lock file content format (kv-leader.info)
|
||||
* meta-core's identity and URL set (from its UDP announce, or /urls)
|
||||
* Written by meta-core leader, read by other services
|
||||
*/
|
||||
export interface LeaderLockInfo {
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* KV Manager - Unified manager for KV storage and service discovery
|
||||
*
|
||||
* This is the main entry point for the KV subsystem. It:
|
||||
* 1. Uses LeaderClient to read leader info from meta-core
|
||||
* 1. Uses LeaderClient to locate meta-core over UDP (meta-discovery v1)
|
||||
* 2. Creates and manages the KV client (Redis)
|
||||
* 3. Handles service discovery registration
|
||||
* 4. Manages reconnection on leader changes
|
||||
*
|
||||
* Note: Leader election is now handled by meta-core (Go sidecar).
|
||||
* This service only reads the leader info and connects to Redis.
|
||||
* Note: there is no leader election. meta-core announces itself on the
|
||||
* discovery group; this service listens and connects to its HTTP API.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
@@ -112,7 +111,12 @@ export class KVManager {
|
||||
this.leaderClient = new LeaderClient({
|
||||
metaCoreUrl: this.config.metaCoreUrl,
|
||||
serviceName: this.config.serviceName,
|
||||
baseUrl: this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`,
|
||||
// Browser-facing URL for the nav menu, in the order the spec requires:
|
||||
// PUBLIC_URL (reachable when there is no Caddy perimeter in front — a
|
||||
// debug-direct port) -> BASE_URL (the Caddy URL) -> container host.
|
||||
// Announcing a Caddy URL on a stack with no Caddy running is what puts
|
||||
// dead links in every neighbour's menu.
|
||||
baseUrl: process.env.PUBLIC_URL || this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`,
|
||||
});
|
||||
|
||||
// Watch for leader changes
|
||||
@@ -313,10 +317,6 @@ export class KVManager {
|
||||
/**
|
||||
* Get service registration instance
|
||||
*/
|
||||
/** @deprecated File-based registration is gone; always null. */
|
||||
getServiceRegistration(): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the KV store is healthy
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* Service Registration using shared filesystem
|
||||
*
|
||||
* Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
||||
* with its base URL, status, and heartbeat timestamp.
|
||||
*
|
||||
* Service discovery is centralized in meta-core - this class only handles registration.
|
||||
* For service discovery, use the /api/services endpoint (proxied to meta-core via nginx).
|
||||
*
|
||||
* Each service must call start() to register itself and begin heartbeat updates.
|
||||
* meta-core runs a cleanup process that removes services with stale heartbeats.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { hostname } from 'os';
|
||||
import type { ServiceInfo } from './IKVClient.js';
|
||||
import { isNodeError } from '../types/ExtendedInterfaces.js';
|
||||
|
||||
interface ServiceRegistrationConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
/** Service name */
|
||||
serviceName: string;
|
||||
|
||||
/** Service version (optional, not used in simplified format) */
|
||||
version?: string;
|
||||
|
||||
/** Base URL for the service */
|
||||
apiUrl: string;
|
||||
|
||||
/** Heartbeat interval in ms (default: 30000) */
|
||||
heartbeatInterval?: number;
|
||||
|
||||
/** Stale threshold in ms (default: 60000) */
|
||||
staleThreshold?: number;
|
||||
}
|
||||
|
||||
export class ServiceRegistration {
|
||||
private config: ServiceRegistrationConfig;
|
||||
private servicesDir: string;
|
||||
private serviceFilePath: string;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private isShuttingDown = false;
|
||||
private currentHostname: string;
|
||||
|
||||
constructor(config: ServiceRegistrationConfig) {
|
||||
this.config = {
|
||||
heartbeatInterval: 30000,
|
||||
staleThreshold: 60000,
|
||||
...config
|
||||
};
|
||||
|
||||
this.currentHostname = hostname();
|
||||
this.servicesDir = `${this.config.metaCorePath}/services`;
|
||||
// Use hostname-based file naming
|
||||
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure services directory exists
|
||||
*/
|
||||
private async ensureServicesDir(): Promise<void> {
|
||||
await fs.mkdir(this.servicesDir, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Build service info for this service
|
||||
*/
|
||||
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
|
||||
return {
|
||||
name: this.config.serviceName,
|
||||
hostname: this.currentHostname,
|
||||
baseUrl: this.config.apiUrl,
|
||||
status,
|
||||
lastHeartbeat: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this service by writing service info to the shared filesystem.
|
||||
* meta-core's cleanup process monitors these files and removes stale entries.
|
||||
*/
|
||||
async register(): Promise<void> {
|
||||
await this.ensureServicesDir();
|
||||
|
||||
const info = this.buildServiceInfo('running');
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
|
||||
console.log(`[ServiceRegistration] Registered ${this.config.serviceName}-${this.currentHostname}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update service status
|
||||
*/
|
||||
async updateStatus(status: ServiceInfo['status']): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.status = status;
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error) {
|
||||
console.error('[ServiceRegistration] Failed to update status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat (update lastHeartbeat timestamp)
|
||||
*/
|
||||
async heartbeat(): Promise<void> {
|
||||
try {
|
||||
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
|
||||
const info = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
info.lastHeartbeat = new Date().toISOString();
|
||||
|
||||
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
|
||||
} catch (error) {
|
||||
// If file was deleted, re-register
|
||||
if (isNodeError(error) && error.code === 'ENOENT') {
|
||||
await this.register();
|
||||
} else {
|
||||
console.error('[ServiceRegistration] Heartbeat failed:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat loop
|
||||
*/
|
||||
startHeartbeat(): void {
|
||||
this.heartbeatTimer = setInterval(async () => {
|
||||
if (this.isShuttingDown) return;
|
||||
await this.heartbeat();
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat loop
|
||||
*/
|
||||
stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister this service (on shutdown)
|
||||
*/
|
||||
async unregister(): Promise<void> {
|
||||
this.isShuttingDown = true;
|
||||
this.stopHeartbeat();
|
||||
|
||||
try {
|
||||
// Remove the service file
|
||||
await fs.unlink(this.serviceFilePath);
|
||||
console.log(`[ServiceRegistration] Unregistered ${this.config.serviceName}-${this.currentHostname}`);
|
||||
} catch (error) {
|
||||
if (!isNodeError(error) || error.code !== 'ENOENT') {
|
||||
console.error('[ServiceRegistration] Failed to unregister:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Full startup sequence: register service and start heartbeat loop.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
await this.register();
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full shutdown sequence
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
await this.unregister();
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Getters
|
||||
// ========================================================================
|
||||
|
||||
getServiceFilePath(): string {
|
||||
return this.serviceFilePath;
|
||||
}
|
||||
|
||||
getServicesDir(): string {
|
||||
return this.servicesDir;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
* - IKVClient interface for KV storage abstraction
|
||||
* - RedisKVClient for Redis-based storage
|
||||
* - LeaderClient for reading leader info from meta-core
|
||||
* - ServiceRegistration for service registration (discovery is centralized in meta-core)
|
||||
* Services announce and discover over UDP (meta-discovery v1); there is no
|
||||
* separate registration step, so no ServiceRegistration export.
|
||||
* - KVManager for unified management of all components
|
||||
* - MetadataUtils for flattening/reconstructing nested metadata
|
||||
*/
|
||||
@@ -13,6 +14,5 @@
|
||||
export * from './IKVClient.js';
|
||||
export * from './RedisClient.js';
|
||||
export * from './LeaderClient.js';
|
||||
export * from './ServiceRegistration.js';
|
||||
export * from './KVManager.js';
|
||||
export * from './MetadataUtils.js';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import './meta-service-menu.js';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore -- plain ES module, no .d.ts; imported for its side effect
|
||||
// of defining the custom element.
|
||||
import '../meta-service-menu.js';
|
||||
|
||||
/**
|
||||
* ServiceNav — the neighbouring meta-* services, as a burger menu.
|
||||
|
||||
Reference in New Issue
Block a user