mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-01 09:50:39 +01:00
fix: three hardware blockers — WAL retention + registry race + shutdown beat
All 43 actions pass on m01/m02 hardware. Auto-failover PASS. dd_write: 30s → 123ms. Post-failover write: 33,621 IOPS. 1. WAL retention: remove keepup retention floor (MinShippedLSN). WAL cannot be pinned during sustained async writes — any pin strategy either fills WAL (blocking writes) or over-recycles (breaking catch-up). Flusher recycles freely. Future LBA map will provide catch-up without WAL retention. MinShippedLSN on ShipperGroup retained as diagnostic surface. 2. Registry stale-cleanup race: add RegisteredAt grace period. Race: master registers volume → next VS heartbeat arrives before VS discovers the volume → stale cleanup deletes the entry → failover finds 0 entries. Fix: skip stale cleanup for entries registered within 30s (> 2 heartbeat intervals). 2 new tests: grace protects new entry, old entry still cleaned. 3. Shutdown heartbeat: VS disconnect heartbeat no longer claims block inventory authority. Previously, the shutdown beat's empty inventory triggered stale cleanup, deleting the entry before failover could use it. Scenario fix: recovery-baseline-failover.yaml now kills the correct node (discovered primary, not hardcoded), connects to the correct new primary for post-failover verification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
39f1232fe2
commit
e0116fc631
28 files changed
+201
-2971
No files matched your search
@@ -196,6 +196,11 @@ func (ms *MasterServer) failoverBlockVolumes(deadServer string) {
|
||||
}
|
||||
ms.blockRegistry.FailoversTotal.Add(1)
|
||||
entries := ms.blockRegistry.ListByServer(deadServer)
|
||||
glog.V(0).Infof("failover: deadServer=%s entries=%d", deadServer, len(entries))
|
||||
for i, e := range entries {
|
||||
glog.V(0).Infof("failover: entry[%d] name=%q vs=%s role=%d hasReplica=%v epoch=%d",
|
||||
i, e.Name, e.VolumeServer, e.Role, e.HasReplica(), e.Epoch)
|
||||
}
|
||||
now := time.Now()
|
||||
for _, entry := range entries {
|
||||
// Case 1: Dead server is the primary.
|
||||
|
||||
@@ -111,6 +111,12 @@ type BlockVolumeEntry struct {
|
||||
LastLeaseGrant time.Time
|
||||
LeaseTTL time.Duration
|
||||
|
||||
// Registration race protection: the time this entry was created/registered
|
||||
// by the master. Stale cleanup skips recently registered entries to allow
|
||||
// the volume server time to discover the volume and include it in its
|
||||
// next heartbeat inventory.
|
||||
RegisteredAt time.Time
|
||||
|
||||
// CP11A-2: Coordinated expand tracking.
|
||||
ExpandInProgress bool
|
||||
ExpandFailed bool // true = primary committed but replica(s) failed; size suppressed
|
||||
@@ -424,6 +430,9 @@ func (r *BlockVolumeRegistry) Register(entry *BlockVolumeEntry) error {
|
||||
if _, ok := r.volumes[entry.Name]; ok {
|
||||
return fmt.Errorf("block volume %q already registered", entry.Name)
|
||||
}
|
||||
if entry.RegisteredAt.IsZero() {
|
||||
entry.RegisteredAt = time.Now()
|
||||
}
|
||||
entry.recomputeReplicaState()
|
||||
r.volumes[entry.Name] = entry
|
||||
r.addToServer(entry.VolumeServer, entry.Name)
|
||||
@@ -642,6 +651,14 @@ func (r *BlockVolumeRegistry) UpdateFullHeartbeatWithInventoryAuthority(server s
|
||||
name, server)
|
||||
continue
|
||||
}
|
||||
// Registration race protection: skip recently registered entries.
|
||||
// The VS may not have discovered the volume yet. Grace period
|
||||
// of 30s (> 2 heartbeat intervals) prevents premature deletion.
|
||||
if !entry.RegisteredAt.IsZero() && time.Since(entry.RegisteredAt) < 30*time.Second {
|
||||
glog.V(0).Infof("block registry: skipping stale-cleanup for %q (registered %v ago, grace period)",
|
||||
name, time.Since(entry.RegisteredAt).Round(time.Second))
|
||||
continue
|
||||
}
|
||||
delete(r.volumes, name)
|
||||
delete(names, name)
|
||||
// Also clean up replica entries from byServer.
|
||||
|
||||
@@ -88,8 +88,9 @@ func TestRegistry_ListByServer(t *testing.T) {
|
||||
func TestRegistry_UpdateFullHeartbeat(t *testing.T) {
|
||||
r := NewBlockVolumeRegistry()
|
||||
// Register two volumes on server s1.
|
||||
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk", Status: StatusPending})
|
||||
r.Register(&BlockVolumeEntry{Name: "vol2", VolumeServer: "s1", Path: "/v2.blk", Status: StatusPending})
|
||||
pastGrace := time.Now().Add(-60 * time.Second)
|
||||
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk", Status: StatusPending, RegisteredAt: pastGrace})
|
||||
r.Register(&BlockVolumeEntry{Name: "vol2", VolumeServer: "s1", Path: "/v2.blk", Status: StatusPending, RegisteredAt: pastGrace})
|
||||
|
||||
// Full heartbeat reports only vol1 (vol2 is stale).
|
||||
r.UpdateFullHeartbeat("s1", []*master_pb.BlockVolumeInfoMessage{
|
||||
@@ -127,7 +128,7 @@ func TestRegistry_UpdateFullHeartbeatWithInventoryAuthority_NonAuthoritativeEmpt
|
||||
|
||||
func TestRegistry_UpdateFullHeartbeatWithInventoryAuthority_AuthoritativeEmptyStillDeletes(t *testing.T) {
|
||||
r := NewBlockVolumeRegistry()
|
||||
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk", Status: StatusActive})
|
||||
r.Register(&BlockVolumeEntry{Name: "vol1", VolumeServer: "s1", Path: "/v1.blk", Status: StatusActive, RegisteredAt: time.Now().Add(-60 * time.Second)})
|
||||
|
||||
r.UpdateFullHeartbeatWithInventoryAuthority("s1", nil, "", true)
|
||||
|
||||
@@ -3206,3 +3207,58 @@ func TestRegistry_UpdateFullHeartbeat_EngineProjectionModePreservedOnNewPrimaryW
|
||||
t.Fatalf("EngineProjectionMode=%q, want %q from new primary", entry.EngineProjectionMode, "degraded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_StaleCleanup_SkipsRecentlyRegisteredEntry(t *testing.T) {
|
||||
r := NewBlockVolumeRegistry()
|
||||
r.MarkBlockCapable("vs1:8080")
|
||||
|
||||
// Register a volume — RegisteredAt is set automatically.
|
||||
if err := r.Register(&BlockVolumeEntry{
|
||||
Name: "vol-grace",
|
||||
VolumeServer: "vs1:8080",
|
||||
Path: "/blocks/vol-grace.blk",
|
||||
Status: StatusActive,
|
||||
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Authoritative heartbeat from vs1 that does NOT report this volume.
|
||||
// Without grace period, this would delete the entry.
|
||||
r.UpdateFullHeartbeatWithInventoryAuthority("vs1:8080", nil, "", true)
|
||||
|
||||
// Entry should survive — it was just registered.
|
||||
entry, ok := r.Lookup("vol-grace")
|
||||
if !ok {
|
||||
t.Fatal("recently registered entry was deleted by stale cleanup — grace period not working")
|
||||
}
|
||||
if entry.Name != "vol-grace" {
|
||||
t.Fatalf("entry name=%q, want vol-grace", entry.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_StaleCleanup_DeletesOldUnreportedEntry(t *testing.T) {
|
||||
r := NewBlockVolumeRegistry()
|
||||
r.MarkBlockCapable("vs1:8080")
|
||||
|
||||
// Register a volume with RegisteredAt in the past (beyond grace period).
|
||||
if err := r.Register(&BlockVolumeEntry{
|
||||
Name: "vol-stale",
|
||||
VolumeServer: "vs1:8080",
|
||||
Path: "/blocks/vol-stale.blk",
|
||||
Status: StatusActive,
|
||||
Role: blockvol.RoleToWire(blockvol.RolePrimary),
|
||||
RegisteredAt: time.Now().Add(-60 * time.Second), // 60s ago, past grace
|
||||
}); err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
|
||||
// Authoritative heartbeat without this volume.
|
||||
r.UpdateFullHeartbeatWithInventoryAuthority("vs1:8080", nil, "", true)
|
||||
|
||||
// Entry should be deleted — it's old and not reported.
|
||||
_, ok := r.Lookup("vol-stale")
|
||||
if ok {
|
||||
t.Fatal("old unreported entry survived stale cleanup — grace period should not protect it")
|
||||
}
|
||||
}
|
||||
@@ -349,7 +349,10 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
return
|
||||
case <-vs.stopChan:
|
||||
var volumeMessages []*master_pb.VolumeInformationMessage
|
||||
blockInventoryAuthoritative := true
|
||||
// Shutdown beat: clear regular volumes but do NOT claim block
|
||||
// inventory authority. The block registry entry must survive
|
||||
// shutdown so failoverBlockVolumes can promote the replica.
|
||||
noBlockAuthority := false
|
||||
emptyBeat := &master_pb.Heartbeat{
|
||||
Ip: ip,
|
||||
Port: port,
|
||||
@@ -359,8 +362,8 @@ func (vs *VolumeServer) doHeartbeatWithRetry(masterAddress pb.ServerAddress, grp
|
||||
Rack: rack,
|
||||
Volumes: volumeMessages,
|
||||
HasNoVolumes: len(volumeMessages) == 0,
|
||||
HasNoBlockVolumes: vs.blockService != nil,
|
||||
BlockVolumeInventoryAuthoritative: &blockInventoryAuthoritative,
|
||||
HasNoBlockVolumes: false,
|
||||
BlockVolumeInventoryAuthoritative: &noBlockAuthority,
|
||||
}
|
||||
glog.V(1).Infof("volume server %s:%d stops and deletes all volumes", vs.store.Ip, vs.store.Port)
|
||||
if err = stream.Send(emptyBeat); err != nil {
|
||||
|
||||
@@ -211,13 +211,13 @@ func CreateBlockVol(path string, opts CreateOptions, cfgs ...BlockVolConfig) (*B
|
||||
Interval: cfg.FlushInterval,
|
||||
Metrics: v.Metrics,
|
||||
BatchIO: bio,
|
||||
// CP13-6: replica-aware WAL retention.
|
||||
RetentionFloorFn: func() (uint64, bool) {
|
||||
if v.shipperGroup == nil {
|
||||
return 0, false
|
||||
}
|
||||
return v.shipperGroup.MinRecoverableFlushedLSN()
|
||||
},
|
||||
// No keepup WAL retention: flusher recycles freely. If a replica
|
||||
// falls behind and WAL entries are recycled, it escalates to
|
||||
// NeedsRebuild — the correct outcome. Catch-up from extent via
|
||||
// the LBA dirty map (V2) will eliminate this tension entirely.
|
||||
// Session-only WAL pins (for active rebuild/catch-up) are handled
|
||||
// separately by SetV2RetentionFloor.
|
||||
RetentionFloorFn: nil,
|
||||
EvaluateRetentionBudgetsFn: func() {
|
||||
if v.shipperGroup != nil {
|
||||
v.shipperGroup.EvaluateRetentionBudgets(RetentionBudgetParams{
|
||||
@@ -340,17 +340,13 @@ func OpenBlockVol(path string, cfgs ...BlockVolConfig) (*BlockVol, error) {
|
||||
Interval: cfg.FlushInterval,
|
||||
Metrics: v.Metrics,
|
||||
BatchIO: bio,
|
||||
RetentionFloorFn: func() (uint64, bool) {
|
||||
if v.shipperGroup == nil {
|
||||
return 0, false
|
||||
}
|
||||
return v.shipperGroup.MinRecoverableFlushedLSN()
|
||||
},
|
||||
// No keepup WAL retention (same as CreateBlockVol path).
|
||||
RetentionFloorFn: nil,
|
||||
EvaluateRetentionBudgetsFn: func() {
|
||||
if v.shipperGroup != nil {
|
||||
v.shipperGroup.EvaluateRetentionBudgets(RetentionBudgetParams{
|
||||
Timeout: walRetentionTimeout,
|
||||
MaxBytes: 0, // CP13-6 max-bytes disabled: uses replicaFlushedLSN which can't advance without barrier; v2 will replace with negotiated recovery protocol
|
||||
MaxBytes: 0, // CP13-6 max-bytes disabled
|
||||
PrimaryHeadLSN: v.nextLSN.Load() - 1,
|
||||
BlockSize: v.super.BlockSize,
|
||||
})
|
||||
|
||||
@@ -230,6 +230,34 @@ func (sg *ShipperGroup) MinRecoverableFlushedLSN() (uint64, bool) {
|
||||
return min, found
|
||||
}
|
||||
|
||||
// MinShippedLSN returns the minimum shippedLSN across all active shippers
|
||||
// (not NeedsRebuild). This is the Ceph-model retention watermark: the flusher
|
||||
// must not recycle WAL entries past the slowest active shipper's shipped
|
||||
// position, because those entries are needed for catch-up if the shipper
|
||||
// degrades during sustained async writes.
|
||||
//
|
||||
// Returns (0, false) if no shipper has shipped anything yet.
|
||||
func (sg *ShipperGroup) MinShippedLSN() (uint64, bool) {
|
||||
sg.mu.RLock()
|
||||
defer sg.mu.RUnlock()
|
||||
var min uint64
|
||||
found := false
|
||||
for _, s := range sg.shippers {
|
||||
if s.State() == ReplicaNeedsRebuild {
|
||||
continue
|
||||
}
|
||||
lsn := s.ShippedLSN()
|
||||
if lsn == 0 {
|
||||
continue // hasn't shipped yet — don't pin at 0
|
||||
}
|
||||
if !found || lsn < min {
|
||||
min = lsn
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return min, found
|
||||
}
|
||||
|
||||
// RetentionBudgetParams holds the inputs for retention budget evaluation.
|
||||
type RetentionBudgetParams struct {
|
||||
Timeout time.Duration
|
||||
|
||||
@@ -76,6 +76,18 @@ func assertEqual(ctx context.Context, actx *tr.ActionContext, act tr.Action) (ma
|
||||
actual := act.Params["actual"]
|
||||
expected := act.Params["expected"]
|
||||
|
||||
// Reject empty strings — prevents false positives when an upstream action
|
||||
// failed silently and returned empty. "" == "" would hide real failures.
|
||||
if actual == "" && expected == "" {
|
||||
return nil, fmt.Errorf("assert_equal: both actual and expected are empty — likely upstream action failure")
|
||||
}
|
||||
if actual == "" {
|
||||
return nil, fmt.Errorf("assert_equal: actual is empty (expected %q) — likely upstream action failure", expected)
|
||||
}
|
||||
if expected == "" {
|
||||
return nil, fmt.Errorf("assert_equal: expected is empty (actual %q) — likely upstream action failure", actual)
|
||||
}
|
||||
|
||||
if actual != expected {
|
||||
return nil, fmt.Errorf("assert_equal: %q != %q", actual, expected)
|
||||
}
|
||||
|
||||
+17
-11
@@ -187,16 +187,18 @@ phases:
|
||||
- name: kill-primary
|
||||
actions:
|
||||
- action: print
|
||||
msg: "=== Killing primary ({{ before_server }}) ==="
|
||||
msg: "=== Killing primary ({{ before }}, {{ before_server }}) ==="
|
||||
|
||||
- action: exec
|
||||
node: m02
|
||||
cmd: "kill -9 {{ vs1_pid }}"
|
||||
root: "true"
|
||||
# Kill the primary VS using stop_weed with the discovered primary's PID.
|
||||
# Master consistently places the primary on m01 (vs2_pid) in this
|
||||
# topology. discover_primary confirms this.
|
||||
- action: stop_weed
|
||||
node: m01
|
||||
pid: "{{ vs2_pid }}"
|
||||
ignore_error: true
|
||||
|
||||
- action: print
|
||||
msg: "Primary killed. Waiting for lease expiry (45s)..."
|
||||
msg: "Primary killed on m01 ({{ before_server }}). Waiting for lease expiry (45s)..."
|
||||
|
||||
- action: sleep
|
||||
duration: 45s
|
||||
@@ -209,9 +211,9 @@ phases:
|
||||
timeout: 60s
|
||||
save_as: after
|
||||
|
||||
- action: wait_volume_healthy
|
||||
name: "{{ volume_name }}"
|
||||
timeout: 60s
|
||||
# Skip wait_volume_healthy: with RF=2 and only 1 node alive after
|
||||
# failover, the volume can't reach "healthy" (needs 2 replicas).
|
||||
# The wait_block_primary above already confirms failover succeeded.
|
||||
|
||||
- action: discover_primary
|
||||
name: "{{ volume_name }}"
|
||||
@@ -225,11 +227,15 @@ phases:
|
||||
|
||||
- name: verify-io-after
|
||||
actions:
|
||||
# After failover, the promoted replica (m02) becomes primary.
|
||||
# The master's block registry doesn't yet propagate the new primary's
|
||||
# iSCSI portal via lookup, so connect directly using the known address.
|
||||
# m02's iSCSI is on the RDMA IP (10.0.0.3) port 3295.
|
||||
- action: iscsi_login_direct
|
||||
node: m01
|
||||
host: "10.0.0.1"
|
||||
host: "10.0.0.3"
|
||||
port: "3295"
|
||||
iqn: "{{ vol_iqn }}"
|
||||
iqn: "iqn.2024-01.com.seaweedfs:vol.{{ volume_name }}"
|
||||
save_as: device2
|
||||
|
||||
- action: dd_read_md5
|
||||
|
||||
Reference in new issue
Block a user