Files
seaweedfs/weed/storage/blockvol/qa_phase3_engine_test.go
T
Ping QiuandClaude Opus 4.6 a107685f00 feat: Phase 4A CP1 — epoch, lease, role state machine, write gate
Local fencing primitives for block volumes. Every write path validates
role + epoch + lease before accepting data. RoleNone (default) skips
all checks for Phase 3 backward compatibility.

New files: epoch.go, lease.go, role.go, write_gate.go
Modified: superblock.go (Epoch field), blockvol.go (fencing fields,
writeGate in WriteLBA/Trim), group_commit.go (PostSyncCheck/Gotcha A),
dirty_map.go (P3-BUG-9 power-of-2 panic)

Bug fixes: BUG-4A-1 (atomic epoch), BUG-4A-2 (CAS SetRole),
BUG-4A-3 (mutex SetEpoch), BUG-4A-4 (single role.Load),
BUG-4A-6 (safeCallback recover)

837 tests (557 engine + 280 iSCSI), all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:00:06 -08:00

493 lines
12 KiB
Go

package blockvol
import (
"errors"
"math"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestQAPhase3Engine tests Phase 3 engine adversarial scenarios:
// adaptive group commit, sharded dirty map, WAL pressure.
func TestQAPhase3Engine(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T)
}{
// QA-1.1: Group Commit Adversarial
{name: "gc_syncfunc_panic", run: testQAGCSyncFuncPanic},
{name: "gc_thundering_herd_1000", run: testQAGCThunderingHerd1000},
{name: "gc_lowwatermark_equals_maxbatch", run: testQAGCLowWatermarkEqualsMaxBatch},
// QA-1.2: Sharded Dirty Map Adversarial
{name: "dm_snapshot_during_heavy_write", run: testQADMSnapshotDuringHeavyWrite},
{name: "dm_put_delete_put_same_lba", run: testQADMPutDeletePutSameLBA},
{name: "dm_max_uint64_lba", run: testQADMMaxUint64LBA},
{name: "dm_non_power_of_2_shards", run: testQADMNonPowerOf2Shards},
{name: "dm_zero_shards", run: testQADMZeroShards},
{name: "dm_len_during_concurrent_writes", run: testQADMLenDuringConcurrentWrites},
// QA-1.3: WAL Pressure Adversarial
{name: "wal_pressure_flusher_slow", run: testQAWALPressureFlusherSlow},
{name: "wal_pressure_threshold_0", run: testQAWALPressureThreshold0},
{name: "wal_pressure_threshold_1", run: testQAWALPressureThreshold1},
{name: "wal_close_during_pressure_block", run: testQAWALCloseDuringPressureBlock},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
}
}
// --- QA-1.1: Group Commit Adversarial ---
func testQAGCSyncFuncPanic(t *testing.T) {
// syncFunc panics: Run() goroutine crashes, pending waiters must not hang forever.
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
panic("simulated disk panic")
},
MaxDelay: 10 * time.Millisecond,
})
// Wrap Run() with panic recovery.
runDone := make(chan struct{})
go func() {
defer close(runDone)
defer func() { recover() }()
gc.Run()
}()
// Submit should either return an error or hang. Use a timeout.
result := make(chan error, 1)
go func() {
result <- gc.Submit()
}()
select {
case <-result:
// Got a result (error or nil) -- panic was recovered, or waiter unblocked.
case <-runDone:
// Run() exited due to panic. Submit will hang on channel read.
// This is expected behavior: panic in syncFunc is fatal.
// Test passes: no goroutine leak from Run(), panic was contained.
case <-time.After(3 * time.Second):
t.Fatal("syncFunc panic: Run() and Submit both hung for 3s")
}
}
func testQAGCThunderingHerd1000(t *testing.T) {
// 1000 goroutines Submit() simultaneously: all must return, batching should work.
var syncCalls atomic.Uint64
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
syncCalls.Add(1)
return nil
},
MaxDelay: 50 * time.Millisecond,
MaxBatch: 64,
LowWatermark: 4,
})
go gc.Run()
defer gc.Stop()
const n = 1000
var wg sync.WaitGroup
errs := make([]error, n)
wg.Add(n)
for i := 0; i < n; i++ {
go func(idx int) {
defer wg.Done()
errs[idx] = gc.Submit()
}(i)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("thundering herd: 1000 submits hung for 10s")
}
for i, err := range errs {
if err != nil {
t.Errorf("Submit[%d]: %v", i, err)
}
}
c := syncCalls.Load()
if c >= 1000 {
t.Errorf("syncCalls = %d, expected batching (< 1000)", c)
}
t.Logf("thundering herd: 1000 submits, %d fsyncs", c)
}
func testQAGCLowWatermarkEqualsMaxBatch(t *testing.T) {
// lowWatermark = maxBatch = 8: skipDelay always true when < 8 pending.
var syncCalls atomic.Uint64
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
syncCalls.Add(1)
return nil
},
MaxDelay: 5 * time.Second, // should never wait this long
MaxBatch: 8,
LowWatermark: 8,
})
go gc.Run()
defer gc.Stop()
// Single serial submits: since pending < lowWatermark (8), skipDelay=true.
for i := 0; i < 5; i++ {
if err := gc.Submit(); err != nil {
t.Fatalf("Submit %d: %v", i, err)
}
}
// Each serial submit should have triggered its own fsync (no batching).
if c := syncCalls.Load(); c != 5 {
t.Errorf("syncCalls = %d, want 5 (skipDelay always true)", c)
}
}
// --- QA-1.2: Sharded Dirty Map Adversarial ---
func testQADMSnapshotDuringHeavyWrite(t *testing.T) {
dm := NewDirtyMap(256)
const numWriters = 16
const opsPerWriter = 1000
var wg sync.WaitGroup
// Writers: continuously Put entries.
wg.Add(numWriters)
for g := 0; g < numWriters; g++ {
go func(id int) {
defer wg.Done()
base := uint64(id * opsPerWriter)
for i := uint64(0); i < opsPerWriter; i++ {
dm.Put(base+i, (base+i)*10, base+i+1, 4096)
}
}(g)
}
// Concurrent snapshots.
wg.Add(1)
go func() {
defer wg.Done()
for round := 0; round < 20; round++ {
snap := dm.Snapshot()
// Each snapshot entry should have valid fields.
for _, e := range snap {
if e.Length != 4096 {
t.Errorf("snapshot entry LBA %d has length %d, want 4096", e.Lba, e.Length)
return
}
}
}
}()
wg.Wait()
// Test passes if no race/panic.
}
func testQADMPutDeletePutSameLBA(t *testing.T) {
dm := NewDirtyMap(256)
const iterations = 1000
for i := 0; i < iterations; i++ {
dm.Put(100, uint64(i)*10, uint64(i)+1, 4096)
dm.Delete(100)
dm.Put(100, uint64(i)*10+5, uint64(i)+2, 4096)
}
// Final state: should have the last Put's entry.
off, lsn, length, ok := dm.Get(100)
if !ok {
t.Fatal("Get(100) not found after Put-Delete-Put cycle")
}
expectedOff := uint64(999)*10 + 5
expectedLsn := uint64(999) + 2
if off != expectedOff {
t.Errorf("walOffset = %d, want %d", off, expectedOff)
}
if lsn != expectedLsn {
t.Errorf("lsn = %d, want %d", lsn, expectedLsn)
}
if length != 4096 {
t.Errorf("length = %d, want 4096", length)
}
}
func testQADMMaxUint64LBA(t *testing.T) {
dm := NewDirtyMap(256)
maxLBA := uint64(math.MaxUint64)
dm.Put(maxLBA, 12345, 1, 4096)
off, lsn, length, ok := dm.Get(maxLBA)
if !ok {
t.Fatal("Get(MaxUint64) not found")
}
if off != 12345 || lsn != 1 || length != 4096 {
t.Errorf("got (%d, %d, %d), want (12345, 1, 4096)", off, lsn, length)
}
dm.Delete(maxLBA)
_, _, _, ok = dm.Get(maxLBA)
if ok {
t.Error("Get(MaxUint64) should not be found after delete")
}
}
func testQADMNonPowerOf2Shards(t *testing.T) {
// P3-BUG-9 fix: non-power-of-2 shards now panic to prevent silent
// shard routing bugs (mask-based routing requires power-of-2).
defer func() {
r := recover()
if r == nil {
t.Fatal("expected panic for non-power-of-2 numShards")
}
}()
NewDirtyMap(7) // should panic
}
func testQADMZeroShards(t *testing.T) {
// NewDirtyMap(0) should default to 1 shard (no panic, no divide-by-zero).
dm := NewDirtyMap(0)
dm.Put(42, 100, 1, 4096)
off, _, _, ok := dm.Get(42)
if !ok {
t.Fatal("Get(42) not found with 0 shards (defaulted to 1)")
}
if off != 100 {
t.Errorf("walOffset = %d, want 100", off)
}
}
func testQADMLenDuringConcurrentWrites(t *testing.T) {
dm := NewDirtyMap(256)
const numWriters = 32
const opsPerWriter = 100
var wg sync.WaitGroup
// Writers.
wg.Add(numWriters)
for g := 0; g < numWriters; g++ {
go func(id int) {
defer wg.Done()
base := uint64(id * opsPerWriter)
for i := uint64(0); i < opsPerWriter; i++ {
dm.Put(base+i, (base+i)*10, base+i+1, 4096)
}
}(g)
}
// Concurrent Len() reader.
wg.Add(1)
go func() {
defer wg.Done()
for round := 0; round < 100; round++ {
n := dm.Len()
if n < 0 {
t.Errorf("Len() = %d, must be non-negative", n)
return
}
}
}()
wg.Wait()
// Final Len should be numWriters * opsPerWriter (each LBA unique).
if dm.Len() != numWriters*opsPerWriter {
t.Errorf("final Len() = %d, want %d", dm.Len(), numWriters*opsPerWriter)
}
}
// --- QA-1.3: WAL Pressure Adversarial ---
func testQAWALPressureFlusherSlow(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "slow_flush.blockvol")
cfg := DefaultConfig()
cfg.WALPressureThreshold = 0.3
cfg.WALFullTimeout = 3 * time.Second
cfg.FlushInterval = 5 * time.Millisecond
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 64 * 1024,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
defer v.Close()
// Write many blocks. With a small WAL and active flusher, some may succeed
// and some may encounter WAL pressure. None should hang forever.
var succeeded, walFull int
for i := 0; i < 30; i++ {
err := v.WriteLBA(uint64(i%256), makeBlock(byte('A'+i%26)))
if err == nil {
succeeded++
} else if errors.Is(err, ErrWALFull) {
walFull++
} else {
t.Fatalf("WriteLBA(%d): unexpected error: %v", i, err)
}
}
t.Logf("flusher_slow: %d succeeded, %d WAL full", succeeded, walFull)
if succeeded == 0 {
t.Error("no writes succeeded -- flusher not draining?")
}
}
func testQAWALPressureThreshold0(t *testing.T) {
// threshold=0: urgent flush on every write. Should still work.
dir := t.TempDir()
path := filepath.Join(dir, "thresh0.blockvol")
cfg := DefaultConfig()
cfg.WALPressureThreshold = 0.0 // always urgent
cfg.FlushInterval = 5 * time.Millisecond
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
defer v.Close()
// Every write triggers urgent flush. Should still succeed.
for i := 0; i < 20; i++ {
if err := v.WriteLBA(uint64(i), makeBlock(byte('A'+i%26))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
// Verify data readable.
for i := 0; i < 20; i++ {
data, err := v.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
if data[0] != byte('A'+i%26) {
t.Errorf("LBA %d: first byte = %c, want %c", i, data[0], byte('A'+i%26))
}
}
}
func testQAWALPressureThreshold1(t *testing.T) {
// threshold=1.0: never urgent. WAL fills, eventually ErrWALFull.
dir := t.TempDir()
path := filepath.Join(dir, "thresh1.blockvol")
cfg := DefaultConfig()
cfg.WALPressureThreshold = 1.0 // never urgent
cfg.WALFullTimeout = 100 * time.Millisecond
cfg.FlushInterval = 1 * time.Hour // disable periodic flush
entrySize := uint64(walEntryHeaderSize + 4096)
walSize := entrySize * 4 // tiny: 4 entries
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: walSize,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
defer v.Close()
// Stop flusher to prevent any drainage.
v.flusher.Stop()
// Fill WAL.
var gotWALFull bool
for i := 0; i < 10; i++ {
err := v.WriteLBA(uint64(i%256), makeBlock(byte('X')))
if errors.Is(err, ErrWALFull) {
gotWALFull = true
break
} else if err != nil {
t.Fatalf("WriteLBA(%d): unexpected error: %v", i, err)
}
}
if !gotWALFull {
t.Error("expected ErrWALFull with threshold=1.0 and no flusher")
}
}
func testQAWALCloseDuringPressureBlock(t *testing.T) {
// Writer blocked on WAL full, Close() called -- writer should unblock.
dir := t.TempDir()
path := filepath.Join(dir, "close_pressure.blockvol")
cfg := DefaultConfig()
cfg.WALFullTimeout = 10 * time.Second // long timeout
cfg.FlushInterval = 1 * time.Hour // disable flusher
entrySize := uint64(walEntryHeaderSize + 4096)
walSize := entrySize * 2 // tiny: 2 entries
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: walSize,
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
// Stop flusher.
v.flusher.Stop()
// Fill WAL.
for i := 0; i < 2; i++ {
if err := v.WriteLBA(uint64(i), makeBlock(byte('A'+i))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
// Start a writer that will block on WAL full.
writeResult := make(chan error, 1)
go func() {
writeResult <- v.WriteLBA(2, makeBlock('Z'))
}()
// Give it time to block.
time.Sleep(50 * time.Millisecond)
// Close the volume -- should unblock the writer.
v.Close()
select {
case err := <-writeResult:
// Writer unblocked (error expected: ErrWALFull or closed fd).
if err == nil {
t.Error("expected error from WriteLBA after Close, got nil")
}
t.Logf("close_during_pressure: writer unblocked with: %v", err)
case <-time.After(5 * time.Second):
t.Fatal("writer still blocked 5s after Close()")
}
}