Files
seaweedfs/weed/operation/volume_move/volume_move.go
T
Chris Lu 4f50c5b0d4 feat: throughput limits for replicate, EC shard, and worker-driven moves (#10749)
* feat: throughput limits for replicate, EC shard, and worker-driven moves

VolumeCopy was the only rate-limitable transfer; EC shard copies,
replica creation, and worker-driven moves all ran at whatever the
receiving server's maintenance rate allowed, with no per-operation
control.

- proto: VolumeEcShardsCopyRequest and the balance / ec_balance task
  params and configs gain io_byte_per_second; 0 keeps today's behavior
  (the volume server's own maintenance rate governs).
- volume server: VolumeEcShardsCopy throttles with one WriteThrottler
  per request, shared across the shard, .ecx, .ecj, .vif, and .ecsum
  copies so the limit caps the transfer as a whole - the same shape as
  VolumeCopy.
- volume_move: ReplicateVolume accepts the limit; EcMoveOptions carries
  it through MoveEcShards/CopyAndMountEcShards into the copy request,
  with fake-client tests asserting propagation.
- shell: ec.balance gains -ioBytePerSecond; volume.tier.move's
  replication top-up honors the command's existing -ioBytePerSecond
  instead of running unthrottled.
- worker: balance and ec_balance configs gain io_byte_per_second
  (surfaced in the admin config schema), carried through detection and
  plugin job parameters into task params and handed to the shared
  mover; batch balance jobs inherit the limit from their detection
  results.

The limit is per copy stream, so maxParallelization multiplies the
aggregate ceiling.

* worker plugins: expose io_byte_per_second in the plugin config and derive it

The plugin-driven detection path derives its task Config from the
plugin configuration values, and both balance and ec_balance left
IoBytePerSecond at zero there - a configured limit silently reverted
to the server maintenance rate. Both derive functions now read the
field (clamped at zero), and the plugin descriptors expose it with
defaults so the configuration form carries it.
2026-08-13 13:22:58 -07:00

504 lines
23 KiB
Go

package volume_move
import (
"context"
"errors"
"fmt"
"io"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/util"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ErrSourceKeptReadonly marks a move failure whose recovery deliberately keeps
// the source volume readonly — the target copy may hold data the source lacks
// (an ambiguous source delete, or an undeletable stale copy). Callers that
// froze the source themselves must not thaw it on this error.
var ErrSourceKeptReadonly = errors.New("the source volume is deliberately kept readonly")
// VolumeMoveOptions control LiveMoveVolume.
type VolumeMoveOptions struct {
// DiskType changes the volume's disk type on the target ("" keeps it).
DiskType string
// IoBytePerSecond throttles the copy (0 = unlimited).
IoBytePerSecond int64
// IdleTimeout is how long the tail phase waits for in-flight requests to
// drain (default 5s).
IdleTimeout time.Duration
// Writer receives human-readable progress lines (nil discards them).
Writer io.Writer
// Progress, when set, receives percent/stage callbacks as the move advances.
Progress func(percent float64, stage string)
}
func (o *VolumeMoveOptions) fillDefaults() {
if o.IdleTimeout <= 0 {
o.IdleTimeout = 5 * time.Second
}
if o.Writer == nil {
o.Writer = io.Discard
}
if o.Progress == nil {
o.Progress = func(float64, string) {}
}
}
// LiveMoveVolume moves one volume between volume servers while it keeps serving
// reads: freeze the source (readonly), copy, tail to drain in-flight requests,
// verify the target matches the source, then delete the source. A failure
// before the source delete restores the source's writability if this move is
// what froze it.
func (m *Mover) LiveMoveVolume(ctx context.Context, volumeId needle.VolumeId, source, target pb.ServerAddress, opts VolumeMoveOptions) (err error) {
opts.fillDefaults()
// VolumeCopy tears down any existing copy on the target first, so a
// same-server "move" would delete the volume and then fail to read it.
// SameServer, not ==: "node:8080" and "node:8080.18080" are one server.
if SameServer(source, target) {
return fmt.Errorf("refusing to move volume %d onto its own server %s", volumeId, source)
}
opts.Progress(10, fmt.Sprintf("marking volume %d readonly on %s", volumeId, source))
sourceWasWritable, err := m.ensureVolumeReadonly(ctx, volumeId, source, true)
// The source stays authoritative until its delete succeeds; on any earlier
// failure undo the freeze this move added and clean up the target copy.
// Installed before the error check: the marking RPC itself can fail after
// the server applied the mark (e.g. its master notification failed).
var copyStarted, copyCompleted, sourceDeleteStarted bool
var targetHadVolume, targetStateKnown bool
defer func() {
if err != nil {
if sourceDeleteStarted {
// Past verification: the target is complete and may already hold
// new writes, and the delete may or may not have reached the
// source. Deleting the target or reopening the source could fork
// the volume; keep the source readonly.
fmt.Fprintf(opts.Writer, "volume %d is left readonly on %s: the source delete failed with the verified copy on %s mounted\n", volumeId, source, target)
glog.Warningf("volume %d is left readonly on %s: the source delete failed with the verified copy on %s mounted", volumeId, source, target)
err = fmt.Errorf("%w: %w", ErrSourceKeptReadonly, err)
return
}
// The cleanup runs on its own deadline so an abort via cancelled
// context still cleans up.
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cleanupCancel()
cleanupTarget := copyCompleted
if copyStarted && !copyCompleted {
// The server can finish the copy and mount the target even when
// the client loses the stream, so probe rather than assume.
exists, known := m.probeVolume(cleanupCtx, volumeId, target)
switch {
case known && !exists:
// nothing mounted on the target; just undo the freeze below
case known && exists && targetStateKnown && !targetHadVolume:
// the failed copy created it; remove it so the source stays
// the only replica
cleanupTarget = true
default:
// A copy may sit mounted on the target and its provenance
// cannot be proven: the prior state is unknown, the target
// held a replica before the move, or the probe failed.
// Deleting it risks someone else's replica; reopening the
// source beside it risks two writable replicas taking
// divergent writes. Keep the source readonly.
fmt.Fprintf(opts.Writer, "volume %d is left readonly on %s: a copy may exist on %s but its origin cannot be determined; delete one side explicitly, then re-run the move\n", volumeId, source, target)
glog.Warningf("volume %d is left readonly on %s: a copy may exist on %s but its origin cannot be determined; delete one side explicitly, then re-run the move", volumeId, source, target)
err = fmt.Errorf("%w: %w", ErrSourceKeptReadonly, err)
return
}
}
if cleanupTarget {
// The target copy may be missing tailed entries; remove it so
// the source stays the only replica.
if dErr := m.DeleteVolume(cleanupCtx, volumeId, target, false, true); dErr != nil {
// Restoring the source while the stale target stays mounted
// risks divergent replicas; keep the source readonly. A
// re-run refuses while the copy exists, so name the fix.
fmt.Fprintf(opts.Writer, "volume %d is left readonly on %s: failed to delete the incomplete copy on %s: %v; delete that copy, then re-run the move\n", volumeId, source, target, dErr)
glog.Warningf("volume %d is left readonly on %s: failed to delete the incomplete copy on %s: %v; delete that copy, then re-run the move", volumeId, source, target, dErr)
err = fmt.Errorf("%w: %w", ErrSourceKeptReadonly, err)
return
}
}
if sourceWasWritable {
m.restoreVolumeWritable(volumeId, source)
} else {
// The hard freeze added on top of a readonly-reporting status is
// kept: it is indistinguishable from an operator's mark. It is
// not persisted, so a volume server restart clears it.
fmt.Fprintf(opts.Writer, "volume %d on %s keeps the readonly mark the move added; volume.mark -writable clears it if the prior readonly state was transient\n", volumeId, source)
}
}
}()
if err != nil {
return fmt.Errorf("mark volume %d readonly on %s: %v", volumeId, source, err)
}
targetHadVolume, targetStateKnown = m.probeVolume(ctx, volumeId, target)
if !sourceWasWritable {
// A readonly source next to an existing copy is the signature of a
// previous move whose source delete failed — that copy may be the
// authoritative one, serving writes the source never saw, and the
// copy below would tear it down. No client-side observation proves
// otherwise: compaction revision is shared by ordinary replicas,
// aggregate sizes shrink under compaction, and any snapshot can be
// invalidated by a write right after it. Fail closed on any existing
// copy or unknown state; the operator deletes one side explicitly.
if !targetStateKnown {
err = fmt.Errorf("%w: cannot determine whether %s already holds a copy of volume %d; refusing to overwrite it while the source is readonly", ErrSourceKeptReadonly, target, volumeId)
return err
}
if targetHadVolume {
err = fmt.Errorf("%w: volume %d already has a copy on %s while the source on %s is readonly — likely a previous move that did not finish; delete the source to keep that copy, or delete the copy before re-running the move", ErrSourceKeptReadonly, volumeId, target, source)
return err
}
}
opts.Progress(20, fmt.Sprintf("copying volume %d from %s to %s", volumeId, source, target))
fmt.Fprintf(opts.Writer, "copying volume %d from %s to %s\n", volumeId, source, target)
copyStarted = true
lastAppendAtNs, err := m.copyVolumeData(ctx, volumeId, source, target, opts.DiskType, opts.IoBytePerSecond, opts.Writer)
if err != nil {
return fmt.Errorf("copy volume %d from %s to %s: %v", volumeId, source, target, err)
}
copyCompleted = true
opts.Progress(70, fmt.Sprintf("tailing volume %d from %s to %s", volumeId, source, target))
fmt.Fprintf(opts.Writer, "tailing volume %d from %s to %s\n", volumeId, source, target)
tailFailed := false
if tailErr := m.TailVolume(ctx, volumeId, source, target, lastAppendAtNs, opts.IdleTimeout); tailErr != nil {
// A tail failure is tolerable only when the volume was already readonly
// before this move began: frozen for the whole copy, it should have no
// in-flight writes for the tail to drain — and the stability check
// below still proves it. A volume this move froze can have stragglers
// admitted just before the freeze that only the tail delivers, so
// losing the tail there must abort the move.
if sourceWasWritable {
return fmt.Errorf("tail volume %d from %s to %s: %v", volumeId, source, target, tailErr)
}
tailFailed = true
fmt.Fprintf(opts.Writer, "tail volume %d from %s to %s: %v\n", volumeId, source, target, tailErr)
glog.Warningf("tail volume %d from %s to %s: %v", volumeId, source, target, tailErr)
}
// Verify before the point of no return: the source is deleted only when the
// target holds at least everything the source does. The source status is
// read here, after the tail — a write in flight when the source was frozen
// can still land after an earlier read, and a stale snapshot would wave
// through an incomplete target.
opts.Progress(85, fmt.Sprintf("verifying volume %d on %s", volumeId, target))
sourceStatus, err := m.ReadVolumeFileStatus(ctx, volumeId, source)
if err != nil {
return fmt.Errorf("read volume %d status on %s: %v", volumeId, source, err)
}
if tailFailed {
// A successful tail proves the source held still for the idle window; a
// tolerated tail failure proved nothing, so substitute the same drain
// barrier here: the source status must not change across the window.
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(opts.IdleTimeout):
}
secondStatus, statusErr := m.ReadVolumeFileStatus(ctx, volumeId, source)
if statusErr != nil {
return fmt.Errorf("re-read volume %d status on %s: %v", volumeId, source, statusErr)
}
if secondStatus.DatFileSize != sourceStatus.DatFileSize || secondStatus.IdxFileSize != sourceStatus.IdxFileSize || secondStatus.FileCount != sourceStatus.FileCount {
return fmt.Errorf("volume %d on %s is still changing after a failed tail; aborting the move", volumeId, source)
}
sourceStatus = secondStatus
}
targetStatus, err := m.ReadVolumeFileStatus(ctx, volumeId, target)
if err != nil {
return fmt.Errorf("verify volume %d on target %s before deleting source: %v", volumeId, target, err)
}
if err = verifyTargetNotBehind(volumeId, sourceStatus, targetStatus); err != nil {
return err
}
if targetStatus.DatFileSize > sourceStatus.DatFileSize || targetStatus.IdxFileSize > sourceStatus.IdxFileSize || targetStatus.FileCount > sourceStatus.FileCount {
// The target announced itself writable when the copy mounted it, so
// clients may have written to it during the tail. Those writes live on
// the surviving copy; committing keeps them.
fmt.Fprintf(opts.Writer, "volume %d on %s has writes beyond the source; they stay with the moved volume\n", volumeId, target)
}
opts.Progress(90, fmt.Sprintf("deleting volume %d from %s", volumeId, source))
fmt.Fprintf(opts.Writer, "deleting volume %d from %s\n", volumeId, source)
sourceDeleteStarted = true
if err = m.DeleteVolume(ctx, volumeId, source, false, true); err != nil {
return fmt.Errorf("delete volume %d from %s: %v", volumeId, source, err)
}
opts.Progress(100, fmt.Sprintf("moved volume %d from %s to %s", volumeId, source, target))
fmt.Fprintf(opts.Writer, "moved volume %d from %s to %s\n", volumeId, source, target)
return nil
}
// verifyTargetNotBehind fails when the target holds less than the (frozen)
// source — the copy or tail missed data and deleting the source would lose it.
// A target that is ahead is not an error: the target serves writes during the
// tail, and those belong to the copy that survives the move.
func verifyTargetNotBehind(volumeId needle.VolumeId, source, target *volume_server_pb.ReadVolumeFileStatusResponse) error {
if target.DatFileSize < source.DatFileSize {
return fmt.Errorf("volume %d target is behind the source: .dat %d < %d bytes", volumeId, target.DatFileSize, source.DatFileSize)
}
if target.IdxFileSize < source.IdxFileSize {
return fmt.Errorf("volume %d target is behind the source: .idx %d < %d bytes", volumeId, target.IdxFileSize, source.IdxFileSize)
}
if target.FileCount < source.FileCount {
return fmt.Errorf("volume %d target is behind the source: %d < %d files", volumeId, target.FileCount, source.FileCount)
}
return nil
}
// CopyVolume freezes the volume on source, copies it to target, and reports the
// stamp of the last entry copied. restoreWritable also restores the source's
// writability on success, for copies that leave the source serving; a failed
// copy always undoes the freeze this call added.
func (m *Mover) CopyVolume(ctx context.Context, volumeId needle.VolumeId, source, target pb.ServerAddress, diskType string, ioBytePerSecond int64, restoreWritable bool, writer io.Writer) (lastAppendAtNs uint64, err error) {
if writer == nil {
writer = io.Discard
}
if SameServer(source, target) {
return 0, fmt.Errorf("refusing to copy volume %d onto its own server %s", volumeId, source)
}
// The copy is non-destructive, so a readonly-reporting source is taken as
// is (force=false): missed concurrent deletes only make the new replica
// trivially stale, and hard-marking here could pin a transiently readonly
// (e.g. low-disk) source readonly with nothing to gate on it.
sourceWasWritable, err := m.ensureVolumeReadonly(ctx, volumeId, source, false)
defer func() {
if sourceWasWritable && (err != nil || restoreWritable) {
m.restoreVolumeWritable(volumeId, source)
}
}()
if err != nil {
return 0, err
}
return m.copyVolumeData(ctx, volumeId, source, target, diskType, ioBytePerSecond, writer)
}
// ensureVolumeReadonly freezes the volume on server and reports whether it was
// writable beforehand — only then may a failure path undo the freeze, since a
// volume that was already readonly (e.g. full, or operator-set) must stay so.
// With force, the mark is issued even when the status already reports
// readonly: that answer also covers transient low-disk state and the
// readonly-but-can-delete flag, and neither blocks needle deletes, so only the
// hard mark makes the volume immutable — required before deleting the source
// of a move. Without force, a readonly-reporting volume is left untouched.
func (m *Mover) ensureVolumeReadonly(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress, force bool) (wasWritable bool, err error) {
err = m.withClient(false, server, func(client volume_server_pb.VolumeServerClient) error {
resp, statusErr := client.VolumeStatus(ctx, &volume_server_pb.VolumeStatusRequest{
VolumeId: uint32(volumeId),
})
if statusErr != nil {
return statusErr
}
wasWritable = !resp.IsReadOnly
if !wasWritable && !force {
return nil
}
_, readonlyErr := client.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: uint32(volumeId),
Persist: false,
})
return readonlyErr
})
return
}
// probeVolume reports whether server currently has the volume, and whether the
// answer is trustworthy. The Go server answers a missing volume with a plain
// error (gRPC code Unknown — the only way this RPC fails there); the Rust
// server answers with codes.NotFound. Both mean the server responded and the
// volume is absent. A transport-level failure means the state is unknown, and
// callers deciding to delete must treat unknown as hands-off.
func (m *Mover) probeVolume(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress) (exists bool, known bool) {
_, err := m.ReadVolumeFileStatus(ctx, volumeId, server)
if err == nil {
return true, true
}
if s, ok := status.FromError(err); ok && (s.Code() == codes.Unknown || s.Code() == codes.NotFound) {
return false, true
}
return false, false
}
// restoreVolumeWritable undoes a readonly mark after a failed move. It runs on
// its own deadline so an abort via cancelled context still restores the source,
// and only logs a failure — the caller is already returning the move error.
func (m *Mover) restoreVolumeWritable(volumeId needle.VolumeId, server pb.ServerAddress) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := m.MarkVolumeWritable(ctx, volumeId, server, true, false); err != nil {
glog.Warningf("restore volume %d writable on %s: %v", volumeId, server, err)
}
}
// copyVolumeData streams a VolumeCopy on the target, which pulls the volume
// from source and mounts it, and returns the stamp of the last entry copied
// for the tail phase.
func (m *Mover) copyVolumeData(ctx context.Context, volumeId needle.VolumeId, source, target pb.ServerAddress, diskType string, ioBytePerSecond int64, writer io.Writer) (lastAppendAtNs uint64, err error) {
// The target dials the embedded source itself; a malformed one would
// abort the target server, not this client.
if err = checkDialable(source); err != nil {
return 0, err
}
err = m.withClient(true, target, func(client volume_server_pb.VolumeServerClient) error {
stream, replicateErr := client.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(volumeId),
SourceDataNode: string(source),
DiskType: diskType,
IoBytePerSecond: ioBytePerSecond,
})
if replicateErr != nil {
return replicateErr
}
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return recvErr
}
if resp.LastAppendAtNs != 0 {
lastAppendAtNs = resp.LastAppendAtNs
} else {
fmt.Fprintf(writer, "%s => %s volume %d processed %s\n", source, target, volumeId, util.BytesToHumanReadable(uint64(resp.ProcessedBytes)))
}
}
return nil
})
return
}
// TailVolume has the target follow the source's appends since sinceNs until the
// source stays idle for idleTimeout, draining requests in flight when the move
// froze the source.
func (m *Mover) TailVolume(ctx context.Context, volumeId needle.VolumeId, source, target pb.ServerAddress, sinceNs uint64, idleTimeout time.Duration) error {
// The target dials the embedded source itself; a malformed one would
// abort the target server, not this client.
if err := checkDialable(source); err != nil {
return err
}
return m.withClient(true, target, func(client volume_server_pb.VolumeServerClient) error {
_, replicateErr := client.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{
VolumeId: uint32(volumeId),
SinceNs: sinceNs,
IdleTimeoutSeconds: uint32(idleTimeout.Seconds()),
SourceVolumeServer: string(source),
})
return replicateErr
})
}
// ReadVolumeFileStatus reads the volume's file sizes and needle count on server.
func (m *Mover) ReadVolumeFileStatus(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress) (resp *volume_server_pb.ReadVolumeFileStatusResponse, err error) {
err = m.withClient(false, server, func(client volume_server_pb.VolumeServerClient) error {
var statusErr error
resp, statusErr = client.ReadVolumeFileStatus(ctx, &volume_server_pb.ReadVolumeFileStatusRequest{
VolumeId: uint32(volumeId),
})
return statusErr
})
return
}
// DeleteVolume removes the volume from server. When keepRemoteData is true, the
// cloud-tier object backing the volume is left intact — used on the source side
// of a move where another server is taking over the same .vif.
func (m *Mover) DeleteVolume(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress, onlyEmpty bool, keepRemoteData bool) error {
return m.withClient(false, server, func(client volume_server_pb.VolumeServerClient) error {
_, deleteErr := client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
VolumeId: uint32(volumeId),
OnlyEmpty: onlyEmpty,
KeepRemoteData: keepRemoteData,
})
return deleteErr
})
}
// MarkVolumeWritable marks the volume writable (or readonly when writable is
// false, persisted per persist) on server.
func (m *Mover) MarkVolumeWritable(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress, writable, persist bool) (err error) {
return m.withClient(false, server, func(client volume_server_pb.VolumeServerClient) error {
if writable {
_, err = client.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{
VolumeId: uint32(volumeId),
})
} else {
_, err = client.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
VolumeId: uint32(volumeId),
Persist: persist,
})
}
return err
})
}
// ReplicateVolume copies a volume from source to target without touching the
// source — the replica-creation half of a move. ioBytePerSecond limits the
// copy rate; 0 falls back to the volume server's maintenance rate.
func (m *Mover) ReplicateVolume(ctx context.Context, volumeId needle.VolumeId, source, target pb.ServerAddress, diskType string, ioBytePerSecond int64, writer io.Writer) error {
if writer == nil {
writer = io.Discard
}
if SameServer(source, target) {
return fmt.Errorf("refusing to replicate volume %d onto its own server %s", volumeId, source)
}
// The target dials the embedded source itself; a malformed one would
// abort the target server, not this client.
if err := checkDialable(source); err != nil {
return err
}
return m.withClient(false, target, func(client volume_server_pb.VolumeServerClient) error {
stream, replicateErr := client.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
VolumeId: uint32(volumeId),
SourceDataNode: string(source),
DiskType: diskType,
IoBytePerSecond: ioBytePerSecond,
})
if replicateErr != nil {
return replicateErr
}
for {
resp, recvErr := stream.Recv()
if recvErr != nil {
if recvErr == io.EOF {
break
}
return recvErr
}
if resp.ProcessedBytes > 0 {
fmt.Fprintf(writer, "volume %d processed %s bytes\n", volumeId, util.BytesToHumanReadable(uint64(resp.ProcessedBytes)))
}
}
return nil
})
}
// ConfigureVolumeReplication sets the replication setting on the volume at server.
func (m *Mover) ConfigureVolumeReplication(ctx context.Context, volumeId needle.VolumeId, server pb.ServerAddress, replication string) error {
return m.withClient(false, server, func(client volume_server_pb.VolumeServerClient) error {
resp, configureErr := client.VolumeConfigure(ctx, &volume_server_pb.VolumeConfigureRequest{
VolumeId: uint32(volumeId),
Replication: replication,
})
if configureErr != nil {
return configureErr
}
if resp.Error != "" {
return errors.New(resp.Error)
}
return nil
})
}