Files
seaweedfs/weed/storage/blockvol/blockvol_test.go
T
Ping QiuandClaude Opus 4.6 6a400f6760 feat: add BlockVol engine and iSCSI target (Phase 1 + Phase 2)
Phase 1: Extent-mapped block storage engine with WAL, crash recovery,
dirty map, flusher, and group commit. 174 tests, zero SeaweedFS imports.

Phase 2: Pure Go iSCSI target (RFC 7143) with PDU codec, login
negotiation, SendTargets discovery, 12 SCSI opcodes, Data-In/Out/R2T
sequencing, session management, and standalone iscsi-target binary.
164 tests. IQN->BlockDevice binding via DeviceLookup interface.

Total: 338 tests, 14.6K lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:32:01 -08:00

686 lines
17 KiB
Go

package blockvol
import (
"bytes"
"encoding/binary"
"errors"
"os"
"path/filepath"
"testing"
)
func TestBlockVol(t *testing.T) {
tests := []struct {
name string
run func(t *testing.T)
}{
{name: "write_then_read", run: testWriteThenRead},
{name: "overwrite_read_latest", run: testOverwriteReadLatest},
{name: "write_no_sync_not_durable", run: testWriteNoSyncNotDurable},
{name: "write_sync_durable", run: testWriteSyncDurable},
{name: "write_multiple_sync", run: testWriteMultipleSync},
{name: "read_unflushed", run: testReadUnflushed},
{name: "read_flushed", run: testReadFlushed},
{name: "read_mixed_dirty_clean", run: testReadMixedDirtyClean},
{name: "wal_read_corrupt_length", run: testWALReadCorruptLength},
{name: "open_invalid_superblock", run: testOpenInvalidSuperblock},
{name: "trim_large_length_read_returns_zero", run: testTrimLargeLengthReadReturnsZero},
// Task 1.10: Lifecycle tests.
{name: "lifecycle_create_close_reopen", run: testLifecycleCreateCloseReopen},
{name: "lifecycle_close_flushes_dirty", run: testLifecycleCloseFlushes},
{name: "lifecycle_double_close", run: testLifecycleDoubleClose},
{name: "lifecycle_info", run: testLifecycleInfo},
{name: "lifecycle_write_sync_close_reopen", run: testLifecycleWriteSyncCloseReopen},
// Task 1.11: Crash stress test.
{name: "crash_stress_100", run: testCrashStress100},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.run(t)
})
}
}
func createTestVol(t *testing.T) *BlockVol {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.blockvol")
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024, // 1MB
BlockSize: 4096,
WALSize: 256 * 1024, // 256KB WAL
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
return v
}
func makeBlock(fill byte) []byte {
b := make([]byte, 4096)
for i := range b {
b[i] = fill
}
return b
}
func testWriteThenRead(t *testing.T) {
v := createTestVol(t)
defer v.Close()
data := makeBlock('A')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("read data does not match written data")
}
}
func testOverwriteReadLatest(t *testing.T) {
v := createTestVol(t)
defer v.Close()
if err := v.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatalf("WriteLBA(A): %v", err)
}
if err := v.WriteLBA(0, makeBlock('B')); err != nil {
t.Fatalf("WriteLBA(B): %v", err)
}
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if !bytes.Equal(got, makeBlock('B')) {
t.Error("read should return latest write ('B'), not 'A'")
}
}
func testWriteNoSyncNotDurable(t *testing.T) {
v := createTestVol(t)
path := v.Path()
if err := v.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Simulate crash: close fd without sync.
v.fd.Close()
// Reopen -- without recovery (Phase 1.9), data MAY be lost.
// This test just verifies we can reopen without error.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol after crash: %v", err)
}
defer v2.Close()
// Data may or may not be present -- both are correct without SyncCache.
}
func testWriteSyncDurable(t *testing.T) {
v := createTestVol(t)
path := v.Path()
data := makeBlock('A')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Sync WAL (manual fsync for now, group commit in Task 1.7).
if err := v.wal.Sync(); err != nil {
t.Fatalf("Sync: %v", err)
}
// Update superblock WALHead so reopen knows where entries are.
v.super.WALHead = v.wal.LogicalHead()
v.super.WALCheckpointLSN = 0
if _, err := v.fd.Seek(0, 0); err != nil {
t.Fatalf("Seek: %v", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
t.Fatalf("WriteTo: %v", err)
}
v.fd.Sync()
v.fd.Close()
// Reopen and manually replay WAL to verify data is durable.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol: %v", err)
}
defer v2.Close()
// Manually replay: read WAL entry and populate dirty map.
replayBuf := make([]byte, v2.super.WALHead)
if _, err := v2.fd.ReadAt(replayBuf, int64(v2.super.WALOffset)); err != nil {
t.Fatalf("read WAL for replay: %v", err)
}
entry, err := DecodeWALEntry(replayBuf)
if err != nil {
t.Fatalf("decode WAL entry: %v", err)
}
blocks := entry.Length / v2.super.BlockSize
for i := uint32(0); i < blocks; i++ {
v2.dirtyMap.Put(entry.LBA+uint64(i), 0, entry.LSN, v2.super.BlockSize)
}
got, err := v2.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA after recovery: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("data not durable after sync + reopen")
}
}
func testWriteMultipleSync(t *testing.T) {
v := createTestVol(t)
defer v.Close()
// Write 10 blocks with different data.
for i := uint64(0); i < 10; i++ {
if err := v.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
// Read all back.
for i := uint64(0); i < 10; i++ {
got, err := v.ReadLBA(i, 4096)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
expected := makeBlock(byte('A' + i))
if !bytes.Equal(got, expected) {
t.Errorf("block %d: data mismatch", i)
}
}
}
func testReadUnflushed(t *testing.T) {
v := createTestVol(t)
defer v.Close()
data := makeBlock('X')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Read before flusher runs -- should come from dirty map / WAL.
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("unflushed read: data mismatch")
}
}
func testReadFlushed(t *testing.T) {
v := createTestVol(t)
defer v.Close()
data := makeBlock('F')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Manually flush: copy WAL data to extent region, clear dirty map.
extentStart := v.super.WALOffset + v.super.WALSize
if _, err := v.fd.WriteAt(data, int64(extentStart)); err != nil {
t.Fatalf("manual flush write: %v", err)
}
v.dirtyMap.Delete(0)
// Read should now come from extent region.
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA after flush: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("flushed read: data mismatch")
}
}
func testReadMixedDirtyClean(t *testing.T) {
v := createTestVol(t)
defer v.Close()
// Write blocks 0, 2, 4 (dirty).
for _, lba := range []uint64{0, 2, 4} {
if err := v.WriteLBA(lba, makeBlock(byte('A'+lba))); err != nil {
t.Fatalf("WriteLBA(%d): %v", lba, err)
}
}
// Manually flush block 0 to extent, remove from dirty map.
extentStart := v.super.WALOffset + v.super.WALSize
if _, err := v.fd.WriteAt(makeBlock('A'), int64(extentStart)); err != nil {
t.Fatalf("manual flush: %v", err)
}
v.dirtyMap.Delete(0)
// Block 0: from extent (flushed)
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA(0): %v", err)
}
if !bytes.Equal(got, makeBlock('A')) {
t.Error("block 0 (flushed) mismatch")
}
// Block 2: from dirty map (WAL)
got, err = v.ReadLBA(2, 4096)
if err != nil {
t.Fatalf("ReadLBA(2): %v", err)
}
if !bytes.Equal(got, makeBlock('C')) { // 'A'+2 = 'C'
t.Error("block 2 (dirty) mismatch")
}
// Block 4: from dirty map (WAL)
got, err = v.ReadLBA(4, 4096)
if err != nil {
t.Fatalf("ReadLBA(4): %v", err)
}
if !bytes.Equal(got, makeBlock('E')) { // 'A'+4 = 'E'
t.Error("block 4 (dirty) mismatch")
}
// Blocks 1, 3, 5: never written, should be zeros (from extent region).
for _, lba := range []uint64{1, 3, 5} {
got, err = v.ReadLBA(lba, 4096)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", lba, err)
}
if !bytes.Equal(got, make([]byte, 4096)) {
t.Errorf("block %d (unwritten) should be zeros", lba)
}
}
}
func testWALReadCorruptLength(t *testing.T) {
v := createTestVol(t)
defer v.Close()
// Write a valid block.
if err := v.WriteLBA(0, makeBlock('A')); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Get the WAL offset from dirty map.
walOff, _, _, ok := v.dirtyMap.Get(0)
if !ok {
t.Fatal("block 0 not in dirty map")
}
// Corrupt the Length field in the WAL entry on disk.
// Length is at header offset 26 (LSN=8 + Epoch=8 + Type=1 + Flags=1 + LBA=8).
absOff := int64(v.super.WALOffset + walOff)
lengthOff := absOff + 26
var hugeLenBuf [4]byte
binary.LittleEndian.PutUint32(hugeLenBuf[:], 999999999) // ~1GB
if _, err := v.fd.WriteAt(hugeLenBuf[:], lengthOff); err != nil {
t.Fatalf("corrupt length: %v", err)
}
// ReadLBA should detect the corrupt length and error (not panic/OOM).
_, err := v.ReadLBA(0, 4096)
if err == nil {
t.Error("expected error reading corrupt WAL entry, got nil")
}
}
func testOpenInvalidSuperblock(t *testing.T) {
dir := t.TempDir()
// Create a valid volume, then corrupt BlockSize to 0.
path := filepath.Join(dir, "corrupt.blockvol")
v, err := CreateBlockVol(path, CreateOptions{VolumeSize: 1024 * 1024})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
v.Close()
// Corrupt BlockSize (at superblock offset 36: Magic=4 + Version=2 + Flags=2 + UUID=16 + VolumeSize=8 + ExtentSize=4 = 36).
fd, err := os.OpenFile(path, os.O_RDWR, 0644)
if err != nil {
t.Fatalf("open for corrupt: %v", err)
}
var zeroBuf [4]byte
if _, err := fd.WriteAt(zeroBuf[:], 36); err != nil {
fd.Close()
t.Fatalf("corrupt blocksize: %v", err)
}
fd.Close()
// OpenBlockVol should reject the corrupt superblock.
_, err = OpenBlockVol(path)
if err == nil {
t.Fatal("expected error opening volume with BlockSize=0")
}
if !errors.Is(err, ErrInvalidSuperblock) {
t.Errorf("expected ErrInvalidSuperblock, got: %v", err)
}
}
// --- Task 1.10: Lifecycle tests ---
func testLifecycleCreateCloseReopen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lifecycle.blockvol")
// Create, write, close.
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
data := makeBlock('L')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache: %v", err)
}
if err := v.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Reopen and verify data survived.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol: %v", err)
}
defer v2.Close()
got, err := v2.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA: %v", err)
}
if !bytes.Equal(got, data) {
t.Error("data not durable after create→write→sync→close→reopen")
}
}
func testLifecycleCloseFlushes(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "flush.blockvol")
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
// Write several blocks.
for i := uint64(0); i < 5; i++ {
if err := v.WriteLBA(i, makeBlock(byte('A'+i))); err != nil {
t.Fatalf("WriteLBA(%d): %v", i, err)
}
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache: %v", err)
}
// Close does a final flush — dirty map should be drained.
if err := v.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Reopen and verify.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol: %v", err)
}
defer v2.Close()
for i := uint64(0); i < 5; i++ {
got, err := v2.ReadLBA(i, 4096)
if err != nil {
t.Fatalf("ReadLBA(%d): %v", i, err)
}
expected := makeBlock(byte('A' + i))
if !bytes.Equal(got, expected) {
t.Errorf("block %d: data mismatch after close+reopen", i)
}
}
}
func testLifecycleDoubleClose(t *testing.T) {
v := createTestVol(t)
if err := v.Close(); err != nil {
t.Fatalf("first Close: %v", err)
}
// Second close should not panic (group committer + flusher are idempotent).
// The fd.Close() will return an error but should not panic.
_ = v.Close()
}
func testLifecycleInfo(t *testing.T) {
v := createTestVol(t)
defer v.Close()
info := v.Info()
if info.VolumeSize != 1*1024*1024 {
t.Errorf("VolumeSize = %d, want %d", info.VolumeSize, 1*1024*1024)
}
if info.BlockSize != 4096 {
t.Errorf("BlockSize = %d, want 4096", info.BlockSize)
}
if !info.Healthy {
t.Error("Healthy = false, want true")
}
}
func testLifecycleWriteSyncCloseReopen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wsco.blockvol")
// Cycle: create → write → sync → close → reopen → write → sync → close → verify.
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: 1 * 1024 * 1024,
BlockSize: 4096,
WALSize: 256 * 1024,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
if err := v.WriteLBA(0, makeBlock('X')); err != nil {
t.Fatalf("WriteLBA round 1: %v", err)
}
if err := v.SyncCache(); err != nil {
t.Fatalf("SyncCache round 1: %v", err)
}
v.Close()
// Reopen, write more.
v2, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol round 2: %v", err)
}
if err := v2.WriteLBA(1, makeBlock('Y')); err != nil {
t.Fatalf("WriteLBA round 2: %v", err)
}
// Overwrite block 0.
if err := v2.WriteLBA(0, makeBlock('Z')); err != nil {
t.Fatalf("WriteLBA overwrite: %v", err)
}
if err := v2.SyncCache(); err != nil {
t.Fatalf("SyncCache round 2: %v", err)
}
v2.Close()
// Final reopen — verify.
v3, err := OpenBlockVol(path)
if err != nil {
t.Fatalf("OpenBlockVol round 3: %v", err)
}
defer v3.Close()
got0, _ := v3.ReadLBA(0, 4096)
if !bytes.Equal(got0, makeBlock('Z')) {
t.Error("block 0: expected 'Z' (overwritten)")
}
got1, _ := v3.ReadLBA(1, 4096)
if !bytes.Equal(got1, makeBlock('Y')) {
t.Error("block 1: expected 'Y'")
}
}
// --- Task 1.11: Crash stress test ---
func testCrashStress100(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "stress.blockvol")
const (
volumeSize = 256 * 1024 // 256KB volume (64 blocks of 4KB)
blockSize = 4096
walSize = 64 * 1024 // 64KB WAL
maxLBA = volumeSize / blockSize
iterations = 100
)
// Oracle: tracks the expected state of each block.
oracle := make(map[uint64]byte) // lba → fill byte (0 = zeros/trimmed)
// Create initial volume.
v, err := CreateBlockVol(path, CreateOptions{
VolumeSize: volumeSize,
BlockSize: blockSize,
WALSize: walSize,
})
if err != nil {
t.Fatalf("CreateBlockVol: %v", err)
}
for iter := 0; iter < iterations; iter++ {
// Deterministic "random" ops using iteration number.
numOps := 3 + (iter % 5) // 3-7 ops per iteration
for op := 0; op < numOps; op++ {
lba := uint64((iter*7 + op*13) % maxLBA)
action := (iter + op) % 3 // 0=write, 1=overwrite, 2=trim
switch action {
case 0, 1: // write
fill := byte('A' + (iter+op)%26)
err := v.WriteLBA(lba, makeBlock(fill))
if err != nil {
if errors.Is(err, ErrWALFull) {
continue // WAL full, skip this op
}
t.Fatalf("iter %d op %d: WriteLBA(%d): %v", iter, op, lba, err)
}
oracle[lba] = fill
case 2: // trim
err := v.Trim(lba, blockSize)
if err != nil {
if errors.Is(err, ErrWALFull) {
continue
}
t.Fatalf("iter %d op %d: Trim(%d): %v", iter, op, lba, err)
}
oracle[lba] = 0
}
}
// Sync WAL.
if err := v.SyncCache(); err != nil {
t.Fatalf("iter %d: SyncCache: %v", iter, err)
}
// Simulate crash: close fd without clean shutdown.
v.fd.Sync() // ensure WAL is on disk
// Write superblock with current WAL positions for recovery.
v.super.WALHead = v.wal.LogicalHead()
v.super.WALTail = v.wal.LogicalTail()
if _, seekErr := v.fd.Seek(0, 0); seekErr != nil {
t.Fatalf("iter %d: Seek: %v", iter, seekErr)
}
v.super.WriteTo(v.fd)
v.fd.Sync()
// Hard crash: close fd, stop goroutines.
v.groupCommit.Stop()
v.flusher.Stop()
v.fd.Close()
// Reopen with recovery.
v, err = OpenBlockVol(path)
if err != nil {
t.Fatalf("iter %d: OpenBlockVol: %v", iter, err)
}
// Verify oracle against actual reads.
for lba, fill := range oracle {
got, readErr := v.ReadLBA(lba, blockSize)
if readErr != nil {
t.Fatalf("iter %d: ReadLBA(%d): %v", iter, lba, readErr)
}
var expected []byte
if fill == 0 {
expected = make([]byte, blockSize)
} else {
expected = makeBlock(fill)
}
if !bytes.Equal(got, expected) {
t.Fatalf("iter %d: block %d mismatch: got[0]=%d want[0]=%d", iter, lba, got[0], expected[0])
}
}
}
v.Close()
}
func testTrimLargeLengthReadReturnsZero(t *testing.T) {
v := createTestVol(t)
defer v.Close()
// Write data first.
data := makeBlock('A')
if err := v.WriteLBA(0, data); err != nil {
t.Fatalf("WriteLBA: %v", err)
}
// Verify data is written.
got, err := v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA before trim: %v", err)
}
if !bytes.Equal(got, data) {
t.Fatal("data mismatch before trim")
}
// Trim with a length larger than WAL size — should still work.
// The trim Length is metadata (trim extent), not a data allocation.
if err := v.Trim(0, 4096); err != nil {
t.Fatalf("Trim: %v", err)
}
// Read should return zeros (TRIM entry in dirty map).
got, err = v.ReadLBA(0, 4096)
if err != nil {
t.Fatalf("ReadLBA after trim: %v", err)
}
if !bytes.Equal(got, make([]byte, 4096)) {
t.Error("read after trim should return zeros")
}
}