Ctrl K
ComunidadGuías…

internal/home/cluster_balance.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package home

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"strconv"
	"strings"

	"github.com/redis/go-redis/v9"
	log "github.com/sirupsen/logrus"
)

const redisChannelRebalance = "rebalance"

// ErrRebalanceRequested asks the supervisor to reconnect without draining safe active executions.
var ErrRebalanceRequested = errors.New("home cluster rebalance requested")

type rebalanceInstruction struct {
	ID     string      `json:"id"`
	Term   int64       `json:"term"`
	Target clusterNode `json:"target"`
}

type clusterBalanceState struct {
	moving           bool
	returning        bool
	target           clusterNode
	origin           clusterNode
	request          rebalanceInstruction
	result           string
	registered       bool
	unsupported      bool
	latencySupported bool
}

func (c *Client) canExecuteRebalanceLocked() bool {
	return c.managed && c.clusterDiscoveryEnabledLocked() && !c.legacyMembership &&
		c.lifecycle.LifecycleConfigRevision > 0 && c.heartbeatOK.Load() &&
		!c.dispatchFenced.Load() && !c.ambiguousDispatch.Load() &&
		recoveryState(c.recoveryState.Load()) == recoveryStateStable && !c.balance.moving
}

// rebalanceCommand owns a cancellable transport independent of issued dispatches.
func (c *Client) rebalanceCommand(ctx context.Context, address string, args ...any) (string, error) {
	if errContext := ctx.Err(); errContext != nil {
		return "", errContext
	}
	c.mu.Lock()
	if address == "" {
		address, _ = c.addrLocked()
	}
	options, errOptions := c.redisOptionsLocked(address)
	c.mu.Unlock()
	if errOptions != nil {
		return "", errOptions
	}
	options.ReadTimeout = -1
	options.WriteTimeout = -1
	baseDialer := redis.NewDialer(options)
	options.Dialer = func(dialCtx context.Context, network, dialAddress string) (net.Conn, error) {
		conn, errDial := baseDialer(dialCtx, network, dialAddress)
		if errDial != nil {
			return nil, errDial
		}
		if ctx.Err() != nil {
			_ = closeUnderlyingTransport(conn)
			return nil, context.Cause(ctx)
		}
		return newPluginSyncCancelableConn(ctx, conn), nil
	}
	command := redis.NewClient(options)
	stopCancel := context.AfterFunc(ctx, func() { _ = command.Close() })
	defer func() {
		stopCancel()
		if errClose := command.Close(); errClose != nil && ctx.Err() == nil {
			log.Debugf("Home rebalance transport close failed: %v", errClose)
		}
	}()
	raw, errCommand := command.Do(ctx, args...).Text()
	if errCommand != nil {
		return "", fmt.Errorf("Home rebalance %v: %w", args[0], errCommand)
	}
	return raw, nil
}

func (c *Client) syncClusterBalance(ctx context.Context) error {
	c.mu.Lock()
	eligible := c.canExecuteRebalanceLocked() && !c.balance.unsupported
	registered, request, result := c.balance.registered, c.balance.request, c.balance.result
	c.mu.Unlock()
	if !eligible {
		return nil
	}
	if !registered {
		raw, errEnable := c.rebalanceCommand(ctx, "", "CLUSTER", "BALANCE", "ENABLE")
		if errEnable != nil {
			message := strings.ToLower(errEnable.Error())
			if isHomeCommandUnsupported(errEnable) || strings.Contains(message, "unsupported subcommand") || strings.Contains(message, "rebalance unsupported") {
				// Legacy Home has no capability code; only an explicit unsupported reply disables balancing.
				c.mu.Lock()
				c.balance.unsupported = true
				c.mu.Unlock()
				return nil
			}
			return fmt.Errorf("register Home rebalance: %w", errEnable)
		}
		var response struct {
			OK           bool     `json:"ok"`
			Capabilities []string `json:"capabilities"`
		}
		if errJSON := json.Unmarshal([]byte(raw), &response); errJSON != nil {
			return fmt.Errorf("decode Home rebalance ENABLE response: %w", errJSON)
		}
		if !response.OK {
			return nil
		}
		c.mu.Lock()
		c.balance.registered = true
		for _, capability := range response.Capabilities {
			if capability == clusterLatencyCapability {
				c.balance.latencySupported = true
			}
		}
		c.mu.Unlock()
	}
	if result == "" {
		return nil
	}

	raw, errResult := c.rebalanceCommand(ctx, "", "CLUSTER", "BALANCE", "RESULT", request.ID, result)
	if errResult != nil {
		return fmt.Errorf("report Home rebalance result: %w", errResult)
	}
	var response struct {
		OK bool `json:"ok"`
	}
	if errJSON := json.Unmarshal([]byte(raw), &response); errJSON != nil {
		return fmt.Errorf("decode Home rebalance RESULT response: %w", errJSON)
	}
	if !response.OK {
		return nil
	}
	c.mu.Lock()
	if c.balance.request.ID == request.ID && c.balance.result == result {
		c.balance.result = ""
		c.balance.request = rebalanceInstruction{}
	}
	c.mu.Unlock()
	return nil
}

// executeClusterRebalance claims an exact persisted plan before touching any transport.
// This lifetime keeps its original address; only the supervisor's replacement uses the target.
func (c *Client) executeClusterRebalance(ctx context.Context, payload []byte) error {
	var request rebalanceInstruction
	if errJSON := json.Unmarshal(payload, &request); errJSON != nil {
		return fmt.Errorf("decode Home rebalance instruction: %w", errJSON)
	}
	request.Target.IP = strings.TrimSpace(request.Target.IP)
	if request.ID == "" || request.Term <= 0 || request.Target.IP == "" || request.Target.Port <= 0 || request.Target.Port > 65535 {
		return nil
	}
	c.mu.Lock()
	eligible := c.canExecuteRebalanceLocked() && c.balance.registered && c.balance.result == "" &&
		(request.Target.IP != strings.TrimSpace(c.homeCfg.Host) || request.Target.Port != c.homeCfg.Port)
	c.mu.Unlock()
	if !eligible || ctx.Err() != nil {
		return nil
	}
	raw, errClaim := c.rebalanceCommand(ctx, "", "CLUSTER", "BALANCE", "CLAIM", request.ID, request.Term, request.Target.IP, request.Target.Port)
	if errClaim != nil {
		return errClaim
	}
	var response struct {
		OK bool `json:"ok"`
	}
	if errJSON := json.Unmarshal([]byte(raw), &response); errJSON != nil {
		return fmt.Errorf("decode Home rebalance CLAIM response: %w", errJSON)
	}
	if !response.OK {
		return nil
	}
	c.mu.Lock()
	c.balance.request = request
	c.balance.result = "failed"
	c.mu.Unlock()
	if _, errPing := c.rebalanceCommand(ctx, clusterNodeAddress(request.Target), "PING"); errPing != nil {
		if errReport := c.syncClusterBalance(ctx); errReport != nil {
			return errors.Join(errPing, errReport)
		}
		c.mu.Lock()
		recovered := ctx.Err() == nil && c.canExecuteRebalanceLocked() && c.balance.result == ""
		c.mu.Unlock()
		if !recovered {
			return errPing
		}
		// No switch occurred. An acknowledged failure and a healthy source safely end this plan, not the subscription.
		log.Warnf("Home rebalance preflight failed; failure reported, keeping source: plan=%q target=%q error=%v", request.ID, clusterNodeAddress(request.Target), errPing)
		return nil
	}
	c.mu.Lock()
	if ctx.Err() != nil || !c.canExecuteRebalanceLocked() {
		c.mu.Unlock()
		return c.syncClusterBalance(ctx)
	}
	c.balance.origin = clusterNode{IP: strings.TrimSpace(c.homeCfg.Host), Port: c.homeCfg.Port}
	c.balance.target = request.Target
	c.balance.result = ""
	c.balance.moving = true
	c.markMembershipTakeoverEligible()
	origin := c.balance.origin
	c.mu.Unlock()
	log.Infof("executing Home rebalance plan: plan=%q from=%q to=%q", request.ID, clusterNodeAddress(origin), clusterNodeAddress(request.Target))
	return ErrRebalanceRequested
}

func (c *Client) finishClusterRebalance(ctx context.Context) error {
	c.mu.Lock()
	if c.balance.moving {
		c.balance.moving = false
		c.balance.result = "success"
	}
	c.balance.returning = false
	c.mu.Unlock()
	return c.syncClusterBalance(ctx)
}

func (c *Client) failClusterRebalance() {
	c.mu.Lock()
	defer c.mu.Unlock()
	if !c.balance.moving {
		return
	}
	c.balance.moving = false
	c.balance.returning = true
	c.balance.result = "failed"
	c.switchToNodeLocked(c.balance.origin)
}

// chooseClusterNodeLocked only chooses bootstrap/fault-recovery targets, never active migrations.
func (c *Client) chooseClusterNodeLocked(nodes []clusterNode, excludeCurrent bool, draw float64) (clusterNode, bool) {
	var choices []clusterNode
	for _, node := range nodes {
		if node.IP == "" || node.Port <= 0 || node.Port > 65535 {
			continue
		}
		if excludeCurrent && node.IP == strings.TrimSpace(c.homeCfg.Host) && node.Port == c.homeCfg.Port {
			continue
		}
		if len(choices) == 0 || node.ClientCount < choices[0].ClientCount {
			choices = []clusterNode{node}
		} else if node.ClientCount == choices[0].ClientCount {
			choices = append(choices, node)
		}
	}
	if len(choices) == 0 {
		return clusterNode{}, false
	}
	return choices[int(draw*float64(len(choices)))], true
}

func clusterNodeAddress(node clusterNode) string {
	return net.JoinHostPort(node.IP, strconv.Itoa(node.Port))
}