Files
seaweedfs/weed/storage/blockvol/dist_group_commit_test.go
T
Ping QiuandClaude Opus 4.6 548e47e482 feat: reconnect handshake + WAL catch-up protocol (CP13-5)
Adds the sync_all reconnect protocol: when a degraded shipper
reconnects, it performs a handshake (ResumeShipReq/Resp) to
determine the replica's durable progress, then streams missed
WAL entries to close the gap before resuming live shipping.

New wire messages:
- MsgResumeShipReq (0x03): primary sends epoch, headLSN, retainStart
- MsgResumeShipResp (0x04): replica returns status + flushedLSN
- MsgCatchupDone (0x05): marks end of catch-up stream

Decision matrix after handshake:
- R == H: already caught up → InSync
- S <= R+1 <= H: recoverable gap → CatchingUp → stream → InSync
- R+1 < S: gap exceeds retained WAL → NeedsRebuild
- R > H: impossible progress → NeedsRebuild

WALAccess interface: narrow abstraction (RetainedRange + StreamEntries)
avoids coupling shipper to raw WAL internals.

Bootstrap vs reconnect split: fresh shippers (HasFlushedProgress=false)
use CP13-4 bootstrap path. Previously-synced shippers use handshake.

Catch-up retry budget: maxCatchupRetries=3 before NeedsRebuild.

ReplicaReceiver now initializes receivedLSN/flushedLSN from volume's
nextLSN on construction (handles receiver restart on existing volume).

TestBug2_SyncAll_SyncCache_AfterDegradedShipperRecovers flips FAIL→PASS.
All previously-passing baseline tests remain green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:38:06 -07:00

161 lines
4.8 KiB
Go

package blockvol
import (
"errors"
"os"
"path/filepath"
"testing"
)
// mockShipperGroup creates a ShipperGroup with N mock shippers.
// failIdxs specifies which shippers should return errors on Barrier.
func newTestVolWithMode(t *testing.T, mode DurabilityMode) (*BlockVol, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.blk")
vol, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 4 * 1024 * 1024,
DurabilityMode: mode,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
// Write some data so nextLSN > 0.
data := make([]byte, 4096)
data[0] = 0xAB
if err := vol.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
return vol, dir
}
// fakeShipper creates a WALShipper that we can control degraded state for testing.
// We can't easily create mock WALShippers, but we can test MakeDistributedSync
// with nil/empty ShipperGroup for edge cases and real shippers for others.
func TestDistSync_BestEffort_NilGroup(t *testing.T) {
vol, _ := newTestVolWithMode(t, DurabilityBestEffort)
defer vol.Close()
syncCalled := false
fn := MakeDistributedSync(func() error {
syncCalled = true
return nil
}, nil, vol)
if err := fn(); err != nil {
t.Fatalf("sync error: %v", err)
}
if !syncCalled {
t.Error("walSync not called")
}
}
func TestDistSync_SyncAll_NilGroup_Succeeds(t *testing.T) {
// sync_all with nil group (no replicas configured) should succeed locally.
vol, _ := newTestVolWithMode(t, DurabilitySyncAll)
defer vol.Close()
fn := MakeDistributedSync(func() error { return nil }, nil, vol)
if err := fn(); err != nil {
t.Fatalf("sync error: %v", err)
}
}
func TestDistSync_SyncAll_AllDegraded_Fails(t *testing.T) {
vol, _ := newTestVolWithMode(t, DurabilitySyncAll)
defer vol.Close()
// Create a shipper group with one degraded shipper.
shipper := NewWALShipper("127.0.0.1:99999", "127.0.0.1:99998", func() uint64 {
return vol.epoch.Load()
}, nil, vol.Metrics)
shipper.state.Store(uint32(ReplicaDegraded))
group := NewShipperGroup([]*WALShipper{shipper})
fn := MakeDistributedSync(func() error { return nil }, group, vol)
err := fn()
if !errors.Is(err, ErrDurabilityBarrierFailed) {
t.Fatalf("expected ErrDurabilityBarrierFailed, got: %v", err)
}
}
func TestDistSync_SyncQuorum_AllDegraded_RF3_Fails(t *testing.T) {
vol, _ := newTestVolWithMode(t, DurabilitySyncQuorum)
defer vol.Close()
s1 := NewWALShipper("127.0.0.1:99999", "127.0.0.1:99998", func() uint64 { return 0 }, nil, vol.Metrics)
s2 := NewWALShipper("127.0.0.1:99997", "127.0.0.1:99996", func() uint64 { return 0 }, nil, vol.Metrics)
s1.state.Store(uint32(ReplicaDegraded))
s2.state.Store(uint32(ReplicaDegraded))
group := NewShipperGroup([]*WALShipper{s1, s2})
fn := MakeDistributedSync(func() error { return nil }, group, vol)
err := fn()
if !errors.Is(err, ErrDurabilityQuorumLost) {
t.Fatalf("expected ErrDurabilityQuorumLost, got: %v", err)
}
}
func TestDistSync_BestEffort_BackwardCompat(t *testing.T) {
// best_effort with all-degraded group should still succeed (local fsync only).
vol, _ := newTestVolWithMode(t, DurabilityBestEffort)
defer vol.Close()
s1 := NewWALShipper("127.0.0.1:99999", "127.0.0.1:99998", func() uint64 { return 0 }, nil, vol.Metrics)
s1.state.Store(uint32(ReplicaDegraded))
group := NewShipperGroup([]*WALShipper{s1})
fn := MakeDistributedSync(func() error { return nil }, group, vol)
if err := fn(); err != nil {
t.Fatalf("best_effort all-degraded should succeed: %v", err)
}
}
func TestDistSync_Metrics_IncrementOnFailure(t *testing.T) {
vol, _ := newTestVolWithMode(t, DurabilitySyncAll)
defer vol.Close()
s1 := NewWALShipper("127.0.0.1:99999", "127.0.0.1:99998", func() uint64 { return 0 }, nil, vol.Metrics)
s1.state.Store(uint32(ReplicaDegraded))
group := NewShipperGroup([]*WALShipper{s1})
fn := MakeDistributedSync(func() error { return nil }, group, vol)
fn() // should fail
if vol.Metrics.DurabilityBarrierFailedTotal.Load() == 0 {
t.Error("expected DurabilityBarrierFailedTotal > 0")
}
}
func TestDistSync_LocalFsyncFail_AlwaysErrors(t *testing.T) {
// Regardless of mode, local fsync failure always returns error.
for _, mode := range []DurabilityMode{DurabilityBestEffort, DurabilitySyncAll, DurabilitySyncQuorum} {
t.Run(mode.String(), func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.blk")
vol, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 4 * 1024 * 1024,
DurabilityMode: mode,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
defer vol.Close()
localErr := errors.New("disk I/O error")
fn := MakeDistributedSync(func() error { return localErr }, nil, vol)
err = fn()
if !errors.Is(err, localErr) {
t.Errorf("expected local error, got: %v", err)
}
})
}
}
// Cleanup helper for tests that create files.
func cleanupFile(t *testing.T, path string) {
t.Helper()
os.Remove(path)
}