mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-31 17:30:42 +01:00
* volume.copy: reject only on file-count drift, warn on deleted-count Go warned and kept the copy on any count mismatch. The file count is exact on both sides of a stable source, so a mismatch means a genuinely bad copy and should reject; only the deleted count is a rebuild-time estimate that legitimately drifts. Match the Rust check_copy_counts, which warns solely on the counter that can drift. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume.copy: stage the copy and validate before dropping the old replica The destination replica was deleted before the copy even started, so a copy that failed its checks — now including the file-count validation — left the server with no replica at all. The copy now writes under a copying/ staging dir the volume loader never scans, the file-size and count checks run on the staged .idx (LoadCompactNeedleMap gives the same counters the mount would), and only then is the existing replica deleted and the staged files renamed into place. A rejected or failed copy leaves the previous replica untouched, and the .note marker is no longer needed since partial staged files can never be loaded. * volume.copy: remove only the files a failed copy moved into place * volume.copy: keep the .note in-progress marker through the renames A crash between the staged-file renames left a loadable volume without its .vif (writable where the source was read-only) or a partial set the load scan could not find. Write the .note before the renames and land the .vif first so any partial state is discovered and swept, then drop the note before the mount. * volume.copy: serialize copies of the same volume Two copies of the same volume write the same staged copying/ file names and can pass the existing-volume check before the first one mounts. Fail a concurrent second copy as retryable, matching the Rust volume server's volume_copies_in_flight. * volume.copy: restore the replaced replica when the swap fails Once the staged copy validates, the outgoing replica's files are hard-linked into copying/ before DeleteVolume unlinks them. If a later rename or the mount then fails, the preserved files are renamed back and remounted, so a failed swap no longer leaves the node with no replica. The links are removed on success. * volume.copy: clear stale needle-map files around the swap A leftover .ldb/.sdx/.rdb at the destination base is trusted over the fresh .idx the mount loads, and a mount that fails leaves the backends it built. Clear them before the staged files land and again when a failed copy cleans up, gated on no live replica owning them. Also keep <base>.vif on cleanup while an .ecx index shares the basename. * volume.copy: skip the swap when the caller has gone A caller that left during the copy cannot use the result; bail before the swap deletes the replica it would replace. * volume.copy: discard a mounted copy when the result send fails A mounted copy was already announced to the master, so a later failure deletes it through the store to withdraw the announcement before the files go away, rather than unlinking files out from under a registered replica. * volume.copy: keep a vif-less copy discoverable on a split index dir With -dir.idx the staged .idx lands outside the data dir, so a copy whose source has no .vif leaves nothing the volume scan can find and the mount fails after the replica it replaced was already deleted. An empty .vif marks the basename; the loader treats it as absent and writes a real one on mount. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * volume.copy: preserve the replaced replica on its own disk * volume.copy: restore the replaced replica's .vif past a kept incoming one * test: cover the rollback link/restore helpers * volume.copy: preserve the mounted replica's own files for swap rollback The swap backup linked files at the copy's target paths, which are wrong when the replica sits on another disk or keeps its .idx beside the .dat; a failed swap then restored nothing. Link the mounted volume's real paths and require every present file to link before the delete, since a partial backup cannot roll back. Re-check the store at swap time so a replica that mounted mid-copy is replaced rather than overwritten, and drop a .kept incoming .vif that occupies the path the replica's own .vif restores to. * volume.copy: clear a stale .idx a mount would prefer over the copy A load switches dirIdx to the data dir when an .idx sits beside the .dat, so a leftover <data>.idx shadows the fresh index the copy lands in the idx dir, and a surviving <idx>.idx fills in when the source had no index at all. Clearing only the derived backends still let a stale needle map reach the mount. * volume.copy: leave the shared copying/ dir for racing copies Copies of different volumes serialize per volume id but share the disk's copying/ dir; a finishing copy could rmdir it between another copy's MkdirAll and its first staged write, failing that copy with ENOENT. * volume.copy: fail replica preservation on stat errors other than not-found * volume.copy: refuse a mount while that volume is being copied A copy claims the volume id, then waits on the source with the replacement staged. A mount in that window loads the replica whose files the swap then clears; refuse it with Unavailable, retryable once the copy settles. Mirrors the same refusal on the Rust volume server. * volume.copy: refuse configure while that volume is being copied Configure unmounts and remounts; during a copy that already claimed the volume id the remount loads files the swap then unlinks. Refuse with Unavailable, retryable once the copy settles. Mirrors the Rust volume server's same refusal. * volume.copy: leave a still-standing replica's index on a rejected copy * volume.copy: preserve an unmounted replica's files for swap rollback * volume.copy: claim the in-flight id in mount and configure, not just check it * volume.copy: keep an EC-shared .vif when displacing an unmounted replica * volume.copy: restore replaced files over a surviving partial restoreReplacedVolumeFiles deleted a preserved link whenever the base name was still taken, on the assumption a landed file there belonged to the copy. When a moved-file removal fails, that left a surviving partial paired with the restored set's other files — mixed generations in one replica. A leftover at the basename now comes off first so the backup goes back, and the link is kept whenever it cannot go back. * volume.copy: link a repeated replaced path once so rollback cannot delete it * volume.copy: refuse allocation and staged adoption under the copy claim AllocateVolume and adoptStagedVolume still registered a volume while a copy of it held the destination: NewVolume loads the index before registration, and the copy then truncates or replaces files the live replica already opened, leaving it mounted on a stale index. Both paths now claim the volume id like VolumeMount and VolumeConfigure, so registration either wins the claim before the copy starts or sees it and refuses with Unavailable. * volume.copy: serialize reload loads with the copy claim A SIGHUP reload ran loadExistingVolume outside the copy's claim: its .note branch could unlink a copy's files mid-write, and its NewVolume+SetVolume could register a replica between the copy's recheck and its swap, leaving a live volume on a stale index. loadExistingVolume now consults a per-location hook the server installs: a vid whose claim is held is skipped for the next pass; otherwise the load takes the claim and its release drops it after registration, so whichever side claims the id first owns the files for the operation. * volume.copy: let a claim holder's own mount past the load guard VolumeMount, VolumeConfigure, VolumeCopy, and adoptStagedVolume all hold the per-volume claim while calling store.MountVolume, which reaches loadExistingVolume where the load guard saw the caller's own claim and skipped the disk: every mount failed with 'volume not found on disk'. Plumb claimHeld through MountVolume -> LoadVolume -> loadExistingVolume via the new MountVolumeClaimed entry point so an owner's own load proceeds while loads without the claim are still skipped. * volume.copy: keep the strict count check when only deletes drift Deletes append tombstone rows without moving the file count, so a copy whose source only deleted mid-flight still has an exactly comparable file count. Gate the strict check on file-count stability alone; the deleted-count drift is already a warning inside checkCopyCounts. * volume.copy: abort the swap when stale index files cannot be removed A surviving .ldb/.sdx/.idx is trusted over the copied .idx at mount, so a failed removal must stop the copy before the staged files land, not be swallowed. Mirrors the Rust side's remove_partial_files abort. * volume.copy: clear staged leftovers before starting a copy The staging names are fixed per volume, so a crashed copy can leave files under copying/; a retry whose new source lacks an optional file (.vif, or .dat on a remote volume) would rename the previous run's bytes into place. Clear them up front and refuse the copy when they cannot be removed. --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1315 lines
51 KiB
Go
1315 lines
51 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/backend"
|
|
|
|
"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/storage"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/idx"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
const BufferSizeLimit = 1024 * 1024 * 2
|
|
|
|
// volumeCopyGuard serializes a copy's registration recheck and file
|
|
// clearing against mounts and configures of the same volume.
|
|
type volumeCopyGuard struct {
|
|
sync.Mutex
|
|
copying bool
|
|
}
|
|
|
|
func (vs *VolumeServer) volumeCopyGuard(volumeId uint32) *volumeCopyGuard {
|
|
guard, _ := vs.volumeCopyGuards.LoadOrStore(volumeId, &volumeCopyGuard{})
|
|
return guard.(*volumeCopyGuard)
|
|
}
|
|
|
|
// volumeLoadGuard is the storage-layer hook for volume loads: a vid with a
|
|
// copy in flight is skipped, otherwise the returned release keeps the load's
|
|
// registration ordered against the copy's own recheck. A copy holding the
|
|
// guard means the same skip.
|
|
func (vs *VolumeServer) volumeLoadGuard(vid needle.VolumeId) (func(), bool) {
|
|
guard := vs.volumeCopyGuard(uint32(vid))
|
|
if !guard.TryLock() {
|
|
return func() {}, true
|
|
}
|
|
if guard.copying {
|
|
guard.Unlock()
|
|
return func() {}, true
|
|
}
|
|
return guard.Unlock, false
|
|
}
|
|
|
|
// VolumeCopy copy the .idx .dat .vif files, and mount the volume
|
|
func (vs *VolumeServer) VolumeCopy(req *volume_server_pb.VolumeCopyRequest, stream volume_server_pb.VolumeServer_VolumeCopyServer) error {
|
|
if err := vs.checkGrpcAdminAuth(stream.Context()); err != nil {
|
|
return err
|
|
}
|
|
if err := vs.CheckMaintenanceMode(); err != nil {
|
|
return err
|
|
}
|
|
if !vs.AllowUntrustedRemoteEndpoints {
|
|
if err := validateReplicaTarget(stream.Context(), req.SourceDataNode); err != nil {
|
|
return fmt.Errorf("invalid source data node %s: %w", req.SourceDataNode, err)
|
|
}
|
|
}
|
|
|
|
// One copy of a volume at a time: the staged file names are fixed per
|
|
// volume, so a racing second copy would overwrite the files this copy
|
|
// still owns. Retryable once the in-flight copy settles.
|
|
guard := vs.volumeCopyGuard(req.VolumeId)
|
|
guard.Lock()
|
|
if guard.copying {
|
|
guard.Unlock()
|
|
return status.Errorf(codes.Unavailable, "volume %d is already being copied", req.VolumeId)
|
|
}
|
|
guard.copying = true
|
|
guard.Unlock()
|
|
defer func() {
|
|
guard.Lock()
|
|
guard.copying = false
|
|
guard.Unlock()
|
|
}()
|
|
|
|
// A pre-existing local replica is NOT deleted up front. Deleting before the
|
|
// source is confirmed reachable destroys a healthy copy on a transient
|
|
// source outage (and, on retry, can lose the volume entirely). The copy is
|
|
// staged next to it and validated first; the delete only runs once the
|
|
// replacement is known good (validation details below).
|
|
|
|
// the master will not start compaction for read-only volumes, so it is safe to just copy files directly
|
|
// copy .dat and .idx files
|
|
// read .idx .dat file size and timestamp
|
|
// send .idx file
|
|
// send .dat file
|
|
// confirm size and timestamp
|
|
var volFileInfoResp *volume_server_pb.ReadVolumeFileStatusResponse
|
|
var sourceVolumeStatus *volume_server_pb.VolumeStatusResponse
|
|
var sourceVolumeStatusAfterCopy *volume_server_pb.VolumeStatusResponse
|
|
var dataBaseFileName, indexBaseFileName, idxFileName, datFileName, vifFileName string
|
|
var stagedDataBaseFileName, stagedIndexBaseFileName string
|
|
var stagedIdxFileName, stagedDatFileName, stagedVifFileName string
|
|
var movedFiles []string
|
|
var replacedLinks []string
|
|
var replacedRemoved, mounted bool
|
|
var hasRemoteDatFile bool
|
|
var copyLocation *storage.DiskLocation
|
|
var copyReservedSpace uint64
|
|
err := operation.WithVolumeServerClientOptions(true, pb.ServerAddress(req.SourceDataNode), func(client volume_server_pb.VolumeServerClient) (err error) {
|
|
sourceVolumeStatus, err = client.VolumeStatus(stream.Context(), &volume_server_pb.VolumeStatusRequest{
|
|
VolumeId: req.VolumeId,
|
|
})
|
|
if err != nil {
|
|
glog.Warningf("failed to read source volume %d status before copy; skip record count validation: %v", req.VolumeId, err)
|
|
sourceVolumeStatus = nil
|
|
}
|
|
|
|
volFileInfoResp, err = client.ReadVolumeFileStatus(stream.Context(),
|
|
&volume_server_pb.ReadVolumeFileStatusRequest{
|
|
VolumeId: req.VolumeId,
|
|
})
|
|
if nil != err {
|
|
return fmt.Errorf("read volume file status failed, %w", err)
|
|
}
|
|
|
|
diskType := volFileInfoResp.DiskType
|
|
if req.DiskType != "" {
|
|
diskType = req.DiskType
|
|
}
|
|
hasRemoteDatFile = volFileInfoResp.VolumeInfo != nil && len(volFileInfoResp.VolumeInfo.Files) > 0
|
|
|
|
var preallocateSize int64
|
|
if grpcErr := pb.WithMasterClient(context.Background(), false, vs.GetMaster(context.Background()), vs.grpcDialOption, false, func(client master_pb.SeaweedClient) error {
|
|
resp, err := client.GetMasterConfiguration(context.Background(), &master_pb.GetMasterConfigurationRequest{})
|
|
if err != nil {
|
|
return fmt.Errorf("get master %s configuration: %v", vs.GetMaster(context.Background()), err)
|
|
}
|
|
if resp.VolumePreallocate {
|
|
preallocateSize = int64(resp.VolumeSizeLimitMB) * (1 << 20)
|
|
}
|
|
return nil
|
|
}); grpcErr != nil {
|
|
glog.V(0).Infof("connect to %s: %v", vs.GetMaster(context.Background()), grpcErr)
|
|
}
|
|
|
|
// a remote-backed volume only lands its .idx/.vif locally; the .dat stays in the tier
|
|
neededSpace := volFileInfoResp.DatFileSize
|
|
if hasRemoteDatFile {
|
|
neededSpace = volFileInfoResp.IdxFileSize
|
|
} else if backend.PreallocateSupported && uint64(preallocateSize) > neededSpace {
|
|
// the .dat reserves the master's volume size limit up front, so the
|
|
// location must fit the reservation, not just the copied bytes
|
|
neededSpace = uint64(preallocateSize)
|
|
}
|
|
location := vs.store.ReserveFreeLocation(neededSpace, func(location *storage.DiskLocation) bool {
|
|
return location.DiskType == types.ToDiskType(diskType) &&
|
|
location.AvailableSpace.Load() > neededSpace
|
|
}, needle.VolumeId(req.VolumeId))
|
|
if location == nil {
|
|
return fmt.Errorf("%s %s", util.ErrVolumeNoSpaceLeft, types.ToDiskType(diskType).ReadableString())
|
|
}
|
|
copyLocation = location
|
|
copyReservedSpace = neededSpace
|
|
|
|
// The headroom charge covers bytes promised but not yet visible to
|
|
// statfs; as the copy's bytes land they stop needing the charge, so a
|
|
// slow copy does not count its own .dat against the free space a
|
|
// second reservation needs.
|
|
commitCopy := func(n uint64) {
|
|
if n > copyReservedSpace {
|
|
n = copyReservedSpace
|
|
}
|
|
if n > 0 {
|
|
vs.store.CommitFreeLocation(location, n)
|
|
copyReservedSpace -= n
|
|
}
|
|
}
|
|
newCopyProgress := func(skip uint64) func(int64) {
|
|
counted := int64(skip)
|
|
return func(processed int64) {
|
|
if processed > counted {
|
|
commitCopy(uint64(processed - counted))
|
|
counted = processed
|
|
}
|
|
}
|
|
}
|
|
|
|
dataBaseFileName = storage.VolumeFileName(location.Directory, volFileInfoResp.Collection, int(req.VolumeId))
|
|
indexBaseFileName = storage.VolumeFileName(location.IdxDirectory, volFileInfoResp.Collection, int(req.VolumeId))
|
|
|
|
// The copy writes into a copying/ staging dir the volume loader never
|
|
// scans: every staged byte is this copy's own, a live replica at the
|
|
// final basename is never touched by the copy, and a failed copy can
|
|
// always clean up after itself.
|
|
stagedDataBaseFileName = filepath.Join(location.Directory, "copying", filepath.Base(dataBaseFileName))
|
|
stagedIndexBaseFileName = filepath.Join(location.IdxDirectory, "copying", filepath.Base(indexBaseFileName))
|
|
if mkErr := os.MkdirAll(filepath.Dir(stagedDataBaseFileName), 0755); mkErr != nil {
|
|
err = mkErr
|
|
return fmt.Errorf("create copying dir for volume %d: %w", req.VolumeId, mkErr)
|
|
}
|
|
if mkErr := os.MkdirAll(filepath.Dir(stagedIndexBaseFileName), 0755); mkErr != nil {
|
|
err = mkErr
|
|
return fmt.Errorf("create copying dir for volume %d: %w", req.VolumeId, mkErr)
|
|
}
|
|
stagedIdxFileName = stagedIndexBaseFileName + ".idx"
|
|
stagedDatFileName = stagedDataBaseFileName + ".dat"
|
|
stagedVifFileName = stagedDataBaseFileName + ".vif"
|
|
|
|
defer func() {
|
|
if err != nil {
|
|
os.Remove(stagedDatFileName)
|
|
os.Remove(stagedIdxFileName)
|
|
os.Remove(stagedVifFileName)
|
|
os.Remove(stagedVifFileName + ".copying")
|
|
if dataBaseFileName != "" {
|
|
// A .note only marks an in-flight copy; a partial write
|
|
// must not be left to fail the next volume load.
|
|
os.Remove(dataBaseFileName + ".note")
|
|
}
|
|
// Zero before the outer defer sees the same error, or the
|
|
// charge would be released twice.
|
|
vs.store.ReleaseFreeLocation(location, copyReservedSpace)
|
|
copyReservedSpace = 0
|
|
}
|
|
}()
|
|
|
|
// The staging names are fixed per volume, so a crashed copy can
|
|
// leave files behind; an optional file the new source lacks (.vif,
|
|
// or .dat on a remote volume) must not inherit the previous run's.
|
|
for _, leftover := range []string{stagedDatFileName, stagedIdxFileName, stagedVifFileName, stagedDatFileName + ".copying", stagedVifFileName + ".copying"} {
|
|
if rmErr := os.Remove(leftover); rmErr != nil && !os.IsNotExist(rmErr) {
|
|
return fmt.Errorf("clear staged %s for volume %d: %w", leftover, req.VolumeId, rmErr)
|
|
}
|
|
}
|
|
|
|
var copyDatAllocated uint64
|
|
if preallocateSize > 0 && !hasRemoteDatFile {
|
|
volumeFile := stagedDataBaseFileName + ".dat"
|
|
_, err := backend.CreateVolumeFile(volumeFile, preallocateSize, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("create volume file %s: %v", volumeFile, err)
|
|
}
|
|
// fallocate already holds those bytes on disk; when the
|
|
// filesystem did not reserve them the write progress below
|
|
// settles the charge as the bytes land instead.
|
|
if fi, statErr := os.Stat(volumeFile); statErr == nil {
|
|
copyDatAllocated = storage.AllocatedSize(fi)
|
|
commitCopy(copyDatAllocated)
|
|
}
|
|
}
|
|
|
|
// println("source:", volFileInfoResp.String())
|
|
copyResponse := &volume_server_pb.VolumeCopyResponse{}
|
|
reportInterval := int64(1024 * 1024 * 128)
|
|
nextReportTarget := reportInterval
|
|
var modifiedTsNs int64
|
|
var sendErr error
|
|
var ioBytePerSecond int64
|
|
if req.IoBytePerSecond <= 0 {
|
|
ioBytePerSecond = vs.maintenanceBytePerSecond
|
|
} else {
|
|
ioBytePerSecond = req.IoBytePerSecond
|
|
}
|
|
throttler := util.NewWriteThrottler(ioBytePerSecond)
|
|
|
|
if !hasRemoteDatFile {
|
|
datProgress := newCopyProgress(copyDatAllocated)
|
|
// Write into the .dat CreateVolumeFile just made so the
|
|
// fallocated blocks carry over to the final name at rename.
|
|
if modifiedTsNs, err = vs.doCopyFileWithThrottler(client, false, req.Collection, req.VolumeId, volFileInfoResp.CompactionRevision, volFileInfoResp.DatFileSize, stagedDataBaseFileName, ".dat", preallocateSize > 0, true, func(processed int64) bool {
|
|
datProgress(processed)
|
|
if processed > nextReportTarget {
|
|
copyResponse.ProcessedBytes = processed
|
|
if sendErr = stream.Send(copyResponse); sendErr != nil {
|
|
return false
|
|
}
|
|
nextReportTarget = processed + reportInterval
|
|
}
|
|
return true
|
|
}, throttler); err != nil {
|
|
return err
|
|
}
|
|
if sendErr != nil {
|
|
return sendErr
|
|
}
|
|
if modifiedTsNs > 0 {
|
|
os.Chtimes(stagedDataBaseFileName+".dat", time.Unix(0, modifiedTsNs), time.Unix(0, modifiedTsNs))
|
|
}
|
|
}
|
|
|
|
idxProgress := newCopyProgress(0)
|
|
if modifiedTsNs, err = vs.doCopyFileWithThrottler(client, false, req.Collection, req.VolumeId, volFileInfoResp.CompactionRevision, volFileInfoResp.IdxFileSize, stagedIndexBaseFileName, ".idx", false, false, func(processed int64) bool {
|
|
idxProgress(processed)
|
|
return true
|
|
}, throttler); err != nil {
|
|
return err
|
|
}
|
|
if modifiedTsNs > 0 {
|
|
os.Chtimes(stagedIndexBaseFileName+".idx", time.Unix(0, modifiedTsNs), time.Unix(0, modifiedTsNs))
|
|
}
|
|
|
|
vifProgress := newCopyProgress(0)
|
|
if modifiedTsNs, err = vs.doCopyFileWithThrottler(client, false, req.Collection, req.VolumeId, volFileInfoResp.CompactionRevision, 1024*1024, stagedDataBaseFileName, ".vif", false, true, func(processed int64) bool {
|
|
vifProgress(processed)
|
|
return true
|
|
}, throttler); err != nil {
|
|
return err
|
|
}
|
|
if modifiedTsNs > 0 {
|
|
os.Chtimes(stagedDataBaseFileName+".vif", time.Unix(0, modifiedTsNs), time.Unix(0, modifiedTsNs))
|
|
}
|
|
|
|
var statusErr error
|
|
sourceVolumeStatusAfterCopy, statusErr = client.VolumeStatus(stream.Context(), &volume_server_pb.VolumeStatusRequest{
|
|
VolumeId: req.VolumeId,
|
|
})
|
|
if statusErr != nil {
|
|
err = fmt.Errorf("read source volume %d status after copy failed: %w", req.VolumeId, statusErr)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}, vs.grpcDialOption, vs.guardedGrpcDialOption(req.SourceDataNode))
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if dataBaseFileName == "" {
|
|
return fmt.Errorf("not found volume %d file", req.VolumeId)
|
|
}
|
|
|
|
idxFileName = indexBaseFileName + ".idx"
|
|
datFileName = dataBaseFileName + ".dat"
|
|
vifFileName = dataBaseFileName + ".vif"
|
|
|
|
defer func() {
|
|
if copyLocation == nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
keepFiles := false
|
|
if mounted {
|
|
// A mounted copy was already announced to the master; delete
|
|
// through the store so the announcement is withdrawn before
|
|
// the files go away. keepRemoteData: the .vif may point at
|
|
// the same cloud-tier object the source still references.
|
|
if delErr := vs.store.DeleteVolume(needle.VolumeId(req.VolumeId), false, false, true); delErr != nil {
|
|
glog.Warningf("discard mounted copy of volume %d: %v", req.VolumeId, delErr)
|
|
// A refused delete leaves it registered; unlinking its
|
|
// files now would strand a live replica.
|
|
keepFiles = vs.store.GetVolume(needle.VolumeId(req.VolumeId)) != nil
|
|
}
|
|
}
|
|
os.Remove(stagedIdxFileName)
|
|
os.Remove(stagedDatFileName)
|
|
os.Remove(stagedVifFileName)
|
|
os.Remove(stagedVifFileName + ".copying")
|
|
if !keepFiles {
|
|
// Only files this copy moved into place come off; before the
|
|
// renames the final basenames can still be the replica being
|
|
// replaced, and a rejected copy must leave it intact.
|
|
for _, moved := range movedFiles {
|
|
// A regular volume and an EC volume for the same id share
|
|
// <base>.vif; keep it while the EC index is here.
|
|
if moved == vifFileName && copyLocation.HasEcxFileOnDisk(volFileInfoResp.Collection, needle.VolumeId(req.VolumeId)) {
|
|
continue
|
|
}
|
|
os.Remove(moved)
|
|
}
|
|
// A failed mount leaves the needle-map backends it built;
|
|
// drop them so the next load does not trust a stale index.
|
|
// Only after the renames could those be this copy's — before
|
|
// them the basenames belong to a replica still on disk, and
|
|
// an unmounted one reports no store volume. A replica still
|
|
// standing keeps its files either way.
|
|
if len(movedFiles) > 0 && vs.store.GetVolume(needle.VolumeId(req.VolumeId)) == nil {
|
|
if cleanErr := removeStaleIndexFiles(indexBaseFileName, dataBaseFileName); cleanErr != nil {
|
|
glog.Warningf("clear stale index files of failed copy of volume %d: %v", req.VolumeId, cleanErr)
|
|
}
|
|
}
|
|
if dataBaseFileName != "" {
|
|
os.Remove(dataBaseFileName + ".note")
|
|
}
|
|
if len(replacedLinks) > 0 {
|
|
// The swap failed after the previous replica's files were
|
|
// displaced; put the preserved files back. A surviving
|
|
// partial — like an incoming .vif kept for a coexisting
|
|
// EC volume — is dropped so the original comes back.
|
|
restoreReplacedVolumeFiles(replacedLinks)
|
|
replacedLinks = nil
|
|
// Only a replica the store deleted gets remounted; an
|
|
// unmounted one goes back to files on disk, as found.
|
|
if replacedRemoved {
|
|
if remountErr := vs.store.MountVolumeClaimed(needle.VolumeId(req.VolumeId), &req.Collection); remountErr != nil {
|
|
glog.Warningf("remount replaced replica of volume %d: %v", req.VolumeId, remountErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, link := range replacedLinks {
|
|
os.Remove(link)
|
|
}
|
|
vs.store.ReleaseFreeLocation(copyLocation, copyReservedSpace)
|
|
} else {
|
|
for _, link := range replacedLinks {
|
|
os.Remove(link)
|
|
}
|
|
vs.store.CommitFreeLocation(copyLocation, copyReservedSpace)
|
|
}
|
|
// copying/ is shared by copies of other volumes on the disk, so it
|
|
// stays behind; a leftover staged file is rewritten or removed by the
|
|
// next copy of its volume.
|
|
}()
|
|
|
|
if err = checkCopyFiles(volFileInfoResp, hasRemoteDatFile, stagedIdxFileName, stagedDatFileName); err != nil { // added by panyc16
|
|
return err
|
|
}
|
|
|
|
var lastAppendAtNs = volFileInfoResp.DatFileTimestampSeconds * uint64(time.Second)
|
|
if !hasRemoteDatFile {
|
|
if appendAtNs, appendErr := findLastAppendAtNsFromCopiedFiles(stagedIdxFileName, stagedDatFileName, needle.Version(volFileInfoResp.Version)); appendErr == nil && appendAtNs > 0 {
|
|
lastAppendAtNs = appendAtNs
|
|
} else if appendErr != nil {
|
|
glog.V(1).Infof("failed to find last append timestamp for volume %d: %v", req.VolumeId, appendErr)
|
|
}
|
|
}
|
|
|
|
shouldValidateCopyCounts := copyCountsStable(sourceVolumeStatus, sourceVolumeStatusAfterCopy)
|
|
if !shouldValidateCopyCounts {
|
|
glog.V(1).Infof("source volume %d changed during copy; skip record count validation", req.VolumeId)
|
|
}
|
|
|
|
// The staged copy is checked before the existing replica is dropped, so
|
|
// a rejected copy leaves the replica it was meant to replace in place.
|
|
if shouldValidateCopyCounts {
|
|
stagedIdx, openErr := os.Open(stagedIdxFileName)
|
|
if openErr != nil {
|
|
err = fmt.Errorf("open copied index of volume %d for validation: %w", req.VolumeId, openErr)
|
|
return err
|
|
}
|
|
copiedNeedleMap, mapErr := storage.LoadCompactNeedleMap(stagedIdx, needle.Version(volFileInfoResp.Version))
|
|
stagedIdx.Close()
|
|
if mapErr != nil {
|
|
err = fmt.Errorf("load copied index of volume %d for validation: %w", req.VolumeId, mapErr)
|
|
return err
|
|
}
|
|
if countErr := checkCopyCounts(req.VolumeId, sourceVolumeStatusAfterCopy, uint64(copiedNeedleMap.FileCount()), uint64(copiedNeedleMap.DeletedCount())); countErr != nil {
|
|
err = countErr
|
|
return countErr
|
|
}
|
|
}
|
|
|
|
// A caller that left during the copy cannot use the result; bail before
|
|
// the swap deletes the replica it would replace.
|
|
if ctxErr := stream.Context().Err(); ctxErr != nil {
|
|
err = ctxErr
|
|
return ctxErr
|
|
}
|
|
|
|
// A replica that mounted mid-copy must leave before the swap too, so check
|
|
// the store again rather than the stale pre-copy snapshot.
|
|
if replaced := vs.store.GetVolume(needle.VolumeId(req.VolumeId)); replaced != nil {
|
|
glog.V(0).Infof("volume %d already exists. deleting before copying from %s...", req.VolumeId, req.SourceDataNode)
|
|
// Hard-link the outgoing replica's files into copying/ first: the
|
|
// inodes survive the delete, so a failed swap can put the previous
|
|
// replica back instead of leaving the node with none. The paths come
|
|
// from the mounted volume — it can sit on another disk, with its .idx
|
|
// beside the .dat. A partial backup cannot restore the replica.
|
|
var backupComplete bool
|
|
replacedLinks, backupComplete = linkReplacedVolumeFiles(
|
|
replaced.DataFileName()+".dat",
|
|
replaced.IndexFileName()+".idx",
|
|
replaced.DataFileName()+".vif",
|
|
replaced.DataFileName()+".idx",
|
|
)
|
|
if !backupComplete {
|
|
err = fmt.Errorf("preserve files of existing volume %d for rollback", req.VolumeId)
|
|
return err
|
|
}
|
|
// keep remote data: the inbound copy carries a .vif that may point at
|
|
// the same cloud-tier object the existing volume references.
|
|
if delErr := vs.store.DeleteVolume(needle.VolumeId(req.VolumeId), false, false, true); delErr != nil {
|
|
err = fmt.Errorf("failed to delete existing volume %d: %v", req.VolumeId, delErr)
|
|
return err
|
|
}
|
|
replacedRemoved = true
|
|
glog.V(0).Infof("deleted existing volume %d before copying.", req.VolumeId)
|
|
} else {
|
|
// An unmounted replica at the final basenames gets no store delete:
|
|
// the stale-index cleanup and the renames displace it silently, and a
|
|
// copy without a staged .dat would leave the old one orphaned. Link
|
|
// its files the same way so a failed swap restores them (no remount —
|
|
// it was not mounted), then take them off; refuse the copy when they
|
|
// cannot be preserved.
|
|
var backupComplete bool
|
|
replacedLinks, backupComplete = linkReplacedVolumeFiles(
|
|
datFileName,
|
|
idxFileName,
|
|
vifFileName,
|
|
dataBaseFileName+".idx",
|
|
dataBaseFileName+".note",
|
|
)
|
|
if !backupComplete {
|
|
err = fmt.Errorf("preserve files of existing volume %d for rollback", req.VolumeId)
|
|
return err
|
|
}
|
|
for _, replacedFile := range []string{datFileName, idxFileName, dataBaseFileName + ".idx"} {
|
|
os.Remove(replacedFile)
|
|
}
|
|
// The .vif may be shared with an EC volume for the same id: while a
|
|
// sealed .ecx is here it must stay — an incoming .vif overwrites it
|
|
// on rename, but with no incoming one the EC volume would lose the
|
|
// settings it only stores there.
|
|
if !copyLocation.HasEcxFileOnDisk(volFileInfoResp.Collection, needle.VolumeId(req.VolumeId)) {
|
|
os.Remove(vifFileName)
|
|
}
|
|
}
|
|
|
|
// A stale .note at the final basename fails the next load; the staged
|
|
// copy carries none, so clear leftovers before the files land. A stale
|
|
// derived index would likewise be trusted over the fresh .idx, unless a
|
|
// live replica landed mid-copy and still owns it.
|
|
os.Remove(dataBaseFileName + ".note")
|
|
if vs.store.GetVolume(needle.VolumeId(req.VolumeId)) == nil {
|
|
if cleanErr := removeStaleIndexFiles(indexBaseFileName, dataBaseFileName); cleanErr != nil {
|
|
err = fmt.Errorf("clear stale index files before copying volume %d: %w", req.VolumeId, cleanErr)
|
|
return err
|
|
}
|
|
}
|
|
|
|
// The .note marks the copy as in-progress through the renames: a crash
|
|
// mid-rename leaves the note and the load scan sweeps the partial set.
|
|
// The .vif lands first so the scan always has a basename to find.
|
|
if noteErr := util.WriteFile(dataBaseFileName+".note", []byte(fmt.Sprintf("copying from %s", req.SourceDataNode)), 0755); noteErr != nil {
|
|
err = noteErr
|
|
return fmt.Errorf("write .note for volume %d: %w", req.VolumeId, noteErr)
|
|
}
|
|
|
|
for _, pair := range [][2]string{
|
|
{stagedVifFileName, vifFileName},
|
|
{stagedDatFileName, datFileName},
|
|
{stagedIdxFileName, idxFileName},
|
|
} {
|
|
if renameErr := os.Rename(pair[0], pair[1]); renameErr != nil {
|
|
if os.IsNotExist(renameErr) {
|
|
continue
|
|
}
|
|
err = fmt.Errorf("move copied %s into place for volume %d: %w", pair[0], req.VolumeId, renameErr)
|
|
return err
|
|
}
|
|
movedFiles = append(movedFiles, pair[1])
|
|
}
|
|
|
|
// With a separate index directory the .idx lands outside the data dir, so
|
|
// a copy whose source has no .vif leaves nothing the volume scan can find.
|
|
// An empty .vif marks the basename for discovery; the loader treats it as
|
|
// absent and writes a real one on mount.
|
|
if filepath.Dir(indexBaseFileName) != filepath.Dir(dataBaseFileName) && !util.FileExists(vifFileName) {
|
|
if writeErr := util.WriteFile(vifFileName, nil, 0644); writeErr != nil {
|
|
err = fmt.Errorf("write .vif marker for volume %d: %w", req.VolumeId, writeErr)
|
|
return err
|
|
}
|
|
movedFiles = append(movedFiles, vifFileName)
|
|
}
|
|
|
|
// A leftover .note fails the load on the next restart, so a removal
|
|
// failure must fail the copy rather than be silently swallowed.
|
|
if noteErr := os.Remove(dataBaseFileName + ".note"); noteErr != nil && !os.IsNotExist(noteErr) {
|
|
err = noteErr
|
|
return fmt.Errorf("remove .note for volume %d: %w", req.VolumeId, noteErr)
|
|
}
|
|
|
|
// Load and validate the volume before announcing it to the master. A failed
|
|
// validation is unloaded by the store without ever making the replica
|
|
// routable.
|
|
err = vs.store.MountVolumeClaimed(needle.VolumeId(req.VolumeId), &req.Collection, func(targetVolume *storage.Volume) error {
|
|
if !shouldValidateCopyCounts {
|
|
return nil
|
|
}
|
|
return checkCopyCounts(req.VolumeId, sourceVolumeStatusAfterCopy, targetVolume.FileCount(), targetVolume.DeletedCount())
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to mount or validate volume %d: %w", req.VolumeId, err)
|
|
}
|
|
mounted = true
|
|
|
|
if err = stream.Send(&volume_server_pb.VolumeCopyResponse{
|
|
LastAppendAtNs: lastAppendAtNs,
|
|
}); err != nil {
|
|
glog.Errorf("send response: %v", err)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// linkReplacedVolumeFiles hard-links each file into a copying/ sibling so
|
|
// the inodes survive the replacement's delete and a failed swap can put the
|
|
// previous replica's files back. The loader never scans copying/. A repeated
|
|
// path links once: data and index can share a directory and so a basename.
|
|
// It reports false when a file that exists could not be preserved — deleting
|
|
// the replica then would leave nothing to roll back to.
|
|
func linkReplacedVolumeFiles(baseFileNames ...string) (links []string, complete bool) {
|
|
complete = true
|
|
seen := make(map[string]bool)
|
|
for _, base := range baseFileNames {
|
|
if seen[base] {
|
|
continue
|
|
}
|
|
seen[base] = true
|
|
if _, err := os.Stat(base); err != nil {
|
|
if !os.IsNotExist(err) {
|
|
glog.Warningf("preserve replaced file %s: %v", base, err)
|
|
complete = false
|
|
}
|
|
continue
|
|
}
|
|
link := filepath.Join(filepath.Dir(base), "copying", "replaced_"+filepath.Base(base))
|
|
if err := os.MkdirAll(filepath.Dir(link), 0755); err != nil {
|
|
glog.Warningf("preserve replaced file %s: %v", base, err)
|
|
complete = false
|
|
continue
|
|
}
|
|
os.Remove(link)
|
|
if err := os.Link(base, link); err != nil {
|
|
glog.Warningf("preserve replaced file %s: %v", base, err)
|
|
complete = false
|
|
continue
|
|
}
|
|
links = append(links, link)
|
|
}
|
|
return links, complete
|
|
}
|
|
|
|
// restoreReplacedVolumeFiles puts linkReplacedVolumeFiles' links back at
|
|
// their base names after a failed swap. A leftover at the basename is a
|
|
// surviving partial of the copy — it comes off first so the restored set
|
|
// stays one generation. The link is kept whenever it cannot go back: an
|
|
// existing path does not mean the copy's write succeeded.
|
|
func restoreReplacedVolumeFiles(links []string) {
|
|
for _, link := range links {
|
|
base := replacedBaseOf(link)
|
|
if _, err := os.Stat(base); err == nil {
|
|
if rmErr := os.Remove(base); rmErr != nil {
|
|
glog.Warningf("restore replaced file %s: cannot remove leftover %s: %v", link, base, rmErr)
|
|
continue
|
|
}
|
|
}
|
|
if err := os.Rename(link, base); err != nil {
|
|
glog.Warningf("restore replaced file %s: %v", link, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func replacedBaseOf(link string) string {
|
|
return filepath.Join(filepath.Dir(filepath.Dir(link)), strings.TrimPrefix(filepath.Base(link), "replaced_"))
|
|
}
|
|
|
|
// removeStaleIndexFiles drops the needle-map files a mount would trust over
|
|
// a fresh copy. A load prefers an .idx beside the .dat over one in the idx
|
|
// dir, so a leftover .idx is cleared along with the backends a mount builds
|
|
// (.ldb/.sdx/.rdb); both bases are covered. The first removal error is
|
|
// returned: a surviving index would be trusted over the copied .idx, so a
|
|
// caller that can still abort must know the removal failed.
|
|
func removeStaleIndexFiles(indexBaseFileName, dataBaseFileName string) error {
|
|
var firstErr error
|
|
for _, base := range []string{indexBaseFileName, dataBaseFileName} {
|
|
for _, name := range []string{base + ".ldb", base + ".sdx", base + ".rdb", base + ".idx"} {
|
|
if rmErr := os.RemoveAll(name); rmErr != nil && firstErr == nil {
|
|
firstErr = rmErr
|
|
}
|
|
}
|
|
}
|
|
return firstErr
|
|
}
|
|
|
|
func (vs *VolumeServer) doCopyFile(client volume_server_pb.VolumeServerClient, isEcVolume bool, collection string, vid, compactRevision uint32, stopOffset uint64, baseFileName, ext string, isAppend, ignoreSourceFileNotFound bool, progressFn storage.ProgressFunc) (modifiedTsNs int64, err error) {
|
|
return vs.doCopyFileWithThrottler(client, isEcVolume, collection, vid, compactRevision, stopOffset, baseFileName, ext, isAppend, ignoreSourceFileNotFound, progressFn, util.NewWriteThrottler(vs.maintenanceBytePerSecond))
|
|
}
|
|
|
|
func (vs *VolumeServer) doCopyFileWithThrottler(client volume_server_pb.VolumeServerClient, isEcVolume bool, collection string, vid, compactRevision uint32, stopOffset uint64, baseFileName, ext string, isAppend, ignoreSourceFileNotFound bool, progressFn storage.ProgressFunc, throttler *util.WriteThrottler) (modifiedTsNs int64, err error) {
|
|
|
|
copyFileClient, err := client.CopyFile(context.Background(), &volume_server_pb.CopyFileRequest{
|
|
VolumeId: vid,
|
|
Ext: ext,
|
|
CompactionRevision: compactRevision,
|
|
StopOffset: stopOffset,
|
|
Collection: collection,
|
|
IsEcVolume: isEcVolume,
|
|
IgnoreSourceFileNotFound: ignoreSourceFileNotFound,
|
|
})
|
|
if err != nil {
|
|
return modifiedTsNs, fmt.Errorf("failed to start copying volume %d %s file: %v", vid, ext, err)
|
|
}
|
|
|
|
modifiedTsNs, err = writeToFile(copyFileClient, baseFileName+ext, throttler, isAppend, ignoreSourceFileNotFound, progressFn)
|
|
if err != nil {
|
|
return modifiedTsNs, fmt.Errorf("failed to copy %s file: %v", baseFileName+ext, err)
|
|
}
|
|
|
|
return modifiedTsNs, nil
|
|
|
|
}
|
|
|
|
// checkCopyFiles verifies the copied file sizes. Record counts are compared
|
|
// after the target volume is mounted, when the target needle map is available.
|
|
func checkCopyFiles(originFileInf *volume_server_pb.ReadVolumeFileStatusResponse, hasRemoteDatFile bool, idxFileName, datFileName string) error {
|
|
stat, err := os.Stat(idxFileName)
|
|
if err != nil {
|
|
// If the idx file doesn't exist but the expected size is 0, that's OK (empty volume)
|
|
if os.IsNotExist(err) && originFileInf.IdxFileSize == 0 {
|
|
// empty volume, idx file not needed
|
|
} else {
|
|
return fmt.Errorf("stat idx file %s failed: %v", idxFileName, err)
|
|
}
|
|
} else if originFileInf.IdxFileSize != uint64(stat.Size()) {
|
|
return fmt.Errorf("idx file %s size [%v] is not same as origin file size [%v]",
|
|
idxFileName, stat.Size(), originFileInf.IdxFileSize)
|
|
}
|
|
|
|
if hasRemoteDatFile {
|
|
return nil
|
|
}
|
|
|
|
stat, err = os.Stat(datFileName)
|
|
if err != nil {
|
|
return fmt.Errorf("get dat file info failed, %w", err)
|
|
}
|
|
if originFileInf.DatFileSize != uint64(stat.Size()) {
|
|
return fmt.Errorf("the dat file size [%v] is not same as origin file size [%v]",
|
|
stat.Size(), originFileInf.DatFileSize)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkCopyCounts(vid uint32, origin *volume_server_pb.VolumeStatusResponse, targetFileCount, targetDeletedCount uint64) error {
|
|
// The file count is exact on both sides, so a mismatch on a stable source
|
|
// means the copy is genuinely bad and rejects. The deleted count is a
|
|
// rebuild-time estimate (bloom-filter dedup on some needle-map backends,
|
|
// rows appended between the status snapshots), so it legitimately drifts
|
|
// and only warns. The .dat/.idx size checks already gate integrity.
|
|
if origin.FileCount != targetFileCount {
|
|
return fmt.Errorf("target file count [%d] is not same as origin file count [%d]", targetFileCount, origin.FileCount)
|
|
}
|
|
if origin.FileDeletedCount != targetDeletedCount {
|
|
glog.Warningf("copied volume %d deleted count [%d] differs from origin deleted count [%d] (counter drift, copy kept)", vid, targetDeletedCount, origin.FileDeletedCount)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func copyCountsStable(before, after *volume_server_pb.VolumeStatusResponse) bool {
|
|
// A writable source may receive writes while its files are copied; then
|
|
// the before/after file counts do not describe one stable snapshot and
|
|
// strict target-count validation would report a false error. Deletes do
|
|
// not move the file count — they append tombstone rows the deleted
|
|
// counter records — so drift there leaves the strict check exact, and
|
|
// checkCopyCounts warns on it instead.
|
|
return before != nil && after != nil &&
|
|
before.FileCount == after.FileCount
|
|
}
|
|
|
|
func findLastAppendAtNsFromCopiedFiles(idxFileName, datFileName string, version needle.Version) (uint64, error) {
|
|
if version < needle.Version3 {
|
|
return 0, nil
|
|
}
|
|
|
|
idxFile, err := os.Open(idxFileName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open idx file %s: %w", idxFileName, err)
|
|
}
|
|
defer idxFile.Close()
|
|
|
|
fi, err := idxFile.Stat()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("stat idx file %s: %w", idxFileName, err)
|
|
}
|
|
if fi.Size() == 0 {
|
|
return 0, nil
|
|
}
|
|
if fi.Size()%int64(types.NeedleMapEntrySize) != 0 {
|
|
return 0, fmt.Errorf("unexpected idx file %s size: %d", idxFileName, fi.Size())
|
|
}
|
|
|
|
buf := make([]byte, types.NeedleMapEntrySize)
|
|
if _, err := idxFile.ReadAt(buf, fi.Size()-int64(types.NeedleMapEntrySize)); err != nil {
|
|
return 0, fmt.Errorf("read idx file %s: %w", idxFileName, err)
|
|
}
|
|
_, offset, _ := idx.IdxFileEntry(buf)
|
|
if offset.IsZero() {
|
|
return 0, nil
|
|
}
|
|
|
|
datFile, err := os.Open(datFileName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("open dat file %s: %w", datFileName, err)
|
|
}
|
|
defer datFile.Close()
|
|
|
|
datBackend := backend.NewDiskFile(datFile)
|
|
n, _, _, err := needle.ReadNeedleHeader(datBackend, version, offset.ToActualOffset())
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read needle header %s offset %d: %w", datFileName, offset.ToActualOffset(), err)
|
|
}
|
|
|
|
tailOffset := offset.ToActualOffset() + int64(types.NeedleHeaderSize) + int64(n.Size)
|
|
tail := make([]byte, needle.NeedleChecksumSize+types.TimestampSize)
|
|
readCount, readErr := datBackend.ReadAt(tail, tailOffset)
|
|
if readErr == io.EOF && readCount == len(tail) {
|
|
readErr = nil
|
|
}
|
|
if readErr != nil {
|
|
return 0, fmt.Errorf("read needle tail %s offset %d: %w", datFileName, tailOffset, readErr)
|
|
}
|
|
|
|
return util.BytesToUint64(tail[needle.NeedleChecksumSize : needle.NeedleChecksumSize+types.TimestampSize]), nil
|
|
}
|
|
|
|
func writeToFile(client volume_server_pb.VolumeServer_CopyFileClient, fileName string, wt *util.WriteThrottler, isAppend, ignoreSourceFileNotFound bool, progressFn storage.ProgressFunc) (modifiedTsNs int64, err error) {
|
|
glog.V(4).Infof("writing to %s", fileName)
|
|
|
|
// For an optional copy (ignoreSourceFileNotFound), stage into a temp sibling
|
|
// and atomically rename on success, so a source that lacks the file cannot
|
|
// truncate a valid pre-existing destination. Mandatory copies write in place.
|
|
writePath := fileName
|
|
stageThenCommit := ignoreSourceFileNotFound && !isAppend
|
|
if stageThenCommit {
|
|
writePath = fileName + ".copying"
|
|
}
|
|
|
|
flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC
|
|
if isAppend {
|
|
flags = os.O_WRONLY | os.O_CREATE
|
|
}
|
|
dst, err := os.OpenFile(writePath, flags, 0644)
|
|
if err != nil {
|
|
return modifiedTsNs, fmt.Errorf("open file %s: %w", writePath, err)
|
|
}
|
|
// Track the destination handle through a closer that runs at most once.
|
|
// On Windows os.Remove fails while the file is still open, so any path
|
|
// that wants to delete the file we just created must close the handle
|
|
// first. The deferred call here is the safety net for normal returns.
|
|
dstClosed := false
|
|
closeDst := func() {
|
|
if dstClosed {
|
|
return
|
|
}
|
|
dstClosed = true
|
|
_ = dst.Close()
|
|
}
|
|
defer closeDst()
|
|
|
|
// removeIncomplete deletes the partially-written file we just opened
|
|
// with O_TRUNC. Used on stream / write / cancellation errors so a
|
|
// caller (notably VolumeEcShardsCopy distributing .ecx) doesn't end
|
|
// up with a 0-byte stub that downstream code mistakes for a valid
|
|
// empty file. Skip in isAppend mode — the existing content is not
|
|
// ours to remove, and resumable appends rely on partial state.
|
|
removeIncomplete := func(reason string) {
|
|
if isAppend {
|
|
return
|
|
}
|
|
closeDst()
|
|
if removeErr := os.Remove(writePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
glog.Warningf("failed to remove incomplete file %s after %s: %v", writePath, reason, removeErr)
|
|
} else if removeErr == nil {
|
|
glog.V(1).Infof("removed incomplete file %s after %s", writePath, reason)
|
|
}
|
|
}
|
|
|
|
var progressedBytes int64
|
|
for {
|
|
resp, receiveErr := client.Recv()
|
|
if receiveErr == io.EOF {
|
|
break
|
|
}
|
|
if resp != nil && resp.ModifiedTsNs != 0 {
|
|
modifiedTsNs = resp.ModifiedTsNs
|
|
}
|
|
if receiveErr != nil {
|
|
removeIncomplete("receive error")
|
|
return modifiedTsNs, fmt.Errorf("receiving %s: %w", fileName, receiveErr)
|
|
}
|
|
if _, writeErr := dst.Write(resp.FileContent); writeErr != nil {
|
|
removeIncomplete("write error")
|
|
return modifiedTsNs, fmt.Errorf("write file %s: %w", fileName, writeErr)
|
|
}
|
|
progressedBytes += int64(len(resp.FileContent))
|
|
if progressFn != nil {
|
|
if !progressFn(progressedBytes) {
|
|
removeIncomplete("progress cancelled")
|
|
return modifiedTsNs, fmt.Errorf("interrupted copy operation")
|
|
}
|
|
}
|
|
wt.MaybeSlowdown(int64(len(resp.FileContent)))
|
|
}
|
|
// If we never received a modifiedTsNs, it means the source file did not exist.
|
|
// Remove the empty file we created to avoid leaving corrupted empty files.
|
|
// Note: We check modifiedTsNs (not progressedBytes) because an empty source file
|
|
// is valid and should result in an empty destination file.
|
|
if modifiedTsNs == 0 && !isAppend {
|
|
closeDst()
|
|
if removeErr := os.Remove(writePath); removeErr != nil && !os.IsNotExist(removeErr) {
|
|
glog.V(1).Infof("failed to remove empty file %s: %v", writePath, removeErr)
|
|
} else if removeErr == nil {
|
|
glog.V(1).Infof("removed empty file %s (source file not found)", writePath)
|
|
}
|
|
return modifiedTsNs, nil
|
|
}
|
|
|
|
// Commit the staged temp into place.
|
|
if stageThenCommit {
|
|
closeDst()
|
|
if renameErr := os.Rename(writePath, fileName); renameErr != nil {
|
|
os.Remove(writePath)
|
|
return modifiedTsNs, fmt.Errorf("commit copied file %s: %w", fileName, renameErr)
|
|
}
|
|
}
|
|
return modifiedTsNs, nil
|
|
}
|
|
|
|
func (vs *VolumeServer) ReadVolumeFileStatus(ctx context.Context, req *volume_server_pb.ReadVolumeFileStatusRequest) (*volume_server_pb.ReadVolumeFileStatusResponse, error) {
|
|
resp := &volume_server_pb.ReadVolumeFileStatusResponse{}
|
|
v := vs.store.GetVolume(needle.VolumeId(req.VolumeId))
|
|
if v == nil {
|
|
return nil, fmt.Errorf("not found volume id %d", req.VolumeId)
|
|
}
|
|
|
|
resp.VolumeId = req.VolumeId
|
|
datSize, idxSize, modTime := v.FileStat()
|
|
resp.DatFileSize = datSize
|
|
resp.IdxFileSize = idxSize
|
|
resp.DatFileTimestampSeconds = uint64(modTime.Unix())
|
|
resp.IdxFileTimestampSeconds = uint64(modTime.Unix())
|
|
resp.FileCount = v.FileCount()
|
|
resp.CompactionRevision = uint32(v.CompactionRevision)
|
|
resp.Collection = v.Collection
|
|
resp.DiskType = string(v.DiskType())
|
|
resp.VolumeInfo = v.GetVolumeInfo()
|
|
resp.Version = uint32(v.Version())
|
|
resp.ExpireAtSec = v.ExpireAtSec()
|
|
return resp, nil
|
|
}
|
|
|
|
// checkVolumeFileExtension guards the client-supplied Ext that CopyFile and
|
|
// ReceiveFile turn into an on-disk path. Both RPCs are intentionally ungated
|
|
// for cluster-internal peers (see volume_grpc_admin_auth_coverage_test.go), so
|
|
// this is the only check standing between a peer request and the os.Open /
|
|
// os.Create target: without it an Ext like "/../../x" is joined onto the volume
|
|
// directory and, once path-cleaned, resolves outside it. A genuine extension is
|
|
// a leading dot followed by alphanumerics -- ".dat", ".idx", ".vif", ".ecx",
|
|
// ".ecj", ".ecsum", ".ec00".. -- and never contains a separator or "..".
|
|
func checkVolumeFileExtension(ext string) error {
|
|
if len(ext) < 2 || ext[0] != '.' {
|
|
return fmt.Errorf("invalid file extension %q", ext)
|
|
}
|
|
for _, r := range ext[1:] {
|
|
if r < '0' || (r > '9' && r < 'A') || (r > 'Z' && r < 'a') || r > 'z' {
|
|
return fmt.Errorf("invalid file extension %q", ext)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkVolumeCollection guards the client-supplied Collection, which CopyFile
|
|
// and ReceiveFile fold into a path component ("<collection>_<vid>"). An empty
|
|
// collection is the default; any other value must be a single path element so a
|
|
// collection like "../../x" cannot climb out of the volume directory once
|
|
// path-cleaned. Collection names are user-facing and may hold '.' or '-', so
|
|
// this rejects only separators and bare parent references rather than the
|
|
// stricter alphanumeric rule used for extensions.
|
|
func checkVolumeCollection(collection string) error {
|
|
if collection == "." || collection == ".." || strings.ContainsAny(collection, `/\`) {
|
|
return fmt.Errorf("invalid collection %q", collection)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CopyFile client pulls the volume related file from the source server.
|
|
// if req.CompactionRevision != math.MaxUint32, it ensures the compact revision is as expected
|
|
// The copying still stop at req.StopOffset, but you can set it to math.MaxUint64 in order to read all data.
|
|
func (vs *VolumeServer) CopyFile(req *volume_server_pb.CopyFileRequest, stream volume_server_pb.VolumeServer_CopyFileServer) error {
|
|
|
|
if err := checkVolumeFileExtension(req.Ext); err != nil {
|
|
return err
|
|
}
|
|
if err := checkVolumeCollection(req.Collection); err != nil {
|
|
return err
|
|
}
|
|
|
|
var fileName string
|
|
if !req.IsEcVolume {
|
|
v := vs.store.GetVolume(needle.VolumeId(req.VolumeId))
|
|
if v == nil {
|
|
return fmt.Errorf("not found volume id %d", req.VolumeId)
|
|
}
|
|
|
|
if uint32(v.CompactionRevision) != req.CompactionRevision && req.CompactionRevision != math.MaxUint32 {
|
|
return fmt.Errorf("volume %d is compacted", req.VolumeId)
|
|
}
|
|
v.SyncToDisk()
|
|
fileName = v.FileName(req.Ext)
|
|
} else {
|
|
// Sync EC volume files to disk before copying to ensure deletions are visible
|
|
// This fixes issue #7751 where deleted files in encoded volumes were not
|
|
// properly marked as deleted when decoded.
|
|
if ecVolume, found := vs.store.FindEcVolume(needle.VolumeId(req.VolumeId)); found {
|
|
ecVolume.Sync()
|
|
}
|
|
|
|
baseFileName := erasure_coding.EcShardBaseFileName(req.Collection, int(req.VolumeId)) + req.Ext
|
|
for _, location := range vs.store.Locations {
|
|
tName := util.Join(location.Directory, baseFileName)
|
|
if util.FileExists(tName) {
|
|
fileName = tName
|
|
}
|
|
tName = util.Join(location.IdxDirectory, baseFileName)
|
|
if util.FileExists(tName) {
|
|
fileName = tName
|
|
}
|
|
}
|
|
if fileName == "" {
|
|
if req.IgnoreSourceFileNotFound {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("CopyFile not found ec volume id %d", req.VolumeId)
|
|
}
|
|
}
|
|
|
|
bytesToRead := int64(req.StopOffset)
|
|
|
|
file, err := os.Open(fileName)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// If file doesn't exist and we're asked to copy 0 bytes (empty file),
|
|
// or if IgnoreSourceFileNotFound is set, treat as success
|
|
if req.IgnoreSourceFileNotFound || req.StopOffset == 0 {
|
|
return nil
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
fileInfo, err := file.Stat()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fileModTsNs := fileInfo.ModTime().UnixNano()
|
|
|
|
buffer := make([]byte, BufferSizeLimit)
|
|
|
|
for bytesToRead > 0 {
|
|
bytesread, err := file.Read(buffer)
|
|
|
|
// println(fileName, "read", bytesread, "bytes, with target", bytesToRead)
|
|
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
return err
|
|
}
|
|
// println(fileName, "read", bytesread, "bytes, with target", bytesToRead, "err", err.Error())
|
|
break
|
|
}
|
|
|
|
if int64(bytesread) > bytesToRead {
|
|
bytesread = int(bytesToRead)
|
|
}
|
|
err = stream.Send(&volume_server_pb.CopyFileResponse{
|
|
FileContent: buffer[:bytesread],
|
|
ModifiedTsNs: fileModTsNs,
|
|
})
|
|
if err != nil {
|
|
// println("sending", bytesread, "bytes err", err.Error())
|
|
return err
|
|
}
|
|
fileModTsNs = 0 // only send once
|
|
|
|
bytesToRead -= int64(bytesread)
|
|
|
|
}
|
|
|
|
// If no data has been sent in the loop (e.g. for an empty file, or when stopOffset is 0),
|
|
// we still need to send the ModifiedTsNs so the client knows the source file exists.
|
|
// fileModTsNs is set to 0 after the first send, so if it's still non-zero,
|
|
// we haven't sent anything yet.
|
|
if fileModTsNs != 0 {
|
|
err = stream.Send(&volume_server_pb.CopyFileResponse{
|
|
ModifiedTsNs: fileModTsNs,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// diskHoldsEcShardFile reports whether dir contains any <vid>.ecNN shard file,
|
|
// so a decoded <vid>.dat is never staged beside a shard. Unlike FindEcVolume,
|
|
// it also catches shards present on disk but not mounted.
|
|
func diskHoldsEcShardFile(dir, collection string, vid needle.VolumeId) bool {
|
|
base := erasure_coding.EcShardFileName(collection, dir, int(vid))
|
|
for i := 0; i < erasure_coding.MaxShardCount; i++ {
|
|
if fi, err := os.Stat(base + erasure_coding.ToExt(i)); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ReceiveFile receives a file stream from client and writes it to storage
|
|
func (vs *VolumeServer) ReceiveFile(stream volume_server_pb.VolumeServer_ReceiveFileServer) error {
|
|
if err := vs.CheckMaintenanceMode(); err != nil {
|
|
return err
|
|
}
|
|
|
|
var fileInfo *volume_server_pb.ReceiveFileInfo
|
|
var targetFile *os.File
|
|
var filePath string
|
|
var bytesWritten uint64
|
|
// Set while an EC .ecj is being received; released only after the file is
|
|
// closed and any partial copy removed.
|
|
var ecjWriteDone func()
|
|
|
|
defer func() {
|
|
if targetFile != nil {
|
|
targetFile.Close()
|
|
}
|
|
if ecjWriteDone != nil {
|
|
ecjWriteDone()
|
|
}
|
|
}()
|
|
|
|
for {
|
|
req, err := stream.Recv()
|
|
if err == io.EOF {
|
|
// Stream completed successfully
|
|
if targetFile != nil {
|
|
targetFile.Sync()
|
|
glog.V(1).Infof("Successfully received file %s (%d bytes)", filePath, bytesWritten)
|
|
}
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
BytesWritten: bytesWritten,
|
|
})
|
|
}
|
|
if err != nil {
|
|
// Clean up on error
|
|
if targetFile != nil {
|
|
targetFile.Close()
|
|
os.Remove(filePath)
|
|
}
|
|
glog.Errorf("Failed to receive stream: %v", err)
|
|
return fmt.Errorf("failed to receive stream: %v", err)
|
|
}
|
|
|
|
switch data := req.Data.(type) {
|
|
case *volume_server_pb.ReceiveFileRequest_Info:
|
|
// First message contains file info
|
|
fileInfo = data.Info
|
|
glog.V(1).Infof("ReceiveFile: volume %d, ext %s, collection %s, shard %d, size %d",
|
|
fileInfo.VolumeId, fileInfo.Ext, fileInfo.Collection, fileInfo.ShardId, fileInfo.FileSize)
|
|
|
|
if err := checkVolumeFileExtension(fileInfo.Ext); err != nil {
|
|
glog.Errorf("ReceiveFile: %v", err)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
if err := checkVolumeCollection(fileInfo.Collection); err != nil {
|
|
glog.Errorf("ReceiveFile: %v", err)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if fileInfo.IsEcVolume {
|
|
// os.Create below truncates in place; a mounted EcVolume
|
|
// holds fds on the same inodes, so overwriting corrupts
|
|
// live readers.
|
|
if _, mounted := vs.store.FindEcVolume(needle.VolumeId(fileInfo.VolumeId)); mounted {
|
|
mountedDisks := vs.store.FindEcVolumeDiskIds(needle.VolumeId(fileInfo.VolumeId))
|
|
glog.Errorf("ReceiveFile: ec volume %d is mounted on disk_ids:%v; refusing overwrite for %s", fileInfo.VolumeId, mountedDisks, fileInfo.Ext)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("ec volume %d is mounted on disk_ids:%v; unmount before ReceiveFile", fileInfo.VolumeId, mountedDisks),
|
|
})
|
|
}
|
|
|
|
// disk_id=0 means "unset" (protobuf default), so auto-select
|
|
// using the same primitive as VolumeEcShardsCopy: prefer a
|
|
// disk that has the EC volume mounted, then a disk that owns
|
|
// the .ecx on disk (the volume hasn't been mounted yet —
|
|
// relevant when shards stream in mid-rebuild before any
|
|
// mount has happened; see #9212), then any HDD, then any
|
|
// disk.
|
|
var targetLocation *storage.DiskLocation
|
|
if fileInfo.DiskId > 0 {
|
|
if fileInfo.DiskId >= uint32(len(vs.store.Locations)) {
|
|
glog.Errorf("ReceiveFile: invalid disk_id %d: only have %d disks", fileInfo.DiskId, len(vs.store.Locations))
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("invalid disk_id %d: only have %d disks", fileInfo.DiskId, len(vs.store.Locations)),
|
|
})
|
|
}
|
|
targetLocation = vs.store.Locations[fileInfo.DiskId]
|
|
} else {
|
|
// Pass the build's default data-shard count for the helper's
|
|
// free-slot maths; it's a parameter so custom-ratio builds
|
|
// (e.g. enterprise) can swap it without touching this file.
|
|
targetLocation = vs.store.FindEcShardTargetLocation(fileInfo.Collection, needle.VolumeId(fileInfo.VolumeId), erasure_coding.DataShardsCount)
|
|
}
|
|
if targetLocation == nil {
|
|
glog.Errorf("ReceiveFile: no storage location available")
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: "no storage location available",
|
|
})
|
|
}
|
|
|
|
// Create EC shard file path
|
|
baseFileName := erasure_coding.EcShardBaseFileName(fileInfo.Collection, int(fileInfo.VolumeId))
|
|
filePath = util.Join(targetLocation.Directory, baseFileName+fileInfo.Ext)
|
|
// The mounted check above runs once; a volume can still mount on
|
|
// this journal while the stream writes it. Registered as a writer
|
|
// before the file is created, that mount cannot compact the
|
|
// journal and leave the rest of the stream in an unlinked inode.
|
|
if fileInfo.Ext == ".ecj" && ecjWriteDone == nil {
|
|
ecjWriteDone = erasure_coding.BeginEcjWrite(filePath)
|
|
}
|
|
} else {
|
|
// Regular volume file
|
|
v := vs.store.GetVolume(needle.VolumeId(fileInfo.VolumeId))
|
|
if v == nil {
|
|
if fileInfo.DiskType == "" {
|
|
glog.Errorf("ReceiveFile: volume %d not found", fileInfo.VolumeId)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("volume %d not found", fileInfo.VolumeId),
|
|
})
|
|
}
|
|
// Staged-new-volume mode (EC decode onto a clean peer): the
|
|
// volume does not exist here yet. Pick a free-slot disk location
|
|
// of the requested medium and stage the file as
|
|
// <base><ext>.copying, to be renamed into place and mounted by
|
|
// VolumeEcShardsToVolume(from_staged). .idx.copying/.vif.copying
|
|
// are not valid volume names, so the scanner never half-loads.
|
|
want := types.ToDiskType(fileInfo.DiskType)
|
|
stagedVid := needle.VolumeId(fileInfo.VolumeId)
|
|
loc := vs.store.FindFreeLocation(func(l *storage.DiskLocation) bool {
|
|
if l.DiskType != want {
|
|
return false
|
|
}
|
|
// Don't stage the decoded .dat onto a disk that holds a shard
|
|
// of this vid. Check the mounted map and the on-disk files, so
|
|
// an unmounted or orphan shard (on disk, absent from the map)
|
|
// is caught too.
|
|
if _, holds := l.FindEcVolume(stagedVid); holds {
|
|
return false
|
|
}
|
|
return !diskHoldsEcShardFile(l.Directory, fileInfo.Collection, stagedVid)
|
|
})
|
|
if loc == nil {
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("no %s disk location with a free slot for volume %d", fileInfo.DiskType, fileInfo.VolumeId),
|
|
})
|
|
}
|
|
filePath = storage.VolumeFileName(loc.Directory, fileInfo.Collection, int(fileInfo.VolumeId)) + fileInfo.Ext + ".copying"
|
|
} else {
|
|
filePath = v.FileName(fileInfo.Ext)
|
|
}
|
|
}
|
|
|
|
// Create target file
|
|
targetFile, err = os.Create(filePath)
|
|
if err != nil {
|
|
glog.Errorf("ReceiveFile: failed to create file %s: %v", filePath, err)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("failed to create file: %v", err),
|
|
})
|
|
}
|
|
glog.V(1).Infof("ReceiveFile: created target file %s", filePath)
|
|
|
|
case *volume_server_pb.ReceiveFileRequest_FileContent:
|
|
// Subsequent messages contain file content
|
|
if targetFile == nil {
|
|
glog.Errorf("ReceiveFile: file info must be sent first")
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: "file info must be sent first",
|
|
})
|
|
}
|
|
|
|
n, err := targetFile.Write(data.FileContent)
|
|
if err != nil {
|
|
targetFile.Close()
|
|
os.Remove(filePath)
|
|
glog.Errorf("ReceiveFile: failed to write to file %s: %v", filePath, err)
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: fmt.Sprintf("failed to write file: %v", err),
|
|
})
|
|
}
|
|
bytesWritten += uint64(n)
|
|
glog.V(2).Infof("ReceiveFile: wrote %d bytes to %s (total: %d)", n, filePath, bytesWritten)
|
|
|
|
default:
|
|
glog.Errorf("ReceiveFile: unknown message type")
|
|
return stream.SendAndClose(&volume_server_pb.ReceiveFileResponse{
|
|
Error: "unknown message type",
|
|
})
|
|
}
|
|
}
|
|
}
|