replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 14s

This commit is contained in:
Worph
2026-09-14 11:25:34 +02:00
parent af51bbf943
commit f5cafa77b2
6 changed files with 36 additions and 237 deletions
@@ -237,7 +237,7 @@ export class UnifiedAPIServer {
this.setupStatsRoutes(); 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. // LeaderClient to resolve meta-core's current URL dynamically.
this.setupServiceDiscoveryRoutes(); this.setupServiceDiscoveryRoutes();
@@ -592,33 +592,28 @@ export class UnifiedAPIServer {
} }
/** /**
* Service discovery — proxies to meta-core's /api/services, resolving the * Service discovery — meta-discovery v1. Served from this service's own UDP
* current leader's URL via LeaderClient (no hardcoded hostname). * neighbour map; no meta-core round trip, so the nav survives a core outage.
*/ */
private setupServiceDiscoveryRoutes(): void { private setupServiceDiscoveryRoutes(): void {
this.app.get('/api/services', async (_request, reply) => { // meta-discovery v1: neighbours heard over UDP, served from this service's
try { // 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(); const leaderClient = this.kvManager?.getLeaderClient();
if (!leaderClient) { 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(); const neighbors = leaderClient.getNeighbors();
if (!apiUrl) { return reply.send({
return reply.status(503).send({ error: 'Leader not available yet' }); current: 'meta-sort',
} enabled: true,
const response = await fetch(`${apiUrl}/api/services`, { count: neighbors.length,
signal: AbortSignal.timeout(5000), 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) });
}
}); });
} }
+1 -1
View File
@@ -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 * Written by meta-core leader, read by other services
*/ */
export interface LeaderLockInfo { export interface LeaderLockInfo {
+9 -9
View File
@@ -2,13 +2,12 @@
* KV Manager - Unified manager for KV storage and service discovery * KV Manager - Unified manager for KV storage and service discovery
* *
* This is the main entry point for the KV subsystem. It: * 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) * 2. Creates and manages the KV client (Redis)
* 3. Handles service discovery registration
* 4. Manages reconnection on leader changes * 4. Manages reconnection on leader changes
* *
* Note: Leader election is now handled by meta-core (Go sidecar). * Note: there is no leader election. meta-core announces itself on the
* This service only reads the leader info and connects to Redis. * discovery group; this service listens and connects to its HTTP API.
* *
* Usage: * Usage:
* ```typescript * ```typescript
@@ -112,7 +111,12 @@ export class KVManager {
this.leaderClient = new LeaderClient({ this.leaderClient = new LeaderClient({
metaCoreUrl: this.config.metaCoreUrl, metaCoreUrl: this.config.metaCoreUrl,
serviceName: this.config.serviceName, 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 // Watch for leader changes
@@ -313,10 +317,6 @@ export class KVManager {
/** /**
* Get service registration instance * Get service registration instance
*/ */
/** @deprecated File-based registration is gone; always null. */
getServiceRegistration(): null {
return null;
}
/** /**
* Check if the KV store is healthy * 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;
}
}
+2 -2
View File
@@ -5,7 +5,8 @@
* - IKVClient interface for KV storage abstraction * - IKVClient interface for KV storage abstraction
* - RedisKVClient for Redis-based storage * - RedisKVClient for Redis-based storage
* - LeaderClient for reading leader info from meta-core * - 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 * - KVManager for unified management of all components
* - MetadataUtils for flattening/reconstructing nested metadata * - MetadataUtils for flattening/reconstructing nested metadata
*/ */
@@ -13,6 +14,5 @@
export * from './IKVClient.js'; export * from './IKVClient.js';
export * from './RedisClient.js'; export * from './RedisClient.js';
export * from './LeaderClient.js'; export * from './LeaderClient.js';
export * from './ServiceRegistration.js';
export * from './KVManager.js'; export * from './KVManager.js';
export * from './MetadataUtils.js'; export * from './MetadataUtils.js';
@@ -1,5 +1,8 @@
import { useEffect } from 'react'; 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. * ServiceNav — the neighbouring meta-* services, as a burger menu.