Some checks failed
go: build dev binaries / cleanup (push) Successful in 4s
Codespell / Check for spelling errors (push) Failing after 3s
docker: build dev containers / build-rust-binaries (amd64, x86_64-unknown-linux-musl) (push) Failing after 3s
docker: build dev containers / build-dev-containers (push) Has been skipped
docker: build dev containers / build-rust-binaries (arm64, true, aarch64-unknown-linux-musl) (push) Failing after 3s
End to End / FUSE Mount (push) Failing after 2s
EC Integration Tests / EC Integration Tests (push) Failing after 30s
FUSE DLM Integration Tests / FUSE DLM Integration Tests (push) Failing after 3s
FUSE Volume Server Failover Tests / FUSE Volume Server Failover (push) Failing after 3s
FUSE Integration Tests / FUSE Integration Testing (push) Failing after 2s
FUSE P2P Peer Chunk Sharing Integration Tests / FUSE P2P Peer Chunk Sharing (push) Failing after 3s
go: build binary / Go Vet (push) Failing after 2s
go: build binary / Go Vet 32-bit (push) Failing after 3s
go: build binary / Build (push) Failing after 4s
go: build binary / Build cross-platform (freebsd/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/amd64) (push) Failing after 3s
go: build binary / Build cross-platform (darwin/arm64) (push) Failing after 3s
go: build binary / Build cross-platform (windows/arm64) (push) Failing after 3s
go: build binary / Test (push) Failing after 2s
go: build binary / Test 32-bit (push) Failing after 3s
helm: lint and test charts / lint-test (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (11) (push) Failing after 3s
Java Client Integration Tests / Java Integration Tests (17) (push) Failing after 3s
go: build dev binaries / build_dev_linux_windows (amd64, linux) (push) Failing after 2s
go: build dev binaries / build_dev_linux_windows (amd64, windows) (push) Failing after 3s
go: build dev binaries / build_dev_darwin (amd64, darwin) (push) Has been skipped
go: build dev binaries / build_dev_darwin (arm64, darwin) (push) Has been skipped
Java Client Unit Tests / Java Unit Tests (11) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (17) (push) Failing after 2s
Java Client Unit Tests / Java Unit Tests (21) (push) Failing after 3s
Java Client Unit Tests / Java Unit Tests (8) (push) Failing after 3s
Kafka Quick Test (Load Test with Schema Registry) / Kafka Client Load Test (Quick) (push) Failing after 3s
Kafka Gateway Tests / Kafka Unit Tests (unit-tests-1) (push) Failing after 11s
Kafka Gateway Tests / Kafka Integration Tests (Critical) (integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka End-to-End Tests (with SMQ) (e2e-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Consumer Group Tests (Highly Isolated) (consumer-group-1) (push) Failing after 2s
Kafka Gateway Tests / Kafka Client Compatibility (with SMQ) (client-compat-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka SMQ Integration Tests (Full Stack) (smq-integration-1) (push) Failing after 3s
Kafka Gateway Tests / Kafka Protocol Tests (Isolated) (protocol-1) (push) Failing after 3s
KMS Tests / KMS Provider Integration Tests (push) Failing after 2s
KMS Tests / S3 KMS End-to-End Tests (push) Failing after 3s
Master Cold Start Tests / Master Cold Start Tests (push) Failing after 4s
Metadata Subscribe Integration Tests / Metadata Subscribe Integration Tests (push) Failing after 3s
Multi-Master Tests / Multi-Master Failover Tests (push) Failing after 3s
Performance / CPU and Heap Profile (push) Failing after 2s
Performance / S3 Read/Write Benchmark (rust volume) (push) Failing after 3s
pjdfstest POSIX Compliance / pjdfstest (push) Failing after 2s
Plugin Worker Integration Tests / Plugin Worker: erasure_coding (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: vacuum (push) Failing after 3s
Rust Volume Server Tests / Rust Integration Tests (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 1) (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 1) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 2) (push) Failing after 3s
Rust Volume Server Tests / Go Tests with Rust Volume (grpc - Shard 3) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Distributed Tests (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Performance Tests (push) Failing after 3s
Performance / Throughput Benchmark (go volume) (push) Failing after 3s
Performance / Throughput Benchmark (rust volume) (push) Failing after 2s
Performance / S3 Read/Write Benchmark (go volume) (push) Failing after 3s
Plugin Worker Integration Tests / Plugin Worker: volume_balance (push) Failing after 3s
PostgreSQL Gateway Tests / PostgreSQL Basic Tests (push) Failing after 3s
Rust Volume Server Tests / Rust Unit Tests (push) Failing after 2s
Rust Volume Server Tests / Go Tests with Rust Volume (http - Shard 3) (push) Failing after 3s
Rust Plugin Worker Tests / Rust Plugin Worker Build and Unit Tests (push) Failing after 3s
rust: build dev volume server binaries / cleanup (push) Successful in 3s
S3 ETag and ACL Tests / S3 ETag + ACL Integration Tests (push) Failing after 3s
S3 IAM Integration Tests / IAM Unit Tests (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (advanced) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (basic) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (group) (push) Failing after 3s
S3 IAM Integration Tests / S3 IAM Integration Tests (policy-enforcement) (push) Failing after 2s
S3 IAM Integration Tests / S3 IAM Integration Tests (sts) (push) Failing after 4s
S3 Keycloak Integration Tests / S3 Keycloak Integration Tests (push) Failing after 3s
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.11) (push) Failing after 3s
S3 PyArrow Parquet Tests / Go Unit Tests (Implicit Directory) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.12) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-amd64, x86_64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-darwin (darwin-arm64, aarch64-apple-darwin) (push) Has been cancelled
rust: build dev volume server binaries / build-rust-volume-dev-linux (linux-amd64, x86_64-unknown-linux-gnu) (push) Has been cancelled
S3 PyArrow Parquet Tests / PyArrow Parquet Tests (Python 3.9) (push) Has been cancelled
S3 Proxy Signature Tests / S3 Proxy Signature Verification Tests (push) Has been cancelled
S3 SDK V2 Route Disambiguation Tests / S3 SDK V2 Routing Tests (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (quick) (push) Has been cancelled
S3 SSE Tests / S3 SSE Compatibility Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Metadata Persistence Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Copy Operations Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Multipart Upload Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Performance Test (push) Has been cancelled
S3 SSE Tests / S3 Volume Encryption Test (push) Has been cancelled
S3 SSE Tests / S3 SSE Integration Tests (comprehensive) (push) Has been cancelled
Spark Integration Tests / Spark Integration Tests (push) Failing after 2s
Telemetry Integration Tests / telemetry-integration-test (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 1) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 1) (push) Failing after 3s
Ceph S3 tests / Basic S3 tests (KV store) (push) Has been cancelled
Ceph S3 tests / S3 Versioning & Object Lock tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Unit Tests (push) Failing after 3s
Ceph S3 tests / S3 CORS tests (push) Has been cancelled
Ceph S3 tests / SeaweedFS Custom S3 Copy tests (push) Has been cancelled
Ceph S3 tests / Basic S3 tests (SQL store) (push) Has been cancelled
test s3 over https using aws-cli / awscli-tests (push) Has been cancelled
TLS Rotation Integration Tests / TLS Rotation Integration Tests (push) Has been cancelled
Vacuum Integration Tests / Vacuum Integration Tests (push) Has been cancelled
S3 Policy Integration Tests / S3 Policy Variables Integration Tests (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (advanced-policy) (push) Failing after 3s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (basic-policy) (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Enforcement Integration Tests (contextual-policy) (push) Failing after 4s
S3 Policy Integration Tests / Trusted Proxy Detection Tests (push) Failing after 2s
S3 Policy Integration Tests / S3 Policy Shell Integration Tests (push) Failing after 3s
Samba on FUSE Integration / samba-integration (push) Failing after 2s
SFTP Integration Tests / SFTP Integration Testing (push) Failing after 3s
terraform: validate and test modules / fmt, validate, plan-level tests (push) Failing after 3s
terraform: validate and test modules / local cluster smoke test (real weed) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 3) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (grpc - Shard 2) (push) Failing after 3s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 2) (push) Failing after 2s
Volume Server Integration Tests / Volume Server Integration Tests (http - Shard 3) (push) Failing after 5s
mount: windows conformance / WinFsp conformance (push) Has been cancelled
mount: windows / Mount on Windows (push) Has been cancelled
634 lines
25 KiB
Go
634 lines
25 KiB
Go
package volume_move
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/volume_server_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
srcAddr = pb.ServerAddress("src:8080")
|
|
dstAddr = pb.ServerAddress("dst:8080")
|
|
)
|
|
|
|
func volumeStatus(datSize, idxSize, fileCount uint64) *volume_server_pb.ReadVolumeFileStatusResponse {
|
|
return &volume_server_pb.ReadVolumeFileStatusResponse{
|
|
DatFileSize: datSize,
|
|
IdxFileSize: idxSize,
|
|
FileCount: fileCount,
|
|
}
|
|
}
|
|
|
|
func TestSameServer(t *testing.T) {
|
|
if !SameServer("node:8080", "node:8080.18080") {
|
|
t.Error("one server written with and without an explicit grpc port compared unequal")
|
|
}
|
|
// Test harnesses run several servers on HTTP port 0 with distinct grpc
|
|
// ports; the grpc endpoint is the server's identity.
|
|
if SameServer("127.0.0.1:0.36825", "127.0.0.1:0.38609") {
|
|
t.Error("two servers sharing a degenerate HTTP address compared equal")
|
|
}
|
|
// Malformed addresses (an unvalidated -source/-target flag) must compare
|
|
// without touching the fatal ToGrpcAddress parser.
|
|
if !SameServer("node:abc", "node:abc") {
|
|
t.Error("identical malformed addresses compared unequal")
|
|
}
|
|
if SameServer("node:abc", "node:8080") {
|
|
t.Error("malformed and well-formed addresses compared equal")
|
|
}
|
|
if SameServer("", "node:8080") {
|
|
t.Error("empty and well-formed addresses compared equal")
|
|
}
|
|
}
|
|
|
|
func TestCheckDialable(t *testing.T) {
|
|
// Rejected: the class the fatal parser aborts on, plus dotted forms with
|
|
// a non-numeric component — those canonicalize differently across dial
|
|
// paths ("node:8080.bad" dials as node:bad on one and node:18080 on the
|
|
// other, silently aliasing another server).
|
|
for _, addr := range []pb.ServerAddress{"node:abc", "host.domain.com:x8080", "[::1]:abc",
|
|
"node:8080.bad", "node:bad.8080", "node:.bad", "node:8080."} {
|
|
if err := checkDialable(addr); err == nil {
|
|
t.Errorf("%q accepted although it is unsafe to dial", addr)
|
|
}
|
|
}
|
|
// Accepted: normalized cleanly, or handed to the dialer untouched where
|
|
// a bad address fails as an ordinary error.
|
|
for _, addr := range []pb.ServerAddress{"node:8080", "node:8080.18080", "127.0.0.1:0.36825", "", "node", "node:"} {
|
|
if err := checkDialable(addr); err != nil {
|
|
t.Errorf("%q rejected: %v", addr, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEmbeddedSourceAddressValidated(t *testing.T) {
|
|
// The target dials the source embedded in copy/tail requests through the
|
|
// same fatal parser; the client must reject a malformed source before
|
|
// issuing any RPC.
|
|
bad := pb.ServerAddress("node:abc")
|
|
|
|
ops := map[string]func(m *Mover) error{
|
|
"ReplicateVolume": func(m *Mover) error {
|
|
return m.ReplicateVolume(context.Background(), 7, bad, dstAddr, "", 0, nil)
|
|
},
|
|
"TailVolume": func(m *Mover) error {
|
|
return m.TailVolume(context.Background(), 7, bad, dstAddr, 0, time.Second)
|
|
},
|
|
"CopyAndMountEcShards": func(m *Mover) error {
|
|
return m.CopyAndMountEcShards(context.Background(), 7, "c1", []erasure_coding.ShardId{3}, bad, dstAddr, 0, 0, nil)
|
|
},
|
|
"MoveEcShards": func(m *Mover) error {
|
|
move := ecMove(3)
|
|
move.Source = bad
|
|
return m.MoveEcShards(context.Background(), move, EcMoveOptions{})
|
|
},
|
|
}
|
|
for name, op := range ops {
|
|
cluster := newFakeCluster()
|
|
err := op(cluster.mover())
|
|
if err == nil || !strings.Contains(err.Error(), "invalid volume server address") {
|
|
t.Errorf("%s: expected invalid-address error, got: %v", name, err)
|
|
}
|
|
if len(cluster.callList()) != 0 {
|
|
t.Errorf("%s: RPCs issued with a malformed embedded source: %v", name, cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewMoverRejectsMalformedAddressWithoutDialing(t *testing.T) {
|
|
// The production dial path must fail the move, not the process, when a
|
|
// caller passes an unvalidated malformed address.
|
|
mover := NewMover(grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
err := mover.LiveMoveVolume(context.Background(), 7, "node:abc", "node:8080", VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "invalid volume server address") {
|
|
t.Fatalf("expected invalid-address error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func assertCalls(t *testing.T, got, want []string) {
|
|
t.Helper()
|
|
if fmt.Sprint(got) != fmt.Sprint(want) {
|
|
t.Fatalf("RPC sequence mismatch:\n got: %v\n want: %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeSequence(t *testing.T) {
|
|
cluster := newFakeCluster()
|
|
cluster.lastAppendAtNs = 12345
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{
|
|
DiskType: "ssd",
|
|
IoBytePerSecond: 42,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LiveMoveVolume: %v", err)
|
|
}
|
|
|
|
// The source status read must come after the tail: a write in flight when
|
|
// the source was frozen can land after an earlier read, and verifying
|
|
// against that stale snapshot would miss a missed tail. The first target
|
|
// status read probes whether the target already held the volume, so a
|
|
// failed copy never cleans up someone else's replica.
|
|
assertCalls(t, cluster.callList(), []string{
|
|
"src:8080 VolumeStatus",
|
|
"src:8080 VolumeMarkReadonly",
|
|
"dst:8080 ReadVolumeFileStatus",
|
|
"dst:8080 VolumeCopy",
|
|
"dst:8080 VolumeTailReceiver",
|
|
"src:8080 ReadVolumeFileStatus",
|
|
"dst:8080 ReadVolumeFileStatus",
|
|
"src:8080 VolumeDelete",
|
|
})
|
|
|
|
copyReq := cluster.copyReqs[0]
|
|
if copyReq.DiskType != "ssd" || copyReq.IoBytePerSecond != 42 || copyReq.SourceDataNode != string(srcAddr) {
|
|
t.Errorf("copy request not propagated: %+v", copyReq)
|
|
}
|
|
deleteReq := cluster.deleteReqs[0]
|
|
if deleteReq.OnlyEmpty || !deleteReq.KeepRemoteData {
|
|
t.Errorf("source delete must keep remote data and not be only-empty: %+v", deleteReq)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeVerifyMismatchKeepsSource(t *testing.T) {
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "behind the source") {
|
|
t.Fatalf("expected target-behind error, got: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
for _, call := range calls {
|
|
if call == "src:8080 VolumeDelete" {
|
|
t.Fatalf("source deleted despite verification failure: %v", calls)
|
|
}
|
|
}
|
|
// The incomplete target copy is removed so the restored source stays the
|
|
// only replica, then the source is made writable again.
|
|
if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
|
|
t.Fatalf("expected target cleanup then source restore, got: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeAbortKeepsSourceReadonlyWhenTargetCleanupFails(t *testing.T) {
|
|
// Restoring the source while the stale target stays mounted risks
|
|
// divergent replicas; the source stays readonly until a re-run recovers.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)
|
|
cluster.errs["dst:8080 VolumeDelete"] = errors.New("target unreachable")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected verification failure")
|
|
}
|
|
if !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("error does not mark the source as deliberately kept readonly: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("source made writable with the stale target still mounted: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeAlreadyReadonlyStaysReadonly(t *testing.T) {
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.errs["dst:8080 VolumeCopy"] = errors.New("copy failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected copy failure")
|
|
}
|
|
|
|
// The hard mark is issued even for a readonly-reporting volume: that
|
|
// status also covers low-disk and readonly-but-can-delete states, which
|
|
// still accept needle deletes.
|
|
marked := false
|
|
for _, call := range cluster.callList() {
|
|
if call == "src:8080 VolumeMarkReadonly" {
|
|
marked = true
|
|
}
|
|
// A volume that was readonly before the move (e.g. full) must stay so.
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatal("made an already-readonly volume writable after a failed move")
|
|
}
|
|
}
|
|
if !marked {
|
|
t.Fatalf("readonly-reporting source not hard-frozen: %v", cluster.callList())
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumePreFrozenAbortCleansTarget(t *testing.T) {
|
|
// tiering.run freezes replicas itself and thaws them after a failed move,
|
|
// so even a move that did not do the freezing must remove its target copy
|
|
// on abort — a stale mounted target plus a thawed source is divergence.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(900, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected verification failure")
|
|
}
|
|
|
|
cleaned := false
|
|
for _, call := range cluster.callList() {
|
|
if call == "dst:8080 VolumeDelete" {
|
|
cleaned = true
|
|
}
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("thawed a source this move did not freeze: %v", cluster.callList())
|
|
}
|
|
}
|
|
if !cleaned {
|
|
t.Fatalf("incomplete target copy not cleaned up: %v", cluster.callList())
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeCopyErrorCleansMountedTarget(t *testing.T) {
|
|
// The server can finish the copy and mount the target even when the client
|
|
// loses the stream; the abort probes the target and cleans up the copy.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
|
|
cluster.errs["dst:8080 VolumeCopy"] = errors.New("stream lost")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected copy failure")
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
|
|
t.Fatalf("expected target cleanup then source restore, got: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeCopyErrorUnknownTargetKeepsSourceReadonly(t *testing.T) {
|
|
// The pre-copy probe failed at the transport level, so whether the target
|
|
// held a replica before the move is unknown; the failed copy may still
|
|
// have mounted a complete copy there (the server can finish after the
|
|
// client loses the stream). Deleting it risks a healthy pre-existing
|
|
// replica, and reopening the source beside it risks two writable replicas
|
|
// taking divergent writes — so the source stays readonly.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1
|
|
cluster.statusFailureErr = status.Error(codes.Unavailable, "connection refused")
|
|
cluster.errs["dst:8080 VolumeCopy"] = errors.New("stream lost")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("expected kept-readonly failure, got: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
for _, call := range calls {
|
|
if call == "dst:8080 VolumeDelete" {
|
|
t.Fatalf("deleted the target with its prior state unknown: %v", calls)
|
|
}
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("reopened the source beside a possibly-mounted copy: %v", calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeCopyErrorLeavesPreexistingTarget(t *testing.T) {
|
|
// A target that held the volume before the move is someone else's replica;
|
|
// a failed copy must not delete it.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.errs["dst:8080 VolumeCopy"] = errors.New("copy failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("expected kept-readonly failure, got: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
for _, call := range calls {
|
|
if call == "dst:8080 VolumeDelete" {
|
|
t.Fatalf("deleted a pre-existing target replica after a failed copy: %v", calls)
|
|
}
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("reopened the source with a copy of unprovable origin on the target: %v", calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeTailErrorAborts(t *testing.T) {
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "tail volume") {
|
|
t.Fatalf("expected tail error, got: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
for _, call := range calls {
|
|
if call == "src:8080 VolumeDelete" {
|
|
t.Fatalf("source deleted despite tail failure: %v", calls)
|
|
}
|
|
}
|
|
if fmt.Sprint(calls[len(calls)-2:]) != fmt.Sprint([]string{"dst:8080 VolumeDelete", "src:8080 VolumeMarkWritable"}) {
|
|
t.Fatalf("expected target cleanup then source restore, got: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeTailErrorToleratedForReadonlySource(t *testing.T) {
|
|
// A volume that was already readonly before the move was frozen for the
|
|
// whole copy, so a failed tail has nothing to deliver.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
|
|
cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{IdleTimeout: time.Millisecond})
|
|
if err != nil {
|
|
t.Fatalf("LiveMoveVolume on readonly source with failed tail: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
if calls[len(calls)-1] != "src:8080 VolumeDelete" {
|
|
t.Fatalf("move did not complete with source delete: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeTailFailureAbortsWhenSourceStillChanging(t *testing.T) {
|
|
// A tolerated tail failure substitutes a drain barrier: the source status
|
|
// must hold still across the idle window, or the move aborts.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1 // no volume on the target before the copy
|
|
cluster.statusSeq[string(srcAddr)] = []*volume_server_pb.ReadVolumeFileStatusResponse{
|
|
volumeStatus(1000, 100, 10),
|
|
volumeStatus(1024, 116, 11), // a straggler landed between the two reads
|
|
}
|
|
cluster.errs["dst:8080 VolumeTailReceiver"] = errors.New("tail failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{IdleTimeout: time.Millisecond})
|
|
if err == nil || !strings.Contains(err.Error(), "still changing") {
|
|
t.Fatalf("expected still-changing abort, got: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "src:8080 VolumeDelete" {
|
|
t.Fatalf("source deleted while still changing: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeSourceDeleteFailureKeepsBothReadonly(t *testing.T) {
|
|
// Past verification the target is complete and may hold new writes, and
|
|
// the failed delete may or may not have reached the source. Deleting the
|
|
// target or reopening the source could fork the volume; keep everything
|
|
// readonly for a re-run.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.errs["src:8080 VolumeDelete"] = errors.New("delete timed out")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil {
|
|
t.Fatal("expected source delete failure")
|
|
}
|
|
if !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("error does not mark the source as deliberately kept readonly: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "dst:8080 VolumeDelete" {
|
|
t.Fatalf("deleted the verified target copy after a failed source delete: %v", cluster.callList())
|
|
}
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("restored source writability next to a mounted verified copy: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeRefusesRecopyOverCompleteTarget(t *testing.T) {
|
|
// A readonly source next to a target already holding a copy that is not
|
|
// behind it is the signature of a previous move whose source delete
|
|
// failed; recopying would tear down the possibly-authoritative target.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "already has a copy") {
|
|
t.Fatalf("expected recopy refusal, got: %v", err)
|
|
}
|
|
if !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("refusal does not mark the source as deliberately kept readonly: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "dst:8080 VolumeCopy" || call == "dst:8080 VolumeDelete" {
|
|
t.Fatalf("touched the possibly-authoritative target: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeReadonlySourceUnknownTargetRefused(t *testing.T) {
|
|
// With a readonly source, an unreachable target probe must fail closed:
|
|
// the target could hold the authoritative copy of a previous move.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1
|
|
cluster.statusFailureErr = status.Error(codes.Unavailable, "connection refused")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("expected fail-closed refusal, got: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "dst:8080 VolumeCopy" {
|
|
t.Fatalf("copied over a target in unknown state: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumePreFrozenSourceMissingRustTargetProceeds(t *testing.T) {
|
|
// The Rust volume server answers a missing volume with codes.NotFound
|
|
// (the Go server uses a plain error, code Unknown). Both are definitive
|
|
// absence: with a pre-frozen source (tiering freezes before moving), a
|
|
// NotFound misclassified as "unknown" would refuse the move with
|
|
// ErrSourceKeptReadonly — and tiering callers then deliberately skip
|
|
// thawing, stranding every replica readonly after an ordinary move.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.statusFailures[string(dstAddr)] = 1
|
|
cluster.statusFailureErr = status.Error(codes.NotFound, "not found volume id 7")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LiveMoveVolume to an empty Rust target: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
if calls[len(calls)-1] != "src:8080 VolumeDelete" {
|
|
t.Fatalf("move did not complete with source delete: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeReadonlySourceExistingTargetRefused(t *testing.T) {
|
|
// No client-side observation can prove an existing copy is a stale
|
|
// remnant rather than the authoritative copy of an unfinished move —
|
|
// compaction shrinks the authoritative copy below the stale source, and
|
|
// any snapshot can be invalidated by a write right after it. Even a
|
|
// target that reads as behind is refused.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(900, 90, 9)
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !errors.Is(err, ErrSourceKeptReadonly) {
|
|
t.Fatalf("expected fail-closed refusal, got: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "dst:8080 VolumeCopy" || call == "dst:8080 VolumeDelete" {
|
|
t.Fatalf("touched a possibly-authoritative target: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCopyVolumeLeavesReadonlySourceUntouched(t *testing.T) {
|
|
// The copy is non-destructive, so a readonly-reporting source (possibly
|
|
// only transiently, e.g. low disk) is not hard-marked.
|
|
cluster := newFakeCluster()
|
|
cluster.readonly[string(srcAddr)] = true
|
|
|
|
_, err := cluster.mover().CopyVolume(context.Background(), 7, srcAddr, dstAddr, "", 0, true, nil)
|
|
if err != nil {
|
|
t.Fatalf("CopyVolume: %v", err)
|
|
}
|
|
|
|
for _, call := range cluster.callList() {
|
|
if call == "src:8080 VolumeMarkReadonly" || call == "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("readonly source touched by a non-destructive copy: %v", cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeTargetAheadCommits(t *testing.T) {
|
|
// The target serves writes while the move tails; a target that got ahead
|
|
// holds those acknowledged writes, and the move must commit so they stay
|
|
// on the surviving copy.
|
|
cluster := newFakeCluster()
|
|
cluster.status[string(srcAddr)] = volumeStatus(1000, 100, 10)
|
|
cluster.status[string(dstAddr)] = volumeStatus(1100, 110, 11)
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err != nil {
|
|
t.Fatalf("LiveMoveVolume with target ahead: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
if calls[len(calls)-1] != "src:8080 VolumeDelete" {
|
|
t.Fatalf("move did not commit with source delete: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeRejectsSameServer(t *testing.T) {
|
|
// The second target is the same server written with an explicit grpc port;
|
|
// the guard must see through the representation difference.
|
|
for _, target := range []pb.ServerAddress{srcAddr, pb.ServerAddress("src:8080.18080")} {
|
|
cluster := newFakeCluster()
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, target, VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "its own server") {
|
|
t.Fatalf("target %q: expected same-server rejection, got: %v", target, err)
|
|
}
|
|
if len(cluster.callList()) != 0 {
|
|
t.Fatalf("target %q: RPCs issued for a rejected move: %v", target, cluster.callList())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLiveMoveVolumeReadonlyMarkFailureRestores(t *testing.T) {
|
|
// The marking RPC can fail after the server applied the mark (e.g. its
|
|
// master notification failed), so a mark error must still restore.
|
|
cluster := newFakeCluster()
|
|
cluster.errs["src:8080 VolumeMarkReadonly"] = errors.New("master notification failed")
|
|
|
|
err := cluster.mover().LiveMoveVolume(context.Background(), 7, srcAddr, dstAddr, VolumeMoveOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), "mark volume") {
|
|
t.Fatalf("expected mark-readonly error, got: %v", err)
|
|
}
|
|
|
|
calls := cluster.callList()
|
|
if calls[len(calls)-1] != "src:8080 VolumeMarkWritable" {
|
|
t.Fatalf("source writability not restored after failed readonly mark: %v", calls)
|
|
}
|
|
}
|
|
|
|
func TestReplicateVolumePropagatesThroughput(t *testing.T) {
|
|
cluster := newFakeCluster()
|
|
|
|
if err := cluster.mover().ReplicateVolume(context.Background(), 7, srcAddr, dstAddr, "ssd", 55, nil); err != nil {
|
|
t.Fatalf("ReplicateVolume: %v", err)
|
|
}
|
|
copyReq := cluster.copyReqs[0]
|
|
if copyReq.IoBytePerSecond != 55 || copyReq.DiskType != "ssd" {
|
|
t.Errorf("replicate request not propagated: %+v", copyReq)
|
|
}
|
|
}
|
|
|
|
func TestCopyVolumeRestoreWritable(t *testing.T) {
|
|
for _, restoreWritable := range []bool{true, false} {
|
|
cluster := newFakeCluster()
|
|
_, err := cluster.mover().CopyVolume(context.Background(), 7, srcAddr, dstAddr, "", 0, restoreWritable, nil)
|
|
if err != nil {
|
|
t.Fatalf("CopyVolume(restoreWritable=%v): %v", restoreWritable, err)
|
|
}
|
|
restored := false
|
|
for _, call := range cluster.callList() {
|
|
if call == "src:8080 VolumeMarkWritable" {
|
|
restored = true
|
|
}
|
|
}
|
|
if restored != restoreWritable {
|
|
t.Errorf("restoreWritable=%v but writability restored=%v", restoreWritable, restored)
|
|
}
|
|
}
|
|
}
|