Fixed olric cluster problem

This commit is contained in:
anonpenguin23 2026-01-31 12:14:49 +02:00
parent 51371e199d
commit 8c392194bb
4 changed files with 175 additions and 101 deletions

View File

@ -1,6 +1,7 @@
package namespace package namespace
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@ -90,7 +91,9 @@ func (h *SpawnHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
zap.String("node_id", req.NodeID), zap.String("node_id", req.NodeID),
) )
ctx := r.Context() // Use a background context for spawn operations so processes outlive the HTTP request.
// Stop operations can use request context since they're short-lived.
ctx := context.Background()
switch req.Action { switch req.Action {
case "spawn-rqlite": case "spawn-rqlite":

View File

@ -306,46 +306,76 @@ func (cm *ClusterManager) startRQLiteCluster(ctx context.Context, cluster *Names
return instances, nil return instances, nil
} }
// startOlricCluster starts Olric instances on all nodes (locally or remotely) // startOlricCluster starts Olric instances on all nodes concurrently.
// Olric uses memberlist for peer discovery — all peers must be reachable at roughly
// the same time. Sequential spawning fails because early instances exhaust their
// retry budget before later instances start. By spawning all concurrently, all
// memberlist ports open within seconds of each other, allowing discovery to succeed.
func (cm *ClusterManager) startOlricCluster(ctx context.Context, cluster *NamespaceCluster, nodes []NodeCapacity, portBlocks []*PortBlock) ([]*olric.OlricInstance, error) { func (cm *ClusterManager) startOlricCluster(ctx context.Context, cluster *NamespaceCluster, nodes []NodeCapacity, portBlocks []*PortBlock) ([]*olric.OlricInstance, error) {
instances := make([]*olric.OlricInstance, len(nodes)) instances := make([]*olric.OlricInstance, len(nodes))
errs := make([]error, len(nodes))
// Build peer addresses (all nodes) // Build configs for all nodes upfront
peerAddresses := make([]string, len(nodes)) configs := make([]olric.InstanceConfig, len(nodes))
for i, node := range nodes { for i, node := range nodes {
peerAddresses[i] = fmt.Sprintf("%s:%d", node.InternalIP, portBlocks[i].OlricMemberlistPort) var peers []string
} for j, peerNode := range nodes {
if j != i {
// Start all Olric instances peers = append(peers, fmt.Sprintf("%s:%d", peerNode.InternalIP, portBlocks[j].OlricMemberlistPort))
for i, node := range nodes { }
cfg := olric.InstanceConfig{ }
configs[i] = olric.InstanceConfig{
Namespace: cluster.NamespaceName, Namespace: cluster.NamespaceName,
NodeID: node.NodeID, NodeID: node.NodeID,
HTTPPort: portBlocks[i].OlricHTTPPort, HTTPPort: portBlocks[i].OlricHTTPPort,
MemberlistPort: portBlocks[i].OlricMemberlistPort, MemberlistPort: portBlocks[i].OlricMemberlistPort,
BindAddr: node.InternalIP, // Bind to node's WG IP (0.0.0.0 resolves to IPv6 on some hosts) BindAddr: node.InternalIP, // Bind to WG IP directly (0.0.0.0 resolves to IPv6 on some hosts)
AdvertiseAddr: node.InternalIP, // Advertise WG IP to peers AdvertiseAddr: node.InternalIP, // Advertise WG IP to peers
PeerAddresses: peerAddresses, PeerAddresses: peers,
} }
}
var instance *olric.OlricInstance // Spawn all instances concurrently
var err error var wg sync.WaitGroup
if node.NodeID == cm.localNodeID { for i, node := range nodes {
cm.logger.Info("Spawning Olric locally", zap.String("node", node.NodeID)) wg.Add(1)
instance, err = cm.olricSpawner.SpawnInstance(ctx, cfg) go func(idx int, n NodeCapacity) {
} else { defer wg.Done()
cm.logger.Info("Spawning Olric remotely", zap.String("node", node.NodeID), zap.String("ip", node.InternalIP)) if n.NodeID == cm.localNodeID {
instance, err = cm.spawnOlricRemote(ctx, node.InternalIP, cfg) cm.logger.Info("Spawning Olric locally", zap.String("node", n.NodeID))
} instances[idx], errs[idx] = cm.olricSpawner.SpawnInstance(ctx, configs[idx])
if err != nil { } else {
// Stop previously started instances cm.logger.Info("Spawning Olric remotely", zap.String("node", n.NodeID), zap.String("ip", n.InternalIP))
for j := 0; j < i; j++ { instances[idx], errs[idx] = cm.spawnOlricRemote(ctx, n.InternalIP, configs[idx])
cm.stopOlricOnNode(ctx, nodes[j].NodeID, nodes[j].InternalIP, cluster.NamespaceName)
} }
return nil, fmt.Errorf("failed to start Olric on node %s: %w", node.NodeID, err) }(i, node)
} }
instances[i] = instance wg.Wait()
// Check for errors — if any failed, stop all and return
for i, err := range errs {
if err != nil {
cm.logger.Error("Olric spawn failed", zap.String("node", nodes[i].NodeID), zap.Error(err))
// Stop any that succeeded
for j := range nodes {
if errs[j] == nil {
cm.stopOlricOnNode(ctx, nodes[j].NodeID, nodes[j].InternalIP, cluster.NamespaceName)
}
}
return nil, fmt.Errorf("failed to start Olric on node %s: %w", nodes[i].NodeID, err)
}
}
// All instances started — give memberlist time to converge.
// Olric's memberlist retries peer joins every ~1s for ~10 attempts.
// Since all instances are now up, they should discover each other quickly.
cm.logger.Info("All Olric instances started, waiting for memberlist convergence",
zap.Int("node_count", len(nodes)),
)
time.Sleep(5 * time.Second)
// Log events and record cluster nodes
for i, node := range nodes {
cm.logEvent(ctx, cluster.ID, EventOlricStarted, node.NodeID, "Olric instance started", nil) cm.logEvent(ctx, cluster.ID, EventOlricStarted, node.NodeID, "Olric instance started", nil)
cm.logEvent(ctx, cluster.ID, EventOlricJoined, node.NodeID, "Olric instance joined memberlist", nil) cm.logEvent(ctx, cluster.ID, EventOlricJoined, node.NodeID, "Olric instance joined memberlist", nil)
@ -354,6 +384,18 @@ func (cm *ClusterManager) startOlricCluster(ctx context.Context, cluster *Namesp
} }
} }
// Verify at least the local instance is still healthy after convergence
for i, node := range nodes {
if node.NodeID == cm.localNodeID && instances[i] != nil {
healthy, err := instances[i].IsHealthy(ctx)
if !healthy {
cm.logger.Warn("Local Olric instance unhealthy after convergence wait", zap.Error(err))
} else {
cm.logger.Info("Local Olric instance healthy after convergence")
}
}
}
return instances, nil return instances, nil
} }

View File

@ -54,32 +54,34 @@ type InstanceSpawner struct {
// OlricInstance represents a running Olric instance for a namespace // OlricInstance represents a running Olric instance for a namespace
type OlricInstance struct { type OlricInstance struct {
Namespace string Namespace string
NodeID string NodeID string
HTTPPort int HTTPPort int
MemberlistPort int MemberlistPort int
BindAddr string BindAddr string
AdvertiseAddr string AdvertiseAddr string
PeerAddresses []string // Memberlist peer addresses for cluster discovery PeerAddresses []string // Memberlist peer addresses for cluster discovery
ConfigPath string ConfigPath string
DataDir string DataDir string
PID int PID int
Status InstanceNodeStatus Status InstanceNodeStatus
StartedAt time.Time StartedAt time.Time
LastHealthCheck time.Time LastHealthCheck time.Time
cmd *exec.Cmd cmd *exec.Cmd
logger *zap.Logger logFile *os.File // kept open for process lifetime
waitDone chan struct{} // closed when cmd.Wait() completes
logger *zap.Logger
} }
// InstanceConfig holds configuration for spawning an Olric instance // InstanceConfig holds configuration for spawning an Olric instance
type InstanceConfig struct { type InstanceConfig struct {
Namespace string // Namespace name (e.g., "alice") Namespace string // Namespace name (e.g., "alice")
NodeID string // Physical node ID NodeID string // Physical node ID
HTTPPort int // HTTP API port HTTPPort int // HTTP API port
MemberlistPort int // Memberlist gossip port MemberlistPort int // Memberlist gossip port
BindAddr string // Address to bind (e.g., "0.0.0.0") BindAddr string // Address to bind (e.g., "0.0.0.0")
AdvertiseAddr string // Address to advertise (e.g., "192.168.1.10") AdvertiseAddr string // Address to advertise (e.g., "192.168.1.10")
PeerAddresses []string // Memberlist peer addresses for initial cluster join PeerAddresses []string // Memberlist peer addresses for initial cluster join
} }
// OlricConfig represents the Olric YAML configuration structure // OlricConfig represents the Olric YAML configuration structure
@ -117,19 +119,21 @@ func instanceKey(namespace, nodeID string) string {
} }
// SpawnInstance starts a new Olric instance for a namespace on a specific node. // SpawnInstance starts a new Olric instance for a namespace on a specific node.
// Returns the instance info or an error if spawning fails. // The process is decoupled from the caller's context — it runs independently until
// explicitly stopped. Only returns an error if the process fails to start or the
// memberlist port doesn't open within the timeout.
// Note: The memberlist port opening does NOT mean the cluster has formed — peers may
// still be joining. Use WaitForProcessRunning() after spawning all instances to verify.
func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig) (*OlricInstance, error) { func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig) (*OlricInstance, error) {
key := instanceKey(cfg.Namespace, cfg.NodeID) key := instanceKey(cfg.Namespace, cfg.NodeID)
is.mu.Lock() is.mu.Lock()
if existing, ok := is.instances[key]; ok { if existing, ok := is.instances[key]; ok {
is.mu.Unlock() if existing.Status == InstanceStatusRunning || existing.Status == InstanceStatusStarting {
// Instance already exists, return it if running is.mu.Unlock()
if existing.Status == InstanceStatusRunning {
return existing, nil return existing, nil
} }
// Otherwise, remove it and start fresh // Remove stale instance
is.mu.Lock()
delete(is.instances, key) delete(is.instances, key)
} }
is.mu.Unlock() is.mu.Unlock()
@ -165,6 +169,7 @@ func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig
ConfigPath: configPath, ConfigPath: configPath,
DataDir: dataDir, DataDir: dataDir,
Status: InstanceStatusStarting, Status: InstanceStatusStarting,
waitDone: make(chan struct{}),
logger: is.logger.With(zap.String("namespace", cfg.Namespace), zap.String("node_id", cfg.NodeID)), logger: is.logger.With(zap.String("namespace", cfg.Namespace), zap.String("node_id", cfg.NodeID)),
} }
@ -174,12 +179,14 @@ func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig
zap.Strings("peers", cfg.PeerAddresses), zap.Strings("peers", cfg.PeerAddresses),
) )
// Create command with config environment variable // Use exec.Command (NOT exec.CommandContext) so the process is NOT killed
cmd := exec.CommandContext(ctx, "olric-server") // when the HTTP request context or provisioning context is cancelled.
// The process lives until explicitly stopped via StopInstance().
cmd := exec.Command("olric-server")
cmd.Env = append(os.Environ(), fmt.Sprintf("OLRIC_SERVER_CONFIG=%s", configPath)) cmd.Env = append(os.Environ(), fmt.Sprintf("OLRIC_SERVER_CONFIG=%s", configPath))
instance.cmd = cmd instance.cmd = cmd
// Setup logging // Setup logging — keep the file open for the process lifetime
logPath := filepath.Join(logsDir, fmt.Sprintf("olric-%s.log", cfg.NodeID)) logPath := filepath.Join(logsDir, fmt.Sprintf("olric-%s.log", cfg.NodeID))
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil { if err != nil {
@ -188,6 +195,7 @@ func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig
Cause: err, Cause: err,
} }
} }
instance.logFile = logFile
cmd.Stdout = logFile cmd.Stdout = logFile
cmd.Stderr = logFile cmd.Stderr = logFile
@ -201,18 +209,26 @@ func (is *InstanceSpawner) SpawnInstance(ctx context.Context, cfg InstanceConfig
} }
} }
logFile.Close()
instance.PID = cmd.Process.Pid instance.PID = cmd.Process.Pid
instance.StartedAt = time.Now() instance.StartedAt = time.Now()
// Reap the child process in a background goroutine to prevent zombies.
// This goroutine closes the log file and signals via waitDone when the process exits.
go func() {
_ = cmd.Wait()
logFile.Close()
close(instance.waitDone)
}()
// Store instance // Store instance
is.mu.Lock() is.mu.Lock()
is.instances[key] = instance is.instances[key] = instance
is.mu.Unlock() is.mu.Unlock()
// Wait for instance to be ready // Wait for the memberlist port to accept TCP connections.
if err := is.waitForInstanceReady(ctx, instance); err != nil { // This confirms the process started and Olric initialized its network layer.
// It does NOT guarantee peers have joined — that happens asynchronously.
if err := is.waitForPortReady(ctx, instance); err != nil {
// Kill the process on failure // Kill the process on failure
if cmd.Process != nil { if cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
@ -295,20 +311,17 @@ func (is *InstanceSpawner) StopInstance(ctx context.Context, ns, nodeID string)
_ = instance.cmd.Process.Kill() _ = instance.cmd.Process.Kill()
} }
// Wait for process to exit with timeout // Wait for process to exit via the reaper goroutine
done := make(chan error, 1)
go func() {
done <- instance.cmd.Wait()
}()
select { select {
case <-done: case <-instance.waitDone:
instance.logger.Info("Olric instance stopped gracefully") instance.logger.Info("Olric instance stopped gracefully")
case <-time.After(10 * time.Second): case <-time.After(10 * time.Second):
instance.logger.Warn("Olric instance did not stop gracefully, killing") instance.logger.Warn("Olric instance did not stop gracefully, killing")
_ = instance.cmd.Process.Kill() _ = instance.cmd.Process.Kill()
<-instance.waitDone // wait for reaper to finish
case <-ctx.Done(): case <-ctx.Done():
_ = instance.cmd.Process.Kill() _ = instance.cmd.Process.Kill()
<-instance.waitDone
return ctx.Err() return ctx.Err()
} }
} }
@ -379,31 +392,29 @@ func (is *InstanceSpawner) HealthCheck(ctx context.Context, ns, nodeID string) (
return healthy, err return healthy, err
} }
// waitForInstanceReady waits for the Olric instance to be ready // waitForPortReady waits for the Olric memberlist port to accept TCP connections.
func (is *InstanceSpawner) waitForInstanceReady(ctx context.Context, instance *OlricInstance) error { // This is a lightweight check — it confirms the process started but does NOT
// Olric doesn't have a standard /ready endpoint, so we check if the process // guarantee that peers have joined the cluster.
// is running and the memberlist port is accepting connections func (is *InstanceSpawner) waitForPortReady(ctx context.Context, instance *OlricInstance) error {
// Use BindAddr for the health check — this is the address the process actually listens on.
// AdvertiseAddr may differ from BindAddr (e.g., 0.0.0.0 resolves to IPv6 on some hosts).
checkAddr := instance.BindAddr
if checkAddr == "" || checkAddr == "0.0.0.0" {
checkAddr = "localhost"
}
addr := fmt.Sprintf("%s:%d", checkAddr, instance.MemberlistPort)
maxAttempts := 30 // 30 seconds maxAttempts := 30
for i := 0; i < maxAttempts; i++ { for i := 0; i < maxAttempts; i++ {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-instance.waitDone:
// Process exited before becoming ready
return fmt.Errorf("Olric process exited unexpectedly (pid %d)", instance.PID)
case <-time.After(1 * time.Second): case <-time.After(1 * time.Second):
} }
// Check if the process is still running
if instance.cmd != nil && instance.cmd.ProcessState != nil && instance.cmd.ProcessState.Exited() {
return fmt.Errorf("Olric process exited unexpectedly")
}
// Try to connect to the memberlist port to verify it's accepting connections
// Use the advertise address since Olric may bind to a specific IP
addr := fmt.Sprintf("%s:%d", instance.AdvertiseAddr, instance.MemberlistPort)
if instance.AdvertiseAddr == "" {
addr = fmt.Sprintf("localhost:%d", instance.MemberlistPort)
}
conn, err := net.DialTimeout("tcp", addr, 2*time.Second) conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
if err != nil { if err != nil {
instance.logger.Debug("Waiting for Olric memberlist", instance.logger.Debug("Waiting for Olric memberlist",
@ -415,7 +426,7 @@ func (is *InstanceSpawner) waitForInstanceReady(ctx context.Context, instance *O
} }
conn.Close() conn.Close()
instance.logger.Debug("Olric instance ready", instance.logger.Debug("Olric memberlist port ready",
zap.Int("attempts", i+1), zap.Int("attempts", i+1),
zap.String("addr", addr), zap.String("addr", addr),
) )
@ -430,14 +441,27 @@ func (is *InstanceSpawner) monitorInstance(instance *OlricInstance) {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for range ticker.C { for {
select {
case <-instance.waitDone:
// Process exited — update status and stop monitoring
is.mu.Lock()
key := instanceKey(instance.Namespace, instance.NodeID)
if _, exists := is.instances[key]; exists {
instance.Status = InstanceStatusStopped
instance.logger.Warn("Olric instance process exited unexpectedly")
}
is.mu.Unlock()
return
case <-ticker.C:
}
is.mu.RLock() is.mu.RLock()
key := instanceKey(instance.Namespace, instance.NodeID) key := instanceKey(instance.Namespace, instance.NodeID)
_, exists := is.instances[key] _, exists := is.instances[key]
is.mu.RUnlock() is.mu.RUnlock()
if !exists { if !exists {
// Instance was removed
return return
} }
@ -454,21 +478,18 @@ func (is *InstanceSpawner) monitorInstance(instance *OlricInstance) {
instance.logger.Warn("Olric instance health check failed") instance.logger.Warn("Olric instance health check failed")
} }
is.mu.Unlock() is.mu.Unlock()
// Check if process is still running
if instance.cmd != nil && instance.cmd.ProcessState != nil && instance.cmd.ProcessState.Exited() {
is.mu.Lock()
instance.Status = InstanceStatusStopped
is.mu.Unlock()
instance.logger.Warn("Olric instance process exited unexpectedly")
return
}
} }
} }
// IsHealthy checks if the Olric instance is healthy by verifying the memberlist port is accepting connections // IsHealthy checks if the Olric instance is healthy by verifying the memberlist port is accepting connections
func (oi *OlricInstance) IsHealthy(ctx context.Context) (bool, error) { func (oi *OlricInstance) IsHealthy(ctx context.Context) (bool, error) {
// Olric doesn't have a standard /ready HTTP endpoint, so we check memberlist connectivity // Check if process has exited first
select {
case <-oi.waitDone:
return false, fmt.Errorf("process has exited")
default:
}
addr := fmt.Sprintf("%s:%d", oi.AdvertiseAddr, oi.MemberlistPort) addr := fmt.Sprintf("%s:%d", oi.AdvertiseAddr, oi.MemberlistPort)
if oi.AdvertiseAddr == "" || oi.AdvertiseAddr == "0.0.0.0" { if oi.AdvertiseAddr == "" || oi.AdvertiseAddr == "0.0.0.0" {
addr = fmt.Sprintf("localhost:%d", oi.MemberlistPort) addr = fmt.Sprintf("localhost:%d", oi.MemberlistPort)

View File

@ -318,8 +318,16 @@ func setReflectValue(field reflect.Value, raw any) error {
return nil return nil
} }
fallthrough fallthrough
case reflect.Ptr:
// Handle pointer types (e.g. *time.Time, *string, *int)
// nil raw is already handled above (leaves zero/nil pointer)
elem := reflect.New(field.Type().Elem())
if err := setReflectValue(elem.Elem(), raw); err != nil {
return err
}
field.Set(elem)
return nil
default: default:
// Not supported yet
return fmt.Errorf("unsupported dest field kind: %s", field.Kind()) return fmt.Errorf("unsupported dest field kind: %s", field.Kind())
} }
return nil return nil