bugfix on service discovery

This commit is contained in:
Pierre
2026-02-02 00:02:39 +01:00
parent 9a60f0e231
commit 38643eb129
9 changed files with 360 additions and 31 deletions
+21 -1
View File
@@ -53,12 +53,19 @@ func main() {
// Create and start service discovery // Create and start service discovery
disc := discovery.NewService(cfg) disc := discovery.NewService(cfg)
disc.SetRoleProvider(&roleProviderAdapter{election: election})
if err := disc.Start(); err != nil { if err := disc.Start(); err != nil {
log.Fatalf("[meta-core] Failed to start service discovery: %v", err) log.Fatalf("[meta-core] Failed to start service discovery: %v", err)
} }
// Create and start dead service cleaner
cleaner := discovery.NewCleaner(cfg)
if err := cleaner.Start(); err != nil {
log.Printf("[meta-core] Warning: failed to start service cleaner: %v", err)
}
// Create and start API server // Create and start API server
apiServer := api.NewServer(cfg, election, disc, storageClient) apiServer := api.NewServer(cfg, election, disc, cleaner, storageClient)
if err := apiServer.Start(); err != nil { if err := apiServer.Start(); err != nil {
log.Fatalf("[meta-core] Failed to start API server: %v", err) log.Fatalf("[meta-core] Failed to start API server: %v", err)
} }
@@ -75,6 +82,10 @@ func main() {
log.Printf("[meta-core] Error stopping API server: %v", err) log.Printf("[meta-core] Error stopping API server: %v", err)
} }
if err := cleaner.Stop(); err != nil {
log.Printf("[meta-core] Error stopping service cleaner: %v", err)
}
if err := disc.Stop(); err != nil { if err := disc.Stop(); err != nil {
log.Printf("[meta-core] Error stopping service discovery: %v", err) log.Printf("[meta-core] Error stopping service discovery: %v", err)
} }
@@ -109,3 +120,12 @@ func (a *storageConnectorAdapter) Connect(url string) error {
func (a *storageConnectorAdapter) Close() error { func (a *storageConnectorAdapter) Close() error {
return a.client.Close() return a.client.Close()
} }
// roleProviderAdapter adapts leader.Election to discovery.RoleProvider
type roleProviderAdapter struct {
election *leader.Election
}
func (a *roleProviderAdapter) Role() string {
return string(a.election.Role())
}
+1 -1
View File
@@ -21,7 +21,7 @@
min-height: 100vh; min-height: 100vh;
} }
</style> </style>
<script type="module" crossorigin src="/assets/index-D4NLfyZA.js"></script> <script type="module" crossorigin src="/assets/index-Bd95QA6X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css"> <link rel="stylesheet" crossorigin href="/assets/index-DtR_2V4s.css">
</head> </head>
<body> <body>
+11 -6
View File
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react';
interface ServiceInfo { interface ServiceInfo {
name: string; name: string;
api: string; hostname: string;
baseUrl: string;
status: string; status: string;
capabilities: string[]; lastHeartbeat: 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;
@@ -69,12 +74,12 @@ function ServiceNav() {
return ( return (
<a <a
key={service.name} key={service.name}
href={isActive ? '#' : service.api} href={isActive ? '#' : service.baseUrl}
className={`service-link${isActive ? ' active' : ''}`} className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (!isActive) { if (!isActive) {
window.location.href = service.api; window.location.href = service.baseUrl;
} }
}} }}
> >
+11
View File
@@ -449,6 +449,17 @@ func (s *Server) handleGetService(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, service) writeJSON(w, http.StatusOK, service)
} }
// handleCleanupStats handles GET /services/cleanup/stats
func (s *Server) handleCleanupStats(w http.ResponseWriter, r *http.Request) {
if s.cleaner == nil {
writeError(w, http.StatusServiceUnavailable, "cleaner not initialized")
return
}
stats := s.cleaner.Stats()
writeJSON(w, http.StatusOK, stats)
}
// writeJSON writes a JSON response // writeJSON writes a JSON response
func writeJSON(w http.ResponseWriter, status int, data interface{}) { func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
+4
View File
@@ -21,6 +21,7 @@ type Server struct {
config *config.Config config *config.Config
election *leader.Election election *leader.Election
discovery *discovery.Service discovery *discovery.Service
cleaner *discovery.Cleaner
storage *storage.Client storage *storage.Client
mountsManager *mounts.Manager mountsManager *mounts.Manager
mountsHandlers *mounts.Handlers mountsHandlers *mounts.Handlers
@@ -37,12 +38,14 @@ func NewServer(
cfg *config.Config, cfg *config.Config,
election *leader.Election, election *leader.Election,
disc *discovery.Service, disc *discovery.Service,
cleaner *discovery.Cleaner,
stor *storage.Client, stor *storage.Client,
) *Server { ) *Server {
s := &Server{ s := &Server{
config: cfg, config: cfg,
election: election, election: election,
discovery: disc, discovery: disc,
cleaner: cleaner,
storage: stor, storage: stor,
router: mux.NewRouter(), router: mux.NewRouter(),
} }
@@ -130,6 +133,7 @@ func (s *Server) setupRoutes() {
// Service discovery // Service discovery
s.router.HandleFunc("/services", s.handleListServices).Methods("GET") s.router.HandleFunc("/services", s.handleListServices).Methods("GET")
s.router.HandleFunc("/services/cleanup/stats", s.handleCleanupStats).Methods("GET")
s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET") s.router.HandleFunc("/services/{name}", s.handleGetService).Methods("GET")
// Mount management routes (if manager initialized) // Mount management routes (if manager initialized)
+4
View File
@@ -29,6 +29,8 @@ type Config struct {
HealthCheckIntervalMS int // Health check interval in ms (default: 5000) HealthCheckIntervalMS int // Health check interval in ms (default: 5000)
HeartbeatIntervalMS int // Service heartbeat interval in ms (default: 30000) HeartbeatIntervalMS int // Service heartbeat interval in ms (default: 30000)
StaleThresholdMS int // Stale service threshold in ms (default: 60000) StaleThresholdMS int // Stale service threshold in ms (default: 60000)
CleanupIntervalMS int // Dead service cleanup interval in ms (default: 600000)
DeadServiceThresholdMS int // Dead service threshold in ms (default: 600000)
// File watcher configuration // File watcher configuration
WatchFolderList []string // List of folders to watch for file changes WatchFolderList []string // List of folders to watch for file changes
@@ -55,6 +57,8 @@ func Load() *Config {
HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000), HealthCheckIntervalMS: getEnvInt("HEALTH_CHECK_INTERVAL_MS", 5000),
HeartbeatIntervalMS: getEnvInt("HEARTBEAT_INTERVAL_MS", 30000), HeartbeatIntervalMS: getEnvInt("HEARTBEAT_INTERVAL_MS", 30000),
StaleThresholdMS: getEnvInt("STALE_THRESHOLD_MS", 60000), StaleThresholdMS: getEnvInt("STALE_THRESHOLD_MS", 60000),
CleanupIntervalMS: getEnvInt("CLEANUP_INTERVAL_MS", 600000),
DeadServiceThresholdMS: getEnvInt("DEAD_SERVICE_THRESHOLD_MS", 600000),
WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000), WatchIntervalMS: getEnvInt("WATCH_INTERVAL_MS", 1000),
DebounceMS: getEnvInt("DEBOUNCE_MS", 30000), DebounceMS: getEnvInt("DEBOUNCE_MS", 30000),
EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true), EnableFileWatcher: getEnvBool("ENABLE_FILE_WATCHER", true),
+196
View File
@@ -0,0 +1,196 @@
package discovery
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/metazla/meta-core/internal/config"
)
// CleanupStats holds statistics about the cleanup process
type CleanupStats struct {
LastRunTime string `json:"lastRunTime,omitempty"`
ServicesRemoved int `json:"servicesRemoved"`
TotalRuns int `json:"totalRuns"`
Errors int `json:"errors"`
}
// Cleaner handles periodic cleanup of dead service registration files
type Cleaner struct {
config *config.Config
servicesDir string
ownFile string // Our own service file (never delete)
stats CleanupStats
stopChan chan struct{}
wg sync.WaitGroup
mu sync.RWMutex
}
// NewCleaner creates a new service cleaner
func NewCleaner(cfg *config.Config) *Cleaner {
hostname, _ := os.Hostname()
return &Cleaner{
config: cfg,
servicesDir: cfg.ServicesDir(),
ownFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+"-"+hostname+".json"),
stopChan: make(chan struct{}),
}
}
// Start begins the cleanup loop
func (c *Cleaner) Start() error {
log.Printf("[Cleaner] Starting dead service cleanup (interval: %dms, threshold: %dms)",
c.config.CleanupIntervalMS, c.config.DeadServiceThresholdMS)
c.wg.Add(1)
go c.cleanupLoop()
return nil
}
// Stop stops the cleanup loop
func (c *Cleaner) Stop() error {
log.Println("[Cleaner] Stopping dead service cleanup...")
close(c.stopChan)
c.wg.Wait()
return nil
}
// cleanupLoop runs the periodic cleanup
func (c *Cleaner) cleanupLoop() {
defer c.wg.Done()
// Run an initial cleanup shortly after starting
initialDelay := time.NewTimer(30 * time.Second)
select {
case <-c.stopChan:
initialDelay.Stop()
return
case <-initialDelay.C:
c.runCleanup()
}
ticker := time.NewTicker(time.Duration(c.config.CleanupIntervalMS) * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-c.stopChan:
return
case <-ticker.C:
c.runCleanup()
}
}
}
// runCleanup scans the services directory and removes dead services
func (c *Cleaner) runCleanup() {
c.mu.Lock()
c.stats.TotalRuns++
c.stats.LastRunTime = time.Now().UTC().Format(time.RFC3339)
c.mu.Unlock()
entries, err := os.ReadDir(c.servicesDir)
if err != nil {
if !os.IsNotExist(err) {
log.Printf("[Cleaner] Failed to read services directory: %v", err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
}
return
}
threshold := time.Duration(c.config.DeadServiceThresholdMS) * time.Millisecond
removedCount := 0
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
filePath := filepath.Join(c.servicesDir, entry.Name())
// Never delete our own service file
if filePath == c.ownFile {
continue
}
// Check if service is dead
isDead, serviceName, age := c.isServiceDead(filePath, threshold)
if isDead {
if err := os.Remove(filePath); err != nil {
log.Printf("[Cleaner] Failed to remove dead service file %s: %v", entry.Name(), err)
c.mu.Lock()
c.stats.Errors++
c.mu.Unlock()
} else {
ageMinutes := age.Minutes()
log.Printf("[Cleaner] Removed dead service: %s (last heartbeat: %.1f minutes ago)", serviceName, ageMinutes)
removedCount++
}
}
}
if removedCount > 0 {
c.mu.Lock()
c.stats.ServicesRemoved += removedCount
c.mu.Unlock()
log.Printf("[Cleaner] Cleanup complete: removed %d dead service(s)", removedCount)
}
}
// isServiceDead checks if a service file represents a dead service
// Returns: isDead, serviceName, age since last heartbeat
func (c *Cleaner) isServiceDead(filePath string, threshold time.Duration) (bool, string, time.Duration) {
data, err := os.ReadFile(filePath)
if err != nil {
// Can't read file - treat as dead to clean up corrupted files
log.Printf("[Cleaner] Cannot read service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, filepath.Base(filePath), threshold
}
var info ServiceInfo
if err := json.Unmarshal(data, &info); err != nil {
// Corrupted JSON - skip rather than delete (might be recoverable)
log.Printf("[Cleaner] Skipping corrupted service file %s: %v", filepath.Base(filePath), err)
return false, "", 0
}
// Parse last heartbeat timestamp
if info.LastHeartbeat == "" {
// No heartbeat timestamp - treat as dead
return true, info.Name, threshold
}
lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat)
if err != nil {
// Invalid timestamp - treat as dead
log.Printf("[Cleaner] Invalid timestamp in service file %s: %v (treating as dead)", filepath.Base(filePath), err)
return true, info.Name, threshold
}
age := time.Since(lastHeartbeat)
if age > threshold {
return true, info.Name, age
}
return false, info.Name, age
}
// Stats returns a copy of the current cleanup statistics
func (c *Cleaner) Stats() CleanupStats {
c.mu.RLock()
defer c.mu.RUnlock()
return CleanupStats{
LastRunTime: c.stats.LastRunTime,
ServicesRemoved: c.stats.ServicesRemoved,
TotalRuns: c.stats.TotalRuns,
Errors: c.stats.Errors,
}
}
+27 -2
View File
@@ -21,6 +21,12 @@ type ServiceInfo struct {
BaseUrl string `json:"baseUrl"` BaseUrl string `json:"baseUrl"`
Status string `json:"status"` Status string `json:"status"`
LastHeartbeat string `json:"lastHeartbeat"` LastHeartbeat string `json:"lastHeartbeat"`
Role string `json:"role,omitempty"` // "leader", "follower", or empty (for non-meta-core services)
}
// RoleProvider interface for getting the current role
type RoleProvider interface {
Role() string
} }
// Service handles service registration and discovery // Service handles service registration and discovery
@@ -29,6 +35,7 @@ type Service struct {
servicesDir string servicesDir string
serviceFile string serviceFile string
info *ServiceInfo info *ServiceInfo
roleProvider RoleProvider
mu sync.RWMutex mu sync.RWMutex
stopChan chan struct{} stopChan chan struct{}
@@ -46,6 +53,11 @@ func NewService(cfg *config.Config) *Service {
} }
} }
// SetRoleProvider sets the role provider for this service
func (s *Service) SetRoleProvider(rp RoleProvider) {
s.roleProvider = rp
}
// Start begins service registration and heartbeat // Start begins service registration and heartbeat
func (s *Service) Start() error { func (s *Service) Start() error {
log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName) log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName)
@@ -95,13 +107,20 @@ func (s *Service) buildServiceInfo() *ServiceInfo {
baseUrl = fmt.Sprintf("http://%s:%d", ip, s.config.APIPort) baseUrl = fmt.Sprintf("http://%s:%d", ip, s.config.APIPort)
} }
return &ServiceInfo{ info := &ServiceInfo{
Name: s.config.ServiceName, Name: s.config.ServiceName,
Hostname: hostname, Hostname: hostname,
BaseUrl: baseUrl, BaseUrl: baseUrl,
Status: "running", Status: "running",
LastHeartbeat: time.Now().UTC().Format(time.RFC3339), LastHeartbeat: time.Now().UTC().Format(time.RFC3339),
} }
// Add role if role provider is set (e.g., for meta-core)
if s.roleProvider != nil {
info.Role = s.roleProvider.Role()
}
return info
} }
// register writes service info to file // register writes service info to file
@@ -145,7 +164,7 @@ func (s *Service) heartbeatLoop() {
} }
} }
// heartbeat updates the last heartbeat timestamp // heartbeat updates the last heartbeat timestamp and role
func (s *Service) heartbeat() { func (s *Service) heartbeat() {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -155,6 +174,12 @@ func (s *Service) heartbeat() {
} }
s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339) s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339)
// Update role in case it changed (e.g., leader election)
if s.roleProvider != nil {
s.info.Role = s.roleProvider.Role()
}
if err := s.writeServiceInfo(s.info); err != nil { if err := s.writeServiceInfo(s.info); err != nil {
log.Printf("[Discovery] Failed to update heartbeat: %v", err) log.Printf("[Discovery] Failed to update heartbeat: %v", err)
} }
+76 -12
View File
@@ -1,12 +1,16 @@
package leader package leader
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"net" "net"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -311,17 +315,24 @@ func (e *Election) buildLeaderInfo() *LeaderLockInfo {
} }
} }
// writeLeaderInfo atomically writes leader info to file // writeLeaderInfo atomically writes leader API URL to file (plain text)
// Only writes if the content has changed to avoid triggering file watchers unnecessarily
func (e *Election) writeLeaderInfo(info *LeaderLockInfo) error { func (e *Election) writeLeaderInfo(info *LeaderLockInfo) error {
infoPath := e.config.InfoFilePath() infoPath := e.config.InfoFilePath()
tempPath := infoPath + ".tmp"
data, err := json.MarshalIndent(info, "", " ") // Write just the API URL as plain text
if err != nil { newData := []byte(info.ApiUrl)
return err
// Check if file already has the same content
existingData, err := os.ReadFile(infoPath)
if err == nil && string(existingData) == string(newData) {
// Content unchanged, skip write to avoid triggering file watchers
return nil
} }
if err := os.WriteFile(tempPath, data, 0644); err != nil { // Content changed or file doesn't exist, write atomically
tempPath := infoPath + ".tmp"
if err := os.WriteFile(tempPath, newData, 0644); err != nil {
return err return err
} }
@@ -329,11 +340,11 @@ func (e *Election) writeLeaderInfo(info *LeaderLockInfo) error {
return err return err
} }
log.Printf("[Election] Wrote leader info to %s", infoPath) log.Printf("[Election] Wrote leader API URL to %s: %s", infoPath, info.ApiUrl)
return nil return nil
} }
// readLeaderInfo reads leader info from file // readLeaderInfo reads leader API URL from file and fetches full info via /urls API
func (e *Election) readLeaderInfo() (*LeaderLockInfo, error) { func (e *Election) readLeaderInfo() (*LeaderLockInfo, error) {
infoPath := e.config.InfoFilePath() infoPath := e.config.InfoFilePath()
@@ -345,12 +356,65 @@ func (e *Election) readLeaderInfo() (*LeaderLockInfo, error) {
return nil, err return nil, err
} }
var info LeaderLockInfo // File contains plain text API URL
if err := json.Unmarshal(data, &info); err != nil { apiUrl := strings.TrimSpace(string(data))
return nil, err if apiUrl == "" {
return nil, nil
} }
return &info, nil // Fetch full info from /urls API
return e.fetchLeaderInfoFromAPI(apiUrl)
}
// fetchLeaderInfoFromAPI calls the /urls API to get full leader info
func (e *Election) fetchLeaderInfoFromAPI(apiUrl string) (*LeaderLockInfo, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", apiUrl+"/urls", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call /urls API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("/urls API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Parse URLsResponse format
var urlsResp struct {
Hostname string `json:"hostname"`
BaseUrl string `json:"baseUrl"`
ApiUrl string `json:"apiUrl"`
RedisUrl string `json:"redisUrl"`
WebdavUrl string `json:"webdavUrl"`
IsLeader bool `json:"isLeader"`
}
if err := json.Unmarshal(body, &urlsResp); err != nil {
return nil, fmt.Errorf("failed to parse /urls response: %w", err)
}
// Convert to LeaderLockInfo
return &LeaderLockInfo{
Hostname: urlsResp.Hostname,
BaseUrl: urlsResp.BaseUrl,
ApiUrl: urlsResp.ApiUrl,
RedisUrl: urlsResp.RedisUrl,
WebdavUrl: urlsResp.WebdavUrl,
Timestamp: time.Now().UnixMilli(),
PID: 0, // Unknown for remote leader
}, nil
} }
// healthCheckLoop periodically checks health and updates timestamps // healthCheckLoop periodically checks health and updates timestamps