mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-08-15 11:47:36 +00:00
* fix(volume): don't fatal on missing .idx for remote-tiered volume A .vif left behind without its .idx (orphaned by a crashed move, partial copy, or hand-edit) would trip glog.Fatalf in checkIdxFile and take the whole volume server down on boot, killing every healthy volume on it too. For remote-tiered volumes treat it as a per-volume load error so the server can come up and the operator can clean up the stray .vif. Refs #9331. * fix(balance): skip remote-tiered volumes in admin balance detection The admin/worker balance detector had no equivalent of the shell-side guard ("does not move volume in remote storage" in command_volume_balance.go), so it scheduled moves on remote-tiered volumes. The "move" copies .idx/.vif to the destination and then calls Volume.Destroy on the source, which calls backendStorage.DeleteFile — deleting the remote object the destination's new .vif now points at. Populate HasRemoteCopy on the metrics emitted by both the admin maintenance scanner and the worker's master poll, then drop those volumes at the top of Detection. Fixes #9331. * Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix(volume): keep remote data on volume-move-driven delete The on-source delete after a volume move (admin/worker balance and shell volume.move) ran Volume.Destroy with no way to opt out of the remote-object cleanup. Volume.Destroy unconditionally calls backendStorage.DeleteFile for remote-tiered volumes, so a successful move would copy .idx/.vif to the destination and then nuke the cloud object the destination's new .vif was already pointing at. Add VolumeDeleteRequest.keep_remote_data and plumb it through Store.DeleteVolume / DiskLocation.DeleteVolume / Volume.Destroy. The balance task and shell volume.move set it to true; the post-tier-upload cleanup of other replicas and the over-replication trim in volume.fix.replication also set it to true since the remote object is still referenced. Other real-delete callers keep the default. The delete-before-receive path in VolumeCopy also sets it: the inbound copy carries a .vif that may reference the same cloud object as the existing volume. Refs #9331. * test(storage): in-process remote-tier integration tests Cover the four operations the user is most likely to run against a cloud-tiered volume — balance/move, vacuum, EC encode, EC decode — by registering a local-disk-backed BackendStorage as the "remote" tier and exercising the real Volume / DiskLocation / EC encoder code paths. Locks in: - Destroy(keepRemoteData=true) preserves the remote object (move case) - Destroy(keepRemoteData=false) deletes it (real-delete case) - Vacuum/compact on a remote-tier volume never deletes the remote object - EC encode requires the local .dat (callers must download first) - EC encode + rebuild round-trips after a tier-down Tests run in-process and finish in under a second total — no cluster, binary, or external storage required. * fix(rust-volume): keep remote data on volume-move-driven delete Mirror the Go fix in seaweed-volume: plumb keep_remote_data through grpc volume_delete → Store.delete_volume → DiskLocation.delete_volume → Volume.destroy, and skip the s3-tier delete_file call when the flag is set. The pre-receive cleanup in volume_copy passes true for the same reason as the Go side: the inbound copy carries a .vif that may reference the same cloud object as the existing volume. The Rust loader already warns rather than fataling on a stray .vif without an .idx (volume.rs load_index_inmemory / load_index_redb), so no counterpart to the Go fatal-on-missing-idx fix is needed. Refs #9331. * fix(volume): preserve remote tier on IO-error eviction; fix EC test target Two review nits: - Store.MaybeAddVolumes' periodic cleanup pass deleted IO-errored volumes with keepRemoteData=false, so a transient local fault on a remote-tiered volume would also nuke the cloud object. Track the delete reason via a parallel slice and pass keepRemoteData=v.HasRemoteFile() for IO-error evictions; TTL-expired evictions still pass false. - TestRemoteTier_ECEncodeDecode_AfterDownload deleted shards 0..3 but called them "parity" — by the klauspost/reedsolomon convention shards 0..DataShardsCount-1 are data and DataShardsCount..TotalShardsCount-1 are parity. Switch the loop to delete the parity range so the intent matches the indices. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
306 lines
10 KiB
Go
306 lines
10 KiB
Go
package balance
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/operation"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/worker_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/worker/types/base"
|
|
"google.golang.org/grpc"
|
|
)
|
|
|
|
// BalanceTask implements the Task interface
|
|
type BalanceTask struct {
|
|
*base.BaseTask
|
|
server string
|
|
volumeID uint32
|
|
collection string
|
|
progress float64
|
|
grpcDialOption grpc.DialOption
|
|
}
|
|
|
|
// NewBalanceTask creates a new balance task instance
|
|
func NewBalanceTask(id string, server string, volumeID uint32, collection string, grpcDialOption grpc.DialOption) *BalanceTask {
|
|
return &BalanceTask{
|
|
BaseTask: base.NewBaseTask(id, types.TaskTypeBalance),
|
|
server: server,
|
|
volumeID: volumeID,
|
|
collection: collection,
|
|
grpcDialOption: grpcDialOption,
|
|
}
|
|
}
|
|
|
|
// Execute implements the Task interface
|
|
func (t *BalanceTask) Execute(ctx context.Context, params *worker_pb.TaskParams) error {
|
|
if params == nil {
|
|
return fmt.Errorf("task parameters are required")
|
|
}
|
|
|
|
balanceParams := params.GetBalanceParams()
|
|
if balanceParams == nil {
|
|
return fmt.Errorf("balance parameters are required")
|
|
}
|
|
|
|
// Get source and destination from unified arrays
|
|
if len(params.Sources) == 0 {
|
|
return fmt.Errorf("source is required for balance task")
|
|
}
|
|
if len(params.Targets) == 0 {
|
|
return fmt.Errorf("target is required for balance task")
|
|
}
|
|
|
|
sourceNode := params.Sources[0].Node
|
|
destNode := params.Targets[0].Node
|
|
|
|
if sourceNode == "" {
|
|
return fmt.Errorf("source node is required for balance task")
|
|
}
|
|
if destNode == "" {
|
|
return fmt.Errorf("destination node is required for balance task")
|
|
}
|
|
|
|
t.GetLogger().WithFields(map[string]interface{}{
|
|
"volume_id": t.volumeID,
|
|
"source": sourceNode,
|
|
"destination": destNode,
|
|
"collection": t.collection,
|
|
}).Info("Starting balance task - moving volume")
|
|
|
|
sourceServer := pb.ServerAddress(sourceNode)
|
|
targetServer := pb.ServerAddress(destNode)
|
|
volumeId := needle.VolumeId(t.volumeID)
|
|
|
|
// Step 1: Mark volume readonly
|
|
t.ReportProgress(10.0)
|
|
t.GetLogger().Info("Marking volume readonly for move")
|
|
if err := t.markVolumeReadonly(ctx, sourceServer, volumeId); err != nil {
|
|
return fmt.Errorf("failed to mark volume readonly: %v", err)
|
|
}
|
|
// Restore source writability if any subsequent step fails, so the
|
|
// source volume is not left permanently readonly on abort.
|
|
sourceMarkedReadonly := true
|
|
defer func() {
|
|
if sourceMarkedReadonly {
|
|
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cleanupCancel()
|
|
if wErr := t.markVolumeWritable(cleanupCtx, sourceServer, volumeId); wErr != nil {
|
|
glog.Warningf("failed to restore volume %d writability on %s: %v", volumeId, sourceServer, wErr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Step 2: Read source volume size before copy (for post-copy verification)
|
|
t.ReportProgress(15.0)
|
|
sourceStatus, err := t.readVolumeFileStatus(ctx, sourceServer, volumeId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read source volume status: %v", err)
|
|
}
|
|
|
|
// Step 3: Copy volume to destination (VolumeCopy also mounts the volume)
|
|
t.ReportProgress(20.0)
|
|
t.GetLogger().Info("Copying volume to destination")
|
|
lastAppendAtNs, err := t.copyVolume(ctx, sourceServer, targetServer, volumeId)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to copy volume: %v", err)
|
|
}
|
|
|
|
// Step 4: Tail for updates
|
|
t.ReportProgress(70.0)
|
|
t.GetLogger().Info("Syncing final updates")
|
|
if err := t.tailVolume(ctx, sourceServer, targetServer, volumeId, lastAppendAtNs); err != nil {
|
|
glog.Warningf("Tail operation failed (may be normal): %v", err)
|
|
}
|
|
|
|
// Step 5: Verify the volume on target before deleting source.
|
|
// This is a critical safety check — once the source is deleted, data loss
|
|
// is irreversible. We verify the target has the volume with matching size.
|
|
t.ReportProgress(85.0)
|
|
t.GetLogger().Info("Verifying volume on target before deleting source")
|
|
targetStatus, err := t.readVolumeFileStatus(ctx, targetServer, volumeId)
|
|
if err != nil {
|
|
return fmt.Errorf("aborting: cannot verify volume %d on target %s before deleting source: %v", volumeId, targetServer, err)
|
|
}
|
|
if targetStatus.DatFileSize != sourceStatus.DatFileSize {
|
|
return fmt.Errorf("aborting: volume %d .dat size mismatch — source %d bytes, target %d bytes",
|
|
volumeId, sourceStatus.DatFileSize, targetStatus.DatFileSize)
|
|
}
|
|
if targetStatus.FileCount != sourceStatus.FileCount {
|
|
return fmt.Errorf("aborting: volume %d file count mismatch — source %d, target %d",
|
|
volumeId, sourceStatus.FileCount, targetStatus.FileCount)
|
|
}
|
|
if targetStatus.IdxFileSize != sourceStatus.IdxFileSize {
|
|
return fmt.Errorf("aborting: volume %d .idx size mismatch — source %d bytes, target %d bytes",
|
|
volumeId, sourceStatus.IdxFileSize, targetStatus.IdxFileSize)
|
|
}
|
|
|
|
// Step 6: Delete from source — after this, the move is committed.
|
|
// Clear the readonly flag so the defer doesn't try to restore writability.
|
|
t.ReportProgress(90.0)
|
|
t.GetLogger().Info("Deleting volume from source server")
|
|
if err := t.deleteVolume(ctx, sourceServer, volumeId); err != nil {
|
|
return fmt.Errorf("failed to delete volume from source: %v", err)
|
|
}
|
|
sourceMarkedReadonly = false
|
|
|
|
t.ReportProgress(100.0)
|
|
glog.Infof("Balance task completed successfully: volume %d moved from %s to %s",
|
|
t.volumeID, sourceNode, destNode)
|
|
return nil
|
|
}
|
|
|
|
// Validate implements the UnifiedTask interface
|
|
func (t *BalanceTask) Validate(params *worker_pb.TaskParams) error {
|
|
if params == nil {
|
|
return fmt.Errorf("task parameters are required")
|
|
}
|
|
|
|
balanceParams := params.GetBalanceParams()
|
|
if balanceParams == nil {
|
|
return fmt.Errorf("balance parameters are required")
|
|
}
|
|
|
|
if params.VolumeId != t.volumeID {
|
|
return fmt.Errorf("volume ID mismatch: expected %d, got %d", t.volumeID, params.VolumeId)
|
|
}
|
|
|
|
// Validate that at least one source matches our server
|
|
found := false
|
|
for _, source := range params.Sources {
|
|
if source.Node == t.server {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("no source matches expected server %s", t.server)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// EstimateTime implements the UnifiedTask interface
|
|
func (t *BalanceTask) EstimateTime(params *worker_pb.TaskParams) time.Duration {
|
|
// Basic estimate based on simulated steps
|
|
return 14 * time.Second // Sum of all step durations
|
|
}
|
|
|
|
// GetProgress returns current progress
|
|
func (t *BalanceTask) GetProgress() float64 {
|
|
return t.progress
|
|
}
|
|
|
|
// Helper methods for real balance operations
|
|
|
|
// markVolumeReadonly marks the volume readonly on the source server.
|
|
func (t *BalanceTask) markVolumeReadonly(ctx context.Context, server pb.ServerAddress, volumeId needle.VolumeId) error {
|
|
return operation.WithVolumeServerClient(false, server, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
_, err := client.VolumeMarkReadonly(ctx, &volume_server_pb.VolumeMarkReadonlyRequest{
|
|
VolumeId: uint32(volumeId),
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// markVolumeWritable restores the volume to writable on the source server.
|
|
func (t *BalanceTask) markVolumeWritable(ctx context.Context, server pb.ServerAddress, volumeId needle.VolumeId) error {
|
|
return operation.WithVolumeServerClient(false, server, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
_, err := client.VolumeMarkWritable(ctx, &volume_server_pb.VolumeMarkWritableRequest{
|
|
VolumeId: uint32(volumeId),
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// copyVolume copies volume from source to target server.
|
|
func (t *BalanceTask) copyVolume(ctx context.Context, sourceServer, targetServer pb.ServerAddress, volumeId needle.VolumeId) (uint64, error) {
|
|
var lastAppendAtNs uint64
|
|
|
|
err := operation.WithVolumeServerClient(true, targetServer, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
stream, err := client.VolumeCopy(ctx, &volume_server_pb.VolumeCopyRequest{
|
|
VolumeId: uint32(volumeId),
|
|
SourceDataNode: string(sourceServer),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for {
|
|
resp, recvErr := stream.Recv()
|
|
if recvErr != nil {
|
|
if recvErr == io.EOF {
|
|
break
|
|
}
|
|
return recvErr
|
|
}
|
|
|
|
if resp.LastAppendAtNs != 0 {
|
|
lastAppendAtNs = resp.LastAppendAtNs
|
|
} else {
|
|
// Report copy progress
|
|
glog.V(1).Infof("Volume %d copy progress: %s", volumeId,
|
|
util.BytesToHumanReadable(uint64(resp.ProcessedBytes)))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return lastAppendAtNs, err
|
|
}
|
|
|
|
// tailVolume syncs remaining updates from source to target.
|
|
func (t *BalanceTask) tailVolume(ctx context.Context, sourceServer, targetServer pb.ServerAddress, volumeId needle.VolumeId, sinceNs uint64) error {
|
|
return operation.WithVolumeServerClient(true, targetServer, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
_, err := client.VolumeTailReceiver(ctx, &volume_server_pb.VolumeTailReceiverRequest{
|
|
VolumeId: uint32(volumeId),
|
|
SinceNs: sinceNs,
|
|
IdleTimeoutSeconds: 60, // 1 minute timeout
|
|
SourceVolumeServer: string(sourceServer),
|
|
})
|
|
return err
|
|
})
|
|
}
|
|
|
|
// readVolumeFileStatus reads the volume's file status (sizes, file count) from a server.
|
|
func (t *BalanceTask) readVolumeFileStatus(ctx context.Context, server pb.ServerAddress, volumeId needle.VolumeId) (*volume_server_pb.ReadVolumeFileStatusResponse, error) {
|
|
var resp *volume_server_pb.ReadVolumeFileStatusResponse
|
|
err := operation.WithVolumeServerClient(false, server, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
var err error
|
|
resp, err = client.ReadVolumeFileStatus(ctx,
|
|
&volume_server_pb.ReadVolumeFileStatusRequest{
|
|
VolumeId: uint32(volumeId),
|
|
})
|
|
return err
|
|
})
|
|
return resp, err
|
|
}
|
|
|
|
// deleteVolume deletes the volume from the source server after a successful
|
|
// move. KeepRemoteData=true prevents the source from removing the cloud-tier
|
|
// object that the destination's freshly-copied .vif now points at.
|
|
func (t *BalanceTask) deleteVolume(ctx context.Context, server pb.ServerAddress, volumeId needle.VolumeId) error {
|
|
return operation.WithVolumeServerClient(false, server, t.grpcDialOption,
|
|
func(client volume_server_pb.VolumeServerClient) error {
|
|
_, err := client.VolumeDelete(ctx, &volume_server_pb.VolumeDeleteRequest{
|
|
VolumeId: uint32(volumeId),
|
|
OnlyEmpty: false,
|
|
KeepRemoteData: true,
|
|
})
|
|
return err
|
|
})
|
|
}
|