package weed_server import ( "context" "fmt" "hash/fnv" "strings" "sync" "time" "github.com/seaweedfs/seaweedfs/weed/glog" "github.com/seaweedfs/seaweedfs/weed/pb/master_pb" "github.com/seaweedfs/seaweedfs/weed/storage/blockvol" ) // pendingRebuild records a volume that needs rebuild when a dead VS reconnects. type pendingRebuild struct { VolumeName string OldPath string // path on dead server NewPrimary string // promoted replica server Epoch uint64 ReplicaDataAddr string // CP13-8: saved from before death for catch-up-first recovery ReplicaCtrlAddr string // CP13-8: saved from before death for catch-up-first recovery } // deferredPromotion tracks a deferred promotion timer with its volume context. type deferredPromotion struct { Timer *time.Timer VolumeName string CurrentPrimary string // current (stale) primary that will be replaced AffectedServer string // dead server addr } // blockFailoverState holds failover and rebuild state on the master. type blockFailoverState struct { mu sync.Mutex pendingRebuilds map[string][]pendingRebuild // dead server addr -> pending rebuilds deferredTimers map[string][]deferredPromotion // dead server addr -> pending deferred promotions } // FailoverVolumeState is one volume's failover diagnosis entry. type FailoverVolumeState struct { VolumeName string CurrentPrimary string AffectedServer string // dead server that triggered the failover/rebuild DeferredPromotion bool // true if a deferred promotion timer is pending PendingRebuild bool // true if a rebuild is pending for this volume Reason string // "lease_wait", "rebuild_pending", or "" } // FailoverDiagnostic is a bounded read-only snapshot of failover state // for operator-visible diagnosis. P3 diagnosability surface. // // Volume-oriented: each entry describes one volume's failover state. // Aggregate counts are derived from the volume list. type FailoverDiagnostic struct { V2PromotionEnabled bool // T3: whether durability-first V2 promotion is active V2PromotionReady bool // T3: whether V2 evidence querier is wired (false = fail-closed placeholder) Volumes []FailoverVolumeState PendingRebuildCount map[string]int // dead server → count of pending rebuilds DeferredPromotionCount map[string]int // dead server → count of deferred promotion timers } func (fs *blockFailoverState) DiagnosticSnapshot() FailoverDiagnostic { fs.mu.Lock() defer fs.mu.Unlock() diag := FailoverDiagnostic{ PendingRebuildCount: make(map[string]int), DeferredPromotionCount: make(map[string]int), } for server, rebuilds := range fs.pendingRebuilds { diag.PendingRebuildCount[server] = len(rebuilds) for _, rb := range rebuilds { diag.Volumes = append(diag.Volumes, FailoverVolumeState{ VolumeName: rb.VolumeName, CurrentPrimary: rb.NewPrimary, AffectedServer: server, PendingRebuild: true, Reason: "rebuild_pending", }) } } for server, promos := range fs.deferredTimers { diag.DeferredPromotionCount[server] = len(promos) for _, dp := range promos { diag.Volumes = append(diag.Volumes, FailoverVolumeState{ VolumeName: dp.VolumeName, CurrentPrimary: dp.CurrentPrimary, AffectedServer: dp.AffectedServer, DeferredPromotion: true, Reason: "lease_wait", }) } } return diag } // FailoverDiagnosticSnapshot returns a FailoverDiagnostic enriched with // V2 promotion rollout state so operators can observe the active mode. func (ms *MasterServer) FailoverDiagnosticSnapshot() FailoverDiagnostic { diag := ms.blockFailover.DiagnosticSnapshot() diag.V2PromotionEnabled = ms.blockV2Promotion diag.V2PromotionReady = ms.blockV2Promotion && ms.blockVSQueryEvidence != nil return diag } // PublicationDiagnostic is a bounded read-only snapshot comparing the // operator-visible publication (LookupBlockVolume response) against the // registry authority for one volume. P3 diagnosability surface for S2. type PublicationDiagnostic struct { VolumeName string LookupVolumeServer string // what LookupBlockVolume returns LookupIscsiAddr string AuthorityVolumeServer string // registry entry (source of truth) AuthorityIscsiAddr string Coherent bool // true if lookup == authority Reason string // "" if coherent, otherwise why they diverge } // PublicationDiagnosticFor returns a PublicationDiagnostic for the named volume. // It performs two independent reads: // - Lookup side: calls LookupBlockVolume (the actual gRPC method) // - Authority side: reads the registry directly // // Then compares the two. If they diverge, Coherent=false with a Reason. func (ms *MasterServer) PublicationDiagnosticFor(volumeName string) (PublicationDiagnostic, bool) { if ms.blockRegistry == nil { return PublicationDiagnostic{}, false } // Read 1: the operator-visible publication surface. lookupResp, err := ms.LookupBlockVolume(context.Background(), &master_pb.LookupBlockVolumeRequest{Name: volumeName}) if err != nil { return PublicationDiagnostic{}, false } // Read 2: the registry authority (separate read). entry, ok := ms.blockRegistry.Lookup(volumeName) if !ok { return PublicationDiagnostic{}, false } diag := PublicationDiagnostic{ VolumeName: volumeName, LookupVolumeServer: lookupResp.VolumeServer, LookupIscsiAddr: lookupResp.IscsiAddr, AuthorityVolumeServer: entry.VolumeServer, AuthorityIscsiAddr: entry.ISCSIAddr, } // Compare the two reads. vsMatch := diag.LookupVolumeServer == diag.AuthorityVolumeServer iscsiMatch := diag.LookupIscsiAddr == diag.AuthorityIscsiAddr diag.Coherent = vsMatch && iscsiMatch if !diag.Coherent { if !vsMatch { diag.Reason = "volume_server_mismatch" } else { diag.Reason = "iscsi_addr_mismatch" } } return diag, true } func newBlockFailoverState() *blockFailoverState { return &blockFailoverState{ pendingRebuilds: make(map[string][]pendingRebuild), deferredTimers: make(map[string][]deferredPromotion), } } // failoverBlockVolumes is called when a volume server disconnects. // It checks each block volume on that server and: // - If dead server is primary: promote best replica (if lease expired). // - If dead server hosts a replica: remove from replica list, record pending rebuild. func (ms *MasterServer) failoverBlockVolumes(deadServer string) { if ms.blockRegistry == nil { return } ms.blockRegistry.FailoversTotal.Add(1) entries := ms.blockRegistry.ListByServer(deadServer) now := time.Now() for _, entry := range entries { // Case 1: Dead server is the primary. if entry.VolumeServer == deadServer && blockvol.RoleFromWire(entry.Role) == blockvol.RolePrimary { if !entry.HasReplica() { glog.Warningf("failover: %q has no replica, cannot promote", entry.Name) continue } // F2: Wait for lease expiry before promoting. leaseExpiry := entry.LastLeaseGrant.Add(entry.LeaseTTL) if now.Before(leaseExpiry) { delay := leaseExpiry.Sub(now) glog.V(0).Infof("failover: %q lease expires in %v, deferring promotion", entry.Name, delay) volumeName := entry.Name capturedEpoch := entry.Epoch // T3: capture epoch for stale-timer validation capturedDeadServer := deadServer // capture for closure timer := time.AfterFunc(delay, func() { // Clean up the deferred entry regardless of outcome. ms.removeFiredDeferredPromotion(capturedDeadServer, volumeName) // T3: Re-validate before acting — prevent stale timer on recreated/changed volume. current, ok := ms.blockRegistry.Lookup(volumeName) if !ok { glog.V(0).Infof("failover: deferred promotion for %q skipped (volume deleted)", volumeName) return } if current.Epoch != capturedEpoch { glog.V(0).Infof("failover: deferred promotion for %q skipped (epoch changed %d -> %d)", volumeName, capturedEpoch, current.Epoch) return } ms.promoteReplica(volumeName) }) ms.blockFailover.mu.Lock() ms.blockFailover.deferredTimers[deadServer] = append( ms.blockFailover.deferredTimers[deadServer], deferredPromotion{ Timer: timer, VolumeName: volumeName, CurrentPrimary: entry.VolumeServer, AffectedServer: deadServer, }) ms.blockFailover.mu.Unlock() continue } // Lease already expired — promote immediately. ms.promoteReplica(entry.Name) continue } // Case 2: Dead server hosts a replica (not the primary). if entry.VolumeServer != deadServer { ri := entry.ReplicaByServer(deadServer) if ri != nil { replicaPath := ri.Path replicaDataAddr := ri.DataAddr // CP13-8: save before removal replicaCtrlAddr := ri.CtrlAddr // Remove dead replica from registry. if err := ms.blockRegistry.RemoveReplica(entry.Name, deadServer); err != nil { glog.Warningf("failover: RemoveReplica %q on %s: %v", entry.Name, deadServer, err) continue } // Record pending rebuild for when dead server reconnects. ms.recordPendingRebuild(deadServer, pendingRebuild{ VolumeName: entry.Name, OldPath: replicaPath, NewPrimary: entry.VolumeServer, Epoch: entry.Epoch, ReplicaDataAddr: replicaDataAddr, ReplicaCtrlAddr: replicaCtrlAddr, }) glog.V(0).Infof("failover: removed dead replica %s for %q, pending rebuild", deadServer, entry.Name) } } } } // promoteReplica promotes the best replica to primary for the named volume, // enqueues an assignment for the new primary, and records a pending rebuild. // When blockV2Promotion is true, uses fresh on-demand evidence and // durability-first selection. When false, uses legacy health-score-first. func (ms *MasterServer) promoteReplica(volumeName string) { entry, ok := ms.blockRegistry.Lookup(volumeName) if !ok { return } if !entry.HasReplica() { return } if ms.blockV2Promotion { if ms.blockVSQueryEvidence == nil { glog.Warningf("failover: V2 promotion enabled but evidence querier is nil for %q — fail closed (not falling back to V1)", volumeName) return } ms.promoteReplicaV2(volumeName, entry) return } ms.promoteReplicaV1(volumeName, entry) } // promoteReplicaV1 is the legacy promotion path: health-score-first, // heartbeat-stale data, no fresh evidence query. func (ms *MasterServer) promoteReplicaV1(volumeName string, entry BlockVolumeEntry) { oldPrimary := entry.VolumeServer oldPath := entry.Path oldPrimaryISCSIAddr := entry.ISCSIAddr // CP8-2: Use PromoteBestReplica (picks by health score, tie-break by WALHeadLSN). newEpoch, err := ms.blockRegistry.PromoteBestReplica(volumeName) if err != nil { glog.Warningf("failover: PromoteBestReplica %q: %v", volumeName, err) return } ms.finalizePromotion(volumeName, oldPrimary, oldPath, oldPrimaryISCSIAddr, newEpoch) } // promoteReplicaV2 queries each candidate for fresh evidence, selects by // CommittedLSN (durability-first), and fail-closes when no eligible candidate // exists. Does NOT silently fall back to V1 — if evidence fails, promotion // does not proceed. func (ms *MasterServer) promoteReplicaV2(volumeName string, entry BlockVolumeEntry) { oldPrimary := entry.VolumeServer oldPath := entry.Path oldPrimaryISCSIAddr := entry.ISCSIAddr // Collect candidates from registry membership. var candidates []promotionCandidate for _, ri := range entry.Replicas { candidates = append(candidates, promotionCandidate{ server: ri.Server, path: ri.Path, expectedEpoch: entry.Epoch, }) } if len(candidates) == 0 { glog.Warningf("failover V2: %q has no replica candidates", volumeName) return } // Query each candidate for fresh evidence. evidence, errs := queryAllCandidateEvidence(ms.blockVSQueryEvidence, candidates) for _, err := range errs { glog.Warningf("failover V2: %s", err) } // Fail-closed on partial evidence: if any candidate query failed, an // unreachable candidate may be the most durable. Promoting from // incomplete evidence violates durability-first ordering. if len(errs) > 0 { glog.Warningf("failover V2: %q: fail-closed — %d/%d candidate queries failed, cannot guarantee durability ordering", volumeName, len(errs), len(candidates)) return } // Durability-first selection. Fail-closed if no eligible candidate. best, err := selectDurabilityFirstCandidate(evidence) if err != nil { glog.Warningf("failover V2: %q: %v (queried %d, errors %d)", volumeName, err, len(candidates), len(errs)) return } // Apply promotion in registry using the selected server. newEpoch, err := ms.blockRegistry.PromoteReplicaByServer(volumeName, best.Server) if err != nil { glog.Warningf("failover V2: PromoteReplicaByServer %q %s: %v", volumeName, best.Server, err) return } glog.V(0).Infof("failover V2: %q selected %s (CommittedLSN=%d WALHeadLSN=%d HealthScore=%.2f)", volumeName, best.Server, best.CommittedLSN, best.WALHeadLSN, best.HealthScore) ms.finalizePromotion(volumeName, oldPrimary, oldPath, oldPrimaryISCSIAddr, newEpoch) } // finalizePromotion performs post-registry promotion steps: // enqueue assignment for new primary, record pending rebuild for old primary, bump metrics. // Called by both promoteReplica (auto) and blockVolumePromoteHandler (manual). func (ms *MasterServer) finalizePromotion(volumeName, oldPrimary, oldPath, oldPrimaryISCSIAddr string, newEpoch uint64) { // Re-read entry after promotion. entry, ok := ms.blockRegistry.Lookup(volumeName) if !ok { return } // Build assignment for new primary. Include ReplicaAddrs for remaining replicas. leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second) assignment := blockvol.BlockVolumeAssignment{ Path: entry.Path, Epoch: newEpoch, Role: blockvol.RoleToWire(blockvol.RolePrimary), LeaseTtlMs: leaseTTLMs, } for _, ri := range entry.Replicas { assignment.ReplicaAddrs = append(assignment.ReplicaAddrs, blockvol.ReplicaAddr{ DataAddr: ri.DataAddr, CtrlAddr: ri.CtrlAddr, ServerID: ri.Server, // V2: stable identity }) } // Backward compat: also set scalar fields if exactly 1 replica. if len(entry.Replicas) == 1 { assignment.ReplicaDataAddr = entry.Replicas[0].DataAddr assignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr assignment.ReplicaServerID = entry.Replicas[0].Server // V2: stable identity } ms.blockAssignmentQueue.Enqueue(entry.VolumeServer, assignment) // Record pending rebuild for when dead server reconnects. replicaDataAddr, replicaCtrlAddr := deterministicReplicaAddrsForReplicaPath(oldPath, oldPrimary, oldPrimaryISCSIAddr) ms.recordPendingRebuild(oldPrimary, pendingRebuild{ VolumeName: volumeName, OldPath: oldPath, NewPrimary: entry.VolumeServer, Epoch: newEpoch, ReplicaDataAddr: replicaDataAddr, ReplicaCtrlAddr: replicaCtrlAddr, }) ms.blockRegistry.PromotionsTotal.Add(1) glog.V(0).Infof("failover: promoted replica for %q: new primary=%s epoch=%d (old primary=%s)", volumeName, entry.VolumeServer, newEpoch, oldPrimary) } // deterministicReplicaAddrsForReplicaPath mirrors the volume-server-side // ReplicationPorts derivation so the master can preserve the reconnect catch-up // path even before the restarted replica emits a second heartbeat with explicit // receiver addresses. func deterministicReplicaAddrsForReplicaPath(path, serverAddr, iscsiAddr string) (dataAddr, ctrlAddr string) { host := serverAddr if idx := strings.LastIndex(host, ":"); idx >= 0 { host = host[:idx] } if host == "" { return "", "" } basePort := 3260 if idx := strings.LastIndex(iscsiAddr, ":"); idx >= 0 { var p int if _, err := fmt.Sscanf(iscsiAddr[idx+1:], "%d", &p); err == nil && p > 0 { basePort = p } } h := fnv.New32a() _, _ = h.Write([]byte(path)) offset := int(h.Sum32()%500) * 3 dataPort := basePort + 1000 + offset ctrlPort := dataPort + 1 return fmt.Sprintf("%s:%d", host, dataPort), fmt.Sprintf("%s:%d", host, ctrlPort) } // recordPendingRebuild stores a pending rebuild for a dead server. func (ms *MasterServer) recordPendingRebuild(deadServer string, rb pendingRebuild) { if ms.blockFailover == nil { return } ms.blockFailover.mu.Lock() defer ms.blockFailover.mu.Unlock() ms.blockFailover.pendingRebuilds[deadServer] = append(ms.blockFailover.pendingRebuilds[deadServer], rb) } // drainPendingRebuilds returns and clears pending rebuilds for a server. func (ms *MasterServer) drainPendingRebuilds(server string) []pendingRebuild { if ms.blockFailover == nil { return nil } ms.blockFailover.mu.Lock() defer ms.blockFailover.mu.Unlock() rebuilds := ms.blockFailover.pendingRebuilds[server] delete(ms.blockFailover.pendingRebuilds, server) return rebuilds } // cancelDeferredTimers stops all deferred promotion timers for a server (R2-F2). // Called when a VS reconnects before its lease-deferred timers fire, preventing split-brain. func (ms *MasterServer) cancelDeferredTimers(server string) { if ms.blockFailover == nil { return } ms.blockFailover.mu.Lock() promos := ms.blockFailover.deferredTimers[server] delete(ms.blockFailover.deferredTimers, server) ms.blockFailover.mu.Unlock() for _, dp := range promos { dp.Timer.Stop() } if len(promos) > 0 { glog.V(0).Infof("failover: cancelled %d deferred promotion timers for reconnected %s", len(promos), server) } } // removeFiredDeferredPromotion removes a single deferred promotion entry after // its timer has fired (whether it promoted or was skipped). This keeps // FailoverDiagnostic accurate: once the timer fires, the volume is no longer // in lease-wait state. func (ms *MasterServer) removeFiredDeferredPromotion(server, volumeName string) { if ms.blockFailover == nil { return } ms.blockFailover.mu.Lock() defer ms.blockFailover.mu.Unlock() promos := ms.blockFailover.deferredTimers[server] for i, dp := range promos { if dp.VolumeName == volumeName { ms.blockFailover.deferredTimers[server] = append(promos[:i], promos[i+1:]...) if len(ms.blockFailover.deferredTimers[server]) == 0 { delete(ms.blockFailover.deferredTimers, server) } return } } } // recoverBlockVolumes is called when a previously dead VS reconnects. // It cancels any deferred promotion timers (R2-F2), drains pending rebuilds, // enqueues rebuild assignments, and checks for orphaned primaries (T2/B-06). func (ms *MasterServer) recoverBlockVolumes(reconnectedServer string) { // R2-F2: Cancel deferred promotion timers for this server to prevent split-brain. ms.cancelDeferredTimers(reconnectedServer) // T2 (B-06): Check for orphaned primaries — volumes where the reconnecting // server is a replica but the primary is dead/disconnected. ms.reevaluateOrphanedPrimaries(reconnectedServer) rebuilds := ms.drainPendingRebuilds(reconnectedServer) if len(rebuilds) == 0 { return } for _, rb := range rebuilds { entry, ok := ms.blockRegistry.Lookup(rb.VolumeName) if !ok { glog.V(0).Infof("rebuild: volume %q deleted while %s was down, skipping", rb.VolumeName, reconnectedServer) continue } // CP13-8: Use replica addresses saved before death for catch-up-first recovery. // These are deterministic (derived from volume path hash in ReplicationPorts), // so they should be the same after VS restart. If the VS somehow gets different // ports (e.g., port conflict), the catch-up attempt will fail at the TCP level // and fall through to the shipper's NeedsRebuild → master rebuild path. // This is an optimization, not a source of truth — the master remains the // authority for topology/assignment changes. dataAddr := rb.ReplicaDataAddr ctrlAddr := rb.ReplicaCtrlAddr // Update registry: reconnected server becomes a replica. ms.blockRegistry.AddReplica(rb.VolumeName, ReplicaInfo{ Server: reconnectedServer, Path: rb.OldPath, DataAddr: dataAddr, CtrlAddr: ctrlAddr, }) // CP13-8: Try catch-up first (Replica assignment), fall back to rebuild. // If the replica can catch up from the primary's retained WAL, this is // much faster than a full rebuild. The shipper's reconnect handshake // (CP13-5) determines whether catch-up or rebuild is actually needed. // If catch-up fails, the shipper marks NeedsRebuild, and the master // sends a Rebuilding assignment on the next heartbeat cycle. if dataAddr != "" { leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second) // Send Replica assignment to the reconnected server. ms.blockAssignmentQueue.Enqueue(reconnectedServer, blockvol.BlockVolumeAssignment{ Path: rb.OldPath, Epoch: entry.Epoch, Role: blockvol.RoleToWire(blockvol.RoleReplica), LeaseTtlMs: leaseTTLMs, ReplicaDataAddr: dataAddr, ReplicaCtrlAddr: ctrlAddr, }) // Also re-send Primary assignment so the primary gets fresh replica addresses. primaryAssignment := blockvol.BlockVolumeAssignment{ Path: entry.Path, Epoch: entry.Epoch, Role: blockvol.RoleToWire(blockvol.RolePrimary), LeaseTtlMs: leaseTTLMs, } // Include all replica addresses with stable identity. for _, ri := range entry.Replicas { primaryAssignment.ReplicaAddrs = append(primaryAssignment.ReplicaAddrs, blockvol.ReplicaAddr{ DataAddr: ri.DataAddr, CtrlAddr: ri.CtrlAddr, ServerID: ri.Server, // V2: stable identity }) } if len(entry.Replicas) == 1 { primaryAssignment.ReplicaDataAddr = entry.Replicas[0].DataAddr primaryAssignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr primaryAssignment.ReplicaServerID = entry.Replicas[0].Server // V2 } ms.blockAssignmentQueue.Enqueue(entry.VolumeServer, primaryAssignment) glog.V(0).Infof("recover: enqueued catch-up (Replica) for %q on %s (epoch=%d, data=%s) + Primary refresh on %s", rb.VolumeName, reconnectedServer, entry.Epoch, dataAddr, entry.VolumeServer) continue } // Fallback: no known addresses — use rebuild path. rebuildAddr := entry.RebuildListenAddr if rebuildAddr == "" { glog.Warningf("rebuild: %q RebuildListenAddr is empty (new primary %s may not have heartbeated yet), "+ "queuing rebuild anyway — VS should retry on empty addr", rb.VolumeName, entry.VolumeServer) } ms.blockAssignmentQueue.Enqueue(reconnectedServer, blockvol.BlockVolumeAssignment{ Path: rb.OldPath, Epoch: entry.Epoch, Role: blockvol.RoleToWire(blockvol.RoleRebuilding), RebuildAddr: rebuildAddr, }) ms.blockRegistry.RebuildsTotal.Add(1) glog.V(0).Infof("rebuild: enqueued rebuild for %q on %s (epoch=%d, rebuildAddr=%s)", rb.VolumeName, reconnectedServer, entry.Epoch, rebuildAddr) } } // reevaluateOrphanedPrimaries checks if the given server is a replica for any // volumes whose primary is dead (not block-capable). If so, promotes the best // available replica — but only after the old primary's lease has expired, to // refreshPrimaryForAddrChange sends a fresh Primary assignment when a replica's // receiver address changed (e.g., restart with port conflict). This ensures the // primary's shipper gets the new address without waiting for the next heartbeat cycle. func (ms *MasterServer) refreshPrimaryForAddrChange(ac ReplicaAddrChange) { entry, ok := ms.blockRegistry.Lookup(ac.VolumeName) if !ok { return } leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second) assignment := blockvol.BlockVolumeAssignment{ Path: entry.Path, Epoch: entry.Epoch, Role: blockvol.RoleToWire(blockvol.RolePrimary), LeaseTtlMs: leaseTTLMs, } for _, ri := range entry.Replicas { assignment.ReplicaAddrs = append(assignment.ReplicaAddrs, blockvol.ReplicaAddr{ DataAddr: ri.DataAddr, CtrlAddr: ri.CtrlAddr, ServerID: ri.Server, // V2: stable identity }) } if len(entry.Replicas) == 1 { assignment.ReplicaDataAddr = entry.Replicas[0].DataAddr assignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr assignment.ReplicaServerID = entry.Replicas[0].Server // V2 } // Use current registry primary (not stale ac.PrimaryServer) in case // failover happened between address-change detection and this refresh. currentPrimary := entry.VolumeServer ms.blockAssignmentQueue.Enqueue(currentPrimary, assignment) glog.V(0).Infof("recover: replica addr changed for %q (data: %s→%s, ctrl: %s→%s), refreshed Primary on %s", ac.VolumeName, ac.OldDataAddr, ac.NewDataAddr, ac.OldCtrlAddr, ac.NewCtrlAddr, currentPrimary) } // enqueuePrimaryRefresh sends a fresh Primary assignment with replica addresses. // CP13-8A: called when a replica re-registers after promote so the new primary // gets shipper configuration for the re-registered replica. func (ms *MasterServer) enqueuePrimaryRefresh(entry BlockVolumeEntry) { leaseTTLMs := blockvol.LeaseTTLToWire(30 * time.Second) assignment := blockvol.BlockVolumeAssignment{ Path: entry.Path, Epoch: entry.Epoch, Role: blockvol.RoleToWire(blockvol.RolePrimary), LeaseTtlMs: leaseTTLMs, } for _, ri := range entry.Replicas { assignment.ReplicaAddrs = append(assignment.ReplicaAddrs, blockvol.ReplicaAddr{ DataAddr: ri.DataAddr, CtrlAddr: ri.CtrlAddr, ServerID: ri.Server, }) } if len(entry.Replicas) == 1 { assignment.ReplicaDataAddr = entry.Replicas[0].DataAddr assignment.ReplicaCtrlAddr = entry.Replicas[0].CtrlAddr assignment.ReplicaServerID = entry.Replicas[0].Server } ms.blockAssignmentQueue.Enqueue(entry.VolumeServer, assignment) glog.V(0).Infof("CP13-8A: enqueued Primary refresh for %q on %s with %d replica(s)", entry.Name, entry.VolumeServer, len(entry.Replicas)) } // maintain the same split-brain protection as failoverBlockVolumes(). // This fixes B-06 (orphaned primary after replica re-register) // and partially B-08 (fast reconnect skips failover window). func (ms *MasterServer) reevaluateOrphanedPrimaries(server string) { if ms.blockRegistry == nil { return } orphaned := ms.blockRegistry.VolumesWithDeadPrimary(server) now := time.Now() for _, volumeName := range orphaned { entry, ok := ms.blockRegistry.Lookup(volumeName) if !ok { continue } // Respect lease expiry — same gate as failoverBlockVolumes(). leaseExpiry := entry.LastLeaseGrant.Add(entry.LeaseTTL) if now.Before(leaseExpiry) { delay := leaseExpiry.Sub(now) glog.V(0).Infof("failover: orphaned primary for %q (replica %s alive, primary dead) "+ "but lease expires in %v, deferring promotion", volumeName, server, delay) capturedEpoch := entry.Epoch deadPrimary := entry.VolumeServer timer := time.AfterFunc(delay, func() { // Clean up the deferred entry regardless of outcome. ms.removeFiredDeferredPromotion(deadPrimary, volumeName) current, ok := ms.blockRegistry.Lookup(volumeName) if !ok { return } if current.Epoch != capturedEpoch { glog.V(0).Infof("failover: deferred orphan promotion for %q skipped (epoch changed %d -> %d)", volumeName, capturedEpoch, current.Epoch) return } ms.promoteReplica(volumeName) }) ms.blockFailover.mu.Lock() ms.blockFailover.deferredTimers[deadPrimary] = append( ms.blockFailover.deferredTimers[deadPrimary], deferredPromotion{ Timer: timer, VolumeName: volumeName, CurrentPrimary: deadPrimary, AffectedServer: deadPrimary, }) ms.blockFailover.mu.Unlock() continue } glog.V(0).Infof("failover: orphaned primary detected for %q (replica %s alive, primary dead, lease expired), promoting", volumeName, server) ms.promoteReplica(volumeName) } }