mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-26 18:04:33 +00:00
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>
2217 lines
70 KiB
Go
2217 lines
70 KiB
Go
// 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
|
||
}
|
||
}
|