Files
seaweedfs/weed/storage/blockvol/promotion.go
T
Ping QiuandClaude Opus 4.6 9acd187587 feat: Phase 8 complete -- CP8-5 stability gate, lease grant fix, Docker e2e, 13 chaos scenarios
Phase 8 closes with all 6 checkpoints done (CP8-1 through CP8-5 + CP8-3-1):
- CP8-5: 12/12 enterprise QA scenarios PASS on real hardware (m01/M02)
- Master-authoritative lease grants (BUG-CP85-11): master renews primary
  write leases on every heartbeat response, replacing retain-until-confirmed
  assignment queue semantics that caused 30s lease expiry
- Post-rebuild WAL shipping gap fix (BUG-CP85-1): syncLSNAfterRebuild
  advances replica nextLSN so WAL entries are accepted after rebuild
- Block heartbeat startup race fix (BUG-CP85-10): dynamic blockService
  check on each tick instead of one-shot at loop start
- 8 new tests: 4 engine lease grant + 4 registry lease grant
- 13 new YAML scenarios: chaos (kill-loop, partition, disk-full),
  database integrity (sqlite crash, ext4 fsck), perf baseline,
  metrics verify, snapshot stress, expand-failover, session storm,
  role flap, 24h soak
- 12 new testrunner actions (database, fsck, grep_log, write_loop_bg,
  stop_bg, assert_metric_gt/eq/lt) + phase repeat support
- Docker compose setup + getting-started guide for block storage users
- 960+ cumulative unit tests, 24 YAML scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 21:30:14 -08:00

138 lines
4.1 KiB
Go

package blockvol
import (
"errors"
"fmt"
"time"
)
var (
ErrInvalidAssignment = errors.New("blockvol: invalid assignment transition")
ErrDrainTimeout = errors.New("blockvol: drain timeout waiting for in-flight ops")
ErrEpochRegression = errors.New("blockvol: epoch regression")
)
const defaultDrainTimeout = 10 * time.Second
// HandleAssignment processes a role/epoch/lease assignment from master.
// Serialized with vol.assignMu to prevent concurrent assignment races.
func HandleAssignment(vol *BlockVol, newEpoch uint64, newRole Role, leaseTTL time.Duration) error {
vol.assignMu.Lock()
defer vol.assignMu.Unlock()
current := vol.Role()
// Same role -> refresh lease and update epoch if bumped.
if current == newRole {
if newEpoch < vol.Epoch() {
return fmt.Errorf("%w: new %d < current %d", ErrEpochRegression, newEpoch, vol.Epoch())
}
if newEpoch > vol.Epoch() {
if err := vol.SetEpoch(newEpoch); err != nil {
return fmt.Errorf("assignment refresh: set epoch: %w", err)
}
vol.SetMasterEpoch(newEpoch)
}
if current == RolePrimary {
vol.lease.Grant(leaseTTL)
}
return nil
}
switch {
case current == RoleReplica && newRole == RolePrimary:
return promote(vol, newEpoch, leaseTTL)
case current == RolePrimary && newRole == RoleStale:
return demote(vol, newEpoch)
case current == RoleStale && newRole == RoleRebuilding:
// Rebuild started externally via StartRebuild.
return vol.SetRole(RoleRebuilding)
case current == RoleNone && newRole == RoleRebuilding:
// After VS restart, volume is RoleNone. Master may send Rebuilding
// assignment if this was a stale replica that needs rebuild.
if err := vol.SetEpoch(newEpoch); err != nil {
return fmt.Errorf("assign rebuilding: set epoch: %w", err)
}
vol.SetMasterEpoch(newEpoch)
return vol.SetRole(RoleRebuilding)
case current == RoleNone && newRole == RolePrimary:
return promote(vol, newEpoch, leaseTTL)
case current == RoleNone && newRole == RoleReplica:
if err := vol.SetEpoch(newEpoch); err != nil {
return fmt.Errorf("assign replica: set epoch: %w", err)
}
vol.SetMasterEpoch(newEpoch)
return vol.SetRole(RoleReplica)
default:
return fmt.Errorf("%w: %s -> %s", ErrInvalidAssignment, current, newRole)
}
}
// promote transitions Replica/None -> Primary.
// Order matters: epoch durable BEFORE writes possible.
func promote(vol *BlockVol, newEpoch uint64, leaseTTL time.Duration) error {
if newEpoch < vol.Epoch() {
return fmt.Errorf("%w: new %d < current %d", ErrEpochRegression, newEpoch, vol.Epoch())
}
if err := vol.SetEpoch(newEpoch); err != nil {
return fmt.Errorf("promote: set epoch: %w", err)
}
vol.SetMasterEpoch(newEpoch)
if err := vol.SetRole(RolePrimary); err != nil {
return fmt.Errorf("promote: set role: %w", err)
}
vol.lease.Grant(leaseTTL)
return nil
}
// demote transitions Primary -> Draining -> Stale.
// Revokes lease first, drains in-flight ops, then persists new epoch.
func demote(vol *BlockVol, newEpoch uint64) error {
// Guard epoch monotonicity before any state changes.
if newEpoch < vol.Epoch() {
return fmt.Errorf("%w: new %d < current %d", ErrEpochRegression, newEpoch, vol.Epoch())
}
// Revoke lease --writeGate blocks new writes immediately.
vol.lease.Revoke()
// Transition to Draining.
if err := vol.SetRole(RoleDraining); err != nil {
return fmt.Errorf("demote: set draining: %w", err)
}
// Wait for in-flight ops to drain.
drainTTL := vol.drainTimeout
if drainTTL == 0 {
drainTTL = defaultDrainTimeout
}
deadline := time.NewTimer(drainTTL)
defer deadline.Stop()
ticker := time.NewTicker(1 * time.Millisecond)
defer ticker.Stop()
for vol.opsOutstanding.Load() > 0 {
select {
case <-deadline.C:
return ErrDrainTimeout
case <-ticker.C:
}
}
// Stop shippers if present.
if vol.shipperGroup != nil {
vol.shipperGroup.StopAll()
}
// Transition Draining -> Stale.
if err := vol.SetRole(RoleStale); err != nil {
return fmt.Errorf("demote: set stale: %w", err)
}
// Persist new epoch.
if err := vol.SetEpoch(newEpoch); err != nil {
return fmt.Errorf("demote: set epoch: %w", err)
}
vol.SetMasterEpoch(newEpoch)
return nil
}