Files
seaweedfs/weed/storage/blockvol/qa_phase4a_cp1_test.go
T
Ping QiuandClaude Opus 4.6 09c7e40d29 feat: Phase 4A CP4a -- simulated master, assignment sequence tests, BlockVolumeStatus
Add SimulatedMaster test helper + 20 assignment sequence tests (8 sequence,
5 failover, 5 adversarial, 2 status). Add BlockVolumeStatus struct and
Status() method. Includes QA test files for CP1-CP4a. 940 total unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:33:45 -08:00

1199 lines
30 KiB
Go

package blockvol
import (
"errors"
"math"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// TestQAPhase4ACP1 tests Phase 4A CP1 fencing primitives adversarially:
// epoch, lease, role state machine, write gate, and full integration.
func TestQAPhase4ACP1(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T)
}{
// QA-4A-1: Epoch Adversarial
{name: "epoch_concurrent_set_epoch", run: testQAEpochConcurrentSetEpoch},
{name: "epoch_set_during_write", run: testQAEpochSetDuringWrite},
{name: "epoch_max_uint64", run: testQAEpochMaxUint64},
{name: "epoch_reopen_after_concurrent_sets", run: testQAEpochReopenAfterConcurrentSets},
// QA-4A-2: Lease Adversarial
{name: "lease_grant_zero_ttl", run: testQALeaseGrantZeroTTL},
{name: "lease_grant_negative_ttl", run: testQALeaseGrantNegativeTTL},
{name: "lease_concurrent_grant_revoke", run: testQALeaseConcurrentGrantRevoke},
{name: "lease_expiry_boundary_1ms", run: testQALeaseExpiryBoundary1ms},
{name: "lease_revoke_during_write", run: testQALeaseRevokeDuringWrite},
// QA-4A-3: Role State Machine Adversarial
{name: "role_concurrent_transitions", run: testQARoleConcurrentTransitions},
{name: "role_rapid_cycle_100x", run: testQARoleRapidCycle100x},
{name: "role_callback_panic_recovery", run: testQARoleCallbackPanicRecovery},
{name: "role_callback_slow_blocks_transition", run: testQARoleCallbackSlowBlocksTransition},
{name: "role_unknown_value", run: testQARoleUnknownValue},
// QA-4A-4: Write Gate Adversarial
{name: "gate_role_change_during_write", run: testQAGateRoleChangeDuringWrite},
{name: "gate_epoch_bump_during_write", run: testQAGateEpochBumpDuringWrite},
{name: "gate_lease_expire_during_write", run: testQAGateLeaseExpireDuringWrite},
{name: "gate_concurrent_100_writers_role_flip", run: testQAGateConcurrent100WritersRoleFlip},
{name: "gate_gotcha_a_lease_expires_after_wal_before_sync", run: testQAGateGotchaA},
// QA-4A-5: Integration Adversarial
{name: "fencing_full_cycle_primary_to_stale", run: testQAFencingFullCycle},
{name: "fencing_writes_rejected_after_demotion", run: testQAFencingWritesRejectedAfterDemotion},
{name: "fencing_read_always_works", run: testQAFencingReadAlwaysWorks},
{name: "fencing_close_during_role_transition", run: testQAFencingCloseDuringRoleTransition},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
}
}
// --- helpers ---
// createFencedVol creates a blockvol configured as Primary with matching epochs and a valid lease.
func createFencedVol(t *testing.T, opts ...CreateOptions) *BlockVol {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "fenced.blockvol")
o := CreateOptions{
VolumeSize: 1 * 1024 * 1024, // 1MB
BlockSize: 4096,
WALSize: 256 * 1024, // 256KB
}
if len(opts) > 0 {
o = opts[0]
}
cfg := DefaultConfig()
cfg.FlushInterval = 5 * time.Millisecond
v, err := CreateBlockVol(path, o, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
// Set up fencing: epoch=1, masterEpoch=1, role=Primary, lease=10s.
if err := v.SetEpoch(1); err != nil {
t.Fatalf("SetEpoch: %v", err)
}
v.SetMasterEpoch(1)
if err := v.SetRole(RolePrimary); err != nil {
t.Fatalf("SetRole(Primary): %v", err)
}
v.lease.Grant(10 * time.Second)
return v
}
// --- QA-4A-1: Epoch Adversarial ---
func testQAEpochConcurrentSetEpoch(t *testing.T) {
// BUG-4A-1 + BUG-4A-3: concurrent SetEpoch + SetMasterEpoch + WriteLBA.
// Under -race, expects data race on v.epoch / v.masterEpoch (plain uint64).
v := createFencedVol(t)
defer v.Close()
const goroutines = 8
const iterations = 50
var wg sync.WaitGroup
// Epoch/masterEpoch writers.
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
epoch := uint64(id*iterations + i + 1)
_ = v.SetEpoch(epoch)
v.SetMasterEpoch(epoch)
}
}(g)
}
// Concurrent writer.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < iterations*2; i++ {
_ = v.WriteLBA(uint64(i%256), makeBlock(byte('A'+i%26)))
}
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("concurrent SetEpoch + WriteLBA hung for 10s")
}
// Verify: superblock is readable and epoch is a valid value (not corrupted).
finalEpoch := v.Epoch()
if finalEpoch == 0 || finalEpoch > uint64(goroutines*iterations+1) {
t.Errorf("epoch = %d, want [1, %d]", finalEpoch, goroutines*iterations+1)
}
f, err := os.Open(v.Path())
if err != nil {
t.Fatalf("open for verify: %v", err)
}
defer f.Close()
sb, err := ReadSuperblock(f)
if err != nil {
t.Fatalf("ReadSuperblock after concurrent sets: %v (superblock corrupted)", err)
}
if err := sb.Validate(); err != nil {
t.Fatalf("superblock validation failed: %v", err)
}
t.Logf("final superblock epoch: %d", sb.Epoch)
}
func testQAEpochSetDuringWrite(t *testing.T) {
// BUG-4A-1: WriteLBA reads v.epoch (plain uint64) while SetEpoch writes it.
v := createFencedVol(t)
defer v.Close()
const writeIter = 200
const epochBumps = 100
var wg sync.WaitGroup
// Writer loop.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < writeIter; i++ {
_ = v.WriteLBA(uint64(i%256), makeBlock(byte('W')))
}
}()
// Epoch bumper.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < epochBumps; i++ {
epoch := uint64(i + 2)
_ = v.SetEpoch(epoch)
v.SetMasterEpoch(epoch)
time.Sleep(100 * time.Microsecond)
}
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("epoch_set_during_write hung for 10s")
}
// Close and reopen -- verify no corruption and epoch in expected range.
path := v.Path()
v.Close()
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("reopen: %v (superblock may be corrupted)", err)
}
defer v2.Close()
e := v2.Epoch()
if e < 2 || e > uint64(epochBumps+1) {
t.Errorf("reopened epoch = %d, want [2, %d]", e, epochBumps+1)
}
t.Logf("reopened epoch: %d", e)
}
func testQAEpochMaxUint64(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Set both epochs to MaxUint64.
if err := v.SetEpoch(math.MaxUint64); err != nil {
t.Fatalf("SetEpoch(MaxUint64): %v", err)
}
v.SetMasterEpoch(math.MaxUint64)
if v.Epoch() != math.MaxUint64 {
t.Errorf("Epoch() = %d, want MaxUint64", v.Epoch())
}
// WriteLBA should succeed (epochs match, role=Primary, lease valid).
if err := v.WriteLBA(0, makeBlock('M')); err != nil {
t.Fatalf("WriteLBA at MaxUint64 epoch: %v", err)
}
// Verify read.
data, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if data[0] != 'M' {
t.Errorf("data[0] = %c, want M", data[0])
}
// Close, reopen, verify epoch persisted.
path := v.Path()
v.Close()
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer v2.Close()
if v2.Epoch() != math.MaxUint64 {
t.Errorf("reopened Epoch() = %d, want MaxUint64", v2.Epoch())
}
}
func testQAEpochReopenAfterConcurrentSets(t *testing.T) {
// BUG-4A-3: concurrent SetEpoch -> close -> reopen -> verify no corruption.
dir := t.TempDir()
path := filepath.Join(dir, "epoch_reopen.blockvol")
cfg := DefaultConfig()
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)
}
const goroutines = 4
const iterations = 50
validEpochs := make(map[uint64]bool)
for g := 0; g < goroutines; g++ {
for i := 0; i < iterations; i++ {
validEpochs[uint64(g*iterations+i+1)] = true
}
}
var wg sync.WaitGroup
wg.Add(goroutines)
for g := 0; g < goroutines; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
_ = v.SetEpoch(uint64(id*iterations + i + 1))
}
}(g)
}
wg.Wait()
v.Close()
// Reopen and verify epoch is one of the valid values.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("reopen after concurrent sets: %v", err)
}
defer v2.Close()
e := v2.Epoch()
if !validEpochs[e] && e != 0 {
t.Errorf("reopened epoch %d is not a valid epoch from any goroutine", e)
}
// Also verify superblock raw decode works.
f, err := os.Open(path)
if err != nil {
t.Fatalf("open raw: %v", err)
}
defer f.Close()
sb, err := ReadSuperblock(f)
if err != nil {
t.Fatalf("raw ReadSuperblock: %v", err)
}
if err := sb.Validate(); err != nil {
t.Fatalf("superblock validation: %v", err)
}
t.Logf("reopened epoch: %d, superblock valid", e)
}
// --- QA-4A-2: Lease Adversarial ---
func testQALeaseGrantZeroTTL(t *testing.T) {
// BUG-4A-5: Grant(0) stores time.Now() -- immediately expired.
var l Lease
l.Grant(0)
if l.IsValid() {
t.Error("Grant(0): IsValid() = true, want false (immediately expired)")
}
// Grant(1ns) -- may or may not be valid, but must not panic.
l.Grant(time.Nanosecond)
_ = l.IsValid() // just verify no panic
}
func testQALeaseGrantNegativeTTL(t *testing.T) {
// BUG-4A-5: Grant(-1s) stores past time -- always expired.
var l Lease
l.Grant(-1 * time.Second)
if l.IsValid() {
t.Error("Grant(-1s): IsValid() = true, want false")
}
// Verify WriteLBA returns ErrLeaseExpired with a negative-TTL lease.
v := createFencedVol(t)
defer v.Close()
v.lease.Grant(-1 * time.Second)
err := v.WriteLBA(0, makeBlock('N'))
if !errors.Is(err, ErrLeaseExpired) {
t.Errorf("WriteLBA with negative lease: got %v, want ErrLeaseExpired", err)
}
}
func testQALeaseConcurrentGrantRevoke(t *testing.T) {
// Concurrent Grant + Revoke + IsValid: no panic, no data race.
var l Lease
const granters = 4
const revokers = 4
const duration = 500 * time.Millisecond
var wg sync.WaitGroup
stop := make(chan struct{})
// Granters.
wg.Add(granters)
for i := 0; i < granters; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.Grant(1 * time.Second)
}
}
}()
}
// Revokers.
wg.Add(revokers)
for i := 0; i < revokers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
l.Revoke()
}
}
}()
}
// IsValid checker.
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
_ = l.IsValid()
}
}
}()
time.Sleep(duration)
close(stop)
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("concurrent grant/revoke hung for 5s")
}
}
func testQALeaseExpiryBoundary1ms(t *testing.T) {
var l Lease
l.Grant(1 * time.Millisecond)
// Busy-poll until expired.
deadline := time.After(50 * time.Millisecond)
for l.IsValid() {
select {
case <-deadline:
t.Fatal("lease still valid after 50ms (granted 1ms)")
default:
// spin
}
}
// Now verify WriteLBA fails.
v := createFencedVol(t)
defer v.Close()
v.lease.Grant(1 * time.Millisecond)
time.Sleep(5 * time.Millisecond) // ensure expired
err := v.WriteLBA(0, makeBlock('E'))
if !errors.Is(err, ErrLeaseExpired) {
t.Errorf("WriteLBA after 1ms lease expiry: got %v, want ErrLeaseExpired", err)
}
}
func testQALeaseRevokeDuringWrite(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Write some data first.
for i := 0; i < 50; i++ {
if err := v.WriteLBA(uint64(i), makeBlock(byte('A'+i%26))); err != nil {
t.Fatalf("pre-revoke WriteLBA(%d): %v", i, err)
}
}
// Revoke lease.
v.lease.Revoke()
// Subsequent writes must fail.
err := v.WriteLBA(50, makeBlock('Z'))
if !errors.Is(err, ErrLeaseExpired) {
t.Errorf("post-revoke WriteLBA: got %v, want ErrLeaseExpired", err)
}
// Reads should still work (ReadLBA doesn't check writeGate).
for i := 0; i < 50; i++ {
data, err := v.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("post-revoke ReadLBA(%d): %v", i, err)
}
if data[0] != byte('A'+i%26) {
t.Errorf("LBA %d: data[0] = %c, want %c", i, data[0], byte('A'+i%26))
}
}
}
// --- QA-4A-3: Role State Machine Adversarial ---
func testQARoleConcurrentTransitions(t *testing.T) {
// BUG-4A-2: TOCTOU in SetRole. Two goroutines both see old=Stale,
// both pass validation, both Store. One should fail (ideally).
v := createFencedVol(t)
defer v.Close()
// Get to Stale: Primary -> Draining -> Stale.
if err := v.SetRole(RoleDraining); err != nil {
t.Fatalf("SetRole(Draining): %v", err)
}
if err := v.SetRole(RoleStale); err != nil {
t.Fatalf("SetRole(Stale): %v", err)
}
// Now from Stale, both Rebuilding and Replica are valid transitions.
var wg sync.WaitGroup
var errA, errB error
wg.Add(2)
go func() {
defer wg.Done()
errA = v.SetRole(RoleRebuilding)
}()
go func() {
defer wg.Done()
errB = v.SetRole(RoleReplica)
}()
wg.Wait()
// With CAS fix: exactly one must succeed, one must get ErrInvalidRoleTransition.
// If both succeed, CAS was reverted to Load+Store (BUG-4A-2 regression).
finalRole := v.Role()
t.Logf("concurrent transitions from Stale: errA=%v errB=%v finalRole=%s",
errA, errB, finalRole)
if errA == nil && errB == nil {
t.Error("BUG-4A-2 REGRESSION: both transitions succeeded -- CAS not working")
}
if errA != nil && errB != nil {
t.Error("both transitions failed -- unexpected, at least one should succeed")
}
if finalRole != RoleRebuilding && finalRole != RoleReplica {
t.Errorf("final role = %s, want Rebuilding or Replica", finalRole)
}
}
func testQARoleRapidCycle100x(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
var callbackCount atomic.Int64
v.SetRoleCallback(func(old, new Role) {
callbackCount.Add(1)
})
// Full cycle: Primary->Draining->Stale->Rebuilding->Replica->Primary
cycle := []Role{RoleDraining, RoleStale, RoleRebuilding, RoleReplica, RolePrimary}
const rounds = 100
expectedCallbacks := int64(0)
for r := 0; r < rounds; r++ {
for _, target := range cycle {
if err := v.SetRole(target); err != nil {
t.Fatalf("round %d: SetRole(%s): %v", r, target, err)
}
expectedCallbacks++
}
}
if v.Role() != RolePrimary {
t.Errorf("final role = %s, want Primary", v.Role())
}
got := callbackCount.Load()
if got != expectedCallbacks {
t.Errorf("callback count = %d, want %d", got, expectedCallbacks)
}
t.Logf("rapid cycle: %d rounds, %d callbacks", rounds, got)
}
func testQARoleCallbackPanicRecovery(t *testing.T) {
// BUG-4A-6 FIXED: callback panic is now recovered by safeCallback.
v := createFencedVol(t)
defer v.Close()
v.SetRoleCallback(func(old, new Role) {
panic("callback panic!")
})
// SetRole(Draining) -- callback panics but safeCallback recovers it.
// SetRole should return nil (CAS succeeded), not propagate the panic.
err := v.SetRole(RoleDraining)
if err != nil {
t.Fatalf("SetRole(Draining) returned error: %v (expected nil after panic recovery)", err)
}
// Role should be Draining (CAS happened before callback).
if v.Role() != RoleDraining {
t.Errorf("role after recovered panic = %s, want Draining", v.Role())
}
}
func testQARoleCallbackSlowBlocksTransition(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
callbackDone := make(chan struct{})
v.SetRoleCallback(func(old, new Role) {
time.Sleep(100 * time.Millisecond)
close(callbackDone)
})
// SetRole in goroutine (will be slow due to callback).
transitionDone := make(chan error, 1)
go func() {
transitionDone <- v.SetRole(RoleDraining)
}()
// WriteLBA checks role atomically. Since CAS stores the new role before
// callback runs, writes must see Draining after a short wait.
time.Sleep(10 * time.Millisecond) // let CAS+Store happen
err := v.WriteLBA(0, makeBlock('S'))
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("write during slow callback: got %v, want ErrNotPrimary (role should already be Draining)", err)
}
// Wait for callback to complete.
select {
case <-callbackDone:
case <-time.After(5 * time.Second):
t.Fatal("slow callback hung for 5s")
}
select {
case err := <-transitionDone:
if err != nil {
t.Errorf("SetRole(Draining): %v", err)
}
case <-time.After(5 * time.Second):
t.Fatal("SetRole hung for 5s")
}
}
func testQARoleUnknownValue(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Force an unknown role value (255) via atomic store.
v.role.Store(255)
// writeGate should reject (255 != RolePrimary).
err := v.writeGate()
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("writeGate with role=255: got %v, want ErrNotPrimary", err)
}
// SetRole(Primary) from unknown role -- not in transition table.
err = v.SetRole(RolePrimary)
if !errors.Is(err, ErrInvalidRoleTransition) {
t.Errorf("SetRole(Primary) from role=255: got %v, want ErrInvalidRoleTransition", err)
}
// WriteLBA should also fail.
err = v.WriteLBA(0, makeBlock('U'))
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("WriteLBA with role=255: got %v, want ErrNotPrimary", err)
}
}
// --- QA-4A-4: Write Gate Adversarial ---
func testQAGateRoleChangeDuringWrite(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
const writers = 16
const writesPerGoroutine = 50
var wg sync.WaitGroup
var succeeded, rejected atomic.Int64
// Many concurrent writers.
wg.Add(writers)
for g := 0; g < writers; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < writesPerGoroutine; i++ {
lba := uint64((id*writesPerGoroutine + i) % 256)
err := v.WriteLBA(lba, makeBlock(byte('A'+id%26)))
if err == nil {
succeeded.Add(1)
} else if errors.Is(err, ErrNotPrimary) {
rejected.Add(1)
}
// other errors (WAL full, etc.) are ok
}
}(g)
}
// After 10ms, flip role.
time.Sleep(10 * time.Millisecond)
if err := v.SetRole(RoleDraining); err != nil {
t.Logf("SetRole(Draining): %v", err)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("gate_role_change_during_write hung for 10s")
}
t.Logf("role flip: %d succeeded, %d rejected (ErrNotPrimary)",
succeeded.Load(), rejected.Load())
// After transition, ALL new writes must fail.
err := v.WriteLBA(0, makeBlock('X'))
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("write after Draining: got %v, want ErrNotPrimary", err)
}
}
func testQAGateEpochBumpDuringWrite(t *testing.T) {
// BUG-4A-1: data race on v.epoch reads in writeGate/WriteLBA vs SetEpoch writes.
// Use large WAL + short WALFullTimeout to avoid writers blocking on WAL-full.
dir := t.TempDir()
path := filepath.Join(dir, "epoch_bump.blockvol")
cfg := DefaultConfig()
cfg.FlushInterval = 5 * time.Millisecond
cfg.WALFullTimeout = 200 * time.Millisecond
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 1 * 1024 * 1024, // 1MB WAL -- plenty of room
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
defer v.Close()
// Set up fencing.
if err := v.SetEpoch(1); err != nil {
t.Fatalf("SetEpoch: %v", err)
}
v.SetMasterEpoch(1)
if err := v.SetRole(RolePrimary); err != nil {
t.Fatalf("SetRole: %v", err)
}
v.lease.Grant(10 * time.Second)
const writers = 8
const iterations = 30
var wg sync.WaitGroup
// Writers.
wg.Add(writers)
for g := 0; g < writers; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
_ = v.WriteLBA(uint64((id*iterations+i)%256), makeBlock(byte('A'+id%26)))
}
}(g)
}
// Epoch bumper: set epoch=2, masterEpoch=2.
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(5 * time.Millisecond)
_ = v.SetEpoch(2)
v.SetMasterEpoch(2)
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("gate_epoch_bump_during_write hung for 10s")
}
// After bump, both epochs are 2 -- writes must succeed.
if v.Epoch() != 2 {
t.Errorf("Epoch() = %d after bump, want 2", v.Epoch())
}
if writeErr := v.WriteLBA(0, makeBlock('Z')); writeErr != nil {
t.Errorf("write after epoch bump: %v (epochs should match)", writeErr)
}
}
func testQAGateLeaseExpireDuringWrite(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Short lease -- write in a loop that spans the expiry window.
v.lease.Grant(10 * time.Millisecond)
var succeeded, expired int
deadline := time.After(200 * time.Millisecond)
i := 0
loop:
for {
select {
case <-deadline:
break loop
default:
}
writeErr := v.WriteLBA(uint64(i%256), makeBlock(byte('W')))
if writeErr == nil {
succeeded++
} else if errors.Is(writeErr, ErrLeaseExpired) {
expired++
}
i++
time.Sleep(500 * time.Microsecond)
}
t.Logf("lease expire: %d succeeded, %d expired (out of %d)", succeeded, expired, i)
if expired == 0 {
t.Error("no writes expired -- lease didn't expire in time?")
}
if succeeded == 0 {
t.Error("no writes succeeded -- lease expired immediately?")
}
// Verify monotonicity: once expired, no more successes.
v.lease.Grant(0) // force expired
for j := 0; j < 10; j++ {
writeErr := v.WriteLBA(uint64(j), makeBlock('X'))
if !errors.Is(writeErr, ErrLeaseExpired) {
t.Errorf("write %d after forced expiry: got %v, want ErrLeaseExpired", j, writeErr)
}
}
}
func testQAGateConcurrent100WritersRoleFlip(t *testing.T) {
// Use large WAL + short timeout to avoid writers blocking on WAL-full.
dir := t.TempDir()
path := filepath.Join(dir, "100writers.blockvol")
cfg := DefaultConfig()
cfg.FlushInterval = 5 * time.Millisecond
cfg.WALFullTimeout = 200 * time.Millisecond
vol, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 2 * 1024 * 1024, // 2MB WAL
}, cfg)
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
if err := vol.SetEpoch(1); err != nil {
t.Fatalf("SetEpoch: %v", err)
}
vol.SetMasterEpoch(1)
if err := vol.SetRole(RolePrimary); err != nil {
t.Fatalf("SetRole: %v", err)
}
vol.lease.Grant(10 * time.Second)
v := vol
defer v.Close()
const writers = 100
const blocksPerWriter = 10
var wg sync.WaitGroup
var succeeded, notPrimary atomic.Int64
// 100 writers.
wg.Add(writers)
for g := 0; g < writers; g++ {
go func(id int) {
defer wg.Done()
for i := 0; i < blocksPerWriter; i++ {
lba := uint64((id*blocksPerWriter + i) % 256)
err := v.WriteLBA(lba, makeBlock(byte('A'+id%26)))
if err == nil {
succeeded.Add(1)
} else if errors.Is(err, ErrNotPrimary) {
notPrimary.Add(1)
}
}
}(g)
}
// Flip role mid-flight.
time.Sleep(50 * time.Millisecond)
_ = v.SetRole(RoleDraining)
time.Sleep(50 * time.Millisecond)
_ = v.SetRole(RoleStale)
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("100 writers + role flip hung for 10s")
}
s, np := succeeded.Load(), notPrimary.Load()
t.Logf("100 writers: %d succeeded, %d ErrNotPrimary", s, np)
// Verify data integrity for successful writes: each block is readable.
// We can't know which exact writes succeeded, but ReadLBA should not error.
for i := 0; i < 256; i++ {
_, err := v.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("ReadLBA(%d) after stress: %v", i, err)
}
}
}
func testQAGateGotchaA(t *testing.T) {
// Gotcha A: lease expires between WAL append and SyncCache.
// Data IS in WAL, but SyncCache returns ErrLeaseExpired.
v := createFencedVol(t)
defer v.Close()
// Grant a short lease.
v.lease.Grant(50 * time.Millisecond)
// Write data (succeeds -- lease still valid).
if err := v.WriteLBA(0, makeBlock('G')); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Wait for lease to expire.
time.Sleep(100 * time.Millisecond)
// SyncCache must fail -- PostSyncCheck calls writeGate, which checks lease.
err := v.SyncCache()
if !errors.Is(err, ErrLeaseExpired) {
t.Errorf("SyncCache after lease expiry: got %v, want ErrLeaseExpired (Gotcha A)", err)
}
// Key verification: data IS readable from dirty map / WAL.
data, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA after Gotcha A: %v", err)
}
if data[0] != 'G' {
t.Errorf("data[0] = %c, want G (data should be in WAL/dirty map)", data[0])
}
t.Log("Gotcha A: data in WAL, SyncCache fenced, ReadLBA returns written data")
}
// --- QA-4A-5: Integration Adversarial ---
func testQAFencingFullCycle(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Write 100 blocks as Primary.
for i := 0; i < 100; i++ {
if err := v.WriteLBA(uint64(i), makeBlock(byte('A'+i%26))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache: %v", err)
}
// Verify all 100 blocks.
for i := 0; i < 100; 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: data[0] = %c, want %c", i, data[0], byte('A'+i%26))
}
}
// Primary -> Draining -- writes should fail.
if err := v.SetRole(RoleDraining); err != nil {
t.Fatalf("SetRole(Draining): %v", err)
}
if err := v.WriteLBA(0, makeBlock('X')); !errors.Is(err, ErrNotPrimary) {
t.Errorf("write as Draining: got %v, want ErrNotPrimary", err)
}
// Draining -> Stale -- writes rejected.
if err := v.SetRole(RoleStale); err != nil {
t.Fatalf("SetRole(Stale): %v", err)
}
if err := v.WriteLBA(0, makeBlock('X')); !errors.Is(err, ErrNotPrimary) {
t.Errorf("write as Stale: got %v, want ErrNotPrimary", err)
}
// Bump epoch, transition through Rebuilding -> Replica.
if err := v.SetEpoch(2); err != nil {
t.Fatalf("SetEpoch(2): %v", err)
}
v.SetMasterEpoch(2)
if err := v.SetRole(RoleRebuilding); err != nil {
t.Fatalf("SetRole(Rebuilding): %v", err)
}
if err := v.WriteLBA(0, makeBlock('X')); !errors.Is(err, ErrNotPrimary) {
t.Errorf("write as Rebuilding: got %v, want ErrNotPrimary", err)
}
if err := v.SetRole(RoleReplica); err != nil {
t.Fatalf("SetRole(Replica): %v", err)
}
if err := v.WriteLBA(0, makeBlock('X')); !errors.Is(err, ErrNotPrimary) {
t.Errorf("write as Replica: got %v, want ErrNotPrimary", err)
}
// Replica -> Primary with fresh lease -- writes succeed again.
if err := v.SetRole(RolePrimary); err != nil {
t.Fatalf("SetRole(Primary): %v", err)
}
v.lease.Grant(10 * time.Second)
if err := v.WriteLBA(200, makeBlock('N')); err != nil {
t.Fatalf("write as new Primary: %v", err)
}
// Original 100 blocks still readable.
for i := 0; i < 100; i++ {
data, err := v.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("final ReadLBA(%d): %v", i, err)
}
if data[0] != byte('A'+i%26) {
t.Errorf("final LBA %d: data[0] = %c, want %c", i, data[0], byte('A'+i%26))
}
}
t.Log("full cycle: Primary->Draining->Stale->Rebuilding->Replica->Primary complete")
}
func testQAFencingWritesRejectedAfterDemotion(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Write initial data.
if err := v.WriteLBA(0, makeBlock('D')); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache: %v", err)
}
// Revoke lease -> ErrLeaseExpired.
v.lease.Revoke()
err := v.WriteLBA(1, makeBlock('X'))
if !errors.Is(err, ErrLeaseExpired) {
t.Errorf("after Revoke: got %v, want ErrLeaseExpired", err)
}
// Re-grant lease for role checks, then demote.
v.lease.Grant(10 * time.Second)
// Primary -> Draining -> ErrNotPrimary.
if err := v.SetRole(RoleDraining); err != nil {
t.Fatalf("SetRole(Draining): %v", err)
}
err = v.WriteLBA(1, makeBlock('X'))
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("after Draining: got %v, want ErrNotPrimary", err)
}
// Draining -> Stale -> ErrNotPrimary.
if err := v.SetRole(RoleStale); err != nil {
t.Fatalf("SetRole(Stale): %v", err)
}
err = v.WriteLBA(1, makeBlock('X'))
if !errors.Is(err, ErrNotPrimary) {
t.Errorf("after Stale: got %v, want ErrNotPrimary", err)
}
// Verify: LBA 0 has original data, LBA 1 was never written.
data, readErr := v.ReadLBA(0, 4096)
if readErr != nil {
t.Fatalf("ReadLBA(0): %v", readErr)
}
if data[0] != 'D' {
t.Errorf("LBA 0: data[0] = %c, want D", data[0])
}
}
func testQAFencingReadAlwaysWorks(t *testing.T) {
v := createFencedVol(t)
defer v.Close()
// Write data as Primary.
for i := 0; i < 10; i++ {
if err := v.WriteLBA(uint64(i), makeBlock(byte('R'+i%6))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
verifyReads := func(label string) {
for i := 0; i < 10; i++ {
data, err := v.ReadLBA(uint64(i), 4096)
if err != nil {
t.Fatalf("[%s] ReadLBA(%d): %v", label, i, err)
}
if data[0] != byte('R'+i%6) {
t.Errorf("[%s] LBA %d: data[0] = %c, want %c", label, i, data[0], byte('R'+i%6))
}
}
}
// Reads work as Primary.
verifyReads("Primary")
// Draining.
if err := v.SetRole(RoleDraining); err != nil {
t.Fatalf("SetRole(Draining): %v", err)
}
verifyReads("Draining")
// Stale.
if err := v.SetRole(RoleStale); err != nil {
t.Fatalf("SetRole(Stale): %v", err)
}
verifyReads("Stale")
// Rebuilding.
if err := v.SetRole(RoleRebuilding); err != nil {
t.Fatalf("SetRole(Rebuilding): %v", err)
}
verifyReads("Rebuilding")
// Replica.
if err := v.SetRole(RoleReplica); err != nil {
t.Fatalf("SetRole(Replica): %v", err)
}
verifyReads("Replica")
// Expired lease -- reads still work.
v.lease.Revoke()
verifyReads("expired_lease")
// Stale epoch -- reads still work.
v.SetMasterEpoch(999)
verifyReads("stale_epoch")
t.Log("reads work in all roles, with expired lease and stale epoch")
}
func testQAFencingCloseDuringRoleTransition(t *testing.T) {
v := createFencedVol(t)
// Start some writes.
for i := 0; i < 20; i++ {
_ = v.WriteLBA(uint64(i), makeBlock(byte('C')))
}
// Concurrent Close + SetRole.
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_ = v.Close()
}()
go func() {
defer wg.Done()
_ = v.SetRole(RoleDraining)
}()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
t.Log("Close + SetRole concurrent: no deadlock, no panic")
case <-time.After(5 * time.Second):
t.Fatal("Close + SetRole deadlocked for 5s")
}
}