Files
seaweedfs/weed/storage/blockvol/blockvol.go
T
pingqiuandClaude Opus 4.6 df69c83f41 feat: RemoteRebuildIO — primary coordinates rebuild, replica installs
Replace the broken primary-local rebuild executor with RemoteRebuildIO,
a server-side engine.RebuildIO implementation that coordinates remotely.
The primary sends SessionControlV2 (with RebuildAddr trailer) to the
replica's control channel; the replica starts a local rebuild session
and auto-connects to the primary's rebuild server for the base lane.

Single rebuild route: ALL core-present rebuilds use RemoteRebuildIO.
The entire command chain is preserved unchanged:
  PlanRebuild → pending → RebuildStarted → StartRebuildCommand
  → ExecutePendingRebuild → RemoteRebuildIO.TransferFullBase

Key changes:
- SessionControlMsg v2: optional RebuildAddr trailer (len-based decode)
- ReplicaRebuilding shipper state: session-gated live WAL lane
- RemoteRebuildIO: dials replica ctrl, sends session control, reads acks
- Ack forwarding through ObserveReplicaRebuildSessionAck (pins/watchdog)
- Completion proof from replica's achievedLSN, not primary's local vol
- Transport failures emit SessionFailed (no double-emit on ack failures)
- Progress ack rejection fails closed (stale session = abort)
- Replica auto-starts base lane client on v2 session control

State transitions:
  NeedsRebuild → [accepted ack] → Rebuilding → [completed] → InSync
  Rebuilding → [failed/EOF] → NeedsRebuild → [next probe] → retry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:04:22 -07:00

2217 lines
70 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package blockvol implements a block volume storage engine with WAL,
// dirty map, group commit, and crash recovery. It operates on a single
// file and has no dependency on SeaweedFS internals.
package blockvol
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/seaweedfs/seaweedfs/weed/storage/blockvol/batchio"
)
// CreateOptions configures a new block volume.
type CreateOptions struct {
VolumeSize uint64 // required, logical size in bytes
ExtentSize uint32 // default 64KB
BlockSize uint32 // default 4KB
WALSize uint64 // default 64MB
Replication string // default "000"
DurabilityMode DurabilityMode // CP8-3-1: default best_effort (0)
StorageProfile StorageProfile // CP11A-1: default single (0)
}
// ErrVolumeClosed is returned when an operation is attempted on a closed BlockVol.
var ErrVolumeClosed = errors.New("blockvol: volume closed")
// BlockVol is the core block volume engine.
// walRetentionTimeout is the maximum time a recoverable replica can hold
// WAL entries. After this, the replica is escalated to NeedsRebuild and
// its WAL hold is released.
const walRetentionTimeout = 5 * time.Minute
// walRetentionMaxBytes is the maximum WAL bytes a recoverable replica can
// hold before being escalated to NeedsRebuild. When primaryHeadLSN -
// replicaFlushedLSN exceeds this (measured in WAL byte lag), the replica's
// hold is released. CP13-6: bounded max-bytes retention budget.
const walRetentionMaxBytes uint64 = 64 * 1024 * 1024 // 64MB default
type BlockVol struct {
mu sync.RWMutex
ioMu sync.RWMutex // guards local data mutation (WAL/dirtyMap/extent); Lock for restore/import/expand
superMu sync.Mutex // serializes superblock mutation + persist (group commit vs flusher)
shipMu sync.Mutex // serializes ShipAll calls to guarantee LSN-order delivery
fd *os.File
path string
super Superblock
config BlockVolConfig
wal *WALWriter
dirtyMap *DirtyMap
groupCommit *GroupCommitter
flusher *Flusher
nextLSN atomic.Uint64
healthy atomic.Bool
closed atomic.Bool
opsOutstanding atomic.Int64 // in-flight Read/Write/Trim/SyncCache ops
opsDrained chan struct{}
// Replication fields (Phase 4A CP2, generalized to N replicas in CP8-2).
shipperGroup *ShipperGroup
replRecv *ReplicaReceiver
// Fencing fields (Phase 4A).
epoch atomic.Uint64 // current persisted epoch
masterEpoch atomic.Uint64 // expected epoch from master
lease Lease
role atomic.Uint32
roleCallback RoleChangeCallback
// Promotion/rebuild fields (Phase 4A CP3).
rebuildServer *RebuildServer
assignMu sync.Mutex // serializes HandleAssignment calls
drainTimeout time.Duration // default 10s, for demote drain
rebuildSessMu sync.RWMutex
rebuildSess *RebuildSession
// Health score and scrub (CP8-2).
healthScore *HealthScore
scrubber *Scrubber
// Write admission control (BUG-CP103-2).
walAdmission *WALAdmission
// Observability (CP8-4).
Metrics *EngineMetrics
// Shipper state change callback — triggers immediate heartbeat.
onShipperStateChange func(from, to ReplicaState)
// Barrier acceptance callback — reports authoritative durable progress after
// a successful SyncCache/group-commit fence.
onBarrierAccepted func(flushedLSN uint64)
// Barrier rejection callback — reports the semantic reason for a failed
// distributed durability fence.
onBarrierRejected func(reason string)
// Rebuild session ack callback — reports replica-local rebuild session
// progress/result facts for host-side routing to the primary.
onRebuildSessionAck func(SessionAckMsg)
// liveShippingPolicy gates whether configured shippers may consume current
// live-tail WAL entries. The host uses this to keep replicas in bounded
// catch-up until their active protocol session reaches a live-eligible phase.
liveShippingPolicy func(replicaID string, entryLSN uint64) (allow bool, reason string)
// Snapshot fields (Phase 5 CP5-2).
snapMu sync.RWMutex
snapshots map[uint32]*activeSnapshot
}
// CreateBlockVol creates a new block volume file at path.
func CreateBlockVol(path string, opts CreateOptions, cfgs ...BlockVolConfig) (*BlockVol, error) {
var cfg BlockVolConfig
if len(cfgs) > 0 {
cfg = cfgs[0]
}
cfg.applyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
if opts.VolumeSize == 0 {
return nil, ErrInvalidVolumeSize
}
if opts.StorageProfile == ProfileStriped {
return nil, ErrStripedNotImplemented
}
if opts.StorageProfile > ProfileStriped {
return nil, fmt.Errorf("%w: %d", ErrInvalidStorageProfile, opts.StorageProfile)
}
sb, err := NewSuperblock(opts.VolumeSize, opts)
if err != nil {
return nil, fmt.Errorf("blockvol: create superblock: %w", err)
}
sb.CreatedAt = uint64(time.Now().Unix())
// Extent region starts after superblock + WAL.
extentStart := sb.WALOffset + sb.WALSize
totalFileSize := extentStart + opts.VolumeSize
fd, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0644)
if err != nil {
return nil, fmt.Errorf("blockvol: create file: %w", err)
}
if err := fd.Truncate(int64(totalFileSize)); err != nil {
fd.Close()
os.Remove(path)
return nil, fmt.Errorf("blockvol: truncate: %w", err)
}
if _, err := sb.WriteTo(fd); err != nil {
fd.Close()
os.Remove(path)
return nil, fmt.Errorf("blockvol: write superblock: %w", err)
}
if err := fd.Sync(); err != nil {
fd.Close()
os.Remove(path)
return nil, fmt.Errorf("blockvol: sync: %w", err)
}
dm := NewDirtyMap(cfg.DirtyMapShards)
wal := NewWALWriter(fd, sb.WALOffset, sb.WALSize, 0, 0)
v := &BlockVol{
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dm,
healthScore: NewHealthScore(),
Metrics: NewEngineMetrics(),
opsDrained: make(chan struct{}, 1),
snapshots: make(map[uint32]*activeSnapshot),
}
v.nextLSN.Store(1)
v.healthy.Store(true)
v.groupCommit = NewGroupCommitter(GroupCommitterConfig{
SyncFunc: v.fd.Sync,
MaxDelay: cfg.GroupCommitMaxDelay,
MaxBatch: cfg.GroupCommitMaxBatch,
LowWatermark: cfg.GroupCommitLowWatermark,
OnDegraded: func() { v.healthy.Store(false) },
PostSyncCheck: v.writeGate,
Metrics: v.Metrics,
})
go v.groupCommit.Run()
bio, _, err := newBatchIO(cfg.IOBackend, log.Default())
if err != nil {
fd.Close()
os.Remove(path)
return nil, fmt.Errorf("blockvol: %w", err)
}
v.flusher = NewFlusher(FlusherConfig{
FD: fd,
Super: &v.super,
SuperMu: &v.superMu,
WAL: wal,
DirtyMap: dm,
Interval: cfg.FlushInterval,
Metrics: v.Metrics,
BatchIO: bio,
// No keepup WAL retention: flusher recycles freely. If a replica
// falls behind and WAL entries are recycled, it escalates to
// NeedsRebuild — the correct outcome. Catch-up from extent via
// the LBA dirty map (V2) will eliminate this tension entirely.
// Session-only WAL pins (for active rebuild/catch-up) are handled
// separately by SetV2RetentionFloor.
RetentionFloorFn: nil,
EvaluateRetentionBudgetsFn: func() {
if v.shipperGroup != nil {
v.shipperGroup.EvaluateRetentionBudgets(RetentionBudgetParams{
Timeout: walRetentionTimeout,
MaxBytes: 0, // CP13-6 max-bytes disabled: uses replicaFlushedLSN which can't advance without barrier; v2 will replace with negotiated recovery protocol
PrimaryHeadLSN: v.nextLSN.Load() - 1,
BlockSize: v.super.BlockSize,
})
}
},
})
go v.flusher.Run()
v.walAdmission = NewWALAdmission(WALAdmissionConfig{
MaxConcurrent: cfg.WALMaxConcurrentWrites,
SoftWatermark: cfg.WALSoftWatermark,
HardWatermark: cfg.WALHardWatermark,
WALUsedFn: wal.UsedFraction,
NotifyFn: v.flusher.NotifyUrgent,
ClosedFn: v.closed.Load,
Metrics: v.Metrics,
})
return v, nil
}
// OpenBlockVol opens an existing block volume file and runs crash recovery.
func OpenBlockVol(path string, cfgs ...BlockVolConfig) (*BlockVol, error) {
var cfg BlockVolConfig
if len(cfgs) > 0 {
cfg = cfgs[0]
}
cfg.applyDefaults()
if err := cfg.Validate(); err != nil {
return nil, err
}
fd, err := os.OpenFile(path, os.O_RDWR, 0644)
if err != nil {
return nil, fmt.Errorf("blockvol: open file: %w", err)
}
sb, err := ReadSuperblock(fd)
if err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: read superblock: %w", err)
}
if err := sb.Validate(); err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: validate superblock: %w", err)
}
// CP11A-2: Clear stale prepared expand state on recovery.
if sb.PreparedSize != 0 {
log.Printf("blockvol: clearing stale PreparedSize=%d ExpandEpoch=%d on open (crash during prepare phase)", sb.PreparedSize, sb.ExpandEpoch)
sb.PreparedSize = 0
sb.ExpandEpoch = 0
if _, err := fd.Seek(0, 0); err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: seek for prepared clear: %w", err)
}
if _, err := sb.WriteTo(fd); err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: write superblock for prepared clear: %w", err)
}
if err := fd.Sync(); err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: sync for prepared clear: %w", err)
}
}
dirtyMap := NewDirtyMap(cfg.DirtyMapShards)
// Run WAL recovery: replay entries from tail to head.
result, err := RecoverWAL(fd, &sb, dirtyMap)
if err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: recovery: %w", err)
}
nextLSN := sb.WALCheckpointLSN + 1
if result.HighestLSN >= nextLSN {
nextLSN = result.HighestLSN + 1
}
wal := NewWALWriter(fd, sb.WALOffset, sb.WALSize, sb.WALHead, sb.WALTail)
v := &BlockVol{
fd: fd,
path: path,
super: sb,
config: cfg,
wal: wal,
dirtyMap: dirtyMap,
healthScore: NewHealthScore(),
Metrics: NewEngineMetrics(),
opsDrained: make(chan struct{}, 1),
}
v.nextLSN.Store(nextLSN)
v.epoch.Store(sb.Epoch)
v.healthy.Store(true)
v.groupCommit = NewGroupCommitter(GroupCommitterConfig{
SyncFunc: v.fd.Sync,
MaxDelay: cfg.GroupCommitMaxDelay,
MaxBatch: cfg.GroupCommitMaxBatch,
LowWatermark: cfg.GroupCommitLowWatermark,
OnDegraded: func() { v.healthy.Store(false) },
PostSyncCheck: v.writeGate,
Metrics: v.Metrics,
})
go v.groupCommit.Run()
bio, _, err := newBatchIO(cfg.IOBackend, log.Default())
if err != nil {
fd.Close()
return nil, fmt.Errorf("blockvol: %w", err)
}
v.flusher = NewFlusher(FlusherConfig{
FD: fd,
Super: &v.super,
SuperMu: &v.superMu,
WAL: wal,
DirtyMap: dirtyMap,
Interval: cfg.FlushInterval,
Metrics: v.Metrics,
BatchIO: bio,
// No keepup WAL retention (same as CreateBlockVol path).
RetentionFloorFn: nil,
EvaluateRetentionBudgetsFn: func() {
if v.shipperGroup != nil {
v.shipperGroup.EvaluateRetentionBudgets(RetentionBudgetParams{
Timeout: walRetentionTimeout,
MaxBytes: 0, // CP13-6 max-bytes disabled
PrimaryHeadLSN: v.nextLSN.Load() - 1,
BlockSize: v.super.BlockSize,
})
}
},
})
go v.flusher.Run()
// Discover and reopen existing snapshots.
v.snapshots = make(map[uint32]*activeSnapshot)
snapFiles, _ := filepath.Glob(path + ".snap.*")
for _, sf := range snapFiles {
snap, err := openDeltaFile(sf)
if err != nil {
log.Printf("blockvol: skipping snapshot file %s: %v", sf, err)
continue
}
if snap.header.ParentUUID != v.super.UUID {
log.Printf("blockvol: skipping snapshot %d: parent UUID mismatch", snap.id)
snap.Close()
continue
}
v.snapshots[snap.id] = snap
v.flusher.AddSnapshot(snap)
}
if len(v.snapshots) > 0 {
log.Printf("blockvol: recovered %d snapshot(s)", len(v.snapshots))
}
v.walAdmission = NewWALAdmission(WALAdmissionConfig{
MaxConcurrent: cfg.WALMaxConcurrentWrites,
SoftWatermark: cfg.WALSoftWatermark,
HardWatermark: cfg.WALHardWatermark,
WALUsedFn: wal.UsedFraction,
NotifyFn: v.flusher.NotifyUrgent,
ClosedFn: v.closed.Load,
Metrics: v.Metrics,
})
return v, nil
}
// syncWithWALProgress persists the WAL head pointer in the superblock.
// Called by the flusher during checkpoint (not on every group commit).
//
// The extended WAL recovery scan (RecoverWAL) makes this advisory:
// recovery scans past the recorded WALHead using CRC validation, so
// entries written after the last superblock persist are not lost.
// Persisting WALHead reduces recovery scan range but is not required
// for correctness.
func (v *BlockVol) syncWithWALProgress() error {
v.superMu.Lock()
defer v.superMu.Unlock()
// Update superblock WALHead from the live WAL writer.
// WALTail and CheckpointLSN are updated by the flusher, not here.
v.super.WALHead = v.wal.LogicalHead()
// Rewrite the full superblock at offset 0.
var buf bytes.Buffer
if _, err := v.super.WriteTo(&buf); err != nil {
return fmt.Errorf("blockvol: serialize superblock for WAL progress: %w", err)
}
if _, err := v.fd.WriteAt(buf.Bytes(), 0); err != nil {
return fmt.Errorf("blockvol: persist WAL progress: %w", err)
}
// Single fsync covers both WAL data and superblock.
return v.fd.Sync()
}
// beginOp increments the in-flight ops counter. Returns ErrVolumeClosed if
// the volume is already closed, so callers must not proceed.
func (v *BlockVol) beginOp() error {
v.opsOutstanding.Add(1)
if v.closed.Load() {
v.endOp()
return ErrVolumeClosed
}
return nil
}
// endOp decrements the in-flight ops counter and signals the drain channel
// if this was the last op and the volume is closing.
func (v *BlockVol) endOp() {
if v.opsOutstanding.Add(-1) == 0 && v.closed.Load() {
select {
case v.opsDrained <- struct{}{}:
default:
}
}
}
// appendWithRetry appends a WAL entry, retrying on WAL-full by triggering
// the flusher until the timeout expires.
func (v *BlockVol) appendWithRetry(entry *WALEntry) (uint64, error) {
walOff, err := v.wal.Append(entry)
if errors.Is(err, ErrWALFull) {
deadline := time.After(v.config.WALFullTimeout)
for errors.Is(err, ErrWALFull) {
if v.closed.Load() {
return 0, ErrVolumeClosed
}
v.flusher.NotifyUrgent()
select {
case <-deadline:
return 0, fmt.Errorf("blockvol: WAL full timeout: %w", ErrWALFull)
case <-time.After(1 * time.Millisecond):
}
walOff, err = v.wal.Append(entry)
}
}
if err != nil {
return 0, fmt.Errorf("blockvol: WAL append: %w", err)
}
// Re-check closed after retry: Close() may have freed WAL space
// while we were retrying, allowing Append to succeed.
if v.closed.Load() {
return 0, ErrVolumeClosed
}
// Ship to replicas if configured (fire-and-forget).
// LSN-order delivery is guaranteed by shipMu held from LSN allocation
// through shipping in WriteLBA/Trim.
if v.shipperGroup != nil {
v.shipperGroup.ShipAll(entry)
}
// Notify scrubber of written LBAs for CRC invalidation.
if v.scrubber != nil && entry.Type == EntryTypeWrite {
v.scrubber.NotifyWrite(entry.LBA, entry.Length/v.super.BlockSize)
}
return walOff, nil
}
// WriteLBA writes data at the given logical block address.
// Data length must be a multiple of BlockSize.
//
// Product contract: WriteLBA is write-back admission only. It appends to the
// local WAL and may start shipping the entry to replicas, but it returns
// BEFORE the group commit sync and BEFORE any replica barrier confirms
// durability. A successful return means the data is in the local WAL write
// buffer, NOT that it is durable on any node.
//
// For durability, callers must use SyncCache() (or the equivalent flush/FUA
// path). For sync_all mode, SyncCache triggers the distributed barrier and
// only returns success when all required replicas have durably confirmed.
//
// Do not treat WriteLBA returning nil as a durability or replication guarantee.
func (v *BlockVol) WriteLBA(lba uint64, data []byte) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.ioMu.RLock()
defer v.ioMu.RUnlock()
if err := v.writeGate(); err != nil {
return err
}
if err := ValidateWrite(lba, uint32(len(data)), v.super.VolumeSize, v.super.BlockSize); err != nil {
return err
}
// Admission control: throttle/block based on WAL pressure watermarks.
if v.walAdmission != nil {
if err := v.walAdmission.Acquire(v.config.WALFullTimeout); err != nil {
return fmt.Errorf("blockvol: write admission: %w", err)
}
defer v.walAdmission.Release()
}
// shipMu serializes LSN allocation + WAL append + Ship to guarantee
// LSN-order delivery to replicas under concurrent writes.
v.shipMu.Lock()
lsn := v.nextLSN.Add(1) - 1
entry := &WALEntry{
LSN: lsn,
Epoch: v.epoch.Load(),
Type: EntryTypeWrite,
LBA: lba,
Length: uint32(len(data)),
Data: data,
}
walOff, err := v.appendWithRetry(entry)
if err != nil {
v.shipMu.Unlock()
return err
}
v.shipMu.Unlock()
// Check WAL pressure and notify flusher if threshold exceeded.
if v.wal.UsedFraction() >= v.config.WALPressureThreshold {
v.flusher.NotifyUrgent()
}
// Update dirty map: one entry per block written.
blocks := uint32(len(data)) / v.super.BlockSize
for i := uint32(0); i < blocks; i++ {
blockOff := walOff // all blocks in this entry share the same WAL offset
v.dirtyMap.Put(lba+uint64(i), blockOff, lsn, v.super.BlockSize)
}
return nil
}
// ReadLBA reads data at the given logical block address.
// length is in bytes and must be a multiple of BlockSize.
func (v *BlockVol) ReadLBA(lba uint64, length uint32) ([]byte, error) {
if err := v.beginOp(); err != nil {
return nil, err
}
defer v.endOp()
v.ioMu.RLock()
defer v.ioMu.RUnlock()
if err := ValidateWrite(lba, length, v.super.VolumeSize, v.super.BlockSize); err != nil {
return nil, err
}
blocks := length / v.super.BlockSize
result := make([]byte, length)
for i := uint32(0); i < blocks; i++ {
blockLBA := lba + uint64(i)
blockData, err := v.readOneBlock(blockLBA)
if err != nil {
return nil, fmt.Errorf("blockvol: ReadLBA block %d: %w", blockLBA, err)
}
copy(result[i*v.super.BlockSize:], blockData)
}
return result, nil
}
// readOneBlock reads a single block, checking dirty map first, then extent.
func (v *BlockVol) readOneBlock(lba uint64) ([]byte, error) {
for {
walOff, lsn, _, ok := v.dirtyMap.Get(lba)
if !ok {
return v.readBlockFromExtent(lba)
}
data, stale, err := v.readBlockFromWAL(walOff, lba, lsn)
if err != nil {
return nil, err
}
if stale {
// WAL slot was reused. Extent may not have the latest: a newer write
// could have updated dirtyMap after we got our old entry. Re-check
// dirtyMap before trusting extent; if a newer entry exists, retry.
_, currentLSN, _, stillOk := v.dirtyMap.Get(lba)
if stillOk && currentLSN != lsn {
continue
}
return v.readBlockFromExtent(lba)
}
// Verify no newer write overwrote this LBA while we were reading.
// A concurrent WriteLBA could have Put(lba, walOff_new, lsn_new) after
// our Get; we would have read old data at walOff. Re-check and retry.
_, currentLSN, _, stillOk := v.dirtyMap.Get(lba)
if stillOk && currentLSN == lsn {
return data, nil
}
// LSN changed (newer write) or flusher removed: retry with fresh dirtyMap state.
continue
}
}
// maxWALEntryDataLen caps the data length we trust from a WAL entry header.
// Anything larger than the WAL region itself is corrupt.
const maxWALEntryDataLen = 256 * 1024 * 1024 // 256MB absolute ceiling
// readBlockFromWAL reads a block's data from its WAL entry.
// Returns (data, stale, err). If stale is true, the WAL slot was reused
// by a newer entry (flusher reclaimed it) and the caller should fall back
// to the extent region.
func (v *BlockVol) readBlockFromWAL(walOff uint64, lba uint64, expectedLSN uint64) ([]byte, bool, error) {
// Read the WAL entry header to get the full entry size.
headerBuf := make([]byte, walEntryHeaderSize)
absOff := int64(v.super.WALOffset + walOff)
if _, err := v.fd.ReadAt(headerBuf, absOff); err != nil {
return nil, false, fmt.Errorf("readBlockFromWAL: read header at %d: %w", absOff, err)
}
// WAL reuse guard: validate LSN before trusting the entry.
// If the flusher reclaimed this slot and a new write reused it,
// the LSN will differ --fall back to extent read.
entryLSN := binary.LittleEndian.Uint64(headerBuf[0:8])
if entryLSN != expectedLSN {
return nil, true, nil // stale --WAL slot reused
}
// Check entry type first --TRIM has no data payload, so Length is
// metadata (trim extent), not a data size to allocate.
entryType := headerBuf[16] // Type is at offset LSN(8) + Epoch(8) = 16
if entryType == EntryTypeTrim {
// TRIM entry: return zeros regardless of Length field.
return make([]byte, v.super.BlockSize), false, nil
}
if entryType != EntryTypeWrite {
// Unexpected type at a supposedly valid offset --treat as stale.
return nil, true, nil
}
// Parse and validate the data Length field before allocating (WRITE only).
dataLen := v.parseDataLength(headerBuf)
if uint64(dataLen) > v.super.WALSize || uint64(dataLen) > maxWALEntryDataLen {
// LSN matched but length is corrupt --real data integrity error.
return nil, false, fmt.Errorf("readBlockFromWAL: corrupt entry length %d exceeds WAL size %d", dataLen, v.super.WALSize)
}
entryLen := walEntryHeaderSize + int(dataLen)
fullBuf := make([]byte, entryLen)
if _, err := v.fd.ReadAt(fullBuf, absOff); err != nil {
return nil, false, fmt.Errorf("readBlockFromWAL: read entry at %d: %w", absOff, err)
}
entry, err := DecodeWALEntry(fullBuf)
if err != nil {
// LSN matched but CRC failed --real corruption.
return nil, false, fmt.Errorf("readBlockFromWAL: decode: %w", err)
}
// Final guard: verify the entry actually covers this LBA.
if lba < entry.LBA {
return nil, true, nil // stale --different entry at same offset
}
blockOffset := (lba - entry.LBA) * uint64(v.super.BlockSize)
if blockOffset+uint64(v.super.BlockSize) > uint64(len(entry.Data)) {
return nil, true, nil // stale --LBA out of range
}
block := make([]byte, v.super.BlockSize)
copy(block, entry.Data[blockOffset:blockOffset+uint64(v.super.BlockSize)])
return block, false, nil
}
// parseDataLength extracts the Length field from a WAL entry header buffer.
func (v *BlockVol) parseDataLength(headerBuf []byte) uint32 {
// Length is at offset: LSN(8) + Epoch(8) + Type(1) + Flags(1) + LBA(8) = 26
return binary.LittleEndian.Uint32(headerBuf[26:])
}
// readBlockFromExtent reads a block directly from the extent region.
func (v *BlockVol) readBlockFromExtent(lba uint64) ([]byte, error) {
extentStart := v.super.WALOffset + v.super.WALSize
byteOffset := extentStart + lba*uint64(v.super.BlockSize)
block := make([]byte, v.super.BlockSize)
if _, err := v.fd.ReadAt(block, int64(byteOffset)); err != nil {
return nil, fmt.Errorf("readBlockFromExtent: pread at %d: %w", byteOffset, err)
}
return block, nil
}
// Trim marks blocks as deallocated. Subsequent reads return zeros.
// The trim is recorded in the WAL with a Length field so the flusher
// can zero the extent region and recovery can replay the trim.
func (v *BlockVol) Trim(lba uint64, length uint32) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.ioMu.RLock()
defer v.ioMu.RUnlock()
if err := v.writeGate(); err != nil {
return err
}
if err := ValidateWrite(lba, length, v.super.VolumeSize, v.super.BlockSize); err != nil {
return err
}
// Admission control: throttle/block based on WAL pressure watermarks.
if v.walAdmission != nil {
if err := v.walAdmission.Acquire(v.config.WALFullTimeout); err != nil {
return fmt.Errorf("blockvol: trim admission: %w", err)
}
defer v.walAdmission.Release()
}
// shipMu serializes LSN allocation + WAL append + Ship (same as WriteLBA).
v.shipMu.Lock()
lsn := v.nextLSN.Add(1) - 1
entry := &WALEntry{
LSN: lsn,
Epoch: v.epoch.Load(),
Type: EntryTypeTrim,
LBA: lba,
Length: length,
}
walOff, err := v.appendWithRetry(entry)
if err != nil {
v.shipMu.Unlock()
return err
}
v.shipMu.Unlock()
// Update dirty map: mark each trimmed block so the flusher sees it.
// readOneBlock checks entry type and returns zeros for TRIM entries.
blocks := length / v.super.BlockSize
for i := uint32(0); i < blocks; i++ {
v.dirtyMap.Put(lba+uint64(i), walOff, lsn, v.super.BlockSize)
}
return nil
}
// Path returns the file path of the block volume.
func (v *BlockVol) Path() string {
return v.path
}
// Info returns volume metadata.
func (v *BlockVol) Info() VolumeInfo {
return VolumeInfo{
VolumeSize: v.super.VolumeSize,
BlockSize: v.super.BlockSize,
ExtentSize: v.super.ExtentSize,
WALSize: v.super.WALSize,
UUID: v.super.UUID,
Healthy: v.healthy.Load(),
}
}
// VolumeInfo contains read-only volume metadata.
type VolumeInfo struct {
VolumeSize uint64
BlockSize uint32
ExtentSize uint32
WALSize uint64
UUID [16]byte
Healthy bool
}
// DurabilityMode returns the volume's durability mode from the superblock.
func (v *BlockVol) DurabilityMode() DurabilityMode {
return DurabilityMode(v.super.DurabilityMode)
}
// Profile returns the volume's storage profile from the superblock.
func (v *BlockVol) Profile() StorageProfile {
return StorageProfile(v.super.StorageProfile)
}
// WALUsedFraction returns the fraction of WAL space currently in use (0.0 to 1.0).
func (v *BlockVol) WALUsedFraction() float64 {
if v.wal == nil {
return 0
}
return v.wal.UsedFraction()
}
// DirtyMapLen returns the number of entries in the dirty map.
func (v *BlockVol) DirtyMapLen() int {
if v.dirtyMap == nil {
return 0
}
return v.dirtyMap.Len()
}
// SyncCache ensures all previously written WAL entries are durable on disk.
// It submits a sync request to the group committer, which batches fsyncs.
//
// Product contract: SyncCache is the durability fence. For sync_all mode,
// the group committer's sync function calls MakeDistributedSync, which runs
// BarrierAll on all configured replicas in parallel with local fsync. Success
// means: local WAL is fsync'd AND all required replicas have durably
// confirmed via barrier response.
//
// This is the correct point for durability assertions and product guarantees.
// WriteLBA is write-back admission; SyncCache is the commit boundary.
func (v *BlockVol) SyncCache() error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.ioMu.RLock()
defer v.ioMu.RUnlock()
if err := v.groupCommit.Submit(); err != nil {
return err
}
if v.onBarrierAccepted != nil {
if flushedLSN, ok := v.currentDurableBoundary(); ok && flushedLSN > 0 {
v.onBarrierAccepted(flushedLSN)
}
}
return nil
}
// ReplicaAddr holds the data and control addresses for one replica.
type ReplicaAddr struct {
DataAddr string
CtrlAddr string
ServerID string // V2: stable server identity from registry (not address-derived)
}
// WALAccess provides the shipper with the minimal WAL interface needed
// for reconnect handshake and catch-up. Avoids exposing raw WAL internals.
type WALAccess interface {
// RetainedRange returns the current WAL retained LSN range.
// walRetainStart is the lowest LSN still in the WAL.
// primaryHeadLSN is the highest LSN written.
RetainedRange() (walRetainStart, primaryHeadLSN uint64)
// StreamEntries streams WAL entries from fromLSN to the current head.
// Calls fn for each entry. Returns ErrWALRecycled if fromLSN is no longer retained.
StreamEntries(fromLSN uint64, fn func(*WALEntry) error) error
}
// walAccess implements WALAccess for BlockVol.
type walAccess struct {
vol *BlockVol
}
func (a *walAccess) RetainedRange() (uint64, uint64) {
checkpointLSN := uint64(0)
if a.vol.flusher != nil {
checkpointLSN = a.vol.flusher.CheckpointLSN()
}
headLSN := a.vol.nextLSN.Load()
if headLSN > 0 {
headLSN--
}
// WAL retain start: entries before checkpoint are eligible for reclaim.
// The flusher may have already reclaimed them. Use checkpoint+1 as retain start.
retainStart := checkpointLSN + 1
return retainStart, headLSN
}
func (a *walAccess) StreamEntries(fromLSN uint64, fn func(*WALEntry) error) error {
checkpointLSN := uint64(0)
if a.vol.flusher != nil {
checkpointLSN = a.vol.flusher.CheckpointLSN()
}
return a.vol.wal.ScanFrom(a.vol.fd, a.vol.super.WALOffset, checkpointLSN, fromLSN, fn)
}
// SetOnShipperStateChange registers a callback for shipper state transitions.
// Called by the volume server to trigger immediate heartbeat on degradation/recovery.
func (v *BlockVol) SetOnShipperStateChange(fn func(from, to ReplicaState)) {
v.onShipperStateChange = fn
if v != nil && v.shipperGroup != nil {
v.shipperGroup.SetOnStateChange(fn)
}
}
// SetOnBarrierAccepted registers a callback invoked after SyncCache returns
// success with a positive durable boundary.
func (v *BlockVol) SetOnBarrierAccepted(fn func(flushedLSN uint64)) {
v.onBarrierAccepted = fn
}
// SetOnBarrierRejected registers a callback invoked when a distributed barrier
// fails and reports a stable semantic reason.
func (v *BlockVol) SetOnBarrierRejected(fn func(reason string)) {
v.onBarrierRejected = fn
if v != nil && v.shipperGroup != nil {
v.shipperGroup.SetOnBarrierFailure(fn)
}
}
// SetOnRebuildSessionAck registers a callback invoked whenever the local rebuild
// session produces a new host-visible progress/result fact.
func (v *BlockVol) SetOnRebuildSessionAck(fn func(SessionAckMsg)) {
if v == nil {
return
}
v.onRebuildSessionAck = fn
}
func (v *BlockVol) currentDurableBoundary() (uint64, bool) {
if v == nil {
return 0, false
}
if v.DurabilityMode() == DurabilitySyncAll && v.shipperGroup != nil && v.shipperGroup.Len() > 0 {
return v.shipperGroup.MinReplicaFlushedLSNAll()
}
headLSN := v.nextLSN.Load()
if headLSN == 0 {
return 0, false
}
return headLSN - 1, true
}
// SetLiveShippingPolicy installs a host-provided gate for current live-tail
// shipping. The callback is applied to the current shipper group and remembered
// for future SetReplicaAddrs() replacements.
func (v *BlockVol) SetLiveShippingPolicy(fn func(replicaID string, entryLSN uint64) (allow bool, reason string)) {
v.liveShippingPolicy = fn
if v.shipperGroup != nil {
v.shipperGroup.SetLiveShippingPolicy(fn)
}
}
// GetShipperGroup returns the shipper group for debug/observability.
// Returns nil if no replication is configured.
func (v *BlockVol) GetShipperGroup() *ShipperGroup {
return v.shipperGroup
}
// CatchUpReplicaTo replays retained WAL to the named replica up to targetLSN.
// It is the host/runtime entry point for bounded catch-up before live tail.
func (v *BlockVol) CatchUpReplicaTo(replicaID string, targetLSN uint64) (uint64, error) {
if v == nil || v.shipperGroup == nil {
return 0, fmt.Errorf("blockvol: shipper group not configured")
}
if replicaID == "" {
return 0, fmt.Errorf("blockvol: replica identity required for bounded catch-up")
}
return v.shipperGroup.CatchUpReplica(replicaID, targetLSN)
}
// SetReplicaAddr configures a single replica endpoint. Backward-compatible wrapper
// around SetReplicaAddrs for RF=2 callers.
//
// WARNING: This drops ServerID because it only takes dataAddr/ctrlAddr.
// Production code should use SetReplicaAddrs with explicit ReplicaAddr.ServerID
// to preserve stable replica identity for protocol-aware gating.
func (v *BlockVol) SetReplicaAddr(dataAddr, ctrlAddr string) {
v.SetReplicaAddrs([]ReplicaAddr{{DataAddr: dataAddr, CtrlAddr: ctrlAddr}})
}
// SetReplicaAddrs configures N replica endpoints and creates a ShipperGroup
// with distributed group commit. Creates fresh shippers (old group is GC'd).
//
// CP13-5: If the old shipper group had durable progress (any shipper was
// previously InSync), new shippers are seeded with hasFlushedProgress=true
// so they use the reconnect handshake + catch-up path instead of bare
// bootstrap. This is correct because the primary's WAL may contain entries
// the replica hasn't received yet (written during disconnect), and the
// handshake determines the exact gap to replay.
func (v *BlockVol) SetReplicaAddrs(addrs []ReplicaAddr) {
// CP13-5: Check if old group had durable progress before replacing.
hadPriorProgress := false
if v.shipperGroup != nil {
hadPriorProgress = v.shipperGroup.AnyHasFlushedProgress()
}
wa := &walAccess{vol: v}
shippers := make([]*WALShipper, len(addrs))
for i, a := range addrs {
shippers[i] = NewWALShipper(a.DataAddr, a.CtrlAddr, func() uint64 {
return v.epoch.Load()
}, wa, v.Metrics)
shippers[i].SetReplicaID(a.ServerID)
// CP13-5: Seed new shippers with prior progress so reconnect
// path is used instead of bootstrap.
if hadPriorProgress {
shippers[i].hasFlushedProgress.Store(true)
}
}
v.shipperGroup = NewShipperGroup(shippers)
// Wire state change callback so shipper degradation triggers immediate heartbeat.
if v.onShipperStateChange != nil {
v.shipperGroup.SetOnStateChange(v.onShipperStateChange)
}
if v.onBarrierRejected != nil {
v.shipperGroup.SetOnBarrierFailure(v.onBarrierRejected)
}
if v.liveShippingPolicy != nil {
v.shipperGroup.SetLiveShippingPolicy(v.liveShippingPolicy)
}
// Replace the group committer's sync function with a distributed version.
v.groupCommit.Stop()
v.groupCommit = NewGroupCommitter(GroupCommitterConfig{
SyncFunc: MakeDistributedSync(v.fd.Sync, v.shipperGroup, v),
MaxDelay: v.config.GroupCommitMaxDelay,
MaxBatch: v.config.GroupCommitMaxBatch,
LowWatermark: v.config.GroupCommitLowWatermark,
OnDegraded: func() { v.healthy.Store(false) },
PostSyncCheck: v.writeGate,
})
go v.groupCommit.Run()
}
// ReplicaShipperStates returns per-replica status from the primary's shipper group.
// Used by heartbeat to report which replicas need rebuild. Returns nil if no shippers.
func (v *BlockVol) ReplicaShipperStates() []ReplicaShipperStatus {
if v.shipperGroup == nil {
return nil
}
return v.shipperGroup.ShipperStates()
}
// IsClosed returns true if the volume has been closed.
func (v *BlockVol) IsClosed() bool {
return v == nil || v.closed.Load()
}
// ProbeReplicaOnboarding probes all configured shippers and returns per-replica
// results. Used by primary onboarding after assignment to decide
// keepup/catchup/rebuild for each replica.
func (v *BlockVol) ProbeReplicaOnboarding() []ReplicaProbeResult {
if v == nil || v.shipperGroup == nil {
return nil
}
return v.shipperGroup.ProbeReconnectAll()
}
// PrimaryShipperConnected reports whether all configured replica shippers have
// established transport contact for bootstrap/recovery observation. This is a
// transport-level signal only; barrier durability still gates publish_healthy.
func (v *BlockVol) PrimaryShipperConnected() bool {
if v == nil || v.shipperGroup == nil {
return false
}
return v.shipperGroup.AllHaveTransportContact()
}
// V2StatusSnapshot holds the storage state fields needed by the V2 engine bridge.
type V2StatusSnapshot struct {
WALHeadLSN uint64
WALTailLSN uint64
CommittedLSN uint64
CheckpointLSN uint64
CheckpointTrusted bool
}
// StatusSnapshot returns a snapshot of blockvol state for V2 engine consumption.
// Each field reads from the authoritative source:
//
// WALHeadLSN ← nextLSN - 1 (last written LSN)
// WALTailLSN ← super.WALCheckpointLSN (LSN boundary, not byte offset)
// CommittedLSN ← lineage-safe durable boundary
// CheckpointLSN ← super.WALCheckpointLSN (durable base image)
// CheckpointTrusted ← super.Validate() == nil (superblock integrity)
func (v *BlockVol) StatusSnapshot() V2StatusSnapshot {
headLSN := v.nextLSN.Load()
if headLSN > 0 {
headLSN--
}
// WALTailLSN: the oldest retained LSN boundary for recovery classification.
// Entries with LSN > WALTailLSN are guaranteed in the WAL.
walTailLSN := v.super.WALCheckpointLSN
committedLSN := headLSN
if v.DurabilityMode() == DurabilitySyncAll && v.shipperGroup != nil && v.shipperGroup.Len() > 0 {
// For sync_all, local WAL head is not enough: writes may have reached
// the primary WAL before the replica bootstrap barrier succeeds. Report
// only the all-replica durable lower bound as lineage-safe committed.
if minReplicaFlushed, ok := v.shipperGroup.MinReplicaFlushedLSNAll(); ok {
if minReplicaFlushed < committedLSN {
committedLSN = minReplicaFlushed
}
} else {
committedLSN = 0
}
}
return V2StatusSnapshot{
WALHeadLSN: headLSN,
WALTailLSN: walTailLSN,
CommittedLSN: committedLSN,
CheckpointLSN: v.super.WALCheckpointLSN,
CheckpointTrusted: v.super.Validate() == nil,
}
}
// SetV2RetentionFloor registers an additional retention floor function from the
// V2 bridge pinner. The flusher will check this floor before advancing the WAL
// tail, preventing reclaim past any held position.
func (v *BlockVol) SetV2RetentionFloor(fn func() (uint64, bool)) {
if v.flusher != nil {
// Chain with existing retention floor (from shipper group).
existing := v.flusher.RetentionFloorFn()
v.flusher.SetRetentionFloorFn(func() (uint64, bool) {
var min uint64
found := false
if existing != nil {
if lsn, ok := existing(); ok {
min = lsn
found = true
}
}
if lsn, ok := fn(); ok {
if !found || lsn < min {
min = lsn
found = true
}
}
return min, found
})
}
}
// ---------------------------------------------------------------------------
// RebuildInstaller — named local install primitive for V2 rebuild
// ---------------------------------------------------------------------------
// RebuildInstaller writes received extent data to the blockvol's extent
// region during a rebuild transfer. This is the authoritative local install
// primitive: it owns the write path and the durable state handoff.
//
// Commit performs the full state handoff: fsync extent, clear dirty map,
// reset WAL, update superblock with checkpoint boundary, sync superblock,
// and advance nextLSN. After Commit, the volume's local state is
// authoritative at the given snapshot boundary.
//
// Usage:
//
// installer := vol.NewRebuildInstaller()
// for each chunk received over TCP:
// installer.WriteChunk(data)
// installer.Commit(snapshotLSN) // full state handoff
type RebuildInstaller struct {
vol *BlockVol
fd *os.File
extentStart uint64
volumeSize uint64
offset uint64
committed bool
}
// NewRebuildInstaller creates an installer that writes to this volume's
// extent region. The caller feeds chunks and calls Commit when done.
func (v *BlockVol) NewRebuildInstaller() *RebuildInstaller {
return &RebuildInstaller{
vol: v,
fd: v.fd,
extentStart: v.super.WALOffset + v.super.WALSize,
volumeSize: v.super.VolumeSize,
}
}
// WriteChunk writes a chunk of extent data at the current offset.
// Chunks must arrive in order (sequential append to the extent region).
func (ri *RebuildInstaller) WriteChunk(data []byte) error {
if ri.committed {
return fmt.Errorf("rebuild install: already committed")
}
if ri.offset+uint64(len(data)) > ri.volumeSize {
return fmt.Errorf("rebuild install: extent overflow at %d+%d > %d",
ri.offset, len(data), ri.volumeSize)
}
_, err := ri.fd.WriteAt(data, int64(ri.extentStart+ri.offset))
if err != nil {
return fmt.Errorf("rebuild install: write at offset %d: %w", ri.offset, err)
}
ri.offset += uint64(len(data))
return nil
}
// Commit performs the full local state handoff after extent data is written.
// snapshotLSN is the primary's nextLSN at the time the extent copy started
// (returned in MsgRebuildDone). After Commit:
// - extent data is fsynced
// - dirty map is cleared (stale WAL references invalidated)
// - WAL is reset (no valid entries for old data)
// - superblock records the new checkpoint boundary
// - flusher checkpoint is synced
// - nextLSN is advanced to snapshotLSN
//
// This matches the state handoff in rebuild.go:rebuildFullExtent and
// blockvol.go:RestoreFromSnapshot — any path that replaces the local base
// must do the same state clearing.
func (ri *RebuildInstaller) Commit(snapshotLSN uint64) error {
if ri.committed {
return fmt.Errorf("rebuild install: already committed")
}
ri.committed = true
v := ri.vol
// 1. Fsync extent data.
if err := v.fd.Sync(); err != nil {
return fmt.Errorf("rebuild install: fsync extent: %w", err)
}
// 2. Clear dirty map — all data now in extent, stale WAL refs invalid.
v.dirtyMap.Clear()
// 3. Reset WAL — no valid entries for old data.
v.wal.Reset()
// 4. Persist clean superblock state so crash recovery doesn't replay stale WAL.
checkpointLSN := uint64(0)
if snapshotLSN > 0 {
checkpointLSN = snapshotLSN - 1
}
v.mu.Lock()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = checkpointLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("rebuild install: sync superblock: %w", err)
}
v.mu.Unlock()
// 5. Sync flusher's internal checkpoint with the rebuilt superblock state.
if v.flusher != nil {
v.flusher.SetCheckpointLSN(checkpointLSN)
}
// 6. Set nextLSN to the rebuilt boundary. Unconditional store, not
// monotonic-advance: a stale replica may have a higher nextLSN than
// the rebuilt boundary, and that stale value must be overwritten.
v.nextLSN.Store(snapshotLSN)
// 7. Set receiver progress to the rebuilt boundary so subsequent WAL
// shipping from the primary starts from the correct point. Unconditional
// set, not monotonic-advance, for the same reason as nextLSN.
if v.replRecv != nil {
v.replRecv.mu.Lock()
target := snapshotLSN - 1
v.replRecv.receivedLSN = target
v.replRecv.mu.Unlock()
}
return nil
}
// BytesWritten returns how many bytes have been written so far.
func (ri *RebuildInstaller) BytesWritten() uint64 {
return ri.offset
}
// SyncReceiverProgress sets the replica receiver's receivedLSN to
// achievedLSN so that subsequent WAL shipping from the primary does not
// hit contiguous-LSN rejection. Called after the full rebuild completes
// (extent install + optional second catch-up). Unconditional set, not
// monotonic-advance: after rebuild, the achieved boundary IS the truth.
func (v *BlockVol) SyncReceiverProgress(achievedLSN uint64) {
if v.replRecv != nil {
v.replRecv.mu.Lock()
v.replRecv.receivedLSN = achievedLSN
v.replRecv.mu.Unlock()
}
}
// ReceivedLSN returns the replica receiver's current receivedLSN, or 0
// if no receiver is active. Used for convergence verification.
func (v *BlockVol) ReceivedLSN() uint64 {
if v.replRecv == nil {
return 0
}
v.replRecv.mu.Lock()
defer v.replRecv.mu.Unlock()
return v.replRecv.receivedLSN
}
// ApplyRebuildEntry decodes and applies a WAL entry during rebuild.
// Used by the V2 executor for second catch-up after extent install.
// Unlike ReplicaReceiver.applyEntry, no contiguous LSN enforcement
// (catch-up entries arrive in order but may have gaps from flushed entries).
func (v *BlockVol) ApplyRebuildEntry(payload []byte) error {
return applyRebuildEntry(v, payload)
}
// PrepareFullBaseRebuild clears replica-local WAL runtime state ahead of a
// session-controlled full-base rebuild. The rebuilding replica is not serving
// frontend I/O, so it is safe to discard stale local WAL/dirty-map state and
// let the incoming base stream repopulate the extent image from scratch.
func (v *BlockVol) PrepareFullBaseRebuild(baseLSN uint64) error {
if v == nil {
return fmt.Errorf("blockvol: nil volume")
}
if v.flusher != nil {
v.flusher.Pause()
defer v.flusher.Resume()
}
v.ioMu.Lock()
defer v.ioMu.Unlock()
v.dirtyMap.Clear()
v.wal.Reset()
v.mu.Lock()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = baseLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: prepare full-base seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: prepare full-base write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: prepare full-base sync superblock: %w", err)
}
v.mu.Unlock()
if v.flusher != nil {
v.flusher.SetCheckpointLSN(baseLSN)
}
v.nextLSN.Store(baseLSN + 1)
if baseLSN > 0 {
v.SyncReceiverProgress(baseLSN)
}
return nil
}
// ErrTruncationUnsafe is returned by TruncateToLSN when the replica's
// ahead entries have already been flushed to extent. The caller should
// escalate to a full rebuild instead.
var ErrTruncationUnsafe = errors.New("blockvol: truncation unsafe, ahead entries flushed to extent")
// TruncateToLSN performs local correction for replica-ahead recovery.
// After completion, the volume's runtime state is at exactly truncateLSN:
// - dirtyMap cleared (stale WAL references from ahead entries invalidated)
// - WAL reset (ahead entries discarded without flushing them to extent)
// - superblock updated (WALCheckpointLSN = truncateLSN)
// - flusher checkpoint synced
// - nextLSN = truncateLSN + 1
// - receiver progress = truncateLSN
//
// IMPORTANT: This does NOT flush before reset. Ahead entries in the WAL
// are discarded without being written to extent, so the extent retains
// only data that was flushed BEFORE the ahead entries arrived. For the
// typical replica-ahead scenario (a few unflushed entries after failover),
// this is correct: the extent has the primary's data, and discarding the
// WAL removes the ahead entries.
//
// If ahead entries were already flushed to extent by the background flusher,
// those blocks remain in the extent. The primary will overwrite them during
// subsequent WAL shipping. If immediate data consistency is required for
// already-flushed ahead blocks, a full rebuild should be used instead.
func (v *BlockVol) TruncateToLSN(truncateLSN uint64) error {
// Pause flusher WITHOUT flushing — we must clear the dirty map BEFORE
// any flush runs, so ahead entries are never written to extent.
if v.flusher != nil {
v.flusher.Pause()
defer v.flusher.Resume()
}
// Exclusive I/O lock: drain all concurrent WriteLBA / receiver apply
// before mutating WAL/dirty map/superblock. Same pattern as
// RestoreSnapshot and ImportSnapshot.
v.ioMu.Lock()
defer v.ioMu.Unlock()
// Safety check AFTER flusher is paused and I/O is drained.
// Truncation is safe ONLY when checkpointLSN == truncateLSN:
// checkpoint > truncateLSN: ahead entries already flushed to extent
// checkpoint < truncateLSN: kept entries (checkpoint, truncateLSN]
// may still be in WAL only — truncate would discard them
// checkpoint == truncateLSN: extent has exactly the kept state,
// ahead entries are WAL-only and can be safely discarded
if v.super.WALCheckpointLSN != truncateLSN {
return fmt.Errorf("%w: checkpoint %d != truncateLSN %d",
ErrTruncationUnsafe, v.super.WALCheckpointLSN, truncateLSN)
}
// Clear dirty map — stale WAL references from ahead entries are invalidated.
// After this, reads fall through to extent (which has pre-ahead data).
v.dirtyMap.Clear()
// Reset WAL — ahead entries are discarded WITHOUT flushing to extent.
v.wal.Reset()
// Persist truncated state in superblock.
v.mu.Lock()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = truncateLSN
if _, err := v.fd.Seek(0, 0); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate seek superblock: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate write superblock: %w", err)
}
if err := v.fd.Sync(); err != nil {
v.mu.Unlock()
return fmt.Errorf("blockvol: truncate sync superblock: %w", err)
}
v.mu.Unlock()
// Sync flusher checkpoint.
if v.flusher != nil {
v.flusher.SetCheckpointLSN(truncateLSN)
}
// Set nextLSN (unconditional — truncation replaces truth).
v.nextLSN.Store(truncateLSN + 1)
// Align receiver progress.
if v.replRecv != nil {
v.replRecv.mu.Lock()
v.replRecv.receivedLSN = truncateLSN
v.replRecv.mu.Unlock()
}
return nil
}
// ScanWALEntries reads WAL entries from fromLSN using the real ScanFrom mechanism.
// This is the entry point for the V2 bridge executor's catch-up path.
//
// Uses super.WALCheckpointLSN as the recycled boundary (not flusher.CheckpointLSN).
// The superblock checkpoint is the durable boundary persisted to disk.
// The flusher's live checkpointLSN may have advanced further in memory
// but entries between super.WALCheckpointLSN and headLSN are still in the WAL.
func (v *BlockVol) ScanWALEntries(fromLSN uint64, fn func(*WALEntry) error) error {
if v.wal == nil {
return fmt.Errorf("WAL not initialized")
}
// Use the durable superblock checkpoint as the recycled boundary.
// Entries with LSN > super.WALCheckpointLSN are guaranteed in the WAL.
return v.wal.ScanFrom(v.fd, v.super.WALOffset, v.super.WALCheckpointLSN, fromLSN, fn)
}
// ForceFlush triggers a synchronous flush cycle. This advances the checkpoint
// and WAL tail. For test use — in production, the flusher runs automatically.
func (v *BlockVol) ForceFlush() error {
if v.flusher == nil {
return fmt.Errorf("flusher not initialized")
}
return v.flusher.FlushOnce()
}
// ReplicaReceiverAddrInfo holds canonical addresses from the replica receiver.
type ReplicaReceiverAddrInfo struct {
DataAddr string
CtrlAddr string
}
// ReplicaReceiverAddr returns the canonical addresses of the replica receiver,
// or nil if no receiver is running. Used by the VS to store canonical addresses
// in heartbeat state instead of raw assignment addresses.
func (v *BlockVol) ReplicaReceiverAddr() *ReplicaReceiverAddrInfo {
if v.replRecv == nil {
return nil
}
return &ReplicaReceiverAddrInfo{
DataAddr: v.replRecv.DataAddr(),
CtrlAddr: v.replRecv.CtrlAddr(),
}
}
// StartReplicaReceiver starts listening for replicated WAL entries from a primary.
// advertisedHost is the canonical routable IP for this server (e.g., from VS
// server ID). If provided, wildcard-bind listener addresses are resolved to
// advertisedHost:port instead of relying on outbound-IP fallback. On multi-NIC
// hosts, always provide advertisedHost to ensure cross-machine reachability.
func (v *BlockVol) StartReplicaReceiver(dataAddr, ctrlAddr string, advertisedHost ...string) error {
// Idempotency: skip if receiver is already running on this volume.
// Assignments are re-delivered on every heartbeat cycle; the receiver
// only needs to start once. Without this guard, the second Listen()
// fails with "bind: address already in use" and the volume stays degraded.
if v.replRecv != nil {
return nil
}
recv, err := NewReplicaReceiver(v, dataAddr, ctrlAddr, advertisedHost...)
if err != nil {
return err
}
v.replRecv = recv
recv.Serve()
return nil
}
// HandleAssignment processes a role/epoch/lease assignment from master.
func (v *BlockVol) HandleAssignment(epoch uint64, role Role, leaseTTL time.Duration) error {
return HandleAssignment(v, epoch, role, leaseTTL)
}
// StartRebuildServer creates and starts a rebuild server on the given address.
func (v *BlockVol) StartRebuildServer(addr string) error {
srv, err := NewRebuildServer(v, addr)
if err != nil {
return err
}
v.rebuildServer = srv
srv.Serve()
return nil
}
// RebuildServerAddr returns the listening address of the rebuild server,
// or empty string if no rebuild server is running.
func (v *BlockVol) RebuildServerAddr() string {
if v.rebuildServer == nil {
return ""
}
return v.rebuildServer.Addr()
}
// StopRebuildServer stops the rebuild server if running.
func (v *BlockVol) StopRebuildServer() {
if v.rebuildServer != nil {
v.rebuildServer.Stop()
v.rebuildServer = nil
}
}
// degradeReplica logs a warning about a replica barrier failure.
// Individual shippers are already degraded by Barrier itself.
func (v *BlockVol) degradeReplica(err error) {
log.Printf("blockvol: replica degraded: %v", err)
}
// BlockVolumeStatus contains block volume state for heartbeat reporting.
type BlockVolumeStatus struct {
Epoch uint64
WALHeadLSN uint64
Role Role
CheckpointLSN uint64
HasLease bool
HealthScore float64
ReplicaDegraded bool
}
// Status returns the current block volume status for heartbeat reporting.
func (v *BlockVol) Status() BlockVolumeStatus {
var cpLSN uint64
if v.flusher != nil {
cpLSN = v.flusher.CheckpointLSN()
}
var hs float64 = 1.0
if v.healthScore != nil {
hs = v.healthScore.Score()
}
var degraded bool
if v.shipperGroup != nil {
degraded = v.shipperGroup.AnyDegraded()
}
return BlockVolumeStatus{
Epoch: v.epoch.Load(),
WALHeadLSN: v.nextLSN.Load() - 1,
Role: v.Role(),
CheckpointLSN: cpLSN,
HasLease: v.lease.IsValid(),
HealthScore: hs,
ReplicaDegraded: degraded,
}
}
// WALStatus is a point-in-time snapshot of WAL pressure and admission metrics.
type WALStatus struct {
UsedFraction float64 // current WAL usage 0.0–1.0
PressureState string // "normal", "soft", "hard"
SoftWatermark float64 // configured soft threshold
HardWatermark float64 // configured hard threshold
SoftAdmitTotal uint64 // soft watermark throttle events
HardAdmitTotal uint64 // hard watermark block events
TimeoutTotal uint64 // ErrWALFull timeouts
AdmitWaitTotalSec float64 // cumulative wait time in Acquire (seconds)
SoftPressureWaitSec float64 // cumulative writer wait in soft zone (seconds)
HardPressureWaitSec float64 // cumulative writer wait in hard zone (seconds)
}
// WALStatus returns a point-in-time snapshot of WAL pressure state and admission metrics.
func (v *BlockVol) WALStatus() WALStatus {
ws := WALStatus{
UsedFraction: v.WALUsedFraction(),
PressureState: "normal",
SoftWatermark: 0.7,
HardWatermark: 0.9,
}
if v.walAdmission != nil {
ws.PressureState = v.walAdmission.PressureState()
ws.SoftWatermark = v.walAdmission.SoftMark()
ws.HardWatermark = v.walAdmission.HardMark()
ws.SoftPressureWaitSec = float64(v.walAdmission.SoftPressureWaitNs()) / 1e9
ws.HardPressureWaitSec = float64(v.walAdmission.HardPressureWaitNs()) / 1e9
}
if v.Metrics != nil {
ws.SoftAdmitTotal = v.Metrics.WALAdmitSoftTotal.Load()
ws.HardAdmitTotal = v.Metrics.WALAdmitHardTotal.Load()
ws.TimeoutTotal = v.Metrics.WALAdmitTimeoutTotal.Load()
_, sumNs := v.Metrics.WALAdmitWaitSnapshot()
ws.AdmitWaitTotalSec = float64(sumNs) / 1e9
}
return ws
}
// WALPressureState returns the current WAL pressure state ("normal", "soft", "hard").
func (v *BlockVol) WALPressureState() string {
if v.walAdmission == nil {
return "normal"
}
return v.walAdmission.PressureState()
}
// WALSoftPressureWaitNs returns cumulative nanoseconds writers spent waiting in the soft zone.
func (v *BlockVol) WALSoftPressureWaitNs() int64 {
if v.walAdmission == nil {
return 0
}
return v.walAdmission.SoftPressureWaitNs()
}
// WALHardPressureWaitNs returns cumulative nanoseconds writers spent waiting in the hard zone.
func (v *BlockVol) WALHardPressureWaitNs() int64 {
if v.walAdmission == nil {
return 0
}
return v.walAdmission.HardPressureWaitNs()
}
// CheckpointLSN returns the last LSN flushed to the extent region.
func (v *BlockVol) CheckpointLSN() uint64 {
if v.flusher != nil {
return v.flusher.CheckpointLSN()
}
return 0
}
// HealthScore returns the current health score (0.0-1.0).
func (v *BlockVol) HealthScore() float64 {
if v.healthScore == nil {
return 1.0
}
return v.healthScore.Score()
}
// HealthStats returns detailed health statistics.
func (v *BlockVol) HealthStats() HealthStats {
if v.healthScore == nil {
return HealthStats{Score: 1.0}
}
return v.healthScore.Stats()
}
// StartScrub starts background scrubbing with the given interval.
func (v *BlockVol) StartScrub(interval time.Duration) {
if v.scrubber != nil {
return
}
v.scrubber = NewScrubber(v, interval)
v.scrubber.Start()
}
// StopScrub stops the background scrubber.
func (v *BlockVol) StopScrub() {
if v.scrubber != nil {
v.scrubber.Stop()
v.scrubber = nil
}
}
// TriggerScrub triggers an immediate scrub pass.
func (v *BlockVol) TriggerScrub() {
if v.scrubber != nil {
v.scrubber.TriggerNow()
}
}
// ScrubStats returns the current scrub statistics.
func (v *BlockVol) ScrubStats() ScrubStats {
if v.scrubber == nil {
return ScrubStats{}
}
return v.scrubber.Stats()
}
// CreateSnapshot creates a point-in-time snapshot. The snapshot captures the
// exact volume state after all pending writes have been flushed to the extent.
// Only allowed on Primary or None (standalone) roles.
func (v *BlockVol) CreateSnapshot(id uint32) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
// Role check: only Primary or None (standalone).
r := Role(v.role.Load())
if r != RoleNone && r != RolePrimary {
return ErrSnapshotRoleReject
}
// Hold snapMu across duplicate check + insert to prevent two concurrent
// CreateSnapshot(id) from both passing the check. (Fix #3: TOCTOU race.)
v.snapMu.Lock()
if _, exists := v.snapshots[id]; exists {
v.snapMu.Unlock()
return ErrSnapshotExists
}
// SyncCache: ensure all prior WAL writes are durable.
if err := v.groupCommit.Submit(); err != nil {
v.snapMu.Unlock()
return fmt.Errorf("blockvol: snapshot sync: %w", err)
}
// Pause flusher and flush everything to extent.
if err := v.flusher.PauseAndFlush(); err != nil {
v.flusher.Resume()
v.snapMu.Unlock()
return fmt.Errorf("blockvol: snapshot flush: %w", err)
}
// flusher is now paused (flushMu held) -- extent is consistent.
baseLSN := v.flusher.CheckpointLSN()
// Create delta file.
deltaPath := deltaFilePath(v.path, id)
snap, err := createDeltaFile(deltaPath, id, v, baseLSN)
if err != nil {
v.flusher.Resume()
v.snapMu.Unlock()
return err
}
// Register snapshot in flusher and volume (still under snapMu + flushMu).
v.flusher.AddSnapshot(snap)
v.snapshots[id] = snap
v.snapMu.Unlock()
v.flusher.Resume()
// Update superblock snapshot count.
v.snapMu.RLock()
v.super.SnapshotCount = uint32(len(v.snapshots))
v.snapMu.RUnlock()
return v.persistSuperblock()
}
// ReadSnapshot reads data from a snapshot at the given LBA and length (bytes).
func (v *BlockVol) ReadSnapshot(id uint32, lba uint64, length uint32) ([]byte, error) {
if err := v.beginOp(); err != nil {
return nil, err
}
defer v.endOp()
if err := ValidateWrite(lba, length, v.super.VolumeSize, v.super.BlockSize); err != nil {
return nil, err
}
v.snapMu.RLock()
snap, ok := v.snapshots[id]
v.snapMu.RUnlock()
if !ok {
return nil, ErrSnapshotNotFound
}
blocks := length / v.super.BlockSize
result := make([]byte, length)
for i := uint32(0); i < blocks; i++ {
blockLBA := lba + uint64(i)
off := i * v.super.BlockSize
if snap.bitmap.Get(blockLBA) {
// CoW'd block: read from delta file.
deltaOff := int64(snap.dataOffset + blockLBA*uint64(v.super.BlockSize))
if _, err := snap.fd.ReadAt(result[off:off+v.super.BlockSize], deltaOff); err != nil {
return nil, fmt.Errorf("blockvol: read snapshot delta LBA %d: %w", blockLBA, err)
}
} else {
// Not CoW'd: read from extent (still has snapshot-time data).
extentStart := v.super.WALOffset + v.super.WALSize
extentOff := int64(extentStart + blockLBA*uint64(v.super.BlockSize))
if _, err := v.fd.ReadAt(result[off:off+v.super.BlockSize], extentOff); err != nil {
return nil, fmt.Errorf("blockvol: read snapshot extent LBA %d: %w", blockLBA, err)
}
}
}
return result, nil
}
// DeleteSnapshot removes a snapshot and deletes its delta file.
func (v *BlockVol) DeleteSnapshot(id uint32) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.snapMu.Lock()
snap, ok := v.snapshots[id]
if !ok {
v.snapMu.Unlock()
return ErrSnapshotNotFound
}
delete(v.snapshots, id)
remaining := uint32(len(v.snapshots))
v.snapMu.Unlock()
// Pause flusher so no in-flight CoW cycle can use snap.fd after close.
// (Fix #1: use-after-close race.)
v.flusher.PauseAndFlush()
v.flusher.RemoveSnapshot(id)
v.flusher.Resume()
// Close and remove delta file. Safe now -- flusher cannot reference snap.
deltaPath := deltaFilePath(v.path, id)
snap.Close()
os.Remove(deltaPath)
// Update superblock.
v.super.SnapshotCount = remaining
return v.persistSuperblock()
}
// RestoreSnapshot reverts the live volume to the state captured by the snapshot.
// This is destructive: all writes after the snapshot are lost. All snapshots are
// removed after restore. The volume must have no active I/O (call after Close
// coordination or on a standalone volume).
func (v *BlockVol) RestoreSnapshot(id uint32) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
// Exclusive lock: drains all in-flight I/O before modifying extent/WAL/dirtyMap.
v.ioMu.Lock()
defer v.ioMu.Unlock()
v.snapMu.RLock()
snap, ok := v.snapshots[id]
v.snapMu.RUnlock()
if !ok {
return ErrSnapshotNotFound
}
// Pause flusher. Check error -- if flush fails, don't proceed.
if err := v.flusher.PauseAndFlush(); err != nil {
v.flusher.Resume()
return fmt.Errorf("blockvol: restore flush: %w", err)
}
defer v.flusher.Resume()
// Copy CoW'd blocks from delta back to extent.
extentStart := v.super.WALOffset + v.super.WALSize
totalBlocks := v.super.VolumeSize / uint64(v.super.BlockSize)
buf := make([]byte, v.super.BlockSize)
for lba := uint64(0); lba < totalBlocks; lba++ {
if snap.bitmap.Get(lba) {
deltaOff := int64(snap.dataOffset + lba*uint64(v.super.BlockSize))
if _, err := snap.fd.ReadAt(buf, deltaOff); err != nil {
return fmt.Errorf("blockvol: restore read delta LBA %d: %w", lba, err)
}
extentOff := int64(extentStart + lba*uint64(v.super.BlockSize))
if _, err := v.fd.WriteAt(buf, extentOff); err != nil {
return fmt.Errorf("blockvol: restore write extent LBA %d: %w", lba, err)
}
}
}
// Fsync extent.
if err := v.fd.Sync(); err != nil {
return fmt.Errorf("blockvol: restore fsync: %w", err)
}
// Clear dirty map.
v.dirtyMap.Clear()
// Reset WAL.
v.wal.Reset()
v.super.WALHead = 0
v.super.WALTail = 0
v.super.WALCheckpointLSN = snap.header.BaseLSN
v.nextLSN.Store(snap.header.BaseLSN + 1)
// Remove ALL snapshots. Flusher is paused, so no CoW cycle can race.
v.snapMu.Lock()
for sid, s := range v.snapshots {
v.flusher.RemoveSnapshot(sid)
s.Close()
os.Remove(deltaFilePath(v.path, sid))
}
v.snapshots = make(map[uint32]*activeSnapshot)
v.snapMu.Unlock()
// Update superblock.
v.super.SnapshotCount = 0
return v.persistSuperblock()
}
// ListSnapshots returns metadata for all active snapshots.
func (v *BlockVol) ListSnapshots() []SnapshotInfo {
v.snapMu.RLock()
defer v.snapMu.RUnlock()
infos := make([]SnapshotInfo, 0, len(v.snapshots))
for _, snap := range v.snapshots {
infos = append(infos, SnapshotInfo{
ID: snap.id,
BaseLSN: snap.header.BaseLSN,
CreatedAt: time.Unix(int64(snap.header.CreatedAt), 0),
CoWBlocks: snap.bitmap.CountSet(),
})
}
return infos
}
var (
ErrShrinkNotSupported = errors.New("blockvol: shrink not supported")
ErrSnapshotsPreventResize = errors.New("blockvol: cannot resize with active snapshots")
ErrExpandAlreadyInFlight = errors.New("blockvol: expand already in flight")
ErrExpandEpochMismatch = errors.New("blockvol: expand epoch mismatch")
ErrNoExpandInFlight = errors.New("blockvol: no expand in flight")
ErrSameSize = errors.New("blockvol: new size equals current size")
)
// growFile extends the backing file to accommodate newSize bytes of extent data.
// Validates size, alignment, and snapshot constraints. Pauses/resumes flusher.
// Does NOT update VolumeSize in the superblock.
func (v *BlockVol) growFile(newSize uint64) error {
if newSize <= v.super.VolumeSize {
if newSize == v.super.VolumeSize {
return nil // no-op, caller should check
}
return ErrShrinkNotSupported
}
if newSize%uint64(v.super.BlockSize) != 0 {
return ErrAlignment
}
// Hold snapMu across entire operation to prevent concurrent CreateSnapshot.
v.snapMu.RLock()
defer v.snapMu.RUnlock()
if len(v.snapshots) > 0 {
return ErrSnapshotsPreventResize
}
// Pause flusher (no concurrent extent writes during file extension).
if err := v.flusher.PauseAndFlush(); err != nil {
v.flusher.Resume()
return fmt.Errorf("blockvol: expand flush: %w", err)
}
defer v.flusher.Resume()
// Extend file.
extentStart := v.super.WALOffset + v.super.WALSize
newFileSize := int64(extentStart + newSize)
if err := v.fd.Truncate(newFileSize); err != nil {
return fmt.Errorf("blockvol: expand truncate: %w", err)
}
return nil
}
// Expand grows the volume to newSize bytes (standalone direct-commit).
// newSize must be larger than the current size and aligned to BlockSize.
// Fails if snapshots are active. No PreparedSize involved.
func (v *BlockVol) Expand(newSize uint64) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
// Exclusive ioMu: drain I/O before file growth + VolumeSize mutation.
v.ioMu.Lock()
defer v.ioMu.Unlock()
if err := v.writeGate(); err != nil {
return err
}
if newSize == v.super.VolumeSize {
return nil // no-op
}
if err := v.growFile(newSize); err != nil {
return err
}
// Update superblock: direct-commit.
v.super.VolumeSize = newSize
v.super.PreparedSize = 0 // defensive clear
v.super.ExpandEpoch = 0
return v.persistSuperblock()
}
// PrepareExpand grows the file and records the pending expand in the superblock
// without updating VolumeSize. Writes beyond the old VolumeSize are rejected
// by ValidateWrite until CommitExpand is called.
func (v *BlockVol) PrepareExpand(newSize, expandEpoch uint64) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
// Exclusive ioMu: drain I/O before file growth.
v.ioMu.Lock()
defer v.ioMu.Unlock()
v.mu.Lock()
defer v.mu.Unlock()
if v.super.PreparedSize != 0 {
return ErrExpandAlreadyInFlight
}
if newSize <= v.super.VolumeSize {
if newSize == v.super.VolumeSize {
return ErrSameSize
}
return ErrShrinkNotSupported
}
if err := v.growFile(newSize); err != nil {
return err
}
v.super.PreparedSize = newSize
v.super.ExpandEpoch = expandEpoch
return v.persistSuperblock()
}
// CommitExpand activates the prepared expand: VolumeSize = PreparedSize.
// Returns ErrNoExpandInFlight if no prepare was done, or ErrExpandEpochMismatch
// if the epoch doesn't match.
func (v *BlockVol) CommitExpand(expandEpoch uint64) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.ioMu.Lock()
defer v.ioMu.Unlock()
v.mu.Lock()
defer v.mu.Unlock()
if v.super.PreparedSize == 0 {
return ErrNoExpandInFlight
}
if v.super.ExpandEpoch != expandEpoch {
return ErrExpandEpochMismatch
}
v.super.VolumeSize = v.super.PreparedSize
v.super.PreparedSize = 0
v.super.ExpandEpoch = 0
return v.persistSuperblock()
}
// CancelExpand clears the prepared expand state without activating it.
// The file stays physically grown (sparse, harmless).
// If expandEpoch is 0, force-cancels regardless of current epoch.
func (v *BlockVol) CancelExpand(expandEpoch uint64) error {
if err := v.beginOp(); err != nil {
return err
}
defer v.endOp()
v.ioMu.Lock()
defer v.ioMu.Unlock()
v.mu.Lock()
defer v.mu.Unlock()
if expandEpoch != 0 && v.super.ExpandEpoch != expandEpoch {
return ErrExpandEpochMismatch
}
v.super.PreparedSize = 0
v.super.ExpandEpoch = 0
return v.persistSuperblock()
}
// ExpandState returns the current prepared expand state.
func (v *BlockVol) ExpandState() (preparedSize, expandEpoch uint64) {
v.mu.RLock()
defer v.mu.RUnlock()
return v.super.PreparedSize, v.super.ExpandEpoch
}
// persistSuperblock writes the superblock to disk and fsyncs.
func (v *BlockVol) persistSuperblock() error {
v.superMu.Lock()
defer v.superMu.Unlock()
if _, err := v.fd.Seek(0, 0); err != nil {
return fmt.Errorf("blockvol: persist superblock seek: %w", err)
}
if _, err := v.super.WriteTo(v.fd); err != nil {
return fmt.Errorf("blockvol: persist superblock write: %w", err)
}
if err := v.fd.Sync(); err != nil {
return fmt.Errorf("blockvol: persist superblock sync: %w", err)
}
return nil
}
// Close shuts down the block volume and closes the file.
// Shutdown order: shippers -> replica receiver -> rebuild server -> drain ops -> group committer -> flusher -> final flush -> close fd.
func (v *BlockVol) Close() error {
v.closed.Store(true)
// Stop scrubber first (no more extent reads).
v.StopScrub()
// Stop shippers: no more Ship calls.
if v.shipperGroup != nil {
v.shipperGroup.StopAll()
}
// Stop replica receiver: no more barrier waits.
if v.replRecv != nil {
v.replRecv.Stop()
}
// Stop rebuild server.
v.StopRebuildServer()
// Drain in-flight ops: beginOp checks closed and returns ErrVolumeClosed,
// so no new ops can start. Wait for existing ones to finish (max 5s).
if v.opsOutstanding.Load() > 0 {
select {
case <-v.opsDrained:
case <-time.After(5 * time.Second):
// Proceed with shutdown even if ops are stuck.
}
}
if v.groupCommit != nil {
v.groupCommit.Stop()
}
var flushErr error
if v.flusher != nil {
v.flusher.Stop() // stop background goroutine first (no concurrent flush)
flushErr = v.flusher.FlushOnce() // then do final flush safely
v.flusher.CloseBatchIO() // release io_uring ring / kernel resources
}
// Close snapshot delta files.
v.snapMu.Lock()
for _, snap := range v.snapshots {
snap.Close()
}
v.snapshots = nil
v.snapMu.Unlock()
closeErr := v.fd.Close()
if flushErr != nil {
return flushErr
}
return closeErr
}
// newBatchIO creates a BatchIO backend based on the IOBackend config mode.
//
// Returns (backend, selectedName, error):
// - "standard": always succeeds
// - "auto": tries io_uring, falls back to standard with warning
// - "io_uring": requires io_uring, returns error if unavailable
func newBatchIO(mode IOBackendMode, logger *log.Logger) (batchio.BatchIO, string, error) {
impl := batchio.IOUringImpl // compiled-in implementation ("iceber", "giouring", "raw", or "")
if impl == "" {
impl = "none"
}
switch mode {
case IOBackendIOUring:
bio, err := batchio.NewIOUring(256)
if err != nil {
return nil, "", fmt.Errorf("io_uring requested but unavailable (compiled=%s): %w", impl, err)
}
logger.Printf("io backend: requested=io_uring implementation=%s selected=io_uring", impl)
return bio, "io_uring", nil
case IOBackendAuto:
bio, err := batchio.NewIOUring(256)
if err != nil {
logger.Printf("io backend: requested=auto implementation=%s selected=standard reason=%v", impl, err)
return batchio.NewStandard(), "standard", nil
}
logger.Printf("io backend: requested=auto implementation=%s selected=io_uring", impl)
return bio, "io_uring", nil
default: // IOBackendStandard or empty
logger.Printf("io backend: requested=standard implementation=%s selected=standard", impl)
return batchio.NewStandard(), "standard", nil
}
}