snapshot of https://github.com/seaweedfs/seaweedfs.git (master, 2026-09-16; blobs > 131072B omitted)
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
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
This commit is contained in:
63
weed/ec/ec_balance_migrate_test.go
Normal file
63
weed/ec/ec_balance_migrate_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
)
|
||||
|
||||
// TestEcBalanceMigratesCrossDiskTypeShards: a cross-tier encode generates its
|
||||
// shards beside the source .dat — in the SOURCE disk-type bucket — while the
|
||||
// encode's balance targets -diskType. The balance must still see and spread
|
||||
// those shards when the volume is named as migrating; without that, the
|
||||
// planner finds nothing in the target bucket, plans no moves, and the encode's
|
||||
// spread guard aborts a perfectly good encode.
|
||||
func TestEcBalanceMigratesCrossDiskTypeShards(t *testing.T) {
|
||||
allShards := []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}
|
||||
|
||||
build := func() []*EcNode {
|
||||
// Volume 1's fresh shards sit in the default (hdd, "") bucket of dn1;
|
||||
// the balance runs with -diskType=ssd.
|
||||
return []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", allShards),
|
||||
newEcNode("dc1", "rack2", "dn2", 100),
|
||||
newEcNode("dc1", "rack3", "dn3", 100),
|
||||
}
|
||||
}
|
||||
|
||||
// Without the migrating hint the target-bucket filter hides the shards:
|
||||
// nothing moves. This is the standalone ec.balance semantic — shards of
|
||||
// other tiers stay where they are.
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: build(),
|
||||
applyBalancing: false,
|
||||
diskType: types.SsdType,
|
||||
}
|
||||
if err := ecb.balance([]string{"c1"}); err != nil {
|
||||
t.Fatalf("balance without migrating hint: %v", err)
|
||||
}
|
||||
if got := ecb.ecNodes[0].LocalShardIdCount(1); got != len(allShards) {
|
||||
t.Fatalf("without migrating hint, cross-type shards must stay put; dn1 has %d", got)
|
||||
}
|
||||
|
||||
// With the volume named as migrating, the balance ingests the shards from
|
||||
// the source bucket and spreads them.
|
||||
ecb = &ecBalancer{
|
||||
ecNodes: build(),
|
||||
applyBalancing: false,
|
||||
diskType: types.SsdType,
|
||||
migratingVolumeIds: map[uint32]bool{1: true},
|
||||
}
|
||||
if err := ecb.balance([]string{"c1"}); err != nil {
|
||||
t.Fatalf("balance with migrating hint: %v", err)
|
||||
}
|
||||
onDn1 := ecb.ecNodes[0].LocalShardIdCount(1)
|
||||
spread := ecb.ecNodes[1].LocalShardIdCount(1) + ecb.ecNodes[2].LocalShardIdCount(1)
|
||||
if onDn1 == len(allShards) || spread == 0 {
|
||||
t.Fatalf("migrating volume's shards did not spread: dn1=%d others=%d", onDn1, spread)
|
||||
}
|
||||
if onDn1+spread != len(allShards) {
|
||||
t.Fatalf("shards lost or duplicated during dry-run spread: dn1=%d others=%d", onDn1, spread)
|
||||
}
|
||||
}
|
||||
211
weed/ec/ec_check_replication.go
Normal file
211
weed/ec/ec_check_replication.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
)
|
||||
|
||||
// CheckEcVolumeReplication reports EC volumes with under- or over-replicated
|
||||
// shards among the given data nodes. volumeIDs, when non-empty, narrows the
|
||||
// check to those volume ids.
|
||||
func CheckEcVolumeReplication(writer io.Writer, dataNodes []*master_pb.DataNodeInfo, volumeIDs map[uint32]bool, showDetails bool) error {
|
||||
runner := &ecCheckReplicationRunner{
|
||||
writer: writer,
|
||||
dataNodes: dataNodes,
|
||||
volumeIDMap: volumeIDs,
|
||||
}
|
||||
return runner.checkEcVolumes(showDetails)
|
||||
}
|
||||
|
||||
type ecCheckReplicationRunner struct {
|
||||
writer io.Writer
|
||||
dataNodes []*master_pb.DataNodeInfo
|
||||
volumeIDMap map[uint32]bool
|
||||
}
|
||||
|
||||
func (r *ecCheckReplicationRunner) write(format string, a ...any) {
|
||||
fmt.Fprintf(r.writer, format, a...)
|
||||
}
|
||||
|
||||
func (r *ecCheckReplicationRunner) isVolumeIDValid(vid uint32) bool {
|
||||
if len(r.volumeIDMap) == 0 {
|
||||
return true
|
||||
}
|
||||
return r.volumeIDMap[vid]
|
||||
}
|
||||
|
||||
// ecVolumeShardReplication aggregates the observed shards for a single EC volume,
|
||||
// together with the data+parity ratio the volume was encoded with. The ratio is
|
||||
// taken per volume (via erasure_coding.EcShardsVolume*Shards) so custom EC
|
||||
// ratios are checked against their own expected shard count.
|
||||
type ecVolumeShardReplication struct {
|
||||
dataShards int
|
||||
parityShards int
|
||||
// shardAddresses maps a shard id to the sorted node addresses hosting it.
|
||||
shardAddresses map[erasure_coding.ShardId][]string
|
||||
}
|
||||
|
||||
func (h *ecVolumeShardReplication) totalShards() int {
|
||||
return h.dataShards + h.parityShards
|
||||
}
|
||||
|
||||
// replicaCount is the total number of shard copies observed, counting each
|
||||
// over-replicated shard once per hosting node.
|
||||
func (h *ecVolumeShardReplication) replicaCount() int {
|
||||
n := 0
|
||||
for _, addrs := range h.shardAddresses {
|
||||
n += len(addrs)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// unexpectedShardIds returns, in ascending order, any observed shard ids that
|
||||
// fall outside the volume's expected data+parity range. These are anomalies
|
||||
// (e.g. a stray shard) and are treated as over-replication.
|
||||
func (h *ecVolumeShardReplication) unexpectedShardIds() []erasure_coding.ShardId {
|
||||
var ids []erasure_coding.ShardId
|
||||
for sid := range h.shardAddresses {
|
||||
if int(sid) >= h.totalShards() {
|
||||
ids = append(ids, sid)
|
||||
}
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// TODO: check shard sizes?
|
||||
func (r *ecCheckReplicationRunner) checkEcVolumes(showDetails bool) error {
|
||||
// collect EC shard placement, keyed by volume id
|
||||
volumes := map[uint32]*ecVolumeShardReplication{}
|
||||
for _, dni := range r.dataNodes {
|
||||
nodeAddress := dni.GetAddress()
|
||||
for _, di := range dni.GetDiskInfos() {
|
||||
for _, eci := range di.GetEcShardInfos() {
|
||||
vid := eci.GetId()
|
||||
if !r.isVolumeIDValid(vid) {
|
||||
continue
|
||||
}
|
||||
|
||||
h, ok := volumes[vid]
|
||||
if !ok {
|
||||
// all shards of a volume share one ratio; take it from the
|
||||
// first shard message seen for the volume.
|
||||
h = &ecVolumeShardReplication{
|
||||
dataShards: erasure_coding.EcShardsVolumeDataShards(eci),
|
||||
parityShards: erasure_coding.EcShardsVolumeParityShards(eci),
|
||||
shardAddresses: map[erasure_coding.ShardId][]string{},
|
||||
}
|
||||
volumes[vid] = h
|
||||
}
|
||||
|
||||
sinfo := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(eci)
|
||||
for _, sid := range sinfo.Ids() {
|
||||
h.shardAddresses[sid] = append(h.shardAddresses[sid], nodeAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(volumes) == 0 {
|
||||
// Asking about specific volume IDs that turn out not to be EC volumes is
|
||||
// an error; an unfiltered run over a cluster with no EC volumes is a
|
||||
// legitimate, healthy state rather than a failure.
|
||||
if len(r.volumeIDMap) > 0 {
|
||||
return fmt.Errorf("no EC volumes found")
|
||||
}
|
||||
r.write("No EC volumes found.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
// keep the shard address lists nicely sorted, for the sake of readability.
|
||||
for _, h := range volumes {
|
||||
for _, addrs := range h.shardAddresses {
|
||||
slices.Sort(addrs)
|
||||
}
|
||||
}
|
||||
|
||||
// classify each volume against its own expected shard count
|
||||
underreplicatedVolumeIDs := []uint32{}
|
||||
overreplicatedVolumeIDs := []uint32{}
|
||||
for vid, h := range volumes {
|
||||
under := false
|
||||
over := false
|
||||
for sid := 0; sid < h.totalShards(); sid++ {
|
||||
switch len(h.shardAddresses[erasure_coding.ShardId(sid)]) {
|
||||
case 0:
|
||||
under = true
|
||||
case 1:
|
||||
default:
|
||||
over = true
|
||||
}
|
||||
}
|
||||
// shard ids beyond the expected data+parity range are unexpected extras,
|
||||
// i.e. redundant data the ratio doesn't call for.
|
||||
if len(h.unexpectedShardIds()) > 0 {
|
||||
over = true
|
||||
}
|
||||
|
||||
// under- and over-replication are independent problems (missing shards risk
|
||||
// data loss, redundant shards waste space), so a volume exhibiting both is
|
||||
// reported in both lists.
|
||||
if under {
|
||||
underreplicatedVolumeIDs = append(underreplicatedVolumeIDs, vid)
|
||||
}
|
||||
if over {
|
||||
overreplicatedVolumeIDs = append(overreplicatedVolumeIDs, vid)
|
||||
}
|
||||
}
|
||||
slices.Sort(underreplicatedVolumeIDs)
|
||||
slices.Sort(overreplicatedVolumeIDs)
|
||||
|
||||
// ...and display results
|
||||
if len(underreplicatedVolumeIDs) == 0 && len(overreplicatedVolumeIDs) == 0 {
|
||||
r.write("EC volumes are healthy.\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(underreplicatedVolumeIDs) != 0 {
|
||||
r.write("Found %d/%d under-replicated EC volumes: %v\n", len(underreplicatedVolumeIDs), len(volumes), underreplicatedVolumeIDs)
|
||||
}
|
||||
if len(overreplicatedVolumeIDs) != 0 {
|
||||
r.write("Found %d/%d over-replicated EC volumes: %v\n", len(overreplicatedVolumeIDs), len(volumes), overreplicatedVolumeIDs)
|
||||
}
|
||||
|
||||
if showDetails {
|
||||
if len(underreplicatedVolumeIDs) != 0 {
|
||||
r.write("\n")
|
||||
r.writeShardMaps("under-replicated", underreplicatedVolumeIDs, volumes)
|
||||
}
|
||||
if len(overreplicatedVolumeIDs) != 0 {
|
||||
r.write("\n")
|
||||
r.writeShardMaps("over-replicated", overreplicatedVolumeIDs, volumes)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ecCheckReplicationRunner) writeShardMaps(kind string, volumeIDs []uint32, volumes map[uint32]*ecVolumeShardReplication) {
|
||||
for _, vid := range volumeIDs {
|
||||
h := volumes[vid]
|
||||
r.write("Shards map for %s EC volume %v (%d/%d shards):\n", kind, vid, h.replicaCount(), h.totalShards())
|
||||
for sid := 0; sid < h.totalShards(); sid++ {
|
||||
shardTypeDesc := ""
|
||||
if sid >= h.dataShards {
|
||||
shardTypeDesc = " (parity)"
|
||||
}
|
||||
if addrs, ok := h.shardAddresses[erasure_coding.ShardId(sid)]; ok {
|
||||
r.write("\t%02d%s => %v\n", sid, shardTypeDesc, addrs)
|
||||
} else {
|
||||
r.write("\t%02d%s is missing\n", sid, shardTypeDesc)
|
||||
}
|
||||
}
|
||||
for _, sid := range h.unexpectedShardIds() {
|
||||
r.write("\t%02d (unexpected) => %v\n", int(sid), h.shardAddresses[sid])
|
||||
}
|
||||
}
|
||||
}
|
||||
289
weed/ec/ec_check_replication_test.go
Normal file
289
weed/ec/ec_check_replication_test.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
)
|
||||
|
||||
// TestEcCheckReplication exercises the ec.check.replication classification and reporting over a
|
||||
// fake topology. EC volume 1 is fully replicated, EC volume 2 is
|
||||
// under-replicated, and EC volume 3 is over-replicated.
|
||||
func TestEcCheckReplication(t *testing.T) {
|
||||
nodes := []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Address: "localhost:1111",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 1,
|
||||
EcIndexBits: 0b0000000011111,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
EcIndexBits: 0b0000000011111,
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
EcIndexBits: 0b11111111111111,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Address: "localhost:2222",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d2": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{
|
||||
Id: 1,
|
||||
EcIndexBits: 0b11111111100000,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
EcIndexBits: 0b11111000000000,
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
EcIndexBits: 0b00000000001111,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
showDetails bool
|
||||
volumeIDMap map[uint32]bool
|
||||
want string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
showDetails: false,
|
||||
want: `Found 1/3 under-replicated EC volumes: [2]
|
||||
Found 1/3 over-replicated EC volumes: [3]
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "single healthy volume",
|
||||
showDetails: false,
|
||||
volumeIDMap: map[uint32]bool{1: true},
|
||||
want: `EC volumes are healthy.
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "filtered by volume ID",
|
||||
showDetails: false,
|
||||
volumeIDMap: map[uint32]bool{1: true, 2: true, 5: true},
|
||||
want: `Found 1/2 under-replicated EC volumes: [2]
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: "no valid volume IDs",
|
||||
showDetails: false,
|
||||
volumeIDMap: map[uint32]bool{5: true, 6: true},
|
||||
want: "",
|
||||
wantErr: fmt.Errorf("no EC volumes found"),
|
||||
},
|
||||
{
|
||||
name: "with details",
|
||||
showDetails: true,
|
||||
want: `Found 1/3 under-replicated EC volumes: [2]
|
||||
Found 1/3 over-replicated EC volumes: [3]
|
||||
|
||||
Shards map for under-replicated EC volume 2 (10/14 shards):
|
||||
00 => [localhost:1111]
|
||||
01 => [localhost:1111]
|
||||
02 => [localhost:1111]
|
||||
03 => [localhost:1111]
|
||||
04 => [localhost:1111]
|
||||
05 is missing
|
||||
06 is missing
|
||||
07 is missing
|
||||
08 is missing
|
||||
09 => [localhost:2222]
|
||||
10 (parity) => [localhost:2222]
|
||||
11 (parity) => [localhost:2222]
|
||||
12 (parity) => [localhost:2222]
|
||||
13 (parity) => [localhost:2222]
|
||||
|
||||
Shards map for over-replicated EC volume 3 (18/14 shards):
|
||||
00 => [localhost:1111 localhost:2222]
|
||||
01 => [localhost:1111 localhost:2222]
|
||||
02 => [localhost:1111 localhost:2222]
|
||||
03 => [localhost:1111 localhost:2222]
|
||||
04 => [localhost:1111]
|
||||
05 => [localhost:1111]
|
||||
06 => [localhost:1111]
|
||||
07 => [localhost:1111]
|
||||
08 => [localhost:1111]
|
||||
09 => [localhost:1111]
|
||||
10 (parity) => [localhost:1111]
|
||||
11 (parity) => [localhost:1111]
|
||||
12 (parity) => [localhost:1111]
|
||||
13 (parity) => [localhost:1111]
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
var buf bytes.Buffer
|
||||
|
||||
runner := &ecCheckReplicationRunner{
|
||||
writer: &buf,
|
||||
dataNodes: nodes,
|
||||
volumeIDMap: tc.volumeIDMap,
|
||||
}
|
||||
gotErr := runner.checkEcVolumes(tc.showDetails)
|
||||
got := buf.String()
|
||||
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(gotErr, tc.wantErr) {
|
||||
t.Errorf("%s: got error %v, want %v", tc.name, gotErr, tc.wantErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcCheckReplicationNoVolumes checks the empty-topology behavior: an unfiltered
|
||||
// run over a cluster with no EC volumes is healthy (informational message, no
|
||||
// error), while a run filtered to volume IDs that are not EC volumes errors.
|
||||
func TestEcCheckReplicationNoVolumes(t *testing.T) {
|
||||
// a data node reporting a normal volume but no EC shards
|
||||
nodes := []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Address: "localhost:1111",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"d1": {}},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("unfiltered", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
runner := &ecCheckReplicationRunner{writer: &buf, dataNodes: nodes}
|
||||
if err := runner.checkEcVolumes(false); err != nil {
|
||||
t.Errorf("got error %v, want nil", err)
|
||||
}
|
||||
if got, want := buf.String(), "No EC volumes found.\n"; got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("filtered no match", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
runner := &ecCheckReplicationRunner{writer: &buf, dataNodes: nodes, volumeIDMap: map[uint32]bool{5: true}}
|
||||
if err := runner.checkEcVolumes(false); err == nil {
|
||||
t.Errorf("got nil error, want %q", "no EC volumes found")
|
||||
}
|
||||
if got := buf.String(); got != "" {
|
||||
t.Errorf("got output %q, want none", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEcCheckReplicationUnexpectedShard verifies that a shard id beyond a volume's
|
||||
// expected data+parity range is flagged as over-replication and surfaced in the
|
||||
// details map, rather than being silently ignored. Volume 20 is a healthy 10+4
|
||||
// (shards 0..13 on nodeA) with a stray shard 14 on nodeB.
|
||||
func TestEcCheckReplicationUnexpectedShard(t *testing.T) {
|
||||
nodes := []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Address: "nodeA",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 20, EcIndexBits: 0b11111111111111}, // shards 0..13
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Address: "nodeB",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d2": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 20, EcIndexBits: 0b100000000000000}, // stray shard 14
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
want := `Found 1/1 over-replicated EC volumes: [20]
|
||||
|
||||
Shards map for over-replicated EC volume 20 (15/14 shards):
|
||||
00 => [nodeA]
|
||||
01 => [nodeA]
|
||||
02 => [nodeA]
|
||||
03 => [nodeA]
|
||||
04 => [nodeA]
|
||||
05 => [nodeA]
|
||||
06 => [nodeA]
|
||||
07 => [nodeA]
|
||||
08 => [nodeA]
|
||||
09 => [nodeA]
|
||||
10 (parity) => [nodeA]
|
||||
11 (parity) => [nodeA]
|
||||
12 (parity) => [nodeA]
|
||||
13 (parity) => [nodeA]
|
||||
14 (unexpected) => [nodeB]
|
||||
`
|
||||
|
||||
var buf bytes.Buffer
|
||||
runner := &ecCheckReplicationRunner{writer: &buf, dataNodes: nodes}
|
||||
if err := runner.checkEcVolumes(true); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := buf.String(); got != want {
|
||||
t.Errorf("unexpected shard:\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcCheckReplicationMixedAnomalies verifies that a volume with both a missing
|
||||
// shard and a duplicated shard is reported in both lists. Volume 30 has shard 5
|
||||
// missing everywhere (under-replicated) and shard 0 on two nodes (over-replicated).
|
||||
func TestEcCheckReplicationMixedAnomalies(t *testing.T) {
|
||||
nodes := []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Address: "nodeA",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d1": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 30, EcIndexBits: 0b11111111011111}, // shards 0..13 except 5
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Address: "nodeB",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"d2": {
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 30, EcIndexBits: 0b00000000000001}, // duplicate of shard 0
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
want := `Found 1/1 under-replicated EC volumes: [30]
|
||||
Found 1/1 over-replicated EC volumes: [30]
|
||||
`
|
||||
|
||||
var buf bytes.Buffer
|
||||
runner := &ecCheckReplicationRunner{writer: &buf, dataNodes: nodes}
|
||||
if err := runner.checkEcVolumes(false); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := buf.String(); got != want {
|
||||
t.Errorf("mixed anomalies:\n got: %q\nwant: %q", got, want)
|
||||
}
|
||||
}
|
||||
1191
weed/ec/ec_common.go
Normal file
1191
weed/ec/ec_common.go
Normal file
File diff suppressed because it is too large
Load Diff
519
weed/ec/ec_common_test.go
Normal file
519
weed/ec/ec_common_test.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
)
|
||||
|
||||
// TestPickBestDiskOnNodeSelectsPhysicalDiskZero verifies physical disk 0 can be
|
||||
// chosen as the preferred shard target. Gating on bestDiskId != 0 discarded a
|
||||
// best-scoring disk 0 (0 is also the zero value) and returned the fallback.
|
||||
func TestPickBestDiskOnNodeSelectsPhysicalDiskZero(t *testing.T) {
|
||||
ecNode := &EcNode{
|
||||
Disks: map[uint32]*EcDisk{
|
||||
0: {DiskId: 0, DiskType: string(types.SsdType), FreeEcSlots: 10, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
|
||||
5: {DiskId: 5, DiskType: string(types.HardDriveType), FreeEcSlots: 10, EcShards: map[needle.VolumeId]*erasure_coding.ShardsInfo{}},
|
||||
},
|
||||
}
|
||||
|
||||
// Disk 0 matches the requested type; it must win over the non-matching
|
||||
// fallback on disk 5 instead of being treated as "no match".
|
||||
if got := PickBestDiskOnNode(ecNode, needle.VolumeId(42), types.SsdType, false, 0, 0); got != 0 {
|
||||
t.Fatalf("want physical disk 0 (matching type), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceSmall(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack2", "dn2", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceNothingToMove(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack1", "dn2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceAddNewServers(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack1", "dn2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}),
|
||||
newEcNode("dc1", "rack1", "dn3", 100),
|
||||
newEcNode("dc1", "rack1", "dn4", 100),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceAddNewRacks(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack1", "dn2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6}),
|
||||
newEcNode("dc1", "rack2", "dn3", 100),
|
||||
newEcNode("dc1", "rack2", "dn4", 100),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceVolumeEvenButRackUneven(t *testing.T) {
|
||||
ecb := ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn_shared", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0}),
|
||||
|
||||
newEcNode("dc1", "rack1", "dn_a1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{1}),
|
||||
newEcNode("dc1", "rack1", "dn_a2", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{2}),
|
||||
newEcNode("dc1", "rack1", "dn_a3", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{3}),
|
||||
newEcNode("dc1", "rack1", "dn_a4", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{4}),
|
||||
newEcNode("dc1", "rack1", "dn_a5", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{5}),
|
||||
newEcNode("dc1", "rack1", "dn_a6", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{6}),
|
||||
newEcNode("dc1", "rack1", "dn_a7", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{7}),
|
||||
newEcNode("dc1", "rack1", "dn_a8", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{8}),
|
||||
newEcNode("dc1", "rack1", "dn_a9", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{9}),
|
||||
newEcNode("dc1", "rack1", "dn_a10", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{10}),
|
||||
newEcNode("dc1", "rack1", "dn_a11", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{11}),
|
||||
newEcNode("dc1", "rack1", "dn_a12", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{12}),
|
||||
newEcNode("dc1", "rack1", "dn_a13", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{13}),
|
||||
|
||||
newEcNode("dc1", "rack1", "dn_b1", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{1}),
|
||||
newEcNode("dc1", "rack1", "dn_b2", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{2}),
|
||||
newEcNode("dc1", "rack1", "dn_b3", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{3}),
|
||||
newEcNode("dc1", "rack1", "dn_b4", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{4}),
|
||||
newEcNode("dc1", "rack1", "dn_b5", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{5}),
|
||||
newEcNode("dc1", "rack1", "dn_b6", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{6}),
|
||||
newEcNode("dc1", "rack1", "dn_b7", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{7}),
|
||||
newEcNode("dc1", "rack1", "dn_b8", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{8}),
|
||||
newEcNode("dc1", "rack1", "dn_b9", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{9}),
|
||||
newEcNode("dc1", "rack1", "dn_b10", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{10}),
|
||||
newEcNode("dc1", "rack1", "dn_b11", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{11}),
|
||||
newEcNode("dc1", "rack1", "dn_b12", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{12}),
|
||||
newEcNode("dc1", "rack1", "dn_b13", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{13}),
|
||||
|
||||
newEcNode("dc1", "rack1", "dn3", 100),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
}
|
||||
|
||||
func newEcNode(dc string, rack string, dataNodeId string, freeEcSlot int) *EcNode {
|
||||
return &EcNode{
|
||||
Info: &master_pb.DataNodeInfo{
|
||||
Id: dataNodeId,
|
||||
DiskInfos: make(map[string]*master_pb.DiskInfo),
|
||||
},
|
||||
DC: DataCenterId(dc),
|
||||
Rack: RackId(rack),
|
||||
FreeEcSlot: freeEcSlot,
|
||||
}
|
||||
}
|
||||
|
||||
func (ecNode *EcNode) addEcVolumeAndShardsForTest(vid uint32, collection string, shardIds []erasure_coding.ShardId) *EcNode {
|
||||
return ecNode.AddEcVolumeShards(needle.VolumeId(vid), collection, shardIds, types.HardDriveType)
|
||||
}
|
||||
|
||||
// TestCommandEcBalanceEvenDataAndParityDistribution verifies that after balancing:
|
||||
// 1. Data shards (0-9) are evenly distributed across racks (max 2 per rack for 6 racks)
|
||||
// 2. Parity shards (10-13) are evenly distributed across racks (max 1 per rack for 6 racks)
|
||||
func TestCommandEcBalanceEvenDataAndParityDistribution(t *testing.T) {
|
||||
// Setup: All 14 shards start on rack1 (simulating fresh EC encode)
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
// All shards initially on rack1/dn1
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
// Empty nodes on other racks
|
||||
newEcNode("dc1", "rack2", "dn2", 100),
|
||||
newEcNode("dc1", "rack3", "dn3", 100),
|
||||
newEcNode("dc1", "rack4", "dn4", 100),
|
||||
newEcNode("dc1", "rack5", "dn5", 100),
|
||||
newEcNode("dc1", "rack6", "dn6", 100),
|
||||
},
|
||||
applyBalancing: false, // Dry-run mode (simulates moves by updating internal state)
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
|
||||
// After balancing (dry-run), verify the PLANNED distribution by checking what moves were proposed
|
||||
// The ecb.ecNodes state is updated during dry-run to track planned moves
|
||||
vid := needle.VolumeId(1)
|
||||
dataShardCount := erasure_coding.DataShardsCount // 10
|
||||
parityShardCount := erasure_coding.ParityShardsCount // 4
|
||||
|
||||
// Count data and parity shards per rack based on current (updated) state
|
||||
dataPerRack, parityPerRack := countDataAndParityShardsPerRack(ecb.ecNodes, vid, dataShardCount)
|
||||
|
||||
// With 6 racks:
|
||||
// - Data shards (10): max 2 per rack (ceil(10/6) = 2)
|
||||
// - Parity shards (4): max 1 per rack (ceil(4/6) = 1)
|
||||
maxDataPerRack := CeilDivide(dataShardCount, 6) // 2
|
||||
maxParityPerRack := CeilDivide(parityShardCount, 6) // 1
|
||||
|
||||
// Verify no rack has more than max data shards
|
||||
for rackId, count := range dataPerRack {
|
||||
if count > maxDataPerRack {
|
||||
t.Errorf("rack %s has %d data shards, expected max %d", rackId, count, maxDataPerRack)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no rack has more than max parity shards
|
||||
for rackId, count := range parityPerRack {
|
||||
if count > maxParityPerRack {
|
||||
t.Errorf("rack %s has %d parity shards, expected max %d", rackId, count, maxParityPerRack)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all shards are distributed (total counts)
|
||||
totalData := 0
|
||||
totalParity := 0
|
||||
for _, count := range dataPerRack {
|
||||
totalData += count
|
||||
}
|
||||
for _, count := range parityPerRack {
|
||||
totalParity += count
|
||||
}
|
||||
if totalData != dataShardCount {
|
||||
t.Errorf("total data shards = %d, expected %d", totalData, dataShardCount)
|
||||
}
|
||||
if totalParity != parityShardCount {
|
||||
t.Errorf("total parity shards = %d, expected %d", totalParity, parityShardCount)
|
||||
}
|
||||
|
||||
// Verify data shards are spread across at least 5 racks (10 shards / 2 max per rack)
|
||||
racksWithData := len(dataPerRack)
|
||||
minRacksForData := dataShardCount / maxDataPerRack // At least 5 racks needed for 10 data shards
|
||||
if racksWithData < minRacksForData {
|
||||
t.Errorf("data shards spread across only %d racks, expected at least %d", racksWithData, minRacksForData)
|
||||
}
|
||||
|
||||
// Verify parity shards are spread across at least 4 racks (4 shards / 1 max per rack)
|
||||
racksWithParity := len(parityPerRack)
|
||||
if racksWithParity < parityShardCount {
|
||||
t.Errorf("parity shards spread across only %d racks, expected at least %d", racksWithParity, parityShardCount)
|
||||
}
|
||||
|
||||
t.Logf("Distribution after balancing:")
|
||||
t.Logf(" Data shards per rack: %v (max allowed: %d)", dataPerRack, maxDataPerRack)
|
||||
t.Logf(" Parity shards per rack: %v (max allowed: %d)", parityPerRack, maxParityPerRack)
|
||||
}
|
||||
|
||||
// countDataAndParityShardsPerRack counts data and parity shards per rack
|
||||
func countDataAndParityShardsPerRack(ecNodes []*EcNode, vid needle.VolumeId, dataShardCount int) (dataPerRack, parityPerRack map[string]int) {
|
||||
dataPerRack = make(map[string]int)
|
||||
parityPerRack = make(map[string]int)
|
||||
|
||||
for _, ecNode := range ecNodes {
|
||||
si := FindEcVolumeShardsInfo(ecNode, vid, types.HardDriveType)
|
||||
for _, shardId := range si.Ids() {
|
||||
rackId := string(ecNode.Rack)
|
||||
if int(shardId) < dataShardCount {
|
||||
dataPerRack[rackId]++
|
||||
} else {
|
||||
parityPerRack[rackId]++
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// TestCommandEcBalanceMultipleVolumesEvenDistribution tests that multiple volumes
|
||||
// each get their data and parity shards evenly distributed
|
||||
func TestCommandEcBalanceMultipleVolumesEvenDistribution(t *testing.T) {
|
||||
// Setup: Two volumes, each with all 14 shards on different starting racks
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
// Volume 1: all shards on rack1
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
// Volume 2: all shards on rack2
|
||||
newEcNode("dc1", "rack2", "dn2", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
// Empty nodes on other racks
|
||||
newEcNode("dc1", "rack3", "dn3", 100),
|
||||
newEcNode("dc1", "rack4", "dn4", 100),
|
||||
newEcNode("dc1", "rack5", "dn5", 100),
|
||||
newEcNode("dc1", "rack6", "dn6", 100),
|
||||
},
|
||||
applyBalancing: false, // Dry-run mode
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
|
||||
// Check both volumes
|
||||
for _, vid := range []needle.VolumeId{1, 2} {
|
||||
dataPerRack, parityPerRack := countDataAndParityShardsPerRack(ecb.ecNodes, vid, erasure_coding.DataShardsCount)
|
||||
|
||||
maxDataPerRack := CeilDivide(erasure_coding.DataShardsCount, 6)
|
||||
maxParityPerRack := CeilDivide(erasure_coding.ParityShardsCount, 6)
|
||||
|
||||
for rackId, count := range dataPerRack {
|
||||
if count > maxDataPerRack {
|
||||
t.Errorf("volume %d: rack %s has %d data shards, expected max %d", vid, rackId, count, maxDataPerRack)
|
||||
}
|
||||
}
|
||||
for rackId, count := range parityPerRack {
|
||||
if count > maxParityPerRack {
|
||||
t.Errorf("volume %d: rack %s has %d parity shards, expected max %d", vid, rackId, count, maxParityPerRack)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Volume %d - Data: %v, Parity: %v", vid, dataPerRack, parityPerRack)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandEcBalanceAllNodesShareAllVolumes reproduces the scenario from issue #8793:
|
||||
// When every node has a shard of every volume, ec.balance was unable to move any shards
|
||||
// because it skipped volumes that already existed on the target node at the volume level.
|
||||
func TestCommandEcBalanceAllNodesShareAllVolumes(t *testing.T) {
|
||||
// 4 nodes, all in same rack, 2 volumes with 14 shards each.
|
||||
// Distribute shards so every node has shards of both volumes, but unevenly:
|
||||
// dn1: vol1 shards 0-4, vol2 shards 0-4 => 10 shards
|
||||
// dn2: vol1 shards 5-9, vol2 shards 5-9 => 10 shards
|
||||
// dn3: vol1 shards 10-12, vol2 shards 10-12 => 6 shards
|
||||
// dn4: vol1 shard 13, vol2 shard 13 => 2 shards
|
||||
// Total: 28 shards, average = 7 per node
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4}),
|
||||
newEcNode("dc1", "rack1", "dn2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{5, 6, 7, 8, 9}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{5, 6, 7, 8, 9}),
|
||||
newEcNode("dc1", "rack1", "dn3", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{10, 11, 12}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{10, 11, 12}),
|
||||
newEcNode("dc1", "rack1", "dn4", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{13}).
|
||||
addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{13}),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
ecb.balance([]string{"c1"})
|
||||
|
||||
// Count total shards per node after balancing
|
||||
for _, node := range ecb.ecNodes {
|
||||
count := 0
|
||||
if diskInfo, found := node.Info.DiskInfos[string(types.HardDriveType)]; found {
|
||||
for _, ecsi := range diskInfo.EcShardInfos {
|
||||
count += erasure_coding.GetShardCount(ecsi)
|
||||
}
|
||||
}
|
||||
// Average is 7, so all nodes should be at 7 (ceil(28/4) = 7)
|
||||
if count > 7 {
|
||||
t.Errorf("node %s has %d shards after balancing, expected at most 7", node.Info.Id, count)
|
||||
}
|
||||
t.Logf("node %s: %d shards", node.Info.Id, count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandEcBalanceIssue8793Topology simulates the real cluster from issue #8793:
|
||||
// 14 nodes (9 with max=80, 5 with max=33), all in one rack, with mixed capacities.
|
||||
// Each EC volume has 1 shard per node. Nodes have uneven totals (some have extra volumes).
|
||||
func TestCommandEcBalanceIssue8793Topology(t *testing.T) {
|
||||
// Simulate 22 EC volumes across 14 nodes (each volume has 14 shards, 1 per node).
|
||||
// Give nodes 0-3 an extra volume each (vol 23-26, all 14 shards) to create imbalance.
|
||||
// Before balancing: nodes 0-3 have 22+14=36 shards each, nodes 4-13 have 22 shards each.
|
||||
// Total = 4*36 + 10*22 = 144+220 = 364. Capacities are heterogeneous (max 80 vs
|
||||
// 33), so the utilization-based global phase balances by fullness, not count:
|
||||
// 364 shards over 9*80+5*33=885 slots is ~41% full, so large nodes settle near
|
||||
// 33 shards and small nodes near 14 (all ~41% full) rather than ~26 each, which
|
||||
// would drive the small nodes to ~79%.
|
||||
|
||||
type nodeSpec struct {
|
||||
id string
|
||||
maxSlot int
|
||||
}
|
||||
nodes := []nodeSpec{
|
||||
{"192.168.0.12:8332", 80}, {"192.168.0.12:8333", 80}, {"192.168.0.12:8334", 80},
|
||||
{"192.168.0.12:8335", 80}, {"192.168.0.12:8336", 80}, {"192.168.0.12:8337", 80},
|
||||
{"192.168.0.12:8338", 80}, {"192.168.0.12:8339", 80}, {"192.168.0.12:8340", 80},
|
||||
{"192.168.0.12:8341", 33}, {"192.168.0.12:8342", 33}, {"192.168.0.12:8343", 33},
|
||||
{"192.168.0.25:8350", 33}, {"192.168.0.25:8351", 33},
|
||||
}
|
||||
|
||||
ecNodes := make([]*EcNode, len(nodes))
|
||||
for i, ns := range nodes {
|
||||
ecNodes[i] = newEcNode("home", "center", ns.id, ns.maxSlot)
|
||||
}
|
||||
|
||||
// 22 shared volumes: each node gets exactly 1 shard (shard i for node i)
|
||||
for vid := uint32(1); vid <= 22; vid++ {
|
||||
for i := range ecNodes {
|
||||
ecNodes[i].addEcVolumeAndShardsForTest(vid, "cldata", []erasure_coding.ShardId{erasure_coding.ShardId(i)})
|
||||
}
|
||||
}
|
||||
|
||||
// 4 extra volumes only on first 4 nodes (all 14 shards each) to create imbalance
|
||||
for extra := uint32(0); extra < 4; extra++ {
|
||||
vid := 23 + extra
|
||||
nodeIdx := int(extra)
|
||||
allShards := make([]erasure_coding.ShardId, 14)
|
||||
for s := 0; s < 14; s++ {
|
||||
allShards[s] = erasure_coding.ShardId(s)
|
||||
}
|
||||
ecNodes[nodeIdx].addEcVolumeAndShardsForTest(vid, "cldata", allShards)
|
||||
}
|
||||
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: ecNodes,
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
}
|
||||
|
||||
// Log initial state
|
||||
for _, node := range ecb.ecNodes {
|
||||
count := 0
|
||||
if diskInfo, found := node.Info.DiskInfos[string(types.HardDriveType)]; found {
|
||||
for _, ecsi := range diskInfo.EcShardInfos {
|
||||
count += erasure_coding.GetShardCount(ecsi)
|
||||
}
|
||||
}
|
||||
t.Logf("BEFORE node %s (max %d): %d shards", node.Info.Id, node.FreeEcSlot+count, count)
|
||||
}
|
||||
|
||||
ecb.balance([]string{"cldata"})
|
||||
|
||||
// Verify even FULLNESS (shards/capacity), not even count: with heterogeneous
|
||||
// capacities the utilization-based global phase fills nodes proportionally, so
|
||||
// large nodes hold more shards than small ones while every node ends near the
|
||||
// overall fullness. (An even-count result would over-fill the small nodes.)
|
||||
capacityByID := make(map[string]int, len(nodes))
|
||||
for _, ns := range nodes {
|
||||
capacityByID[ns.id] = ns.maxSlot
|
||||
}
|
||||
|
||||
totalShards := 0
|
||||
totalCapacity := 0
|
||||
shardCounts := make(map[string]int)
|
||||
for _, node := range ecb.ecNodes {
|
||||
count := 0
|
||||
if diskInfo, found := node.Info.DiskInfos[string(types.HardDriveType)]; found {
|
||||
for _, ecsi := range diskInfo.EcShardInfos {
|
||||
count += erasure_coding.GetShardCount(ecsi)
|
||||
}
|
||||
}
|
||||
shardCounts[node.Info.Id] = count
|
||||
totalShards += count
|
||||
totalCapacity += capacityByID[node.Info.Id]
|
||||
}
|
||||
overallFullness := float64(totalShards) / float64(totalCapacity)
|
||||
|
||||
// Tolerance well below the gap a count-even result would show (small nodes
|
||||
// would sit ~38 points above overall), but above integer-rounding skew.
|
||||
const tolerance = 0.05
|
||||
for _, node := range ecb.ecNodes {
|
||||
count := shardCounts[node.Info.Id]
|
||||
capacity := capacityByID[node.Info.Id]
|
||||
fullness := float64(count) / float64(capacity)
|
||||
t.Logf("AFTER node %s: %d/%d shards (%.0f%% full, overall %.0f%%)",
|
||||
node.Info.Id, count, capacity, fullness*100, overallFullness*100)
|
||||
if diff := fullness - overallFullness; diff > tolerance || diff < -tolerance {
|
||||
t.Errorf("node %s fullness %.1f%% deviates from overall %.1f%% by more than %.0f points",
|
||||
node.Info.Id, fullness*100, overallFullness*100, tolerance*100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommandEcBalanceVolumeIdsFilter checks that -volumeIds keeps every phase,
|
||||
// dedup included, off the volumes that were not asked for.
|
||||
func TestCommandEcBalanceVolumeIdsFilter(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
// Volume 1: all shards on one node, so balancing has plenty to move.
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
// Volume 2 is equally lopsided, and shard 0 is duplicated on dn3.
|
||||
newEcNode("dc1", "rack2", "dn2", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack3", "dn3", 100).addEcVolumeAndShardsForTest(2, "c1", []erasure_coding.ShardId{0}),
|
||||
newEcNode("dc1", "rack4", "dn4", 100),
|
||||
newEcNode("dc1", "rack5", "dn5", 100),
|
||||
newEcNode("dc1", "rack6", "dn6", 100),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
volumeIds: map[uint32]bool{1: true},
|
||||
}
|
||||
|
||||
if err := ecb.balance([]string{"c1"}); err != nil {
|
||||
t.Fatalf("balance: %v", err)
|
||||
}
|
||||
|
||||
// Volume 1 spreads out.
|
||||
if count := ecb.ecNodes[0].LocalShardIdCount(1); count == 14 {
|
||||
t.Errorf("volume 1 was not balanced: dn1 still holds all %d shards", count)
|
||||
}
|
||||
|
||||
// Volume 2 keeps every shard where it was, duplicate included.
|
||||
if count := ecb.ecNodes[1].LocalShardIdCount(2); count != 14 {
|
||||
t.Errorf("dn2 holds %d shards of the unselected volume 2, want 14", count)
|
||||
}
|
||||
if count := ecb.ecNodes[2].LocalShardIdCount(2); count != 1 {
|
||||
t.Errorf("dn3 holds %d shards of the unselected volume 2, want the duplicate to survive", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandEcBalanceVolumeIdsNotFound(t *testing.T) {
|
||||
ecb := &ecBalancer{
|
||||
ecNodes: []*EcNode{
|
||||
newEcNode("dc1", "rack1", "dn1", 100).addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}),
|
||||
newEcNode("dc1", "rack2", "dn2", 100),
|
||||
},
|
||||
applyBalancing: false,
|
||||
diskType: types.HardDriveType,
|
||||
volumeIds: map[uint32]bool{1: true, 99: true},
|
||||
}
|
||||
|
||||
err := ecb.balance([]string{"c1"})
|
||||
if err == nil || !strings.Contains(err.Error(), "[99]") {
|
||||
t.Fatalf("want an error naming volume 99, got %v", err)
|
||||
}
|
||||
// The valid id must not be balanced either: the plan is all-or-nothing.
|
||||
if count := ecb.ecNodes[0].LocalShardIdCount(1); count != 14 {
|
||||
t.Errorf("dn1 holds %d shards, want the rejected plan to leave volume 1 untouched", count)
|
||||
}
|
||||
}
|
||||
463
weed/ec/ec_decode.go
Normal file
463
weed/ec/ec_decode.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func DoEcDecode(env *Env, topoInfo *master_pb.TopologyInfo, collection string, vid needle.VolumeId, diskType types.DiskType, checkMinFreeSpace bool, diskUsageState *DecodeDiskUsageState) (err error) {
|
||||
|
||||
if !env.isLocked() {
|
||||
return fmt.Errorf("lock is lost")
|
||||
}
|
||||
|
||||
// find volume location
|
||||
nodeToEcShardsInfo, dataShards := collectEcNodeShardsInfo(topoInfo, vid)
|
||||
|
||||
fmt.Printf("ec volume %d shard locations: %+v\n", vid, nodeToEcShardsInfo)
|
||||
|
||||
if len(nodeToEcShardsInfo) == 0 {
|
||||
return fmt.Errorf("no EC shards found for volume %d", vid)
|
||||
}
|
||||
|
||||
// A decode interrupted while deleting the shards leaves the regenerated
|
||||
// volume in place with the set half gone, so a re-run finds both and fails
|
||||
// re-collecting the first shard the interrupted run removed.
|
||||
//
|
||||
// Finding a volume beside the shards is not enough to act on: an encode
|
||||
// interrupted before it deleted the original leaves the same shape, as does
|
||||
// a decode killed while generating, whose volume may be half written. Both
|
||||
// of those leave the shard set COMPLETE. Only the deletion phase can remove
|
||||
// a data shard, so require one to be gone -- that is also exactly the state
|
||||
// no decode can recover from, which makes finishing the cleanup the only
|
||||
// move left rather than a choice between two.
|
||||
if _, found := missingDataShard(nodeToEcShardsInfo, dataShards); found {
|
||||
holder, hasVolume := regularVolumeHolder(topoInfo, vid)
|
||||
if !hasVolume {
|
||||
return fmt.Errorf("volume %d cannot be decoded: data shards are missing and no decoded volume exists to finish", vid)
|
||||
}
|
||||
if err := verifyDecodedVolumeBeforeDelete(env.GrpcDialOption, holder, vid); err != nil {
|
||||
return fmt.Errorf("volume %d is already decoded on %s but did not verify, keeping its ec shards: %v", vid, holder, err)
|
||||
}
|
||||
fmt.Printf("volume %d is already decoded on %s; deleting the ec shards the interrupted run left behind\n", vid, holder)
|
||||
return unmountAndDeleteEcShardsWithPrefix("deleteDecodedEcShards", env.GrpcDialOption, collection, nodeToEcShardsInfo, vid)
|
||||
}
|
||||
|
||||
var originalShardCounts map[pb.ServerAddress]int
|
||||
if diskUsageState != nil {
|
||||
originalShardCounts = make(map[pb.ServerAddress]int, len(nodeToEcShardsInfo))
|
||||
for location, si := range nodeToEcShardsInfo {
|
||||
originalShardCounts[location] = si.Count()
|
||||
}
|
||||
}
|
||||
|
||||
var eligibleTargets map[pb.ServerAddress]struct{}
|
||||
if checkMinFreeSpace {
|
||||
if diskUsageState == nil {
|
||||
return fmt.Errorf("min free space checking requires disk usage state")
|
||||
}
|
||||
eligibleTargets = make(map[pb.ServerAddress]struct{})
|
||||
for location := range nodeToEcShardsInfo {
|
||||
if freeCount, found := diskUsageState.freeVolumeCount(location); found && freeCount > 0 {
|
||||
eligibleTargets[location] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(eligibleTargets) == 0 {
|
||||
return fmt.Errorf("no eligible target datanodes with free volume slots for volume %d (diskType %s); use -checkMinFreeSpace=false to override", vid, diskType.ReadableString())
|
||||
}
|
||||
}
|
||||
|
||||
// collect ec shards to the server with most space
|
||||
targetNodeLocation, err := collectEcShards(env, nodeToEcShardsInfo, collection, vid, eligibleTargets, dataShards)
|
||||
if err != nil {
|
||||
return fmt.Errorf("collectEcShards for volume %d: %v", vid, err)
|
||||
}
|
||||
|
||||
// generate a normal volume
|
||||
err = generateNormalVolume(env.GrpcDialOption, vid, collection, targetNodeLocation)
|
||||
if err != nil {
|
||||
// Special case: if the EC index has no live entries, decoding is a no-op.
|
||||
// Just purge EC shards and return success without generating/mounting an empty volume.
|
||||
if isEcDecodeEmptyVolumeErr(err) {
|
||||
if err := unmountAndDeleteEcShards(env.GrpcDialOption, collection, nodeToEcShardsInfo, vid); err != nil {
|
||||
return err
|
||||
}
|
||||
if diskUsageState != nil {
|
||||
diskUsageState.applyDecode(targetNodeLocation, originalShardCounts, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("generate normal volume %d on %s: %v", vid, targetNodeLocation, err)
|
||||
}
|
||||
|
||||
// mount the decoded volume after server-side offline compaction succeeded
|
||||
err = mountDecodedVolume(env.GrpcDialOption, targetNodeLocation, vid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mount decoded volume %d on %s: %v", vid, targetNodeLocation, err)
|
||||
}
|
||||
|
||||
// Confirm the regenerated .dat is present and non-empty before destroying
|
||||
// the shards. Without this gate, a silent failure in generate/mount could
|
||||
// leave the cluster with neither shards nor volume.
|
||||
if err := verifyDecodedVolumeBeforeDelete(env.GrpcDialOption, targetNodeLocation, vid); err != nil {
|
||||
return fmt.Errorf("verify decoded volume %d on %s before deleting shards: %w", vid, targetNodeLocation, err)
|
||||
}
|
||||
|
||||
// delete the previous ec shards
|
||||
err = unmountAndDeleteEcShardsWithPrefix("deleteDecodedEcShards", env.GrpcDialOption, collection, nodeToEcShardsInfo, vid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete ec shards for volume %d: %v", vid, err)
|
||||
}
|
||||
if diskUsageState != nil {
|
||||
diskUsageState.applyDecode(targetNodeLocation, originalShardCounts, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isEcDecodeEmptyVolumeErr(err error) bool {
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if st.Code() != codes.FailedPrecondition {
|
||||
return false
|
||||
}
|
||||
// Keep this robust against wording tweaks while still being specific.
|
||||
return strings.Contains(st.Message(), erasure_coding.EcNoLiveEntriesSubstring)
|
||||
}
|
||||
|
||||
func unmountAndDeleteEcShards(grpcDialOption grpc.DialOption, collection string, nodeToShardsInfo map[pb.ServerAddress]*erasure_coding.ShardsInfo, vid needle.VolumeId) error {
|
||||
return unmountAndDeleteEcShardsWithPrefix("unmountAndDeleteEcShards", grpcDialOption, collection, nodeToShardsInfo, vid)
|
||||
}
|
||||
|
||||
func unmountAndDeleteEcShardsWithPrefix(prefix string, grpcDialOption grpc.DialOption, collection string, nodeToShardsInfo map[pb.ServerAddress]*erasure_coding.ShardsInfo, vid needle.VolumeId) error {
|
||||
ewg := util.NewErrorWaitGroup(len(nodeToShardsInfo))
|
||||
|
||||
// unmount and delete ec shards in parallel (one goroutine per location)
|
||||
for location, si := range nodeToShardsInfo {
|
||||
location, si := location, si // capture loop variables for goroutine
|
||||
ewg.Add(func() error {
|
||||
fmt.Printf("unmount ec volume %d on %s has shards: %+v\n", vid, location, si.Ids())
|
||||
if err := UnmountEcShards(grpcDialOption, vid, location, si.Ids()); err != nil {
|
||||
return fmt.Errorf("%s unmount ec volume %d on %s: %w", prefix, vid, location, err)
|
||||
}
|
||||
|
||||
fmt.Printf("delete ec volume %d on %s has shards: %+v\n", vid, location, si.Ids())
|
||||
if err := SourceServerDeleteEcShards(grpcDialOption, collection, vid, location, si.Ids()); err != nil {
|
||||
return fmt.Errorf("%s delete ec volume %d on %s: %w", prefix, vid, location, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return ewg.Wait()
|
||||
}
|
||||
|
||||
// missingDataShard reports the first data shard absent from every holder.
|
||||
// Parity shards are not enough to answer this: the decode rebuilds the volume
|
||||
// from the data shards, so one of those going missing is what makes a re-run
|
||||
// impossible.
|
||||
func missingDataShard(nodeToShardsInfo map[pb.ServerAddress]*erasure_coding.ShardsInfo, dataShards int) (erasure_coding.ShardId, bool) {
|
||||
var present erasure_coding.ShardBits
|
||||
for _, si := range nodeToShardsInfo {
|
||||
for _, id := range si.Ids() {
|
||||
present = present.Set(id)
|
||||
}
|
||||
}
|
||||
for id := 0; id < dataShards; id++ {
|
||||
if !present.Has(erasure_coding.ShardId(id)) {
|
||||
return erasure_coding.ShardId(id), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// regularVolumeHolder returns a server already serving vid as a regular
|
||||
// volume. A decode only finds one when an earlier run was interrupted between
|
||||
// regenerating the volume and deleting the shards it came from.
|
||||
func regularVolumeHolder(topoInfo *master_pb.TopologyInfo, vid needle.VolumeId) (pb.ServerAddress, bool) {
|
||||
var holder pb.ServerAddress
|
||||
found := false
|
||||
EachDataNode(topoInfo, func(dc DataCenterId, rack RackId, dn *master_pb.DataNodeInfo) {
|
||||
if found {
|
||||
return
|
||||
}
|
||||
for _, diskInfo := range dn.DiskInfos {
|
||||
for _, vi := range diskInfo.VolumeInfos {
|
||||
if needle.VolumeId(vi.Id) == vid {
|
||||
holder = pb.NewServerAddressFromDataNode(dn)
|
||||
found = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return holder, found
|
||||
}
|
||||
|
||||
func verifyDecodedVolumeBeforeDelete(grpcDialOption grpc.DialOption, target pb.ServerAddress, vid needle.VolumeId) error {
|
||||
var resp *volume_server_pb.ReadVolumeFileStatusResponse
|
||||
if err := operation.WithVolumeServerClient(false, target, grpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
|
||||
r, e := client.ReadVolumeFileStatus(context.Background(), &volume_server_pb.ReadVolumeFileStatusRequest{
|
||||
VolumeId: uint32(vid),
|
||||
})
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
resp = r
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("read volume file status: %w", err)
|
||||
}
|
||||
if resp.DatFileSize == 0 {
|
||||
return fmt.Errorf("decoded .dat is 0 bytes")
|
||||
}
|
||||
if resp.IdxFileSize == 0 {
|
||||
return fmt.Errorf("decoded .idx is 0 bytes")
|
||||
}
|
||||
glog.V(0).Infof("ec decode verification ok for volume %d on %s: dat=%d idx=%d", vid, target, resp.DatFileSize, resp.IdxFileSize)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mountDecodedVolume(grpcDialOption grpc.DialOption, targetNodeLocation pb.ServerAddress, vid needle.VolumeId) error {
|
||||
return operation.WithVolumeServerClient(false, targetNodeLocation, grpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
_, mountErr := volumeServerClient.VolumeMount(context.Background(), &volume_server_pb.VolumeMountRequest{
|
||||
VolumeId: uint32(vid),
|
||||
})
|
||||
return mountErr
|
||||
})
|
||||
}
|
||||
|
||||
func generateNormalVolume(grpcDialOption grpc.DialOption, vid needle.VolumeId, collection string, sourceVolumeServer pb.ServerAddress) error {
|
||||
fmt.Printf("generateNormalVolume from ec volume %d on %s\n", vid, sourceVolumeServer)
|
||||
|
||||
err := operation.WithVolumeServerClient(false, sourceVolumeServer, grpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
_, genErr := volumeServerClient.VolumeEcShardsToVolume(context.Background(), &volume_server_pb.VolumeEcShardsToVolumeRequest{
|
||||
VolumeId: uint32(vid),
|
||||
Collection: collection,
|
||||
})
|
||||
return genErr
|
||||
})
|
||||
|
||||
return err
|
||||
|
||||
}
|
||||
|
||||
func collectEcShards(env *Env, nodeToShardsInfo map[pb.ServerAddress]*erasure_coding.ShardsInfo, collection string, vid needle.VolumeId, eligibleTargets map[pb.ServerAddress]struct{}, dataShards int) (targetNodeLocation pb.ServerAddress, err error) {
|
||||
|
||||
maxShardCount := -1
|
||||
existingShardsInfo := erasure_coding.NewShardsInfo()
|
||||
for loc, si := range nodeToShardsInfo {
|
||||
if eligibleTargets != nil {
|
||||
if _, ok := eligibleTargets[loc]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
toBeCopiedShardCount := si.MinusParityShards(dataShards).Count()
|
||||
if toBeCopiedShardCount > maxShardCount {
|
||||
maxShardCount = toBeCopiedShardCount
|
||||
targetNodeLocation = loc
|
||||
existingShardsInfo = si
|
||||
}
|
||||
}
|
||||
if targetNodeLocation == "" {
|
||||
return "", fmt.Errorf("no eligible target datanodes available to decode volume %d", vid)
|
||||
}
|
||||
|
||||
// Trust only shards the target can actually serve: an interrupted earlier
|
||||
// decode or balance can leave the master believing the target holds a
|
||||
// shard whose file never landed. A phantom entry here would exclude the
|
||||
// shard from the copy set and the decode would then fail with "missing
|
||||
// shard"; probing the target's live inventory makes the re-run re-copy it.
|
||||
if present, probeErr := erasure_coding.CollectShardsOnServer(context.Background(), collection, uint32(vid), string(targetNodeLocation), env.GrpcDialOption); probeErr == nil {
|
||||
confirmed := erasure_coding.NewShardsInfo()
|
||||
for _, sid := range existingShardsInfo.Ids() {
|
||||
if present.Has(sid) {
|
||||
confirmed.Set(erasure_coding.NewShardInfo(sid, 0))
|
||||
}
|
||||
}
|
||||
existingShardsInfo = confirmed
|
||||
} else {
|
||||
fmt.Printf("collectEcShards: probe %s inventory for volume %d: %v (keeping the topology's view)\n", targetNodeLocation, vid, probeErr)
|
||||
}
|
||||
|
||||
fmt.Printf("collectEcShards: ec volume %d collect shards to %s from: %+v\n", vid, targetNodeLocation, nodeToShardsInfo)
|
||||
|
||||
copiedShardsInfo := erasure_coding.NewShardsInfo()
|
||||
for loc, si := range nodeToShardsInfo {
|
||||
if loc == targetNodeLocation {
|
||||
continue
|
||||
}
|
||||
|
||||
needToCopyShardsInfo := si.Minus(existingShardsInfo).MinusParityShards(dataShards)
|
||||
|
||||
err = operation.WithVolumeServerClient(false, targetNodeLocation, env.GrpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
|
||||
// Always collect .ecj from every shard location. Each server's .ecj
|
||||
// only contains deletions for needles whose data resides in shards
|
||||
// held by that server. Without merging all .ecj files, deletions
|
||||
// recorded on other servers would be lost during decode.
|
||||
if needToCopyShardsInfo.Count() > 0 {
|
||||
fmt.Printf("copy %d.%v %s => %s\n", vid, needToCopyShardsInfo.Ids(), loc, targetNodeLocation)
|
||||
} else {
|
||||
fmt.Printf("collect ecj %d %s => %s\n", vid, loc, targetNodeLocation)
|
||||
}
|
||||
|
||||
_, copyErr := volumeServerClient.VolumeEcShardsCopy(context.Background(), &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: uint32(vid),
|
||||
Collection: collection,
|
||||
ShardIds: needToCopyShardsInfo.IdsUint32(),
|
||||
CopyEcxFile: false,
|
||||
CopyEcjFile: true,
|
||||
CopyVifFile: needToCopyShardsInfo.Count() > 0,
|
||||
SourceDataNode: string(loc),
|
||||
})
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("copy %d.%v %s => %s: %v", vid, needToCopyShardsInfo.Ids(), loc, targetNodeLocation, copyErr)
|
||||
}
|
||||
|
||||
if needToCopyShardsInfo.Count() > 0 {
|
||||
fmt.Printf("mount %d.%v on %s\n", vid, needToCopyShardsInfo.Ids(), targetNodeLocation)
|
||||
_, mountErr := volumeServerClient.VolumeEcShardsMount(context.Background(), &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: uint32(vid),
|
||||
Collection: collection,
|
||||
ShardIds: needToCopyShardsInfo.IdsUint32(),
|
||||
})
|
||||
if mountErr != nil {
|
||||
return fmt.Errorf("mount %d.%v on %s: %v", vid, needToCopyShardsInfo.Ids(), targetNodeLocation, mountErr)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
copiedShardsInfo.Add(needToCopyShardsInfo)
|
||||
}
|
||||
|
||||
nodeToShardsInfo[targetNodeLocation] = existingShardsInfo.Plus(copiedShardsInfo)
|
||||
|
||||
return targetNodeLocation, err
|
||||
}
|
||||
func CollectEcShardIds(topoInfo *master_pb.TopologyInfo, collectionMatcher *wildcard.CollectionMatcher, diskType types.DiskType) (vids []needle.VolumeId) {
|
||||
vidMap := make(map[uint32]bool)
|
||||
EachDataNode(topoInfo, func(dc DataCenterId, rack RackId, dn *master_pb.DataNodeInfo) {
|
||||
if diskInfo, found := dn.DiskInfos[string(diskType)]; found {
|
||||
for _, v := range diskInfo.EcShardInfos {
|
||||
if collectionMatcher.Matches(v.Collection) {
|
||||
vidMap[v.Id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for vid := range vidMap {
|
||||
vids = append(vids, needle.VolumeId(vid))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func collectEcNodeShardsInfo(topoInfo *master_pb.TopologyInfo, vid needle.VolumeId) (map[pb.ServerAddress]*erasure_coding.ShardsInfo, int) {
|
||||
res := make(map[pb.ServerAddress]*erasure_coding.ShardsInfo)
|
||||
EachDataNode(topoInfo, func(dc DataCenterId, rack RackId, dn *master_pb.DataNodeInfo) {
|
||||
// Union across ALL disk-type buckets and, within a node, across its
|
||||
// physical disks. Shards sit wherever encode generation and balance
|
||||
// left them — a cross-tier encode leaves them in the source bucket, a
|
||||
// partial migration straddles buckets — and a decode that only looks
|
||||
// at one bucket reports a decodable volume as having no shards at all.
|
||||
// (Same rationale as the encode's shard verification.)
|
||||
for _, diskInfo := range dn.DiskInfos {
|
||||
if diskInfo == nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range diskInfo.EcShardInfos {
|
||||
if v.Id == uint32(vid) {
|
||||
addr := pb.NewServerAddressFromDataNode(dn)
|
||||
si := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(v)
|
||||
if existing, ok := res[addr]; ok {
|
||||
existing.Add(si)
|
||||
} else {
|
||||
res[addr] = si
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// OSS is always 10+4; the per-volume ratio override lives in the enterprise build.
|
||||
return res, erasure_coding.DataShardsCount
|
||||
}
|
||||
|
||||
type DecodeDiskUsageState struct {
|
||||
byNode map[pb.ServerAddress]*decodeDiskUsageCounts
|
||||
}
|
||||
|
||||
type decodeDiskUsageCounts struct {
|
||||
maxVolumeCount int64
|
||||
volumeCount int64
|
||||
remoteVolumeCount int64
|
||||
ecShardCount int64
|
||||
}
|
||||
|
||||
func NewDecodeDiskUsageState(topoInfo *master_pb.TopologyInfo, diskType types.DiskType) *DecodeDiskUsageState {
|
||||
state := &DecodeDiskUsageState{byNode: make(map[pb.ServerAddress]*decodeDiskUsageCounts)}
|
||||
EachDataNode(topoInfo, func(dc DataCenterId, rack RackId, dn *master_pb.DataNodeInfo) {
|
||||
if diskInfo, found := dn.DiskInfos[string(diskType)]; found {
|
||||
state.byNode[pb.NewServerAddressFromDataNode(dn)] = &decodeDiskUsageCounts{
|
||||
maxVolumeCount: diskInfo.MaxVolumeCount,
|
||||
volumeCount: diskInfo.VolumeCount,
|
||||
remoteVolumeCount: diskInfo.RemoteVolumeCount,
|
||||
ecShardCount: int64(CountShards(diskInfo.EcShardInfos)),
|
||||
}
|
||||
}
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *DecodeDiskUsageState) freeVolumeCount(location pb.ServerAddress) (int64, bool) {
|
||||
if state == nil {
|
||||
return 0, false
|
||||
}
|
||||
usage, found := state.byNode[location]
|
||||
if !found {
|
||||
return 0, false
|
||||
}
|
||||
free := usage.maxVolumeCount - (usage.volumeCount - usage.remoteVolumeCount)
|
||||
free -= (usage.ecShardCount + int64(erasure_coding.DataShardsCount) - 1) / int64(erasure_coding.DataShardsCount)
|
||||
return free, true
|
||||
}
|
||||
|
||||
func (state *DecodeDiskUsageState) applyDecode(targetNodeLocation pb.ServerAddress, shardCounts map[pb.ServerAddress]int, createdVolume bool) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
for location, shardCount := range shardCounts {
|
||||
if usage, found := state.byNode[location]; found {
|
||||
usage.ecShardCount -= int64(shardCount)
|
||||
}
|
||||
}
|
||||
if createdVolume {
|
||||
if usage, found := state.byNode[targetNodeLocation]; found {
|
||||
usage.volumeCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
91
weed/ec/ec_decode_test.go
Normal file
91
weed/ec/ec_decode_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
)
|
||||
|
||||
// shardsOn builds one holder's inventory.
|
||||
func shardsOn(ids ...int) *erasure_coding.ShardsInfo {
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for _, id := range ids {
|
||||
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(id), 1024))
|
||||
}
|
||||
return si
|
||||
}
|
||||
|
||||
// missingDataShard decides whether a decode may delete the shards of a volume
|
||||
// that already exists elsewhere. Only the deletion phase of a decode removes a
|
||||
// data shard, so the answer separates "an earlier decode was interrupted while
|
||||
// cleaning up" from the two states that look the same from the outside: an
|
||||
// encode interrupted before it deleted the original, and a decode killed while
|
||||
// generating. Both of those leave every shard in place, and the volume beside
|
||||
// them may be the untouched original or a half-written one -- neither is safe
|
||||
// to trade for the shards.
|
||||
func TestMissingDataShard(t *testing.T) {
|
||||
const dataShards = 10
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
holders map[pb.ServerAddress]*erasure_coding.ShardsInfo
|
||||
wantID erasure_coding.ShardId
|
||||
wantMissed bool
|
||||
}{
|
||||
{
|
||||
name: "complete set on one holder decodes, so nothing is finished",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": shardsOn(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "complete set spread across holders is still complete",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": shardsOn(0, 1, 2, 3, 4),
|
||||
"server2:8080": shardsOn(5, 6, 7, 8, 9),
|
||||
"server3:8080": shardsOn(10, 11, 12, 13),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parity gone is not the deletion phase: the volume still decodes",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": shardsOn(0, 1, 2, 3, 4, 5, 6, 7, 8, 9),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a data shard gone is a decode that was interrupted mid-cleanup",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": shardsOn(0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13),
|
||||
},
|
||||
wantID: 6,
|
||||
wantMissed: true,
|
||||
},
|
||||
{
|
||||
name: "the first gap is reported, not the last",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": shardsOn(0, 3, 4, 5, 6, 7, 8, 9),
|
||||
},
|
||||
wantID: 1,
|
||||
wantMissed: true,
|
||||
},
|
||||
{
|
||||
name: "no holders at all leaves every data shard missing",
|
||||
holders: map[pb.ServerAddress]*erasure_coding.ShardsInfo{},
|
||||
wantID: 0,
|
||||
wantMissed: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id, missed := missingDataShard(tt.holders, dataShards)
|
||||
if missed != tt.wantMissed {
|
||||
t.Fatalf("missingDataShard = %v, want %v", missed, tt.wantMissed)
|
||||
}
|
||||
if missed && id != tt.wantID {
|
||||
t.Errorf("missing shard id = %d, want %d", id, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
231
weed/ec/ec_destructive_guards_test.go
Normal file
231
weed/ec/ec_destructive_guards_test.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// topoWith builds a one-node topology snapshot where regularVids appear as
|
||||
// regular volumes (.dat) and ecVids appear as EC shards.
|
||||
func topoWith(regularVids, ecVids []uint32) *master_pb.TopologyInfo {
|
||||
di := &master_pb.DiskInfo{Type: "hdd"}
|
||||
for _, v := range regularVids {
|
||||
di.VolumeInfos = append(di.VolumeInfos, &master_pb.VolumeInformationMessage{Id: v})
|
||||
}
|
||||
for _, v := range ecVids {
|
||||
di.EcShardInfos = append(di.EcShardInfos, &master_pb.VolumeEcShardInformationMessage{Id: v})
|
||||
}
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
||||
Id: "n1",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"hdd": di},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertEncodableRegularVolumes: ec.encode must refuse an already-EC volume
|
||||
// (no .dat — re-encoding would destroy its shards) and an unknown id, while
|
||||
// allowing a regular volume and a failed-encode retry (regular .dat + orphans).
|
||||
func TestAssertEncodableRegularVolumes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
regular, ec []uint32
|
||||
vids []needle.VolumeId
|
||||
wantErrSubstr string
|
||||
}{
|
||||
{"regular volume", []uint32{1}, nil, []needle.VolumeId{1}, ""},
|
||||
{"already EC", nil, []uint32{1}, []needle.VolumeId{1}, "already EC"},
|
||||
{"regular plus stale orphan shards", []uint32{1}, []uint32{1}, []needle.VolumeId{1}, ""},
|
||||
{"unknown id", nil, nil, []needle.VolumeId{1}, "not found"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := AssertEncodableRegularVolumes(topoWith(tc.regular, tc.ec), tc.vids)
|
||||
if tc.wantErrSubstr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("want nil error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErrSubstr) {
|
||||
t.Fatalf("want error containing %q, got %v", tc.wantErrSubstr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newDryRunRebuilder(log *bytes.Buffer, nodes ...*EcNode) *ecRebuilder {
|
||||
return &ecRebuilder{
|
||||
env: &Env{},
|
||||
ecNodes: nodes,
|
||||
writer: log,
|
||||
applyChanges: false,
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataToRecover_DryRunRecoverableNoSideEffects: a dry-run on a
|
||||
// recoverable degraded volume the rebuilder holds none of must still succeed
|
||||
// and report its plan, while recording zero shards to delete (so the deferred
|
||||
// cleanup issues no VolumeEcShardsDelete RPC).
|
||||
func TestPrepareDataToRecover_DryRunRecoverableNoSideEffects(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
rebuilder := newEcNode("dc1", "rack1", "rebuilder", 100)
|
||||
remote := newEcNode("dc1", "rack1", "remote", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11})
|
||||
erb := newDryRunRebuilder(&log, rebuilder, remote)
|
||||
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for i := 0; i < 12; i++ {
|
||||
locations[i] = []*EcNode{remote}
|
||||
}
|
||||
|
||||
copied, local, err := erb.prepareDataToRecover(rebuilder, "c1", needle.VolumeId(1), locations)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run on a recoverable volume must not error: %v", err)
|
||||
}
|
||||
if len(copied) != 0 {
|
||||
t.Errorf("dry-run must record no copied shards (deletion set), got %v", copied)
|
||||
}
|
||||
if len(local) != 0 {
|
||||
t.Errorf("rebuilder holds no shards locally, got %v", local)
|
||||
}
|
||||
if !strings.Contains(log.String(), "would copy") {
|
||||
t.Errorf("dry-run should report the would-copy plan, got:\n%s", log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataToRecover_UnionsLocalShardsAcrossDisks: shards the rebuilder
|
||||
// holds on more than one disk must all count as local (not copied), so the
|
||||
// per-disk overwrite that made one disk's shards look remote (then self-copied
|
||||
// with O_TRUNC and node-wide deleted) cannot happen.
|
||||
func TestPrepareDataToRecover_UnionsLocalShardsAcrossDisks(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
rebuilder := newEcNode("dc1", "rack1", "rebuilder", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1}).
|
||||
AddEcVolumeShards(needle.VolumeId(1), "c1", []erasure_coding.ShardId{5}, types.SsdType)
|
||||
remote := newEcNode("dc1", "rack1", "remote", 100)
|
||||
erb := newDryRunRebuilder(&log, rebuilder)
|
||||
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for i := 0; i < 12; i++ {
|
||||
locations[i] = []*EcNode{remote}
|
||||
}
|
||||
locations[0] = []*EcNode{rebuilder}
|
||||
locations[1] = []*EcNode{rebuilder}
|
||||
locations[5] = []*EcNode{rebuilder}
|
||||
|
||||
copied, local, err := erb.prepareDataToRecover(rebuilder, "c1", needle.VolumeId(1), locations)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
missing := map[erasure_coding.ShardId]bool{0: true, 1: true, 5: true}
|
||||
for _, s := range local {
|
||||
delete(missing, s)
|
||||
}
|
||||
if len(missing) != 0 {
|
||||
t.Errorf("shards %v should be local (union across disks); local=%v", missing, local)
|
||||
}
|
||||
for _, s := range copied {
|
||||
if s == 0 || s == 1 || s == 5 {
|
||||
t.Errorf("local shard %d must never be in the copy/delete set", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountLocalShards_UnionsAcrossDisks: slot accounting must union shards
|
||||
// across the node's disks like prepareDataToRecover does, or slotsNeeded is
|
||||
// overstated and selectAndReserveRebuilder can reject a valid node.
|
||||
func TestCountLocalShards_UnionsAcrossDisks(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
rebuilder := newEcNode("dc1", "rack1", "rebuilder", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1}).
|
||||
AddEcVolumeShards(needle.VolumeId(1), "c1", []erasure_coding.ShardId{5}, types.SsdType)
|
||||
erb := newDryRunRebuilder(&log, rebuilder)
|
||||
|
||||
if got := erb.countLocalShards(rebuilder, "c1", needle.VolumeId(1)); got != 3 {
|
||||
t.Errorf("countLocalShards must union across disks: want 3, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataToRecover_SelfSourceNotCopied: if the rebuilder is the only
|
||||
// listed holder of a shard it does not report locally, it must be treated as
|
||||
// local rather than copied onto itself and then deleted.
|
||||
func TestPrepareDataToRecover_SelfSourceNotCopied(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
rebuilder := newEcNode("dc1", "rack1", "rebuilder", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 6, 7, 8, 9, 10})
|
||||
erb := newDryRunRebuilder(&log, rebuilder)
|
||||
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for _, s := range []int{0, 1, 2, 3, 4, 6, 7, 8, 9, 10} {
|
||||
locations[s] = []*EcNode{rebuilder}
|
||||
}
|
||||
locations[5] = []*EcNode{rebuilder} // sole holder is the rebuilder, not in its reported shards
|
||||
|
||||
copied, local, err := erb.prepareDataToRecover(rebuilder, "c1", needle.VolumeId(1), locations)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, s := range copied {
|
||||
if s == 5 {
|
||||
t.Errorf("self-sourced shard 5 must not be copied/deleted")
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, s := range local {
|
||||
if s == 5 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("self-sourced shard 5 should be classified local, got local=%v", local)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDataToRecover_ApplyCopyFailureDoesNotCountAsRecoverable(t *testing.T) {
|
||||
var log bytes.Buffer
|
||||
rebuilder := newEcNode("dc1", "rack1", "127.0.0.1:1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8})
|
||||
remote := newEcNode("dc1", "rack1", "127.0.0.1:2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{9})
|
||||
erb := &ecRebuilder{
|
||||
env: &Env{
|
||||
GrpcDialOption: grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
},
|
||||
ecNodes: []*EcNode{rebuilder, remote},
|
||||
writer: &log,
|
||||
applyChanges: true,
|
||||
}
|
||||
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for i := 0; i < 9; i++ {
|
||||
locations[i] = []*EcNode{rebuilder}
|
||||
}
|
||||
locations[9] = []*EcNode{remote}
|
||||
|
||||
copied, local, err := erb.prepareDataToRecover(rebuilder, "c1", needle.VolumeId(1), locations)
|
||||
if err == nil {
|
||||
t.Fatalf("copy failure must leave only 9 usable shards and fail recoverability gate; copied=%v local=%v log:\n%s", copied, local, log.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "9 shards are not enough") {
|
||||
t.Fatalf("expected recoverability error to count only successful/local shards, got %v; log:\n%s", err, log.String())
|
||||
}
|
||||
if len(copied) != 0 {
|
||||
t.Fatalf("failed copy must not be recorded as copied/deletable, got %v", copied)
|
||||
}
|
||||
if !strings.Contains(log.String(), "failed to copy 1.9") {
|
||||
t.Fatalf("expected failed copy to be logged, got:\n%s", log.String())
|
||||
}
|
||||
}
|
||||
1073
weed/ec/ec_encode.go
Normal file
1073
weed/ec/ec_encode.go
Normal file
File diff suppressed because it is too large
Load Diff
214
weed/ec/ec_encode_shard_sizes_test.go
Normal file
214
weed/ec/ec_encode_shard_sizes_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
)
|
||||
|
||||
// sizedShards builds one holder's inventory as {shard id: size}.
|
||||
func sizedShards(idToSize map[int]int64) *erasure_coding.ShardsInfo {
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for id, size := range idToSize {
|
||||
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(id), erasure_coding.ShardSize(size)))
|
||||
}
|
||||
return si
|
||||
}
|
||||
|
||||
// An encode deletes the volume the shards were made from, on the strength of
|
||||
// having counted them. Every shard takes one piece of each block row, so they
|
||||
// are written to the same length: one that disagrees was truncated or half
|
||||
// copied, and counting cannot see it.
|
||||
func TestRequireUniformShardSizes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
byNode map[pb.ServerAddress]*erasure_coding.ShardsInfo
|
||||
wantErr bool
|
||||
errContains []string
|
||||
}{
|
||||
{
|
||||
name: "one holder, all shards the same length",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{0: 1048576, 1: 1048576, 2: 1048576}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "spread across holders, still one length",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{0: 1048576, 1: 1048576}),
|
||||
"server2:8080": sizedShards(map[int]int64{2: 1048576, 3: 1048576}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a truncated shard is named with its holder",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{0: 1048576, 1: 1048576}),
|
||||
"server2:8080": sizedShards(map[int]int64{2: 4096}),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: []string{"disagree on size", "server2:8080.2", "4096"},
|
||||
},
|
||||
{
|
||||
name: "two copies of one shard that disagree are still a disagreement",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{5: 1048576}),
|
||||
"server2:8080": sizedShards(map[int]int64{5: 1048570}),
|
||||
},
|
||||
wantErr: true,
|
||||
errContains: []string{"disagree on size"},
|
||||
},
|
||||
|
||||
// Sizes the cluster does not report must never block an encode: an
|
||||
// older volume server, or one that has not heartbeated its sizes yet,
|
||||
// reports zero, and refusing to delete on that would strand every
|
||||
// encode against such a cluster in the hybrid state this check exists
|
||||
// to avoid.
|
||||
{
|
||||
name: "unreported sizes are skipped, not read as a disagreement",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{0: 1048576, 1: 0}),
|
||||
"server2:8080": sizedShards(map[int]int64{2: 0}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nothing reported at all verifies vacuously",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": sizedShards(map[int]int64{0: 0, 1: 0}),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no holders is not a size problem",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{},
|
||||
},
|
||||
{
|
||||
name: "a nil inventory is skipped rather than panicking",
|
||||
byNode: map[pb.ServerAddress]*erasure_coding.ShardsInfo{
|
||||
"server1:8080": nil,
|
||||
"server2:8080": sizedShards(map[int]int64{0: 1048576}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := requireUniformShardSizes(needle.VolumeId(7), tt.byNode)
|
||||
if !tt.wantErr {
|
||||
if err != nil {
|
||||
t.Fatalf("want the encode to proceed, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("want the deletion held back, got no error")
|
||||
}
|
||||
for _, want := range tt.errContains {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error %q does not mention %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ecShardReport builds one heartbeat entry: a generation stamp and the shards
|
||||
// that generation put on this node, all of one size.
|
||||
func ecShardReport(vid uint32, encodeTsNs int64, size int64, ids ...int) *master_pb.VolumeEcShardInformationMessage {
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for _, id := range ids {
|
||||
si.Set(erasure_coding.NewShardInfo(erasure_coding.ShardId(id), erasure_coding.ShardSize(size)))
|
||||
}
|
||||
return &master_pb.VolumeEcShardInformationMessage{
|
||||
Id: vid,
|
||||
Collection: "ectest",
|
||||
EcIndexBits: si.Bitmap(),
|
||||
ShardSizes: si.SizesInt64(),
|
||||
EncodeTsNs: encodeTsNs,
|
||||
}
|
||||
}
|
||||
|
||||
func topologyWith(reports map[string][]*master_pb.VolumeEcShardInformationMessage) *master_pb.TopologyInfo {
|
||||
var nodes []*master_pb.DataNodeInfo
|
||||
for addr, shards := range reports {
|
||||
nodes = append(nodes, &master_pb.DataNodeInfo{
|
||||
Id: addr,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {EcShardInfos: shards},
|
||||
},
|
||||
})
|
||||
}
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{Id: "rack1", DataNodeInfos: nodes}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// A re-encode can change the ratio, so an older generation's shards are a
|
||||
// different length by nature. Recoverability already judges only the newest
|
||||
// generation; if the size check did not, an orphaned older encode -- one the
|
||||
// pre-encode sweep could not reach, but the master still hears about -- would
|
||||
// disagree on size at every retry and strand the volume beside its shards for
|
||||
// good.
|
||||
func TestShardSizeCheckIgnoresOlderEncodeGenerations(t *testing.T) {
|
||||
const vid = 9
|
||||
|
||||
topo := topologyWith(map[string][]*master_pb.VolumeEcShardInformationMessage{
|
||||
// the current encode: one length, spread over two nodes
|
||||
"server1:8080": {ecShardReport(vid, 2000, 1048576, 0, 1, 2)},
|
||||
"server2:8080": {ecShardReport(vid, 2000, 1048576, 3, 4, 5)},
|
||||
// an orphan from an earlier encode, at a different length
|
||||
"server3:8080": {ecShardReport(vid, 1000, 524288, 0, 1)},
|
||||
})
|
||||
|
||||
byNode := collectNewestGenerationShardsInfo(topo, needle.VolumeId(vid))
|
||||
if _, stale := byNode[pb.ServerAddress("server3:8080")]; stale {
|
||||
t.Error("the older generation's holder must not be collected")
|
||||
}
|
||||
if err := requireUniformShardSizes(needle.VolumeId(vid), byNode); err != nil {
|
||||
t.Fatalf("a healthy current generation must not be vetoed by an orphan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fencing to the newest generation must not blind the check: a short shard
|
||||
// inside that generation is exactly what it exists to catch.
|
||||
func TestShardSizeCheckStillCatchesShortShardInNewestGeneration(t *testing.T) {
|
||||
const vid = 9
|
||||
|
||||
topo := topologyWith(map[string][]*master_pb.VolumeEcShardInformationMessage{
|
||||
"server1:8080": {ecShardReport(vid, 2000, 1048576, 0, 1)},
|
||||
"server2:8080": {ecShardReport(vid, 2000, 4096, 2)},
|
||||
"server3:8080": {ecShardReport(vid, 1000, 524288, 0)},
|
||||
})
|
||||
|
||||
err := requireUniformShardSizes(needle.VolumeId(vid), collectNewestGenerationShardsInfo(topo, needle.VolumeId(vid)))
|
||||
if err == nil {
|
||||
t.Fatal("a truncated shard in the newest generation must hold the deletion back")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "server2:8080.2") {
|
||||
t.Errorf("error %q does not name the short shard", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Volumes encoded before generation stamping report zero, which is a single
|
||||
// legacy generation rather than an orphan to drop.
|
||||
func TestShardSizeCheckHandlesUnstampedGenerations(t *testing.T) {
|
||||
const vid = 9
|
||||
|
||||
topo := topologyWith(map[string][]*master_pb.VolumeEcShardInformationMessage{
|
||||
"server1:8080": {ecShardReport(vid, 0, 1048576, 0, 1)},
|
||||
"server2:8080": {ecShardReport(vid, 0, 4096, 2)},
|
||||
})
|
||||
|
||||
byNode := collectNewestGenerationShardsInfo(topo, needle.VolumeId(vid))
|
||||
if len(byNode) != 2 {
|
||||
t.Fatalf("unstamped shards form one generation, got %d holder(s)", len(byNode))
|
||||
}
|
||||
if err := requireUniformShardSizes(needle.VolumeId(vid), byNode); err == nil {
|
||||
t.Error("a truncated legacy shard must still hold the deletion back")
|
||||
}
|
||||
}
|
||||
531
weed/ec/ec_encode_test.go
Normal file
531
weed/ec/ec_encode_test.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSelectVolumeIdsFromTopology(t *testing.T) {
|
||||
// Topology volumeSizeLimit:1000 MB hdd(volume:28/30 active:28 free:2 remote:0)
|
||||
// DataCenter DefaultDataCenter hdd(volume:28/30 active:28 free:2 remote:0)
|
||||
// Rack DefaultRack hdd(volume:28/30 active:28 free:2 remote:0)
|
||||
// DataNode seaweedfs-volume-0.seaweedfs-volume.sea:8080 hdd(volume:14/15 active:14 free:1 remote:0)
|
||||
// Disk hdd(volume:14/15 active:14 free:1 remote:0) id:0
|
||||
// volume Id:1, Size:23520, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:3, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366011
|
||||
// volume Id:2, Size:28536, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:1, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770365291
|
||||
// volume Id:3, Size:100752, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364931
|
||||
// volume Id:4, Size:3112, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:4, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300850
|
||||
// volume Id:5, Size:145208, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770365652
|
||||
// volume Id:6, Size:146456, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:4, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364812
|
||||
// volume Id:7, Size:8, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:0, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300824
|
||||
// volume Id:8, Size:808, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300796
|
||||
// volume Id:9, Size:8, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:0, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300814
|
||||
// volume Id:10, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364737
|
||||
// volume Id:11, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364796
|
||||
// volume Id:12, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364829
|
||||
// volume Id:13, Size:570428616, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:68, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366062
|
||||
// volume Id:14, Size:478153400, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:57, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366020
|
||||
// Disk hdd {Size:4194776448 FileCount:518 DeletedFileCount:0 DeletedBytes:0}
|
||||
// DataNode seaweedfs-volume-0.seaweedfs-volume.sea:8080 {Size:4194776448 FileCount:518 DeletedFileCount:0 DeletedBytes:0}
|
||||
// DataCenter DefaultDataCenter hdd(volume:28/30 active:28 free:2 remote:0)
|
||||
// Rack DefaultRack hdd(volume:28/30 active:28 free:2 remote:0)
|
||||
// DataNode seaweedfs-volume-1.seaweedfs-volume.sea:8080 hdd(volume:14/15 active:14 free:1 remote:0)
|
||||
// Disk hdd(volume:14/15 active:14 free:1 remote:0) id:0
|
||||
// volume Id:1, Size:23520, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:3, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366012
|
||||
// volume Id:2, Size:28536, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:1, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770365292
|
||||
// volume Id:3, Size:100752, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364931
|
||||
// volume Id:4, Size:3112, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:4, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300850
|
||||
// volume Id:5, Size:145208, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770365653
|
||||
// volume Id:6, Size:146456, ReplicaPlacement:001, Collection:, Version:3, Ttl:, FileCount:4, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364812
|
||||
// volume Id:7, Size:8, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:0, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300824
|
||||
// volume Id:8, Size:808, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:2, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300796
|
||||
// volume Id:9, Size:8, ReplicaPlacement:001, Collection:encrypt-data, Version:3, Ttl:, FileCount:0, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770300814
|
||||
// volume Id:10, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364737
|
||||
// volume Id:11, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364796
|
||||
// volume Id:12, Size:1048582008, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:125, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770364828
|
||||
// volume Id:13, Size:570428616, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:68, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366062
|
||||
// volume Id:14, Size:478153400, ReplicaPlacement:001, Collection:test, Version:3, Ttl:, FileCount:57, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1770366020
|
||||
// Disk hdd {Size:4194776448 FileCount:518 DeletedFileCount:0 DeletedBytes:0}
|
||||
// DataNode seaweedfs-volume-1.seaweedfs-volume.sea:8080 {Size:4194776448 FileCount:518 DeletedFileCount:0 DeletedBytes:0}
|
||||
// Rack DefaultRack {Size:8389552896 FileCount:1036 DeletedFileCount:0 DeletedBytes:0}
|
||||
// DataCenter DefaultDataCenter {Size:8389552896 FileCount:1036 DeletedFileCount:0 DeletedBytes:0}
|
||||
// total size:8389552896 file_count:1036
|
||||
|
||||
topologyInfo := &master_pb.TopologyInfo{
|
||||
Id: "DefaultDataCenter",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "DefaultDataCenter",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "DefaultRack",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "seaweedfs-volume-0.seaweedfs-volume.sea:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
Type: "hdd",
|
||||
FreeVolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
||||
{Id: 1, Size: 23520, Collection: "", ModifiedAtSecond: 1770366011},
|
||||
{Id: 2, Size: 28536, Collection: "", ModifiedAtSecond: 1770365291},
|
||||
{Id: 3, Size: 100752, Collection: "", ModifiedAtSecond: 1770364931},
|
||||
{Id: 4, Size: 3112, Collection: "", ModifiedAtSecond: 1770300850},
|
||||
{Id: 5, Size: 145208, Collection: "", ModifiedAtSecond: 1770365652},
|
||||
{Id: 6, Size: 146456, Collection: "", ModifiedAtSecond: 1770364812},
|
||||
{Id: 7, Size: 8, Collection: "encrypt-data", ModifiedAtSecond: 1770300824},
|
||||
{Id: 8, Size: 808, Collection: "encrypt-data", ModifiedAtSecond: 1770300796},
|
||||
{Id: 9, Size: 8, Collection: "encrypt-data", ModifiedAtSecond: 1770300814},
|
||||
{Id: 10, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364737},
|
||||
{Id: 11, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364796},
|
||||
{Id: 12, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364829},
|
||||
{Id: 13, Size: 570428616, Collection: "test", ModifiedAtSecond: 1770366062},
|
||||
{Id: 14, Size: 478153400, Collection: "test", ModifiedAtSecond: 1770366020},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "seaweedfs-volume-1.seaweedfs-volume.sea:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
Type: "hdd",
|
||||
FreeVolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
||||
{Id: 1, Size: 23520, Collection: "", ModifiedAtSecond: 1770366012},
|
||||
{Id: 2, Size: 28536, Collection: "", ModifiedAtSecond: 1770365292},
|
||||
{Id: 3, Size: 100752, Collection: "", ModifiedAtSecond: 1770364931},
|
||||
{Id: 4, Size: 3112, Collection: "", ModifiedAtSecond: 1770300850},
|
||||
{Id: 5, Size: 145208, Collection: "", ModifiedAtSecond: 1770365653},
|
||||
{Id: 6, Size: 146456, Collection: "", ModifiedAtSecond: 1770364812},
|
||||
{Id: 7, Size: 8, Collection: "encrypt-data", ModifiedAtSecond: 1770300824},
|
||||
{Id: 8, Size: 808, Collection: "encrypt-data", ModifiedAtSecond: 1770300796},
|
||||
{Id: 9, Size: 8, Collection: "encrypt-data", ModifiedAtSecond: 1770300814},
|
||||
{Id: 10, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364737},
|
||||
{Id: 11, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364796},
|
||||
{Id: 12, Size: 1048582008, Collection: "test", ModifiedAtSecond: 1770364828},
|
||||
{Id: 13, Size: 570428616, Collection: "test", ModifiedAtSecond: 1770366062},
|
||||
{Id: 14, Size: 478153400, Collection: "test", ModifiedAtSecond: 1770366020},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
volumeSizeLimitMb := uint64(1000)
|
||||
collectionMatcher, _ := wildcard.CompileCollectionMatcher(".*")
|
||||
|
||||
// ec.encode -force
|
||||
// force means we ignore the check for 4+ volume servers.
|
||||
// But in this unit test we are testing selectVolumeIdsFromTopology, which selects volumes.
|
||||
// The -force flag is handled in the caller of this function.
|
||||
|
||||
// Default values
|
||||
quietPeriod := time.Hour
|
||||
fullPercentage := 95.0
|
||||
verbose := true
|
||||
|
||||
// Mock time to be slightly after the latest modification time in the test data
|
||||
// The latest ModifiedAtSecond in the test data is 1770366062 (Volume 13)
|
||||
// Let's set now to be 1770366062 + quietPeriod.Seconds() + 100
|
||||
nowUnixSeconds := int64(1770366062) + int64(quietPeriod.Seconds()) + 100
|
||||
quietSeconds := int64(quietPeriod.Seconds())
|
||||
|
||||
// Test case 1: Select all volumes with sufficient size
|
||||
// Note: The logic requires (FreeVolumeCount >= 2) unless the volume is already selected?
|
||||
// Wait, distinct volume IDs.
|
||||
// Logic:
|
||||
// if diskInfo.FreeVolumeCount < 2 { skip }
|
||||
// In the test data, FreeVolumeCount is 1. So it should skip all volumes basically?
|
||||
// Let's check the code:
|
||||
/*
|
||||
// check free disk space
|
||||
if good, found := vidMap[v.Id]; found {
|
||||
if good {
|
||||
if diskInfo.FreeVolumeCount < 2 {
|
||||
// ...
|
||||
vidMap[v.Id] = false
|
||||
noFreeDisk++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if diskInfo.FreeVolumeCount < 2 {
|
||||
// ...
|
||||
vidMap[v.Id] = false
|
||||
noFreeDisk++
|
||||
} else {
|
||||
// ...
|
||||
vidMap[v.Id] = true
|
||||
}
|
||||
}
|
||||
*/
|
||||
// Yes, if FreeVolumeCount < 2, it marks the volume as bad (false in vidMap).
|
||||
// In the provided topology, FreeVolumeCount is 1 ("free:1"), so it is < 2.
|
||||
// So ALL volumes should be skipped due to insufficient free disk space.
|
||||
|
||||
vids, _ := SelectVolumeIdsFromTopology(topologyInfo, volumeSizeLimitMb, collectionMatcher, nil, quietSeconds, nowUnixSeconds, fullPercentage, verbose)
|
||||
|
||||
assert.Equal(t, 0, len(vids), "Should select 0 volumes because FreeVolumeCount is 1 (less than 2)")
|
||||
|
||||
// Test case 2: If we had enough free space
|
||||
topologyInfo.DataCenterInfos[0].RackInfos[0].DataNodeInfos[0].DiskInfos["hdd"].FreeVolumeCount = 2
|
||||
topologyInfo.DataCenterInfos[0].RackInfos[0].DataNodeInfos[1].DiskInfos["hdd"].FreeVolumeCount = 2
|
||||
|
||||
// Update expected selection based on size and other filters
|
||||
// Size threshold: 95% of 1000MB = 950MB = 996147200 bytes
|
||||
// Volumes >= 950MB:
|
||||
// Vol 10: 1048582008 > 950MB
|
||||
// Vol 11: 1048582008 > 950MB
|
||||
// Vol 12: 1048582008 > 950MB
|
||||
// Vol 13: 570428616 < 950MB
|
||||
// Vol 14: 478153400 < 950MB
|
||||
// ... others are small
|
||||
|
||||
// So expected volumes: 10, 11, 12.
|
||||
|
||||
vids, _ = SelectVolumeIdsFromTopology(topologyInfo, volumeSizeLimitMb, collectionMatcher, nil, quietSeconds, nowUnixSeconds, fullPercentage, verbose)
|
||||
|
||||
expectedVids := []needle.VolumeId{10, 11, 12}
|
||||
assert.Equal(t, len(expectedVids), len(vids), "Should select 3 volumes")
|
||||
|
||||
// Check content
|
||||
vidMap := make(map[needle.VolumeId]bool)
|
||||
for _, vid := range vids {
|
||||
vidMap[vid] = true
|
||||
}
|
||||
for _, vid := range expectedVids {
|
||||
assert.True(t, vidMap[vid], "Volume %d should be selected", vid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcEncodeNodeCountCheck(t *testing.T) {
|
||||
topologyInfo := &master_pb.TopologyInfo{
|
||||
Id: "DefaultDataCenter",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "DefaultDataCenter",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "DefaultRack",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{Id: "node1"},
|
||||
{Id: "node2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeCount := 0
|
||||
EachDataNode(topologyInfo, func(dc DataCenterId, rack RackId, dn *master_pb.DataNodeInfo) {
|
||||
nodeCount++
|
||||
})
|
||||
|
||||
// Default parity shards count is 4
|
||||
minNodeCount := 4
|
||||
|
||||
// Case 1: Without -force
|
||||
forceChanges := false
|
||||
willProceed := forceChanges || nodeCount >= minNodeCount
|
||||
assert.False(t, willProceed, "Should NOT proceed with %d nodes (min %d) without force", nodeCount, minNodeCount)
|
||||
|
||||
// Case 2: With -force
|
||||
forceChanges = true
|
||||
willProceed = forceChanges || nodeCount >= minNodeCount
|
||||
assert.True(t, willProceed, "Should proceed with -force even with %d nodes", nodeCount)
|
||||
}
|
||||
|
||||
func TestChunkVolumeIds(t *testing.T) {
|
||||
vids := []needle.VolumeId{101, 102, 103, 104, 105}
|
||||
|
||||
assert.Equal(t, [][]needle.VolumeId{
|
||||
{101, 102},
|
||||
{103, 104},
|
||||
{105},
|
||||
}, ChunkVolumeIds(vids, 2))
|
||||
|
||||
assert.Equal(t, [][]needle.VolumeId{vids}, ChunkVolumeIds(vids, 0))
|
||||
}
|
||||
|
||||
func ecShardVisibilityTestTopology(nodes ...*master_pb.DataNodeInfo) *master_pb.TopologyInfo {
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: nodes,
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectEcShardBitsByNode(t *testing.T) {
|
||||
allBits := uint32(1)<<erasure_coding.TotalShardsCount - 1
|
||||
|
||||
// One node reports the volume's shards split across two disk types; the
|
||||
// other node reports nothing for it.
|
||||
node1 := &master_pb.DataNodeInfo{
|
||||
Id: "node1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {
|
||||
MaxVolumeCount: 10,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits & 0x00ff, DiskId: 0},
|
||||
},
|
||||
},
|
||||
"ssd": {
|
||||
Type: "ssd",
|
||||
MaxVolumeCount: 10,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits &^ 0x00ff, DiskId: 1},
|
||||
{Id: 2, EcIndexBits: allBits, DiskId: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
node2 := &master_pb.DataNodeInfo{
|
||||
Id: "node2:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{"": {MaxVolumeCount: 10}},
|
||||
}
|
||||
topo := ecShardVisibilityTestTopology(node1, node2)
|
||||
|
||||
byNode := CollectEcShardBitsByNode(topo, needle.VolumeId(1))
|
||||
assert.Len(t, byNode, 1)
|
||||
assert.Equal(t, erasure_coding.ShardBits(allBits), byNode[pb.NewServerAddressFromDataNode(node1)])
|
||||
|
||||
assert.Empty(t, CollectEcShardBitsByNode(topo, needle.VolumeId(3)))
|
||||
}
|
||||
|
||||
func TestCollectEcShardBitsByNode_MixedGenerations(t *testing.T) {
|
||||
allBits := uint32(1)<<erasure_coding.TotalShardsCount - 1
|
||||
|
||||
nodeWithGeneration := func(id string, ts int64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
Id: id,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {
|
||||
MaxVolumeCount: 10,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits, DiskId: 0, EncodeTsNs: ts},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A full orphaned older generation on node2 must not count: neither toward
|
||||
// the registration union nor as a second holder.
|
||||
fresh := nodeWithGeneration("node1:8080", 200)
|
||||
orphan := nodeWithGeneration("node2:8080", 100)
|
||||
byNode := CollectEcShardBitsByNode(ecShardVisibilityTestTopology(fresh, orphan), needle.VolumeId(1))
|
||||
assert.Len(t, byNode, 1)
|
||||
assert.Equal(t, erasure_coding.ShardBits(allBits), byNode[pb.NewServerAddressFromDataNode(fresh)])
|
||||
|
||||
// Un-stamped entries are the legacy generation zero: dropped when a stamped
|
||||
// generation exists, all counted when nothing is stamped.
|
||||
legacy := nodeWithGeneration("node2:8080", 0)
|
||||
byNode = CollectEcShardBitsByNode(ecShardVisibilityTestTopology(fresh, legacy), needle.VolumeId(1))
|
||||
assert.Len(t, byNode, 1)
|
||||
assert.Equal(t, erasure_coding.ShardBits(allBits), byNode[pb.NewServerAddressFromDataNode(fresh)])
|
||||
|
||||
byNode = CollectEcShardBitsByNode(
|
||||
ecShardVisibilityTestTopology(nodeWithGeneration("node1:8080", 0), nodeWithGeneration("node2:8080", 0)),
|
||||
needle.VolumeId(1))
|
||||
assert.Len(t, byNode, 2)
|
||||
}
|
||||
|
||||
func TestEcShardsClumpedOnOneNode(t *testing.T) {
|
||||
allBits := uint32(1)<<erasure_coding.TotalShardsCount - 1
|
||||
|
||||
holderNode := func() *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
Id: "node1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {
|
||||
MaxVolumeCount: 10,
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits, DiskId: 0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
emptyNode := func(id string, maxVolumeCount, volumeCount int64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
Id: id,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {MaxVolumeCount: maxVolumeCount, VolumeCount: volumeCount},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// All shards on one node while another node has free slots: clumped.
|
||||
node1 := holderNode()
|
||||
topo := ecShardVisibilityTestTopology(node1, emptyNode("node2:8080", 10, 0))
|
||||
holder, clumped := ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.True(t, clumped)
|
||||
assert.Equal(t, pb.NewServerAddressFromDataNode(node1), holder)
|
||||
|
||||
// The only other node has no free slots: the balance could not have spread
|
||||
// the shards, so deletion may proceed.
|
||||
topo = ecShardVisibilityTestTopology(holderNode(), emptyNode("node2:8080", 1, 1))
|
||||
_, clumped = ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.False(t, clumped)
|
||||
|
||||
// Single-node cluster: not a clump.
|
||||
topo = ecShardVisibilityTestTopology(holderNode())
|
||||
_, clumped = ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.False(t, clumped)
|
||||
|
||||
// Shards spread across two nodes: not a clump.
|
||||
spread1 := holderNode()
|
||||
spread1.DiskInfos[""].EcShardInfos[0].EcIndexBits = allBits & 0x007f
|
||||
spread2 := emptyNode("node2:8080", 10, 0)
|
||||
spread2.DiskInfos[""].EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits &^ 0x007f, DiskId: 0},
|
||||
}
|
||||
topo = ecShardVisibilityTestTopology(spread1, spread2)
|
||||
_, clumped = ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.False(t, clumped)
|
||||
|
||||
// No shards visible at all: the recoverability check owns that case.
|
||||
topo = ecShardVisibilityTestTopology(emptyNode("node1:8080", 10, 0), emptyNode("node2:8080", 10, 0))
|
||||
_, clumped = ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.False(t, clumped)
|
||||
|
||||
// A full orphaned older generation on another node must not pose as a
|
||||
// second holder and defeat the clump detection.
|
||||
fresh := holderNode()
|
||||
fresh.DiskInfos[""].EcShardInfos[0].EncodeTsNs = 200
|
||||
orphan := emptyNode("node2:8080", 10, 0)
|
||||
orphan.DiskInfos[""].EcShardInfos = []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: allBits, DiskId: 0, EncodeTsNs: 100},
|
||||
}
|
||||
topo = ecShardVisibilityTestTopology(fresh, orphan, emptyNode("node3:8080", 10, 0))
|
||||
holder, clumped = ecShardsClumpedOnOneNode(topo, needle.VolumeId(1), types.HardDriveType)
|
||||
assert.True(t, clumped)
|
||||
assert.Equal(t, pb.NewServerAddressFromDataNode(fresh), holder)
|
||||
}
|
||||
|
||||
// A volume that lived on ssd gets its shards written beside it, on ssd, while
|
||||
// -diskType still defaults to hdd. The pre-delete check must count them anyway:
|
||||
// scoping the count to the hdd bucket saw a complete set as zero shards and
|
||||
// aborted the encode, leaving the volume as both a .dat and a full shard set.
|
||||
func TestEcShardCountIgnoresDiskTypeOfTheShards(t *testing.T) {
|
||||
allBits := uint32(1)<<erasure_coding.TotalShardsCount - 1
|
||||
node := &master_pb.DataNodeInfo{
|
||||
Id: "node1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {MaxVolumeCount: 10},
|
||||
"ssd": {Type: "ssd", MaxVolumeCount: 10, EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{{Id: 1, EcIndexBits: allBits, DiskId: 1}}},
|
||||
},
|
||||
}
|
||||
topo := ecShardVisibilityTestTopology(node)
|
||||
|
||||
var union erasure_coding.ShardBits
|
||||
for _, bits := range CollectEcShardBitsByNode(topo, needle.VolumeId(1)) {
|
||||
union |= bits
|
||||
}
|
||||
assert.Equal(t, erasure_coding.TotalShardsCount, union.Count(),
|
||||
"shards on a non-default medium must still be counted")
|
||||
|
||||
degraded, err := erasure_coding.RequireRecoverableShardSet(
|
||||
1, union, erasure_coding.DataShardsCount, erasure_coding.TotalShardsCount)
|
||||
assert.NoError(t, err, "a complete shard set must not read as unrecoverable")
|
||||
assert.False(t, degraded)
|
||||
|
||||
// The decode's node view unions across buckets for the same reason: a
|
||||
// decode scoped to the hdd bucket would report this decodable volume as
|
||||
// having no shards at all.
|
||||
byNode, _ := collectEcNodeShardsInfo(topo, needle.VolumeId(1))
|
||||
assert.Len(t, byNode, 1, "decode discovery must see shards on a non-default medium")
|
||||
for _, si := range byNode {
|
||||
assert.Equal(t, erasure_coding.TotalShardsCount, si.Count(),
|
||||
"decode discovery must include every shard on the non-default medium")
|
||||
}
|
||||
}
|
||||
|
||||
// The message an aborted deletion leaves behind is all the operator has to go
|
||||
// on, so it has to name which shards were found and not only how many: a set
|
||||
// holding 0-9 and one holding 4-13 are both "10 shards", and only the ids say
|
||||
// whether what survived can rebuild the volume.
|
||||
func TestEcShardSummaryNamesTheShardIds(t *testing.T) {
|
||||
byNode := map[pb.ServerAddress]erasure_coding.ShardBits{
|
||||
"node2:8080": erasure_coding.ShardBits(0).Set(1).Set(2),
|
||||
"node1:8080": erasure_coding.ShardBits(0).Set(0).Set(12),
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"node1:8080=2 shards [0 12]",
|
||||
"node2:8080=2 shards [1 2]",
|
||||
}, ecShardSummaryByNode(byNode))
|
||||
}
|
||||
|
||||
// A retried ec.encode clears the leftover shards of an interrupted run before
|
||||
// the source health check, but the topology snapshot predates that sweep: the
|
||||
// leftovers still depress FreeVolumeCount on the source disk. The counts must
|
||||
// refund those slots, or the retry refuses the very volume it just cleaned up
|
||||
// after. Numbers mirror the interruption-matrix failure: 8 slots, 4 volumes,
|
||||
// 8 shards of other EC volumes, plus 14 leftover shards of volume 3, so the
|
||||
// master charges ceil(22/10)=3 slots and reports free=1.
|
||||
func TestCollectSourceFreeVolumeCountsRefundsClearedShards(t *testing.T) {
|
||||
newTopo := func() *master_pb.TopologyInfo {
|
||||
return &master_pb.TopologyInfo{
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{{
|
||||
Id: "rack0",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{{
|
||||
Id: "127.0.0.1:8110",
|
||||
Address: "127.0.0.1:8110",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"hdd": {
|
||||
Type: "hdd",
|
||||
MaxVolumeCount: 8,
|
||||
VolumeCount: 4,
|
||||
FreeVolumeCount: 1,
|
||||
VolumeInfos: []*master_pb.VolumeInformationMessage{
|
||||
{Id: 3}, {Id: 6},
|
||||
},
|
||||
EcShardInfos: []*master_pb.VolumeEcShardInformationMessage{
|
||||
{Id: 1, EcIndexBits: 0b00000010001, DiskId: 0}, // shards 0,4
|
||||
{Id: 1, EcIndexBits: 0b10000000100, DiskId: 1}, // shards 2,10
|
||||
{Id: 2, EcIndexBits: 0b00000010001, DiskId: 0},
|
||||
{Id: 2, EcIndexBits: 0b10000000100, DiskId: 1},
|
||||
{Id: 3, EcIndexBits: 0b11111111111111, DiskId: 0}, // interrupted run's leftovers
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
counts := collectSourceFreeVolumeCounts(newTopo(), []needle.VolumeId{3}, nil)
|
||||
assert.Equal(t, 3, counts["3-127.0.0.1:8110"],
|
||||
"the cleared leftovers' ceil(22/10)-ceil(8/10)=2 slots must be refunded")
|
||||
|
||||
counts = collectSourceFreeVolumeCounts(newTopo(), []needle.VolumeId{6}, nil)
|
||||
assert.Equal(t, 1, counts["6-127.0.0.1:8110"],
|
||||
"no refund when the encoded volume has no leftover shards")
|
||||
|
||||
skipped := map[pb.ServerAddress]struct{}{"127.0.0.1:8110": {}}
|
||||
counts = collectSourceFreeVolumeCounts(newTopo(), []needle.VolumeId{3}, skipped)
|
||||
assert.Equal(t, 1, counts["3-127.0.0.1:8110"],
|
||||
"no refund on a node the sweep skipped as unreachable")
|
||||
}
|
||||
51
weed/ec/ec_flags_test.go
Normal file
51
weed/ec/ec_flags_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// countFreeShardSlots must report zero for a physically near-full disk even
|
||||
// though its slot math (MaxVolumeCount - VolumeCount) says it has room, so EC
|
||||
// placement never piles shards onto a full disk. Mirrors the volume #10160 gate.
|
||||
func TestCountFreeShardSlotsPhysicalDiskGate(t *testing.T) {
|
||||
const gb = uint64(1) << 30
|
||||
|
||||
mk := func(total, free uint64) *master_pb.DataNodeInfo {
|
||||
return &master_pb.DataNodeInfo{
|
||||
Id: "n1",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
"": {MaxVolumeCount: 100, VolumeCount: 0, DiskTotalBytes: total, DiskFreeBytes: free},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Physically empty: slot math applies, positive free slots.
|
||||
if got := CountFreeShardSlots(mk(1000*gb, 900*gb), types.HardDriveType); got <= 0 {
|
||||
t.Errorf("physically empty disk free shard slots = %d, want > 0", got)
|
||||
}
|
||||
// Physically 96% full: gated to zero regardless of slot room.
|
||||
if got := CountFreeShardSlots(mk(1000*gb, 40*gb), types.HardDriveType); got != 0 {
|
||||
t.Errorf("physically full disk free shard slots = %d, want 0", got)
|
||||
}
|
||||
// No byte report (older server): fall back to slot math, positive.
|
||||
if got := CountFreeShardSlots(mk(0, 0), types.HardDriveType); got <= 0 {
|
||||
t.Errorf("unreported-bytes disk free shard slots = %d, want > 0 (slot fallback)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVolumeIdsFlag(t *testing.T) {
|
||||
vids, err := ParseVolumeIdsFlag("101, 102,101, 103")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []needle.VolumeId{101, 102, 103}, vids)
|
||||
|
||||
_, err = ParseVolumeIdsFlag("101,abc")
|
||||
assert.Error(t, err)
|
||||
|
||||
_, err = ParseVolumeIdsFlag(" , ")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
520
weed/ec/ec_lifecycle_model_test.go
Normal file
520
weed/ec/ec_lifecycle_model_test.go
Normal file
@@ -0,0 +1,520 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Bounded-exhaustive model check of the EC volume lifecycle.
|
||||
//
|
||||
// The randomized chaos harness samples the state space; this test enumerates
|
||||
// it. The lifecycle of one volume is modeled as a state machine whose
|
||||
// transitions mirror, step by step, the real pipelines in this package, and
|
||||
// the checker explores EVERY schedule within the bound: every operation, a
|
||||
// crash (kill, no cleanup) at every step boundary, an error return (which
|
||||
// runs the rollback, itself crashable at every step) at every step boundary,
|
||||
// a volume-server restart (running the startup reconciliation rules) in every
|
||||
// quiescent state, and every recovery re-run from every crashed state.
|
||||
//
|
||||
// Two safety invariants are asserted in every reachable state, including
|
||||
// mid-operation and post-crash:
|
||||
//
|
||||
// I1 durability: the volume's data is always recoverable — the regular
|
||||
// replica exists, or some single generation holds at least dataShards
|
||||
// shard files.
|
||||
// I2 single serving generation: at most one generation is ever mounted.
|
||||
// I3 single generation on disk: the sweep-before-generate discipline keeps
|
||||
// at most one generation's files on disk through every schedule. (An
|
||||
// externally planted file breaks this by construction; that path is the
|
||||
// chaos harness's, not the orchestration's.)
|
||||
//
|
||||
// And one convergence property: from every quiescent state, the prescribed
|
||||
// recovery — re-running the interrupted operation, per the restart-not-resume
|
||||
// model — terminates in a clean state (a writable regular volume with no EC
|
||||
// leftovers, or exactly one complete mounted generation with no source
|
||||
// replica and no surplus copies).
|
||||
//
|
||||
// The model's fidelity contract: each step below names the code it stands
|
||||
// for. When the pipeline order changes, this file must change with it.
|
||||
//
|
||||
// encode = ProcessEcEncodeBatch/doEcEncode: markReadonly
|
||||
// (markVolumeReplicaWritable false) → sweep
|
||||
// (clearPreexistingEcShards, removes every prior generation) →
|
||||
// generate (VolumeEcShardsGenerate, a new generation's files) →
|
||||
// mount (VolumeEcShardsMount, registers it) → balance
|
||||
// (EcBalance; a crash mid-move leaves a surplus copy, never a
|
||||
// loss, because moves copy before they delete) → verify
|
||||
// (verifyEcShardsBeforeDelete) → deleteSource
|
||||
// (doDeleteVolumesWithLocations, the commit point).
|
||||
// rollback = rollbackFailedEcEncode, run only on an error return before
|
||||
// the commit: rollbackSweep (clearPreexistingEcShards again) →
|
||||
// restoreWritable. A kill runs nothing.
|
||||
// decode = DoEcDecode: collect (collectEcShards) → generateVolume
|
||||
// (VolumeEcShardsToVolume) → mountVolume (VolumeMount) →
|
||||
// verifyDecoded (verifyDecodedVolumeBeforeDelete) → three
|
||||
// per-location shard deletions (unmountAndDeleteEcShards).
|
||||
// Forward-only: no rollback.
|
||||
// reconcile = the volume server startup rules (weed/storage
|
||||
// disk_location_ec.go): with the source .dat present, a
|
||||
// partial generation fails validation and is deleted
|
||||
// (handleFoundEcxFile/validateEcVolume); surviving shard files
|
||||
// with their sidecars re-register (the #9212 adoption).
|
||||
// dedup = the balance's dedup phase removing surplus copies
|
||||
// (ecbalancer "dedup" moves + verifyEcShardOnKeepNode).
|
||||
//
|
||||
// Deliberately outside the model: multi-replica sources (the pipeline syncs
|
||||
// and reduces to one best replica before encoding), per-node shard placement
|
||||
// (I1/I2 do not depend on it), and externally planted files (the chaos
|
||||
// harness covers adoption of foreign shards).
|
||||
|
||||
const (
|
||||
modelDataShards = 10
|
||||
modelTotalShards = 14
|
||||
)
|
||||
|
||||
type modelGen struct {
|
||||
files int // shard files on disk, whether or not registered
|
||||
mounted bool // registered and serving
|
||||
surplus int // duplicate copies left by an interrupted balance move
|
||||
}
|
||||
|
||||
type modelState struct {
|
||||
replica bool
|
||||
replicaReadonly bool
|
||||
gens []modelGen // oldest first; a new encode appends
|
||||
}
|
||||
|
||||
func (s modelState) clone() modelState {
|
||||
c := s
|
||||
c.gens = append([]modelGen(nil), s.gens...)
|
||||
return c
|
||||
}
|
||||
|
||||
func (s modelState) key() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "r%v,ro%v", s.replica, s.replicaReadonly)
|
||||
for _, g := range s.gens {
|
||||
fmt.Fprintf(&b, "|f%d,m%v,s%d", g.files, g.mounted, g.surplus)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// dropEmptyGens removes generations with no files left.
|
||||
func (s *modelState) dropEmptyGens() {
|
||||
kept := s.gens[:0]
|
||||
for _, g := range s.gens {
|
||||
if g.files > 0 {
|
||||
kept = append(kept, g)
|
||||
}
|
||||
}
|
||||
s.gens = kept
|
||||
}
|
||||
|
||||
// ── invariants ──────────────────────────────────────────────────────────────
|
||||
|
||||
func checkInvariants(s modelState) error {
|
||||
// I1: durability.
|
||||
recoverable := s.replica
|
||||
for _, g := range s.gens {
|
||||
if g.files >= modelDataShards {
|
||||
recoverable = true
|
||||
}
|
||||
}
|
||||
if !recoverable {
|
||||
return fmt.Errorf("I1 violated: no replica and no generation with >= %d shard files", modelDataShards)
|
||||
}
|
||||
// I2: at most one mounted generation.
|
||||
mounted := 0
|
||||
for _, g := range s.gens {
|
||||
if g.mounted {
|
||||
mounted++
|
||||
}
|
||||
}
|
||||
if mounted > 1 {
|
||||
return fmt.Errorf("I2 violated: %d generations mounted", mounted)
|
||||
}
|
||||
// I3: at most one generation's files on disk.
|
||||
withFiles := 0
|
||||
for _, g := range s.gens {
|
||||
if g.files > 0 {
|
||||
withFiles++
|
||||
}
|
||||
}
|
||||
if withFiles > 1 {
|
||||
return fmt.Errorf("I3 violated: %d generations have files on disk", withFiles)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isCleanEc(s modelState) bool {
|
||||
if s.replica || len(s.gens) != 1 {
|
||||
return false
|
||||
}
|
||||
g := s.gens[0]
|
||||
return g.files == modelTotalShards && g.mounted && g.surplus == 0
|
||||
}
|
||||
|
||||
// ── operations as step lists ────────────────────────────────────────────────
|
||||
|
||||
// A step mutates the state; ok=false means the step's precondition failed
|
||||
// (the real command would return an error at this point).
|
||||
type modelStep struct {
|
||||
name string
|
||||
apply func(*modelState) (ok bool)
|
||||
}
|
||||
|
||||
func encodeSteps() []modelStep {
|
||||
return []modelStep{
|
||||
{"markReadonly", func(s *modelState) bool {
|
||||
if !s.replica {
|
||||
return false
|
||||
}
|
||||
s.replicaReadonly = true
|
||||
return true
|
||||
}},
|
||||
// The orphan sweep tears down per (node, volume); a crash can land
|
||||
// between nodes, leaving a partially removed generation.
|
||||
{"sweepLoc0", func(s *modelState) bool { sweepLocation(s, 0); return true }},
|
||||
{"sweepLoc1", func(s *modelState) bool { sweepLocation(s, 1); return true }},
|
||||
{"sweepLoc2", func(s *modelState) bool { sweepLocation(s, 2); return true }},
|
||||
// Generation writes the shard files on the generation host; a crash
|
||||
// mid-write leaves a partial, unregistered set beside the .dat, which
|
||||
// the startup validation removes and the next sweep also covers.
|
||||
{"generateHalf", func(s *modelState) bool {
|
||||
if !s.replica {
|
||||
return false
|
||||
}
|
||||
s.gens = append(s.gens, modelGen{files: modelTotalShards / 2})
|
||||
return true
|
||||
}},
|
||||
{"generateRest", func(s *modelState) bool {
|
||||
s.gens[len(s.gens)-1].files = modelTotalShards
|
||||
return true
|
||||
}},
|
||||
{"mount", func(s *modelState) bool {
|
||||
s.gens[len(s.gens)-1].mounted = true
|
||||
return true
|
||||
}},
|
||||
// balance: a completed run changes placement only. Its crash variant
|
||||
// is modeled by crashing between "balanceCopy" and "balanceDelete":
|
||||
// the copy landed, the source deletion did not.
|
||||
{"balanceCopy", func(s *modelState) bool {
|
||||
s.gens[len(s.gens)-1].surplus++
|
||||
return true
|
||||
}},
|
||||
{"balanceDelete", func(s *modelState) bool {
|
||||
s.gens[len(s.gens)-1].surplus--
|
||||
return true
|
||||
}},
|
||||
{"verify", func(s *modelState) bool {
|
||||
return s.gens[len(s.gens)-1].files >= modelDataShards
|
||||
}},
|
||||
{"deleteSource", func(s *modelState) bool {
|
||||
s.replica = false
|
||||
s.replicaReadonly = false
|
||||
return true
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// encodeCommitIndex is the index of deleteSource: an error return at or after
|
||||
// it must not roll back (the shards are the only copy once it runs).
|
||||
func encodeCommitIndex() int { return len(encodeSteps()) - 1 }
|
||||
|
||||
// sweepLocation removes one location's share of every generation's files —
|
||||
// the per-(node, volume) teardown of clearPreexistingEcShards. The last
|
||||
// location clears the remainder, sidecars included.
|
||||
func sweepLocation(s *modelState, loc int) {
|
||||
for gi := range s.gens {
|
||||
g := &s.gens[gi]
|
||||
chunk := modelTotalShards / 3
|
||||
if loc == 2 {
|
||||
g.files, g.mounted, g.surplus = 0, false, 0
|
||||
} else if g.files > chunk {
|
||||
g.files -= chunk
|
||||
} else {
|
||||
g.files = 0
|
||||
}
|
||||
}
|
||||
s.dropEmptyGens()
|
||||
}
|
||||
|
||||
func rollbackSteps() []modelStep {
|
||||
return []modelStep{
|
||||
{"rollbackSweep", func(s *modelState) bool {
|
||||
s.gens = nil
|
||||
return true
|
||||
}},
|
||||
{"restoreWritable", func(s *modelState) bool {
|
||||
if s.replica {
|
||||
s.replicaReadonly = false
|
||||
}
|
||||
return true
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func decodeSteps() []modelStep {
|
||||
steps := []modelStep{
|
||||
{"collect", func(s *modelState) bool {
|
||||
for _, g := range s.gens {
|
||||
if g.mounted && g.files >= modelDataShards {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}},
|
||||
{"generateVolume", func(s *modelState) bool {
|
||||
s.replica = true
|
||||
return true
|
||||
}},
|
||||
{"mountVolume", func(s *modelState) bool { return true }},
|
||||
{"verifyDecoded", func(s *modelState) bool { return s.replica }},
|
||||
}
|
||||
// Shard deletion is per holder location; three locations model a spread
|
||||
// volume, so a crash can land between any two of them.
|
||||
for i := 0; i < 3; i++ {
|
||||
i := i
|
||||
steps = append(steps, modelStep{fmt.Sprintf("deleteShardsLoc%d", i), func(s *modelState) bool {
|
||||
for gi := range s.gens {
|
||||
g := &s.gens[gi]
|
||||
if !g.mounted && g.files == 0 {
|
||||
continue
|
||||
}
|
||||
// Each location holds roughly a third of the shards; the last
|
||||
// pass clears the remainder and the generation with it.
|
||||
chunk := modelTotalShards / 3
|
||||
if i == 2 {
|
||||
g.files, g.surplus, g.mounted = 0, 0, false
|
||||
} else if g.files > chunk {
|
||||
g.files -= chunk
|
||||
}
|
||||
}
|
||||
s.dropEmptyGens()
|
||||
return true
|
||||
}})
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// reconcile applies the volume-server startup rules after a crash-restart.
|
||||
func reconcile(s *modelState) {
|
||||
for gi := range s.gens {
|
||||
g := &s.gens[gi]
|
||||
if s.replica && g.files < modelTotalShards {
|
||||
// .dat present and the local generation is partial: validation
|
||||
// fails and the files are removed (disk_location_ec.go).
|
||||
g.files, g.mounted, g.surplus = 0, false, 0
|
||||
continue
|
||||
}
|
||||
if g.files > 0 {
|
||||
// Surviving files re-register through their sidecars (#9212).
|
||||
g.mounted = true
|
||||
}
|
||||
}
|
||||
s.dropEmptyGens()
|
||||
}
|
||||
|
||||
// dedupSurplus is the balance dedup phase.
|
||||
func dedupSurplus(s *modelState) {
|
||||
for gi := range s.gens {
|
||||
s.gens[gi].surplus = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── recovery convergence ────────────────────────────────────────────────────
|
||||
|
||||
// runToCompletion applies steps without interruption; false when a
|
||||
// precondition fails (the real re-run would error out).
|
||||
func runToCompletion(s *modelState, steps []modelStep) bool {
|
||||
for _, st := range steps {
|
||||
if !st.apply(s) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// recover applies the prescribed recovery for a quiescent state and reports
|
||||
// whether it converges to a clean state. Per the restart-not-resume model:
|
||||
// a surviving replica means the encode restarts from scratch; no replica
|
||||
// means the encode had committed and only surplus cleanup can remain; a
|
||||
// mounted generation beside a replica can also be decoded away.
|
||||
func recoverModel(s modelState) (modelState, error) {
|
||||
// A crash-restart happens before any operator action; its reconciliation
|
||||
// must itself keep the invariants.
|
||||
reconcile(&s)
|
||||
if err := checkInvariants(s); err != nil {
|
||||
return s, fmt.Errorf("after reconcile: %w", err)
|
||||
}
|
||||
|
||||
if !s.replica {
|
||||
// The encode committed: shards are the volume. Dedup cleans surplus.
|
||||
dedupSurplus(&s)
|
||||
if !isCleanEc(s) {
|
||||
return s, fmt.Errorf("no replica and not clean EC")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Replica survives: re-run the encode from scratch (sweep + regenerate),
|
||||
// then the volume is EC; this is the restart path every interrupted
|
||||
// encode and every leftover-orphan state takes.
|
||||
if !runToCompletion(&s, encodeSteps()) {
|
||||
return s, fmt.Errorf("recovery encode did not complete")
|
||||
}
|
||||
dedupSurplus(&s)
|
||||
if err := checkInvariants(s); err != nil {
|
||||
return s, fmt.Errorf("after recovery encode: %w", err)
|
||||
}
|
||||
if !isCleanEc(s) {
|
||||
return s, fmt.Errorf("recovery encode did not converge to clean EC")
|
||||
}
|
||||
// And a decode must be able to bring it back to a regular volume.
|
||||
if !runToCompletion(&s, decodeSteps()) {
|
||||
return s, fmt.Errorf("decode after recovery did not complete")
|
||||
}
|
||||
if err := checkInvariants(s); err != nil {
|
||||
return s, fmt.Errorf("after decode: %w", err)
|
||||
}
|
||||
if !s.replica || len(s.gens) != 0 {
|
||||
return s, fmt.Errorf("decode after recovery did not converge to a bare regular volume")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ── exhaustive exploration ──────────────────────────────────────────────────
|
||||
|
||||
type trace []string
|
||||
|
||||
// explore runs every schedule of one operation from state s: after every
|
||||
// step, the schedule may continue, crash (kill: stop, nothing else runs), or
|
||||
// error out (rollback runs, itself crashable at every step). Every resulting
|
||||
// quiescent state is handed to onQuiescent together with the trace that
|
||||
// produced it.
|
||||
func exploreOp(t *testing.T, s modelState, opName string, steps []modelStep, commitIndex int, withRollback bool, tr trace, onQuiescent func(modelState, trace)) {
|
||||
t.Helper()
|
||||
// Crash before the first step is the same as never starting.
|
||||
for prefix := 1; prefix <= len(steps); prefix++ {
|
||||
run := s.clone()
|
||||
ok := true
|
||||
for i := 0; i < prefix; i++ {
|
||||
if !steps[i].apply(&run) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
if err := checkInvariants(run); err != nil {
|
||||
t.Fatalf("%s after %s: %v\ntrace: %v", opName, steps[i].name, err, append(tr, opName+"/"+steps[i].name))
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
continue // precondition stopped the op; same as an early error
|
||||
}
|
||||
stepTrace := append(append(trace{}, tr...), fmt.Sprintf("%s..%s", opName, steps[prefix-1].name))
|
||||
|
||||
// Kill here: nothing else runs.
|
||||
onQuiescent(run.clone(), append(append(trace{}, stepTrace...), "KILL"))
|
||||
|
||||
// Error return here: the rollback runs, unless the op has committed;
|
||||
// the rollback itself can be killed at each of its step boundaries.
|
||||
if withRollback && prefix <= commitIndex {
|
||||
rb := rollbackSteps()
|
||||
for rbPrefix := 0; rbPrefix <= len(rb); rbPrefix++ {
|
||||
rbRun := run.clone()
|
||||
for i := 0; i < rbPrefix; i++ {
|
||||
rb[i].apply(&rbRun)
|
||||
if err := checkInvariants(rbRun); err != nil {
|
||||
t.Fatalf("%s rollback after %s: %v\ntrace: %v", opName, rb[i].name, err, stepTrace)
|
||||
}
|
||||
}
|
||||
suffix := "FAIL+rollback-killed"
|
||||
if rbPrefix == len(rb) {
|
||||
suffix = "FAIL+rollback-complete"
|
||||
}
|
||||
onQuiescent(rbRun.clone(), append(append(trace{}, stepTrace...), fmt.Sprintf("%s@%d", suffix, rbPrefix)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestECLifecycleModelExhaustive enumerates every crash and failure schedule
|
||||
// of encode and decode up to two chained interrupted operations, checks the
|
||||
// safety invariants in every intermediate state, and requires the prescribed
|
||||
// recovery to converge from every quiescent state.
|
||||
func TestECLifecycleModelExhaustive(t *testing.T) {
|
||||
start := modelState{replica: true}
|
||||
|
||||
visited := map[string]trace{}
|
||||
quiescent := 0
|
||||
schedules := 0
|
||||
var enqueue func(s modelState, depth int, tr trace)
|
||||
|
||||
checkRecovery := func(s modelState, tr trace) {
|
||||
schedules++
|
||||
if _, seen := visited["Q"+s.key()]; seen {
|
||||
return
|
||||
}
|
||||
visited["Q"+s.key()] = tr
|
||||
quiescent++
|
||||
if _, err := recoverModel(s.clone()); err != nil {
|
||||
t.Fatalf("recovery does not converge: %v\nstate: %s\ntrace: %v", err, s.key(), tr)
|
||||
}
|
||||
}
|
||||
|
||||
enqueue = func(s modelState, depth int, tr trace) {
|
||||
checkRecovery(s, tr)
|
||||
if depth == 0 {
|
||||
return
|
||||
}
|
||||
// A crash-restart may happen in any quiescent state before the next
|
||||
// operation; explore both with and without it.
|
||||
restarted := s.clone()
|
||||
reconcile(&restarted)
|
||||
if err := checkInvariants(restarted); err != nil {
|
||||
t.Fatalf("reconcile violated invariants: %v\nstate: %s\ntrace: %v", err, s.key(), tr)
|
||||
}
|
||||
for _, variant := range []struct {
|
||||
st modelState
|
||||
tag string
|
||||
}{{s, ""}, {restarted, "+restart"}} {
|
||||
st, tag := variant.st, variant.tag
|
||||
if st.replica {
|
||||
exploreOp(t, st, "encode"+tag, encodeSteps(), encodeCommitIndex(), true, tr, func(q modelState, qtr trace) {
|
||||
enqueue(q, depth-1, qtr)
|
||||
})
|
||||
}
|
||||
canDecode := false
|
||||
for _, g := range st.gens {
|
||||
if g.mounted && g.files >= modelDataShards {
|
||||
canDecode = true
|
||||
}
|
||||
}
|
||||
if canDecode {
|
||||
exploreOp(t, st, "decode"+tag, decodeSteps(), len(decodeSteps()), false, tr, func(q modelState, qtr trace) {
|
||||
enqueue(q, depth-1, qtr)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Depth 2: an interrupted operation followed by another interrupted
|
||||
// operation, recovery checked at every quiescent point in between and
|
||||
// after. Deeper chains only revisit already-explored states (the state
|
||||
// space is finite and memoized), which the visited count makes apparent.
|
||||
enqueue(start, 2, trace{"start"})
|
||||
|
||||
// Not spec constants — collapse guards: if a refactor of the model or the
|
||||
// explorer accidentally prunes schedules, these counts crater and this
|
||||
// catches it. Update deliberately when the model changes fidelity.
|
||||
if quiescent < 15 || schedules < 500 {
|
||||
t.Fatalf("suspiciously small exploration: %d distinct quiescent states from %d schedules", quiescent, schedules)
|
||||
}
|
||||
keys := make([]string, 0, len(visited))
|
||||
for k := range visited {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
t.Logf("explored %d schedules reaching %d distinct quiescent states, all recoverable", schedules, quiescent)
|
||||
}
|
||||
294
weed/ec/ec_rebalance_slots_test.go
Normal file
294
weed/ec/ec_rebalance_slots_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
)
|
||||
|
||||
// TestECRebalanceWithLimitedSlots tests that EC rebalance handles the scenario
|
||||
// where there are limited free slots on volume servers.
|
||||
//
|
||||
// This is a regression test for the error:
|
||||
//
|
||||
// "no free ec shard slots. only 0 left"
|
||||
//
|
||||
// Scenario (from real usage):
|
||||
// - 6 volume servers in 6 racks
|
||||
// - Each server has max=10 volume slots
|
||||
// - 7 volumes were EC encoded (7 × 14 = 98 EC shards)
|
||||
// - All 14 shards per volume are on the original server (not yet distributed)
|
||||
//
|
||||
// Expected behavior:
|
||||
// - The rebalance algorithm should distribute shards across servers
|
||||
// - Even if perfect distribution isn't possible, it should do best-effort
|
||||
// - Currently fails with "no free ec shard slots" because freeSlots calculation
|
||||
//
|
||||
// doesn't account for shards being moved (freed slots on source, used on target)
|
||||
func TestECRebalanceWithLimitedSlots(t *testing.T) {
|
||||
// Build a topology matching the problematic scenario:
|
||||
// 6 servers, each with 2+ volumes worth of EC shards (all 14 shards per volume on same server)
|
||||
topology := buildLimitedSlotsTopology()
|
||||
|
||||
// Collect EC nodes from the topology
|
||||
ecNodes, totalFreeEcSlots := CollectEcVolumeServersByDc(topology, "", types.HardDriveType)
|
||||
|
||||
t.Logf("Topology summary:")
|
||||
t.Logf(" Number of EC nodes: %d", len(ecNodes))
|
||||
t.Logf(" Total free EC slots: %d", totalFreeEcSlots)
|
||||
|
||||
// Log per-node details
|
||||
for _, node := range ecNodes {
|
||||
shardCount := 0
|
||||
for _, diskInfo := range node.Info.DiskInfos {
|
||||
for _, ecShard := range diskInfo.EcShardInfos {
|
||||
shardCount += erasure_coding.GetShardCount(ecShard)
|
||||
}
|
||||
}
|
||||
t.Logf(" Node %s (rack %s): %d shards, %d free slots",
|
||||
node.Info.Id, node.Rack, shardCount, node.FreeEcSlot)
|
||||
}
|
||||
|
||||
// Calculate total EC shards
|
||||
totalEcShards := 0
|
||||
for _, node := range ecNodes {
|
||||
for _, diskInfo := range node.Info.DiskInfos {
|
||||
for _, ecShard := range diskInfo.EcShardInfos {
|
||||
totalEcShards += erasure_coding.GetShardCount(ecShard)
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Logf(" Total EC shards: %d", totalEcShards)
|
||||
|
||||
// Document the issue:
|
||||
// With 98 EC shards (7 volumes × 14 shards) on 6 servers with max=10 each,
|
||||
// total capacity is 60 slots. But shards already occupy slots on their current servers.
|
||||
//
|
||||
// The current algorithm calculates free slots as:
|
||||
// freeSlots = maxVolumeCount - volumeCount - ecShardCount
|
||||
//
|
||||
// If all shards are on their original servers:
|
||||
// - Server A has 28 shards (2 volumes × 14) → may have negative free slots
|
||||
// - This causes totalFreeEcSlots to be 0 or negative
|
||||
//
|
||||
// The EXPECTED improvement:
|
||||
// - Rebalance should recognize that moving a shard FREES a slot on the source
|
||||
// - The algorithm should work iteratively, moving shards one at a time
|
||||
// - Even if starting with 0 free slots, moving one shard opens a slot
|
||||
|
||||
if totalFreeEcSlots < 1 {
|
||||
// This is the current (buggy) behavior we're documenting
|
||||
t.Logf("")
|
||||
t.Logf("KNOWN ISSUE: totalFreeEcSlots = %d (< 1)", totalFreeEcSlots)
|
||||
t.Logf("")
|
||||
t.Logf("This triggers the error: 'no free ec shard slots. only %d left'", totalFreeEcSlots)
|
||||
t.Logf("")
|
||||
t.Logf("Analysis:")
|
||||
t.Logf(" - %d EC shards across %d servers", totalEcShards, len(ecNodes))
|
||||
t.Logf(" - Shards are concentrated on original servers (not distributed)")
|
||||
t.Logf(" - Current slot calculation doesn't account for slots freed by moving shards")
|
||||
t.Logf("")
|
||||
t.Logf("Expected fix:")
|
||||
t.Logf(" 1. Rebalance should work iteratively, moving one shard at a time")
|
||||
t.Logf(" 2. Moving a shard from A to B: frees 1 slot on A, uses 1 slot on B")
|
||||
t.Logf(" 3. The 'free slots' check should be per-move, not global")
|
||||
t.Logf(" 4. Or: calculate 'redistributable slots' = total capacity - shards that must stay")
|
||||
|
||||
// For now, document this is a known issue - don't fail the test
|
||||
// When the fix is implemented, this test should be updated to verify the fix works
|
||||
return
|
||||
}
|
||||
|
||||
// If we get here, the issue might have been fixed
|
||||
t.Logf("totalFreeEcSlots = %d, rebalance should be possible", totalFreeEcSlots)
|
||||
}
|
||||
|
||||
// TestECRebalanceZeroFreeSlots tests the specific scenario where
|
||||
// the topology appears to have free slots but rebalance fails.
|
||||
//
|
||||
// This can happen when the VolumeCount in the topology includes the original
|
||||
// volumes that were EC-encoded, making the free slot calculation incorrect.
|
||||
func TestECRebalanceZeroFreeSlots(t *testing.T) {
|
||||
// Build a topology where volumes were NOT deleted after EC encoding
|
||||
// (VolumeCount still reflects the original volumes)
|
||||
topology := buildZeroFreeSlotTopology()
|
||||
|
||||
ecNodes, totalFreeEcSlots := CollectEcVolumeServersByDc(topology, "", types.HardDriveType)
|
||||
|
||||
t.Logf("Zero free slots scenario:")
|
||||
for _, node := range ecNodes {
|
||||
shardCount := 0
|
||||
for _, diskInfo := range node.Info.DiskInfos {
|
||||
for _, ecShard := range diskInfo.EcShardInfos {
|
||||
shardCount += erasure_coding.GetShardCount(ecShard)
|
||||
}
|
||||
}
|
||||
t.Logf(" Node %s: %d shards, %d free slots, volumeCount=%d, max=%d",
|
||||
node.Info.Id, shardCount, node.FreeEcSlot,
|
||||
node.Info.DiskInfos[string(types.HardDriveType)].VolumeCount,
|
||||
node.Info.DiskInfos[string(types.HardDriveType)].MaxVolumeCount)
|
||||
}
|
||||
t.Logf(" Total free slots: %d", totalFreeEcSlots)
|
||||
|
||||
if totalFreeEcSlots == 0 {
|
||||
t.Logf("")
|
||||
t.Logf("SCENARIO REPRODUCED: totalFreeEcSlots = 0")
|
||||
t.Logf("This would trigger: 'no free ec shard slots. only 0 left'")
|
||||
}
|
||||
}
|
||||
|
||||
// buildZeroFreeSlotTopology creates a topology where rebalance will fail
|
||||
// because servers are at capacity (volumeCount equals maxVolumeCount)
|
||||
func buildZeroFreeSlotTopology() *master_pb.TopologyInfo {
|
||||
diskTypeKey := string(types.HardDriveType)
|
||||
|
||||
// Each server has max=10, volumeCount=10 (full capacity)
|
||||
// Free capacity = (10-10) * 10 = 0 per server
|
||||
// This will trigger "no free ec shard slots" error
|
||||
return &master_pb.TopologyInfo{
|
||||
Id: "test_zero_free_slots",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
{
|
||||
Id: "rack0",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "127.0.0.1:8080",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
diskTypeKey: {
|
||||
Type: diskTypeKey,
|
||||
MaxVolumeCount: 10,
|
||||
VolumeCount: 10, // At full capacity
|
||||
EcShardInfos: buildEcShards([]uint32{3, 4}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "rack1",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "127.0.0.1:8081",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
diskTypeKey: {
|
||||
Type: diskTypeKey,
|
||||
MaxVolumeCount: 10,
|
||||
VolumeCount: 10,
|
||||
EcShardInfos: buildEcShards([]uint32{1, 7}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "rack2",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "127.0.0.1:8082",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
diskTypeKey: {
|
||||
Type: diskTypeKey,
|
||||
MaxVolumeCount: 10,
|
||||
VolumeCount: 10,
|
||||
EcShardInfos: buildEcShards([]uint32{2}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: "rack3",
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: "127.0.0.1:8083",
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
diskTypeKey: {
|
||||
Type: diskTypeKey,
|
||||
MaxVolumeCount: 10,
|
||||
VolumeCount: 10,
|
||||
EcShardInfos: buildEcShards([]uint32{5, 6}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildEcShards(volumeIds []uint32) []*master_pb.VolumeEcShardInformationMessage {
|
||||
var shards []*master_pb.VolumeEcShardInformationMessage
|
||||
for _, vid := range volumeIds {
|
||||
si := erasure_coding.NewShardsInfo()
|
||||
for _, id := range erasure_coding.AllShardIds() {
|
||||
si.Set(erasure_coding.NewShardInfo(id, 1234))
|
||||
}
|
||||
shards = append(shards, &master_pb.VolumeEcShardInformationMessage{
|
||||
Id: vid,
|
||||
Collection: "ectest",
|
||||
EcIndexBits: si.Bitmap(),
|
||||
ShardSizes: si.SizesInt64(),
|
||||
})
|
||||
}
|
||||
return shards
|
||||
}
|
||||
|
||||
// buildLimitedSlotsTopology creates a topology matching the problematic scenario:
|
||||
// - 6 servers in 6 racks
|
||||
// - Each server has max=10 volume slots
|
||||
// - 7 volumes were EC encoded, shards distributed as follows:
|
||||
// - rack0 (8080): volumes 3,4 → 28 shards
|
||||
// - rack1 (8081): volumes 1,7 → 28 shards
|
||||
// - rack2 (8082): volume 2 → 14 shards
|
||||
// - rack3 (8083): volumes 5,6 → 28 shards
|
||||
// - rack4 (8084): (no volumes originally)
|
||||
// - rack5 (8085): (no volumes originally)
|
||||
func buildLimitedSlotsTopology() *master_pb.TopologyInfo {
|
||||
return &master_pb.TopologyInfo{
|
||||
Id: "test_limited_slots",
|
||||
DataCenterInfos: []*master_pb.DataCenterInfo{
|
||||
{
|
||||
Id: "dc1",
|
||||
RackInfos: []*master_pb.RackInfo{
|
||||
buildRackWithEcShards("rack0", "127.0.0.1:8080", 10, []uint32{3, 4}),
|
||||
buildRackWithEcShards("rack1", "127.0.0.1:8081", 10, []uint32{1, 7}),
|
||||
buildRackWithEcShards("rack2", "127.0.0.1:8082", 10, []uint32{2}),
|
||||
buildRackWithEcShards("rack3", "127.0.0.1:8083", 10, []uint32{5, 6}),
|
||||
buildRackWithEcShards("rack4", "127.0.0.1:8084", 10, []uint32{}),
|
||||
buildRackWithEcShards("rack5", "127.0.0.1:8085", 10, []uint32{}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildRackWithEcShards creates a rack with one data node containing EC shards
|
||||
// for the specified volume IDs (all 14 shards per volume)
|
||||
func buildRackWithEcShards(rackId, nodeId string, maxVolumes int64, volumeIds []uint32) *master_pb.RackInfo {
|
||||
// Note: types.HardDriveType is "" (empty string), so we use "" as the key
|
||||
diskTypeKey := string(types.HardDriveType)
|
||||
|
||||
return &master_pb.RackInfo{
|
||||
Id: rackId,
|
||||
DataNodeInfos: []*master_pb.DataNodeInfo{
|
||||
{
|
||||
Id: nodeId,
|
||||
DiskInfos: map[string]*master_pb.DiskInfo{
|
||||
diskTypeKey: {
|
||||
Type: diskTypeKey,
|
||||
MaxVolumeCount: maxVolumes,
|
||||
VolumeCount: int64(len(volumeIds)), // Original volumes still counted
|
||||
EcShardInfos: buildEcShards(volumeIds),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
435
weed/ec/ec_rebuild.go
Normal file
435
weed/ec/ec_rebuild.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"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/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/types"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
type ecRebuilder struct {
|
||||
env *Env
|
||||
ecNodes []*EcNode
|
||||
writer io.Writer
|
||||
applyChanges bool
|
||||
collections []string
|
||||
volumeIds []needle.VolumeId
|
||||
diskType types.DiskType
|
||||
|
||||
ewg *util.ErrorWaitGroup
|
||||
ecNodesMu sync.Mutex
|
||||
}
|
||||
|
||||
// RebuildEcVolumes finds and rebuilds missing EC shards for the given
|
||||
// collections. Before rebuilding it asks volume servers to recover any shards
|
||||
// left unmounted by a missing .ecx index; such shards are invisible to the
|
||||
// master, so recovering them first avoids regenerating data that is actually
|
||||
// present. volumeIds, when non-empty, restricts the rebuild to those volumes.
|
||||
func RebuildEcVolumes(env *Env, ecNodes []*EcNode, writer io.Writer, collections []string, volumeIds []needle.VolumeId, diskType types.DiskType, maxParallelization int, applyChanges bool) error {
|
||||
erb := &ecRebuilder{
|
||||
env: env,
|
||||
ecNodes: ecNodes,
|
||||
writer: writer,
|
||||
applyChanges: applyChanges,
|
||||
collections: collections,
|
||||
volumeIds: volumeIds,
|
||||
diskType: diskType,
|
||||
|
||||
ewg: util.NewErrorWaitGroup(maxParallelization),
|
||||
}
|
||||
|
||||
// Recover shards left unmounted by a missing .ecx index before planning: such
|
||||
// shards never register with the master, so the rebuild below would treat the
|
||||
// volume as short or unrepairable even though its data is intact (issue #10104).
|
||||
erb.recoverMissingIndexes()
|
||||
|
||||
fmt.Printf("rebuildEcVolumes for %d collection(s)\n", len(collections))
|
||||
for _, c := range collections {
|
||||
erb.rebuildEcVolumes(c)
|
||||
}
|
||||
|
||||
return erb.ewg.Wait()
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) write(format string, a ...any) {
|
||||
fmt.Fprintf(erb.writer, format, a...)
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) isLocked() bool {
|
||||
return erb.env.isLocked()
|
||||
}
|
||||
|
||||
// matchesVolumeId verifies whether the rebuilder is targeted at a given volume ID.
|
||||
func (erb *ecRebuilder) matchesVolumeId(vid needle.VolumeId) bool {
|
||||
if len(erb.volumeIds) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(erb.volumeIds, vid)
|
||||
}
|
||||
|
||||
// countLocalShards returns the number of shards already present locally on the node for the given volume.
|
||||
// Unions across all of the node's disks, like prepareDataToRecover, so slot
|
||||
// accounting matches what the rebuild will actually treat as local.
|
||||
func (erb *ecRebuilder) countLocalShards(node *EcNode, collection string, volumeId needle.VolumeId) int {
|
||||
localShardsInfo := erasure_coding.NewShardsInfo()
|
||||
for _, diskInfo := range node.Info.DiskInfos {
|
||||
for _, ecShardInfo := range diskInfo.EcShardInfos {
|
||||
if ecShardInfo.Collection == collection && needle.VolumeId(ecShardInfo.Id) == volumeId {
|
||||
localShardsInfo.Add(erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(ecShardInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
return localShardsInfo.Count()
|
||||
}
|
||||
|
||||
// selectAndReserveRebuilder atomically selects a rebuilder node with sufficient free slots
|
||||
// and reserves slots only for the non-local shards that need to be copied/generated.
|
||||
func (erb *ecRebuilder) selectAndReserveRebuilder(collection string, volumeId needle.VolumeId) (*EcNode, int, error) {
|
||||
erb.ecNodesMu.Lock()
|
||||
defer erb.ecNodesMu.Unlock()
|
||||
|
||||
if len(erb.ecNodes) == 0 {
|
||||
return nil, 0, fmt.Errorf("no ec nodes available")
|
||||
}
|
||||
|
||||
// Find the node with the most free slots, considering local shards
|
||||
var bestNode *EcNode
|
||||
var bestSlotsNeeded int
|
||||
var maxAvailableSlots int
|
||||
var minSlotsNeeded int = erasure_coding.TotalShardsCount // Start with maximum possible
|
||||
for _, node := range erb.ecNodes {
|
||||
localShards := erb.countLocalShards(node, collection, volumeId)
|
||||
slotsNeeded := erasure_coding.TotalShardsCount - localShards
|
||||
if slotsNeeded < 0 {
|
||||
slotsNeeded = 0
|
||||
}
|
||||
|
||||
if node.FreeEcSlot > maxAvailableSlots {
|
||||
maxAvailableSlots = node.FreeEcSlot
|
||||
}
|
||||
|
||||
if slotsNeeded < minSlotsNeeded {
|
||||
minSlotsNeeded = slotsNeeded
|
||||
}
|
||||
|
||||
if node.FreeEcSlot >= slotsNeeded {
|
||||
if bestNode == nil || node.FreeEcSlot > bestNode.FreeEcSlot {
|
||||
bestNode = node
|
||||
bestSlotsNeeded = slotsNeeded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestNode == nil {
|
||||
return nil, 0, fmt.Errorf("no node has sufficient free slots for volume %d (need at least %d slots, max available: %d)",
|
||||
volumeId, minSlotsNeeded, maxAvailableSlots)
|
||||
}
|
||||
|
||||
// Reserve slots only for non-local shards
|
||||
bestNode.FreeEcSlot -= bestSlotsNeeded
|
||||
|
||||
return bestNode, bestSlotsNeeded, nil
|
||||
}
|
||||
|
||||
// releaseRebuilder releases the reserved slots back to the rebuilder node.
|
||||
func (erb *ecRebuilder) releaseRebuilder(node *EcNode, slotsToRelease int) {
|
||||
erb.ecNodesMu.Lock()
|
||||
defer erb.ecNodesMu.Unlock()
|
||||
|
||||
// Release slots by incrementing the free slot count
|
||||
node.FreeEcSlot += slotsToRelease
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) rebuildEcVolumes(collection string) {
|
||||
fmt.Printf("rebuildEcVolumes for %q\n", collection)
|
||||
|
||||
// collect vid => each shard locations, similar to ecShardMap in topology.go
|
||||
ecShardMap := make(EcShardMap)
|
||||
erb.ecNodesMu.Lock()
|
||||
for _, ecNode := range erb.ecNodes {
|
||||
ecShardMap.registerEcNode(ecNode, collection)
|
||||
}
|
||||
erb.ecNodesMu.Unlock()
|
||||
|
||||
for vid, locations := range ecShardMap {
|
||||
if !erb.matchesVolumeId(vid) {
|
||||
continue
|
||||
}
|
||||
shardCount := locations.shardCount()
|
||||
if shardCount == erasure_coding.TotalShardsCount {
|
||||
continue
|
||||
}
|
||||
if shardCount < erasure_coding.DataShardsCount {
|
||||
erb.write("ec volume %d is unrepairable with %d shards (need %d), skipping\n", vid, shardCount, erasure_coding.DataShardsCount)
|
||||
continue
|
||||
}
|
||||
|
||||
// Capture variables for closure
|
||||
vid := vid
|
||||
locations := locations
|
||||
|
||||
erb.ewg.Add(func() error {
|
||||
// Select rebuilder and reserve slots atomically per volume
|
||||
rebuilder, slotsToReserve, err := erb.selectAndReserveRebuilder(collection, vid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select rebuilder for volume %d: %v", vid, err)
|
||||
}
|
||||
defer erb.releaseRebuilder(rebuilder, slotsToReserve)
|
||||
|
||||
return erb.rebuildOneEcVolume(collection, vid, locations, rebuilder)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// recoverMissingIndexes asks every ec node to fetch a missing .ecx index from a
|
||||
// peer and mount the on-disk shards it could not load on its own. Shards
|
||||
// orphaned this way (index only on another server) are absent from the master
|
||||
// topology, so without this pass ec.rebuild would regenerate or give up on
|
||||
// shards whose data is actually present — and a volume whose every holder lacks
|
||||
// the index would not appear in the topology at all. Each node therefore
|
||||
// recovers all of its on-disk orphans (volume_id 0); an explicit -volumeIds
|
||||
// list narrows that to the requested volumes. On apply it refreshes the topology
|
||||
// so the rebuild planning sees the recovered shards (issue #10104).
|
||||
func (erb *ecRebuilder) recoverMissingIndexes() {
|
||||
erb.ecNodesMu.Lock()
|
||||
nodes := append([]*EcNode(nil), erb.ecNodes...)
|
||||
erb.ecNodesMu.Unlock()
|
||||
if len(nodes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// volume_id 0 means "recover every orphan on the node"; a -volumeIds list
|
||||
// narrows recovery to those ids (each scanned across collections server-side).
|
||||
vids := erb.volumeIds
|
||||
if len(vids) == 0 {
|
||||
vids = []needle.VolumeId{0}
|
||||
}
|
||||
|
||||
if !erb.applyChanges {
|
||||
erb.write("would ask %d ec node(s) to recover EC shards left unmounted by a missing .ecx index\n", len(nodes))
|
||||
return
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
for _, vid := range vids {
|
||||
err := operation.WithVolumeServerClient(false, pb.NewServerAddressFromDataNode(node.Info), erb.env.GrpcDialOption, func(client volume_server_pb.VolumeServerClient) error {
|
||||
_, mountErr := client.VolumeEcShardsMount(context.Background(), &volume_server_pb.VolumeEcShardsMountRequest{
|
||||
VolumeId: uint32(vid),
|
||||
RecoverMissingIndex: true,
|
||||
})
|
||||
return mountErr
|
||||
})
|
||||
if err != nil {
|
||||
erb.write("%s recover missing index (volume %d): %v\n", node.Info.Id, vid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh topology so the rebuild planning sees shards the recovery registered.
|
||||
refreshed, _, err := CollectEcNodes(erb.env, erb.diskType)
|
||||
if err != nil {
|
||||
erb.write("failed to refresh ec nodes after index recovery: %v\n", err)
|
||||
return
|
||||
}
|
||||
erb.ecNodesMu.Lock()
|
||||
erb.ecNodes = refreshed
|
||||
erb.ecNodesMu.Unlock()
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) rebuildOneEcVolume(collection string, volumeId needle.VolumeId, locations EcShardLocations, rebuilder *EcNode) error {
|
||||
if !erb.isLocked() {
|
||||
return fmt.Errorf("lock is lost")
|
||||
}
|
||||
|
||||
fmt.Printf("rebuildOneEcVolume %s %d\n", collection, volumeId)
|
||||
|
||||
// collect shard files to rebuilder local disk
|
||||
var generatedShardIds []erasure_coding.ShardId
|
||||
copiedShardIds, _, err := erb.prepareDataToRecover(rebuilder, collection, volumeId, locations)
|
||||
defer func() {
|
||||
// Clean up the working copies this run actually made, even when the
|
||||
// recoverability gate failed after some copies already succeeded:
|
||||
// they are temp files on the rebuilder nothing else reclaims. Dry-run
|
||||
// copies nothing (copiedShardIds is empty), so this issues no delete
|
||||
// RPC. Use a local error so a cleanup failure cannot mask the return.
|
||||
if !erb.applyChanges || len(copiedShardIds) == 0 {
|
||||
return
|
||||
}
|
||||
if derr := SourceServerDeleteEcShards(erb.env.GrpcDialOption, collection, volumeId, pb.NewServerAddressFromDataNode(rebuilder.Info), copiedShardIds); derr != nil {
|
||||
erb.write("%s delete copied ec shards %s %d.%v: %v\n", rebuilder.Info.Id, collection, volumeId, copiedShardIds, derr)
|
||||
}
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !erb.applyChanges {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generate ec shards, and maybe ecx file
|
||||
generatedShardIds, err = erb.generateMissingShards(collection, volumeId, pb.NewServerAddressFromDataNode(rebuilder.Info))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// mount the generated shards
|
||||
err = MountEcShards(erb.env.GrpcDialOption, collection, volumeId, pb.NewServerAddressFromDataNode(rebuilder.Info), generatedShardIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ensure ECNode updates are atomic
|
||||
erb.ecNodesMu.Lock()
|
||||
defer erb.ecNodesMu.Unlock()
|
||||
rebuilder.AddEcVolumeShards(volumeId, collection, generatedShardIds, erb.diskType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) generateMissingShards(collection string, volumeId needle.VolumeId, sourceLocation pb.ServerAddress) (rebuiltShardIds []erasure_coding.ShardId, err error) {
|
||||
|
||||
err = operation.WithVolumeServerClient(false, sourceLocation, erb.env.GrpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
resp, rebuildErr := volumeServerClient.VolumeEcShardsRebuild(context.Background(), &volume_server_pb.VolumeEcShardsRebuildRequest{
|
||||
VolumeId: uint32(volumeId),
|
||||
Collection: collection,
|
||||
})
|
||||
if rebuildErr == nil {
|
||||
rebuiltShardIds = erasure_coding.Uint32ToShardIds(resp.RebuiltShardIds)
|
||||
}
|
||||
return rebuildErr
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func (erb *ecRebuilder) prepareDataToRecover(rebuilder *EcNode, collection string, volumeId needle.VolumeId, locations EcShardLocations) (copiedShardIds []erasure_coding.ShardId, localShardIds []erasure_coding.ShardId, err error) {
|
||||
|
||||
needEcxFile := true
|
||||
localShardsInfo := erasure_coding.NewShardsInfo()
|
||||
for _, diskInfo := range rebuilder.Info.DiskInfos {
|
||||
for _, ecShardInfo := range diskInfo.EcShardInfos {
|
||||
if ecShardInfo.Collection == collection && needle.VolumeId(ecShardInfo.Id) == volumeId {
|
||||
needEcxFile = false
|
||||
// Union across disks: the rebuilder may hold this volume's
|
||||
// shards on more than one disk. Overwriting per-disk would
|
||||
// make a shard on a non-last disk look remote and get copied
|
||||
// onto itself (O_TRUNC) and then node-wide deleted.
|
||||
localShardsInfo.Add(erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(ecShardInfo))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetShardCount := erasure_coding.TotalShardsCount
|
||||
for i := erasure_coding.TotalShardsCount; i < len(locations); i++ {
|
||||
if len(locations[i]) > 0 {
|
||||
targetShardCount = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
// recoverableRemoteShards counts remote shards that can contribute to the
|
||||
// rebuild. Dry-run counts the plan; apply mode counts only successful copies.
|
||||
recoverableRemoteShards := 0
|
||||
for i := 0; i < targetShardCount; i++ {
|
||||
ecNodes := locations[i]
|
||||
shardId := erasure_coding.ShardId(i)
|
||||
if len(ecNodes) == 0 {
|
||||
erb.write("missing shard %d.%d\n", volumeId, shardId)
|
||||
continue
|
||||
}
|
||||
|
||||
if localShardsInfo.Has(shardId) {
|
||||
localShardIds = append(localShardIds, shardId)
|
||||
erb.write("use existing shard %d.%d\n", volumeId, shardId)
|
||||
continue
|
||||
}
|
||||
|
||||
// The rebuilder is itself the only listed holder: never copy a shard
|
||||
// onto itself (the in-place O_TRUNC would destroy it) nor schedule it
|
||||
// for the post-rebuild delete. Treat it as already local.
|
||||
if ecNodes[0].Info.Id == rebuilder.Info.Id {
|
||||
localShardIds = append(localShardIds, shardId)
|
||||
erb.write("use existing shard %d.%d (already on rebuilder)\n", volumeId, shardId)
|
||||
continue
|
||||
}
|
||||
|
||||
if !erb.applyChanges {
|
||||
recoverableRemoteShards++
|
||||
erb.write("would copy %d.%d from %s\n", volumeId, shardId, ecNodes[0].Info.Id)
|
||||
continue
|
||||
}
|
||||
|
||||
copyErr := operation.WithVolumeServerClient(false, pb.NewServerAddressFromDataNode(rebuilder.Info), erb.env.GrpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
_, copyErr := volumeServerClient.VolumeEcShardsCopy(context.Background(), &volume_server_pb.VolumeEcShardsCopyRequest{
|
||||
VolumeId: uint32(volumeId),
|
||||
Collection: collection,
|
||||
ShardIds: []uint32{uint32(shardId)},
|
||||
CopyEcxFile: needEcxFile,
|
||||
CopyEcjFile: true,
|
||||
CopyVifFile: needEcxFile,
|
||||
SourceDataNode: string(pb.NewServerAddressFromDataNode(ecNodes[0].Info)),
|
||||
})
|
||||
return copyErr
|
||||
})
|
||||
if copyErr != nil {
|
||||
erb.write("%s failed to copy %d.%d from %s: %v\n", rebuilder.Info.Id, volumeId, shardId, ecNodes[0].Info.Id, copyErr)
|
||||
continue
|
||||
}
|
||||
recoverableRemoteShards++
|
||||
if needEcxFile {
|
||||
needEcxFile = false
|
||||
}
|
||||
erb.write("%s copied %d.%d from %s\n", rebuilder.Info.Id, volumeId, shardId, ecNodes[0].Info.Id)
|
||||
// Only shards this run actually copied are temp working files to be
|
||||
// deleted afterward; never a pre-existing local or remote shard.
|
||||
copiedShardIds = append(copiedShardIds, shardId)
|
||||
}
|
||||
|
||||
if len(localShardIds)+recoverableRemoteShards >= erasure_coding.DataShardsCount {
|
||||
return copiedShardIds, localShardIds, nil
|
||||
}
|
||||
|
||||
// Hand back what was copied so the caller deletes these orphaned working
|
||||
// shards: recovery failed, but the temp files are already on the rebuilder.
|
||||
return copiedShardIds, localShardIds, fmt.Errorf("%d shards are not enough to recover volume %d", len(localShardIds)+recoverableRemoteShards, volumeId)
|
||||
|
||||
}
|
||||
|
||||
type EcShardMap map[needle.VolumeId]EcShardLocations
|
||||
type EcShardLocations [][]*EcNode
|
||||
|
||||
func (ecShardMap EcShardMap) registerEcNode(ecNode *EcNode, collection string) {
|
||||
for _, diskInfo := range ecNode.Info.DiskInfos {
|
||||
for _, shardInfo := range diskInfo.EcShardInfos {
|
||||
if shardInfo.Collection == collection {
|
||||
existing, found := ecShardMap[needle.VolumeId(shardInfo.Id)]
|
||||
if !found {
|
||||
// Use MaxShardCount (32) to support custom EC ratios
|
||||
existing = make([][]*EcNode, erasure_coding.MaxShardCount)
|
||||
ecShardMap[needle.VolumeId(shardInfo.Id)] = existing
|
||||
}
|
||||
for _, shardId := range erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(shardInfo).Ids() {
|
||||
existing[shardId] = append(existing[shardId], ecNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ecShardLocations EcShardLocations) shardCount() (count int) {
|
||||
for _, locations := range ecShardLocations {
|
||||
if len(locations) > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
324
weed/ec/ec_rebuild_test.go
Normal file
324
weed/ec/ec_rebuild_test.go
Normal file
@@ -0,0 +1,324 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/needle"
|
||||
"github.com/seaweedfs/seaweedfs/weed/util"
|
||||
)
|
||||
|
||||
// TestEcShardMapRegister tests that EC shards are properly registered
|
||||
func TestEcShardMapRegister(t *testing.T) {
|
||||
ecShardMap := make(EcShardMap)
|
||||
|
||||
// Create test nodes with EC shards
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6})
|
||||
node2 := newEcNode("dc1", "rack1", "node2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{7, 8, 9, 10, 11, 12, 13})
|
||||
|
||||
ecShardMap.registerEcNode(node1, "c1")
|
||||
ecShardMap.registerEcNode(node2, "c1")
|
||||
|
||||
// Verify volume 1 is registered
|
||||
locations, found := ecShardMap[needle.VolumeId(1)]
|
||||
if !found {
|
||||
t.Fatal("Expected volume 1 to be registered")
|
||||
}
|
||||
|
||||
// Check shard count
|
||||
count := locations.shardCount()
|
||||
if count != erasure_coding.TotalShardsCount {
|
||||
t.Errorf("Expected %d shards, got %d", erasure_coding.TotalShardsCount, count)
|
||||
}
|
||||
|
||||
// Verify shard distribution
|
||||
for i := 0; i < 7; i++ {
|
||||
if len(locations[i]) != 1 || locations[i][0].Info.Id != "node1" {
|
||||
t.Errorf("Shard %d should be on node1", i)
|
||||
}
|
||||
}
|
||||
for i := 7; i < erasure_coding.TotalShardsCount; i++ {
|
||||
if len(locations[i]) != 1 || locations[i][0].Info.Id != "node2" {
|
||||
t.Errorf("Shard %d should be on node2", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcShardMapShardCount tests shard counting
|
||||
func TestEcShardMapShardCount(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
shardIds []erasure_coding.ShardId
|
||||
expectedCount int
|
||||
}{
|
||||
{"all shards", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}, 14},
|
||||
{"data shards only", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 10},
|
||||
{"parity shards only", []erasure_coding.ShardId{10, 11, 12, 13}, 4},
|
||||
{"missing some shards", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8}, 9},
|
||||
{"single shard", []erasure_coding.ShardId{0}, 1},
|
||||
{"no shards", []erasure_coding.ShardId{}, 0},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for _, shardId := range tc.shardIds {
|
||||
locations[shardId] = []*EcNode{
|
||||
newEcNode("dc1", "rack1", "node1", 100),
|
||||
}
|
||||
}
|
||||
|
||||
count := locations.shardCount()
|
||||
if count != tc.expectedCount {
|
||||
t.Errorf("Expected %d shards, got %d", tc.expectedCount, count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildEcVolumesInsufficientShards tests that unrepairable volumes are skipped
|
||||
func TestRebuildEcVolumesInsufficientShards(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a volume with insufficient shards (less than DataShardsCount)
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4}) // Only 5 shards
|
||||
|
||||
erb := &ecRebuilder{
|
||||
env: &Env{},
|
||||
ewg: util.NewErrorWaitGroup(10),
|
||||
ecNodes: []*EcNode{node1},
|
||||
writer: &logBuffer,
|
||||
}
|
||||
|
||||
erb.rebuildEcVolumes("c1")
|
||||
err := erb.ewg.Wait()
|
||||
|
||||
// Unrepairable volumes should be skipped (not cause an error)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for unrepairable volume (should be skipped), got: %v", err)
|
||||
}
|
||||
// Verify the skip message was logged
|
||||
if !strings.Contains(logBuffer.String(), "unrepairable") {
|
||||
t.Errorf("Expected 'unrepairable' in log output, got: %s", logBuffer.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRebuildEcVolumesCompleteVolume tests that complete volumes are skipped
|
||||
func TestRebuildEcVolumesCompleteVolume(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a volume with all shards
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13})
|
||||
|
||||
erb := &ecRebuilder{
|
||||
env: &Env{},
|
||||
ewg: util.NewErrorWaitGroup(10),
|
||||
ecNodes: []*EcNode{node1},
|
||||
writer: &logBuffer,
|
||||
applyChanges: false,
|
||||
}
|
||||
|
||||
erb.rebuildEcVolumes("c1")
|
||||
err := erb.ewg.Wait()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error for complete volume, got: %v", err)
|
||||
}
|
||||
|
||||
// The function should return quickly without attempting rebuild
|
||||
// since the volume is already complete
|
||||
}
|
||||
|
||||
// TestRebuildEcVolumesInsufficientSpace tests error handling for insufficient disk space
|
||||
func TestRebuildEcVolumesInsufficientSpace(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a volume with missing shards but insufficient free slots
|
||||
// Node has 10 local shards, missing 4 shards (10,11,12,13), so needs 4 free slots
|
||||
// Set free slots to 3 (insufficient)
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 3). // Only 3 free slots, need 4
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
|
||||
|
||||
erb := &ecRebuilder{
|
||||
env: &Env{},
|
||||
ewg: util.NewErrorWaitGroup(10),
|
||||
ecNodes: []*EcNode{node1},
|
||||
writer: &logBuffer,
|
||||
applyChanges: false,
|
||||
}
|
||||
|
||||
erb.rebuildEcVolumes("c1")
|
||||
err := erb.ewg.Wait()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for insufficient disk space, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no node has sufficient free slots") {
|
||||
t.Errorf("Expected 'no node has sufficient free slots' in error message, got: %s", err.Error())
|
||||
}
|
||||
// Verify the enhanced error message includes diagnostic information
|
||||
if !strings.Contains(err.Error(), "need") || !strings.Contains(err.Error(), "max available") {
|
||||
t.Errorf("Expected diagnostic information in error message, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleNodesWithShards tests rebuild with shards distributed across multiple nodes
|
||||
func TestMultipleNodesWithShards(t *testing.T) {
|
||||
ecShardMap := make(EcShardMap)
|
||||
|
||||
// Create 3 nodes with different shards
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3})
|
||||
node2 := newEcNode("dc1", "rack1", "node2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{4, 5, 6, 7})
|
||||
node3 := newEcNode("dc1", "rack1", "node3", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{8, 9})
|
||||
|
||||
ecShardMap.registerEcNode(node1, "c1")
|
||||
ecShardMap.registerEcNode(node2, "c1")
|
||||
ecShardMap.registerEcNode(node3, "c1")
|
||||
|
||||
locations := ecShardMap[needle.VolumeId(1)]
|
||||
count := locations.shardCount()
|
||||
|
||||
// We have 10 shards total, which is enough for data shards
|
||||
if count != 10 {
|
||||
t.Errorf("Expected 10 shards, got %d", count)
|
||||
}
|
||||
|
||||
// Verify each shard is on the correct node
|
||||
for i := 0; i < 4; i++ {
|
||||
if len(locations[i]) != 1 || locations[i][0].Info.Id != "node1" {
|
||||
t.Errorf("Shard %d should be on node1", i)
|
||||
}
|
||||
}
|
||||
for i := 4; i < 8; i++ {
|
||||
if len(locations[i]) != 1 || locations[i][0].Info.Id != "node2" {
|
||||
t.Errorf("Shard %d should be on node2", i)
|
||||
}
|
||||
}
|
||||
for i := 8; i < 10; i++ {
|
||||
if len(locations[i]) != 1 || locations[i][0].Info.Id != "node3" {
|
||||
t.Errorf("Shard %d should be on node3", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDuplicateShards tests handling of duplicate shards on multiple nodes
|
||||
func TestDuplicateShards(t *testing.T) {
|
||||
ecShardMap := make(EcShardMap)
|
||||
|
||||
// Create 2 nodes with overlapping shards (both have shard 0)
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3})
|
||||
node2 := newEcNode("dc1", "rack1", "node2", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 4, 5, 6}) // Duplicate shard 0
|
||||
|
||||
ecShardMap.registerEcNode(node1, "c1")
|
||||
ecShardMap.registerEcNode(node2, "c1")
|
||||
|
||||
locations := ecShardMap[needle.VolumeId(1)]
|
||||
|
||||
// Shard 0 should be on both nodes
|
||||
if len(locations[0]) != 2 {
|
||||
t.Errorf("Expected shard 0 on 2 nodes, got %d", len(locations[0]))
|
||||
}
|
||||
|
||||
// Verify both nodes are registered for shard 0
|
||||
foundNode1 := false
|
||||
foundNode2 := false
|
||||
for _, node := range locations[0] {
|
||||
if node.Info.Id == "node1" {
|
||||
foundNode1 = true
|
||||
}
|
||||
if node.Info.Id == "node2" {
|
||||
foundNode2 = true
|
||||
}
|
||||
}
|
||||
if !foundNode1 || !foundNode2 {
|
||||
t.Error("Both nodes should have shard 0")
|
||||
}
|
||||
|
||||
// Shard count should be 7 (unique shards: 0, 1, 2, 3, 4, 5, 6)
|
||||
count := locations.shardCount()
|
||||
if count != 7 {
|
||||
t.Errorf("Expected 7 unique shards, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataToRecoverTargetShardCount tests that prepareDataToRecover caps the shard reporting
|
||||
func TestPrepareDataToRecoverTargetShardCount(t *testing.T) {
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
// Create a node with 10 shards (0-9)
|
||||
node1 := newEcNode("dc1", "rack1", "node1", 100).
|
||||
addEcVolumeAndShardsForTest(1, "c1", []erasure_coding.ShardId{0, 1, 2, 3, 4, 5, 6, 7, 8, 9})
|
||||
|
||||
erb := &ecRebuilder{
|
||||
env: &Env{},
|
||||
ecNodes: []*EcNode{node1},
|
||||
writer: &logBuffer,
|
||||
}
|
||||
|
||||
locations := make(EcShardLocations, erasure_coding.MaxShardCount)
|
||||
for i := 0; i < 10; i++ {
|
||||
locations[i] = []*EcNode{node1}
|
||||
}
|
||||
|
||||
// Shards 10-13 are missing, but 14-31 should NOT be reported
|
||||
_, _, err := erb.prepareDataToRecover(node1, "c1", needle.VolumeId(1), locations)
|
||||
if err != nil {
|
||||
t.Fatalf("prepareDataToRecover failed: %v", err)
|
||||
}
|
||||
|
||||
output := logBuffer.String()
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
|
||||
// We expect "use existing shard 1.0" through "use existing shard 1.9" (10 lines)
|
||||
// and "missing shard 1.10" through "missing shard 1.13" (4 lines)
|
||||
// Total 14 lines expected. Shards 14-31 should be absent.
|
||||
expectedLineCount := 14
|
||||
if len(lines) != expectedLineCount {
|
||||
t.Errorf("Expected %d lines of output, got %d. Output:\n%s", expectedLineCount, len(lines), output)
|
||||
}
|
||||
|
||||
for i := 14; i < erasure_coding.MaxShardCount; i++ {
|
||||
if strings.Contains(output, "shard 1."+strings.TrimSpace(string(rune('0'+i)))) ||
|
||||
strings.Contains(output, "shard 1."+fmt.Sprintf("%d", i)) {
|
||||
t.Errorf("Shard 1.%d should not be reported in output", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuilderVolumeIdMatches(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
rebuilderVolumeIds []needle.VolumeId
|
||||
volumeId needle.VolumeId
|
||||
want bool
|
||||
}{
|
||||
{"no target volume IDs (nil)", nil, needle.VolumeId(1234), true},
|
||||
{"no target volume IDs", []needle.VolumeId{}, needle.VolumeId(5678), true},
|
||||
{"volume ID matches", []needle.VolumeId{needle.VolumeId(123), needle.VolumeId(456), needle.VolumeId(789)}, needle.VolumeId(456), true},
|
||||
{"volume ID mismatch", []needle.VolumeId{needle.VolumeId(123), needle.VolumeId(456), needle.VolumeId(789)}, needle.VolumeId(321), false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ecb := &ecRebuilder{
|
||||
volumeIds: tc.rebuilderVolumeIds,
|
||||
}
|
||||
|
||||
if got, want := ecb.matchesVolumeId(tc.volumeId), tc.want; got != want {
|
||||
t.Errorf("Expected %v, got %v", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
81
weed/ec/ec_scrub.go
Normal file
81
weed/ec/ec_scrub.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"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/util"
|
||||
)
|
||||
|
||||
// ScrubEcVolumes asks each volume server to scrub its EC volumes (optionally
|
||||
// narrowed to volumeIds) in the given mode, aggregating and reporting results.
|
||||
func ScrubEcVolumes(env *Env, writer io.Writer, volumeServerAddrs []pb.ServerAddress, volumeIds []uint32, mode volume_server_pb.VolumeScrubMode, forceDeletedNeedlesCheck bool, maxParallelization int, showDetails bool) error {
|
||||
var brokenVolumesStr, brokenShardsStr []string
|
||||
var details []string
|
||||
var totalVolumes, brokenVolumes, brokenShards, totalFiles uint64
|
||||
var mu sync.Mutex
|
||||
|
||||
ewg := util.NewErrorWaitGroup(maxParallelization)
|
||||
count := 0
|
||||
for _, addr := range volumeServerAddrs {
|
||||
ewg.Add(func() error {
|
||||
mu.Lock()
|
||||
count++
|
||||
fmt.Fprintf(writer, "Scrubbing %s (%d/%d)...\n", addr.String(), count, len(volumeServerAddrs))
|
||||
mu.Unlock()
|
||||
|
||||
err := operation.WithVolumeServerClient(false, addr, env.GrpcDialOption, func(volumeServerClient volume_server_pb.VolumeServerClient) error {
|
||||
res, err := volumeServerClient.ScrubEcVolume(context.Background(), &volume_server_pb.ScrubEcVolumeRequest{
|
||||
Mode: mode,
|
||||
VolumeIds: volumeIds,
|
||||
ForceDeletedNeedlesCheck: forceDeletedNeedlesCheck,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
totalVolumes += res.GetTotalVolumes()
|
||||
totalFiles += res.GetTotalFiles()
|
||||
brokenVolumes += uint64(len(res.GetBrokenVolumeIds()))
|
||||
brokenShards += uint64(len(res.GetBrokenShardInfos()))
|
||||
for _, d := range res.GetDetails() {
|
||||
details = append(details, fmt.Sprintf("[%s] %s", addr, d))
|
||||
}
|
||||
for _, vid := range res.GetBrokenVolumeIds() {
|
||||
brokenVolumesStr = append(brokenVolumesStr, fmt.Sprintf("%s:%v", addr, vid))
|
||||
}
|
||||
for _, si := range res.GetBrokenShardInfos() {
|
||||
brokenShardsStr = append(brokenShardsStr, fmt.Sprintf("%s:%v:%v", addr, si.VolumeId, si.ShardId))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := ewg.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(writer, "Scrubbed %d EC files and %d volumes on %d nodes\n", totalFiles, totalVolumes, len(volumeServerAddrs))
|
||||
if brokenVolumes != 0 {
|
||||
fmt.Fprintf(writer, "\nGot scrub failures on %d EC volumes and %d EC shards :(\n", brokenVolumes, brokenShards)
|
||||
fmt.Fprintf(writer, "Affected volumes: %s\n", strings.Join(brokenVolumesStr, ", "))
|
||||
if len(brokenShardsStr) != 0 {
|
||||
fmt.Fprintf(writer, "Affected shards: %s\n", strings.Join(brokenShardsStr, ", "))
|
||||
}
|
||||
if showDetails && len(details) != 0 {
|
||||
fmt.Fprintf(writer, "Details:\n\t%s\n", strings.Join(details, "\n\t"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
210
weed/ec/ec_shard_unmount.go
Normal file
210
weed/ec/ec_shard_unmount.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/erasure_coding"
|
||||
)
|
||||
|
||||
const (
|
||||
ecShardActionTimeout = 1 * time.Minute
|
||||
)
|
||||
|
||||
// ShardRef names one EC shard copy, optionally pinned to a node address.
|
||||
type ShardRef struct {
|
||||
ShardID uint32
|
||||
Collection string
|
||||
NodeAddress string
|
||||
}
|
||||
|
||||
func (s *ShardRef) String() string {
|
||||
if s.NodeAddress == "" {
|
||||
return fmt.Sprintf("%d", s.ShardID)
|
||||
}
|
||||
return fmt.Sprintf("%d@%s", s.ShardID, s.NodeAddress)
|
||||
}
|
||||
|
||||
// ShardRefsFromString parses a comma-separated list of shard IDs, each either a
|
||||
// bare numeric ID or <shard_id>@<node_address> to pick one copy.
|
||||
func ShardRefsFromString(shards string) ([]*ShardRef, error) {
|
||||
res := []*ShardRef{}
|
||||
|
||||
for _, s := range strings.Split(shards, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("empty shard ID in %q", shards)
|
||||
}
|
||||
|
||||
// optional <shard ID>@<node address> to pick one copy
|
||||
idStr, addr, _ := strings.Cut(s, "@")
|
||||
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil || id < 0 || id >= erasure_coding.MaxShardCount {
|
||||
return nil, fmt.Errorf("invalid shard ID %q", s)
|
||||
}
|
||||
|
||||
res = append(res, &ShardRef{ShardID: uint32(id), NodeAddress: addr})
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ShardUnmountRequest describes one ec.shard.unmount run.
|
||||
type ShardUnmountRequest struct {
|
||||
Topology *master_pb.TopologyInfo
|
||||
VolumeID uint32
|
||||
Shards []*ShardRef
|
||||
Delete bool
|
||||
IgnoreInvalid bool
|
||||
Apply bool
|
||||
}
|
||||
|
||||
type ecShardUnmounter struct {
|
||||
env *Env
|
||||
writer io.Writer
|
||||
req ShardUnmountRequest
|
||||
}
|
||||
|
||||
// UnmountShards unmounts, and optionally deletes, the requested EC shard
|
||||
// copies, resolving them against the live topology first. Dry-run unless
|
||||
// req.Apply is set.
|
||||
func UnmountShards(env *Env, writer io.Writer, req ShardUnmountRequest) error {
|
||||
c := &ecShardUnmounter{env: env, writer: writer, req: req}
|
||||
return c.doShardsUnmount()
|
||||
}
|
||||
|
||||
func (c *ecShardUnmounter) write(format string, a ...any) {
|
||||
fmt.Fprintf(c.writer, format, a...)
|
||||
}
|
||||
|
||||
func (c *ecShardUnmounter) liveShardsForVolume() []*ShardRef {
|
||||
shards := []*ShardRef{}
|
||||
|
||||
for _, dci := range c.req.Topology.GetDataCenterInfos() {
|
||||
for _, ri := range dci.GetRackInfos() {
|
||||
for _, dni := range ri.GetDataNodeInfos() {
|
||||
nodeAddress := dni.GetAddress()
|
||||
for _, di := range dni.GetDiskInfos() {
|
||||
for _, eci := range di.GetEcShardInfos() {
|
||||
if eci.GetId() == c.req.VolumeID {
|
||||
sinfo := erasure_coding.ShardsInfoFromVolumeEcShardInformationMessage(eci)
|
||||
for _, sid := range sinfo.Ids() {
|
||||
shards = append(shards, &ShardRef{
|
||||
ShardID: uint32(sid),
|
||||
Collection: eci.GetCollection(),
|
||||
NodeAddress: nodeAddress,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(shards, func(i, j int) bool { return shards[i].ShardID < shards[j].ShardID })
|
||||
return shards
|
||||
}
|
||||
|
||||
func (c *ecShardUnmounter) printShards(ss []*ShardRef) {
|
||||
for _, s := range ss {
|
||||
c.write("\t%v\n", s)
|
||||
}
|
||||
c.write("\n")
|
||||
}
|
||||
|
||||
func (c *ecShardUnmounter) doShardsUnmount() error {
|
||||
liveShards := c.liveShardsForVolume()
|
||||
c.write("Live shard topology for volume ID %d (%d shards):\n", c.req.VolumeID, len(liveShards))
|
||||
c.printShards(liveShards)
|
||||
|
||||
// resolve target shards against the live topology
|
||||
targetShards := []*ShardRef{}
|
||||
for _, ps := range c.req.Shards {
|
||||
var result *ShardRef
|
||||
for _, ts := range liveShards {
|
||||
if ts.ShardID == ps.ShardID {
|
||||
if ps.NodeAddress == "" || ps.NodeAddress == ts.NodeAddress {
|
||||
if result != nil {
|
||||
return fmt.Errorf("shard %v is ambiguous", ps)
|
||||
}
|
||||
result = ts
|
||||
}
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
if !c.req.IgnoreInvalid {
|
||||
return fmt.Errorf("shard %v is invalid", ps)
|
||||
}
|
||||
c.write("!!! ignoring invalid shard %v\n", ps)
|
||||
} else {
|
||||
targetShards = append(targetShards, result)
|
||||
}
|
||||
}
|
||||
if len(targetShards) == 0 {
|
||||
return fmt.Errorf("got no shards to process")
|
||||
}
|
||||
|
||||
mode := "unmount"
|
||||
if c.req.Delete {
|
||||
mode = "unmount + delete"
|
||||
}
|
||||
c.write("Will %s %d shard(s):\n", mode, len(targetShards))
|
||||
c.printShards(targetShards)
|
||||
|
||||
if !c.req.Apply {
|
||||
c.write("Not proceeding in dry-run mode\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !c.env.isLocked() {
|
||||
return fmt.Errorf("lock is lost")
|
||||
}
|
||||
|
||||
for _, s := range targetShards {
|
||||
if err := c.unmountShard(s); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
c.write("\nAll done!\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ecShardUnmounter) unmountShard(s *ShardRef) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ecShardActionTimeout)
|
||||
defer cancel()
|
||||
|
||||
return operation.WithVolumeServerClient(false, pb.ServerAddress(s.NodeAddress), c.env.GrpcDialOption, func(vsc volume_server_pb.VolumeServerClient) error {
|
||||
c.write("Unmounting shard %v for volume ID %d...\n", s, c.req.VolumeID)
|
||||
if _, err := vsc.VolumeEcShardsUnmount(ctx, &volume_server_pb.VolumeEcShardsUnmountRequest{
|
||||
VolumeId: c.req.VolumeID,
|
||||
ShardIds: []uint32{s.ShardID},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.req.Delete {
|
||||
c.write("Deleting shard %v for volume ID %d...\n", s, c.req.VolumeID)
|
||||
if _, err := vsc.VolumeEcShardsDelete(ctx, &volume_server_pb.VolumeEcShardsDeleteRequest{
|
||||
VolumeId: c.req.VolumeID,
|
||||
Collection: s.Collection,
|
||||
ShardIds: []uint32{s.ShardID},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
102
weed/ec/ec_shard_unmount_test.go
Normal file
102
weed/ec/ec_shard_unmount_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package ec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/storage/erasure_coding"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEcShardsFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []*ShardRef
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "single id",
|
||||
in: "2",
|
||||
want: []*ShardRef{{ShardID: 2}},
|
||||
},
|
||||
{
|
||||
name: "list of ids",
|
||||
in: "0, 3 ,5",
|
||||
want: []*ShardRef{{ShardID: 0}, {ShardID: 3}, {ShardID: 5}},
|
||||
},
|
||||
{
|
||||
name: "qualified keeps host:port",
|
||||
in: "3@10.200.18.88:9007",
|
||||
want: []*ShardRef{{ShardID: 3, NodeAddress: "10.200.18.88:9007"}},
|
||||
},
|
||||
{
|
||||
name: "mixed bare and qualified",
|
||||
in: "2,3@10.200.18.88:9007",
|
||||
want: []*ShardRef{{ShardID: 2}, {ShardID: 3, NodeAddress: "10.200.18.88:9007"}},
|
||||
},
|
||||
{
|
||||
name: "colon without node marker is not a shard ID",
|
||||
in: "3:10.200.18.88:9007",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// shard IDs beyond the default 10+4 total are valid on custom-ratio volumes.
|
||||
name: "shard id within MaxShardCount",
|
||||
in: "20",
|
||||
want: []*ShardRef{{ShardID: 20}},
|
||||
},
|
||||
{
|
||||
name: "shard id at MaxShardCount is rejected",
|
||||
in: "32",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "negative shard id",
|
||||
in: "-1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// must not wrap through uint32 into a valid-looking shard ID (4294967301 -> 5)
|
||||
name: "out-of-range shard id does not wrap",
|
||||
in: "4294967301",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-numeric shard id",
|
||||
in: "abc",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty element",
|
||||
in: "1,,2",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
// guard the assumption the bound test relies on
|
||||
assert.Equal(t, uint32(32), uint32(erasure_coding.MaxShardCount))
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ShardRefsFromString(tt.in)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// the printed topology form must parse back, so entries can be copied verbatim.
|
||||
func TestEcShardStringRoundTrips(t *testing.T) {
|
||||
for _, s := range []*ShardRef{
|
||||
{ShardID: 2},
|
||||
{ShardID: 3, NodeAddress: "10.200.18.88:9007"},
|
||||
} {
|
||||
got, err := ShardRefsFromString(s.String())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []*ShardRef{s}, got)
|
||||
}
|
||||
}
|
||||
39
weed/ec/env.go
Normal file
39
weed/ec/env.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package ec holds the EC (erasure coding) cluster orchestration logic shared
|
||||
// by the weed shell commands and the maintenance workers: topology analysis of
|
||||
// EC shards, the encode/balance pipelines, and the volume-server RPC wrappers
|
||||
// they drive. The placement policy itself lives in
|
||||
// weed/storage/erasure_coding/ecbalancer; low-level shard mechanics live in
|
||||
// weed/storage/erasure_coding.
|
||||
package ec
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/seaweedfs/seaweedfs/weed/pb/master_pb"
|
||||
"github.com/seaweedfs/seaweedfs/weed/wdclient"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
// Env carries the cluster access hooks EC operations need, decoupled from any
|
||||
// particular caller (shell CommandEnv, worker task, admin server).
|
||||
type Env struct {
|
||||
GrpcDialOption grpc.DialOption
|
||||
// FetchTopology returns a fresh master topology snapshot (and the master's
|
||||
// volume size limit in MB) after an optional delay.
|
||||
FetchTopology func(delay time.Duration) (*master_pb.TopologyInfo, uint64, error)
|
||||
// GetVolumeLocations returns the current replica locations for a volume id,
|
||||
// or false if the volume is unknown.
|
||||
GetVolumeLocations func(vid uint32) ([]wdclient.Location, bool)
|
||||
// IsLocked reports whether the caller still holds the cluster admin lock.
|
||||
// Callers without a lock concept return true.
|
||||
IsLocked func() bool
|
||||
}
|
||||
|
||||
// isLocked treats a nil Env or nil hook as locked, matching the shell's
|
||||
// nil-receiver behavior so dry-run paths work without a cluster connection.
|
||||
func (env *Env) isLocked() bool {
|
||||
if env == nil || env.IsLocked == nil {
|
||||
return true
|
||||
}
|
||||
return env.IsLocked()
|
||||
}
|
||||
Reference in New Issue
Block a user