feat: Phase 3 — performance tuning, iSCSI session refactor, store integration

Phase 3 delivers five checkpoints:

CP1 Engine Tuning: BlockVolConfig tunables, 256-shard DirtyMap, adaptive
group commit (low-watermark immediate flush), WAL pressure handling with
backpressure and ErrWALFull timeout.

CP2 iSCSI Session Refactor: RX/TX goroutine split with respCh (cap 64),
txLoop for serialized response writes, StatSN assignment modes. Login
phase stays single-goroutine; full-duplex after login.

CP3 Store Integration: BlockVolAdapter (iscsi.BlockDevice interface),
BlockVolumeStore management, BlockService in volume_server_block.go,
CLI flags (--block.listen/dir/iqn.prefix), sw-block-attach.sh helper.

CP5 Concurrency Hardening: WAL reuse guard (LSN validation in ReadLBA),
opsOutstanding counter with beginOp/endOp + Close drain, appendWithRetry
shared by WriteLBA and TrimLBA, flusher LSN guard in FlushOnce.

Bug fixes (P3-BUG-2–11): unbounded pending queue cap, Data-Out timeout,
flusher error logging, GroupCommitter panic recovery, Close vs concurrent
ops guard, target shutdown race, WAL-full retry vs Close, WRITE SAME(16)
for XFS, MODE SENSE(10) + VPD 0xB0/0xB2 for Linux kernel compatibility.

797 tests passing (517 engine + 280 iSCSI), go vet clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ping QiuandClaude Opus 4.6 committed 2026-03-01 10:43:34 -08:00
1 parent 9b7be60b0c
commit 80801b0fac
37 files changed
+6569 -461

No files matched your search

+297
View File
@@ -21,6 +21,15 @@ func TestGroupCommitter(t *testing.T) {
{name: "group_sequential", run: testGroupSequential},
{name: "group_shutdown", run: testGroupShutdown},
{name: "group_submit_after_stop", run: testGroupSubmitAfterStop},
// Phase 3 Task 1.4: Adaptive group commit.
{name: "adaptive_single_immediate", run: testAdaptiveSingleImmediate},
{name: "adaptive_batch_above_watermark", run: testAdaptiveBatchAboveWatermark},
{name: "adaptive_max_batch_triggers_early", run: testAdaptiveMaxBatchTriggersEarly},
{name: "adaptive_watermark_zero_compat", run: testAdaptiveWatermarkZeroCompat},
{name: "adaptive_threshold_boundary", run: testAdaptiveThresholdBoundary},
{name: "adaptive_ramp_up", run: testAdaptiveRampUp},
{name: "adaptive_max_delay_honored", run: testAdaptiveMaxDelayHonored},
{name: "adaptive_fsync_error_all_waiters", run: testAdaptiveFsyncErrorAllWaiters},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -262,6 +271,294 @@ func testGroupShutdown(t *testing.T) {
}
}
// --- Phase 3 Task 1.4: Adaptive group commit tests ---
func testAdaptiveSingleImmediate(t *testing.T) {
// With lowWatermark=4, a single Submit should flush immediately (no delay).
var syncCalls atomic.Uint64
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
syncCalls.Add(1)
return nil
},
MaxDelay: 5 * time.Second, // very long delay
LowWatermark: 4,
})
go gc.Run()
defer gc.Stop()
start := time.Now()
if err := gc.Submit(); err != nil {
t.Fatalf("Submit: %v", err)
}
elapsed := time.Since(start)
if syncCalls.Load() != 1 {
t.Errorf("syncCalls = %d, want 1", syncCalls.Load())
}
// Should complete much faster than 5s maxDelay.
if elapsed > 500*time.Millisecond {
t.Errorf("Submit took %v, expected immediate (low watermark skip)", elapsed)
}
}
func testAdaptiveBatchAboveWatermark(t *testing.T) {
// With lowWatermark=4 and a slow sync, 10 concurrent submits should batch.
var syncCalls atomic.Uint64
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
syncCalls.Add(1)
time.Sleep(5 * time.Millisecond) // slow sync so requests queue up
return nil
},
MaxDelay: 50 * time.Millisecond,
MaxBatch: 64,
LowWatermark: 4,
})
go gc.Run()
defer gc.Stop()
const n = 10
var wg sync.WaitGroup
// Pre-enqueue all requests, then start Run.
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
gc.Submit()
}()
}
wg.Wait()
// With slow sync, many should batch. At most n fsyncs (no batching), at least 1.
c := syncCalls.Load()
if c == 0 {
t.Error("syncCalls = 0, want > 0")
}
t.Logf("adaptive batch: %d submits, %d fsyncs", n, c)
}
func testAdaptiveMaxBatchTriggersEarly(t *testing.T) {
// maxBatch should still trigger immediate flush even with adaptive watermark.
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error { return nil },
MaxDelay: 5 * time.Second,
MaxBatch: 8,
LowWatermark: 4,
})
go gc.Run()
defer gc.Stop()
var wg sync.WaitGroup
wg.Add(8)
for i := 0; i < 8; i++ {
go func() {
defer wg.Done()
gc.Submit()
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("maxBatch=8 did not trigger flush with adaptive watermark")
}
}
func testAdaptiveWatermarkZeroCompat(t *testing.T) {
// LowWatermark=0 should preserve Phase 2 behavior (always wait for delay).
var syncCalls atomic.Uint64
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
syncCalls.Add(1)
return nil
},
MaxDelay: 10 * time.Millisecond,
LowWatermark: 0,
})
go gc.Run()
defer gc.Stop()
// Two sequential submits should each trigger their own fsync.
if err := gc.Submit(); err != nil {
t.Fatalf("Submit 1: %v", err)
}
if err := gc.Submit(); err != nil {
t.Fatalf("Submit 2: %v", err)
}
if c := syncCalls.Load(); c != 2 {
t.Errorf("syncCalls = %d, want 2 (no watermark optimization)", c)
}
}
func testAdaptiveThresholdBoundary(t *testing.T) {
// Exactly lowWatermark concurrent submits: should batch and flush.
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 = 4 // exactly lowWatermark
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
gc.Submit()
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All 4 should complete. SyncCount should be >= 1.
if c := syncCalls.Load(); c == 0 {
t.Error("syncCalls = 0, want >= 1")
}
case <-time.After(2 * time.Second):
t.Fatal("threshold boundary: timed out waiting for 4 submits")
}
}
func testAdaptiveRampUp(t *testing.T) {
// Start with single callers (below watermark -> immediate), then ramp up.
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()
// Phase 1: single serial submits (below watermark, immediate).
for i := 0; i < 5; i++ {
if err := gc.Submit(); err != nil {
t.Fatalf("serial Submit %d: %v", i, err)
}
}
serialSyncs := syncCalls.Load()
if serialSyncs != 5 {
t.Errorf("serial phase: syncCalls = %d, want 5 (1 per call)", serialSyncs)
}
// Phase 2: burst 64 concurrent submits (above watermark, batched).
var wg sync.WaitGroup
wg.Add(64)
for i := 0; i < 64; i++ {
go func() {
defer wg.Done()
gc.Submit()
}()
}
wg.Wait()
totalSyncs := syncCalls.Load()
batchSyncs := totalSyncs - serialSyncs
// 64 submits should complete without deadlock. Some batching expected
// but fast syncFunc means each call may flush individually.
if batchSyncs > 64 {
t.Errorf("batch phase: %d fsyncs for 64 submits, want <= 64", batchSyncs)
}
t.Logf("ramp_up: serial=%d, batch=%d fsyncs for 64 submits", serialSyncs, batchSyncs)
}
func testAdaptiveMaxDelayHonored(t *testing.T) {
// 2 concurrent submits (below watermark) should still complete promptly.
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error { return nil },
MaxDelay: 5 * time.Second,
LowWatermark: 4,
})
go gc.Run()
defer gc.Stop()
var wg sync.WaitGroup
wg.Add(2)
for i := 0; i < 2; i++ {
go func() {
defer wg.Done()
gc.Submit()
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Good: completed without waiting 5s maxDelay (adaptive skip).
case <-time.After(2 * time.Second):
t.Fatal("2 submits should complete quickly (below watermark), took >2s")
}
}
func testAdaptiveFsyncErrorAllWaiters(t *testing.T) {
// Inject fsync error during batched flush: all waiters must receive the error.
errIO := errors.New("simulated disk error")
var degradedCalled atomic.Bool
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error {
return errIO
},
MaxDelay: 50 * time.Millisecond,
MaxBatch: 64,
LowWatermark: 4,
OnDegraded: func() { degradedCalled.Store(true) },
})
go gc.Run()
defer gc.Stop()
const n = 8
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)
}
wg.Wait()
for i, err := range errs {
if !errors.Is(err, errIO) {
t.Errorf("Submit[%d]: got %v, want %v", i, err, errIO)
}
}
if !degradedCalled.Load() {
t.Error("OnDegraded was not called")
}
}
func testGroupSubmitAfterStop(t *testing.T) {
gc := NewGroupCommitter(GroupCommitterConfig{
SyncFunc: func() error { return nil },