mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-09-19 14:34:15 +00:00
Engine execution layer for V2 replication protocol: - RebuildInstaller: full state handoff (dirty map, WAL, superblock, flusher) - TruncateToLSN: exact safety predicate (checkpointLSN == truncateLSN), ErrTruncationUnsafe escalation to NeedsRebuild - SyncReceiverProgress: unconditional Store for post-rebuild alignment - V2StatusSnapshot: CommittedLSN = nextLSN-1 for sync_all V2 bridge real I/O executors: - TransferFullBase: TCP streaming + RebuildInstaller + second catch-up - TransferSnapshot: SHA-256 verified streaming to disk - TruncateWAL: ErrTruncationUnsafe detection + escalation - StreamWALEntries: rebuild-mode TCP apply Engine executor interfaces: - CatchUpIO.TruncateWAL, RebuildIO.TransferFullBase returns achievedLSN - CatchUpExecutor truncation-only skip, NeedsRebuild escalation - RebuildExecutor uses achievedLSN for progress tracking Design docs reorganized: superseded planning docs removed, protocol truths and closure map added. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
404 lines
14 KiB
Go
404 lines
14 KiB
Go
package blockvol
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// Snapshot artifact format constants.
|
|
const (
|
|
SnapshotArtifactFormatV1 = 1
|
|
ArtifactLayoutSingleFile = "single-file"
|
|
ExportToolVersion = "sw-block-cp11a4"
|
|
exportChunkBlocks = 256 // read 256 blocks per chunk (1MB at 4KB block size)
|
|
exportTempSnapBase = uint32(0xFFFF0000) // base for temp snapshot IDs
|
|
)
|
|
|
|
// exportTempSnapSeq generates unique temp snapshot IDs per export to avoid collisions.
|
|
var exportTempSnapSeq atomic.Uint32
|
|
|
|
// FlagImported is set in Superblock.Flags after a successful import.
|
|
// Used to detect non-empty volumes even when nextLSN == 1 (import bypasses WAL).
|
|
const FlagImported uint16 = 1 << 0
|
|
|
|
// SnapshotArtifactManifest describes a snapshot export artifact.
|
|
type SnapshotArtifactManifest struct {
|
|
FormatVersion int `json:"format_version"`
|
|
SourceVolume string `json:"source_volume"`
|
|
SourceSizeBytes uint64 `json:"source_size_bytes"`
|
|
SourceBlockSize uint32 `json:"source_block_size"`
|
|
StorageProfile string `json:"storage_profile"`
|
|
CreatedAt string `json:"created_at"`
|
|
ArtifactLayout string `json:"artifact_layout"`
|
|
DataObjectKey string `json:"data_object_key"`
|
|
DataSizeBytes uint64 `json:"data_size_bytes"`
|
|
SHA256 string `json:"sha256"`
|
|
Compression string `json:"compression"`
|
|
ExportToolVersion string `json:"export_tool_version"`
|
|
// BaseLSN is the exact snapshot boundary — the highest LSN whose effects
|
|
// are included in the exported image. Added for V2 rebuild execution
|
|
// so the receiver can verify the imported base equals the planned target.
|
|
// Zero for manifests created before this field was added.
|
|
BaseLSN uint64 `json:"base_lsn,omitempty"`
|
|
}
|
|
|
|
var (
|
|
ErrUnsupportedArtifactVersion = errors.New("blockvol: unsupported artifact format version")
|
|
ErrUnsupportedArtifactLayout = errors.New("blockvol: unsupported artifact layout")
|
|
ErrUnsupportedProfileExport = errors.New("blockvol: only single profile supported for export/import")
|
|
ErrManifestMissingField = errors.New("blockvol: manifest missing required field")
|
|
ErrChecksumMismatch = errors.New("blockvol: data checksum mismatch")
|
|
ErrImportSizeMismatch = errors.New("blockvol: target volume size does not match manifest")
|
|
ErrImportBlockSizeMismatch = errors.New("blockvol: target block size does not match manifest")
|
|
ErrImportTargetNotEmpty = errors.New("blockvol: target volume is not empty (use AllowOverwrite)")
|
|
ErrImportDataShort = errors.New("blockvol: import data shorter than manifest declares")
|
|
ErrImportActiveSnapshots = errors.New("blockvol: cannot import with active snapshots (extent would be overwritten)")
|
|
)
|
|
|
|
// MarshalManifest encodes a manifest as indented JSON.
|
|
func MarshalManifest(m *SnapshotArtifactManifest) ([]byte, error) {
|
|
return json.MarshalIndent(m, "", " ")
|
|
}
|
|
|
|
// UnmarshalManifest decodes and validates a manifest from JSON.
|
|
func UnmarshalManifest(data []byte) (*SnapshotArtifactManifest, error) {
|
|
var m SnapshotArtifactManifest
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("blockvol: unmarshal manifest: %w", err)
|
|
}
|
|
if err := ValidateManifest(&m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// ValidateManifest checks that all required fields are present and supported.
|
|
func ValidateManifest(m *SnapshotArtifactManifest) error {
|
|
if m.FormatVersion != SnapshotArtifactFormatV1 {
|
|
return fmt.Errorf("%w: %d", ErrUnsupportedArtifactVersion, m.FormatVersion)
|
|
}
|
|
if m.ArtifactLayout != ArtifactLayoutSingleFile {
|
|
return fmt.Errorf("%w: %s", ErrUnsupportedArtifactLayout, m.ArtifactLayout)
|
|
}
|
|
if m.StorageProfile != "single" {
|
|
return fmt.Errorf("%w: %s", ErrUnsupportedProfileExport, m.StorageProfile)
|
|
}
|
|
if m.SourceSizeBytes == 0 {
|
|
return fmt.Errorf("%w: source_size_bytes", ErrManifestMissingField)
|
|
}
|
|
if m.SourceBlockSize == 0 {
|
|
return fmt.Errorf("%w: source_block_size", ErrManifestMissingField)
|
|
}
|
|
if m.SHA256 == "" {
|
|
return fmt.Errorf("%w: sha256", ErrManifestMissingField)
|
|
}
|
|
if m.DataSizeBytes == 0 {
|
|
return fmt.Errorf("%w: data_size_bytes", ErrManifestMissingField)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ExportOptions configures a snapshot export.
|
|
type ExportOptions struct {
|
|
DataObjectKey string // S3 object key for the data object (stored in manifest)
|
|
SnapshotID uint32 // if > 0, export from existing snapshot; if 0, create+delete temp
|
|
}
|
|
|
|
// ExportSnapshot exports a crash-consistent snapshot of the volume to w.
|
|
// If opts.SnapshotID is 0, a temporary snapshot is created and deleted after export.
|
|
// If opts.SnapshotID is > 0, the existing snapshot is used (not deleted).
|
|
// The full logical volume image is streamed to w with SHA-256 computed inline.
|
|
func (v *BlockVol) ExportSnapshot(ctx context.Context, w io.Writer, opts ExportOptions) (*SnapshotArtifactManifest, error) {
|
|
if err := v.beginOp(); err != nil {
|
|
return nil, err
|
|
}
|
|
defer v.endOp()
|
|
|
|
if v.Profile() != ProfileSingle {
|
|
return nil, ErrUnsupportedProfileExport
|
|
}
|
|
|
|
info := v.Info()
|
|
snapID := opts.SnapshotID
|
|
deleteSnap := false
|
|
|
|
if snapID == 0 {
|
|
// Generate unique temp snapshot ID to avoid collisions with concurrent exports
|
|
// and user-created snapshots.
|
|
snapID = exportTempSnapBase + exportTempSnapSeq.Add(1)
|
|
if err := v.CreateSnapshot(snapID); err != nil {
|
|
return nil, fmt.Errorf("blockvol: export create temp snapshot: %w", err)
|
|
}
|
|
deleteSnap = true
|
|
defer func() {
|
|
if deleteSnap {
|
|
v.DeleteSnapshot(snapID)
|
|
}
|
|
}()
|
|
} else {
|
|
// Verify snapshot exists.
|
|
v.snapMu.RLock()
|
|
_, ok := v.snapshots[snapID]
|
|
v.snapMu.RUnlock()
|
|
if !ok {
|
|
return nil, ErrSnapshotNotFound
|
|
}
|
|
}
|
|
|
|
// Read snapshot BaseLSN for the manifest.
|
|
var baseLSN uint64
|
|
v.snapMu.RLock()
|
|
if snap, ok := v.snapshots[snapID]; ok {
|
|
baseLSN = snap.header.BaseLSN
|
|
}
|
|
v.snapMu.RUnlock()
|
|
|
|
h := sha256.New()
|
|
mw := io.MultiWriter(w, h)
|
|
|
|
totalBlocks := info.VolumeSize / uint64(info.BlockSize)
|
|
chunkBlocks := uint64(exportChunkBlocks)
|
|
var totalWritten uint64
|
|
|
|
for startBlock := uint64(0); startBlock < totalBlocks; startBlock += chunkBlocks {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
remaining := totalBlocks - startBlock
|
|
n := chunkBlocks
|
|
if n > remaining {
|
|
n = remaining
|
|
}
|
|
readBytes := uint32(n * uint64(info.BlockSize))
|
|
|
|
data, err := v.ReadSnapshot(snapID, startBlock, readBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("blockvol: export read block %d: %w", startBlock, err)
|
|
}
|
|
|
|
if _, err := mw.Write(data); err != nil {
|
|
return nil, fmt.Errorf("blockvol: export write: %w", err)
|
|
}
|
|
totalWritten += uint64(len(data))
|
|
}
|
|
|
|
manifest := &SnapshotArtifactManifest{
|
|
FormatVersion: SnapshotArtifactFormatV1,
|
|
SourceVolume: v.Path(),
|
|
SourceSizeBytes: info.VolumeSize,
|
|
SourceBlockSize: info.BlockSize,
|
|
StorageProfile: "single",
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
ArtifactLayout: ArtifactLayoutSingleFile,
|
|
DataObjectKey: opts.DataObjectKey,
|
|
DataSizeBytes: totalWritten,
|
|
SHA256: hex.EncodeToString(h.Sum(nil)),
|
|
Compression: "none",
|
|
ExportToolVersion: ExportToolVersion,
|
|
BaseLSN: baseLSN,
|
|
}
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
// ImportOptions configures a snapshot import.
|
|
type ImportOptions struct {
|
|
AllowOverwrite bool // if true, allow import into a non-empty volume
|
|
}
|
|
|
|
// ImportSnapshot imports a snapshot artifact into this volume.
|
|
// The volume must match the manifest's size and block size.
|
|
// Data is read from r and written directly to the extent region, bypassing the WAL.
|
|
// SHA-256 is verified against the manifest after the full read.
|
|
//
|
|
// IMPORTANT: Import is a destructive, non-atomic operation. If the import fails
|
|
// mid-stream (reader error, context cancellation), the extent region will contain
|
|
// a mix of old and new data. The volume remains operational but its data is
|
|
// inconsistent. On failure, the volume should be discarded or re-imported with
|
|
// AllowOverwrite. A future enhancement may add pre-import snapshot for rollback.
|
|
//
|
|
// Active snapshots are rejected because import overwrites the extent region that
|
|
// non-CoW'd snapshot blocks read from, which would silently corrupt snapshot reads.
|
|
func (v *BlockVol) ImportSnapshot(ctx context.Context, manifest *SnapshotArtifactManifest, r io.Reader, opts ImportOptions) 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()
|
|
|
|
if err := ValidateManifest(manifest); err != nil {
|
|
return err
|
|
}
|
|
|
|
info := v.Info()
|
|
if info.VolumeSize != manifest.SourceSizeBytes {
|
|
return fmt.Errorf("%w: target=%d manifest=%d", ErrImportSizeMismatch, info.VolumeSize, manifest.SourceSizeBytes)
|
|
}
|
|
if info.BlockSize != manifest.SourceBlockSize {
|
|
return fmt.Errorf("%w: target=%d manifest=%d", ErrImportBlockSizeMismatch, info.BlockSize, manifest.SourceBlockSize)
|
|
}
|
|
|
|
// Reject import if active snapshots exist. Import overwrites the extent
|
|
// region directly; non-CoW'd snapshot blocks read from extent and would
|
|
// return import data instead of snapshot-time data (BUG-CP11A4-1).
|
|
v.snapMu.RLock()
|
|
snapCount := len(v.snapshots)
|
|
v.snapMu.RUnlock()
|
|
if snapCount > 0 {
|
|
return ErrImportActiveSnapshots
|
|
}
|
|
|
|
// Empty check: nextLSN > 1 means WAL writes occurred; FlagImported means a
|
|
// previous import wrote directly to extent (bypassing WAL, so nextLSN stays 1).
|
|
if !opts.AllowOverwrite && (v.nextLSN.Load() > 1 || v.super.Flags&FlagImported != 0) {
|
|
return ErrImportTargetNotEmpty
|
|
}
|
|
|
|
// Pause flusher — we write directly to extent.
|
|
if err := v.flusher.PauseAndFlush(); err != nil {
|
|
v.flusher.Resume()
|
|
return fmt.Errorf("blockvol: import flush: %w", err)
|
|
}
|
|
defer v.flusher.Resume()
|
|
|
|
// Stream data to extent, computing SHA-256.
|
|
h := sha256.New()
|
|
tr := io.TeeReader(r, h)
|
|
|
|
extentStart := v.super.WALOffset + v.super.WALSize
|
|
chunkSize := uint64(exportChunkBlocks) * uint64(info.BlockSize)
|
|
buf := make([]byte, chunkSize)
|
|
var totalRead uint64
|
|
|
|
for totalRead < manifest.DataSizeBytes {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
remaining := manifest.DataSizeBytes - totalRead
|
|
readSize := chunkSize
|
|
if readSize > remaining {
|
|
readSize = remaining
|
|
}
|
|
|
|
n, err := io.ReadFull(tr, buf[:readSize])
|
|
if n > 0 {
|
|
writeOff := int64(extentStart) + int64(totalRead)
|
|
if _, werr := v.fd.WriteAt(buf[:n], writeOff); werr != nil {
|
|
return fmt.Errorf("blockvol: import write at offset %d: %w", writeOff, werr)
|
|
}
|
|
totalRead += uint64(n)
|
|
}
|
|
if err != nil {
|
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
|
break
|
|
}
|
|
return fmt.Errorf("blockvol: import read: %w", err)
|
|
}
|
|
}
|
|
|
|
if totalRead != manifest.DataSizeBytes {
|
|
return fmt.Errorf("%w: read %d bytes, manifest declares %d", ErrImportDataShort, totalRead, manifest.DataSizeBytes)
|
|
}
|
|
|
|
// Verify checksum.
|
|
got := hex.EncodeToString(h.Sum(nil))
|
|
if got != manifest.SHA256 {
|
|
return fmt.Errorf("%w: got %s, want %s", ErrChecksumMismatch, got, manifest.SHA256)
|
|
}
|
|
|
|
// Fsync extent.
|
|
if err := v.fd.Sync(); err != nil {
|
|
return fmt.Errorf("blockvol: import sync: %w", err)
|
|
}
|
|
|
|
// Reset WAL and dirty map for clean state.
|
|
v.dirtyMap.Clear()
|
|
v.wal.Reset()
|
|
v.super.WALHead = 0
|
|
v.super.WALTail = 0
|
|
v.super.Flags |= FlagImported
|
|
if err := v.persistSuperblock(); err != nil {
|
|
return fmt.Errorf("blockvol: import persist superblock: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ImportSnapshotForRebuild imports a snapshot artifact and converges all
|
|
// local runtime state to the exact snapshot boundary (baseLSN). This is
|
|
// the rebuild-oriented import primitive for V2 snapshot_tail execution.
|
|
//
|
|
// Unlike generic ImportSnapshot, this method:
|
|
// - requires manifest.BaseLSN > 0 (exact boundary must be explicit)
|
|
// - verifies the imported base equals the requested snapshotLSN
|
|
// - converges WALCheckpointLSN, nextLSN, flusher, and receiver to baseLSN
|
|
//
|
|
// After this call, the volume is at exactly snapshotLSN. The caller can
|
|
// then replay WAL tail entries from snapshotLSN+1 to targetLSN.
|
|
func (v *BlockVol) ImportSnapshotForRebuild(ctx context.Context, manifest *SnapshotArtifactManifest, r io.Reader, snapshotLSN uint64) error {
|
|
// Validate: manifest must carry explicit BaseLSN matching the requested boundary.
|
|
if manifest.BaseLSN == 0 {
|
|
return fmt.Errorf("blockvol: rebuild import requires explicit BaseLSN in manifest")
|
|
}
|
|
if manifest.BaseLSN != snapshotLSN {
|
|
return fmt.Errorf("blockvol: rebuild import boundary mismatch: manifest BaseLSN=%d != requested snapshotLSN=%d",
|
|
manifest.BaseLSN, snapshotLSN)
|
|
}
|
|
|
|
// Use generic import with AllowOverwrite (rebuild target may have stale data).
|
|
if err := v.ImportSnapshot(ctx, manifest, r, ImportOptions{AllowOverwrite: true}); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Converge all runtime state to the exact snapshot boundary.
|
|
// This is the same state handoff as RebuildInstaller.Commit but
|
|
// for the snapshot_tail path, where the boundary is exact.
|
|
v.mu.Lock()
|
|
v.super.WALCheckpointLSN = snapshotLSN
|
|
if _, err := v.fd.Seek(0, 0); err != nil {
|
|
v.mu.Unlock()
|
|
return fmt.Errorf("blockvol: rebuild import seek superblock: %w", err)
|
|
}
|
|
if _, err := v.super.WriteTo(v.fd); err != nil {
|
|
v.mu.Unlock()
|
|
return fmt.Errorf("blockvol: rebuild import write superblock: %w", err)
|
|
}
|
|
if err := v.fd.Sync(); err != nil {
|
|
v.mu.Unlock()
|
|
return fmt.Errorf("blockvol: rebuild import sync superblock: %w", err)
|
|
}
|
|
v.mu.Unlock()
|
|
|
|
if v.flusher != nil {
|
|
v.flusher.SetCheckpointLSN(snapshotLSN)
|
|
}
|
|
|
|
// Set nextLSN (unconditional, not monotonic — rebuild replaces truth).
|
|
v.nextLSN.Store(snapshotLSN + 1)
|
|
|
|
// Align receiver progress.
|
|
if v.replRecv != nil {
|
|
v.replRecv.mu.Lock()
|
|
v.replRecv.receivedLSN = snapshotLSN
|
|
v.replRecv.mu.Unlock()
|
|
}
|
|
|
|
return nil
|
|
}
|