mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-01 13:47:51 +00:00
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>
493 lines
12 KiB
Go
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()")
|
|
}
|
|
}
|