Files
seaweedfs/sw-block/runtime/masterv2/master.go
T
pingqiuandClaude Opus 4.6 b8c6944e3f feat: V2 MVP milestone — masterv2 + volumev2 + in-process failover
V2 runtime packages:
- sw-block/runtime/masterv2: identity authority (desired state,
  heartbeat handling, promotion arbitration via SelectPromotionCandidate)
- sw-block/runtime/volumev2: per-volume micro-cluster shell (node,
  orchestrator, control session, iSCSI frontend, takeover gate,
  failover session + driver, replica summary reconstruction)
- sw-block/runtime/purev2: RF1 execution shell (engine + store +
  dispatcher + local boundary observations)
- sw-block/runtime/protocolv2: three-channel separation
  (heartbeat/assignment/query + replica summary)

V2 binaries:
- sw-block/cmd/v2singleblock: single-node RF1 block server
- sw-block/cmd/purev2rf1: minimal RF1 runtime binary

Milestone capabilities:
- RF1 write/read/sync with engine-driven mode projection
- masterv2 ↔ volumev2 heartbeat convergence + assignment reissue
- Promotion query with fresh CommittedLSN/WALHeadLSN evidence
- Replica summary for bounded takeover reconstruction
- Primary-loss reconstruction from peer summaries (fail-closed gate)
- In-process failover driver with session observability
- Local boundary observations feed engine (Committed/Durable/Checkpoint)

Design docs:
- v2-two-loop-protocol.md: identity vs data-control separation
- v2-automata-ownership-map.md: event/command ownership split
- v2-loop1-surface-draft.md: heartbeat/query/assignment field spec
- v2-volumev2-single-node-mvp.md: target layering
- v2-kernel-closure-review.md: per-volume micro-cluster principle
- v2-pure-runtime-rf1-bootstrap.md, v2-capability-map.md,
  v2-proof-and-retest-pyramid.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:08:02 -07:00

265 lines
7.3 KiB
Go

package masterv2
import (
"fmt"
"slices"
"sync"
"time"
"github.com/seaweedfs/seaweedfs/sw-block/runtime/protocolv2"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol"
)
// Config defines the minimal masterv2 control-loop settings.
type Config struct {
LeaseTTL time.Duration
}
// VolumeSpec is one master-owned desired volume intent.
type VolumeSpec struct {
Name string
Path string
PrimaryNodeID string
CreateOptions blockvol.CreateOptions
}
// Assignment is the minimal masterv2 -> volumev2 control message.
type Assignment = protocolv2.Assignment
// VolumeHeartbeat is the minimal volumev2 -> masterv2 periodic identity observation.
type VolumeHeartbeat = protocolv2.VolumeHeartbeat
// NodeHeartbeat is the minimal per-node heartbeat used by the POC control loop.
type NodeHeartbeat = protocolv2.NodeHeartbeat
// PromotionQueryRequest asks one candidate for fresh failover evidence.
type PromotionQueryRequest = protocolv2.PromotionQueryRequest
// PromotionQueryResponse returns fresh candidate evidence at query time.
type PromotionQueryResponse = protocolv2.PromotionQueryResponse
// VolumeView is the master-side observed state for one desired volume.
type VolumeView struct {
Name string
Path string
PrimaryNodeID string
DesiredEpoch uint64
ObservedEpoch uint64
ObservedRole string
Mode string
ModeReason string
CommittedLSN uint64
RoleApplied bool
ReplicaReady bool
LastHeartbeatAt time.Time
}
type desiredVolume struct {
spec VolumeSpec
epoch uint64
}
// Master is a small in-process V2 control plane POC.
// It owns desired state and emits assignments on heartbeats.
type Master struct {
mu sync.RWMutex
cfg Config
desired map[string]desiredVolume
views map[string]VolumeView
}
// New creates the minimal masterv2 POC.
func New(cfg Config) *Master {
if cfg.LeaseTTL <= 0 {
cfg.LeaseTTL = 30 * time.Second
}
return &Master{
cfg: cfg,
desired: make(map[string]desiredVolume),
views: make(map[string]VolumeView),
}
}
// DeclarePrimary records one desired RF1 primary placement.
func (m *Master) DeclarePrimary(spec VolumeSpec) error {
if spec.Name == "" {
return fmt.Errorf("masterv2: volume name is required")
}
if spec.Path == "" {
return fmt.Errorf("masterv2: volume path is required")
}
if spec.PrimaryNodeID == "" {
return fmt.Errorf("masterv2: primary node id is required")
}
m.mu.Lock()
defer m.mu.Unlock()
entry, ok := m.desired[spec.Name]
if !ok {
entry = desiredVolume{epoch: 1}
} else if entry.spec.Path != spec.Path || entry.spec.PrimaryNodeID != spec.PrimaryNodeID || entry.spec.CreateOptions != spec.CreateOptions {
entry.epoch++
}
entry.spec = spec
m.desired[spec.Name] = entry
view := m.views[spec.Name]
view.Name = spec.Name
view.Path = spec.Path
view.PrimaryNodeID = spec.PrimaryNodeID
view.DesiredEpoch = entry.epoch
m.views[spec.Name] = view
return nil
}
// HandleHeartbeat consumes one node heartbeat and returns any assignments the node should apply.
func (m *Master) HandleHeartbeat(hb NodeHeartbeat) ([]Assignment, error) {
if hb.NodeID == "" {
return nil, fmt.Errorf("masterv2: heartbeat node id is required")
}
if hb.ReportedAt.IsZero() {
hb.ReportedAt = time.Now()
}
m.mu.Lock()
defer m.mu.Unlock()
byName := make(map[string]VolumeHeartbeat, len(hb.Volumes))
for _, vol := range hb.Volumes {
byName[vol.Name] = vol
view := m.views[vol.Name]
view.Name = vol.Name
view.Path = vol.Path
view.ObservedEpoch = vol.Epoch
view.ObservedRole = vol.Role
view.Mode = vol.Mode
view.ModeReason = vol.ModeReason
view.CommittedLSN = vol.CommittedLSN
view.RoleApplied = vol.RoleApplied
view.ReplicaReady = vol.ReplicaReady
view.LastHeartbeatAt = hb.ReportedAt
if desired, ok := m.desired[vol.Name]; ok {
view.PrimaryNodeID = desired.spec.PrimaryNodeID
view.DesiredEpoch = desired.epoch
}
m.views[vol.Name] = view
}
var assignments []Assignment
names := make([]string, 0, len(m.desired))
for name := range m.desired {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
desired := m.desired[name]
if desired.spec.PrimaryNodeID != hb.NodeID {
continue
}
reported, ok := byName[name]
if ok && reported.Path == desired.spec.Path && reported.Epoch == desired.epoch && reported.Role == "primary" && reported.RoleApplied {
continue
}
assignments = append(assignments, m.primaryAssignment(desired))
}
return assignments, nil
}
// Volume returns the latest master-side view for one volume.
func (m *Master) Volume(name string) (VolumeView, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
view, ok := m.views[name]
return view, ok
}
// SelectPromotionCandidate chooses the best eligible candidate from fresh
// promotion-query responses. Selection is durability-first: highest
// CommittedLSN wins, then WALHeadLSN as a weaker tiebreaker.
func (m *Master) SelectPromotionCandidate(responses []PromotionQueryResponse) (PromotionQueryResponse, error) {
candidates := make([]PromotionQueryResponse, 0, len(responses))
for _, resp := range responses {
if resp.Eligible {
candidates = append(candidates, resp)
}
}
if len(candidates) == 0 {
return PromotionQueryResponse{}, fmt.Errorf("masterv2: no eligible promotion candidates")
}
slices.SortStableFunc(candidates, func(a, b PromotionQueryResponse) int {
if a.CommittedLSN != b.CommittedLSN {
if a.CommittedLSN > b.CommittedLSN {
return -1
}
return 1
}
if a.WALHeadLSN != b.WALHeadLSN {
if a.WALHeadLSN > b.WALHeadLSN {
return -1
}
return 1
}
switch {
case a.NodeID < b.NodeID:
return -1
case a.NodeID > b.NodeID:
return 1
default:
return 0
}
})
return candidates[0], nil
}
// AuthorizePromotion selects the best candidate from fresh promotion evidence,
// advances desired ownership when needed, and returns the assignment the chosen
// node should apply. This is authorization only; takeover reconstruction and
// activation remain the new primary's responsibility.
func (m *Master) AuthorizePromotion(volumeName string, responses []PromotionQueryResponse) (Assignment, error) {
if volumeName == "" {
return Assignment{}, fmt.Errorf("masterv2: volume name is required")
}
selected, err := m.SelectPromotionCandidate(responses)
if err != nil {
return Assignment{}, err
}
if selected.VolumeName != "" && selected.VolumeName != volumeName {
return Assignment{}, fmt.Errorf("masterv2: promotion candidate volume %q does not match %q", selected.VolumeName, volumeName)
}
m.mu.Lock()
defer m.mu.Unlock()
desired, ok := m.desired[volumeName]
if !ok {
return Assignment{}, fmt.Errorf("masterv2: unknown volume %q", volumeName)
}
if desired.spec.PrimaryNodeID != selected.NodeID {
desired.spec.PrimaryNodeID = selected.NodeID
desired.epoch++
m.desired[volumeName] = desired
}
view := m.views[volumeName]
view.Name = desired.spec.Name
view.Path = desired.spec.Path
view.PrimaryNodeID = desired.spec.PrimaryNodeID
view.DesiredEpoch = desired.epoch
m.views[volumeName] = view
return m.primaryAssignment(desired), nil
}
func (m *Master) primaryAssignment(desired desiredVolume) Assignment {
return Assignment{
Name: desired.spec.Name,
Path: desired.spec.Path,
NodeID: desired.spec.PrimaryNodeID,
Epoch: desired.epoch,
LeaseTTL: m.cfg.LeaseTTL,
CreateOptions: desired.spec.CreateOptions,
Role: "primary",
}
}