replace file discovery by udp discovery
Build and Push Docker Image / build (push) Failing after 14s
Build and Push Docker Image / build (push) Failing after 14s
This commit is contained in:
@@ -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
|
||||||
const leaderClient = this.kvManager?.getLeaderClient();
|
// still renders when the core is down. `services` is kept as an alias
|
||||||
if (!leaderClient) {
|
// while any older dashboard build is still in circulation.
|
||||||
return reply.status(503).send({ error: 'KVManager not initialized' });
|
this.app.get('/api/neighbors', async (_request, reply) => {
|
||||||
}
|
const leaderClient = this.kvManager?.getLeaderClient();
|
||||||
const apiUrl = await leaderClient.getApiUrl();
|
if (!leaderClient) {
|
||||||
if (!apiUrl) {
|
return reply.send({ current: 'meta-sort', enabled: false, count: 0, neighbors: [] });
|
||||||
return reply.status(503).send({ error: 'Leader not available yet' });
|
|
||||||
}
|
|
||||||
const response = await fetch(`${apiUrl}/api/services`, {
|
|
||||||
signal: AbortSignal.timeout(5000),
|
|
||||||
});
|
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
const neighbors = leaderClient.getNeighbors();
|
||||||
|
return reply.send({
|
||||||
|
current: 'meta-sort',
|
||||||
|
enabled: true,
|
||||||
|
count: neighbors.length,
|
||||||
|
neighbors,
|
||||||
|
services: neighbors,
|
||||||
|
self: leaderClient.self(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user