Files
seaweedfs/weed/storage/blockvol/sync_all_adversarial_test.go
T
pingqiuandClaude Opus 4.6 785a7d7efd feat: wire real pinner into flusher retention + real WAL scan executor (Phase 07 P1)
Pinner wired to real retention:
- NewPinner calls vol.SetV2RetentionFloor(p.MinWALRetentionFloor)
- Flusher.RetentionFloorFn() / SetRetentionFloorFn() exposed
- SetV2RetentionFloor chains with existing shipper retention floor
- Holds actually prevent WAL reclaim (not just tracked state)

Executor uses real WAL scan:
- BlockVol.ScanWALEntries(fromLSN, callback) wraps wal.ScanFrom
  with real fd, walOffset, checkpointLSN
- Executor.StreamWALEntries uses ScanWALEntries (not stub)
- Reads real WAL entries, tracks highest LSN scanned

CommittedLSN mapping:
- Explicitly documented as interim V1 model (committed = checkpointed)
- Will diverge when V2 distributed commit separates from local flush

Carry-forward:
- TransferSnapshot/TransferFullBase/TruncateWAL: stubs (need extent I/O)
- Control intent from confirmed failover: deferred

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 20:01:46 -07:00

566 lines
14 KiB
Go

package blockvol
// CP13-5 adversarial tests: edge cases for reconnect, catch-up, and state machine.
// These test the 6 audit points from the CP13-5 review.
import (
"bytes"
"path/filepath"
"sync"
"testing"
"time"
)
// ---------- Point 1: catchupFailures concurrency ----------
// TestAdversarial_ConcurrentBarrierDoesNotCorruptCatchupFailures verifies
// that rapid concurrent SyncCache calls (which trigger Barrier on the same
// shipper) do not corrupt the catchupFailures counter.
// The group committer serializes SyncCache, but this test exercises the
// boundary by calling Barrier directly from multiple goroutines.
func TestAdversarial_ConcurrentBarrierDoesNotCorruptCatchupFailures(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Write + sync to establish InSync.
if err := primary.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatal(err)
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
// Fire 10 concurrent SyncCache calls.
var wg sync.WaitGroup
errors := make([]error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
if err := primary.WriteLBA(uint64(idx+1), makeBlock(byte('B'+idx))); err != nil {
errors[idx] = err
return
}
errors[idx] = primary.SyncCache()
}(i)
}
wg.Wait()
// All should succeed (healthy path).
for i, err := range errors {
if err != nil {
t.Errorf("concurrent SyncCache[%d]: %v", i, err)
}
}
}
// ---------- Point 2: bootstrap vs reconnect discriminator ----------
// TestAdversarial_FreshShipperUsesBootstrapNotReconnect verifies that a
// freshly created shipper (hasFlushedProgress=false) uses the bootstrap
// path (bare TCP connect), not the reconnect handshake path.
func TestAdversarial_FreshShipperUsesBootstrapNotReconnect(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
sg := primary.shipperGroup
s := sg.Shipper(0)
if s == nil {
t.Fatal("no shipper")
}
// Fresh shipper: hasFlushedProgress must be false.
if s.HasFlushedProgress() {
t.Fatal("fresh shipper should not have flushed progress")
}
// State should be Disconnected (initial).
if s.State() != ReplicaDisconnected {
t.Fatalf("fresh shipper state=%s, want Disconnected", s.State())
}
// First write + sync should succeed via bootstrap path.
if err := primary.WriteLBA(0, makeBlock('X')); err != nil {
t.Fatal(err)
}
if err := primary.SyncCache(); err != nil {
t.Fatalf("first SyncCache (bootstrap): %v", err)
}
// After first successful barrier, hasFlushedProgress should be true.
if !s.HasFlushedProgress() {
t.Fatal("after successful barrier, hasFlushedProgress should be true")
}
if s.State() != ReplicaInSync {
t.Fatalf("after bootstrap barrier, state=%s, want InSync", s.State())
}
}
// TestAdversarial_ReconnectUsesHandshakeNotBootstrap verifies that after
// a degraded shipper reconnects, it uses the handshake protocol (not bare
// TCP retry) because hasFlushedProgress is true.
func TestAdversarial_ReconnectUsesHandshakeNotBootstrap(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Establish InSync.
if err := primary.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatal(err)
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
sg := primary.shipperGroup
s := sg.Shipper(0)
if !s.HasFlushedProgress() {
t.Fatal("should have flushed progress after sync")
}
// Disconnect replica.
recv.Stop()
time.Sleep(50 * time.Millisecond)
// Write during disconnect.
if err := primary.WriteLBA(1, makeBlock('B')); err != nil {
t.Fatal(err)
}
// Reconnect.
recv2, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv2.Serve()
defer recv2.Stop()
// Reconfigure shipper to new address (preserving shipper identity).
primary.SetReplicaAddr(recv2.DataAddr(), recv2.CtrlAddr())
// The shipper still has hasFlushedProgress=true (identity preserved in
// SetReplicaAddr? depends on implementation). If SetReplicaAddr creates
// new shippers, this test validates the bootstrap path again.
// Either way, SyncCache must succeed.
syncDone := make(chan error, 1)
go func() {
syncDone <- primary.SyncCache()
}()
select {
case err := <-syncDone:
if err != nil {
t.Fatalf("SyncCache after reconnect: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("SyncCache hung after reconnect")
}
}
// ---------- Point 3: duplicate catch-up LSN semantics ----------
// TestAdversarial_ReplicaRejectsDuplicateLSN verifies the replica skips
// entries with LSN <= receivedLSN (duplicate/old), does not error.
func TestAdversarial_ReplicaRejectsDuplicateLSN(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Write 5 entries.
for i := uint64(0); i < 5; i++ {
if err := primary.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatal(err)
}
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
// Verify replica has all 5.
if recv.ReceivedLSN() < 5 {
t.Fatalf("replica receivedLSN=%d, expected >=5", recv.ReceivedLSN())
}
// Manually send a duplicate entry (LSN 3) to the replica.
// This should be silently skipped, not error.
entry := &WALEntry{
LSN: 3, // already received
Epoch: 1,
Type: EntryTypeWrite,
LBA: 100,
Length: 4096,
Data: makeBlock('Z'),
}
err = recv.ApplyEntryForTest(entry)
if err != nil {
t.Fatalf("duplicate LSN should be skipped, got error: %v", err)
}
// Original data at LBA 2 (LSN 3) should be unchanged.
replica.flusher.FlushOnce()
got, _ := replica.ReadLBA(2, 4096)
if got[0] != 'C' {
t.Fatalf("LBA 2: expected C, got %c — duplicate entry corrupted data", got[0])
}
}
// TestAdversarial_ReplicaRejectsGapLSN verifies the replica rejects entries
// with LSN > receivedLSN+1 (gap — entries were missed).
func TestAdversarial_ReplicaRejectsGapLSN(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Write 3 entries.
for i := uint64(0); i < 3; i++ {
if err := primary.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatal(err)
}
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
// Manually send LSN 10 (skipping 4-9). Should fail with gap error.
entry := &WALEntry{
LSN: 10,
Epoch: 1,
Type: EntryTypeWrite,
LBA: 50,
Length: 4096,
Data: makeBlock('Z'),
}
err = recv.ApplyEntryForTest(entry)
if err == nil {
t.Fatal("gap LSN should be rejected, got nil error")
}
}
// ---------- Point 4: NeedsRebuild stickiness ----------
// TestAdversarial_NeedsRebuildBlocksAllPaths verifies that once a shipper
// enters NeedsRebuild, neither Ship nor Barrier can bring it back to healthy.
func TestAdversarial_NeedsRebuildBlocksAllPaths(t *testing.T) {
dir := t.TempDir()
opts := CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 32 * 1024, // tiny WAL
DurabilityMode: DurabilitySyncAll,
}
primary, err := CreateBlockVol(filepath.Join(dir, "primary.blk"), opts)
if err != nil {
t.Fatal(err)
}
defer primary.Close()
primary.SetRole(RolePrimary)
primary.SetEpoch(1)
primary.SetMasterEpoch(1)
primary.lease.Grant(30 * time.Second)
replica, err := CreateBlockVol(filepath.Join(dir, "replica.blk"), opts)
if err != nil {
t.Fatal(err)
}
defer replica.Close()
replica.SetRole(RoleReplica)
replica.SetEpoch(1)
replica.SetMasterEpoch(1)
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Establish sync.
if err := primary.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatal(err)
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
// Disconnect and write a lot to overflow WAL.
recv.Stop()
time.Sleep(50 * time.Millisecond)
for i := uint64(0); i < 50; i++ {
_ = primary.WriteLBA(i%8, makeBlock(byte('0'+i%10)))
}
primary.flusher.FlushOnce()
primary.flusher.FlushOnce()
// Reconnect — gap should exceed retained WAL → NeedsRebuild.
recv2, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv2.Serve()
defer recv2.Stop()
primary.SetReplicaAddr(recv2.DataAddr(), recv2.CtrlAddr())
// SyncCache should fail.
syncDone := make(chan error, 1)
go func() {
syncDone <- primary.SyncCache()
}()
select {
case err := <-syncDone:
if err == nil {
t.Fatal("SyncCache should fail after NeedsRebuild")
}
case <-time.After(10 * time.Second):
t.Fatal("SyncCache hung")
}
// Verify the shipper is in NeedsRebuild or Degraded.
sg := primary.shipperGroup
if sg == nil {
t.Fatal("no shipper group")
}
s := sg.Shipper(0)
if s == nil {
t.Fatal("no shipper")
}
st := s.State()
if st == ReplicaInSync {
t.Fatal("shipper should NOT be InSync after NeedsRebuild")
}
t.Logf("shipper state after gap: %s (expected Degraded or NeedsRebuild)", st)
// Try Ship — should silently drop (not transition to healthy).
if err := primary.WriteLBA(0, makeBlock('Z')); err != nil {
t.Fatal(err)
}
// State should still be unhealthy.
st2 := s.State()
if st2 == ReplicaInSync {
t.Fatal("Ship should not restore InSync from NeedsRebuild/Degraded")
}
// Try Barrier again — should still fail.
syncDone2 := make(chan error, 1)
go func() {
syncDone2 <- primary.SyncCache()
}()
select {
case err := <-syncDone2:
if err == nil {
t.Fatal("second SyncCache should still fail after NeedsRebuild")
}
case <-time.After(10 * time.Second):
t.Fatal("second SyncCache hung")
}
}
// ---------- Point 6: data integrity after catch-up ----------
// TestAdversarial_CatchupDoesNotOverwriteNewerData verifies that if the
// replica has data at an LBA from a later LSN, catch-up replay of an
// earlier LSN for the same LBA does not overwrite the newer version.
// (This is actually handled by the WAL: the dirty map always uses the
// latest LSN for each LBA.)
func TestAdversarial_CatchupDoesNotOverwriteNewerData(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
defer recv.Stop()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Write LBA 0 = 'A' (LSN 1), then LBA 0 = 'B' (LSN 2).
if err := primary.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatal(err)
}
if err := primary.WriteLBA(0, makeBlock('B')); err != nil {
t.Fatal(err)
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
// Disconnect, write LBA 0 = 'C' (LSN 3).
recv.Stop()
time.Sleep(50 * time.Millisecond)
if err := primary.WriteLBA(0, makeBlock('C')); err != nil {
t.Fatal(err)
}
// Reconnect — catch-up sends LSN 3.
recv2, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv2.Serve()
defer recv2.Stop()
primary.SetReplicaAddr(recv2.DataAddr(), recv2.CtrlAddr())
syncDone := make(chan error, 1)
go func() {
syncDone <- primary.SyncCache()
}()
select {
case err := <-syncDone:
if err != nil {
t.Fatalf("SyncCache: %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("SyncCache hung")
}
// Replica should have 'C' at LBA 0, not 'A' or 'B'.
replica.flusher.FlushOnce()
got, err := replica.ReadLBA(0, 4096)
if err != nil {
t.Fatal(err)
}
if got[0] != 'C' {
t.Fatalf("LBA 0: expected C (latest), got %c — catch-up overwrote newer data", got[0])
}
}
// TestAdversarial_CatchupMultipleDisconnects verifies that multiple
// disconnect/reconnect cycles with writes in between all converge correctly.
func TestAdversarial_CatchupMultipleDisconnects(t *testing.T) {
primary, replica := createSyncAllPair(t)
defer primary.Close()
defer replica.Close()
recv, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv.Serve()
primary.SetReplicaAddr(recv.DataAddr(), recv.CtrlAddr())
// Cycle 1: write, sync, disconnect, write.
for i := uint64(0); i < 3; i++ {
if err := primary.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatal(err)
}
}
if err := primary.SyncCache(); err != nil {
t.Fatal(err)
}
recv.Stop()
time.Sleep(30 * time.Millisecond)
for i := uint64(3); i < 5; i++ {
if err := primary.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatal(err)
}
}
// Reconnect 1.
recv2, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv2.Serve()
primary.SetReplicaAddr(recv2.DataAddr(), recv2.CtrlAddr())
if err := primary.SyncCache(); err != nil {
t.Fatalf("cycle 1 reconnect SyncCache: %v", err)
}
// Cycle 2: disconnect again, write more.
recv2.Stop()
time.Sleep(30 * time.Millisecond)
for i := uint64(5); i < 8; i++ {
if err := primary.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatal(err)
}
}
// Reconnect 2.
recv3, err := NewReplicaReceiver(replica, "127.0.0.1:0", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
recv3.Serve()
defer recv3.Stop()
primary.SetReplicaAddr(recv3.DataAddr(), recv3.CtrlAddr())
if err := primary.SyncCache(); err != nil {
t.Fatalf("cycle 2 reconnect SyncCache: %v", err)
}
// Verify all 8 blocks on replica.
replica.flusher.FlushOnce()
for i := uint64(0); i < 8; i++ {
got, err := replica.ReadLBA(i, 4096)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
expected := byte('A' + i)
if !bytes.Equal(got[:1], []byte{expected}) {
t.Errorf("LBA %d: expected %c, got %c after 2 disconnect/reconnect cycles", i, expected, got[0])
}
}
}