Files
seaweedfs/weed/shell/command_volume_merge.go
T
Chris LuandGitHub 0799084e98 refactor: share volume and EC shard move logic between shell and workers (#10727)
* operation: add shared volume_move package for volume and EC shard moves

The shell commands (volume.move, volume.balance, ec.balance, tier moves)
and the maintenance workers (balance, ec_balance) each carried their own
copy of the move RPC sequences, and the copies had drifted: the worker
verified the target before deleting the source but dropped the disk
type and IO throttle; the shell passed those but deleted the source
unverified.

volume_move.Mover carries the merged sequences, keeping the stricter
behavior from each side:

- LiveMoveVolume: check-then-hard-freeze the source (VolumeStatus's
  IsReadOnly also covers low-disk and readonly-but-can-delete states,
  which still accept needle deletes), copy with disk type and IO
  throttle, tail, verify the target is not behind the source before the
  destructive source delete (a target that is ahead holds writes it
  accepted during the tail and the move commits to keep them), and
  restore the source's writability when a failure precedes the delete
  and this move did the freezing. Aborts clean up the incomplete target
  copy; a failed cleanup or an ambiguous source delete keeps the source
  readonly (ErrSourceKeptReadonly) so callers do not thaw a source next
  to a possibly-authoritative copy. With a readonly source, an existing
  or unknown-state target refuses the move outright: no client-side
  observation can prove such a copy is a stale remnant rather than the
  authoritative copy of an unfinished move.
- MoveEcShards: copy with the .ecx/.ecj/.vif/.ecsum sidecars, mount,
  verify the target registered every shard before unmount+delete on the
  source, and reject same-server moves (the EC delete is server-wide).

Server identity is the grpc endpoint (SameServer), so node:8080 and
node:8080.18080 compare equal while test servers sharing a degenerate
HTTP address stay distinct; addresses are validated non-fatally before
dialing and before being embedded in copy/tail requests, since both the
client dialer and the receiving server normalize them through a parser
that aborts the process on a malformed port. The Rust volume server's
codes.NotFound counts as a definitively absent probe answer alongside
the Go server's plain-error code Unknown.

All RPCs go through an injectable ClientFunc, so the sequences are unit
tested against a fake volume server client: RPC order, request fields,
and that verification failures keep the source intact.

* shell, worker: delegate volume and EC shard moves to operation/volume_move

LiveMoveVolume and the copy/tail/delete/mark-writable helpers become
thin wrappers over the shared mover, keeping their signatures; the EC
helpers keep their per-step output and delegate the RPCs. BalanceTask
and ECBalanceTask keep their parameter validation, progress reporting,
and guards (same-node cross-disk rejection, dedup keep-node
verification, shard ids range-checked before the uint8 narrowing) and
hand the RPC sequences to the mover. volume.tier.move skips its
thaw-on-failure when the mover deliberately kept the source readonly,
since reopening the replicas beside a possibly-authoritative target
copy would fork the volume.

The tail-failure tolerance moves inside the mover: a failed tail is
tolerated only when the volume was already readonly before the move
began, backstopped by a stability re-read across the idle window, so
volume.balance's -skipTailError-by-readonly heuristic and tier-move's
unconditional skip both become the same authoritative rule.

* volume_move: keep the source readonly when a failed copy leaves a target of unknown origin

A failed copy can leave a complete, mounted copy on the target (the
server finishes after the client loses the stream). The abort probed
the target only when its pre-copy state was known-absent; an unknown
prior state skipped both the probe and the cleanup and then reopened
the source - two writable replicas of one volume, diverging from the
next write on.

The abort now probes the target on every failed copy and restores the
source only when the target provably holds nothing. A copy whose
provenance cannot be proven (unknown prior state, a pre-existing
replica, or an unreachable target) is never deleted, and the source
stays readonly with ErrSourceKeptReadonly naming the recovery.

* test: teach the plugin worker harness the shared move sequence

The fake volume server lacked VolumeStatus, which the shared mover now
issues before freezing the source, and the batch execution test's
status-read accounting predates the pre-copy target probe and the
verification reads. Mirrors the harness the enterprise tree already
carries.
2026-08-12 12:29:40 -07:00

599 lines
20 KiB
Go

package shell
import (
"bytes"
"container/heap"
"context"
"flag"
"fmt"
"io"
"sync"
"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/master_pb"
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
"github.com/seaweedfs/seaweedfs/weed/storage/super_block"
"github.com/seaweedfs/seaweedfs/weed/storage/types"
"google.golang.org/grpc"
)
// mergeIdleTimeoutSeconds is the timeout for idle streams during needle tailing.
// This ensures that slow or stalled streams don't block the merge indefinitely.
// Set to 5 seconds to handle network congestion and avoid premature stream termination.
// Can be made configurable in the future if needed for different deployment scenarios.
const mergeIdleTimeoutSeconds = 5
// mergeDeduplicationWindowNs defines the time window for deduplication across replicas.
// Since the same needle ID can have different timestamps on different servers due to
// clock skew and replication lag, we deduplicate needles with the same ID within this window.
// Set to 5 seconds in nanoseconds to handle typical server clock differences.
const mergeDeduplicationWindowNs = 5 * time.Second
func init() {
Commands = append(Commands, &commandVolumeMerge{})
}
type commandVolumeMerge struct{}
func (c *commandVolumeMerge) Name() string {
return "volume.merge"
}
func (c *commandVolumeMerge) Help() string {
return `merge replicas for a volume id in timestamp order into a fresh copy
volume.merge -volumeId <volume id>
This command:
1) marks the volume readonly on replicas (if not already)
2) allocates a temporary copy on a third location
3) merges replicas in append timestamp order, skipping duplicates
4) verifies the merged copy is not short before touching the replicas
5) replaces the original replicas with the merged volume
6) restores writable state if it was writable before
`
}
func (c *commandVolumeMerge) HasTag(CommandTag) bool {
return false
}
func (c *commandVolumeMerge) Do(args []string, commandEnv *CommandEnv, writer io.Writer) (err error) {
mergeCommand := flag.NewFlagSet(c.Name(), flag.ContinueOnError)
volumeIdInt := mergeCommand.Int("volumeId", 0, "the volume id")
targetNodeStr := mergeCommand.String("target", "", "optional target volume server <host>:<port> for temporary merge output")
noLock := mergeCommand.Bool("noLock", false, "do not lock the admin shell at one's own risk")
if err = mergeCommand.Parse(args); err != nil {
return err
}
if *volumeIdInt == 0 {
return fmt.Errorf("volumeId is required")
}
if *noLock {
commandEnv.noLock = true
} else if err = commandEnv.confirmIsLocked(args); err != nil {
return err
}
volumeId := needle.VolumeId(*volumeIdInt)
topologyInfo, _, err := collectTopologyInfo(commandEnv, 0)
if err != nil {
return err
}
volumeReplicas, allLocations := collectVolumeReplicaLocations(topologyInfo)
replicas := volumeReplicas[uint32(volumeId)]
if len(replicas) < 2 {
return fmt.Errorf("volume %d has %d replica(s); merge requires at least two", volumeId, len(replicas))
}
volumeInfo := replicas[0].info
replicaPlacement, err := super_block.NewReplicaPlacementFromByte(byte(volumeInfo.ReplicaPlacement))
if err != nil {
return fmt.Errorf("parse replica placement for volume %d: %w", volumeId, err)
}
var targetServer pb.ServerAddress
if *targetNodeStr != "" {
targetServer = pb.ServerAddress(*targetNodeStr)
if isReplicaServer(targetServer, replicas) {
return fmt.Errorf("target %s already hosts volume %d", *targetNodeStr, volumeId)
}
if err = allocateMergeVolume(commandEnv.option.GrpcDialOption, targetServer, volumeInfo, replicaPlacement); err != nil {
return err
}
} else {
targetServer, err = allocateMergeVolumeOnThirdLocation(commandEnv.option.GrpcDialOption, allLocations, replicas, volumeInfo, replicaPlacement)
if err != nil {
return err
}
}
cleanupTarget := true
defer func() {
if !cleanupTarget {
return
}
if delErr := deleteVolume(context.Background(), commandEnv.option.GrpcDialOption, volumeId, targetServer, false, false); delErr != nil {
glog.Warningf("failed to clean up temporary merge volume %d on %s: %v", volumeId, targetServer, delErr)
}
}()
writableReplicaIndices, err := ensureVolumeReadonly(commandEnv, replicas)
if err != nil {
return err
}
if len(writableReplicaIndices) > 0 {
defer func() {
// Only restore writable state for replicas that were originally writable
writableReplicas := make([]*VolumeReplica, 0, len(writableReplicaIndices))
for _, idx := range writableReplicaIndices {
writableReplicas = append(writableReplicas, replicas[idx])
}
if restoreErr := markReplicasWritable(commandEnv.option.GrpcDialOption, writableReplicas, true, false); restoreErr != nil {
glog.Warningf("failed to restore writable state for volume %d: %v", volumeId, restoreErr)
}
}()
}
done := make(chan struct{})
defer close(done)
sources := make([]needleStream, 0, len(replicas))
for _, replica := range replicas {
server := pb.NewServerAddressFromDataNode(replica.location.dataNode)
sources = append(sources, startTailNeedleStream(commandEnv.option.GrpcDialOption, volumeId, server, done))
}
mergeErr := operation.WithVolumeServerClient(false, targetServer, commandEnv.option.GrpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
version := needle.Version(volumeInfo.Version)
if version == 0 {
version = needle.GetCurrentVersion()
}
return mergeNeedleStreams(sources, func(streamIndex int, n *needle.Needle) error {
blob, size, err := needleBlobFromNeedle(n, version)
if err != nil {
return err
}
_, err = client.WriteNeedleBlob(context.Background(), &volume_server_pb.WriteNeedleBlobRequest{
VolumeId: uint32(volumeId),
NeedleId: uint64(n.Id),
Size: int32(size),
NeedleBlob: blob,
})
return err
})
})
if mergeErr != nil {
return mergeErr
}
// Verify the merged copy before overwriting any replica. A short or empty
// merge stamped over every replica at once is unrecoverable; on failure the
// originals are left intact and the bad merged copy is cleaned up.
if err = verifyMergedVolume(commandEnv.option.GrpcDialOption, volumeId, targetServer, replicas); err != nil {
return err
}
// The merged copy is now the only authoritative copy. Keep it until every
// replica is rebuilt from it so a mid-loop failure can still be finished.
cleanupTarget = false
for i, replica := range replicas {
sourceServer := pb.NewServerAddressFromDataNode(replica.location.dataNode)
if _, err = copyVolume(context.Background(), commandEnv.option.GrpcDialOption, writer, volumeId, targetServer, sourceServer, "", 0, false); err != nil {
return fmt.Errorf("rebuild replica %d/%d on %s from merged volume %d: %w; merged copy kept on %s, re-run to finish", i+1, len(replicas), sourceServer, volumeId, err, targetServer)
}
}
if err = deleteVolume(context.Background(), commandEnv.option.GrpcDialOption, volumeId, targetServer, false, false); err != nil {
return err
}
fmt.Fprintf(writer, "merged volume %d from %d replicas via %s\n", volumeId, len(replicas), targetServer)
return nil
}
type needleStream interface {
Next() (*needle.Needle, bool)
Err() error
}
type tailNeedleStream struct {
ch <-chan *needle.Needle
errMu sync.Mutex
err error
}
func (s *tailNeedleStream) Next() (*needle.Needle, bool) {
n, ok := <-s.ch
return n, ok
}
func (s *tailNeedleStream) Err() error {
s.errMu.Lock()
defer s.errMu.Unlock()
return s.err
}
func (s *tailNeedleStream) setErr(err error) {
s.errMu.Lock()
s.err = err
s.errMu.Unlock()
}
func startTailNeedleStream(grpcDialOption grpc.DialOption, volumeId needle.VolumeId, server pb.ServerAddress, done <-chan struct{}) *tailNeedleStream {
ch := make(chan *needle.Needle, 32)
stream := &tailNeedleStream{ch: ch}
go func() {
err := operation.TailVolumeFromSource(server, grpcDialOption, volumeId, 0, mergeIdleTimeoutSeconds, func(n *needle.Needle) error {
select {
case ch <- n:
case <-done:
return fmt.Errorf("merge cancelled")
}
return nil
})
close(ch)
stream.setErr(err)
}()
return stream
}
type needleMergeItem struct {
streamIndex int
needle *needle.Needle
timestamp uint64
}
type needleMergeHeap []needleMergeItem
func (h needleMergeHeap) Len() int { return len(h) }
func (h needleMergeHeap) Less(i, j int) bool {
if h[i].timestamp == h[j].timestamp {
return h[i].needle.Id < h[j].needle.Id
}
return h[i].timestamp < h[j].timestamp
}
func (h needleMergeHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *needleMergeHeap) Push(x any) {
*h = append(*h, x.(needleMergeItem))
}
func (h *needleMergeHeap) Pop() any {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
func mergeNeedleStreams(streams []needleStream, consume func(int, *needle.Needle) error) error {
h := &needleMergeHeap{}
heap.Init(h)
for i, stream := range streams {
if n, ok := stream.Next(); ok {
heap.Push(h, needleMergeItem{streamIndex: i, needle: n, timestamp: needleTimestamp(n)})
}
}
// Track seen needle IDs (by stream) within a time window to skip cross-stream duplicates only.
// Needles with the same ID from different streams within mergeDeduplicationWindowNs are considered
// cross-stream duplicates and skipped. Same-stream duplicates (overwrites) are kept.
// Map: needleId -> streamIndex that first processed it in this window
seenAtTimestamp := make(map[types.NeedleId]int)
var windowStartTimestamp uint64
windowInitialized := false
for h.Len() > 0 {
item := heap.Pop(h).(needleMergeItem)
ts := item.timestamp
n := item.needle
// Initialize window on first timestamp, or move to new window when outside current window
if !windowInitialized {
windowStartTimestamp = ts
windowInitialized = true
} else if ts > windowStartTimestamp+uint64(mergeDeduplicationWindowNs) {
// Moving to a new window: clear the watermark to reduce memory usage.
// This is safe because we only skip duplicates within the same time window.
seenAtTimestamp = make(map[types.NeedleId]int)
windowStartTimestamp = ts
}
// Skip cross-stream duplicates: if we've already seen this needle ID from a DIFFERENT stream
// within this time window, skip it. Same-stream duplicates (overwrites) are kept.
if seenStreamIdx, exists := seenAtTimestamp[n.Id]; exists && seenStreamIdx != item.streamIndex {
// Cross-stream duplicate from different stream - skip this occurrence
if nextN, ok := streams[item.streamIndex].Next(); ok {
heap.Push(h, needleMergeItem{streamIndex: item.streamIndex, needle: nextN, timestamp: needleTimestamp(nextN)})
}
continue
}
// Record this stream's occurrence of this needle ID in this window
// (overwrite if from same stream, since we process in timestamp order)
seenAtTimestamp[n.Id] = item.streamIndex
if err := consume(item.streamIndex, n); err != nil {
return err
}
if nextN, ok := streams[item.streamIndex].Next(); ok {
heap.Push(h, needleMergeItem{streamIndex: item.streamIndex, needle: nextN, timestamp: needleTimestamp(nextN)})
}
}
for _, stream := range streams {
if err := stream.Err(); err != nil {
return err
}
}
return nil
}
func needleTimestamp(n *needle.Needle) uint64 {
if n.AppendAtNs != 0 {
return n.AppendAtNs
}
if n.LastModified != 0 {
return uint64(time.Unix(int64(n.LastModified), 0).UnixNano())
}
return 0
}
// memoryBackendFile implements backend.BackendStorageFile using an in-memory buffer
type memoryBackendFile struct {
buf *bytes.Buffer
}
func (m *memoryBackendFile) ReadAt(p []byte, off int64) (n int, err error) {
data := m.buf.Bytes()
if off >= int64(len(data)) {
return 0, io.EOF
}
n = copy(p, data[off:])
if off+int64(n) < int64(len(data)) {
return n, nil
}
return n, io.EOF
}
func (m *memoryBackendFile) WriteAt(p []byte, off int64) (n int, err error) {
data := m.buf.Bytes()
if off > int64(len(data)) {
// Pad with zeros
m.buf.Write(make([]byte, off-int64(len(data))))
// Refresh data snapshot after padding to see the padded length
data = m.buf.Bytes()
}
if off == int64(len(data)) {
return m.buf.Write(p)
}
// Overwrite existing data: preserve any trailing bytes beyond the write range
newLen := off + int64(len(p))
if newLen < int64(len(data)) {
newLen = int64(len(data))
}
newData := make([]byte, newLen)
copy(newData, data)
copy(newData[off:], p)
m.buf = bytes.NewBuffer(newData)
return len(p), nil
}
func (m *memoryBackendFile) Truncate(off int64) error {
data := m.buf.Bytes()
if off > int64(len(data)) {
m.buf.Write(make([]byte, off-int64(len(data))))
} else {
m.buf = bytes.NewBuffer(data[:off])
}
return nil
}
func (m *memoryBackendFile) Close() error {
return nil
}
func (m *memoryBackendFile) GetStat() (datSize int64, modTime time.Time, err error) {
return int64(m.buf.Len()), time.Now(), nil
}
func (m *memoryBackendFile) Name() string {
return "memory"
}
func (m *memoryBackendFile) Sync() error {
return nil
}
func newMemoryBackendFile() *memoryBackendFile {
return &memoryBackendFile{
buf: &bytes.Buffer{},
}
}
func needleBlobFromNeedle(n *needle.Needle, version needle.Version) ([]byte, types.Size, error) {
// Use in-memory buffer for serialization to avoid expensive temporary file I/O
memFile := newMemoryBackendFile()
defer memFile.Close()
// Append reports Size(n.DataSize); the .dat header and the needle map both use
// n.Size, which Append fills in as it serializes.
_, _, actualSize, err := n.Append(memFile, version)
if err != nil {
return nil, 0, err
}
buf := make([]byte, actualSize)
read, err := memFile.ReadAt(buf, 0)
if err != nil && err != io.EOF {
return nil, 0, err
}
return buf[:read], n.Size, nil
}
func allocateMergeVolumeOnThirdLocation(grpcDialOption grpc.DialOption, allLocations []location, replicas []*VolumeReplica, info *master_pb.VolumeInformationMessage, replicaPlacement *super_block.ReplicaPlacement) (pb.ServerAddress, error) {
replicaNodes := map[string]struct{}{}
for _, replica := range replicas {
replicaNodes[replica.location.dataNode.Id] = struct{}{}
}
for _, loc := range allLocations {
if _, exists := replicaNodes[loc.dataNode.Id]; exists {
continue
}
if !locationHasDiskType(loc, info.DiskType) {
continue
}
server := pb.NewServerAddressFromDataNode(loc.dataNode)
if err := allocateMergeVolume(grpcDialOption, server, info, replicaPlacement); err != nil {
glog.V(1).Infof("failed to allocate merge volume on %s with replication %s: %v", server, replicaPlacement.String(), err)
continue
}
return server, nil
}
return "", fmt.Errorf("no third location available to merge volume %d", info.Id)
}
func allocateMergeVolume(grpcDialOption grpc.DialOption, server pb.ServerAddress, info *master_pb.VolumeInformationMessage, replicaPlacement *super_block.ReplicaPlacement) error {
return operation.WithVolumeServerClient(false, server, grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
_, err := client.AllocateVolume(context.Background(), &volume_server_pb.AllocateVolumeRequest{
VolumeId: info.Id,
Collection: info.Collection,
Preallocate: 0,
Replication: replicaPlacement.String(),
Ttl: needle.LoadTTLFromUint32(info.Ttl).String(),
DiskType: info.DiskType,
Version: info.Version,
})
return err
})
}
// ensureVolumeReadonly marks all replicas as readonly and returns the indices of replicas that were writable
func ensureVolumeReadonly(commandEnv *CommandEnv, replicas []*VolumeReplica) ([]int, error) {
var writableReplicaIndices []int
for i, replica := range replicas {
server := pb.NewServerAddressFromDataNode(replica.location.dataNode)
status, err := readVolumeStatus(commandEnv.option.GrpcDialOption, server, needle.VolumeId(replica.info.Id))
if err != nil {
return nil, err
}
if !status.IsReadOnly {
writableReplicaIndices = append(writableReplicaIndices, i)
}
}
if len(writableReplicaIndices) > 0 {
if err := markReplicasWritable(commandEnv.option.GrpcDialOption, replicas, false, false); err != nil {
return nil, err
}
}
return writableReplicaIndices, nil
}
// verifyMergedVolume checks the freshly merged copy is at least as complete as the
// most complete source replica before the originals are overwritten.
func verifyMergedVolume(grpcDialOption grpc.DialOption, volumeId needle.VolumeId, targetServer pb.ServerAddress, replicas []*VolumeReplica) error {
merged, err := readVolumeStatus(grpcDialOption, targetServer, volumeId)
if err != nil {
return fmt.Errorf("read merged volume %d on %s: %w", volumeId, targetServer, err)
}
replicaLive := make(map[pb.ServerAddress]uint64, len(replicas))
for _, replica := range replicas {
server := pb.NewServerAddressFromDataNode(replica.location.dataNode)
status, err := readVolumeStatus(grpcDialOption, server, volumeId)
if err != nil {
return fmt.Errorf("read replica volume %d on %s: %w", volumeId, server, err)
}
replicaLive[server] = liveNeedleCount(status)
}
return evaluateMergedVolume(volumeId, liveNeedleCount(merged), replicaLive)
}
// evaluateMergedVolume reports why a merged copy with mergedLive live needles is
// unsafe to overwrite replicas whose live counts are replicaLive. The merge is the
// union of every replica's needles, so the merged copy must be non-empty and hold
// at least as many live needles as the most complete replica.
func evaluateMergedVolume(volumeId needle.VolumeId, mergedLive uint64, replicaLive map[pb.ServerAddress]uint64) error {
if mergedLive == 0 {
return fmt.Errorf("merged volume %d is empty; keeping original replicas", volumeId)
}
var maxLive uint64
var maxFrom pb.ServerAddress
for server, live := range replicaLive {
if live > maxLive {
maxLive, maxFrom = live, server
}
}
if mergedLive < maxLive {
return fmt.Errorf("merged volume %d has %d live needles, fewer than replica %s with %d; refusing to overwrite replicas", volumeId, mergedLive, maxFrom, maxLive)
}
return nil
}
func liveNeedleCount(status *volume_server_pb.VolumeStatusResponse) uint64 {
if status == nil || status.FileCount < status.FileDeletedCount {
return 0
}
return status.FileCount - status.FileDeletedCount
}
func readVolumeStatus(grpcDialOption grpc.DialOption, server pb.ServerAddress, volumeId needle.VolumeId) (*volume_server_pb.VolumeStatusResponse, error) {
var resp *volume_server_pb.VolumeStatusResponse
err := operation.WithVolumeServerClient(false, server, grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
r, statusErr := client.VolumeStatus(context.Background(), &volume_server_pb.VolumeStatusRequest{VolumeId: uint32(volumeId)})
if statusErr != nil {
return statusErr
}
resp = r
return nil
})
if err == nil && resp == nil {
return nil, fmt.Errorf("empty volume status response from %s", server)
}
return resp, err
}
func isReplicaServer(target pb.ServerAddress, replicas []*VolumeReplica) bool {
for _, replica := range replicas {
if pb.NewServerAddressFromDataNode(replica.location.dataNode).Equals(target) {
return true
}
}
return false
}
func locationHasDiskType(loc location, diskType string) bool {
for _, diskInfo := range loc.dataNode.DiskInfos {
if diskInfo.Type == diskType {
return true
}
}
return false
}
func markReplicasWritable(grpcDialOption grpc.DialOption, replicas []*VolumeReplica, writable bool, persist bool) error {
for _, replica := range replicas {
server := pb.NewServerAddressFromDataNode(replica.location.dataNode)
err := operation.WithVolumeServerClient(false, server, grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
if writable {
_, err := client.VolumeMarkWritable(context.Background(), &volume_server_pb.VolumeMarkWritableRequest{VolumeId: replica.info.Id})
return err
}
_, err := client.VolumeMarkReadonly(context.Background(), &volume_server_pb.VolumeMarkReadonlyRequest{VolumeId: replica.info.Id, Persist: persist})
return err
})
if err != nil {
return err
}
}
return nil
}